diff --git a/.changeset/brave-dolls-glow.md b/.changeset/brave-dolls-glow.md new file mode 100644 index 000000000..355900eb7 --- /dev/null +++ b/.changeset/brave-dolls-glow.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Blur images in stream chat diff --git a/.changeset/cold-moons-listen.md b/.changeset/cold-moons-listen.md new file mode 100644 index 000000000..f52b46f7d --- /dev/null +++ b/.changeset/cold-moons-listen.md @@ -0,0 +1,5 @@ +--- +"nostrudel": patch +--- + +Fixed bug with stream loading wrong chat diff --git a/.changeset/neat-gorillas-push.md b/.changeset/neat-gorillas-push.md new file mode 100644 index 000000000..2b7a86a2b --- /dev/null +++ b/.changeset/neat-gorillas-push.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +UX improvements to zap modal diff --git a/.changeset/silver-keys-fetch.md b/.changeset/silver-keys-fetch.md new file mode 100644 index 000000000..f7b05e8e1 --- /dev/null +++ b/.changeset/silver-keys-fetch.md @@ -0,0 +1,5 @@ +--- +"nostrudel": patch +--- + +Fix blured images opening when clicked diff --git a/.changeset/soft-mugs-march.md b/.changeset/soft-mugs-march.md new file mode 100644 index 000000000..fc21ec8d3 --- /dev/null +++ b/.changeset/soft-mugs-march.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add toggle chat button to mobile stream view diff --git a/.changeset/tough-buttons-speak.md b/.changeset/tough-buttons-speak.md new file mode 100644 index 000000000..fd943297f --- /dev/null +++ b/.changeset/tough-buttons-speak.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add user likes tab under profile view diff --git a/src/app.tsx b/src/app.tsx index 85ab16fdd..675eda6cd 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -34,9 +34,10 @@ import UserMediaTab from "./views/user/media"; import ToolsHomeView from "./views/tools"; import Nip19ToolsView from "./views/tools/nip19"; import UserAboutTab from "./views/user/about"; +import UserLikesTab from "./views/user/likes"; -const LiveStreamsTab = React.lazy(() => import("./views/home/streams")); -const StreamView = React.lazy(() => import("./views/home/streams/stream")); +const LiveStreamsTab = React.lazy(() => import("./views/streams")); +const StreamView = React.lazy(() => import("./views/streams/stream")); const SearchView = React.lazy(() => import("./views/search")); const RootPage = () => ( @@ -73,6 +74,7 @@ const router = createHashRouter([ { path: "notes", element: }, { path: "media", element: }, { path: "zaps", element: }, + { path: "likes", element: }, { path: "followers", element: }, { path: "following", element: }, { path: "relays", element: }, @@ -97,6 +99,10 @@ const router = createHashRouter([ { path: "nip19", element: }, ], }, + { + path: "streams", + element: , + }, { path: "l/:link", element: }, { path: "t/:hashtag", element: }, { @@ -105,10 +111,6 @@ const router = createHashRouter([ children: [ { path: "", element: }, { path: "following", element: }, - { - path: "streams", - element: , - }, { path: "global", element: }, ], }, diff --git a/src/components/embed-types/common.tsx b/src/components/embed-types/common.tsx index d62fbec9e..8908cfd4e 100644 --- a/src/components/embed-types/common.tsx +++ b/src/components/embed-types/common.tsx @@ -2,14 +2,27 @@ import { Box, Image, ImageProps, Link, useDisclosure } from "@chakra-ui/react"; import appSettings from "../../services/app-settings"; import { ImageGalleryLink } from "../image-gallery"; import { useIsMobile } from "../../hooks/use-is-mobile"; -import { useTrusted } from "../note/trust"; +import { useTrusted } from "../../providers/trust"; import OpenGraphCard from "../open-graph-card"; const BlurredImage = (props: ImageProps) => { const { isOpen, onOpen } = useDisclosure(); return ( - + { + e.stopPropagation(); + e.preventDefault(); + onOpen(); + } + : undefined + } + cursor="pointer" + filter={isOpen ? "" : "blur(1.5rem)"} + {...props} + /> ); }; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 41378382f..06431a1e3 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -101,7 +101,7 @@ export const LinkItem = createIcon({ export const LightningIcon = createIcon({ displayName: "LightningIcon", d: "M13 10h7l-9 13v-9H4l9-13z", - defaultProps, + defaultProps: { ...defaultProps, color: "yellow.400" }, }); export const RelayIcon = createIcon({ @@ -259,3 +259,9 @@ export const AtIcon = createIcon({ d: "M20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C13.6418 20 15.1681 19.5054 16.4381 18.6571L17.5476 20.3214C15.9602 21.3818 14.0523 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12V13.5C22 15.433 20.433 17 18.5 17C17.2958 17 16.2336 16.3918 15.6038 15.4659C14.6942 16.4115 13.4158 17 12 17C9.23858 17 7 14.7614 7 12C7 9.23858 9.23858 7 12 7C13.1258 7 14.1647 7.37209 15.0005 8H17V13.5C17 14.3284 17.6716 15 18.5 15C19.3284 15 20 14.3284 20 13.5V12ZM12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9Z", defaultProps, }); + +export const LiveStreamIcon = createIcon({ + displayName: "LiveStreamIcon", + d: "M16 4C16.5523 4 17 4.44772 17 5V9.2L22.2133 5.55071C22.4395 5.39235 22.7513 5.44737 22.9096 5.6736C22.9684 5.75764 23 5.85774 23 5.96033V18.0397C23 18.3158 22.7761 18.5397 22.5 18.5397C22.3974 18.5397 22.2973 18.5081 22.2133 18.4493L17 14.8V19C17 19.5523 16.5523 20 16 20H2C1.44772 20 1 19.5523 1 19V5C1 4.44772 1.44772 4 2 4H16ZM15 6H3V18H15V6ZM7.4 8.82867C7.47607 8.82867 7.55057 8.85036 7.61475 8.8912L11.9697 11.6625C12.1561 11.7811 12.211 12.0284 12.0924 12.2148C12.061 12.2641 12.0191 12.306 11.9697 12.3375L7.61475 15.1088C7.42837 15.2274 7.18114 15.1725 7.06254 14.9861C7.02169 14.9219 7 14.8474 7 14.7713V9.22867C7 9.00776 7.17909 8.82867 7.4 8.82867ZM21 8.84131L17 11.641V12.359L21 15.1587V8.84131Z", + defaultProps, +}); diff --git a/src/components/invoice-modal.tsx b/src/components/invoice-modal.tsx index 684872804..317535d54 100644 --- a/src/components/invoice-modal.tsx +++ b/src/components/invoice-modal.tsx @@ -11,10 +11,9 @@ import { useDisclosure, useToast, } from "@chakra-ui/react"; -import { ExternalLinkIcon, LightningIcon, QrCodeIcon } from "./icons"; +import { ExternalLinkIcon, QrCodeIcon } from "./icons"; import QrCodeSvg from "./qr-code-svg"; import { CopyIconButton } from "./copy-icon-button"; -import { useIsMobile } from "../hooks/use-is-mobile"; export default function InvoiceModal({ invoice, @@ -22,17 +21,19 @@ export default function InvoiceModal({ onPaid, ...props }: Omit & { invoice: string; onPaid: () => void }) { - const isMobile = useIsMobile(); const toast = useToast(); const showQr = useDisclosure(); const payWithWebLn = async (invoice: string) => { - if (window.webln && invoice) { - if (!window.webln.enabled) await window.webln.enable(); - await window.webln.sendPayment(invoice); + try { + if (window.webln && invoice) { + if (!window.webln.enabled) await window.webln.enable(); + await window.webln.sendPayment(invoice); - if (onPaid) onPaid(); - onClose(); + if (onPaid) onPaid(); + } + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); } }; const payWithApp = async (invoice: string) => { diff --git a/src/components/note/buttons/repost-button.tsx b/src/components/note/buttons/repost-button.tsx index ca526d45c..2c42d45bc 100644 --- a/src/components/note/buttons/repost-button.tsx +++ b/src/components/note/buttons/repost-button.tsx @@ -37,9 +37,7 @@ export function RepostButton({ event }: { event: NostrEvent }) { await nostrPostAction(clientRelaysService.getWriteUrls(), repost); onClose(); } catch (e) { - if (e instanceof Error) { - toast({ status: "error", description: e.message }); - } + if (e instanceof Error) toast({ description: e.message, status: "error" }); } setLoading(false); }; diff --git a/src/components/note/embeded-note.tsx b/src/components/note/embedded-note.tsx similarity index 96% rename from src/components/note/embeded-note.tsx rename to src/components/note/embedded-note.tsx index d673613ea..8f0590c80 100644 --- a/src/components/note/embeded-note.tsx +++ b/src/components/note/embedded-note.tsx @@ -9,7 +9,7 @@ import { UserDnsIdentityIcon } from "../user-dns-identity-icon"; import useSubject from "../../hooks/use-subject"; import appSettings from "../../services/app-settings"; import EventVerificationIcon from "../event-verification-icon"; -import { TrustProvider } from "./trust"; +import { TrustProvider } from "../../providers/trust"; import { NoteLink } from "../note-link"; export default function EmbeddedNote({ note }: { note: NostrEvent }) { diff --git a/src/components/note/index.tsx b/src/components/note/index.tsx index 1902873f5..e6f7e028c 100644 --- a/src/components/note/index.tsx +++ b/src/components/note/index.tsx @@ -32,7 +32,7 @@ import { RepostButton } from "./buttons/repost-button"; import { QuoteRepostButton } from "./buttons/quote-repost-button"; import { ExternalLinkIcon } from "../icons"; import NoteContentWithWarning from "./note-content-with-warning"; -import { TrustProvider } from "./trust"; +import { TrustProvider } from "../../providers/trust"; import { NoteLink } from "../note-link"; import { useRegisterIntersectionEntity } from "../../providers/intersection-observer"; diff --git a/src/components/note/note-contents.tsx b/src/components/note/note-contents.tsx index 0c16dadd7..0168dedbf 100644 --- a/src/components/note/note-contents.tsx +++ b/src/components/note/note-contents.tsx @@ -21,11 +21,10 @@ import { renderOpenGraphUrl, } from "../embed-types"; import { ImageGalleryProvider } from "../image-gallery"; -import { useTrusted } from "./trust"; import { renderRedditUrl } from "../embed-types/reddit"; import EmbeddedContent from "../embeded-content"; -function buildContents(event: NostrEvent | DraftNostrEvent, trusted = false) { +function buildContents(event: NostrEvent | DraftNostrEvent) { let content: EmbedableContent = [event.content.trim()]; // common @@ -70,8 +69,7 @@ export type NoteContentsProps = { }; export const NoteContents = React.memo(({ event, maxHeight }: NoteContentsProps) => { - const trusted = useTrusted(); - const content = buildContents(event, trusted); + const content = buildContents(event); const expand = useExpand(); const [innerHeight, setInnerHeight] = useState(0); const ref = useRef(null); diff --git a/src/components/note/note-menu.tsx b/src/components/note/note-menu.tsx index fa1b1ce2e..ac1355d9e 100644 --- a/src/components/note/note-menu.tsx +++ b/src/components/note/note-menu.tsx @@ -53,12 +53,7 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit) { const account = useCurrentAccount(); const metadata = useUserMetadata(note.pubkey); + const { requestPay } = useInvoiceModalContext(); const zaps = useEventZaps(note.id) ?? []; const parsedZaps = useMemo(() => { const parsed = []; @@ -29,7 +31,11 @@ export default function NoteZapButton({ note, ...props }: { note: NostrEvent } & const hasZapped = !!account && parsedZaps.some((zapRequest) => zapRequest.request.pubkey === account.pubkey); const tipAddress = metadata?.lud06 || metadata?.lud16; - const invoicePaid = () => eventZapsService.requestZaps(note.id, clientRelaysService.getReadUrls(), true); + const handleInvoice = async (invoice: string) => { + onClose(); + await requestPay(invoice); + eventZapsService.requestZaps(note.id, clientRelaysService.getReadUrls(), true); + }; return ( <> @@ -44,7 +50,9 @@ export default function NoteZapButton({ note, ...props }: { note: NostrEvent } & > {readablizeSats(totalZaps(zaps) / 1000)} - {isOpen && } + {isOpen && ( + + )} ); } diff --git a/src/components/note/quote-note.tsx b/src/components/note/quote-note.tsx index c4232ab52..9b8e04572 100644 --- a/src/components/note/quote-note.tsx +++ b/src/components/note/quote-note.tsx @@ -1,6 +1,6 @@ import { useReadRelayUrls } from "../../hooks/use-client-relays"; import useSingleEvent from "../../hooks/use-single-event"; -import EmbeddedNote from "./embeded-note"; +import EmbeddedNote from "./embedded-note"; import { NoteLink } from "../note-link"; const QuoteNote = ({ noteId, relay }: { noteId: string; relay?: string }) => { diff --git a/src/components/page/desktop-side-nav.tsx b/src/components/page/desktop-side-nav.tsx index 15c2f31e2..838beb3e5 100644 --- a/src/components/page/desktop-side-nav.tsx +++ b/src/components/page/desktop-side-nav.tsx @@ -7,12 +7,12 @@ import { ConnectedRelays } from "../connected-relays"; import { ChatIcon, FeedIcon, + LiveStreamIcon, LogoutIcon, NotificationIcon, ProfileIcon, RelayIcon, SearchIcon, - ToolsIcon, } from "../icons"; import ProfileLink from "./profile-link"; import AccountSwitcher from "./account-switcher"; @@ -42,6 +42,9 @@ export default function DesktopSideNav() { + diff --git a/src/components/post-modal/index.tsx b/src/components/post-modal/index.tsx index 8acbb6c6c..353254835 100644 --- a/src/components/post-modal/index.tsx +++ b/src/components/post-modal/index.tsx @@ -27,7 +27,7 @@ import { ImageIcon } from "../icons"; import { NoteLink } from "../note-link"; import { NoteContents } from "../note/note-contents"; import { PostResults } from "./post-results"; -import { TrustProvider } from "../note/trust"; +import { TrustProvider } from "../../providers/trust"; function emptyDraft(): DraftNostrEvent { return { @@ -96,12 +96,7 @@ export const PostModal = ({ isOpen, onClose, initialDraft }: PostModalProps) => setDraft((d) => ({ ...d, content: (d.content += imageUrl) })); } } catch (e) { - if (e instanceof Error) { - toast({ - status: "error", - description: e.message, - }); - } + if (e instanceof Error) toast({ description: e.message, status: "error" }); } setUploading(false); }; diff --git a/src/components/repost-note.tsx b/src/components/repost-note.tsx index 1fc74da87..cb983a81f 100644 --- a/src/components/repost-note.tsx +++ b/src/components/repost-note.tsx @@ -9,7 +9,7 @@ import { NoteMenu } from "./note/note-menu"; import { UserAvatar } from "./user-avatar"; import { UserDnsIdentityIcon } from "./user-dns-identity-icon"; import { UserLink } from "./user-link"; -import { TrustProvider } from "./note/trust"; +import { TrustProvider } from "../providers/trust"; import { safeJson } from "../helpers/parse"; import { verifySignature } from "nostr-tools"; import { useReadRelayUrls } from "../hooks/use-client-relays"; diff --git a/src/components/user-tip-button.tsx b/src/components/user-tip-button.tsx index bc56df3d5..59c9d3bb9 100644 --- a/src/components/user-tip-button.tsx +++ b/src/components/user-tip-button.tsx @@ -1,13 +1,13 @@ -import { IconButton, IconButtonProps, useDisclosure, useToast } from "@chakra-ui/react"; +import { IconButton, IconButtonProps, useDisclosure } from "@chakra-ui/react"; import { useUserMetadata } from "../hooks/use-user-metadata"; import { LightningIcon } from "./icons"; -import { useState } from "react"; -import { encodeText } from "../helpers/bech32"; import ZapModal from "./zap-modal"; +import { useInvoiceModalContext } from "../providers/invoice-modal"; export const UserTipButton = ({ pubkey, ...props }: { pubkey: string } & Omit) => { const metadata = useUserMetadata(pubkey); const { isOpen, onOpen, onClose } = useDisclosure(); + const { requestPay } = useInvoiceModalContext(); if (!metadata) return null; // use lud06 and lud16 fields interchangeably @@ -25,7 +25,17 @@ export const UserTipButton = ({ pubkey, ...props }: { pubkey: string } & Omit - {isOpen && } + {isOpen && ( + { + await requestPay(invoice); + onClose(); + }} + /> + )} ); }; diff --git a/src/components/zap-modal.tsx b/src/components/zap-modal.tsx index 474ed4a68..cadd0cee8 100644 --- a/src/components/zap-modal.tsx +++ b/src/components/zap-modal.tsx @@ -1,41 +1,40 @@ import { + Box, Button, Flex, - IconButton, + Heading, + Image, Input, - InputGroup, - InputLeftElement, Modal, ModalBody, + ModalCloseButton, ModalContent, ModalOverlay, ModalProps, Text, - useDisclosure, useToast, } from "@chakra-ui/react"; -import { useState } from "react"; -import { getUserDisplayName } from "../helpers/user-metadata"; -import { NostrEvent } from "../types/nostr-event"; -import { SubmitHandler, useForm } from "react-hook-form"; +import { DraftNostrEvent, NostrEvent } from "../types/nostr-event"; +import { useForm } from "react-hook-form"; import { UserAvatar } from "./user-avatar"; -import { useUserMetadata } from "../hooks/use-user-metadata"; import { UserLink } from "./user-link"; import { parsePaymentRequest, readablizeSats } from "../helpers/bolt11"; -import { ExternalLinkIcon, LightningIcon, QrCodeIcon } from "./icons"; -import lnurlMetadataService from "../services/lnurl-metadata"; -import { useAsync } from "react-use"; -import { nip57 } from "nostr-tools"; +import { LightningIcon } from "./icons"; +import { Kind } from "nostr-tools"; import clientRelaysService from "../services/client-relays"; import { getEventRelays } from "../services/event-relays"; import { useSigningContext } from "../providers/signing-provider"; -import QrCodeSvg from "./qr-code-svg"; -import { CopyIconButton } from "./copy-icon-button"; -import { useIsMobile } from "../hooks/use-is-mobile"; import appSettings from "../services/app-settings"; import useSubject from "../hooks/use-subject"; import useUserLNURLMetadata from "../hooks/use-user-lnurl-metadata"; import { requestZapInvoice } from "../helpers/zaps"; +import { ParsedStream, getATag } from "../helpers/nostr/stream"; +import EmbeddedNote from "./note/embedded-note"; +import dayjs from "dayjs"; +import { unique } from "../helpers/array"; +import { useUserRelays } from "../hooks/use-user-relays"; +import { RelayMode } from "../classes/relay"; +import relayScoreboardService from "../services/relay-scoreboard"; type FormValues = { amount: number; @@ -43,29 +42,30 @@ type FormValues = { }; export type ZapModalProps = Omit & { - event?: NostrEvent; pubkey: string; - onPaid?: () => void; + event?: NostrEvent; + stream?: ParsedStream; initialComment?: string; initialAmount?: number; + onInvoice: (invoice: string) => void; }; export default function ZapModal({ event, pubkey, + stream, onClose, - onPaid, initialComment, initialAmount, + onInvoice, ...props }: ZapModalProps) { - const metadata = useUserMetadata(pubkey); - const { requestSignature } = useSigningContext(); const toast = useToast(); - const [promptInvoice, setPromptInvoice] = useState(); - const { isOpen: showQr, onToggle: toggleQr } = useDisclosure(); - const isMobile = useIsMobile(); - const { zapAmounts } = useSubject(appSettings); + const { requestSignature } = useSigningContext(); + const { customZapAmounts } = useSubject(appSettings); + const userReadRelays = useUserRelays(pubkey) + .filter((r) => r.mode & RelayMode.READ) + .map((r) => r.url); const { register, @@ -76,7 +76,7 @@ export default function ZapModal({ } = useForm({ mode: "onBlur", defaultValues: { - amount: initialAmount ?? zapAmounts[0], + amount: initialAmount ?? (parseInt(customZapAmounts.split(",")[0]) || 100), comment: initialComment ?? "", }, }); @@ -86,7 +86,7 @@ export default function ZapModal({ const canZap = lnurlMetadata?.allowsNostr && lnurlMetadata?.nostrPubkey; const actionName = canZap ? "Zap" : "Tip"; - const onSubmitZap: SubmitHandler = async (values) => { + const onSubmitZap = handleSubmit(async (values) => { try { if (!tipAddress) throw new Error("No lightning address"); if (lnurlMetadata) { @@ -95,21 +95,29 @@ export default function ZapModal({ if (amountInMilisat > lnurlMetadata.maxSendable) throw new Error("amount to large"); if (amountInMilisat < lnurlMetadata.minSendable) throw new Error("amount to small"); if (canZap) { - const otherRelays = event ? getEventRelays(event.id).value : []; - const readRelays = clientRelaysService.getReadUrls(); + const eventRelays = event ? getEventRelays(event.id).value : []; + const eventRelaysRanked = relayScoreboardService.getRankedRelays(eventRelays).slice(0, 4); + const writeRelays = clientRelaysService.getWriteUrls(); + const writeRelaysRanked = relayScoreboardService.getRankedRelays(writeRelays).slice(0, 4); + const userReadRelaysRanked = relayScoreboardService.getRankedRelays(userReadRelays).slice(0, 4); - const zapRequest = nip57.makeZapRequest({ - profile: pubkey, - event: event?.id ?? null, - relays: [...otherRelays, ...readRelays], - amount: amountInMilisat, - comment: values.comment, - }); + const zapRequest: DraftNostrEvent = { + kind: Kind.ZapRequest, + created_at: dayjs().unix(), + content: values.comment, + tags: [ + ["p", pubkey], + ["relays", ...unique([...writeRelaysRanked, ...userReadRelaysRanked, ...eventRelaysRanked])], + ["amount", String(amountInMilisat)], + ], + }; + if (event) zapRequest.tags.push(["e", event.id]); + if (stream) zapRequest.tags.push(["a", getATag(stream)]); const signed = await requestSignature(zapRequest); if (signed) { const payRequest = await requestZapInvoice(signed, lnurlMetadata.callback); - payInvoice(payRequest); + await onInvoice(payRequest); } } else { const callbackUrl = new URL(lnurlMetadata.callback); @@ -121,148 +129,83 @@ export default function ZapModal({ const parsed = parsePaymentRequest(payRequest); if (parsed.amount !== amountInMilisat) throw new Error("incorrect amount"); - payInvoice(payRequest); + await onInvoice(payRequest); } else throw new Error("Failed to get invoice"); } } else throw new Error("Failed to get LNURL metadata"); } catch (e) { if (e instanceof Error) toast({ status: "error", description: e.message }); } - }; - - const payWithWebLn = async (invoice: string) => { - if (window.webln && invoice) { - if (!window.webln.enabled) await window.webln.enable(); - await window.webln.sendPayment(invoice); - - toast({ - title: actionName + " sent", - status: "success", - duration: 3000, - }); - - if (onPaid) onPaid(); - onClose(); - } - }; - const payWithApp = async (invoice: string) => { - window.open("lightning:" + invoice); - - const listener = () => { - if (document.visibilityState === "visible") { - if (onPaid) onPaid(); - onClose(); - document.removeEventListener("visibilitychange", listener); - } - }; - setTimeout(() => { - document.addEventListener("visibilitychange", listener); - }, 1000 * 2); - }; - - const payInvoice = (invoice: string) => { - switch (appSettings.value.lightningPayMode) { - case "webln": - payWithWebLn(invoice); - break; - case "external": - payWithApp(invoice); - break; - default: - case "prompt": - setPromptInvoice(invoice); - break; - } - }; - - const handleClose = () => { - // if there was an invoice and we are closing the modal. presume it was paid - if (promptInvoice && onPaid) { - onPaid(); - } - onClose(); - }; + }); return ( - + + - {promptInvoice ? ( +
- {showQr && } - - - } - aria-label="Show QrCode" - onClick={toggleQr} - variant="solid" - size="md" + + + + + {tipAddress} + + + + {stream && ( + + + Stream: {stream.title} + + {stream.image && } + + )} + {event && } + + {(canZap || lnurlMetadata?.commentAllowed) && ( + - - - - {window.webln && ( - - )} - - - - ) : ( - - - - - {actionName} - - - - {zapAmounts.map((amount, i) => ( - ))} - - - - {!isMobile && ( - - - - )} - - - {(canZap || lnurlMetadata?.commentAllowed) && ( - - )} - + + + + -
- )} + +
diff --git a/src/helpers/nostr/stream.ts b/src/helpers/nostr/stream.ts index 5fc23647f..9ddd261bc 100644 --- a/src/helpers/nostr/stream.ts +++ b/src/helpers/nostr/stream.ts @@ -61,7 +61,7 @@ export function parseStreamEvent(stream: NostrEvent): ParsedStream { } export function getATag(stream: ParsedStream) { - return `${stream.event.kind}:${stream.author}:${stream.starts}`; + return `${stream.event.kind}:${stream.author}:${stream.identifier}`; } export function buildChatMessage(stream: ParsedStream, content: string) { diff --git a/src/hooks/use-app-settings.ts b/src/hooks/use-app-settings.ts index 203502079..1dec16e0b 100644 --- a/src/hooks/use-app-settings.ts +++ b/src/hooks/use-app-settings.ts @@ -13,12 +13,7 @@ export default function useAppSettings() { try { return replaceSettings({ ...settings, ...newSettings }); } catch (e) { - if (e instanceof Error) { - toast({ - status: "error", - description: e.message, - }); - } + if (e instanceof Error) toast({ description: e.message, status: "error" }); } }, [settings] diff --git a/src/hooks/use-scroll-position.ts b/src/hooks/use-scroll-position.ts deleted file mode 100644 index c079857cc..000000000 --- a/src/hooks/use-scroll-position.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { MutableRefObject, useState } from "react"; -import { useInterval } from "react-use"; - -export default function useScrollPosition(ref: MutableRefObject, interval = 1000) { - const [percent, setPercent] = useState(0); - useInterval(() => { - if (!ref.current) return; - const scrollBottom = ref.current.scrollTop + ref.current.getClientRects()[0].height; - - if (ref.current.scrollHeight === 0) { - return setPercent(1); - } - - const scrollPosition = Math.min(scrollBottom / ref.current.scrollHeight, 1); - setPercent(scrollPosition); - }, interval); - return percent; -} diff --git a/src/providers/invoice-modal.tsx b/src/providers/invoice-modal.tsx index 512fcf55e..87f9f0783 100644 --- a/src/providers/invoice-modal.tsx +++ b/src/providers/invoice-modal.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useContext, useState } from "react"; import InvoiceModal from "../components/invoice-modal"; import createDefer, { Deferred } from "../classes/deferred"; +import appSettings from "../services/app-settings"; export type InvoiceModalContext = { requestPay: (invoice: string) => Promise; @@ -20,7 +21,17 @@ export const InvoiceModalProvider = ({ children }: { children: React.ReactNode } const [invoice, setInvoice] = useState(); const [defer, setDefer] = useState>(); - 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(); setDefer(defer); setInvoice(invoice); diff --git a/src/providers/signing-provider.tsx b/src/providers/signing-provider.tsx index e23fe19e0..1c6b75987 100644 --- a/src/providers/signing-provider.tsx +++ b/src/providers/signing-provider.tsx @@ -37,12 +37,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) => if (!current) throw new Error("No account"); return await signingService.requestSignature(draft, current); } catch (e) { - if (e instanceof Error) { - toast({ - status: "error", - description: e.message, - }); - } + if (e instanceof Error) toast({ description: e.message, status: "error" }); } }, [toast, current] @@ -53,12 +48,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) => if (!current) throw new Error("No account"); return await signingService.requestDecrypt(data, pubkey, current); } catch (e) { - if (e instanceof Error) { - toast({ - status: "error", - description: e.message, - }); - } + if (e instanceof Error) toast({ description: e.message, status: "error" }); } }, [toast, current] @@ -69,12 +59,7 @@ export const SigningProvider = ({ children }: { children: React.ReactNode }) => if (!current) throw new Error("No account"); return await signingService.requestEncrypt(data, pubkey, current); } catch (e) { - if (e instanceof Error) { - toast({ - status: "error", - description: e.message, - }); - } + if (e instanceof Error) toast({ description: e.message, status: "error" }); } }, [toast, current] diff --git a/src/components/note/trust.tsx b/src/providers/trust.tsx similarity index 74% rename from src/components/note/trust.tsx rename to src/providers/trust.tsx index 6fd3731fe..32f9c3649 100644 --- a/src/components/note/trust.tsx +++ b/src/providers/trust.tsx @@ -1,8 +1,8 @@ import React, { PropsWithChildren, useContext } from "react"; -import { NostrEvent } from "../../types/nostr-event"; -import { useCurrentAccount } from "../../hooks/use-current-account"; -import clientFollowingService from "../../services/client-following"; -import useSubject from "../../hooks/use-subject"; +import { NostrEvent } from "../types/nostr-event"; +import { useCurrentAccount } from "../hooks/use-current-account"; +import clientFollowingService from "../services/client-following"; +import useSubject from "../hooks/use-subject"; const TrustContext = React.createContext(false); diff --git a/src/services/user-app-settings.ts b/src/services/user-app-settings.ts index f179bae01..f3441956c 100644 --- a/src/services/user-app-settings.ts +++ b/src/services/user-app-settings.ts @@ -9,12 +9,6 @@ import db from "./db"; const DTAG = "nostrudel-settings"; -export enum LightningPayMode { - Prompt = "prompt", - Webln = "webln", - External = "external", -} - export type AppSettings = { colorMode: ColorMode; blurImages: boolean; @@ -22,8 +16,10 @@ export type AppSettings = { proxyUserMedia: boolean; showReactions: boolean; showSignatureVerification: boolean; - lightningPayMode: LightningPayMode; - zapAmounts: number[]; + + autoPayWithWebLN: boolean; + customZapAmounts: string; + primaryColor: string; imageProxy: string; corsProxy: string; @@ -40,8 +36,10 @@ export const defaultSettings: AppSettings = { proxyUserMedia: false, showReactions: true, showSignatureVerification: false, - lightningPayMode: LightningPayMode.Prompt, - zapAmounts: [50, 200, 500, 1000], + + autoPayWithWebLN: true, + customZapAmounts: "50,200,500,1000,2000,5000", + primaryColor: "#8DB600", imageProxy: "", corsProxy: "", diff --git a/src/views/home/index.tsx b/src/views/home/index.tsx index adf5d2609..9afaca39a 100644 --- a/src/views/home/index.tsx +++ b/src/views/home/index.tsx @@ -3,8 +3,6 @@ import { Outlet, useMatches, useNavigate } from "react-router-dom"; const tabs = [ { label: "Following", path: "/following" }, - // { label: "Discover", path: "/discover" }, - { label: "Streams", path: "/streams" }, { label: "Global", path: "/global" }, ]; diff --git a/src/views/home/streams/stream/stream-chat.tsx b/src/views/home/streams/stream/stream-chat.tsx deleted file mode 100644 index 5c3ada5c0..000000000 --- a/src/views/home/streams/stream/stream-chat.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import { useCallback, useMemo, useRef } from "react"; -import dayjs from "dayjs"; -import { - Box, - Button, - Card, - CardBody, - CardHeader, - CardProps, - Flex, - Heading, - IconButton, - Input, - Spacer, - Text, - useToast, -} from "@chakra-ui/react"; -import { ParsedStream, buildChatMessage, getATag } from "../../../../helpers/nostr/stream"; -import { useTimelineLoader } from "../../../../hooks/use-timeline-loader"; -import { useReadRelayUrls } from "../../../../hooks/use-client-relays"; -import { useAdditionalRelayContext } from "../../../../providers/additional-relay-context"; -import useSubject from "../../../../hooks/use-subject"; -import { truncatedId } from "../../../../helpers/nostr-event"; -import { UserAvatar } from "../../../../components/user-avatar"; -import { UserLink } from "../../../../components/user-link"; -import { DraftNostrEvent, NostrEvent } from "../../../../types/nostr-event"; -import IntersectionObserverProvider, { - useRegisterIntersectionEntity, -} from "../../../../providers/intersection-observer"; -import { useTimelineCurserIntersectionCallback } from "../../../../hooks/use-timeline-cursor-intersection-callback"; -import { embedUrls } from "../../../../helpers/embeds"; -import { embedEmoji, renderGenericUrl, renderImageUrl } from "../../../../components/embed-types"; -import EmbeddedContent from "../../../../components/embeded-content"; -import { useForm } from "react-hook-form"; -import { useSigningContext } from "../../../../providers/signing-provider"; -import { nostrPostAction } from "../../../../classes/nostr-post-action"; -import { useUserRelays } from "../../../../hooks/use-user-relays"; -import { RelayMode } from "../../../../classes/relay"; -import { unique } from "../../../../helpers/array"; -import { LightningIcon } from "../../../../components/icons"; -import { parseZapEvent, requestZapInvoice } from "../../../../helpers/zaps"; -import { readablizeSats } from "../../../../helpers/bolt11"; -import { Kind } from "nostr-tools"; -import useUserLNURLMetadata from "../../../../hooks/use-user-lnurl-metadata"; -import { useInvoiceModalContext } from "../../../../providers/invoice-modal"; - -function ChatMessage({ event, stream }: { event: NostrEvent; stream: ParsedStream }) { - const ref = useRef(null); - useRegisterIntersectionEntity(ref, event.id); - - const content = useMemo(() => { - let c = embedUrls([event.content], [renderImageUrl, renderGenericUrl]); - c = embedEmoji(c, event); - return c; - }, [event.content]); - - return ( - - - - - - {dayjs.unix(event.created_at).fromNow()} - - - - - - ); -} - -function ZapMessage({ zap, stream }: { zap: NostrEvent; stream: ParsedStream }) { - const ref = useRef(null); - useRegisterIntersectionEntity(ref, zap.id); - - 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; - - return ( - - - - - - zapped {readablizeSats(payment.amount / 1000)} sats - - {dayjs.unix(request.created_at).fromNow()} - - - - - - ); -} - -export default function StreamChat({ stream, ...props }: CardProps & { stream: ParsedStream }) { - const toast = useToast(); - const contextRelays = useAdditionalRelayContext(); - const readRelays = useReadRelayUrls(contextRelays); - const writeRelays = useUserRelays(stream.author) - .filter((r) => r.mode & RelayMode.READ) - .map((r) => r.url); - - const timeline = useTimelineLoader(`${truncatedId(stream.event.id)}-chat`, readRelays, { - "#a": [getATag(stream)], - kinds: [1311, 9735], - }); - - const events = useSubject(timeline.timeline).sort((a, b) => b.created_at - a.created_at); - - const scrollBox = useRef(null); - const callback = useTimelineCurserIntersectionCallback(timeline); - - const { requestSignature } = useSigningContext(); - const { register, handleSubmit, formState, reset, getValues } = useForm({ - defaultValues: { content: "" }, - }); - const sendMessage = handleSubmit(async (values) => { - try { - const draft = buildChatMessage(stream, values.content); - const signed = await requestSignature(draft); - if (!signed) throw new Error("Failed to sign"); - nostrPostAction(unique([...contextRelays, ...writeRelays]), signed); - reset(); - } catch (e) { - if (e instanceof Error) toast({ description: e.message }); - } - }); - - const { requestPay } = useInvoiceModalContext(); - const zapMetadata = useUserLNURLMetadata(stream.author); - const zapMessage = async () => { - try { - if (!zapMetadata.metadata?.callback) throw new Error("bad lnurl endpoint"); - - const content = getValues().content; - const amount = 100; - const zapRequest: DraftNostrEvent = { - kind: Kind.ZapRequest, - created_at: dayjs().unix(), - content, - tags: [ - ["p", stream.author], - ["a", getATag(stream)], - ["relays", ...writeRelays], - ["amount", String(amount * 1000)], - ], - }; - - const signed = await requestSignature(zapRequest); - if (!signed) throw new Error("Failed to sign"); - - const invoice = await requestZapInvoice(signed, zapMetadata.metadata.callback); - await requestPay(invoice); - - reset(); - } catch (e) { - if (e instanceof Error) toast({ description: e.message }); - } - }; - - return ( - - - - Stream Chat - - - - {events.map((event) => - event.kind === 1311 ? ( - - ) : ( - - ) - )} - - - - - {zapMetadata.metadata?.allowsNostr && ( - } - aria-label="Zap stream" - borderColor="yellow.400" - variant="outline" - onClick={zapMessage} - /> - )} - - - - - ); -} diff --git a/src/views/note/thread-post.tsx b/src/views/note/thread-post.tsx index e3c0f1801..f32ac1077 100644 --- a/src/views/note/thread-post.tsx +++ b/src/views/note/thread-post.tsx @@ -4,7 +4,7 @@ import { ArrowDownSIcon, ArrowUpSIcon } from "../../components/icons"; import { Note } from "../../components/note"; import { countReplies, ThreadItem as ThreadItemData } from "../../helpers/thread"; import { useIsMobile } from "../../hooks/use-is-mobile"; -import { TrustProvider } from "../../components/note/trust"; +import { TrustProvider } from "../../providers/trust"; export type ThreadItemProps = { post: ThreadItemData; diff --git a/src/views/profile/edit.tsx b/src/views/profile/edit.tsx index bb0d142f0..f544b4062 100644 --- a/src/views/profile/edit.tsx +++ b/src/views/profile/edit.tsx @@ -243,12 +243,7 @@ export const ProfileEditView = () => { await results.onComplete; } catch (e) { - if (e instanceof Error) { - toast({ - status: "error", - description: e.message, - }); - } + if (e instanceof Error) toast({ description: e.message, status: "error" }); } }; diff --git a/src/views/relays/index.tsx b/src/views/relays/index.tsx index 519f01468..1f0ff96e2 100644 --- a/src/views/relays/index.tsx +++ b/src/views/relays/index.tsx @@ -61,9 +61,7 @@ function RelaysPage() { } setRelayInputValue(""); } catch (e) { - if (e instanceof Error) { - toast({ status: "error", description: e.message }); - } + if (e instanceof Error) toast({ description: e.message, status: "error" }); } }; const savePending = async () => { diff --git a/src/views/settings/index.tsx b/src/views/settings/index.tsx index 8b32e9bdf..0f71d51a3 100644 --- a/src/views/settings/index.tsx +++ b/src/views/settings/index.tsx @@ -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 { GithubIcon, ToolsIcon } from "../../components/icons"; import LightningSettings from "./lightning-settings"; @@ -10,6 +10,7 @@ import useAppSettings from "../../hooks/use-app-settings"; import { FormProvider, useForm } from "react-hook-form"; export default function SettingsView() { + const toast = useToast(); const { updateSettings, ...settings } = useAppSettings(); const form = useForm({ @@ -18,7 +19,12 @@ export default function SettingsView() { }); 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 ( diff --git a/src/views/settings/lightning-settings.tsx b/src/views/settings/lightning-settings.tsx index 7cfba80fe..51c46a012 100644 --- a/src/views/settings/lightning-settings.tsx +++ b/src/views/settings/lightning-settings.tsx @@ -10,13 +10,15 @@ import { FormHelperText, Input, Select, + Switch, + FormErrorMessage, } from "@chakra-ui/react"; import { LightningIcon } from "../../components/icons"; import { AppSettings } from "../../services/user-app-settings"; import { useFormContext } from "react-hook-form"; export default function LightningSettings() { - const { register } = useFormContext(); + const { register, formState } = useFormContext(); return ( @@ -31,44 +33,35 @@ export default function LightningSettings() { - - Payment mode - - + + + Auto pay with WebLN + + + + - Prompt: Ask every time -
- WebLN: Use browser extension -
- External: Open an external app using "lightning:" link + Enabled: Attempt to automatically pay with WebLN if its available
- + Zap Amounts { - if (Array.isArray(value)) { - return Array.from(value).join(","); - } else { - return value - .split(",") - .map((v) => parseInt(v)) - .filter(Boolean) - .sort((a, b) => a - b); - } + {...register("customZapAmounts", { + validate: (v) => { + if (!/^[\d,]*$/.test(v)) return "Must be a list of comma separated numbers"; + return true; }, })} /> + {formState.errors.customZapAmounts && ( + {formState.errors.customZapAmounts.message} + )} Comma separated list of custom zap amounts diff --git a/src/views/home/streams/status-badge.tsx b/src/views/streams/components/status-badge.tsx similarity index 100% rename from src/views/home/streams/status-badge.tsx rename to src/views/streams/components/status-badge.tsx diff --git a/src/views/home/streams/stream-card.tsx b/src/views/streams/components/stream-card.tsx similarity index 94% rename from src/views/home/streams/stream-card.tsx rename to src/views/streams/components/stream-card.tsx index 8eda2fe13..8bc6b5390 100644 --- a/src/views/home/streams/stream-card.tsx +++ b/src/views/streams/components/stream-card.tsx @@ -2,8 +2,6 @@ import { useMemo } from "react"; import { ParsedStream } from "../../../helpers/nostr/stream"; import { Badge, - Button, - ButtonGroup, Card, CardBody, CardFooter, @@ -13,7 +11,6 @@ import { Heading, IconButton, Image, - Link, LinkBox, LinkOverlay, Modal, @@ -33,14 +30,13 @@ import dayjs from "dayjs"; import relayScoreboardService from "../../../services/relay-scoreboard"; import { getEventRelays } from "../../../services/event-relays"; import { nip19 } from "nostr-tools"; -import { ExternalLinkIcon } from "@chakra-ui/icons"; import StreamStatusBadge from "./status-badge"; import { CodeIcon } from "../../../components/icons"; import RawValue from "../../../components/debug-modals/raw-value"; import RawJson from "../../../components/debug-modals/raw-json"; export default function StreamCard({ stream, ...props }: CardProps & { stream: ParsedStream }) { - const { title, summary, starts, identifier, status, image } = stream; + const { title, identifier, image } = stream; const devModal = useDisclosure(); const naddr = useMemo(() => { @@ -71,7 +67,6 @@ export default function StreamCard({ stream, ...props }: CardProps & { stream: P {title} - {summary} {stream.tags.length > 0 && ( {stream.tags.map((tag) => ( diff --git a/src/views/home/streams/stream-summary-content.tsx b/src/views/streams/components/stream-summary-content.tsx similarity index 100% rename from src/views/home/streams/stream-summary-content.tsx rename to src/views/streams/components/stream-summary-content.tsx diff --git a/src/views/home/streams/index.tsx b/src/views/streams/index.tsx similarity index 74% rename from src/views/home/streams/index.tsx rename to src/views/streams/index.tsx index d4cedc61b..07489988c 100644 --- a/src/views/home/streams/index.tsx +++ b/src/views/streams/index.tsx @@ -1,13 +1,13 @@ import { Flex, Select } from "@chakra-ui/react"; -import { useTimelineLoader } from "../../../hooks/use-timeline-loader"; +import { useTimelineLoader } from "../../hooks/use-timeline-loader"; import { useCallback, useMemo, useRef, useState } from "react"; -import { useReadRelayUrls } from "../../../hooks/use-client-relays"; -import IntersectionObserverProvider from "../../../providers/intersection-observer"; -import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback"; -import useSubject from "../../../hooks/use-subject"; -import StreamCard from "./stream-card"; -import { ParsedStream, parseStreamEvent } from "../../../helpers/nostr/stream"; -import { NostrEvent } from "../../../types/nostr-event"; +import { useReadRelayUrls } from "../../hooks/use-client-relays"; +import IntersectionObserverProvider from "../../providers/intersection-observer"; +import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; +import useSubject from "../../hooks/use-subject"; +import StreamCard from "./components/stream-card"; +import { ParsedStream, parseStreamEvent } from "../../helpers/nostr/stream"; +import { NostrEvent } from "../../types/nostr-event"; export default function LiveStreamsTab() { const readRelays = useReadRelayUrls(); diff --git a/src/views/home/streams/stream/index.tsx b/src/views/streams/stream/index.tsx similarity index 60% rename from src/views/home/streams/stream/index.tsx rename to src/views/streams/stream/index.tsx index ccff8b5a8..3fdcf7971 100644 --- a/src/views/home/streams/stream/index.tsx +++ b/src/views/streams/stream/index.tsx @@ -1,21 +1,41 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useScroll } from "react-use"; import { Box, Button, Flex, Heading, Spacer, Spinner, Text } from "@chakra-ui/react"; import { Link as RouterLink, useParams, Navigate } from "react-router-dom"; -import { ParsedStream, parseStreamEvent } from "../../../../helpers/nostr/stream"; import { nip19 } from "nostr-tools"; -import { NostrRequest } from "../../../../classes/nostr-request"; -import { useReadRelayUrls } from "../../../../hooks/use-client-relays"; -import { unique } from "../../../../helpers/array"; -import { LiveVideoPlayer } from "../../../../components/live-video-player"; + +import { ParsedStream, parseStreamEvent } from "../../../helpers/nostr/stream"; +import { NostrRequest } from "../../../classes/nostr-request"; +import { useReadRelayUrls } from "../../../hooks/use-client-relays"; +import { unique } from "../../../helpers/array"; +import { LiveVideoPlayer } from "../../../components/live-video-player"; import StreamChat from "./stream-chat"; -import { UserAvatarLink } from "../../../../components/user-avatar-link"; -import { UserLink } from "../../../../components/user-link"; -import { useIsMobile } from "../../../../hooks/use-is-mobile"; -import { AdditionalRelayProvider } from "../../../../providers/additional-relay-context"; -import StreamSummaryContent from "../stream-summary-content"; +import { UserAvatarLink } from "../../../components/user-avatar-link"; +import { UserLink } from "../../../components/user-link"; +import { useIsMobile } from "../../../hooks/use-is-mobile"; +import { AdditionalRelayProvider } from "../../../providers/additional-relay-context"; +import StreamSummaryContent from "../components/stream-summary-content"; +import { ArrowDownSIcon, ArrowUpSIcon } from "../../../components/icons"; function StreamPage({ stream }: { stream: ParsedStream }) { const isMobile = useIsMobile(); + const scrollBox = useRef(null); + const scrollState = useScroll(scrollBox); + + const action = + scrollState.y < 256 ? ( + + ) : ( + + ); return ( @@ -43,7 +64,14 @@ function StreamPage({ stream }: { stream: ParsedStream }) { - + ); } diff --git a/src/views/streams/stream/stream-chat.tsx b/src/views/streams/stream/stream-chat.tsx new file mode 100644 index 000000000..3118fa12d --- /dev/null +++ b/src/views/streams/stream/stream-chat.tsx @@ -0,0 +1,238 @@ +import { useMemo, useRef } from "react"; +import dayjs from "dayjs"; +import { + Box, + Button, + Card, + CardBody, + CardHeader, + CardProps, + Flex, + Heading, + IconButton, + Input, + Spacer, + Text, + useDisclosure, + useToast, +} from "@chakra-ui/react"; +import { ParsedStream, buildChatMessage, getATag } from "../../../helpers/nostr/stream"; +import { useTimelineLoader } from "../../../hooks/use-timeline-loader"; +import { useReadRelayUrls } from "../../../hooks/use-client-relays"; +import { useAdditionalRelayContext } from "../../../providers/additional-relay-context"; +import useSubject from "../../../hooks/use-subject"; +import { truncatedId } from "../../../helpers/nostr-event"; +import { UserAvatar } from "../../../components/user-avatar"; +import { UserLink } from "../../../components/user-link"; +import { NostrEvent } from "../../../types/nostr-event"; +import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; +import { useTimelineCurserIntersectionCallback } from "../../../hooks/use-timeline-cursor-intersection-callback"; +import { EmbedableContent, embedUrls } from "../../../helpers/embeds"; +import { + embedEmoji, + embedNostrHashtags, + embedNostrLinks, + embedNostrMentions, + renderGenericUrl, + renderImageUrl, +} from "../../../components/embed-types"; +import EmbeddedContent from "../../../components/embeded-content"; +import { useForm } from "react-hook-form"; +import { useSigningContext } from "../../../providers/signing-provider"; +import { nostrPostAction } from "../../../classes/nostr-post-action"; +import { useUserRelays } from "../../../hooks/use-user-relays"; +import { RelayMode } from "../../../classes/relay"; +import { unique } from "../../../helpers/array"; +import { LightningIcon } from "../../../components/icons"; +import { parseZapEvent } from "../../../helpers/zaps"; +import { readablizeSats } from "../../../helpers/bolt11"; +import useUserLNURLMetadata from "../../../hooks/use-user-lnurl-metadata"; +import { useInvoiceModalContext } from "../../../providers/invoice-modal"; +import { ImageGalleryProvider } from "../../../components/image-gallery"; +import { TrustProvider } from "../../../providers/trust"; +import ZapModal from "../../../components/zap-modal"; + +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 ; +} + +function ChatMessage({ event, stream }: { event: NostrEvent; stream: ParsedStream }) { + const ref = useRef(null); + useRegisterIntersectionEntity(ref, event.id); + + return ( + + + + + + + {dayjs.unix(event.created_at).fromNow()} + + + + + + + ); +} + +function ZapMessage({ zap, stream }: { zap: NostrEvent; stream: ParsedStream }) { + const ref = useRef(null); + useRegisterIntersectionEntity(ref, zap.id); + + const { request, payment } = parseZapEvent(zap); + if (!payment.amount) return null; + + return ( + + + + + + + zapped {readablizeSats(payment.amount / 1000)} sats + + {dayjs.unix(request.created_at).fromNow()} + + + + + + + ); +} + +export default function StreamChat({ + stream, + actions, + ...props +}: CardProps & { stream: ParsedStream; actions?: React.ReactNode }) { + const toast = useToast(); + const contextRelays = useAdditionalRelayContext(); + const readRelays = useReadRelayUrls(contextRelays); + const userReadRelays = useUserRelays(stream.author) + .filter((r) => r.mode & RelayMode.READ) + .map((r) => r.url); + + const timeline = useTimelineLoader(`${truncatedId(stream.event.id)}-chat`, readRelays, { + "#a": [getATag(stream)], + kinds: [1311, 9735], + }); + + const events = useSubject(timeline.timeline).sort((a, b) => b.created_at - a.created_at); + + const scrollBox = useRef(null); + const callback = useTimelineCurserIntersectionCallback(timeline); + + const { requestSignature } = useSigningContext(); + const { register, handleSubmit, formState, reset, getValues } = useForm({ + defaultValues: { content: "" }, + }); + const sendMessage = handleSubmit(async (values) => { + try { + const draft = buildChatMessage(stream, values.content); + const signed = await requestSignature(draft); + if (!signed) throw new Error("Failed to sign"); + nostrPostAction(unique([...contextRelays, ...userReadRelays]), signed); + reset(); + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + }); + + const zapModal = useDisclosure(); + const { requestPay } = useInvoiceModalContext(); + const zapMetadata = useUserLNURLMetadata(stream.author); + + return ( + <> + + + + + Stream Chat + {actions} + + + + {events.map((event) => + event.kind === 1311 ? ( + + ) : ( + + ) + )} + + + + + {zapMetadata.metadata?.allowsNostr && ( + } + aria-label="Zap stream" + borderColor="yellow.400" + variant="outline" + onClick={zapModal.onOpen} + /> + )} + + + + + + {zapModal.isOpen && ( + { + reset(); + zapModal.onClose(); + await requestPay(invoice); + }} + onClose={zapModal.onClose} + initialComment={getValues().content} + /> + )} + + ); +} diff --git a/src/views/tools/nip19.tsx b/src/views/tools/nip19.tsx index ea483d6f3..8c92f854c 100644 --- a/src/views/tools/nip19.tsx +++ b/src/views/tools/nip19.tsx @@ -45,9 +45,7 @@ function EncodeForm() { setOutput(nprofile); } catch (e) { - if (e instanceof Error) { - toast({ description: e.message }); - } + if (e instanceof Error) toast({ description: e.message, status: "error" }); } }); @@ -92,9 +90,7 @@ function DecodeForm() { try { setOutput(nip19.decode(values.input)); } catch (e) { - if (e instanceof Error) { - toast({ description: e.message }); - } + if (e instanceof Error) toast({ description: e.message, status: "error" }); } }); diff --git a/src/views/user/index.tsx b/src/views/user/index.tsx index 031c4c979..0ef57dfb9 100644 --- a/src/views/user/index.tsx +++ b/src/views/user/index.tsx @@ -46,6 +46,7 @@ const tabs = [ { label: "Media", path: "media" }, { label: "Zaps", path: "zaps" }, { label: "Following", path: "following" }, + { label: "Likes", path: "likes" }, { label: "Relays", path: "relays" }, { label: "Reports", path: "reports" }, { label: "Followers", path: "followers" }, diff --git a/src/views/user/likes.tsx b/src/views/user/likes.tsx new file mode 100644 index 000000000..495c63f30 --- /dev/null +++ b/src/views/user/likes.tsx @@ -0,0 +1,79 @@ +import { useRef } from "react"; +import { useOutletContext } from "react-router-dom"; +import { Box, Flex, SkeletonText, Spacer, Text } from "@chakra-ui/react"; +import { Kind } from "nostr-tools"; +import { getReferences, truncatedId } from "../../helpers/nostr-event"; +import { useTimelineLoader } from "../../hooks/use-timeline-loader"; +import { NostrEvent } from "../../types/nostr-event"; +import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; +import { useReadRelayUrls } from "../../hooks/use-client-relays"; +import TimelineActionAndStatus from "../../components/timeline-action-and-status"; +import useSubject from "../../hooks/use-subject"; +import IntersectionObserverProvider, { useRegisterIntersectionEntity } from "../../providers/intersection-observer"; +import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; +import useSingleEvent from "../../hooks/use-single-event"; +import { Note } from "../../components/note"; +import { TrustProvider } from "../../providers/trust"; +import { UserAvatar } from "../../components/user-avatar"; +import { UserLink } from "../../components/user-link"; +import { NoteMenu } from "../../components/note/note-menu"; + +const Like = ({ event }: { event: NostrEvent }) => { + const ref = useRef(null); + useRegisterIntersectionEntity(ref, event.id); + + const contextRelays = useAdditionalRelayContext(); + const readRelays = useReadRelayUrls(contextRelays); + + const refs = getReferences(event); + const eventId: string | undefined = refs.events[0]; + const { event: note } = useSingleEvent(eventId, readRelays); + + var content = <>; + if (!note) return ; + + if (note.kind === Kind.Text) { + content = ( + <> + + + + {event.content === "+" ? "liked" : "reacted with " + event.content} + + + + + + + ); + } else content = <>Unknown note type {note.kind}; + + return {content}; +}; + +export default function UserLikesTab() { + const { pubkey } = useOutletContext() as { pubkey: string }; + const contextRelays = useAdditionalRelayContext(); + const readRelays = useReadRelayUrls(contextRelays); + + const timeline = useTimelineLoader(`${truncatedId(pubkey)}-likes`, readRelays, { authors: [pubkey], kinds: [7] }); + + const lines = useSubject(timeline.timeline); + + const scrollBox = useRef(null); + const callback = useTimelineCurserIntersectionCallback(timeline); + + return ( + + + + {lines.map((event) => ( + + ))} + + + + + + ); +}