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 && (