diff --git a/.changeset/flat-scissors-do.md b/.changeset/flat-scissors-do.md new file mode 100644 index 000000000..304bc7dba --- /dev/null +++ b/.changeset/flat-scissors-do.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add goal views diff --git a/.changeset/silent-wombats-flow.md b/.changeset/silent-wombats-flow.md new file mode 100644 index 000000000..e5c363293 --- /dev/null +++ b/.changeset/silent-wombats-flow.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Improve event embed card diff --git a/src/app.tsx b/src/app.tsx index e1700c0cb..8756f479d 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -37,14 +37,18 @@ import RelaysView from "./views/relays"; import RelayView from "./views/relays/relay"; import RelayReviewsView from "./views/relays/reviews"; import ListsView from "./views/lists"; -import ListView from "./views/lists/list"; +import ListDetailsView from "./views/lists/list-details"; import UserListsTab from "./views/user/lists"; import BrowseListView from "./views/lists/browse"; import EmojiPacksBrowseView from "./views/emoji-packs/browse"; -import EmojiPackView from "./views/emoji-packs/pack"; +import EmojiPackView from "./views/emoji-packs/emoji-pack"; import UserEmojiPacksTab from "./views/user/emoji-packs"; import EmojiPacksView from "./views/emoji-packs"; +import GoalsView from "./views/goals"; +import GoalsBrowseView from "./views/goals/browse"; +import GoalDetailsView from "./views/goals/goal-details"; +import UserGoalsTab from "./views/user/goals"; const StreamsView = React.lazy(() => import("./views/streams")); const StreamView = React.lazy(() => import("./views/streams/stream")); @@ -131,6 +135,7 @@ const router = createHashRouter([ { path: "lists", element: }, { path: "followers", element: }, { path: "following", element: }, + { path: "goals", element: }, { path: "emojis", element: }, { path: "relays", element: }, { path: "reports", element: }, @@ -158,7 +163,15 @@ const router = createHashRouter([ children: [ { path: "", element: }, { path: "browse", element: }, - { path: ":addr", element: }, + { path: ":addr", element: }, + ], + }, + { + path: "goals", + children: [ + { path: "", element: }, + { path: "browse", element: }, + { path: ":id", element: }, ], }, { diff --git a/src/components/embed-event/event-types/embedded-emoji-pack.tsx b/src/components/embed-event/event-types/embedded-emoji-pack.tsx new file mode 100644 index 000000000..58d5f79ab --- /dev/null +++ b/src/components/embed-event/event-types/embedded-emoji-pack.tsx @@ -0,0 +1,59 @@ +import { + ButtonGroup, + Card, + CardBody, + CardFooter, + CardHeader, + CardProps, + Flex, + Heading, + Image, + Link, + Text, +} from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; +import dayjs from "dayjs"; + +import { getSharableEventAddress } from "../../../helpers/nip19"; +import { getEmojisFromPack, getPackName } from "../../../helpers/nostr/emoji-packs"; +import { UserAvatarLink } from "../../user-avatar-link"; +import { UserLink } from "../../user-link"; +import EmojiPackFavoriteButton from "../../../views/emoji-packs/components/emoji-pack-favorite-button"; +import EmojiPackMenu from "../../../views/emoji-packs/components/emoji-pack-menu"; +import { NostrEvent } from "../../../types/nostr-event"; + +export default function EmbeddedEmojiPack({ pack, ...props }: Omit & { pack: NostrEvent }) { + const emojis = getEmojisFromPack(pack); + const naddr = getSharableEventAddress(pack); + + return ( + + + + + {getPackName(pack)} + + + by + + + + + + + + + {emojis.length > 0 && ( + + {emojis.map(({ name, url }) => ( + + ))} + + )} + + + Updated: {dayjs.unix(pack.created_at).fromNow()} + + + ); +} diff --git a/src/components/embed-event/event-types/embedded-goal.tsx b/src/components/embed-event/event-types/embedded-goal.tsx new file mode 100644 index 000000000..e3723465d --- /dev/null +++ b/src/components/embed-event/event-types/embedded-goal.tsx @@ -0,0 +1,33 @@ +import { Card, CardBody, CardHeader, CardProps, Heading, Link, Text } from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; + +import { getSharableEventAddress } from "../../../helpers/nip19"; +import { NostrEvent } from "../../../types/nostr-event"; +import { getGoalName } from "../../../helpers/nostr/goal"; +import { UserAvatarLink } from "../../user-avatar-link"; +import { UserLink } from "../../user-link"; +import GoalProgress from "../../../views/goals/components/goal-progress"; +import GoalZapButton from "../../../views/goals/components/goal-zap-button"; + +export default function EmbeddedGoal({ goal, ...props }: Omit & { goal: NostrEvent }) { + const nevent = getSharableEventAddress(goal); + + return ( + + + + + {getGoalName(goal)} + + + by + + + + + + + + + ); +} diff --git a/src/components/note/embedded-note.tsx b/src/components/embed-event/event-types/embedded-note.tsx similarity index 66% rename from src/components/note/embedded-note.tsx rename to src/components/embed-event/event-types/embedded-note.tsx index ae794b314..7172f11ce 100644 --- a/src/components/note/embedded-note.tsx +++ b/src/components/embed-event/event-types/embedded-note.tsx @@ -1,17 +1,17 @@ import dayjs from "dayjs"; import { Button, Card, CardBody, CardHeader, Spacer, useDisclosure } from "@chakra-ui/react"; -import { NoteContents } from "./note-contents"; -import { NostrEvent } from "../../types/nostr-event"; -import { UserAvatarLink } from "../user-avatar-link"; -import { UserLink } from "../user-link"; -import { UserDnsIdentityIcon } from "../user-dns-identity-icon"; -import useSubject from "../../hooks/use-subject"; -import appSettings from "../../services/settings/app-settings"; -import EventVerificationIcon from "../event-verification-icon"; -import { TrustProvider } from "../../providers/trust"; -import { NoteLink } from "../note-link"; -import { ArrowDownSIcon, ArrowUpSIcon } from "../icons"; +import { NoteContents } from "../../note/note-contents"; +import { NostrEvent } from "../../../types/nostr-event"; +import { UserAvatarLink } from "../../user-avatar-link"; +import { UserLink } from "../../user-link"; +import { UserDnsIdentityIcon } from "../../user-dns-identity-icon"; +import useSubject from "../../../hooks/use-subject"; +import appSettings from "../../../services/settings/app-settings"; +import EventVerificationIcon from "../../event-verification-icon"; +import { TrustProvider } from "../../../providers/trust"; +import { NoteLink } from "../../note-link"; +import { ArrowDownSIcon, ArrowUpSIcon } from "../../icons"; export default function EmbeddedNote({ event }: { event: NostrEvent }) { const { showSignatureVerification } = useSubject(appSettings); diff --git a/src/components/embed-event/event-types/embedded-stream.tsx b/src/components/embed-event/event-types/embedded-stream.tsx new file mode 100644 index 000000000..4c8ca9735 --- /dev/null +++ b/src/components/embed-event/event-types/embedded-stream.tsx @@ -0,0 +1,68 @@ +import { Card, CardBody, CardProps, Flex, Heading, Image, Link, Tag, Text, useBreakpointValue } from "@chakra-ui/react"; +import { Link as RouterLink, useNavigate } from "react-router-dom"; +import dayjs from "dayjs"; + +import { parseStreamEvent } from "../../../helpers/nostr/stream"; +import { NostrEvent } from "../../../types/nostr-event"; +import StreamStatusBadge from "../../../views/streams/components/status-badge"; +import { UserLink } from "../../user-link"; +import { UserAvatar } from "../../user-avatar"; +import useEventNaddr from "../../../hooks/use-event-naddr"; + +export default function EmbeddedStream({ event, ...props }: Omit & { event: NostrEvent }) { + const stream = parseStreamEvent(event); + const naddr = useEventNaddr(stream.event, stream.relays); + const isVertical = useBreakpointValue({ base: true, md: false }); + const navigate = useNavigate(); + + return ( + + + + {isVertical ? ( + navigate(`/streams/${naddr}`)} + maxH="2in" + mx="auto" + mb="2" + /> + ) : ( + navigate(`/streams/${naddr}`)} + /> + )} + + + + {stream.title} + + + + + + + + + + {stream.starts && Started: {dayjs.unix(stream.starts).fromNow()}} + {stream.tags.length > 0 && ( + + {stream.tags.map((tag) => ( + {tag} + ))} + + )} + + + ); +} diff --git a/src/components/embed-event/index.tsx b/src/components/embed-event/index.tsx new file mode 100644 index 000000000..edadb33d2 --- /dev/null +++ b/src/components/embed-event/index.tsx @@ -0,0 +1,68 @@ +import type { DecodeResult } from "nostr-tools/lib/nip19"; +import { Link } from "@chakra-ui/react"; + +import EmbeddedNote from "./event-types/embedded-note"; +import useSingleEvent from "../../hooks/use-single-event"; +import { NoteLink } from "../note-link"; +import { NostrEvent } from "../../types/nostr-event"; +import { Kind, nip19 } from "nostr-tools"; +import useReplaceableEvent from "../../hooks/use-replaceable-event"; +import RelayCard from "../../views/relays/components/relay-card"; +import { STREAM_KIND } from "../../helpers/nostr/stream"; +import { GOAL_KIND } from "../../helpers/nostr/goal"; +import GoalCard from "../../views/goals/components/goal-card"; +import { getSharableEventAddress, safeDecode } from "../../helpers/nip19"; +import EmbeddedStream from "./event-types/embedded-stream"; +import { EMOJI_PACK_KIND } from "../../helpers/nostr/emoji-packs"; +import EmbeddedEmojiPack from "./event-types/embedded-emoji-pack"; +import { buildAppSelectUrl } from "../../helpers/nostr/apps"; +import EmbeddedGoal from "./event-types/embedded-goal"; + +export function EmbedEvent({ event }: { event: NostrEvent }) { + switch (event.kind) { + case Kind.Text: + return ; + case STREAM_KIND: + return ; + case GOAL_KIND: + return ; + case EMOJI_PACK_KIND: + return ; + } + + const address = getSharableEventAddress(event); + return ( + + {address} + + ); +} + +export function EmbedEventPointer({ pointer }: { pointer: DecodeResult }) { + switch (pointer.type) { + case "note": { + const { event } = useSingleEvent(pointer.data); + if (event === undefined) return ; + return ; + } + case "nevent": { + const { event } = useSingleEvent(pointer.data.id, pointer.data.relays); + if (event === undefined) return ; + return ; + } + case "naddr": { + const event = useReplaceableEvent(pointer.data); + if (!event) return {nip19.naddrEncode(pointer.data)}; + return ; + } + case "nrelay": + return ; + } + return null; +} + +export function EmbedEventNostrLink({ link }: { link: string }) { + const pointer = safeDecode(link); + + return pointer ? : <>{link}; +} diff --git a/src/components/embed-types/common.tsx b/src/components/embed-types/common.tsx index 8e9c22361..a6c943175 100644 --- a/src/components/embed-types/common.tsx +++ b/src/components/embed-types/common.tsx @@ -18,5 +18,5 @@ export function renderGenericUrl(match: URL) { } export function renderOpenGraphUrl(match: URL) { - return ; + return ; } diff --git a/src/components/embed-types/nostr.tsx b/src/components/embed-types/nostr.tsx index cc452568a..15bbe4e6c 100644 --- a/src/components/embed-types/nostr.tsx +++ b/src/components/embed-types/nostr.tsx @@ -1,4 +1,3 @@ -import { nip19 } from "nostr-tools"; import { EmbedableContent, embedJSX } from "../../helpers/embeds"; import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event"; import QuoteNote from "../note/quote-note"; @@ -6,6 +5,8 @@ import { UserLink } from "../user-link"; import { Link } from "@chakra-ui/react"; import { Link as RouterLink } from "react-router-dom"; import { getMatchHashtag, getMatchNostrLink, stripInvisibleChar } from "../../helpers/regexp"; +import { safeDecode } from "../../helpers/nip19"; +import { EmbedEventPointer } from "../embed-event"; // nostr:nevent1qqsthg2qlxp9l7egtwa92t8lusm7pjknmjwa75ctrrpcjyulr9754fqpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq36amnwvaz7tmwdaehgu3dwp6kytnhv4kxcmmjv3jhytnwv46q2qg5q9 // nostr:nevent1qqsq3wc73lqxd70lg43m5rul57d4mhcanttjat56e30yx5zla48qzlspz9mhxue69uhkummnw3e82efwvdhk6qgdwaehxw309ahx7uewd3hkcq5hsum @@ -14,23 +15,21 @@ export function embedNostrLinks(content: EmbedableContent) { name: "nostr-link", regexp: getMatchNostrLink(), render: (match) => { - try { - const decoded = nip19.decode(match[2]); + const decoded = safeDecode(match[2]); + if (!decoded) return null; - switch (decoded.type) { - case "npub": - return ; - case "nprofile": - return ; - case "note": - return ; - case "nevent": - return ; - default: - return null; - } - } catch (e) { - return null; + switch (decoded.type) { + case "npub": + return ; + case "nprofile": + return ; + case "note": + case "nevent": + case "naddr": + case "nrelay": + return ; + default: + return null; } }, }); diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 52a6fc2fd..2e063cc00 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -373,3 +373,9 @@ export const EmojiIcon = createIcon({ d: "M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM7 12H9C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12H17C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12Z", defaultProps, }); + +export const GoalIcon = createIcon({ + displayName: "GoalIcon", + d: "M5 3V19H21V21H3V3H5ZM20.2929 6.29289L21.7071 7.70711L16 13.4142L13 10.415L8.70711 14.7071L7.29289 13.2929L13 7.58579L16 10.585L20.2929 6.29289Z", + defaultProps, +}); diff --git a/src/components/layout/nav-items.tsx b/src/components/layout/nav-items.tsx index 8557c1bcf..ac304ab12 100644 --- a/src/components/layout/nav-items.tsx +++ b/src/components/layout/nav-items.tsx @@ -4,6 +4,7 @@ import { ChatIcon, EmojiIcon, FeedIcon, + GoalIcon, ListIcon, LiveStreamIcon, MapIcon, @@ -49,6 +50,9 @@ export default function NavItems({ isInDrawer = false }: { isInDrawer?: boolean + diff --git a/src/components/note-link.tsx b/src/components/note-link.tsx index 1e54e4992..3f7b0858b 100644 --- a/src/components/note-link.tsx +++ b/src/components/note-link.tsx @@ -1,7 +1,6 @@ import { useMemo } from "react"; import { Link, LinkProps } from "@chakra-ui/react"; import { Link as RouterLink } from "react-router-dom"; -import { truncatedId } from "../helpers/nostr/events"; import { nip19 } from "nostr-tools"; import { getSharableNoteId } from "../helpers/nip19"; @@ -14,7 +13,7 @@ export const NoteLink = ({ children, noteId, color = "blue.500", ...props }: Not return ( - {children || truncatedId(nip19.noteEncode(noteId))} + {children || nip19.noteEncode(noteId)} ); }; diff --git a/src/components/note/note-menu.tsx b/src/components/note/note-menu.tsx index 179829bfd..b090cdbdb 100644 --- a/src/components/note/note-menu.tsx +++ b/src/components/note/note-menu.tsx @@ -3,7 +3,7 @@ import { MenuItem, useDisclosure } from "@chakra-ui/react"; import { useCopyToClipboard } from "react-use"; import { nip19 } from "nostr-tools"; -import { getSharableNoteId } from "../../helpers/nip19"; +import { getSharableEventAddress } from "../../helpers/nip19"; import { NostrEvent } from "../../types/nostr-event"; import { MenuIconButton, MenuIconButtonProps } from "../menu-icon-button"; @@ -39,19 +39,20 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit }> Zaps/Reactions - window.open(buildAppSelectUrl(getSharableNoteId(event.id)), "_blank")} - icon={} - > - View in app... - - copyToClipboard("nostr:" + getSharableNoteId(event.id))} icon={}> + {address && ( + window.open(buildAppSelectUrl(address), "_blank")} icon={}> + View in app... + + )} + copyToClipboard("nostr:" + address)} icon={}> Copy Share Link {noteId && ( diff --git a/src/components/note/note-zap-button.tsx b/src/components/note/note-zap-button.tsx index d4b615dc3..4d50498ba 100644 --- a/src/components/note/note-zap-button.tsx +++ b/src/components/note/note-zap-button.tsx @@ -11,6 +11,7 @@ import { LightningIcon } from "../icons"; import ZapModal from "../zap-modal"; import { useInvoiceModalContext } from "../../providers/invoice-modal"; import useUserLNURLMetadata from "../../hooks/use-user-lnurl-metadata"; +import { getEventUID } from "../../helpers/nostr/events"; export type NoteZapButtonProps = Omit & { event: NostrEvent; @@ -30,7 +31,7 @@ export default function NoteZapButton({ event, allowComment, showEventPreview, . const handleInvoice = async (invoice: string) => { onClose(); await requestPay(invoice); - eventZapsService.requestZaps(event.id, clientRelaysService.getReadUrls(), true); + eventZapsService.requestZaps(getEventUID(event), clientRelaysService.getReadUrls(), true); }; const total = totalZaps(zaps); diff --git a/src/components/note/quote-note.tsx b/src/components/note/quote-note.tsx index de769d684..fb4e77a77 100644 --- a/src/components/note/quote-note.tsx +++ b/src/components/note/quote-note.tsx @@ -1,8 +1,9 @@ import { useReadRelayUrls } from "../../hooks/use-client-relays"; import useSingleEvent from "../../hooks/use-single-event"; -import EmbeddedNote from "./embedded-note"; +import EmbeddedNote from "../embed-event/event-types/embedded-note"; import { NoteLink } from "../note-link"; +/** @deprecated */ const QuoteNote = ({ noteId, relays }: { noteId: string; relays?: string[] }) => { const readRelays = useReadRelayUrls(relays); const { event, loading } = useSingleEvent(noteId, readRelays); diff --git a/src/components/open-graph-card.tsx b/src/components/open-graph-card.tsx index 59380e64d..3195eb016 100644 --- a/src/components/open-graph-card.tsx +++ b/src/components/open-graph-card.tsx @@ -1,4 +1,17 @@ -import { Box, CardProps, Heading, Image, Link, LinkBox, LinkOverlay, Text } from "@chakra-ui/react"; +import { + Box, + Card, + CardBody, + CardProps, + Flex, + Heading, + Image, + Link, + LinkBox, + LinkOverlay, + Text, + useBreakpointValue, +} from "@chakra-ui/react"; import useOpenGraphData from "../hooks/use-open-graph-data"; export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit) { @@ -10,23 +23,40 @@ export default function OpenGraphCard({ url, ...props }: { url: URL } & Omit ); + const isVertical = useBreakpointValue({ base: true, md: false }); + if (!data) return link; return ( - - {data.ogImage?.length === 1 && ( - - )} - - - - - {data.ogTitle?.trim() ?? data.dcTitle?.trim()} - - - {data.ogDescription || data.dcDescription} - {link} - - + + + {data.ogImage?.length === 1 && ( + + )} + + + + {data.ogTitle?.trim() ?? data.dcTitle?.trim()} + + + {data.ogDescription || data.dcDescription} + {link} + + + ); } diff --git a/src/components/zap-modal.tsx b/src/components/zap-modal.tsx index bae62ce8d..742a40d3a 100644 --- a/src/components/zap-modal.tsx +++ b/src/components/zap-modal.tsx @@ -19,7 +19,7 @@ import dayjs from "dayjs"; import { Kind } from "nostr-tools"; import { useForm } from "react-hook-form"; -import { DraftNostrEvent, NostrEvent } from "../types/nostr-event"; +import { DraftNostrEvent, NostrEvent, isDTag } from "../types/nostr-event"; import { UserAvatar } from "./user-avatar"; import { UserLink } from "./user-link"; import { parsePaymentRequest, readablizeSats } from "../helpers/bolt11"; @@ -32,12 +32,14 @@ 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 EmbeddedNote from "./embed-event/event-types/embedded-note"; import { unique } from "../helpers/array"; import { useUserRelays } from "../hooks/use-user-relays"; import { RelayMode } from "../classes/relay"; import relayScoreboardService from "../services/relay-scoreboard"; import { useAdditionalRelayContext } from "../providers/additional-relay-context"; +import { getEventCoordinate, isReplaceable } from "../helpers/nostr/events"; +import { EmbedEvent } from "./embed-event"; type FormValues = { amount: number; @@ -47,7 +49,7 @@ type FormValues = { export type ZapModalProps = Omit & { pubkey: string; event?: NostrEvent; - stream?: ParsedStream; + relays?: string[]; initialComment?: string; initialAmount?: number; onInvoice: (invoice: string) => void; @@ -59,7 +61,7 @@ export type ZapModalProps = Omit & { export default function ZapModal({ event, pubkey, - stream, + relays, onClose, initialComment, initialAmount, @@ -132,8 +134,11 @@ export default function ZapModal({ ], }; - if (event) zapRequest.tags.push(["e", event.id]); - if (stream) zapRequest.tags.push(["a", getATag(stream)]); + if (event) { + if (isReplaceable(event.kind) && event.tags.some(isDTag)) { + zapRequest.tags.push(["a", getEventCoordinate(event)]); + } else zapRequest.tags.push(["e", event.id]); + } const signed = await requestSignature(zapRequest); if (signed) { @@ -175,15 +180,7 @@ export default function ZapModal({ - {showEventPreview && stream && ( - - - Stream: {stream.title} - - {stream.image && } - - )} - {showEventPreview && event && } + {showEventPreview && event && } {allowComment && (canZap || lnurlMetadata?.commentAllowed) && ( 0) { + return nip19.neventEncode({ id: event.id, relays: onlyTwo }); + } else return nip19.noteEncode(event.id); + } +} + +export function encodePointer(pointer: DecodeResult) { + switch (pointer.type) { + case "naddr": + return nip19.naddrEncode(pointer.data); + case "nprofile": + return nip19.nprofileEncode(pointer.data); + case "nevent": + return nip19.neventEncode(pointer.data); + case "nrelay": + return nip19.nrelayEncode(pointer.data); + case "nsec": + return nip19.nsecEncode(pointer.data); + case "npub": + return nip19.npubEncode(pointer.data); + case "note": + return nip19.noteEncode(pointer.data); + } +} + +export function getPointerFromTag(tag: Tag): DecodeResult | null { + if (isETag(tag)) { + if (!tag[1]) return null; + return { + type: "nevent", + data: { + id: tag[1], + relays: tag[2] ? [tag[2]] : undefined, + }, + }; + } else if (isATag(tag)) { + const [_, coordinate, relay] = tag; + const parts = coordinate.split(":") as (string | undefined)[]; + const kind = parts[0] && parseInt(parts[0]); + const pubkey = parts[1]; + const d = parts[2]; + + if (!kind) return null; + if (!pubkey) return null; + if (!d) return null; + + return { + type: "naddr", + data: { + kind, + pubkey, + identifier: d, + relays: relay ? [relay] : undefined, + }, + }; + } else if (isPTag(tag)) { + const [_, pubkey, relay] = tag; + if (!pubkey) return null; + return { type: "nprofile", data: { pubkey, relays: relay ? [relay] : undefined } }; + } + return null; } diff --git a/src/helpers/nostr/events.ts b/src/helpers/nostr/events.ts index 771c22556..e23180d2e 100644 --- a/src/helpers/nostr/events.ts +++ b/src/helpers/nostr/events.ts @@ -2,11 +2,11 @@ import dayjs from "dayjs"; import { Kind, nip19 } from "nostr-tools"; import { getEventRelays } from "../../services/event-relays"; -import { DraftNostrEvent, isETag, isPTag, NostrEvent, RTag, Tag } from "../../types/nostr-event"; +import { ATag, DraftNostrEvent, ETag, isETag, isPTag, NostrEvent, RTag, Tag } from "../../types/nostr-event"; import { RelayConfig, RelayMode } from "../../classes/relay"; import { getMatchNostrLink } from "../regexp"; import relayScoreboardService from "../../services/relay-scoreboard"; -import { AddressPointer } from "nostr-tools/lib/nip19"; +import type { AddressPointer, EventPointer } from "nostr-tools/lib/nip19"; export function truncatedId(str: string, keep = 6) { if (str.length < keep * 2 + 3) return str; diff --git a/src/helpers/nostr/goal.ts b/src/helpers/nostr/goal.ts new file mode 100644 index 000000000..bf1dea680 --- /dev/null +++ b/src/helpers/nostr/goal.ts @@ -0,0 +1,79 @@ +import dayjs from "dayjs"; +import { NostrEvent, isRTag } from "../../types/nostr-event"; +import { DecodeResult } from "nostr-tools/lib/nip19"; +import { getPointerFromTag } from "../nip19"; + +export const GOAL_KIND = 9041; + +export type ParsedGoal = { + event: NostrEvent; + author: string; + amount: number; + relays: string[]; +}; + +export function getGoalPointerFromEvent(event: NostrEvent) { + const tag = event.tags.find((t) => t[0] === "goal"); + const id = tag?.[1]; + const relay = tag?.[2]; + return id ? { id, relay } : undefined; +} + +export function getGoalName(goal: NostrEvent) { + return goal.content; +} +export function getGoalRelays(goal: NostrEvent) { + const relays = goal.tags.find((t) => t[0] === "relays"); + return relays ? relays.slice(1) : []; +} +export function getGoalAmount(goal: NostrEvent) { + const amount = goal.tags.find((t) => t[0] === "amount")?.[1]; + if (amount === undefined) throw new Error("Missing amount"); + const int = parseInt(amount); + if (!Number.isFinite(int)) throw new Error("Amount not a number"); + if (int <= 0) throw new Error("Amount less than or equal to zero"); + return int; +} +export function getGoalClosedDate(goal: NostrEvent) { + const value = goal.tags.find((t) => t[0] === "closed_at")?.[1]; + if (value === undefined) return; + const date = dayjs.unix(parseInt(value)); + if (!date.isValid) throw new Error("Invalid date"); + return date.unix(); +} + +export function getGoalLinks(goal: NostrEvent) { + return goal.tags.filter(isRTag).map((t) => t[1]); +} +export function getGoalEventPointers(goal: NostrEvent) { + const pointers: DecodeResult[] = []; + + for (const tag of goal.tags) { + const decoded = getPointerFromTag(tag); + + if (decoded?.type === "naddr" || decoded?.type === "nevent") { + pointers.push(decoded); + } + } + + return pointers; +} + +export function validateGoal(goal: NostrEvent) { + const amount = getGoalAmount(goal); + const relays = getGoalRelays(goal); + if (relays.length) throw new Error("zero relays"); + return true; +} + +export function safeValidateGoal(goal: NostrEvent) { + try { + return validateGoal(goal); + } catch (e) {} + return false; +} + +export function getGoalTag(goal: NostrEvent, relay?: string) { + const id = goal.id; + return ["goal", id, relay].filter(Boolean); +} diff --git a/src/helpers/regexp.ts b/src/helpers/regexp.ts index e27659aae..dfa02f27a 100644 --- a/src/helpers/regexp.ts +++ b/src/helpers/regexp.ts @@ -1,5 +1,5 @@ export const getMatchNostrLink = () => - /(nostr:|@)?((npub|note|nprofile|nevent)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi; + /(nostr:|@)?((npub|note|nprofile|nevent|nrelay|naddr)1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58,})/gi; export const getMatchHashtag = () => /(^|[^\p{L}])#([\p{L}\p{N}]+)/gu; export const getMatchLink = () => /https?:\/\/([a-zA-Z0-9\.\-]+\.[a-zA-Z]+)([\p{Letter}\p{Number}&\.-\/\?=#\-@%\+_,:]*)/gu; diff --git a/src/hooks/use-event-zaps.ts b/src/hooks/use-event-zaps.ts index 4d60eecfc..769e367c6 100644 --- a/src/hooks/use-event-zaps.ts +++ b/src/hooks/use-event-zaps.ts @@ -1,15 +1,16 @@ import { useMemo } from "react"; + import eventZapsService from "../services/event-zaps"; import { useReadRelayUrls } from "./use-client-relays"; import useSubject from "./use-subject"; import { parseZapEvent } from "../helpers/zaps"; -export default function useEventZaps(eventId: string, additionalRelays: string[] = [], alwaysFetch = true) { +export default function useEventZaps(eventUID: string, additionalRelays: string[] = [], alwaysFetch = true) { const relays = useReadRelayUrls(additionalRelays); const subject = useMemo( - () => eventZapsService.requestZaps(eventId, relays, alwaysFetch), - [eventId, relays.join("|"), alwaysFetch], + () => eventZapsService.requestZaps(eventUID, relays, alwaysFetch), + [eventUID, relays.join("|"), alwaysFetch], ); const events = useSubject(subject) || []; diff --git a/src/hooks/use-single-event.ts b/src/hooks/use-single-event.ts index 8620ae147..f43cf3c7d 100644 --- a/src/hooks/use-single-event.ts +++ b/src/hooks/use-single-event.ts @@ -1,10 +1,13 @@ import { useAsync } from "react-use"; -import singleEventService from "../services/single-event"; -export default function useSingleEvent(id?: string, relays: string[] = []) { +import singleEventService from "../services/single-event"; +import { useReadRelayUrls } from "./use-client-relays"; + +export default function useSingleEvent(id?: string, additionalRelays: string[] = []) { + const readRelays = useReadRelayUrls(additionalRelays); const { loading, value: event } = useAsync(async () => { - if (id) return singleEventService.requestEvent(id, relays); - }, [id, relays.join("|")]); + if (id) return singleEventService.requestEvent(id, readRelays); + }, [id, readRelays.join("|")]); return { event, diff --git a/src/services/event-zaps.ts b/src/services/event-zaps.ts index 35a3f8bfe..a206ca7f8 100644 --- a/src/services/event-zaps.ts +++ b/src/services/event-zaps.ts @@ -1,23 +1,26 @@ import { Kind } from "nostr-tools"; + import { NostrRequest } from "../classes/nostr-request"; import Subject from "../classes/subject"; import { SuperMap } from "../classes/super-map"; import { getReferences } from "../helpers/nostr/events"; import { NostrEvent } from "../types/nostr-event"; +import { NostrRequestFilter } from "../types/nostr-query"; +import { isHexKey } from "../helpers/nip19"; -type eventId = string; +type eventUID = string; type relay = string; class EventZapsService { - subjects = new SuperMap>(() => new Subject([])); - pending = new SuperMap>(() => new Set()); + subjects = new SuperMap>(() => new Subject([])); + pending = new SuperMap>(() => new Set()); - requestZaps(eventId: string, relays: relay[], alwaysFetch = true) { - const subject = this.subjects.get(eventId); + requestZaps(eventUID: eventUID, relays: relay[], alwaysFetch = true) { + const subject = this.subjects.get(eventUID); if (!subject.value || alwaysFetch) { for (const relay of relays) { - this.pending.get(eventId).add(relay); + this.pending.get(eventUID).add(relay); } } @@ -41,7 +44,7 @@ class EventZapsService { batchRequests() { if (this.pending.size === 0) return; - const idsFromRelays: Record = {}; + const idsFromRelays: Record = {}; for (const [id, relays] of this.pending) { for (const relay of relays) { idsFromRelays[relay] = idsFromRelays[relay] ?? []; @@ -52,7 +55,18 @@ class EventZapsService { for (const [relay, ids] of Object.entries(idsFromRelays)) { const request = new NostrRequest([relay]); request.onEvent.subscribe(this.handleEvent, this); - request.start({ "#e": ids, kinds: [Kind.Zap] }); + const eventIds = ids.filter(isHexKey); + const coordinates = ids.filter((id) => id.includes(":")); + + const queries: NostrRequestFilter = []; + if (eventIds.length > 0) { + queries.push({ "#e": eventIds, kinds: [Kind.Zap] }); + } + if (coordinates.length > 0) { + queries.push({ "#a": coordinates, kinds: [Kind.Zap] }); + } + + request.start(queries); } this.pending.clear(); } diff --git a/src/views/emoji-packs/components/emoji-pack-card.tsx b/src/views/emoji-packs/components/emoji-pack-card.tsx index 63c471933..89177e49a 100644 --- a/src/views/emoji-packs/components/emoji-pack-card.tsx +++ b/src/views/emoji-packs/components/emoji-pack-card.tsx @@ -12,13 +12,12 @@ import { Image, Link, Text, - Tooltip, } from "@chakra-ui/react"; import dayjs from "dayjs"; import { UserAvatarLink } from "../../../components/user-avatar-link"; import { UserLink } from "../../../components/user-link"; -import { getSharableEventNaddr } from "../../../helpers/nip19"; +import { getSharableEventAddress } from "../../../helpers/nip19"; import { NostrEvent } from "../../../types/nostr-event"; import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; import EmojiPackFavoriteButton from "./emoji-pack-favorite-button"; @@ -28,14 +27,14 @@ import EmojiPackMenu from "./emoji-pack-menu"; export default function EmojiPackCard({ pack, ...props }: Omit & { pack: NostrEvent }) { const emojis = getEmojisFromPack(pack); - const naddr = getSharableEventNaddr(pack); + const naddr = getSharableEventAddress(pack); // if there is a parent intersection observer, register this card const ref = useRef(null); useRegisterIntersectionEntity(ref, getEventUID(pack)); return ( - + diff --git a/src/views/emoji-packs/components/emoji-pack-menu.tsx b/src/views/emoji-packs/components/emoji-pack-menu.tsx index ee2dc513b..cff102ad7 100644 --- a/src/views/emoji-packs/components/emoji-pack-menu.tsx +++ b/src/views/emoji-packs/components/emoji-pack-menu.tsx @@ -6,7 +6,7 @@ import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-ic import { useCurrentAccount } from "../../../hooks/use-current-account"; import NoteDebugModal from "../../../components/debug-modals/note-debug-modal"; import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons"; -import { getSharableEventNaddr } from "../../../helpers/nip19"; +import { getSharableEventAddress } from "../../../helpers/nip19"; import { buildAppSelectUrl } from "../../../helpers/nostr/apps"; import { useDeleteEventContext } from "../../../providers/delete-event-provider"; @@ -21,7 +21,7 @@ export default function EmojiPackMenu({ const [_clipboardState, copyToClipboard] = useCopyToClipboard(); - const naddr = getSharableEventNaddr(pack); + const naddr = getSharableEventAddress(pack); return ( <> diff --git a/src/views/emoji-packs/pack.tsx b/src/views/emoji-packs/emoji-pack.tsx similarity index 97% rename from src/views/emoji-packs/pack.tsx rename to src/views/emoji-packs/emoji-pack.tsx index 6d1f347e2..305391d4f 100644 --- a/src/views/emoji-packs/pack.tsx +++ b/src/views/emoji-packs/emoji-pack.tsx @@ -92,7 +92,6 @@ export default function EmojiPackView() { - {/* */} {emojis.map(({ name, url }) => ( @@ -101,7 +100,6 @@ export default function EmojiPackView() { ))} - {/* */} diff --git a/src/views/goals/browse.tsx b/src/views/goals/browse.tsx new file mode 100644 index 000000000..edafd3465 --- /dev/null +++ b/src/views/goals/browse.tsx @@ -0,0 +1,67 @@ +import { Flex, SimpleGrid, Switch, useDisclosure } from "@chakra-ui/react"; + +import PeopleListProvider, { usePeopleListContext } from "../../providers/people-list-provider"; +import PeopleListSelection from "../../components/people-list-selection/people-list-selection"; +import useTimelineLoader from "../../hooks/use-timeline-loader"; +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 GoalCard from "./components/goal-card"; +import { getEventUID } from "../../helpers/nostr/events"; +import { GOAL_KIND, getGoalClosedDate } from "../../helpers/nostr/goal"; +import { SwipeState } from "yet-another-react-lightbox"; +import { useCallback } from "react"; +import { NostrEvent } from "../../types/nostr-event"; +import dayjs from "dayjs"; + +function GoalsBrowsePage() { + const { filter, listId } = usePeopleListContext(); + const showClosed = useDisclosure(); + + const readRelays = useReadRelayUrls(); + const eventFilter = useCallback( + (event: NostrEvent) => { + const closed = getGoalClosedDate(event); + if (!showClosed.isOpen && closed && dayjs().isAfter(dayjs.unix(closed))) return false; + return true; + }, + [showClosed.isOpen], + ); + const timeline = useTimelineLoader( + `${listId}-browse-goals`, + readRelays, + { ...filter, kinds: [GOAL_KIND] }, + { enabled: !!filter, eventFilter }, + ); + + const goals = useSubject(timeline.timeline); + const callback = useTimelineCurserIntersectionCallback(timeline); + + return ( + + + + + + Show ended + + + + + {goals.map((event) => ( + + ))} + + + + ); +} + +export default function GoalsBrowseView() { + return ( + + + + ); +} diff --git a/src/views/goals/components/goal-card.tsx b/src/views/goals/components/goal-card.tsx new file mode 100644 index 000000000..db0063d44 --- /dev/null +++ b/src/views/goals/components/goal-card.tsx @@ -0,0 +1,50 @@ +import { memo, useRef } from "react"; +import { Link as RouterLink } from "react-router-dom"; +import { ButtonGroup, Card, CardBody, CardHeader, CardProps, Heading, Link, Text } from "@chakra-ui/react"; + +import { UserAvatarLink } from "../../../components/user-avatar-link"; +import { UserLink } from "../../../components/user-link"; +import { getSharableEventAddress } from "../../../helpers/nip19"; +import { NostrEvent } from "../../../types/nostr-event"; +import { useRegisterIntersectionEntity } from "../../../providers/intersection-observer"; +import { getEventUID } from "../../../helpers/nostr/events"; +import { getGoalClosedDate, getGoalName } from "../../../helpers/nostr/goal"; +import GoalMenu from "./goal-menu"; +import GoalProgress from "./goal-progress"; +import GoalContents from "./goal-contents"; +import dayjs from "dayjs"; + +function GoalCard({ goal, ...props }: Omit & { goal: NostrEvent }) { + const nevent = getSharableEventAddress(goal); + + // if there is a parent intersection observer, register this card + const ref = useRef(null); + useRegisterIntersectionEntity(ref, getEventUID(goal)); + + const closed = getGoalClosedDate(goal); + + return ( + + + + + {getGoalName(goal)} + + + by + + + + + + + + {closed && Ends: {dayjs.unix(closed).fromNow()}} + + + + + ); +} + +export default memo(GoalCard); diff --git a/src/views/goals/components/goal-contents.tsx b/src/views/goals/components/goal-contents.tsx new file mode 100644 index 000000000..be05302a0 --- /dev/null +++ b/src/views/goals/components/goal-contents.tsx @@ -0,0 +1,21 @@ +import { EmbedEventPointer } from "../../../components/embed-event"; +import { getGoalEventPointers, getGoalLinks } from "../../../helpers/nostr/goal"; +import { NostrEvent } from "../../../types/nostr-event"; +import { encodePointer } from "../../../helpers/nip19"; +import OpenGraphCard from "../../../components/open-graph-card"; + +export default function GoalContents({ goal }: { goal: NostrEvent }) { + const pointers = getGoalEventPointers(goal); + const links = getGoalLinks(goal); + + return ( + <> + {pointers.map((pointer) => ( + + ))} + {links.map((link) => ( + + ))} + + ); +} diff --git a/src/views/goals/components/goal-menu.tsx b/src/views/goals/components/goal-menu.tsx new file mode 100644 index 000000000..dfeb83e87 --- /dev/null +++ b/src/views/goals/components/goal-menu.tsx @@ -0,0 +1,50 @@ +import { MenuItem, useDisclosure } from "@chakra-ui/react"; +import { useCopyToClipboard } from "react-use"; + +import { NostrEvent } from "../../../types/nostr-event"; +import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button"; +import { useCurrentAccount } from "../../../hooks/use-current-account"; +import NoteDebugModal from "../../../components/debug-modals/note-debug-modal"; +import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons"; +import { getSharableEventAddress } from "../../../helpers/nip19"; +import { buildAppSelectUrl } from "../../../helpers/nostr/apps"; +import { useDeleteEventContext } from "../../../providers/delete-event-provider"; + +export default function GoalMenu({ goal, ...props }: { goal: NostrEvent } & Omit) { + // const account = useCurrentAccount(); + const infoModal = useDisclosure(); + + // const { deleteEvent } = useDeleteEventContext(); + const [_clipboardState, copyToClipboard] = useCopyToClipboard(); + + const nevent = getSharableEventAddress(goal); + + return ( + <> + + {nevent && ( + <> + window.open(buildAppSelectUrl(nevent), "_blank")} icon={}> + View in app... + + copyToClipboard("nostr:" + nevent)} icon={}> + Copy Share Link + + + )} + {/* {account?.pubkey === goal.pubkey && ( + } color="red.500" onClick={() => deleteEvent(goal)}> + Delete Goal + + )} */} + }> + View Raw + + + + {infoModal.isOpen && ( + + )} + + ); +} diff --git a/src/views/goals/components/goal-progress.tsx b/src/views/goals/components/goal-progress.tsx new file mode 100644 index 000000000..ba770d982 --- /dev/null +++ b/src/views/goals/components/goal-progress.tsx @@ -0,0 +1,24 @@ +import { Flex, Progress, Text } from "@chakra-ui/react"; +import { NostrEvent } from "../../../types/nostr-event"; +import { getGoalAmount, getGoalRelays } from "../../../helpers/nostr/goal"; +import { LightningIcon } from "../../../components/icons"; +import useEventZaps from "../../../hooks/use-event-zaps"; +import { getEventUID } from "../../../helpers/nostr/events"; +import { totalZaps } from "../../../helpers/zaps"; +import { readablizeSats } from "../../../helpers/bolt11"; + +export default function GoalProgress({ goal }: { goal: NostrEvent }) { + const amount = getGoalAmount(goal); + const zaps = useEventZaps(getEventUID(goal), getGoalRelays(goal), true); + const raised = totalZaps(zaps); + + return ( + + + + + {readablizeSats(raised / 1000)} / {readablizeSats(amount / 1000)} ({Math.round((raised / amount) * 1000) / 10}%) + + + ); +} diff --git a/src/views/goals/components/goal-zap-button.tsx b/src/views/goals/components/goal-zap-button.tsx new file mode 100644 index 000000000..8dccefbf6 --- /dev/null +++ b/src/views/goals/components/goal-zap-button.tsx @@ -0,0 +1,45 @@ +import { Button, ButtonProps, useDisclosure } from "@chakra-ui/react"; +import { NostrEvent } from "../../../types/nostr-event"; +import ZapModal from "../../../components/zap-modal"; +import eventZapsService from "../../../services/event-zaps"; +import { getEventUID } from "../../../helpers/nostr/events"; +import { useInvoiceModalContext } from "../../../providers/invoice-modal"; +import { getGoalRelays } from "../../../helpers/nostr/goal"; +import { useReadRelayUrls } from "../../../hooks/use-client-relays"; + +export default function GoalZapButton({ + goal, + ...props +}: Omit & { goal: NostrEvent }) { + const modal = useDisclosure(); + const { requestPay } = useInvoiceModalContext(); + + const readRelays = useReadRelayUrls(getGoalRelays(goal)); + const handleInvoice = async (invoice: string) => { + modal.onClose(); + await requestPay(invoice); + setTimeout(() => { + eventZapsService.requestZaps(getEventUID(goal), readRelays, true); + }, 1000); + }; + + return ( + <> + + {modal.isOpen && ( + + )} + + ); +} diff --git a/src/views/goals/components/goal-zap-list.tsx b/src/views/goals/components/goal-zap-list.tsx new file mode 100644 index 000000000..d185a590a --- /dev/null +++ b/src/views/goals/components/goal-zap-list.tsx @@ -0,0 +1,39 @@ +import { Box, Flex, Spacer, Text } from "@chakra-ui/react"; +import { getEventUID } from "../../../helpers/nostr/events"; +import { getGoalRelays } from "../../../helpers/nostr/goal"; +import useEventZaps from "../../../hooks/use-event-zaps"; +import { NostrEvent } from "../../../types/nostr-event"; +import { UserAvatarLink } from "../../../components/user-avatar-link"; +import { UserLink } from "../../../components/user-link"; +import { readablizeSats } from "../../../helpers/bolt11"; +import { LightningIcon } from "../../../components/icons"; +import dayjs from "dayjs"; + +export default function GoalZapList({ goal }: { goal: NostrEvent }) { + const zaps = useEventZaps(getEventUID(goal), getGoalRelays(goal), true); + + const sorted = Array.from(zaps).sort((a, b) => b.event.created_at - a.event.created_at); + + return ( + <> + {sorted.map((zap) => ( + + + + + + {dayjs.unix(zap.event.created_at).fromNow()} + + {zap.request.content && {zap.request.content}} + + + {zap.payment.amount && ( + + {readablizeSats(zap.payment.amount / 1000)} + + )} + + ))} + + ); +} diff --git a/src/views/goals/goal-details.tsx b/src/views/goals/goal-details.tsx new file mode 100644 index 000000000..6134d5985 --- /dev/null +++ b/src/views/goals/goal-details.tsx @@ -0,0 +1,71 @@ +import { useNavigate, useParams } from "react-router-dom"; +import { nip19 } from "nostr-tools"; + +import { Button, ButtonGroup, Divider, Flex, Heading, Spacer, Spinner } from "@chakra-ui/react"; +import { ArrowLeftSIcon } from "../../components/icons"; +import GoalMenu from "./components/goal-menu"; +import { getGoalAmount, getGoalName } from "../../helpers/nostr/goal"; +import GoalProgress from "./components/goal-progress"; +import useSingleEvent from "../../hooks/use-single-event"; +import { isHexKey } from "../../helpers/nip19"; +import { EventPointer } from "nostr-tools/lib/nip19"; +import { UserAvatar } from "../../components/user-avatar"; +import { UserLink } from "../../components/user-link"; +import GoalContents from "./components/goal-contents"; +import GoalZapList from "./components/goal-zap-list"; +import { readablizeSats } from "../../helpers/bolt11"; +import GoalZapButton from "./components/goal-zap-button"; + +function useGoalPointerFromParams(): EventPointer { + const { id } = useParams() as { id: string }; + if (isHexKey(id)) return { id }; + const parsed = nip19.decode(id); + if (parsed.type === "nevent") return parsed.data; + if (parsed.type === "note") return { id: parsed.data }; + throw new Error("bad goal id"); +} + +export default function GoalDetailsView() { + const navigate = useNavigate(); + const pointer = useGoalPointerFromParams(); + + const { event: goal } = useSingleEvent(pointer.id, pointer.relays); + + if (!goal) return ; + + return ( + + + + + + {getGoalName(goal)} ({readablizeSats(getGoalAmount(goal) / 1000)}) + + + + + + + + + + + + + + + + + Progress: + + + + Contributors: + + + + + ); +} diff --git a/src/views/goals/index.tsx b/src/views/goals/index.tsx new file mode 100644 index 000000000..972fa56f8 --- /dev/null +++ b/src/views/goals/index.tsx @@ -0,0 +1,81 @@ +import { Button, Center, Divider, Flex, Heading, Link, SimpleGrid, Spacer } from "@chakra-ui/react"; +import { Navigate, Link as RouterLink } from "react-router-dom"; + +import { useCurrentAccount } from "../../hooks/use-current-account"; +import { ExternalLinkIcon } from "../../components/icons"; +import { getEventUID } from "../../helpers/nostr/events"; +import { useReadRelayUrls } from "../../hooks/use-client-relays"; +import useTimelineLoader from "../../hooks/use-timeline-loader"; +import useSubject from "../../hooks/use-subject"; +import GoalCard from "./components/goal-card"; +import { GOAL_KIND } from "../../helpers/nostr/goal"; + +function UserGoalsManagerPage() { + const account = useCurrentAccount()!; + + const readRelays = useReadRelayUrls(); + const timeline = useTimelineLoader( + `${account.pubkey}-goals`, + readRelays, + { + authors: [account.pubkey], + kinds: [GOAL_KIND], + }, + { enabled: !!account.pubkey }, + ); + + const goals = useSubject(timeline.timeline); + + if (goals.length === 0) { + return ( +
+ You don't have any goals,{" "} + + Find a goal + {" "} + to support or{" "} + + Create one + +
+ ); + } + + return ( + <> + {goals.length > 0 && ( + <> + + Created goals + + + + {goals.map((event) => ( + + ))} + + + )} + + ); +} + +export default function GoalsView() { + const account = useCurrentAccount(); + + return ( + + + + + + + + {account ? : } + + ); +} diff --git a/src/views/lists/components/list-card.tsx b/src/views/lists/components/list-card.tsx index aef89c9f6..9e82e82dd 100644 --- a/src/views/lists/components/list-card.tsx +++ b/src/views/lists/components/list-card.tsx @@ -6,6 +6,7 @@ import { CardBody, CardFooter, CardHeader, + CardProps, Flex, Heading, Link, @@ -17,7 +18,7 @@ import dayjs from "dayjs"; import { UserAvatarLink } from "../../../components/user-avatar-link"; import { UserLink } from "../../../components/user-link"; import { getEventsFromList, getListName, getPubkeysFromList } from "../../../helpers/nostr/lists"; -import { getSharableEventNaddr } from "../../../helpers/nip19"; +import { getSharableEventAddress } from "../../../helpers/nip19"; import { NostrEvent } from "../../../types/nostr-event"; import useReplaceableEvent from "../../../hooks/use-replaceable-event"; import { createCoordinate } from "../../../services/replaceable-event-requester"; @@ -29,18 +30,18 @@ import ListFavoriteButton from "./list-favorite-button"; import { getEventUID } from "../../../helpers/nostr/events"; import ListMenu from "./list-menu"; -function ListCardRender({ event }: { event: NostrEvent }) { +function ListCardRender({ event, ...props }: Omit & { event: NostrEvent }) { const people = getPubkeysFromList(event); const notes = getEventsFromList(event); const link = - event.kind === Kind.Contacts ? createCoordinate(Kind.Contacts, event.pubkey) : getSharableEventNaddr(event); + event.kind === Kind.Contacts ? createCoordinate(Kind.Contacts, event.pubkey) : getSharableEventAddress(event); // if there is a parent intersection observer, register this card const ref = useRef(null); useRegisterIntersectionEntity(ref, getEventUID(event)); return ( - + diff --git a/src/views/lists/components/list-menu.tsx b/src/views/lists/components/list-menu.tsx index b9d9a25e6..b09715db2 100644 --- a/src/views/lists/components/list-menu.tsx +++ b/src/views/lists/components/list-menu.tsx @@ -6,7 +6,7 @@ import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-ic import { useCurrentAccount } from "../../../hooks/use-current-account"; import NoteDebugModal from "../../../components/debug-modals/note-debug-modal"; import { CodeIcon, ExternalLinkIcon, RepostIcon, TrashIcon } from "../../../components/icons"; -import { getSharableEventNaddr } from "../../../helpers/nip19"; +import { getSharableEventAddress } from "../../../helpers/nip19"; import { buildAppSelectUrl } from "../../../helpers/nostr/apps"; import { useDeleteEventContext } from "../../../providers/delete-event-provider"; @@ -18,7 +18,7 @@ export default function ListMenu({ list, ...props }: { list: NostrEvent } & Omit const [_clipboardState, copyToClipboard] = useCopyToClipboard(); - const naddr = getSharableEventNaddr(list); + const naddr = getSharableEventAddress(list); return ( <> diff --git a/src/views/lists/index.tsx b/src/views/lists/index.tsx index a5584bfe9..9b24810c1 100644 --- a/src/views/lists/index.tsx +++ b/src/views/lists/index.tsx @@ -9,7 +9,7 @@ import ListCard from "./components/list-card"; import { getEventUID } from "../../helpers/nostr/events"; import useUserLists from "../../hooks/use-user-lists"; import NewListModal from "./components/new-list-modal"; -import { getSharableEventNaddr } from "../../helpers/nip19"; +import { getSharableEventAddress } from "../../helpers/nip19"; import { MUTE_LIST_KIND, NOTE_LIST_KIND, PEOPLE_LIST_KIND, PIN_LIST_KIND } from "../../helpers/nostr/lists"; import useFavoriteLists from "../../hooks/use-favorite-lists"; @@ -89,7 +89,7 @@ function ListsPage() { navigate(`/lists/${getSharableEventNaddr(list)}`)} + onCreated={(list) => navigate(`/lists/${getSharableEventAddress(list)}`)} /> )} diff --git a/src/views/lists/list.tsx b/src/views/lists/list-details.tsx similarity index 98% rename from src/views/lists/list.tsx rename to src/views/lists/list-details.tsx index e7af031bc..e8fc30580 100644 --- a/src/views/lists/list.tsx +++ b/src/views/lists/list-details.tsx @@ -31,7 +31,7 @@ function useListCoordinate() { return parsed.data; } -export default function ListView() { +export default function ListDetailsView() { const navigate = useNavigate(); const coordinate = useListCoordinate(); const { deleteEvent } = useDeleteEventContext(); diff --git a/src/views/relays/components/relay-card.tsx b/src/views/relays/components/relay-card.tsx index d96b6bb0b..9f2f85462 100644 --- a/src/views/relays/components/relay-card.tsx +++ b/src/views/relays/components/relay-card.tsx @@ -1,3 +1,4 @@ +import { PropsWithChildren } from "react"; import { Box, Button, @@ -21,9 +22,10 @@ import { ModalOverlay, Tag, useDisclosure, - useToast, } from "@chakra-ui/react"; +import styled from "@emotion/styled"; import { Link as RouterLink } from "react-router-dom"; + import { useRelayInfo } from "../../../hooks/use-relay-info"; import { RelayFavicon } from "../../../components/relay-favicon"; import { CodeIcon, RepostIcon } from "../../../components/icons"; @@ -34,13 +36,8 @@ import clientRelaysService from "../../../services/client-relays"; import { RelayMode } from "../../../classes/relay"; import { UserDnsIdentityIcon } from "../../../components/user-dns-identity-icon"; import { useCurrentAccount } from "../../../hooks/use-current-account"; -import styled from "@emotion/styled"; -import { PropsWithChildren, useCallback } from "react"; import RawJson from "../../../components/debug-modals/raw-json"; -import { DraftNostrEvent } from "../../../types/nostr-event"; -import dayjs from "dayjs"; -import { useSigningContext } from "../../../providers/signing-provider"; -import NostrPublishAction from "../../../classes/nostr-publish-action"; +import { RelayShareButton } from "./relay-share-button"; const B = styled.span` font-weight: bold; @@ -146,44 +143,6 @@ export function RelayDebugButton({ url, ...props }: { url: string } & Omit) { - const toast = useToast(); - const { requestSignature } = useSigningContext(); - - const recommendRelay = useCallback(async () => { - try { - const writeRelays = clientRelaysService.getWriteUrls(); - - const draft: DraftNostrEvent = { - kind: 2, - content: relay, - tags: [], - created_at: dayjs().unix(), - }; - - const signed = await requestSignature(draft); - const post = new NostrPublishAction("Share Relay", writeRelays, signed); - await post.onComplete; - } catch (e) { - if (e instanceof Error) toast({ description: e.message, status: "error" }); - } - }, []); - - return ( - } - aria-label="Recommend Relay" - title="Recommend Relay" - onClick={recommendRelay} - variant="ghost" - {...props} - /> - ); -} - export function RelayPaidTag({ url }: { url: string }) { const { info } = useRelayInfo(url); diff --git a/src/views/relays/components/relay-share-button.tsx b/src/views/relays/components/relay-share-button.tsx new file mode 100644 index 000000000..ebd2945b8 --- /dev/null +++ b/src/views/relays/components/relay-share-button.tsx @@ -0,0 +1,47 @@ +import { useCallback } from "react"; +import dayjs from "dayjs"; +import { IconButton, IconButtonProps, useToast } from "@chakra-ui/react"; + +import { useSigningContext } from "../../../providers/signing-provider"; +import clientRelaysService from "../../../services/client-relays"; +import { DraftNostrEvent } from "../../../types/nostr-event"; +import NostrPublishAction from "../../../classes/nostr-publish-action"; +import { RepostIcon } from "../../../components/icons"; + +export function RelayShareButton({ + relay, + ...props +}: { relay: string } & Omit) { + const toast = useToast(); + const { requestSignature } = useSigningContext(); + + const recommendRelay = useCallback(async () => { + try { + const writeRelays = clientRelaysService.getWriteUrls(); + + const draft: DraftNostrEvent = { + kind: 2, + content: relay, + tags: [], + created_at: dayjs().unix(), + }; + + const signed = await requestSignature(draft); + const post = new NostrPublishAction("Share Relay", writeRelays, signed); + await post.onComplete; + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + }, []); + + return ( + } + aria-label="Recommend Relay" + title="Recommend Relay" + onClick={recommendRelay} + variant="ghost" + {...props} + /> + ); +} diff --git a/src/views/streams/components/status-badge.tsx b/src/views/streams/components/status-badge.tsx index 2253f03dd..50d13da41 100644 --- a/src/views/streams/components/status-badge.tsx +++ b/src/views/streams/components/status-badge.tsx @@ -8,13 +8,13 @@ export default function StreamStatusBadge({ switch (stream.status) { case "live": return ( - + live ); case "ended": return ( - + ended ); diff --git a/src/views/streams/components/streamer-cards.tsx b/src/views/streams/components/streamer-cards.tsx index aee0a67d3..be740aaec 100644 --- a/src/views/streams/components/streamer-cards.tsx +++ b/src/views/streams/components/streamer-cards.tsx @@ -34,7 +34,7 @@ function StreamerCard({ cord, relay, ...props }: { cord: string; relay?: string const link = card.tags.find((t) => t[0] === "r")?.[1]; return ( - + {image && } {title && ( @@ -42,10 +42,10 @@ function StreamerCard({ cord, relay, ...props }: { cord: string; relay?: string )} - + {link && ( - {link} + {!image && link} )} diff --git a/src/views/streams/stream/stream-chat/chat-message-form.tsx b/src/views/streams/stream/stream-chat/chat-message-form.tsx index 9e5542944..dc8463bf4 100644 --- a/src/views/streams/stream/stream-chat/chat-message-form.tsx +++ b/src/views/streams/stream/stream-chat/chat-message-form.tsx @@ -78,7 +78,7 @@ export default function ChatMessageForm({ stream }: { stream: ParsedStream }) { {zapModal.isOpen && ( { reset(); diff --git a/src/views/user/goals.tsx b/src/views/user/goals.tsx new file mode 100644 index 000000000..d33fc143f --- /dev/null +++ b/src/views/user/goals.tsx @@ -0,0 +1,36 @@ +import { useOutletContext } from "react-router-dom"; +import { Divider, Flex, Heading, SimpleGrid } from "@chakra-ui/react"; + +import { useAdditionalRelayContext } from "../../providers/additional-relay-context"; +import useTimelineLoader from "../../hooks/use-timeline-loader"; +import useSubject from "../../hooks/use-subject"; +import { getEventUID } from "../../helpers/nostr/events"; +import IntersectionObserverProvider from "../../providers/intersection-observer"; +import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; +import { GOAL_KIND } from "../../helpers/nostr/goal"; +import GoalCard from "../goals/components/goal-card"; + +export default function UserGoalsTab() { + const { pubkey } = useOutletContext() as { pubkey: string }; + const readRelays = useAdditionalRelayContext(); + + const timeline = useTimelineLoader(pubkey + "-goals", readRelays, { + authors: [pubkey], + kinds: [GOAL_KIND], + }); + const goals = useSubject(timeline.timeline); + + const callback = useTimelineCurserIntersectionCallback(timeline); + + return ( + + + + {goals.map((goal) => ( + + ))} + + + + ); +} diff --git a/src/views/user/index.tsx b/src/views/user/index.tsx index 329dff4b9..45112453f 100644 --- a/src/views/user/index.tsx +++ b/src/views/user/index.tsx @@ -50,6 +50,7 @@ const tabs = [ { label: "Following", path: "following" }, { label: "Likes", path: "likes" }, { label: "Relays", path: "relays" }, + { label: "Goals", path: "goals" }, { label: "Emoji Packs", path: "emojis" }, { label: "Reports", path: "reports" }, { label: "Followers", path: "followers" }, diff --git a/src/views/user/relays.tsx b/src/views/user/relays.tsx index dd7db5259..23ce8751d 100644 --- a/src/views/user/relays.tsx +++ b/src/views/user/relays.tsx @@ -9,11 +9,12 @@ import useSubject from "../../hooks/use-subject"; import { NostrEvent } from "../../types/nostr-event"; import RelayReviewNote from "../relays/components/relay-review-note"; import { RelayFavicon } from "../../components/relay-favicon"; -import { RelayDebugButton, RelayJoinAction, RelayMetadata, RelayShareButton } from "../relays/components/relay-card"; +import { RelayDebugButton, RelayJoinAction, RelayMetadata } from "../relays/components/relay-card"; import IntersectionObserverProvider from "../../providers/intersection-observer"; import { useTimelineCurserIntersectionCallback } from "../../hooks/use-timeline-cursor-intersection-callback"; import { useRelayInfo } from "../../hooks/use-relay-info"; import { ErrorBoundary } from "../../components/error-boundary"; +import { RelayShareButton } from "../relays/components/relay-share-button"; function Relay({ url, reviews }: { url: string; reviews: NostrEvent[] }) { const { info } = useRelayInfo(url);