blur images in stream chat

This commit is contained in:
hzrd149 2023-07-02 10:11:30 -05:00
parent 5a537ab9ab
commit e4b40dda68
24 changed files with 258 additions and 225 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Blur images in stream chat

View File

@ -2,7 +2,7 @@ import { Box, Image, ImageProps, Link, useDisclosure } from "@chakra-ui/react";
import appSettings from "../../services/app-settings"; import appSettings from "../../services/app-settings";
import { ImageGalleryLink } from "../image-gallery"; import { ImageGalleryLink } from "../image-gallery";
import { useIsMobile } from "../../hooks/use-is-mobile"; import { useIsMobile } from "../../hooks/use-is-mobile";
import { useTrusted } from "../note/trust"; import { useTrusted } from "../../providers/trust";
import OpenGraphCard from "../open-graph-card"; import OpenGraphCard from "../open-graph-card";
const BlurredImage = (props: ImageProps) => { const BlurredImage = (props: ImageProps) => {

View File

@ -11,10 +11,9 @@ import {
useDisclosure, useDisclosure,
useToast, useToast,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { ExternalLinkIcon, LightningIcon, QrCodeIcon } from "./icons"; import { ExternalLinkIcon, QrCodeIcon } from "./icons";
import QrCodeSvg from "./qr-code-svg"; import QrCodeSvg from "./qr-code-svg";
import { CopyIconButton } from "./copy-icon-button"; import { CopyIconButton } from "./copy-icon-button";
import { useIsMobile } from "../hooks/use-is-mobile";
export default function InvoiceModal({ export default function InvoiceModal({
invoice, invoice,
@ -22,17 +21,20 @@ export default function InvoiceModal({
onPaid, onPaid,
...props ...props
}: Omit<ModalProps, "children"> & { invoice: string; onPaid: () => void }) { }: Omit<ModalProps, "children"> & { invoice: string; onPaid: () => void }) {
const isMobile = useIsMobile();
const toast = useToast(); const toast = useToast();
const showQr = useDisclosure(); const showQr = useDisclosure();
const payWithWebLn = async (invoice: string) => { const payWithWebLn = async (invoice: string) => {
if (window.webln && invoice) { try {
if (!window.webln.enabled) await window.webln.enable(); if (window.webln && invoice) {
await window.webln.sendPayment(invoice); if (!window.webln.enabled) await window.webln.enable();
await window.webln.sendPayment(invoice);
if (onPaid) onPaid(); if (onPaid) onPaid();
onClose(); onClose();
}
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
} }
}; };
const payWithApp = async (invoice: string) => { const payWithApp = async (invoice: string) => {

View File

@ -37,9 +37,7 @@ export function RepostButton({ event }: { event: NostrEvent }) {
await nostrPostAction(clientRelaysService.getWriteUrls(), repost); await nostrPostAction(clientRelaysService.getWriteUrls(), repost);
onClose(); onClose();
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) toast({ description: e.message, status: "error" });
toast({ status: "error", description: e.message });
}
} }
setLoading(false); setLoading(false);
}; };

View File

@ -9,7 +9,7 @@ import { UserDnsIdentityIcon } from "../user-dns-identity-icon";
import useSubject from "../../hooks/use-subject"; import useSubject from "../../hooks/use-subject";
import appSettings from "../../services/app-settings"; import appSettings from "../../services/app-settings";
import EventVerificationIcon from "../event-verification-icon"; import EventVerificationIcon from "../event-verification-icon";
import { TrustProvider } from "./trust"; import { TrustProvider } from "../../providers/trust";
import { NoteLink } from "../note-link"; import { NoteLink } from "../note-link";
export default function EmbeddedNote({ note }: { note: NostrEvent }) { export default function EmbeddedNote({ note }: { note: NostrEvent }) {

View File

@ -32,7 +32,7 @@ import { RepostButton } from "./buttons/repost-button";
import { QuoteRepostButton } from "./buttons/quote-repost-button"; import { QuoteRepostButton } from "./buttons/quote-repost-button";
import { ExternalLinkIcon } from "../icons"; import { ExternalLinkIcon } from "../icons";
import NoteContentWithWarning from "./note-content-with-warning"; import NoteContentWithWarning from "./note-content-with-warning";
import { TrustProvider } from "./trust"; import { TrustProvider } from "../../providers/trust";
import { NoteLink } from "../note-link"; import { NoteLink } from "../note-link";
import { useRegisterIntersectionEntity } from "../../providers/intersection-observer"; import { useRegisterIntersectionEntity } from "../../providers/intersection-observer";

View File

@ -21,11 +21,10 @@ import {
renderOpenGraphUrl, renderOpenGraphUrl,
} from "../embed-types"; } from "../embed-types";
import { ImageGalleryProvider } from "../image-gallery"; import { ImageGalleryProvider } from "../image-gallery";
import { useTrusted } from "./trust";
import { renderRedditUrl } from "../embed-types/reddit"; import { renderRedditUrl } from "../embed-types/reddit";
import EmbeddedContent from "../embeded-content"; import EmbeddedContent from "../embeded-content";
function buildContents(event: NostrEvent | DraftNostrEvent, trusted = false) { function buildContents(event: NostrEvent | DraftNostrEvent) {
let content: EmbedableContent = [event.content.trim()]; let content: EmbedableContent = [event.content.trim()];
// common // common
@ -70,8 +69,7 @@ export type NoteContentsProps = {
}; };
export const NoteContents = React.memo(({ event, maxHeight }: NoteContentsProps) => { export const NoteContents = React.memo(({ event, maxHeight }: NoteContentsProps) => {
const trusted = useTrusted(); const content = buildContents(event);
const content = buildContents(event, trusted);
const expand = useExpand(); const expand = useExpand();
const [innerHeight, setInnerHeight] = useState(0); const [innerHeight, setInnerHeight] = useState(0);
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);

View File

@ -53,12 +53,7 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
await results.onComplete; await results.onComplete;
deleteModal.onClose(); deleteModal.onClose();
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) toast({ description: e.message, status: "error" });
toast({
status: "error",
description: e.message,
});
}
} finally { } finally {
setDeleting(false); setDeleting(false);
} }

View File

@ -27,7 +27,7 @@ import { ImageIcon } from "../icons";
import { NoteLink } from "../note-link"; import { NoteLink } from "../note-link";
import { NoteContents } from "../note/note-contents"; import { NoteContents } from "../note/note-contents";
import { PostResults } from "./post-results"; import { PostResults } from "./post-results";
import { TrustProvider } from "../note/trust"; import { TrustProvider } from "../../providers/trust";
function emptyDraft(): DraftNostrEvent { function emptyDraft(): DraftNostrEvent {
return { return {
@ -96,12 +96,7 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) =>
setDraft((d) => ({ ...d, content: (d.content += imageUrl) })); setDraft((d) => ({ ...d, content: (d.content += imageUrl) }));
} }
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) toast({ description: e.message, status: "error" });
toast({
status: "error",
description: e.message,
});
}
} }
setUploading(false); setUploading(false);
}; };

View File

@ -9,7 +9,7 @@ import { NoteMenu } from "./note/note-menu";
import { UserAvatar } from "./user-avatar"; import { UserAvatar } from "./user-avatar";
import { UserDnsIdentityIcon } from "./user-dns-identity-icon"; import { UserDnsIdentityIcon } from "./user-dns-identity-icon";
import { UserLink } from "./user-link"; import { UserLink } from "./user-link";
import { TrustProvider } from "./note/trust"; import { TrustProvider } from "../providers/trust";
import { safeJson } from "../helpers/parse"; import { safeJson } from "../helpers/parse";
import { verifySignature } from "nostr-tools"; import { verifySignature } from "nostr-tools";
import { useReadRelayUrls } from "../hooks/use-client-relays"; import { useReadRelayUrls } from "../hooks/use-client-relays";

View File

@ -59,13 +59,13 @@ export default function ZapModal({
initialAmount, initialAmount,
...props ...props
}: ZapModalProps) { }: ZapModalProps) {
const isMobile = useIsMobile();
const metadata = useUserMetadata(pubkey); const metadata = useUserMetadata(pubkey);
const { requestSignature } = useSigningContext(); const { requestSignature } = useSigningContext();
const toast = useToast(); const toast = useToast();
const [promptInvoice, setPromptInvoice] = useState<string>(); const [promptInvoice, setPromptInvoice] = useState<string>();
const { isOpen: showQr, onToggle: toggleQr } = useDisclosure(); const { isOpen: showQr, onToggle: toggleQr } = useDisclosure();
const isMobile = useIsMobile(); const { customZapAmounts } = useSubject(appSettings);
const { zapAmounts } = useSubject(appSettings);
const { const {
register, register,
@ -76,7 +76,7 @@ export default function ZapModal({
} = useForm<FormValues>({ } = useForm<FormValues>({
mode: "onBlur", mode: "onBlur",
defaultValues: { defaultValues: {
amount: initialAmount ?? zapAmounts[0], amount: initialAmount ?? (parseInt(customZapAmounts.split(",")[0]) || 100),
comment: initialComment ?? "", comment: initialComment ?? "",
}, },
}); });
@ -160,19 +160,11 @@ export default function ZapModal({
}, 1000 * 2); }, 1000 * 2);
}; };
const payInvoice = (invoice: string) => { const payInvoice = async (invoice: string) => {
switch (appSettings.value.lightningPayMode) { if (appSettings.value.autoPayWithWebLN) {
case "webln": await payWithWebLn(invoice);
payWithWebLn(invoice);
break;
case "external":
payWithApp(invoice);
break;
default:
case "prompt":
setPromptInvoice(invoice);
break;
} }
setPromptInvoice(invoice);
}; };
const handleClose = () => { const handleClose = () => {
@ -228,11 +220,14 @@ export default function ZapModal({
<UserLink pubkey={pubkey} /> <UserLink pubkey={pubkey} />
</Flex> </Flex>
<Flex gap="2" alignItems="center" flexWrap="wrap"> <Flex gap="2" alignItems="center" flexWrap="wrap">
{zapAmounts.map((amount, i) => ( {customZapAmounts
<Button key={amount + i} onClick={() => setValue("amount", amount)} size="sm" variant="outline"> .split(",")
{amount} .map((v) => parseInt(v))
</Button> .map((amount, i) => (
))} <Button key={amount + i} onClick={() => setValue("amount", amount)} size="sm" variant="outline">
{amount}
</Button>
))}
</Flex> </Flex>
<Flex gap="2"> <Flex gap="2">
<InputGroup maxWidth={32}> <InputGroup maxWidth={32}>

View File

@ -13,12 +13,7 @@ export default function useAppSettings() {
try { try {
return replaceSettings({ ...settings, ...newSettings }); return replaceSettings({ ...settings, ...newSettings });
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) toast({ description: e.message, status: "error" });
toast({
status: "error",
description: e.message,
});
}
} }
}, },
[settings] [settings]

View File

@ -1,6 +1,7 @@
import React, { useCallback, useContext, useState } from "react"; import React, { useCallback, useContext, useState } from "react";
import InvoiceModal from "../components/invoice-modal"; import InvoiceModal from "../components/invoice-modal";
import createDefer, { Deferred } from "../classes/deferred"; import createDefer, { Deferred } from "../classes/deferred";
import appSettings from "../services/app-settings";
export type InvoiceModalContext = { export type InvoiceModalContext = {
requestPay: (invoice: string) => Promise<void>; requestPay: (invoice: string) => Promise<void>;
@ -20,7 +21,17 @@ export const InvoiceModalProvider = ({ children }: { children: React.ReactNode }
const [invoice, setInvoice] = useState<string>(); const [invoice, setInvoice] = useState<string>();
const [defer, setDefer] = useState<Deferred<void>>(); const [defer, setDefer] = useState<Deferred<void>>();
const requestPay = useCallback((invoice: string) => { const requestPay = useCallback(async (invoice: string) => {
if (window.webln && appSettings.value.autoPayWithWebLN) {
try {
if (!window.webln.enabled) await window.webln.enable();
await window.webln.sendPayment(invoice);
handlePaid();
return;
} catch (e) {}
}
const defer = createDefer<void>(); const defer = createDefer<void>();
setDefer(defer); setDefer(defer);
setInvoice(invoice); setInvoice(invoice);

View File

@ -37,12 +37,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
if (!current) throw new Error("No account"); if (!current) throw new Error("No account");
return await signingService.requestSignature(draft, current); return await signingService.requestSignature(draft, current);
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) toast({ description: e.message, status: "error" });
toast({
status: "error",
description: e.message,
});
}
} }
}, },
[toast, current] [toast, current]
@ -53,12 +48,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
if (!current) throw new Error("No account"); if (!current) throw new Error("No account");
return await signingService.requestDecrypt(data, pubkey, current); return await signingService.requestDecrypt(data, pubkey, current);
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) toast({ description: e.message, status: "error" });
toast({
status: "error",
description: e.message,
});
}
} }
}, },
[toast, current] [toast, current]
@ -69,12 +59,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) =>
if (!current) throw new Error("No account"); if (!current) throw new Error("No account");
return await signingService.requestEncrypt(data, pubkey, current); return await signingService.requestEncrypt(data, pubkey, current);
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) toast({ description: e.message, status: "error" });
toast({
status: "error",
description: e.message,
});
}
} }
}, },
[toast, current] [toast, current]

View File

@ -1,8 +1,8 @@
import React, { PropsWithChildren, useContext } from "react"; import React, { PropsWithChildren, useContext } from "react";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../types/nostr-event";
import { useCurrentAccount } from "../../hooks/use-current-account"; import { useCurrentAccount } from "../hooks/use-current-account";
import clientFollowingService from "../../services/client-following"; import clientFollowingService from "../services/client-following";
import useSubject from "../../hooks/use-subject"; import useSubject from "../hooks/use-subject";
const TrustContext = React.createContext<boolean>(false); const TrustContext = React.createContext<boolean>(false);

View File

@ -9,12 +9,6 @@ import db from "./db";
const DTAG = "nostrudel-settings"; const DTAG = "nostrudel-settings";
export enum LightningPayMode {
Prompt = "prompt",
Webln = "webln",
External = "external",
}
export type AppSettings = { export type AppSettings = {
colorMode: ColorMode; colorMode: ColorMode;
blurImages: boolean; blurImages: boolean;
@ -22,8 +16,10 @@ export type AppSettings = {
proxyUserMedia: boolean; proxyUserMedia: boolean;
showReactions: boolean; showReactions: boolean;
showSignatureVerification: boolean; showSignatureVerification: boolean;
lightningPayMode: LightningPayMode;
zapAmounts: number[]; autoPayWithWebLN: boolean;
customZapAmounts: string;
primaryColor: string; primaryColor: string;
imageProxy: string; imageProxy: string;
corsProxy: string; corsProxy: string;
@ -40,8 +36,10 @@ export const defaultSettings: AppSettings = {
proxyUserMedia: false, proxyUserMedia: false,
showReactions: true, showReactions: true,
showSignatureVerification: false, showSignatureVerification: false,
lightningPayMode: LightningPayMode.Prompt,
zapAmounts: [50, 200, 500, 1000], autoPayWithWebLN: true,
customZapAmounts: "50,200,500,1000,2000,5000",
primaryColor: "#8DB600", primaryColor: "#8DB600",
imageProxy: "", imageProxy: "",
corsProxy: "", corsProxy: "",

View File

@ -4,7 +4,7 @@ import { ArrowDownSIcon, ArrowUpSIcon } from "../../components/icons";
import { Note } from "../../components/note"; import { Note } from "../../components/note";
import { countReplies, ThreadItem as ThreadItemData } from "../../helpers/thread"; import { countReplies, ThreadItem as ThreadItemData } from "../../helpers/thread";
import { useIsMobile } from "../../hooks/use-is-mobile"; import { useIsMobile } from "../../hooks/use-is-mobile";
import { TrustProvider } from "../../components/note/trust"; import { TrustProvider } from "../../providers/trust";
export type ThreadItemProps = { export type ThreadItemProps = {
post: ThreadItemData; post: ThreadItemData;

View File

@ -243,12 +243,7 @@ export const ProfileEditView = () => {
await results.onComplete; await results.onComplete;
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) toast({ description: e.message, status: "error" });
toast({
status: "error",
description: e.message,
});
}
} }
}; };

View File

@ -61,9 +61,7 @@ function RelaysPage() {
} }
setRelayInputValue(""); setRelayInputValue("");
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) toast({ description: e.message, status: "error" });
toast({ status: "error", description: e.message });
}
} }
}; };
const savePending = async () => { const savePending = async () => {

View File

@ -1,4 +1,4 @@
import { Button, Flex, Accordion, Link } from "@chakra-ui/react"; import { Button, Flex, Accordion, Link, useToast } from "@chakra-ui/react";
import { Link as RouterLink } from "react-router-dom"; import { Link as RouterLink } from "react-router-dom";
import { GithubIcon, ToolsIcon } from "../../components/icons"; import { GithubIcon, ToolsIcon } from "../../components/icons";
import LightningSettings from "./lightning-settings"; import LightningSettings from "./lightning-settings";
@ -10,6 +10,7 @@ import useAppSettings from "../../hooks/use-app-settings";
import { FormProvider, useForm } from "react-hook-form"; import { FormProvider, useForm } from "react-hook-form";
export default function SettingsView() { export default function SettingsView() {
const toast = useToast();
const { updateSettings, ...settings } = useAppSettings(); const { updateSettings, ...settings } = useAppSettings();
const form = useForm({ const form = useForm({
@ -18,7 +19,12 @@ export default function SettingsView() {
}); });
const saveSettings = form.handleSubmit(async (values) => { const saveSettings = form.handleSubmit(async (values) => {
await updateSettings(values); try {
await updateSettings(values);
toast({ title: "Settings saved", status: "success" });
} catch (e) {
if (e instanceof Error) toast({ description: e.message, status: "error" });
}
}); });
return ( return (

View File

@ -10,13 +10,15 @@ import {
FormHelperText, FormHelperText,
Input, Input,
Select, Select,
Switch,
FormErrorMessage,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { LightningIcon } from "../../components/icons"; import { LightningIcon } from "../../components/icons";
import { AppSettings } from "../../services/user-app-settings"; import { AppSettings } from "../../services/user-app-settings";
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
export default function LightningSettings() { export default function LightningSettings() {
const { register } = useFormContext<AppSettings>(); const { register, formState } = useFormContext<AppSettings>();
return ( return (
<AccordionItem> <AccordionItem>
@ -31,44 +33,35 @@ export default function LightningSettings() {
<AccordionPanel> <AccordionPanel>
<Flex direction="column" gap="4"> <Flex direction="column" gap="4">
<FormControl> <FormControl>
<FormLabel htmlFor="lightningPayMode" mb="0"> <Flex alignItems="center">
Payment mode <FormLabel htmlFor="autoPayWithWebLN" mb="0">
</FormLabel> Auto pay with WebLN
<Select id="lightningPayMode" {...register("lightningPayMode")}> </FormLabel>
<option value="prompt">Prompt</option> <Switch id="autoPayWithWebLN" {...register("autoPayWithWebLN")} />
<option value="webln">WebLN</option> </Flex>
<option value="external">External</option>
</Select>
<FormHelperText> <FormHelperText>
<span>Prompt: Ask every time</span> <span>Enabled: Attempt to automatically pay with WebLN if its available</span>
<br />
<span>WebLN: Use browser extension</span>
<br />
<span>External: Open an external app using "lightning:" link</span>
</FormHelperText> </FormHelperText>
</FormControl> </FormControl>
<FormControl> <FormControl>
<FormLabel htmlFor="zap-amounts" mb="0"> <FormLabel htmlFor="customZapAmounts" mb="0">
Zap Amounts Zap Amounts
</FormLabel> </FormLabel>
<Input <Input
id="zap-amounts" id="customZapAmounts"
autoComplete="off" autoComplete="off"
{...register("zapAmounts", { {...register("customZapAmounts", {
setValueAs: (value: number[] | string) => { validate: (v) => {
if (Array.isArray(value)) { if (!/^[\d,]*$/.test(v)) return "Must be a list of comma separated numbers";
return Array.from(value).join(","); return true;
} else {
return value
.split(",")
.map((v) => parseInt(v))
.filter(Boolean)
.sort((a, b) => a - b);
}
}, },
})} })}
/> />
{formState.errors.customZapAmounts && (
<FormErrorMessage>{formState.errors.customZapAmounts.message}</FormErrorMessage>
)}
<FormHelperText> <FormHelperText>
<span>Comma separated list of custom zap amounts</span> <span>Comma separated list of custom zap amounts</span>
</FormHelperText> </FormHelperText>

View File

@ -36,7 +36,7 @@ import RawValue from "../../../components/debug-modals/raw-value";
import RawJson from "../../../components/debug-modals/raw-json"; import RawJson from "../../../components/debug-modals/raw-json";
export default function StreamCard({ stream, ...props }: CardProps & { stream: ParsedStream }) { export default function StreamCard({ stream, ...props }: CardProps & { stream: ParsedStream }) {
const { title, summary, identifier, image } = stream; const { title, identifier, image } = stream;
const devModal = useDisclosure(); const devModal = useDisclosure();
const naddr = useMemo(() => { const naddr = useMemo(() => {

View File

@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { import {
Box, Box,
@ -11,8 +11,15 @@ import {
Heading, Heading,
IconButton, IconButton,
Input, Input,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Spacer, Spacer,
Text, Text,
useDisclosure,
useToast, useToast,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { ParsedStream, buildChatMessage, getATag } from "../../../helpers/nostr/stream"; import { ParsedStream, buildChatMessage, getATag } from "../../../helpers/nostr/stream";
@ -26,8 +33,15 @@ import { UserLink } from "../../../components/user-link";
import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event"; import { DraftNostrEvent, NostrEvent } from "../../../types/nostr-event";
import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../../providers/intersection-observer";
import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback"; import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback";
import { embedUrls } from "../../../helpers/embeds"; import { EmbedableContent, embedUrls } from "../../../helpers/embeds";
import { embedEmoji, renderGenericUrl, renderImageUrl } from "../../../components/embed-types"; import {
embedEmoji,
embedNostrHashtags,
embedNostrLinks,
embedNostrMentions,
renderGenericUrl,
renderImageUrl,
} from "../../../components/embed-types";
import EmbeddedContent from "../../../components/embeded-content"; import EmbeddedContent from "../../../components/embeded-content";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useSigningContext } from "../../../providers/signing-provider"; import { useSigningContext } from "../../../providers/signing-provider";
@ -41,33 +55,50 @@ import { readablizeSats } from "../../../helpers/bolt11";
import { Kind } from "nostr-tools"; import { Kind } from "nostr-tools";
import useUserLNURLMetadata from "../../../hooks/use-user-lnurl-metadata"; import useUserLNURLMetadata from "../../../hooks/use-user-lnurl-metadata";
import { useInvoiceModalContext } from "../../../providers/invoice-modal"; import { useInvoiceModalContext } from "../../../providers/invoice-modal";
import { ImageGalleryProvider } from "../../../components/image-gallery";
import appSettings from "../../../services/app-settings";
import { TrustProvider } from "../../../providers/trust";
function ChatMessageContent({ event }: { event: NostrEvent }) {
const content = useMemo(() => {
let c: EmbedableContent = [event.content];
c = embedUrls(c, [renderImageUrl, renderGenericUrl]);
// nostr
c = embedNostrLinks(c);
c = embedNostrMentions(c, event);
c = embedNostrHashtags(c, event);
c = embedEmoji(c, event);
return c;
}, [event.content]);
return <EmbeddedContent content={content} />;
}
function ChatMessage({ event, stream }: { event: NostrEvent; stream: ParsedStream }) { function ChatMessage({ event, stream }: { event: NostrEvent; stream: ParsedStream }) {
const ref = useRef<HTMLDivElement | null>(null); const ref = useRef<HTMLDivElement | null>(null);
useRegisterIntersectionEntity(ref, event.id); useRegisterIntersectionEntity(ref, event.id);
const content = useMemo(() => {
let c = embedUrls([event.content], [renderImageUrl, renderGenericUrl]);
c = embedEmoji(c, event);
return c;
}, [event.content]);
return ( return (
<Flex direction="column" ref={ref}> <TrustProvider event={event}>
<Flex gap="2" alignItems="center"> <Flex direction="column" ref={ref}>
<UserAvatar pubkey={event.pubkey} size="xs" /> <Flex gap="2" alignItems="center">
<UserLink <UserAvatar pubkey={event.pubkey} size="xs" />
pubkey={event.pubkey} <UserLink
fontWeight="bold" pubkey={event.pubkey}
color={event.pubkey === stream.author ? "rgb(248, 56, 217)" : "cyan"} fontWeight="bold"
/> color={event.pubkey === stream.author ? "rgb(248, 56, 217)" : "cyan"}
<Spacer /> />
<Text>{dayjs.unix(event.created_at).fromNow()}</Text> <Spacer />
<Text>{dayjs.unix(event.created_at).fromNow()}</Text>
</Flex>
<Box>
<ChatMessageContent event={event} />
</Box>
</Flex> </Flex>
<Box> </TrustProvider>
<EmbeddedContent content={content} />
</Box>
</Flex>
); );
} }
@ -76,28 +107,24 @@ function ZapMessage({ zap, stream }: { zap: NostrEvent; stream: ParsedStream })
useRegisterIntersectionEntity(ref, zap.id); useRegisterIntersectionEntity(ref, zap.id);
const { request, payment } = parseZapEvent(zap); const { request, payment } = parseZapEvent(zap);
const content = useMemo(() => {
let c = embedUrls([request.content], [renderImageUrl, renderGenericUrl]);
c = embedEmoji(c, request);
return c;
}, [request.content]);
if (!payment.amount) return null; if (!payment.amount) return null;
return ( return (
<Flex direction="column" borderRadius="md" borderColor="yellow.400" borderWidth="1px" p="2" ref={ref}> <TrustProvider event={request}>
<Flex gap="2"> <Flex direction="column" borderRadius="md" borderColor="yellow.400" borderWidth="1px" p="2" ref={ref}>
<LightningIcon color="yellow.400" /> <Flex gap="2">
<UserAvatar pubkey={request.pubkey} size="xs" /> <LightningIcon color="yellow.400" />
<UserLink pubkey={request.pubkey} fontWeight="bold" color="yellow.400" /> <UserAvatar pubkey={request.pubkey} size="xs" />
<Text>zapped {readablizeSats(payment.amount / 1000)} sats</Text> <UserLink pubkey={request.pubkey} fontWeight="bold" color="yellow.400" />
<Spacer /> <Text>zapped {readablizeSats(payment.amount / 1000)} sats</Text>
<Text>{dayjs.unix(request.created_at).fromNow()}</Text> <Spacer />
<Text>{dayjs.unix(request.created_at).fromNow()}</Text>
</Flex>
<Box>
<ChatMessageContent event={request} />
</Box>
</Flex> </Flex>
<Box> </TrustProvider>
<EmbeddedContent content={content} />
</Box>
</Flex>
); );
} }
@ -106,6 +133,7 @@ export default function StreamChat({
actions, actions,
...props ...props
}: CardProps & { stream: ParsedStream; actions?: React.ReactNode }) { }: CardProps & { stream: ParsedStream; actions?: React.ReactNode }) {
const { customZapAmounts } = useSubject(appSettings);
const toast = useToast(); const toast = useToast();
const contextRelays = useAdditionalRelayContext(); const contextRelays = useAdditionalRelayContext();
const readRelays = useReadRelayUrls(contextRelays); const readRelays = useReadRelayUrls(contextRelays);
@ -135,18 +163,18 @@ export default function StreamChat({
nostrPostAction(unique([...contextRelays, ...writeRelays]), signed); nostrPostAction(unique([...contextRelays, ...writeRelays]), signed);
reset(); reset();
} catch (e) { } catch (e) {
if (e instanceof Error) toast({ description: e.message }); if (e instanceof Error) toast({ description: e.message, status: "error" });
} }
}); });
const zapAmountModal = useDisclosure();
const { requestPay } = useInvoiceModalContext(); const { requestPay } = useInvoiceModalContext();
const zapMetadata = useUserLNURLMetadata(stream.author); const zapMetadata = useUserLNURLMetadata(stream.author);
const zapMessage = async () => { const zapMessage = async (amount: number) => {
try { try {
if (!zapMetadata.metadata?.callback) throw new Error("bad lnurl endpoint"); if (!zapMetadata.metadata?.callback) throw new Error("bad lnurl endpoint");
const content = getValues().content; const content = getValues().content;
const amount = 100;
const zapRequest: DraftNostrEvent = { const zapRequest: DraftNostrEvent = {
kind: Kind.ZapRequest, kind: Kind.ZapRequest,
created_at: dayjs().unix(), created_at: dayjs().unix(),
@ -167,53 +195,93 @@ export default function StreamChat({
reset(); reset();
} catch (e) { } catch (e) {
if (e instanceof Error) toast({ description: e.message }); if (e instanceof Error) toast({ description: e.message, status: "error" });
} }
}; };
return ( return (
<IntersectionObserverProvider callback={callback} root={scrollBox}> <>
<Card {...props} overflow="hidden"> <IntersectionObserverProvider callback={callback} root={scrollBox}>
<CardHeader py="3" display="flex" justifyContent="space-between" alignItems="center"> <ImageGalleryProvider>
<Heading size="md">Stream Chat</Heading> <Card {...props} overflow="hidden">
{actions} <CardHeader py="3" display="flex" justifyContent="space-between" alignItems="center">
</CardHeader> <Heading size="md">Stream Chat</Heading>
<CardBody display="flex" flexDirection="column" gap="2" overflow="hidden" p={0}> {actions}
<Flex </CardHeader>
overflowY="scroll" <CardBody display="flex" flexDirection="column" gap="2" overflow="hidden" p={0}>
overflowX="hidden" <Flex
ref={scrollBox} overflowY="scroll"
direction="column-reverse" overflowX="hidden"
flex={1} ref={scrollBox}
px="4" direction="column-reverse"
py="2" flex={1}
gap="2" px="4"
> py="2"
{events.map((event) => gap="2"
event.kind === 1311 ? ( >
<ChatMessage key={event.id} event={event} stream={stream} /> {events.map((event) =>
) : ( event.kind === 1311 ? (
<ZapMessage key={event.id} zap={event} stream={stream} /> <ChatMessage key={event.id} event={event} stream={stream} />
) ) : (
)} <ZapMessage key={event.id} zap={event} stream={stream} />
</Flex> )
<Box as="form" borderRadius="md" flexShrink={0} display="flex" gap="2" px="2" pb="2" onSubmit={sendMessage}> )}
<Input placeholder="Message" {...register("content", { required: true })} autoComplete="off" /> </Flex>
<Button colorScheme="brand" type="submit" isLoading={formState.isSubmitting}> <Box
Send as="form"
</Button> borderRadius="md"
{zapMetadata.metadata?.allowsNostr && ( flexShrink={0}
<IconButton display="flex"
icon={<LightningIcon color="yellow.400" />} gap="2"
aria-label="Zap stream" px="2"
borderColor="yellow.400" pb="2"
variant="outline" onSubmit={sendMessage}
onClick={zapMessage} >
/> <Input placeholder="Message" {...register("content", { required: true })} autoComplete="off" />
)} <Button colorScheme="brand" type="submit" isLoading={formState.isSubmitting}>
</Box> Send
</CardBody> </Button>
</Card> {zapMetadata.metadata?.allowsNostr && (
</IntersectionObserverProvider> <IconButton
icon={<LightningIcon color="yellow.400" />}
aria-label="Zap stream"
borderColor="yellow.400"
variant="outline"
onClick={zapAmountModal.onOpen}
/>
)}
</Box>
</CardBody>
</Card>
</ImageGalleryProvider>
</IntersectionObserverProvider>
<Modal isOpen={zapAmountModal.isOpen} onClose={zapAmountModal.onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader pb="0">Zap Amount</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Flex gap="2" alignItems="center" flexWrap="wrap">
{customZapAmounts
.split(",")
.map((v) => parseInt(v))
.map((amount, i) => (
<Button
key={amount + i}
onClick={() => {
zapAmountModal.onClose();
zapMessage(amount);
}}
size="sm"
variant="outline"
>
{amount}
</Button>
))}
</Flex>
</ModalBody>
</ModalContent>
</Modal>
</>
); );
} }

View File

@ -45,9 +45,7 @@ function EncodeForm() {
setOutput(nprofile); setOutput(nprofile);
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) toast({ description: e.message, status: "error" });
toast({ description: e.message });
}
} }
}); });
@ -92,9 +90,7 @@ function DecodeForm() {
try { try {
setOutput(nip19.decode(values.input)); setOutput(nip19.decode(values.input));
} catch (e) { } catch (e) {
if (e instanceof Error) { if (e instanceof Error) toast({ description: e.message, status: "error" });
toast({ description: e.message });
}
} }
}); });