diff --git a/.changeset/mean-steaks-notice.md b/.changeset/mean-steaks-notice.md new file mode 100644 index 000000000..6557e41e6 --- /dev/null +++ b/.changeset/mean-steaks-notice.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Support pinning articles diff --git a/src/components/common-menu-items/pin-note.tsx b/src/components/common-menu-items/pin-event.tsx similarity index 62% rename from src/components/common-menu-items/pin-note.tsx rename to src/components/common-menu-items/pin-event.tsx index fca9c1f25..6f1ae1fda 100644 --- a/src/components/common-menu-items/pin-note.tsx +++ b/src/components/common-menu-items/pin-event.tsx @@ -1,21 +1,30 @@ import { useCallback, useState } from "react"; import { MenuItem } from "@chakra-ui/react"; import dayjs from "dayjs"; +import { kinds } from "nostr-tools"; +import { getEventUID } from "nostr-idb"; import useCurrentAccount from "../../hooks/use-current-account"; import useUserPinList from "../../hooks/use-user-pin-list"; -import { DraftNostrEvent, NostrEvent, isETag } from "../../types/nostr-event"; -import { PIN_LIST_KIND, listAddEvent, listRemoveEvent } from "../../helpers/nostr/lists"; +import { DraftNostrEvent, NostrEvent } from "../../types/nostr-event"; +import { PIN_LIST_KIND, isEventInList, listAddEvent, listRemoveEvent } from "../../helpers/nostr/lists"; import { PinIcon } from "../icons"; import { usePublishEvent } from "../../providers/global/publish-provider"; -export default function PinNoteMenuItem({ event }: { event: NostrEvent }) { +export default function PinEventMenuItem({ event }: { event: NostrEvent }) { const publish = usePublishEvent(); const account = useCurrentAccount(); const { list } = useUserPinList(account?.pubkey); - const isPinned = list?.tags.some((t) => isETag(t) && t[1] === event.id) ?? false; - const label = isPinned ? "Unpin Note" : "Pin Note"; + const isPinned = isEventInList(list, event); + + let type = "Note"; + switch (event.kind) { + case kinds.LongFormArticle: + type = "Article"; + break; + } + const label = isPinned ? `Unpin ${type}` : `Pin ${type}`; const [loading, setLoading] = useState(false); const togglePin = useCallback(async () => { @@ -27,8 +36,8 @@ export default function PinNoteMenuItem({ event }: { event: NostrEvent }) { tags: list?.tags ? Array.from(list.tags) : [], }; - if (isPinned) draft = listRemoveEvent(draft, event.id); - else draft = listAddEvent(draft, event.id); + if (isPinned) draft = listRemoveEvent(draft, event); + else draft = listAddEvent(draft, event); await publish(label, draft); setLoading(false); diff --git a/src/components/note/bookmark-event.tsx b/src/components/note/bookmark-event.tsx index cbb841625..49fde3dfc 100644 --- a/src/components/note/bookmark-event.tsx +++ b/src/components/note/bookmark-event.tsx @@ -51,10 +51,10 @@ export default function BookmarkEventButton({ const removeFromList = lists.find((list) => inLists.includes(list) && !cords.includes(getEventCoordinate(list))); if (addToList) { - const draft = listAddEvent(addToList, event.id); + const draft = listAddEvent(addToList, event); await publish("Add to list", draft); } else if (removeFromList) { - const draft = listRemoveEvent(removeFromList, event.id); + const draft = listRemoveEvent(removeFromList, event); await publish("Remove from list", draft); } setLoading(false); diff --git a/src/components/note/note-menu.tsx b/src/components/note/note-menu.tsx index 7c1568b2f..13bafb7e0 100644 --- a/src/components/note/note-menu.tsx +++ b/src/components/note/note-menu.tsx @@ -7,7 +7,7 @@ import { NostrEvent } from "../../types/nostr-event"; import { DotsMenuButton, MenuIconButtonProps } from "../dots-menu-button"; import NoteTranslationModal from "../../views/tools/transform-note/translation"; import Translate01 from "../icons/translate-01"; -import PinNoteMenuItem from "../common-menu-items/pin-note"; +import PinEventMenuItem from "../common-menu-items/pin-event"; import ShareLinkMenuItem from "../common-menu-items/share-link"; import OpenInAppMenuItem from "../common-menu-items/open-in-app"; import MuteUserMenuItem from "../common-menu-items/mute-user"; @@ -47,7 +47,7 @@ export default function NoteMenu({ event, ...props }: { event: NostrEvent } & Om }> Broadcast - + diff --git a/src/helpers/nip19.ts b/src/helpers/nip19.ts index af177417d..05a3d408c 100644 --- a/src/helpers/nip19.ts +++ b/src/helpers/nip19.ts @@ -2,6 +2,7 @@ import { getPublicKey, nip19 } from "nostr-tools"; import { Tag, isATag, isETag, isPTag } from "../types/nostr-event"; import { safeRelayUrls } from "./relay"; +import { parseCoordinate } from "./nostr/event"; export function isHex(str?: string) { if (str?.match(/^[0-9a-f]+$/i)) return true; @@ -61,39 +62,30 @@ export function encodeDecodeResult(result: nip19.DecodeResult) { } export function getPointerFromTag(tag: Tag): nip19.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]; + switch (tag[0]) { + case "e": { + if (!tag[1]) return null; - if (!kind) return null; - if (!pubkey) return null; - if (!d) return null; + const pointer: nip19.DecodeResult = { type: "nevent", data: { id: tag[1] } }; + if (tag[2]) pointer.data.relays = [tag[2]]; + return pointer; + } + case "a": { + const parsed = parseCoordinate(tag[1]); + if (!parsed?.identifier) 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 } }; + const pointer: nip19.DecodeResult = { + type: "naddr", + data: { pubkey: parsed.pubkey, identifier: parsed.identifier, kind: parsed.kind }, + }; + if (tag[2]) pointer.data.relays = [tag[2]]; + return pointer; + } + case "p": { + 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/lists.ts b/src/helpers/nostr/lists.ts index 5dcdfe9c7..9f8cbfb14 100644 --- a/src/helpers/nostr/lists.ts +++ b/src/helpers/nostr/lists.ts @@ -1,9 +1,10 @@ import dayjs from "dayjs"; -import { kinds, nip19 } from "nostr-tools"; +import { EventTemplate, NostrEvent, kinds, nip19 } from "nostr-tools"; -import { DraftNostrEvent, NostrEvent, PTag, isATag, isDTag, isETag, isPTag, isRTag } from "../../types/nostr-event"; -import { parseCoordinate, replaceOrAddSimpleTag } from "./event"; +import { PTag, isATag, isDTag, isPTag, isRTag } from "../../types/nostr-event"; +import { getEventCoordinate, replaceOrAddSimpleTag } from "./event"; import { getRelayVariations, safeRelayUrls } from "../relay"; +import { getPointerFromTag } from "../nip19"; export const MUTE_LIST_KIND = kinds.Mutelist; export const PIN_LIST_KIND = kinds.Pinlist; @@ -27,13 +28,13 @@ export function getListName(event: NostrEvent) { event.tags.find(isDTag)?.[1] ); } -export function setListName(draft: DraftNostrEvent, name: string) { +export function setListName(draft: EventTemplate, name: string) { replaceOrAddSimpleTag(draft, "name", name); } export function getListDescription(event: NostrEvent) { return event.tags.find((t) => t[0] === "description")?.[1]; } -export function setListDescription(draft: DraftNostrEvent, description: string) { +export function setListDescription(draft: EventTemplate, description: string) { replaceOrAddSimpleTag(draft, "description", description); } @@ -54,7 +55,7 @@ export function isSpecialListKind(kind: number) { ); } -export function cloneList(list: NostrEvent, keepCreatedAt = false): DraftNostrEvent { +export function cloneList(list: NostrEvent, keepCreatedAt = false): EventTemplate { return { kind: list.kind, content: list.content, @@ -63,35 +64,33 @@ export function cloneList(list: NostrEvent, keepCreatedAt = false): DraftNostrEv }; } -export function getPubkeysFromList(event: NostrEvent | DraftNostrEvent) { +export function getPubkeysFromList(event: NostrEvent | EventTemplate) { return event.tags.filter(isPTag).map((t) => ({ pubkey: t[1], relay: t[2], petname: t[3] })); } -export function getEventPointersFromList(event: NostrEvent | DraftNostrEvent): nip19.EventPointer[] { - return event.tags.filter(isETag).map((t) => (t[2] ? { id: t[1], relays: [t[2]] } : { id: t[1] })); -} -export function getReferencesFromList(event: NostrEvent | DraftNostrEvent) { +export function getReferencesFromList(event: NostrEvent | EventTemplate) { return event.tags.filter(isRTag).map((t) => ({ url: t[1], petname: t[2] })); } -export function getRelaysFromList(event: NostrEvent | DraftNostrEvent) { +export function getRelaysFromList(event: NostrEvent | EventTemplate) { if (event.kind === kinds.RelayList) return safeRelayUrls(event.tags.filter(isRTag).map((t) => t[1])); else return safeRelayUrls(event.tags.filter((t) => t[0] === "relay" && t[1]).map((t) => t[1]) as string[]); } -export function getCoordinatesFromList(event: NostrEvent | DraftNostrEvent) { +export function getCoordinatesFromList(event: NostrEvent | EventTemplate) { return event.tags.filter(isATag).map((t) => ({ coordinate: t[1], relay: t[2] })); } -export function getAddressPointersFromList(event: NostrEvent | DraftNostrEvent): nip19.AddressPointer[] { - const pointers: nip19.AddressPointer[] = []; - - for (const tag of event.tags) { - if (!tag[1]) continue; - const relay = tag[2]; - const parsed = parseCoordinate(tag[1]); - if (!parsed?.identifier) continue; - - pointers.push({ ...parsed, identifier: parsed?.identifier, relays: relay ? [relay] : undefined }); - } - - return pointers; +export function getEventPointersFromList(event: NostrEvent | EventTemplate): nip19.EventPointer[] { + return event.tags + .map(getPointerFromTag) + .filter((r) => r?.type === "nevent") + .map((r) => r.data); +} +export function getAddressPointersFromList(event: NostrEvent | EventTemplate): nip19.AddressPointer[] { + return event.tags + .map(getPointerFromTag) + .filter((r) => r?.type === "naddr") + .map((r) => r.data); +} +export function getPointersFromList(event: NostrEvent | EventTemplate) { + return event.tags.map(getPointerFromTag).filter((r) => r !== null); } export function isRelayInList(list: NostrEvent, relay: string) { @@ -102,8 +101,16 @@ export function isPubkeyInList(list?: NostrEvent, pubkey?: string) { if (!pubkey || !list) return false; return list.tags.some((t) => t[0] === "p" && t[1] === pubkey); } +export function isEventInList(list?: NostrEvent, event?: NostrEvent) { + if (!event || !list) return false; -export function createEmptyContactList(): DraftNostrEvent { + if (kinds.isParameterizedReplaceableKind(event.kind)) { + const cord = getEventCoordinate(event); + return list.tags.some((t) => t[0] === "a" && t[1] === cord); + } else return list.tags.some((t) => t[0] === "e" && t[1] === event.id); +} + +export function createEmptyContactList(): EventTemplate { return { created_at: dayjs().unix(), content: "", @@ -113,11 +120,11 @@ export function createEmptyContactList(): DraftNostrEvent { } export function listAddPerson( - list: NostrEvent | DraftNostrEvent, + list: NostrEvent | EventTemplate, pubkey: string, relay?: string, petname?: string, -): DraftNostrEvent { +): EventTemplate { if (list.tags.some((t) => t[0] === "p" && t[1] === pubkey)) throw new Error("Person already in list"); const pTag: PTag = ["p", pubkey, relay ?? "", petname ?? ""]; while (pTag[pTag.length - 1] === "") pTag.pop(); @@ -130,7 +137,7 @@ export function listAddPerson( }; } -export function listRemovePerson(list: NostrEvent | DraftNostrEvent, pubkey: string): DraftNostrEvent { +export function listRemovePerson(list: NostrEvent | EventTemplate, pubkey: string): EventTemplate { return { created_at: dayjs().unix(), kind: list.kind, @@ -139,26 +146,32 @@ export function listRemovePerson(list: NostrEvent | DraftNostrEvent, pubkey: str }; } -export function listAddEvent(list: NostrEvent | DraftNostrEvent, event: string, relay?: string): DraftNostrEvent { - if (list.tags.some((t) => t[0] === "e" && t[1] === event)) throw new Error("Event already in list"); +export function listAddEvent(list: NostrEvent | EventTemplate, event: NostrEvent, relay?: string): EventTemplate { + const tag = kinds.isParameterizedReplaceableKind(event.kind) ? ["a", getEventCoordinate(event)] : ["e", event.id]; + if (relay) tag.push(relay); + + if (list.tags.some((t) => t[0] === tag[0] && t[1] === tag[1])) throw new Error("Event already in list"); + return { created_at: dayjs().unix(), kind: list.kind, content: list.content, - tags: [...list.tags, relay ? ["e", event, relay] : ["e", event]], + tags: [...list.tags, tag], }; } -export function listRemoveEvent(list: NostrEvent | DraftNostrEvent, event: string): DraftNostrEvent { +export function listRemoveEvent(list: NostrEvent | EventTemplate, event: NostrEvent): EventTemplate { + const tag = kinds.isParameterizedReplaceableKind(event.kind) ? ["a", getEventCoordinate(event)] : ["e", event.id]; + return { created_at: dayjs().unix(), kind: list.kind, content: list.content, - tags: list.tags.filter((t) => !(t[0] === "e" && t[1] === event)), + tags: list.tags.filter((t) => !(t[0] === tag[0] && t[1] === tag[1])), }; } -export function listAddRelay(list: NostrEvent | DraftNostrEvent, relay: string): DraftNostrEvent { +export function listAddRelay(list: NostrEvent | EventTemplate, relay: string): EventTemplate { if (list.tags.some((t) => t[0] === "e" && t[1] === relay)) throw new Error("Relay already in list"); return { created_at: dayjs().unix(), @@ -168,7 +181,7 @@ export function listAddRelay(list: NostrEvent | DraftNostrEvent, relay: string): }; } -export function listRemoveRelay(list: NostrEvent | DraftNostrEvent, relay: string): DraftNostrEvent { +export function listRemoveRelay(list: NostrEvent | EventTemplate, relay: string): EventTemplate { return { created_at: dayjs().unix(), kind: list.kind, @@ -177,11 +190,7 @@ export function listRemoveRelay(list: NostrEvent | DraftNostrEvent, relay: strin }; } -export function listAddCoordinate( - list: NostrEvent | DraftNostrEvent, - coordinate: string, - relay?: string, -): DraftNostrEvent { +export function listAddCoordinate(list: NostrEvent | EventTemplate, coordinate: string, relay?: string): EventTemplate { if (list.tags.some((t) => t[0] === "a" && t[1] === coordinate)) throw new Error("Event already in list"); return { @@ -192,7 +201,7 @@ export function listAddCoordinate( }; } -export function listRemoveCoordinate(list: NostrEvent | DraftNostrEvent, coordinate: string): DraftNostrEvent { +export function listRemoveCoordinate(list: NostrEvent | EventTemplate, coordinate: string): EventTemplate { return { created_at: dayjs().unix(), kind: list.kind, diff --git a/src/hooks/use-event-bookmark-actions.ts b/src/hooks/use-event-bookmark-actions.ts index c9d5c415d..5c78f5026 100644 --- a/src/hooks/use-event-bookmark-actions.ts +++ b/src/hooks/use-event-bookmark-actions.ts @@ -37,7 +37,7 @@ export default function useEventBookmarkActions(event: NostrEvent) { if (!isBookmarked) return; if (isReplaceable(event.kind)) draft = listRemoveCoordinate(draft, getEventCoordinate(event)); - else draft = listRemoveEvent(draft, event.id); + else draft = listRemoveEvent(draft, event); await publish("Remove Bookmark", draft); setLoading(false); @@ -54,7 +54,7 @@ export default function useEventBookmarkActions(event: NostrEvent) { if (isBookmarked) return; if (isReplaceable(event.kind)) draft = listAddCoordinate(draft, getEventCoordinate(event)); - else draft = listAddEvent(draft, event.id); + else draft = listAddEvent(draft, event); await publish("Bookmark Note", draft); setLoading(false); diff --git a/src/hooks/use-user-pin-list.ts b/src/hooks/use-user-pin-list.ts index 683402ae4..0102f117f 100644 --- a/src/hooks/use-user-pin-list.ts +++ b/src/hooks/use-user-pin-list.ts @@ -1,4 +1,4 @@ -import { PIN_LIST_KIND, getEventPointersFromList } from "../helpers/nostr/lists"; +import { PIN_LIST_KIND, getPointersFromList } from "../helpers/nostr/lists"; import { RequestOptions } from "../services/replaceable-events"; import useCurrentAccount from "./use-current-account"; import useReplaceableEvent from "./use-replaceable-event"; @@ -9,7 +9,7 @@ export default function useUserPinList(pubkey?: string, relays: string[] = [], o const list = useReplaceableEvent(key ? { kind: PIN_LIST_KIND, pubkey: key } : undefined, relays, opts); - const events = list ? getEventPointersFromList(list) : []; + const pointers = list ? getPointersFromList(list) : []; - return { list, events }; + return { list, pointers }; } diff --git a/src/views/articles/components/article-menu.tsx b/src/views/articles/components/article-menu.tsx index a822f4aa2..48edd2128 100644 --- a/src/views/articles/components/article-menu.tsx +++ b/src/views/articles/components/article-menu.tsx @@ -14,6 +14,7 @@ import Recording02 from "../../../components/icons/recording-02"; import Translate01 from "../../../components/icons/translate-01"; import { BroadcastEventIcon } from "../../../components/icons"; import DebugEventMenuItem from "../../../components/debug-modal/debug-event-menu-item"; +import PinEventMenuItem from "../../../components/common-menu-items/pin-event"; export default function ArticleMenu({ article, @@ -34,6 +35,7 @@ export default function ArticleMenu({ + {/* } to={`/tools/transform/${address}?tab=tts`}> Text to speech @@ -41,7 +43,6 @@ export default function ArticleMenu({ } to={`/tools/transform/${address}?tab=translation`}> Translate */} - }> Broadcast diff --git a/src/views/channels/components/channel-join-button.tsx b/src/views/channels/components/channel-join-button.tsx index 24f96b625..56ec3bfc9 100644 --- a/src/views/channels/components/channel-join-button.tsx +++ b/src/views/channels/components/channel-join-button.tsx @@ -28,9 +28,9 @@ export default function ChannelJoinButton({ let draft: DraftNostrEvent; if (isSubscribed) { - draft = listRemoveEvent(favList, channel.id); + draft = listRemoveEvent(favList, channel); } else { - draft = listAddEvent(favList, channel.id); + draft = listAddEvent(favList, channel); } await publish(isSubscribed ? "Leave Channel" : "Join Channel", draft); diff --git a/src/views/user/about/user-pinned-events.tsx b/src/views/user/about/user-pinned-events.tsx index 0d93188e1..a37edc74e 100644 --- a/src/views/user/about/user-pinned-events.tsx +++ b/src/views/user/about/user-pinned-events.tsx @@ -6,20 +6,20 @@ import { EmbedEventPointer } from "../../../components/embed-event"; export default function UserPinnedEvents({ pubkey }: { pubkey: string }) { const contextRelays = useAdditionalRelayContext(); - const { events, list } = useUserPinList(pubkey, contextRelays, { alwaysRequest: true }); + const { pointers } = useUserPinList(pubkey, contextRelays, { alwaysRequest: true }); const showAll = useDisclosure(); - if (events.length === 0) return null; + if (pointers.length === 0) return null; return ( Pinned - {(showAll.isOpen ? events : events.slice(0, 2)).map((event) => ( - + {(showAll.isOpen ? pointers : pointers.slice(0, 2)).map((pointer) => ( + ))} - {!showAll.isOpen && events.length > 2 && ( + {!showAll.isOpen && pointers.length > 2 && (