Skip to content

Commit

Permalink
Add "q" tags for quoted notes
Browse files Browse the repository at this point in the history
bump nostr-tools
  • Loading branch information
hzrd149 committed Oct 9, 2024
1 parent efec513 commit 0e20544
Show file tree
Hide file tree
Showing 21 changed files with 488 additions and 267 deletions.
5 changes: 5 additions & 0 deletions .changeset/fluffy-games-travel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostrudel": minor
---

Add unknown notifications toggle
5 changes: 5 additions & 0 deletions .changeset/two-fireants-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostrudel": minor
---

Add "q" tags for quoted notes
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"nanoid": "^5.0.4",
"ngeohash": "^0.6.3",
"nostr-idb": "^2.1.6",
"nostr-tools": "^2.7.1",
"nostr-tools": "^2.7.2",
"nostr-wasm": "^0.1.0",
"prettier": "^3.2.5",
"react": "^18.2.0",
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 27 additions & 17 deletions src/classes/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,21 @@ import RelaySet from "./relay-set";
import clientRelaysService from "../services/client-relays";
import { getPubkeysMentionedInContent } from "../helpers/nostr/post";
import { TORRENT_COMMENT_KIND } from "../helpers/nostr/torrents";
import { STREAM_CHAT_MESSAGE_KIND } from "../helpers/nostr/stream";
import { MUTE_LIST_KIND, getPubkeysFromList } from "../helpers/nostr/lists";
import { eventStore, queryStore } from "../services/event-store";

export const typeSymbol = Symbol("notificationType");
export const NotificationTypeSymbol = Symbol("notificationType");

export enum NotificationType {
Reply = "reply",
Repost = "repost",
Zap = "zap",
Reaction = "reaction",
Mention = "mention",
Message = "message",
Quote = "quote",
}
export type CategorizedEvent = NostrEvent & { [typeSymbol]?: NotificationType };
export type CategorizedEvent = NostrEvent & { [NotificationTypeSymbol]?: NotificationType };

export default class AccountNotifications {
pubkey: string;
Expand All @@ -46,6 +47,8 @@ export default class AccountNotifications {
kinds.Zap,
TORRENT_COMMENT_KIND,
kinds.LongFormArticle,
kinds.EncryptedDirectMessage,
1111, //NIP-22
],
},
]),
Expand All @@ -57,27 +60,29 @@ export default class AccountNotifications {
private categorizeEvent(event: NostrEvent): CategorizedEvent {
const e = event as CategorizedEvent;

if (e[typeSymbol]) return e;
if (e[NotificationTypeSymbol]) return e;

if (event.kind === kinds.Zap) {
e[typeSymbol] = NotificationType.Zap;
e[NotificationTypeSymbol] = NotificationType.Zap;
} else if (event.kind === kinds.Reaction) {
e[typeSymbol] = NotificationType.Reaction;
e[NotificationTypeSymbol] = NotificationType.Reaction;
} else if (isRepost(event)) {
e[typeSymbol] = NotificationType.Repost;
e[NotificationTypeSymbol] = NotificationType.Repost;
} else if (event.kind === kinds.EncryptedDirectMessage) {
e[NotificationTypeSymbol] = NotificationType.Message;
} else if (
event.kind === kinds.ShortTextNote ||
event.kind === TORRENT_COMMENT_KIND ||
event.kind === STREAM_CHAT_MESSAGE_KIND ||
event.kind === kinds.LiveChatMessage ||
event.kind === kinds.LongFormArticle
) {
// is the "p" tag directly mentioned in the content
const isMentioned = isPTagMentionedInContent(event, this.pubkey);
// is the pubkey mentioned in any way in the content
const isQuoted = getPubkeysMentionedInContent(event.content).includes(this.pubkey);
const isMentioned = getPubkeysMentionedInContent(event.content, true).includes(this.pubkey);
const isQuote = event.tags.some((t) => t[0] === "q" && t[3] === this.pubkey);

if (isMentioned || isQuoted) e[typeSymbol] = NotificationType.Mention;
else if (isReply(event)) e[typeSymbol] = NotificationType.Reply;
if (isMentioned) e[NotificationTypeSymbol] = NotificationType.Mention;
else if (isQuote) e[NotificationTypeSymbol] = NotificationType.Quote;
else if (isReply(event)) e[NotificationTypeSymbol] = NotificationType.Reply;
}
return e;
}
Expand All @@ -89,7 +94,14 @@ export default class AccountNotifications {
singleEventService.requestEvent(eventId, RelaySet.from(clientRelaysService.readRelays.value, relays));
};

switch (e[typeSymbol]) {
// load event quotes
const quotes = event.tags.filter((t) => t[0] === "q" && t[1]);
for (const tag of quotes) {
loadEvent(tag[1], tag[2] ? [tag[2]] : undefined);
}

// load reactions and replies
switch (e[NotificationTypeSymbol]) {
case NotificationType.Reply:
const refs = getThreadReferences(e);
if (refs.reply?.e?.id) loadEvent(refs.reply.e.id, refs.reply.e.relays);
Expand All @@ -105,8 +117,6 @@ export default class AccountNotifications {
}

private filterEvent(event: CategorizedEvent) {
if (!Object.hasOwn(event, typeSymbol)) return false;

// ignore if muted
// TODO: this should be moved somewhere more performant
const muteList = eventStore.getReplaceable(MUTE_LIST_KIND, this.pubkey);
Expand All @@ -118,7 +128,7 @@ export default class AccountNotifications {

const e = event as CategorizedEvent;

switch (e[typeSymbol]) {
switch (e[NotificationTypeSymbol]) {
case NotificationType.Reply:
const refs = getThreadReferences(e);
if (!refs.reply?.e?.id) return false;
Expand Down
26 changes: 22 additions & 4 deletions src/helpers/nostr/post.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EventTemplate, nip19 } from "nostr-tools";
import { EventTemplate, nip18, nip19 } from "nostr-tools";
import { NostrEvent, Tag } from "../../types/nostr-event";
import { getMatchEmoji, getMatchHashtag, getMatchNostrLink } from "../regexp";
import { getThreadReferences } from "./event";
Expand All @@ -9,6 +9,7 @@ import { unique } from "../array";

import relayHintService from "../../services/event-relay-hint";
import userMailboxesService from "../../services/user-mailboxes";
import { EventPointer } from "nostr-tools/nip19";

function addTag(tags: Tag[], tag: Tag, overwrite = false) {
if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1])) {
Expand Down Expand Up @@ -39,6 +40,22 @@ function AddEtag(tags: Tag[], eventId: string, relayHint?: string, type?: string
return [...tags, tag];
}

function AddQuotePointerTag(tags: Tag[], pointer: EventPointer) {
const hint = pointer.relays?.[0] || relayHintService.getEventPointerRelayHint(pointer.id) || "";

const tag: string[] = ["q", pointer.id, hint];
if (pointer.author) tag.push(pointer.author);

if (tags.some((t) => t[0] === tag[0] && t[1] === tag[1] && t[3] === tag[3])) {
// replace the tag
return tags.map((t) => {
if (t[0] === tag[0] && t[1] === tag[1]) return tag;
return t;
});
}
return [...tags, tag];
}

/** adds the "root" and "reply" E tags */
export function addReplyTags(draft: EventTemplate, replyTo: NostrEvent) {
const updated: EventTemplate = { ...draft, tags: Array.from(draft.tags) };
Expand Down Expand Up @@ -70,7 +87,7 @@ export function correctContentMentions(content: string) {
return content.replace(/(\s|^)(?:@)?(npub1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{58})/gi, "$1nostr:$2");
}

export function getPubkeysMentionedInContent(content: string) {
export function getPubkeysMentionedInContent(content: string, direct = false) {
const matched = content.matchAll(getMatchNostrLink());

const pubkeys: string[] = [];
Expand All @@ -87,10 +104,10 @@ export function getPubkeysMentionedInContent(content: string) {
pubkeys.push(decode.data.pubkey);
break;
case "nevent":
if (decode.data.author) pubkeys.push(decode.data.author);
if (decode.data.author && !direct) pubkeys.push(decode.data.author);
break;
case "naddr":
if (decode.data.pubkey) pubkeys.push(decode.data.pubkey);
if (decode.data.pubkey && !direct) pubkeys.push(decode.data.pubkey);
break;
}
}
Expand Down Expand Up @@ -130,6 +147,7 @@ export function ensureTagContentMentions(draft: EventTemplate) {

for (const pointer of mentions) {
updated.tags = AddEtag(updated.tags, pointer.id, pointer.relays?.[0] ?? "", "mention", false);
updated.tags = AddQuotePointerTag(updated.tags, pointer);
}

return updated;
Expand Down
15 changes: 11 additions & 4 deletions src/hooks/use-thread-timeline-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,22 @@ export default function useThreadTimelineLoader(

const readRelays = useMemo(() => unique([...relays, ...(rootPointer?.relays ?? [])]), [relays, rootPointer?.relays]);

const kindArr = kinds ? (kinds.length > 0 ? kinds : undefined) : [eventKinds.ShortTextNote];
const timelineId = `${rootPointer?.id}-thread`;
const timeline = useTimelineLoader(
timelineId,
readRelays,
rootPointer
? {
"#e": [rootPointer.id],
kinds: kinds ? (kinds.length > 0 ? kinds : undefined) : [eventKinds.ShortTextNote],
}
? [
{
"#e": [rootPointer.id],
kinds: kindArr,
},
{
"#q": [rootPointer.id],
kinds: kindArr,
},
]
: undefined,
);

Expand Down
8 changes: 4 additions & 4 deletions src/views/launchpad/components/notifications-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { getEventUID } from "nostr-idb";

import KeyboardShortcut from "../../../components/keyboard-shortcut";
import { useNotifications } from "../../../providers/global/notifications-provider";
import { NotificationType, typeSymbol } from "../../../classes/notifications";
import { NotificationType, NotificationTypeSymbol } from "../../../classes/notifications";
import NotificationItem from "../../notifications/components/notification-item";
import { ErrorBoundary } from "../../../components/error-boundary";
import { useObservable } from "../../../hooks/use-observable";
Expand All @@ -18,9 +18,9 @@ export default function NotificationsCard({ ...props }: Omit<CardProps, "childre
const events =
useObservable(notifications?.timeline)?.filter(
(event) =>
event[typeSymbol] === NotificationType.Mention ||
event[typeSymbol] === NotificationType.Reply ||
event[typeSymbol] === NotificationType.Zap,
event[NotificationTypeSymbol] === NotificationType.Mention ||
event[NotificationTypeSymbol] === NotificationType.Reply ||
event[NotificationTypeSymbol] === NotificationType.Zap,
) ?? [];

const limit = events.length > 20 ? events.slice(0, 20) : events;
Expand Down
41 changes: 41 additions & 0 deletions src/views/notifications/components/mention-notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { ReactNode, forwardRef } from "react";
import { kinds, NostrEvent } from "nostr-tools";

import { EmbedEvent } from "../../../components/embed-event";
import { AtIcon } from "../../../components/icons";
import NotificationIconEntry from "./notification-icon-entry";
import { TimelineNote } from "../../../components/note/timeline-note";
import ArticleCard from "../../articles/components/article-card";

const MentionNotification = forwardRef<HTMLDivElement, { event: NostrEvent; onClick?: () => void }>(
({ event, onClick }, ref) => {
let content: ReactNode;
switch (event.kind) {
case kinds.LongFormArticle:
content = <ArticleCard article={event} />;
break;
case kinds.ShortTextNote:
content = <TimelineNote event={event} showReplyButton />;
break;
default:
content = <EmbedEvent event={event} />;
break;
}

return (
<NotificationIconEntry
ref={ref}
icon={<AtIcon boxSize={6} color="purple.400" />}
id={event.id}
pubkey={event.pubkey}
timestamp={event.created_at}
summary={event.content}
onClick={onClick}
>
{content}
</NotificationIconEntry>
);
},
);

export default MentionNotification;
29 changes: 29 additions & 0 deletions src/views/notifications/components/message-notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NostrEvent } from "nostr-tools";
import { forwardRef } from "react";
import { Text } from "@chakra-ui/react";

import NotificationIconEntry from "./notification-icon-entry";
import UserAvatar from "../../../components/user/user-avatar";
import UserName from "../../../components/user/user-name";
import { EmbedEvent } from "../../../components/embed-event";
import { DirectMessagesIcon } from "../../../components/icons";

const MessageNotification = forwardRef<HTMLDivElement, { event: NostrEvent; onClick?: () => void }>(
({ event, onClick }, ref) => {
return (
<NotificationIconEntry
ref={ref}
icon={<DirectMessagesIcon boxSize={6} color="gray.500" />}
id={event.id}
pubkey={event.pubkey}
timestamp={event.created_at}
summary={<>Direct Messaged</>}
onClick={onClick}
>
<EmbedEvent event={event} />
</NotificationIconEntry>
);
},
);

export default MessageNotification;
Loading

0 comments on commit 0e20544

Please sign in to comment.