From 003839a5e7820a4bd7be72bacc3c4c9d9e83c201 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 24 Aug 2026 10:42:01 +0200 Subject: [PATCH 1/6] feat: ask the relays what is addressed to one key --- packages/nostr/src/discussion.ts | 76 +---- packages/nostr/src/index.ts | 22 ++ packages/nostr/src/notifications.ts | 336 +++++++++++++++++++++ packages/nostr/src/subscribe.ts | 71 +++++ packages/nostr/test/notifications.test.ts | 338 ++++++++++++++++++++++ 5 files changed, 778 insertions(+), 65 deletions(-) create mode 100644 packages/nostr/src/notifications.ts create mode 100644 packages/nostr/src/subscribe.ts create mode 100644 packages/nostr/test/notifications.test.ts diff --git a/packages/nostr/src/discussion.ts b/packages/nostr/src/discussion.ts index 4f9954d..6862b71 100644 --- a/packages/nostr/src/discussion.ts +++ b/packages/nostr/src/discussion.ts @@ -13,7 +13,8 @@ import { } from "./nip25"; import { parseZapReceipt, ZAP_RECEIPT_KIND, type ZapReceipt } from "./nip57"; import { fetchRelayList, type RelayListOptions } from "./nip65"; -import { queryRelays, type RelayOptions, relayPool, relaySet } from "./pool"; +import { queryRelays, type RelayOptions, relaySet } from "./pool"; +import { inChunks, openWidening, type Subscription, without } from "./subscribe"; /** * Where a conversation about a specification is. `relay.ditto.pub` is the one @@ -31,9 +32,6 @@ export const DISCUSSION_RELAYS = [ "wss://relay.nmail.li", ]; -/** A filter with a thousand ids in it is refused by relays that bound their inputs. */ -const MAX_IDS_PER_FILTER = 200; - /** * `#A` alone catches every comment seen in the wild. The other three are asked * for on the same subscription anyway: `#a` for the client that scopes a thread @@ -63,17 +61,11 @@ export const discussionFilters = (coordinate: string, specEventId: string): Filt * the comment it answers: asked for by coordinate it does not exist, and asked * for by its parent it is an ordinary part of the conversation. */ -export const referenceFilters = (ids: string[]): Filter[] => { - const unique = [...new Set(ids)]; - const filters: Filter[] = []; - for (let index = 0; index < unique.length; index += MAX_IDS_PER_FILTER) { - filters.push({ - kinds: [COMMENT_KIND, REACTION_KIND, ZAP_RECEIPT_KIND, DELETION_KIND], - "#e": unique.slice(index, index + MAX_IDS_PER_FILTER), - }); - } - return filters; -}; +export const referenceFilters = (ids: string[]): Filter[] => + inChunks(ids).map((chunk) => ({ + kinds: [COMMENT_KIND, REACTION_KIND, ZAP_RECEIPT_KIND, DELETION_KIND], + "#e": chunk, + })); export type Discussion = { comments: Comment[]; @@ -258,10 +250,7 @@ export const fetchDiscussion = async ( }); }; -const without = (relays: string[], already: string[]): string[] => - relays.filter((relay) => !already.includes(relay)); - -export type DiscussionSubscription = { close: () => void }; +export type DiscussionSubscription = Subscription; /** * The conversation as it happens. A page holding this open sees a comment posted @@ -269,9 +258,7 @@ export type DiscussionSubscription = { close: () => void }; * which is the whole argument for relays being the source of truth. * * Subscribing starts on the relays already known and widens to the author's own - * once their list resolves, rather than waiting for it: the conversation should - * be on screen while that lookup is still happening, and the relays it adds send - * what they have the moment they are asked. + * once their list resolves, rather than waiting for it. * * The second pass is left to the caller: it depends on ids that arrive over * time, and only the caller knows which of them it has already asked about. @@ -284,7 +271,7 @@ export const subscribeDiscussion = ( const filters = discussionFilters(pointer.coordinate, pointer.specEventId); const known = relaysFor(pointer, options); - const subscription = open(known, filters, onEvent, options); + const subscription = openWidening(known, filters, onEvent, options); void outboxOf(pointer, options).then((relays) => subscription.widen(without(relays, known), options.onEose === undefined), ); @@ -307,50 +294,9 @@ export const subscribeReferences = ( const filters = referenceFilters(ids); const known = relaysFor(pointer, options); - const subscription = open(known, filters, onEvent, options); + const subscription = openWidening(known, filters, onEvent, options); void outboxOf(pointer, options).then((relays) => subscription.widen(without(relays, known), true), ); return subscription; }; - -type WideningSubscription = DiscussionSubscription & { - /** Adds relays to a subscription already running, unless it has been closed. */ - widen: (relays: string[], quiet: boolean) => void; -}; - -const open = ( - relays: string[], - filters: Filter[], - onEvent: (event: NostrEvent) => void, - options: DiscussionOptions & { onEose?: () => void }, -): WideningSubscription => { - const pool = options.pool ?? relayPool(); - const closers: { close: () => void }[] = []; - let closed = false; - - const subscribe = (to: string[], onEose: (() => void) | undefined) => { - if (closed || to.length === 0) return; - for (const filter of filters) { - closers.push( - pool.subscribe(to, filter, { - onevent: (event) => onEvent(event as NostrEvent), - oneose: onEose, - }), - ); - } - }; - - subscribe(relays, options.onEose); - - return { - // The widening relays do not report an end of stored events: the caller was - // told the conversation had arrived once already, and saying so again would - // put a page that has finished loading back into loading. - widen: (more, quiet) => subscribe(more, quiet ? undefined : options.onEose), - close: () => { - closed = true; - for (const closer of closers) closer.close(); - }, - }; -}; diff --git a/packages/nostr/src/index.ts b/packages/nostr/src/index.ts index 600ac72..78699dc 100644 --- a/packages/nostr/src/index.ts +++ b/packages/nostr/src/index.ts @@ -155,6 +155,20 @@ export { selectRelayLists, writeRelaysOf, } from "./nip65"; +export { + copyFilters, + MAX_NOTICES, + type Notice, + type NoticeKind, + type NoticeOptions, + type NoticeScope, + notificationFilters, + sortNotices, + subscribeCopies, + subscribeNotices, + subscribeRetractions, + targetFilters, +} from "./notifications"; export { closeRelayPool, queryRelays, @@ -206,3 +220,11 @@ export { toIdentifier, withdrawSpec, } from "./spec"; +export { + inChunks, + MAX_IDS_PER_FILTER, + openWidening, + type Subscription, + type WideningSubscription, + without, +} from "./subscribe"; diff --git a/packages/nostr/src/notifications.ts b/packages/nostr/src/notifications.ts new file mode 100644 index 0000000..66231ac --- /dev/null +++ b/packages/nostr/src/notifications.ts @@ -0,0 +1,336 @@ +import type { Filter } from "nostr-tools/filter"; +import { parseCoordinate, toCoordinate } from "./address"; +import { DISCUSSION_RELAYS } from "./discussion"; +import { type NostrEvent, SPEC_KIND, tagValue } from "./event"; +import { COMMENT_KIND, type Comment, parseComment } from "./nip22"; +import { + DELETION_KIND, + type Deletion, + parseDeletion, + parseReaction, + REACTION_KIND, + type Reaction, + retractions, +} from "./nip25"; +import { parseZapReceipt, ZAP_RECEIPT_KIND, type ZapReceipt } from "./nip57"; +import { type RelayOptions, relaySet } from "./pool"; +import { DEFAULT_RELAYS, READ_RELAYS } from "./relay"; +import type { Spec } from "./spec"; +import { inChunks, openWidening, type Subscription, without } from "./subscribe"; + +/** What one page of news can be before the rest is history rather than news. */ +export const MAX_NOTICES = 200; + +/** + * Four filters rather than one, and the reason is not only that a shared limit + * lets reactions, the loudest kind here, crowd the comments out of it. + * + * NIP-22 scopes a thread's root author with `P` and the author being answered + * with `p`, so a reply to somebody else's comment under my document names me in + * `P` alone: asked for by `p` it does not exist. NIP-57 spends the same tag on + * the opposite party. On a zap receipt `P` is who paid, so asking a relay for + * kind 9735 by `P` returns every zap I ever sent, addressed to me, as news. + */ +export const notificationFilters = (pubkey: string): Filter[] => [ + { kinds: [COMMENT_KIND], "#p": [pubkey], limit: 100 }, + { kinds: [COMMENT_KIND], "#P": [pubkey], limit: 100 }, + { kinds: [REACTION_KIND], "#p": [pubkey], limit: 200 }, + { kinds: [ZAP_RECEIPT_KIND], "#p": [pubkey], limit: 100 }, +]; + +/** Who else publishes under the names I publish under. Nobody is addressed in one. */ +export const copyFilters = (identifiers: string[]): Filter[] => + inChunks(identifiers).map((chunk) => ({ kinds: [SPEC_KIND], "#d": chunk })); + +/** + * Whether anything held here has since been taken back. Deliberately not + * `referenceFilters`, which asks for comments, reactions and zaps on the same + * ids: those belong to a document's record, and a reaction to a comment somebody + * wrote to me is their conversation rather than my news. + */ +export const targetFilters = (ids: string[]): Filter[] => + inChunks(ids).map((chunk) => ({ kinds: [DELETION_KIND], "#e": chunk })); + +/** + * What happened, which decides the sentence the row is drawn with. + * + * `comment` and `thread` both sit under a document of mine: the first answers + * the document, the second answers somebody else inside it. `reply` answers + * something I wrote, wherever it was written. + */ +export type NoticeKind = "comment" | "reply" | "thread" | "reaction" | "zap" | "copy"; + +export type Notice = { + /** The event this is, which is what makes it one row however many relays served it. */ + id: string; + kind: NoticeKind; + /** Who did it. On a zap that is who paid, not the server that signed the receipt. */ + pubkey: string; + createdAt: number; + /** The document it happened under, which is where the row leads. */ + document: { pubkey: string; identifier: string; coordinate: string }; + /** What was answered, when it was something other than the document itself. */ + targetId: string | null; + /** The words, the reaction's symbol, or the copy's title. */ + content: string; + /** A NIP-30 custom emoji's image, on a reaction that named one. */ + emojiUrl: string | null; + /** Satoshis, on a zap and nowhere else. */ + sats: number | null; +}; + +export type NoticeScope = { + /** The reader these are addressed to. Nothing they did themselves is news to them. */ + me: string; + /** + * Other keys' documents under one of my names, already reduced to one live + * revision per coordinate by whoever collected them. + */ + copies?: Spec[]; +}; + +/** Rebuilt rather than carried through, so one document has one spelling here. */ +const documentOf = (coordinate: string | null) => { + const pointer = coordinate === null ? null : parseCoordinate(coordinate); + return pointer === null + ? null + : { + pubkey: pointer.pubkey, + identifier: pointer.identifier, + coordinate: toCoordinate(pointer), + }; +}; + +/** + * A comment is mine to hear about when it hangs from my document or when it + * answers me, and the two are told apart by which tag names me. A reply that + * names me in neither is somebody else's conversation, delivered because relays + * index tag values and do not check them. + */ +const noticeFromComment = (comment: Comment, me: string): Notice | null => { + if (comment.pubkey === me) return null; + const document = documentOf(comment.rootCoordinate); + if (document === null) return null; + + const answered = tagValue(comment.event, "p") === me; + if (document.pubkey !== me && !answered) return null; + + return { + id: comment.id, + kind: comment.parentId === null ? "comment" : answered ? "reply" : "thread", + pubkey: comment.pubkey, + createdAt: comment.createdAt, + document, + targetId: comment.parentId, + content: comment.content, + emojiUrl: null, + sats: null, + }; +}; + +/** + * Anyone may put my key in a `p` tag, so that tag decides what a relay sends and + * never what is shown. What is shown is decided by the target: a reaction to a + * document of mine is one I can see is mine. A reaction naming only an event id + * is dropped here rather than trusted, and asking whose event that id is takes a + * second round trip the caller makes when it wants those rows. + */ +const noticeFromReaction = (reaction: Reaction, event: NostrEvent, me: string): Notice | null => { + if (event.pubkey === me) return null; + const document = documentOf(reaction.targetCoordinate); + if (document === null || document.pubkey !== me) return null; + + return { + id: reaction.id, + kind: "reaction", + pubkey: reaction.pubkey, + createdAt: reaction.createdAt, + document, + targetId: reaction.targetId, + content: reaction.symbol, + emojiUrl: reaction.emojiUrl, + sats: null, + }; +}; + +/** + * A receipt is signed by the recipient's LNURL server, so the payer is only + * known through the request it echoed back. A receipt that carries no request + * names nobody, and a row that cannot say who paid is not worth a line. + */ +const noticeFromZap = (zap: ZapReceipt, me: string): Notice | null => { + if (zap.recipient !== me || zap.zapper === null || zap.zapper === me) return null; + const document = documentOf(zap.targetCoordinate); + if (document === null || document.pubkey !== me) return null; + + return { + id: zap.id, + kind: "zap", + pubkey: zap.zapper, + createdAt: zap.createdAt, + document, + targetId: zap.targetId, + content: zap.comment, + emojiUrl: null, + sats: zap.amountSats, + }; +}; + +const noticeFromCopy = (spec: Spec, me: string): Notice | null => { + if (spec.pubkey === me || spec.isEmpty) return null; + return { + id: spec.event.id, + kind: "copy", + pubkey: spec.pubkey, + createdAt: spec.createdAt, + document: { + pubkey: spec.pubkey, + identifier: spec.identifier, + coordinate: toCoordinate(spec), + }, + targetId: null, + content: spec.title, + emojiUrl: null, + sats: null, + }; +}; + +/** + * A client that sent the same like twice is one reader and not two, which is the + * rule `tallyReactions` applies to a document's own tally. Everything else is + * one row per event: this site counts nothing that has a name attached to it. + */ +const collapsed = (notices: Notice[]): Notice[] => { + const kept = new Map(); + for (const notice of notices) { + if (notice.kind !== "reaction") { + kept.set(notice.id, notice); + continue; + } + const key = `${notice.pubkey}:${notice.targetId ?? notice.document.coordinate}:${notice.content}`; + const current = kept.get(key); + if (current === undefined || notice.createdAt > current.createdAt) kept.set(key, notice); + } + return [...kept.values()]; +}; + +const retracted = (deletions: Deletion[]) => { + const taken = retractions(deletions); + return (notice: Notice): boolean => taken.get(notice.id)?.has(notice.pubkey) === true; +}; + +/** + * Everything addressed to one key, newest first. Every check a relay could have + * skipped is made again here, where it costs a string compare: what a filter + * matched is a tag, and a tag is only what somebody wrote. + */ +export const sortNotices = (events: NostrEvent[], scope: NoticeScope): Notice[] => { + const notices: Notice[] = []; + const deletions: Deletion[] = []; + const seen = new Set(); + + for (const event of events) { + if (seen.has(event.id)) continue; + seen.add(event.id); + + if (event.kind === COMMENT_KIND) { + const comment = parseComment(event); + const notice = comment === null ? null : noticeFromComment(comment, scope.me); + if (notice !== null) notices.push(notice); + } else if (event.kind === REACTION_KIND) { + const reaction = parseReaction(event); + const notice = reaction === null ? null : noticeFromReaction(reaction, event, scope.me); + if (notice !== null) notices.push(notice); + } else if (event.kind === ZAP_RECEIPT_KIND) { + const zap = parseZapReceipt(event); + const notice = zap === null ? null : noticeFromZap(zap, scope.me); + if (notice !== null) notices.push(notice); + } else if (event.kind === DELETION_KIND) { + const deletion = parseDeletion(event); + if (deletion !== null) deletions.push(deletion); + } + } + + for (const spec of scope.copies ?? []) { + const notice = noticeFromCopy(spec, scope.me); + if (notice !== null && !seen.has(notice.id)) { + seen.add(notice.id); + notices.push(notice); + } + } + + const taken = retracted(deletions); + return collapsed(notices) + .filter((notice) => !taken(notice)) + .sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + .slice(0, MAX_NOTICES); +}; + +export type NoticeOptions = RelayOptions & { + /** + * More relays to ask, resolved by the caller while this is already running. + * A callback rather than a list: which relays reach a key is the app's policy, + * and waiting on that lookup would leave the reader with nothing meanwhile. + */ + widen?: () => Promise; +}; + +const openNotices = ( + known: string[], + filters: Filter[], + onEvent: (event: NostrEvent) => void, + options: NoticeOptions & { onEose?: () => void }, +): Subscription => { + const subscription = openWidening(known, filters, onEvent, options); + void (options.widen?.() ?? Promise.resolve([])) + .then((more) => subscription.widen(without(more, known), true)) + .catch(() => {}); + return subscription; +}; + +/** + * Everything addressed to one key, as it happens. `DISCUSSION_RELAYS` is asked + * first because that is where `writeRelays` sends a comment and where the other + * clients reading this kind of document send theirs. + */ +export const subscribeNotices = ( + pubkey: string, + onEvent: (event: NostrEvent) => void, + options: NoticeOptions & { onEose?: () => void } = {}, +): Subscription => + openNotices( + relaySet(options.relays ?? [...DISCUSSION_RELAYS, ...DEFAULT_RELAYS]), + notificationFilters(pubkey), + onEvent, + options, + ); + +/** + * The same, for the names I publish under. A different relay set, because this + * is the one thing here nobody addresses to me: `READ_RELAYS` is where this site + * and its crawler look for documents, so it is where a copy of one is. + */ +export const subscribeCopies = ( + identifiers: string[], + onEvent: (event: NostrEvent) => void, + options: NoticeOptions & { onEose?: () => void } = {}, +): Subscription => + openNotices( + relaySet(options.relays ?? [...READ_RELAYS, ...DISCUSSION_RELAYS]), + copyFilters(identifiers), + onEvent, + options, + ); + +/** Whether anything held is still standing, asked by the ids it would be taken back by. */ +export const subscribeRetractions = ( + ids: string[], + onEvent: (event: NostrEvent) => void, + options: NoticeOptions & { onEose?: () => void } = {}, +): Subscription => + openNotices( + relaySet(options.relays ?? [...DISCUSSION_RELAYS, ...DEFAULT_RELAYS]), + targetFilters(ids), + onEvent, + options, + ); diff --git a/packages/nostr/src/subscribe.ts b/packages/nostr/src/subscribe.ts new file mode 100644 index 0000000..ed51ded --- /dev/null +++ b/packages/nostr/src/subscribe.ts @@ -0,0 +1,71 @@ +import type { Filter } from "nostr-tools/filter"; +import type { NostrEvent } from "./event"; +import { type RelayOptions, relayPool } from "./pool"; + +/** A filter with a thousand ids in it is refused by relays that bound their inputs. */ +export const MAX_IDS_PER_FILTER = 200; + +/** Values chunked into filters a relay will accept, in the order they were given. */ +export const inChunks = (values: T[], size = MAX_IDS_PER_FILTER): T[][] => { + const unique = [...new Set(values)]; + const chunks: T[][] = []; + for (let index = 0; index < unique.length; index += size) { + chunks.push(unique.slice(index, index + size)); + } + return chunks; +}; + +export type Subscription = { close: () => void }; + +export type WideningSubscription = Subscription & { + /** Adds relays to a subscription already running, unless it has been closed. */ + widen: (relays: string[], quiet: boolean) => void; +}; + +export const without = (relays: string[], already: string[]): string[] => + relays.filter((relay) => !already.includes(relay)); + +/** + * A subscription that can be told about more relays after it is running. + * + * Every reader here starts on the relays it already knows and learns about + * better ones a round trip later, from a NIP-65 list. Waiting for that list + * before subscribing would put a lookup on the critical path of something that + * should already be on screen, and the relays added afterwards send what they + * hold the moment they are asked, so nothing is lost by adding them late. + */ +export const openWidening = ( + relays: string[], + filters: Filter[], + onEvent: (event: NostrEvent) => void, + options: RelayOptions & { onEose?: () => void } = {}, +): WideningSubscription => { + const pool = options.pool ?? relayPool(); + const closers: { close: () => void }[] = []; + let closed = false; + + const subscribe = (to: string[], onEose: (() => void) | undefined) => { + if (closed || to.length === 0) return; + for (const filter of filters) { + closers.push( + pool.subscribe(to, filter, { + onevent: (event) => onEvent(event as NostrEvent), + oneose: onEose, + }), + ); + } + }; + + subscribe(relays, options.onEose); + + return { + // The widening relays do not report an end of stored events: the caller was + // told what it asked for had arrived once already, and saying so again would + // put a page that has finished loading back into loading. + widen: (more, quiet) => subscribe(more, quiet ? undefined : options.onEose), + close: () => { + closed = true; + for (const closer of closers) closer.close(); + }, + }; +}; diff --git a/packages/nostr/test/notifications.test.ts b/packages/nostr/test/notifications.test.ts new file mode 100644 index 0000000..e722e5b --- /dev/null +++ b/packages/nostr/test/notifications.test.ts @@ -0,0 +1,338 @@ +import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure"; +import { describe, expect, it } from "vitest"; +import { toCoordinate } from "../src/address"; +import { type NostrEvent, SPEC_KIND } from "../src/event"; +import { buildComment, COMMENT_KIND } from "../src/nip22"; +import { buildReaction, buildRetraction, DELETION_KIND, REACTION_KIND } from "../src/nip25"; +import { buildZapRequest, ZAP_RECEIPT_KIND } from "../src/nip57"; +import { + copyFilters, + MAX_NOTICES, + type Notice, + notificationFilters, + sortNotices, + targetFilters, +} from "../src/notifications"; +import { parseSpec, type Spec } from "../src/spec"; +import { MAX_IDS_PER_FILTER } from "../src/subscribe"; + +const mySecret = generateSecretKey(); +const ME = getPublicKey(mySecret); + +const theirSecret = generateSecretKey(); +const THEM = getPublicKey(theirSecret); + +const thirdSecret = generateSecretKey(); +const THIRD = getPublicKey(thirdSecret); + +const MINE = { coordinate: `${SPEC_KIND}:${ME}:nip-07`, pubkey: ME }; +const THEIRS = { coordinate: `${SPEC_KIND}:${THEM}:nip-46`, pubkey: THEM }; + +const sign = ( + draft: { kind: number; content: string; tags: string[][] }, + secret: Uint8Array, + at = 100, +) => finalizeEvent({ ...draft, created_at: at }, secret) as NostrEvent; + +const comment = ( + root: { coordinate: string; pubkey: string }, + by: Uint8Array, + options: { parent?: { id: string; pubkey: string }; content?: string; at?: number } = {}, +) => + sign( + buildComment({ root, parent: options.parent ?? null, content: options.content ?? "a note" }), + by, + options.at ?? 100, + ); + +const reaction = ( + target: { id: string; pubkey: string; kind: number; coordinate?: string }, + by: Uint8Array, + options: { symbol?: string; at?: number } = {}, +) => sign(buildReaction(target, options.symbol ?? "+"), by, options.at ?? 100); + +/** A real invoice for 21 satoshis, so the amount is read rather than asserted. */ +const INVOICE_21_SATS = + "lnbc210n1pn2s396pp5w7lqvvmqxxwqmqjqxqyjqxqyjqxqyjqxqyjqxqyjqxqyjqxqyjqsdqqcqzzsxqyz5vqsp5usqfaketc5sg7g7fhqg9zqhqvqhqvqhqvqhqvqhqvqhqvqhqvqhqs9qyyssq"; + +const serverSecret = generateSecretKey(); + +const zap = ( + by: Uint8Array, + target: { pubkey: string; coordinate?: string; eventId?: string }, + at = 100, +) => { + const request = sign( + buildZapRequest({ target, amountMsats: 21_000, relays: ["wss://nos.lol"] }), + by, + at, + ); + return sign( + { + kind: ZAP_RECEIPT_KIND, + content: "", + tags: [ + ["p", target.pubkey], + ["bolt11", INVOICE_21_SATS], + ["description", JSON.stringify(request)], + ...(target.coordinate ? [["a", target.coordinate]] : []), + ], + }, + serverSecret, + at, + ); +}; + +const specEvent = (by: Uint8Array, identifier: string, content = "# A copy", at = 100) => + sign( + { + kind: SPEC_KIND, + content, + tags: [ + ["d", identifier], + ["title", "A copy"], + ], + }, + by, + at, + ); + +const asSpec = (event: NostrEvent): Spec => { + const spec = parseSpec(event); + if (spec === null) throw new Error("fixture is not a specification"); + return spec; +}; + +const kinds = (notices: Notice[]) => notices.map((notice) => notice.kind); + +describe("notificationFilters", () => { + it("never asks for a zap receipt by the tag that names the payer", () => { + const zaps = notificationFilters(ME).filter((filter) => + filter.kinds?.includes(ZAP_RECEIPT_KIND), + ); + expect(zaps).toHaveLength(1); + expect(zaps[0]?.["#P"]).toBeUndefined(); + expect(zaps[0]?.["#p"]).toEqual([ME]); + }); + + it("asks for comments both ways, since a reply under my document names me in `P` alone", () => { + const comments = notificationFilters(ME).filter((filter) => + filter.kinds?.includes(COMMENT_KIND), + ); + expect(comments.map((filter) => (filter["#p"] ? "#p" : "#P"))).toEqual(["#p", "#P"]); + }); + + it("gives each kind its own limit, so reactions cannot crowd out the rest", () => { + const filters = notificationFilters(ME); + const reactions = filters.find((filter) => filter.kinds?.includes(REACTION_KIND)); + expect(filters.every((filter) => typeof filter.limit === "number")).toBe(true); + expect(reactions?.kinds).toEqual([REACTION_KIND]); + }); +}); + +describe("copyFilters and targetFilters", () => { + const many = Array.from({ length: MAX_IDS_PER_FILTER + 5 }, (_, index) => `name-${index}`); + + it("chunk what a relay would refuse in one filter", () => { + const filters = copyFilters(many); + expect(filters).toHaveLength(2); + expect(filters[0]?.["#d"]).toHaveLength(MAX_IDS_PER_FILTER); + expect(filters[1]?.["#d"]).toHaveLength(5); + }); + + it("drop a name repeated in the list", () => { + expect(copyFilters(["nip-07", "nip-07"])[0]?.["#d"]).toEqual(["nip-07"]); + }); + + it("ask only about deletions, not about the conversation around them", () => { + expect(targetFilters(["a".repeat(64)])).toEqual([ + { kinds: [DELETION_KIND], "#e": ["a".repeat(64)] }, + ]); + }); +}); + +describe("sortNotices, comments", () => { + it("takes a comment on my document as a comment", () => { + const notices = sortNotices([comment(MINE, theirSecret)], { me: ME }); + expect(kinds(notices)).toEqual(["comment"]); + expect(notices[0]?.pubkey).toBe(THEM); + expect(notices[0]?.document).toEqual({ + pubkey: ME, + identifier: "nip-07", + coordinate: MINE.coordinate, + }); + }); + + it("takes an answer to something I wrote as a reply, wherever it was written", () => { + const mine = comment(THEIRS, mySecret); + const notices = sortNotices( + [comment(THEIRS, thirdSecret, { parent: { id: mine.id, pubkey: ME } })], + { + me: ME, + }, + ); + expect(kinds(notices)).toEqual(["reply"]); + expect(notices[0]?.document.pubkey).toBe(THEM); + expect(notices[0]?.targetId).toBe(mine.id); + }); + + it("takes two other people talking under my document as a thread", () => { + const theirs = comment(MINE, theirSecret); + const notices = sortNotices( + [comment(MINE, thirdSecret, { parent: { id: theirs.id, pubkey: THEM } })], + { me: ME }, + ); + expect(kinds(notices)).toEqual(["thread"]); + expect(notices[0]?.pubkey).toBe(THIRD); + }); + + it("drops what I wrote myself", () => { + expect(sortNotices([comment(MINE, mySecret)], { me: ME })).toEqual([]); + }); + + it("drops a comment that names me in neither tag, which a relay cannot check", () => { + expect(sortNotices([comment(THEIRS, thirdSecret)], { me: ME })).toEqual([]); + }); + + it("drops a comment whose root coordinate is junk", () => { + const junk = sign( + { + kind: COMMENT_KIND, + content: "hello", + tags: [ + ["A", "not-a-coordinate"], + ["p", ME], + ], + }, + theirSecret, + ); + expect(sortNotices([junk], { me: ME })).toEqual([]); + }); +}); + +describe("sortNotices, reactions", () => { + const target = { id: "a".repeat(64), pubkey: ME, kind: SPEC_KIND, coordinate: MINE.coordinate }; + + it("takes a reaction to my document, with its symbol", () => { + const notices = sortNotices([reaction(target, theirSecret, { symbol: "🔥" })], { me: ME }); + expect(kinds(notices)).toEqual(["reaction"]); + expect(notices[0]?.content).toBe("🔥"); + }); + + it("drops a reaction to my own document from me", () => { + expect(sortNotices([reaction(target, mySecret)], { me: ME })).toEqual([]); + }); + + it("drops a reaction naming me on a document that is not mine", () => { + const theirs = { + id: "b".repeat(64), + pubkey: ME, + kind: SPEC_KIND, + coordinate: THEIRS.coordinate, + }; + expect(sortNotices([reaction(theirs, theirSecret)], { me: ME })).toEqual([]); + }); + + it("drops a reaction that names only an event id, until its target is resolved", () => { + const onComment = { id: "c".repeat(64), pubkey: ME, kind: COMMENT_KIND }; + expect(sortNotices([reaction(onComment, theirSecret)], { me: ME })).toEqual([]); + }); + + it("collapses the same like sent twice into one row, keeping the newer", () => { + const first = reaction(target, theirSecret, { at: 100 }); + const second = reaction(target, theirSecret, { at: 200 }); + const notices = sortNotices([first, second], { me: ME }); + expect(notices).toHaveLength(1); + expect(notices[0]?.createdAt).toBe(200); + }); + + it("keeps two different symbols from the same key apart", () => { + const like = reaction(target, theirSecret, { symbol: "+" }); + const fire = reaction(target, theirSecret, { symbol: "🔥" }); + expect(sortNotices([like, fire], { me: ME })).toHaveLength(2); + }); + + it("keeps the same symbol from two keys apart", () => { + const one = reaction(target, theirSecret); + const two = reaction(target, thirdSecret); + expect(sortNotices([one, two], { me: ME })).toHaveLength(2); + }); +}); + +describe("sortNotices, zaps", () => { + it("names who paid rather than the server that signed the receipt", () => { + const receipt = zap(theirSecret, { pubkey: ME, coordinate: MINE.coordinate }); + const notices = sortNotices([receipt], { me: ME }); + expect(kinds(notices)).toEqual(["zap"]); + expect(notices[0]?.pubkey).toBe(THEM); + expect(notices[0]?.sats).toBe(21); + }); + + it("drops a zap I sent myself", () => { + const receipt = zap(mySecret, { pubkey: ME, coordinate: MINE.coordinate }); + expect(sortNotices([receipt], { me: ME })).toEqual([]); + }); + + it("drops a receipt addressed to somebody else", () => { + const receipt = zap(thirdSecret, { pubkey: THEM, coordinate: THEIRS.coordinate }); + expect(sortNotices([receipt], { me: ME })).toEqual([]); + }); +}); + +describe("sortNotices, retractions", () => { + it("drops a comment its own author asked to be forgotten", () => { + const written = comment(MINE, theirSecret); + const taken = sign(buildRetraction(written.id), theirSecret); + expect(sortNotices([written, taken], { me: ME })).toEqual([]); + }); + + it("keeps one somebody else asked to have forgotten", () => { + const written = comment(MINE, theirSecret); + const taken = sign(buildRetraction(written.id), thirdSecret); + expect(sortNotices([written, taken], { me: ME })).toHaveLength(1); + }); +}); + +describe("sortNotices, copies", () => { + it("takes another key publishing under one of my names, and leads to their copy", () => { + const copy = asSpec(specEvent(theirSecret, "nip-07")); + const notices = sortNotices([], { me: ME, copies: [copy] }); + expect(kinds(notices)).toEqual(["copy"]); + expect(notices[0]?.document).toEqual({ + pubkey: THEM, + identifier: "nip-07", + coordinate: toCoordinate(copy), + }); + }); + + it("drops my own document and a blank one", () => { + const own = asSpec(specEvent(mySecret, "nip-07")); + const blank = asSpec(specEvent(theirSecret, "nip-07", "")); + expect(sortNotices([], { me: ME, copies: [own, blank] })).toEqual([]); + }); +}); + +describe("sortNotices, the whole list", () => { + it("takes the same event served by three relays as one row", () => { + const one = comment(MINE, theirSecret); + expect(sortNotices([one, one, one], { me: ME })).toHaveLength(1); + }); + + it("reads newest first, with the lower id breaking a tie", () => { + const old = comment(MINE, theirSecret, { at: 100, content: "old" }); + const a = comment(MINE, theirSecret, { at: 200, content: "a" }); + const b = comment(MINE, thirdSecret, { at: 200, content: "b" }); + const [first, second, third] = sortNotices([old, a, b], { me: ME }); + + expect(third?.createdAt).toBe(100); + expect([first?.id, second?.id].sort()).toEqual([a.id, b.id].sort()); + expect(first?.id).toBe(a.id < b.id ? a.id : b.id); + }); + + it("stops at what one page of news can be", () => { + const many = Array.from({ length: MAX_NOTICES + 10 }, (_, index) => + comment(MINE, theirSecret, { at: 100 + index, content: `note ${index}` }), + ); + expect(sortNotices(many, { me: ME })).toHaveLength(MAX_NOTICES); + }); +}); From f152f1ea0afc0a0a9492c7474c5574c5d684711b Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 24 Aug 2026 11:35:22 +0200 Subject: [PATCH 2/6] feat: put a bell on the header for what was addressed to you --- ROADMAP.md | 4 +- apps/web/app/components/chrome.tsx | 31 +++ apps/web/app/components/identity.tsx | 22 +- .../web/app/components/notifications/bell.tsx | 134 ++++++++++ .../components/notifications/notice-row.tsx | 125 ++++++++++ apps/web/app/components/shell.tsx | 9 +- apps/web/app/lib/notifications.test.ts | 229 ++++++++++++++++++ apps/web/app/lib/notifications.ts | 178 ++++++++++++++ apps/web/app/lib/paths.ts | 3 + apps/web/app/lib/relays.ts | 23 ++ apps/web/app/lib/seen.test.ts | 145 +++++++++++ apps/web/app/lib/seen.ts | 113 +++++++++ apps/web/app/routes.ts | 1 + apps/web/app/routes/notifications.tsx | 87 +++++++ 14 files changed, 1085 insertions(+), 19 deletions(-) create mode 100644 apps/web/app/components/chrome.tsx create mode 100644 apps/web/app/components/notifications/bell.tsx create mode 100644 apps/web/app/components/notifications/notice-row.tsx create mode 100644 apps/web/app/lib/notifications.test.ts create mode 100644 apps/web/app/lib/notifications.ts create mode 100644 apps/web/app/lib/seen.test.ts create mode 100644 apps/web/app/lib/seen.ts create mode 100644 apps/web/app/routes/notifications.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 6aaf851..2217f1b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,6 +57,7 @@ Shippable here: read only, no accounts, no database, but properly indexed. - [x] NIP-22 comments and replies, NIP-25 reactions with NIP-09 retraction - [x] NIP-57 zaps, over WebLN, NIP-47 or an invoice the reader carries - [x] Editor, publishing kind 30817 +- [x] Notifications, read off the relays by the browser holding the key - [ ] NIP-37 encrypted drafts - [ ] Forks, NIP-32 approvals, NIP-84 highlights @@ -67,4 +68,5 @@ Shippable here: read only, no accounts, no database, but properly indexed. - [ ] Full revision history, read from the kind 1349 snapshots the crawler archives, so it is a projection of events rather than server only state - [ ] Meilisearch -- [ ] Web Push +- [ ] Web Push, which reaches a reader whose tab is closed. The list itself is + already in Lot 4: what a server adds is the knock on the door, not the news. diff --git a/apps/web/app/components/chrome.tsx b/apps/web/app/components/chrome.tsx new file mode 100644 index 0000000..ebe0a71 --- /dev/null +++ b/apps/web/app/components/chrome.tsx @@ -0,0 +1,31 @@ +/** + * What the header's own controls are drawn with. Shared rather than copied, + * because two panels hanging off the same line that drift apart by a pixel read + * as two different pieces of software. + */ +export const CHROME = + "rounded-sm border border-rule px-2 py-1 font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-muted hover:border-muted hover:text-ink"; + +/** + * The header is set in wide-tracked capitals, and everything inside it inherits + * that. A panel is not chrome, it is a place to read a sentence and a key, so it + * puts the type back to normal and lets what wants the chrome ask for it. + */ +export const Panel = ({ + children, + width = "w-[min(20rem,calc(100vw-2rem))]", +}: { + children: React.ReactNode; + /** + * Wide enough for what it holds. The default suits a sentence and a key; a + * list of rows wants more, since every pixel it lacks is taken out of the + * sentence in each of them at once. + */ + width?: string; +}) => ( +
+ {children} +
+); diff --git a/apps/web/app/components/identity.tsx b/apps/web/app/components/identity.tsx index 6b9e13f..09b58a2 100644 --- a/apps/web/app/components/identity.tsx +++ b/apps/web/app/components/identity.tsx @@ -14,29 +14,17 @@ import { subscribeSession, } from "~/lib/session"; import { AuthorAvatar } from "./author-avatar"; +import { CHROME, Panel } from "./chrome"; import { CopyButton } from "./copy-button"; import { MakeKey } from "./make-key"; import { SignInDialog } from "./sign-in-dialog"; import { Unlock } from "./unlock"; -const CHROME = - "rounded-sm border border-rule px-2 py-1 font-mono text-[0.6875rem] uppercase tracking-[0.14em] text-muted hover:border-muted hover:text-ink"; - -/** - * The header is set in wide-tracked capitals, and everything inside it inherits - * that. A panel is not chrome, it is a place to read a sentence and a key, so it - * puts the type back to normal and lets what wants the chrome ask for it. - */ -const Panel = ({ children }: { children: React.ReactNode }) => ( -
- {children} -
-); - /** * The header's own control. The server renders it signed out, because the server - * knows nobody, and it changes one tick after the page becomes interactive: the - * slot it sits in is a fixed width so that nothing beside it moves when it does. + * knows nobody, and it changes one tick after the page becomes interactive. The + * width that keeps the rest of the line still while it does is reserved around + * this and the bell together, in `Shell`, rather than around this alone. */ export const Identity = () => { const [open, setOpen] = useState(false); @@ -63,7 +51,7 @@ export const Identity = () => { const nsec = session.pubkey === null ? null : sessionNsec(); return ( -
+
{session.pubkey === null || npub === null ? ( + + {open && ( + + {rows.length === 0 ? ( +

+ {state.status === "ready" + ? "Nothing yet. What people write, mark or pay on your documents lands here." + : "Asking the relays."} +

+ ) : ( + // Bounded, and scrolled past that: eight rows of somebody with a busy + // week is a panel taller than the screen it hangs off, which is a + // page rather than a glance at one. + // + // The negative margin is what puts the scrollbar against the panel's + // border. Left inside the panel's padding it stands in open paper, + // sixteen pixels short of the edge, which reads as a bar somebody + // dropped on the page rather than as the side of a box. +
    + {rows.map((notice) => ( + shownFrom} + onFollowed={() => setOpen(false)} + /> + ))} +
+ )} + {/* Outside the branch above: a panel with nothing in it is still the + only way to the page, and a box that leads nowhere is a dead end. */} +
+ setOpen(false)}> + Everything + +
+
+ )} +
+ ); +}; diff --git a/apps/web/app/components/notifications/notice-row.tsx b/apps/web/app/components/notifications/notice-row.tsx new file mode 100644 index 0000000..2f6ff23 --- /dev/null +++ b/apps/web/app/components/notifications/notice-row.tsx @@ -0,0 +1,125 @@ +import type { Notice } from "@openspecs/nostr"; +import { specPath, toNpub } from "@openspecs/nostr"; +import { Link } from "react-router"; +import { AuthorAvatar } from "~/components/author-avatar"; +import { DISCUSSION_ID } from "~/components/discussion/discussion"; +import { keyTextColor } from "~/lib/color"; +import { type Authors, authorName } from "~/lib/profile"; + +const asDate = (seconds: number): string => new Date(seconds * 1000).toISOString().slice(0, 10); + +/** + * What happened, said the way somebody would say it out loud. The document is + * named by its identifier rather than by its title: a title costs one relay + * query per row, and the identifier is the name this site files a document + * under anyway. + */ +const said = (notice: Notice): string => { + const name = notice.document.identifier; + switch (notice.kind) { + case "comment": + return `commented on ${name}`; + case "reply": + return `replied to you on ${name}`; + case "thread": + return `replied under ${name}`; + case "reaction": + return `reacted to ${name}`; + case "zap": + return `zapped ${name}, ${notice.sats} sats`; + case "copy": + return `published under the name ${name}`; + } +}; + +/** A reaction's symbol, drawn the way the tally under a document draws it. */ +const ReactionMark = ({ notice }: { notice: Notice }) => + notice.emojiUrl === null ? ( + + ) : ( + + ); + +/** + * A conversation is a client rendered part of the document's page, so a link + * into it can only name the section until the comment itself has an address. + */ +const destination = (notice: Notice): string => { + const path = specPath(notice.document); + return notice.kind === "copy" ? path : `${path}#${DISCUSSION_ID}`; +}; + +export const NoticeRow = ({ + notice, + authors, + unread, + onFollowed, +}: { + notice: Notice; + authors: Authors; + unread: boolean; + /** The panel closes behind a row that was followed; the page has nothing to close. */ + onFollowed?: () => void; +}) => { + const npub = toNpub(notice.pubkey); + const author = authors[notice.pubkey] ?? null; + const words = notice.kind === "reaction" ? "" : notice.content; + + return ( +
  • + +
    + +
    +
    + {/* The date shares the name's line rather than standing in a column of + its own. A column costs the same eighty pixels on every row, and in + a panel three hundred wide that is what pushes a short sentence onto + a third line. */} +
    + + {authorName(author, npub)} + + +
    +

    + {said(notice)} + {notice.kind === "reaction" && ( + <> + {" "} + + + )} +

    + {words !== "" && ( +

    + {words} +

    + )} +
    + +
  • + ); +}; diff --git a/apps/web/app/components/shell.tsx b/apps/web/app/components/shell.tsx index 785da3f..4afc199 100644 --- a/apps/web/app/components/shell.tsx +++ b/apps/web/app/components/shell.tsx @@ -1,5 +1,6 @@ import { Link } from "react-router"; import { Identity } from "./identity"; +import { Bell } from "./notifications/bell"; import { SearchBox } from "./search-box"; export const SOURCE_URL = "https://github.com/nogringo/openspecs"; @@ -49,7 +50,13 @@ export const Shell = ({ Source - + {/* The reader's own two controls, held as one. The width reserved for + them is on the pair rather than on either, so the slack a short name + leaves falls outside the two and never between them. */} +
    + + +
    diff --git a/apps/web/app/lib/notifications.test.ts b/apps/web/app/lib/notifications.test.ts new file mode 100644 index 0000000..6ef3951 --- /dev/null +++ b/apps/web/app/lib/notifications.test.ts @@ -0,0 +1,229 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const nostr = vi.hoisted(() => ({ subscribeNotices: vi.fn() })); + +vi.mock("@openspecs/nostr", async (importOriginal) => ({ + ...(await importOriginal()), + ...nostr, +})); + +vi.mock("./relays", () => ({ noticeRelays: vi.fn(async () => []) })); + +import { buildComment, type NostrEvent, SPEC_KIND } from "@openspecs/nostr"; +import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure"; +import { + clearNotices, + markNoticesSeen, + noticesState, + startNotices, + subscribeNoticesState, +} from "./notifications"; +import { forgetSeen, noteKey, seenAt } from "./seen"; + +const myKey = generateSecretKey(); +const ME = getPublicKey(myKey); + +const theirKey = generateSecretKey(); + +const otherKey = generateSecretKey(); +const SOMEBODY_ELSE = getPublicKey(otherKey); + +const ROOT = { coordinate: `${SPEC_KIND}:${ME}:a-specification`, pubkey: ME }; + +const comment = (content: string, at: number) => + finalizeEvent({ ...buildComment({ root: ROOT, content }), created_at: at }, theirKey); + +/** A fake with the one behaviour that matters: it can be written to and read back. */ +const fakeStorage = (): Storage => { + const held = new Map(); + return { + getItem: (key: string) => held.get(key) ?? null, + setItem: (key: string, value: string) => held.set(key, value), + removeItem: (key: string) => held.delete(key), + clear: () => held.clear(), + key: () => null, + length: 0, + } as unknown as Storage; +}; + +/** The one subscription, held so a test can play the relays' part by hand. */ +type Channel = { send: (event: NostrEvent) => void; eose: () => void }; + +let channel: Channel; +let closed = 0; + +beforeEach(() => { + vi.stubGlobal("window", {}); + vi.stubGlobal("localStorage", fakeStorage()); + vi.useFakeTimers(); + clearNotices(); + forgetSeen(); + closed = 0; + nostr.subscribeNotices.mockClear(); + + nostr.subscribeNotices.mockImplementation((_pubkey, onEvent, options) => { + channel = { send: onEvent, eose: () => options?.onEose?.() }; + return { + close: () => { + closed += 1; + }, + }; + }); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +/** The relay client is fetched, and the store batches: neither is instant. */ +const settle = async () => { + await vi.advanceTimersByTimeAsync(200); +}; + +describe("startNotices", () => { + it("opens one subscription for a key", async () => { + startNotices(ME); + await settle(); + expect(nostr.subscribeNotices).toHaveBeenCalledTimes(1); + expect(noticesState().me).toBe(ME); + }); + + it("does not open a second for the same key, however often the header remounts", async () => { + startNotices(ME); + await settle(); + startNotices(ME); + startNotices(ME); + await settle(); + expect(nostr.subscribeNotices).toHaveBeenCalledTimes(1); + expect(closed).toBe(0); + }); + + it("closes what it held and forgets it when another key connects", async () => { + startNotices(ME); + await settle(); + channel.send(comment("a note", 2_000) as NostrEvent); + await settle(); + expect(noticesState().notices).toHaveLength(1); + + startNotices(SOMEBODY_ELSE); + await settle(); + expect(closed).toBe(1); + expect(noticesState().me).toBe(SOMEBODY_ELSE); + expect(noticesState().notices).toEqual([]); + }); + + it("says nothing on a server, where there is no browser to read it", () => { + vi.stubGlobal("window", undefined); + startNotices(ME); + expect(nostr.subscribeNotices).not.toHaveBeenCalled(); + }); + + it("is loading until the relays have listed what they hold", async () => { + startNotices(ME); + await settle(); + expect(noticesState().status).toBe("loading"); + + channel.eose(); + await settle(); + expect(noticesState().status).toBe("ready"); + }); + + it("tells a subscriber that something arrived", async () => { + const listener = vi.fn(); + const unsubscribe = subscribeNoticesState(listener); + startNotices(ME); + await settle(); + listener.mockClear(); + + channel.send(comment("a note", 2_000) as NostrEvent); + await settle(); + expect(listener).toHaveBeenCalled(); + + unsubscribe(); + }); +}); + +describe("the badge", () => { + it("counts nothing for a key this browser has never seen", async () => { + startNotices(ME); + await settle(); + // Everything on the relays predates the moment this key connected here. + channel.send(comment("old news", 1_000) as NostrEvent); + await settle(); + + expect(noticesState().notices).toHaveLength(1); + expect(noticesState().unread).toBe(0); + }); + + it("counts what arrived after the key was last shown its news", async () => { + noteKey(ME, 1_000); + startNotices(ME); + await settle(); + + channel.send(comment("old news", 500) as NostrEvent); + channel.send(comment("news", 2_000) as NostrEvent); + await settle(); + + expect(noticesState().notices).toHaveLength(2); + expect(noticesState().unread).toBe(1); + }); +}); + +describe("markNoticesSeen", () => { + it("clears the badge and holds the mark against a reload", async () => { + noteKey(ME, 1_000); + startNotices(ME); + await settle(); + channel.send(comment("news", 2_000) as NostrEvent); + await settle(); + + markNoticesSeen(); + expect(noticesState().unread).toBe(0); + expect(seenAt(ME)).toBe(2_000); + }); + + it("marks up to the newest it drew, not up to the clock", async () => { + noteKey(ME, 1_000); + startNotices(ME); + await settle(); + channel.send(comment("news", 2_000) as NostrEvent); + await settle(); + + markNoticesSeen(); + // A relay serving an event dated later must still be able to raise the badge. + channel.send(comment("dated tomorrow", 9_000) as NostrEvent); + await settle(); + expect(noticesState().unread).toBe(1); + }); + + it("does nothing when nobody is connected", () => { + expect(() => markNoticesSeen()).not.toThrow(); + }); +}); + +describe("clearNotices", () => { + it("closes the subscription and empties the bell on signing out", async () => { + startNotices(ME); + await settle(); + channel.send(comment("news", 2_000) as NostrEvent); + await settle(); + + clearNotices(); + expect(closed).toBe(1); + expect(noticesState().me).toBeNull(); + expect(noticesState().notices).toEqual([]); + }); + + it("leaves the mark where it is, since it belongs to the key and not the session", async () => { + noteKey(ME, 1_000); + startNotices(ME); + await settle(); + channel.send(comment("news", 2_000) as NostrEvent); + await settle(); + markNoticesSeen(); + + clearNotices(); + expect(seenAt(ME)).toBe(2_000); + }); +}); diff --git a/apps/web/app/lib/notifications.ts b/apps/web/app/lib/notifications.ts new file mode 100644 index 0000000..dc5cc01 --- /dev/null +++ b/apps/web/app/lib/notifications.ts @@ -0,0 +1,178 @@ +import type { NostrEvent, Notice, Subscription } from "@openspecs/nostr"; +import { markSeen, noteKey, seenAt, unreadCount } from "./seen"; + +export type NoticesStatus = "idle" | "loading" | "ready"; + +export type NoticesState = { + /** Whose news this is, so no page ever draws one key another key's mail. */ + me: string | null; + status: NoticesStatus; + notices: Notice[]; + /** The mark these are read against, which is what a row is drawn unread by. */ + seenAt: number; + /** How many arrived since this browser last showed them to this key. */ + unread: number; +}; + +export const NO_NOTICES: NoticesState = Object.freeze({ + me: null, + status: "idle", + notices: [], + seenAt: 0, + unread: 0, +}); + +/** Often enough to watch news arrive, rarely enough not to re-sort on every event. */ +const NOTIFY_MS = 150; + +let state = NO_NOTICES; +let me: string | null = null; +/** The relays have listed what they hold. Not that events stopped arriving. */ +let listed = false; +let events = new Map(); +let subscriptions: Subscription[] = []; +let timer: ReturnType | null = null; + +/** + * The relay client, once it has been fetched. Asked for rather than imported, + * the way the corpus asks for it: this store runs on every page, and somebody + * who only reads should not download a relay client to do it. + */ +let nostr: typeof import("@openspecs/nostr") | null = null; + +const listeners = new Set<() => void>(); + +export const subscribeNoticesState = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const noticesState = (): NoticesState => state; + +/** The server knows nobody, so it has nobody's news: the browser reads this. */ +export const serverNoticesState = (): NoticesState => NO_NOTICES; + +const notify = (): void => { + for (const listener of listeners) listener(); +}; + +const recompute = (): NoticesState => { + if (me === null || nostr === null) return NO_NOTICES; + const mark = seenAt(me) ?? 0; + const notices = nostr.sortNotices([...events.values()], { me }); + + return { + me, + status: listed ? "ready" : "loading", + notices, + seenAt: mark, + unread: unreadCount(notices, mark), + }; +}; + +const publish = (): void => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + state = recompute(); + notify(); +}; + +const publishSoon = (): void => { + if (timer !== null) return; + timer = setTimeout(() => { + timer = null; + publish(); + }, NOTIFY_MS); +}; + +const receive = (event: NostrEvent): void => { + if (events.has(event.id)) return; + events.set(event.id, event); + publishSoon(); +}; + +const close = (): void => { + for (const subscription of subscriptions) subscription.close(); + subscriptions = []; +}; + +const open = async (pubkey: string): Promise => { + const [relay, relays] = await Promise.all([import("@openspecs/nostr"), import("./relays")]); + nostr = relay; + // Signing out, or signing in as somebody else, while this was being fetched. + if (me !== pubkey) return; + + subscriptions.push( + relay.subscribeNotices(pubkey, receive, { + widen: () => relays.noticeRelays(pubkey), + onEose: () => { + // Said once, by the relays asked first. The ones added afterwards + // announce nothing, and saying it again would put a bell that has + // finished loading back into loading. + listed = true; + publish(); + }, + }), + ); + publish(); +}; + +/** + * Idempotent per key, so a component mounted twice in development does not open + * two subscriptions, and so does the header remounting on every navigation. + * + * Not gated on the session's status: reading what is addressed to a key needs + * the key and no signer at all, so a key still under its PIN keeps its bell. + */ +export const startNotices = (pubkey: string): void => { + if (typeof window === "undefined") return; + if (subscriptions.length > 0 && me === pubkey) return; + + close(); + if (me !== pubkey) { + events = new Map(); + state = { ...NO_NOTICES, me: pubkey, status: "loading" }; + } + me = pubkey; + listed = false; + + // Before anything is asked for, and deliberately: a relay that answers fast + // could otherwise deliver a year of news to a key this browser has never seen + // and have it counted as having happened while its reader was away. + noteKey(pubkey); + + notify(); + void open(pubkey); +}; + +/** + * The news has been shown, so it has been read. The mark is taken from what is + * actually on screen rather than from the clock: a Nostr timestamp is written by + * whoever signed the event, and marking up to now would swallow an event dated + * in the future without it ever having been drawn. + */ +export const markNoticesSeen = (): void => { + if (me === null) return; + const newest = state.notices.reduce( + (latest, notice) => Math.max(latest, notice.createdAt), + state.seenAt, + ); + markSeen(me, newest); + publish(); +}; + +/** Signing out. The mark stays where it is: it belongs to the key, not the session. */ +export const clearNotices = (): void => { + close(); + events = new Map(); + me = null; + listed = false; + if (timer !== null) clearTimeout(timer); + timer = null; + state = NO_NOTICES; + notify(); +}; diff --git a/apps/web/app/lib/paths.ts b/apps/web/app/lib/paths.ts index 18f3b0d..b63f13c 100644 --- a/apps/web/app/lib/paths.ts +++ b/apps/web/app/lib/paths.ts @@ -80,6 +80,9 @@ export const diffPath = (npub: string, identifier: string, otherNpub: string): s /** A document nobody has written yet, which belongs to whichever key is connected. */ export const newSpecPath = (): string => "/new"; +/** What was addressed to whichever key is connected. Nobody else has this page. */ +export const notificationsPath = (): string => "/notifications"; + /** * Writing a document again, under the address it already has. `specPath` is * canonical and lives with the schema; this URL is this site's own, like the diff --git a/apps/web/app/lib/relays.ts b/apps/web/app/lib/relays.ts index a5ec1b3..fad0369 100644 --- a/apps/web/app/lib/relays.ts +++ b/apps/web/app/lib/relays.ts @@ -98,6 +98,29 @@ export const inboxRelays = async (pubkeys: string[]): Promise => { return relaySet([...lists.values()].flatMap((list) => list.read)); }; +/** + * A backstop like `MAX_WRITE_RELAYS`, and lower for the same reason it exists at + * all: the four sets below add up to about a dozen, and nothing that truncates + * an honest case belongs here. + */ +export const MAX_NOTICE_RELAYS = 16; + +/** + * Where to read what is addressed to me, which is the mirror of `writeRelays` + * below and is in that order for the same reasons, read backwards: + * + * 1. my inbox, since that is where step 2 there aims and where the outbox model + * tells everybody else to reach me; + * 2. my own write relays, because what answers me usually lands on the writer's + * own relays too, and theirs overlap with mine more often than not; + * 3. the relays this kind of client reads and writes, step 4 there; + * 4. the relays this site reads, step 5. + */ +export const noticeRelays = async (me: string): Promise => { + const [inbox, mine] = await Promise.all([inboxRelays([me]), outboxRelays(me)]); + return relaySet(inbox, mine, DISCUSSION_RELAYS, DEFAULT_RELAYS).slice(0, MAX_NOTICE_RELAYS); +}; + export type WriteTarget = { /** * Everyone this event is addressed to: the document's author always, and the diff --git a/apps/web/app/lib/seen.test.ts b/apps/web/app/lib/seen.test.ts new file mode 100644 index 0000000..653aae7 --- /dev/null +++ b/apps/web/app/lib/seen.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { forgetSeen, markSeen, noteKey, parseSeen, seenAt, unreadCount } from "./seen"; + +const A = "a".repeat(64); +const B = "b".repeat(64); + +/** A fake with the one behaviour that matters: it can be written to and read back. */ +const fakeStorage = (broken = false): Storage => { + const held = new Map(); + return { + getItem: (key: string) => { + if (broken) throw new Error("this browser is in a private window"); + return held.get(key) ?? null; + }, + setItem: (key: string, value: string) => { + if (broken) throw new Error("this browser is in a private window"); + held.set(key, value); + }, + removeItem: (key: string) => { + if (broken) throw new Error("this browser is in a private window"); + held.delete(key); + }, + clear: () => held.clear(), + key: () => null, + length: 0, + } as unknown as Storage; +}; + +beforeEach(() => vi.stubGlobal("localStorage", fakeStorage())); +afterEach(() => vi.unstubAllGlobals()); + +describe("noteKey", () => { + it("starts a key it has never seen counting from now, not from the beginning", () => { + expect(seenAt(A)).toBeNull(); + expect(noteKey(A, 1_000)).toBe(1_000); + expect(seenAt(A)).toBe(1_000); + }); + + it("leaves a mark it already holds where it is", () => { + noteKey(A, 1_000); + expect(noteKey(A, 9_000)).toBe(1_000); + }); + + it("keeps two keys apart", () => { + noteKey(A, 1_000); + noteKey(B, 2_000); + expect([seenAt(A), seenAt(B)]).toEqual([1_000, 2_000]); + }); +}); + +describe("markSeen", () => { + it("moves the mark forward", () => { + noteKey(A, 1_000); + markSeen(A, 2_000); + expect(seenAt(A)).toBe(2_000); + }); + + it("never moves it back, whatever order two tabs write in", () => { + noteKey(A, 1_000); + markSeen(A, 2_000); + markSeen(A, 1_500); + expect(seenAt(A)).toBe(2_000); + }); + + it("ignores a number that is not one", () => { + noteKey(A, 1_000); + markSeen(A, Number.NaN); + expect(seenAt(A)).toBe(1_000); + }); + + it("marks a key nothing noted yet, since the mark belongs to the key", () => { + markSeen(B, 5_000); + expect(seenAt(B)).toBe(5_000); + }); +}); + +describe("the record on disk", () => { + it("survives a reload, which is the whole of what it is for", () => { + noteKey(A, 1_000); + markSeen(A, 4_000); + expect(parseSeen(JSON.parse(localStorage.getItem("openspecs:seen") ?? "null"))?.at[A]).toBe( + 4_000, + ); + }); + + it("keeps the eight most recently read keys and drops the oldest", () => { + const keys = "0123456789".split("").map((digit) => digit.repeat(64)); + keys.forEach((key, index) => { + noteKey(key, 1_000 + index); + }); + + const held = Object.keys( + parseSeen(JSON.parse(localStorage.getItem("openspecs:seen") ?? "null"))?.at ?? {}, + ); + expect(held).toHaveLength(8); + expect(held).not.toContain(keys[0]); + expect(held).toContain(keys[9]); + }); + + it("reads a record written by something else as no record at all", () => { + localStorage.setItem("openspecs:seen", JSON.stringify({ v: 2, at: { [A]: 9_000 } })); + expect(seenAt(A)).toBeNull(); + }); + + it("reads junk as no record at all rather than throwing", () => { + localStorage.setItem("openspecs:seen", "not json"); + expect(seenAt(A)).toBeNull(); + }); + + it("drops an entry that is not a key and a time", () => { + localStorage.setItem( + "openspecs:seen", + JSON.stringify({ v: 1, at: { "not-a-key": 1, [A]: "soon", [B]: 3_000 } }), + ); + expect([seenAt(A), seenAt(B)]).toEqual([null, 3_000]); + }); + + it("counts from the beginning, rather than throwing, where storage is refused", () => { + vi.stubGlobal("localStorage", fakeStorage(true)); + expect(() => markSeen(A, 1_000)).not.toThrow(); + expect(seenAt(A)).toBeNull(); + }); + + it("forgets everything when asked", () => { + noteKey(A, 1_000); + forgetSeen(); + expect(seenAt(A)).toBeNull(); + }); +}); + +describe("unreadCount", () => { + const notices = [{ createdAt: 100 }, { createdAt: 200 }, { createdAt: 300 }]; + + it("counts strictly newer, so the event that set the mark is not counted twice", () => { + expect(unreadCount(notices, 200)).toBe(1); + }); + + it("counts everything against a mark older than all of it", () => { + expect(unreadCount(notices, 0)).toBe(3); + }); + + it("counts nothing once the mark has caught up", () => { + expect(unreadCount(notices, 300)).toBe(0); + }); +}); diff --git a/apps/web/app/lib/seen.ts b/apps/web/app/lib/seen.ts new file mode 100644 index 0000000..8543cb9 --- /dev/null +++ b/apps/web/app/lib/seen.ts @@ -0,0 +1,113 @@ +const SEEN_KEY = "openspecs:seen"; + +export const SEEN_VERSION = 1; + +const HEX_64 = /^[0-9a-f]{64}$/; + +/** + * A browser that has held eight keys has forgotten why it held the first, and + * the mark of a key nobody signs in with any more is thirty bytes of nothing. + * The oldest mark goes, which is the one whose key read its news longest ago. + */ +const MAX_KEYS = 8; + +/** When each key was last shown its news, in unix seconds. */ +type Seen = { v: 1; at: Record }; + +const store = (): Storage | undefined => + typeof localStorage === "undefined" ? undefined : localStorage; + +/** + * Written by hand rather than with zod, the way `parseStoredSession` is, and the + * same parser guards the write: a record nothing can read is a badge that counts + * from the beginning of time. + */ +export const parseSeen = (input: unknown): Seen | null => { + if (typeof input !== "object" || input === null) return null; + const record = input as Record; + if (record.v !== SEEN_VERSION) return null; + if (typeof record.at !== "object" || record.at === null) return null; + + const at: Record = {}; + for (const [pubkey, value] of Object.entries(record.at as Record)) { + if (!HEX_64.test(pubkey)) continue; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) continue; + at[pubkey] = Math.floor(value); + } + return { v: SEEN_VERSION, at }; +}; + +/** + * Read through on every call rather than held in memory. Two tabs of this site + * are two readers of the same mail, and the one that reads it should not have to + * tell the other: the record on disk is what both of them ask. + */ +const read = (): Seen => { + try { + const raw = store()?.getItem(SEEN_KEY); + return ( + (raw === null || raw === undefined ? null : parseSeen(JSON.parse(raw))) ?? { + v: SEEN_VERSION, + at: {}, + } + ); + } catch { + return { v: SEEN_VERSION, at: {} }; + } +}; + +const write = (seen: Seen): void => { + const kept = Object.entries(seen.at) + .sort((a, b) => b[1] - a[1]) + .slice(0, MAX_KEYS); + try { + store()?.setItem(SEEN_KEY, JSON.stringify({ v: SEEN_VERSION, at: Object.fromEntries(kept) })); + } catch {} +}; + +export const seenAt = (pubkey: string): number | null => read().at[pubkey] ?? null; + +const nowSeconds = (): number => Math.floor(Date.now() / 1000); + +/** + * The mark a key reads its news against, seeded on the first sight of that key. + * + * A badge claims that something happened while you were away. A key connecting + * to this browser for the first time has never been here, so there was no while, + * and there is nothing this browser can honestly say about the five hundred + * things that happened before it met them. So the first sight of a key is the + * moment it starts counting from, and the news before it is history. + */ +export const noteKey = (pubkey: string, now = nowSeconds()): number => { + const seen = read(); + const held = seen.at[pubkey]; + if (held !== undefined) return held; + + seen.at[pubkey] = now; + write(seen); + return now; +}; + +/** + * Only ever forward. What is read stays read, whatever order two tabs write in + * and whatever a clock says after the machine has slept. + */ +export const markSeen = (pubkey: string, at: number): void => { + if (!Number.isFinite(at)) return; + const seen = read(); + const held = seen.at[pubkey] ?? 0; + if (at <= held) return; + + seen.at[pubkey] = Math.floor(at); + write(seen); +}; + +export const forgetSeen = (): void => { + try { + store()?.removeItem(SEEN_KEY); + } catch {} +}; + +/** Strictly newer, so the event that set the mark is not counted again. */ +export const unreadCount = (notices: { createdAt: number }[], at: number): number => + notices.filter((notice) => notice.createdAt > at).length; diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index 68905c9..39bdec6 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -4,6 +4,7 @@ export default [ index("routes/home.tsx"), route("specs", "routes/specs.tsx"), route("settings", "routes/settings.tsx"), + route("notifications", "routes/notifications.tsx"), route("new", "routes/new.tsx"), route("sitemap.xml", "routes/sitemap.ts"), route("robots.txt", "routes/robots.ts"), diff --git a/apps/web/app/routes/notifications.tsx b/apps/web/app/routes/notifications.tsx new file mode 100644 index 0000000..2bc643b --- /dev/null +++ b/apps/web/app/routes/notifications.tsx @@ -0,0 +1,87 @@ +import { useEffect, useState, useSyncExternalStore } from "react"; +import { NoticeRow } from "~/components/notifications/notice-row"; +import { Shell } from "~/components/shell"; +import { PAGE_HEADERS } from "~/lib/http"; +import { + markNoticesSeen, + noticesState, + serverNoticesState, + subscribeNoticesState, +} from "~/lib/notifications"; +import { authorsState, serverAuthorsState, subscribeAuthors, wantAuthors } from "~/lib/profiles"; +import { restoreSession, serverSessionState, sessionState, subscribeSession } from "~/lib/session"; +import type { Route } from "./+types/notifications"; + +export function meta(_: Route.MetaArgs) { + return [ + { title: "Notifications | Open Specs" }, + // Nothing here belongs to the site: it is one key's mail, read in one + // browser, and what a crawler would index is the line asking it to connect. + { name: "robots", content: "noindex, nofollow" }, + ]; +} + +export function headers(_: Route.HeadersArgs) { + return PAGE_HEADERS; +} + +/** + * Everything addressed to the connected key. No loader, like the settings page + * and for the same reason: it is asked for with the reader's key, in the + * reader's browser, and the server has neither. + * + * The subscription itself belongs to the bell in the header, which is on this + * page too and has been running since whichever page came before it. + */ +export default function NotificationsRoute() { + useEffect(restoreSession, []); + + const session = useSyncExternalStore(subscribeSession, sessionState, serverSessionState); + const state = useSyncExternalStore(subscribeNoticesState, noticesState, serverNoticesState); + + /** The mark as it stood when this page was opened, so the rows stay marked. */ + const [shownFrom, setShownFrom] = useState(null); + useEffect(() => { + if (state.me === null) return; + setShownFrom((held) => held ?? state.seenAt); + markNoticesSeen(); + }, [state.me, state.seenAt]); + + const authors = useSyncExternalStore(subscribeAuthors, authorsState, serverAuthorsState); + useEffect(() => { + const keys = state.notices.map((notice) => notice.pubkey); + if (keys.length > 0) wantAuthors(keys); + }, [state.notices]); + + return ( + +
    +

    Notifications

    + + {session.pubkey === null ? ( +

    + Connect a key and this page fills with what was addressed to it: what people wrote, + marked or paid on your documents, and who else signs their name to one. +

    + ) : state.notices.length === 0 ? ( +

    + {state.status === "ready" + ? "Nothing yet. This page stays empty until somebody answers something of yours." + : "Asking the relays."} +

    + ) : ( +
      + {state.notices.map((notice) => ( + (shownFrom ?? 0)} + /> + ))} +
    + )} +
    +
    + ); +} From 3d002594ef960c0aad9eca01cc61b9df26957749 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 24 Aug 2026 11:45:56 +0200 Subject: [PATCH 3/6] feat: enhance notifications with copy management --- apps/web/app/lib/notifications.test.ts | 186 ++++++++++++++++++++++++- apps/web/app/lib/notifications.ts | 76 +++++++++- 2 files changed, 258 insertions(+), 4 deletions(-) diff --git a/apps/web/app/lib/notifications.test.ts b/apps/web/app/lib/notifications.test.ts index 6ef3951..b9c232a 100644 --- a/apps/web/app/lib/notifications.test.ts +++ b/apps/web/app/lib/notifications.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -const nostr = vi.hoisted(() => ({ subscribeNotices: vi.fn() })); +const nostr = vi.hoisted(() => ({ + subscribeNotices: vi.fn(), + subscribeCopies: vi.fn(), + fetchSpecs: vi.fn(), +})); vi.mock("@openspecs/nostr", async (importOriginal) => ({ ...(await importOriginal()), @@ -9,7 +13,7 @@ vi.mock("@openspecs/nostr", async (importOriginal) => ({ vi.mock("./relays", () => ({ noticeRelays: vi.fn(async () => []) })); -import { buildComment, type NostrEvent, SPEC_KIND } from "@openspecs/nostr"; +import { buildComment, type NostrEvent, parseSpec, SPEC_KIND, type Spec } from "@openspecs/nostr"; import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure"; import { clearNotices, @@ -33,6 +37,30 @@ const ROOT = { coordinate: `${SPEC_KIND}:${ME}:a-specification`, pubkey: ME }; const comment = (content: string, at: number) => finalizeEvent({ ...buildComment({ root: ROOT, content }), created_at: at }, theirKey); +const specEvent = ( + by: Uint8Array, + identifier: string, + { at = 100, content = "# A copy" } = {}, +): NostrEvent => + finalizeEvent( + { + kind: SPEC_KIND, + content, + created_at: at, + tags: [ + ["d", identifier], + ["title", "A copy"], + ], + }, + by, + ) as NostrEvent; + +const asSpec = (event: NostrEvent): Spec => { + const spec = parseSpec(event); + if (spec === null) throw new Error("fixture is not a specification"); + return spec; +}; + /** A fake with the one behaviour that matters: it can be written to and read back. */ const fakeStorage = (): Storage => { const held = new Map(); @@ -50,6 +78,8 @@ const fakeStorage = (): Storage => { type Channel = { send: (event: NostrEvent) => void; eose: () => void }; let channel: Channel; +/** The copy subscription, opened only once my own documents are known. */ +let copyChannel: Channel | null; let closed = 0; beforeEach(() => { @@ -59,7 +89,21 @@ beforeEach(() => { clearNotices(); forgetSeen(); closed = 0; + copyChannel = null; nostr.subscribeNotices.mockClear(); + nostr.subscribeCopies.mockClear(); + nostr.fetchSpecs.mockReset(); + // Nobody has published anything unless a test says so. + nostr.fetchSpecs.mockResolvedValue([]); + + nostr.subscribeCopies.mockImplementation((_names, onEvent, options) => { + copyChannel = { send: onEvent, eose: () => options?.onEose?.() }; + return { + close: () => { + closed += 1; + }, + }; + }); nostr.subscribeNotices.mockImplementation((_pubkey, onEvent, options) => { channel = { send: onEvent, eose: () => options?.onEose?.() }; @@ -227,3 +271,141 @@ describe("clearNotices", () => { expect(seenAt(ME)).toBe(2_000); }); }); + +describe("copies under one of my names", () => { + const mineNamed = (identifier: string) => asSpec(specEvent(myKey, identifier)); + + /** + * Throws rather than shrugging when the copy subscription was never opened: a + * test expecting no row must fail when nothing was ever sent to it. + */ + const sendCopy = (event: NostrEvent) => { + if (copyChannel === null) throw new Error("the copy subscription was never opened"); + copyChannel.send(event); + }; + + it("watches the names I publish under, and nothing else", async () => { + nostr.fetchSpecs.mockResolvedValue([mineNamed("nip-07"), mineNamed("nip-46")]); + startNotices(ME); + await settle(); + + expect(nostr.subscribeCopies).toHaveBeenCalledTimes(1); + const watched = (nostr.subscribeCopies.mock.calls[0]?.[0] ?? []) as string[]; + expect([...watched].sort()).toEqual(["nip-07", "nip-46"]); + }); + + it("leaves out a name I withdrew, since a document that is gone has no copies", async () => { + const withdrawn = asSpec(specEvent(myKey, "nip-46", { content: "" })); + nostr.fetchSpecs.mockResolvedValue([mineNamed("nip-07"), withdrawn]); + startNotices(ME); + await settle(); + + expect(nostr.subscribeCopies.mock.calls[0]?.[0]).toEqual(["nip-07"]); + }); + + it("opens nothing at all for a key that has published nothing", async () => { + startNotices(ME); + await settle(); + expect(nostr.subscribeCopies).not.toHaveBeenCalled(); + }); + + it("draws another key publishing under one of my names", async () => { + nostr.fetchSpecs.mockResolvedValue([mineNamed("nip-07")]); + startNotices(ME); + await settle(); + + sendCopy(specEvent(theirKey, "nip-07", { at: 2_000 })); + await settle(); + + const [notice] = noticesState().notices; + expect(notice?.kind).toBe("copy"); + expect(notice?.document.identifier).toBe("nip-07"); + // Their copy, not mine: the row leads to the document it is about. + expect(notice?.document.pubkey).not.toBe(ME); + }); + + it("takes a copy revised twice as one row, dated by the newer revision", async () => { + nostr.fetchSpecs.mockResolvedValue([mineNamed("nip-07")]); + startNotices(ME); + await settle(); + + sendCopy(specEvent(theirKey, "nip-07", { at: 2_000 })); + sendCopy(specEvent(theirKey, "nip-07", { at: 3_000 })); + await settle(); + + expect(noticesState().notices).toHaveLength(1); + expect(noticesState().notices[0]?.createdAt).toBe(3_000); + }); + + it("keeps the newest when an older revision arrives last, as relays serve them", async () => { + nostr.fetchSpecs.mockResolvedValue([mineNamed("nip-07")]); + startNotices(ME); + await settle(); + + sendCopy(specEvent(theirKey, "nip-07", { at: 3_000 })); + sendCopy(specEvent(theirKey, "nip-07", { at: 2_000 })); + await settle(); + + expect(noticesState().notices).toHaveLength(1); + expect(noticesState().notices[0]?.createdAt).toBe(3_000); + }); + + it("draws two keys under the same name as two rows", async () => { + nostr.fetchSpecs.mockResolvedValue([mineNamed("nip-07")]); + startNotices(ME); + await settle(); + + sendCopy(specEvent(theirKey, "nip-07", { at: 2_000 })); + sendCopy(specEvent(otherKey, "nip-07", { at: 2_100 })); + await settle(); + + expect(noticesState().notices).toHaveLength(2); + }); + + it("says nothing about my own revisions", async () => { + nostr.fetchSpecs.mockResolvedValue([mineNamed("nip-07")]); + startNotices(ME); + await settle(); + + sendCopy(specEvent(myKey, "nip-07", { at: 2_000 })); + await settle(); + + expect(noticesState().notices).toEqual([]); + }); + + it("drops a copy under a name that is not one of mine, whatever the filter matched", async () => { + nostr.fetchSpecs.mockResolvedValue([mineNamed("nip-07")]); + startNotices(ME); + await settle(); + + sendCopy(specEvent(theirKey, "some-other-name", { at: 2_000 })); + await settle(); + + expect(noticesState().notices).toEqual([]); + }); + + it("drops a copy its author withdrew", async () => { + nostr.fetchSpecs.mockResolvedValue([mineNamed("nip-07")]); + startNotices(ME); + await settle(); + + sendCopy(specEvent(theirKey, "nip-07", { at: 2_000, content: "" })); + await settle(); + + expect(noticesState().notices).toEqual([]); + }); + + it("forgets the copies and the names when another key connects", async () => { + nostr.fetchSpecs.mockResolvedValue([mineNamed("nip-07")]); + startNotices(ME); + await settle(); + sendCopy(specEvent(theirKey, "nip-07", { at: 2_000 })); + await settle(); + expect(noticesState().notices).toHaveLength(1); + + nostr.fetchSpecs.mockResolvedValue([]); + startNotices(SOMEBODY_ELSE); + await settle(); + expect(noticesState().notices).toEqual([]); + }); +}); diff --git a/apps/web/app/lib/notifications.ts b/apps/web/app/lib/notifications.ts index dc5cc01..b977ba7 100644 --- a/apps/web/app/lib/notifications.ts +++ b/apps/web/app/lib/notifications.ts @@ -1,4 +1,4 @@ -import type { NostrEvent, Notice, Subscription } from "@openspecs/nostr"; +import type { NostrEvent, Notice, Spec, Subscription } from "@openspecs/nostr"; import { markSeen, noteKey, seenAt, unreadCount } from "./seen"; export type NoticesStatus = "idle" | "loading" | "ready"; @@ -25,11 +25,23 @@ export const NO_NOTICES: NoticesState = Object.freeze({ /** Often enough to watch news arrive, rarely enough not to re-sort on every event. */ const NOTIFY_MS = 150; +/** + * One filter's worth of names, the wall `copyFilters` chunks at. Somebody who + * publishes more documents than this is watched for the first two hundred: + * asking every relay about a thousand names to draw a row nobody may ever get + * costs more than the row is worth. + */ +const MAX_WATCHED_NAMES = 200; + let state = NO_NOTICES; let me: string | null = null; /** The relays have listed what they hold. Not that events stopped arriving. */ let listed = false; let events = new Map(); +/** Other keys' documents under one of my names, one live revision per coordinate. */ +let copies = new Map(); +/** The names I publish under, which is what makes a copy a copy of mine. */ +let names = new Set(); let subscriptions: Subscription[] = []; let timer: ReturnType | null = null; @@ -61,7 +73,7 @@ const notify = (): void => { const recompute = (): NoticesState => { if (me === null || nostr === null) return NO_NOTICES; const mark = seenAt(me) ?? 0; - const notices = nostr.sortNotices([...events.values()], { me }); + const notices = nostr.sortNotices([...events.values()], { me, copies: [...copies.values()] }); return { me, @@ -95,6 +107,61 @@ const receive = (event: NostrEvent): void => { publishSoon(); }; +/** + * A copy is held as a document rather than as an event, keyed by its coordinate. + * An addressable event gets a new id every time its author saves it, so the same + * copy revised four times is four events and one thing that happened: somebody + * else is publishing under your name. This is `latestByCoordinate` applied one + * event at a time, tie broken on the lower id as NIP-01 breaks it, and the row + * ends up dated by the newest revision, which is when they last touched it. + */ +const receiveCopy = (event: NostrEvent): void => { + if (nostr === null || me === null) return; + const spec = nostr.parseSpec(event); + // Relays index tag values, they do not check them, and a withdrawn copy is a + // copy taken back. The name is checked again for the first reason. + if (spec === null || spec.pubkey === me || spec.isEmpty) return; + if (!names.has(spec.identifier)) return; + + const coordinate = nostr.toCoordinate(spec); + const current = copies.get(coordinate); + if (current !== undefined) { + if (spec.createdAt < current.createdAt) return; + if (spec.createdAt === current.createdAt && spec.event.id >= current.event.id) return; + } + copies.set(coordinate, spec); + publishSoon(); +}; + +/** + * Which names to watch, which nothing can say until my own documents are known. + * A second round trip, and deliberately behind the first: what was addressed to + * me is on screen while this is still being asked for. + * + * A name I published an empty revision over is left out. That is a judgement + * call worth naming, since the opposite reading holds too: somebody taking over + * a name I withdrew is arguably the thing I most want to hear about. Withdrawing + * is how this site says a document is gone, and a document that is gone has no + * copies to speak of. + */ +const watchCopies = async ( + pubkey: string, + relay: typeof import("@openspecs/nostr"), +): Promise => { + const mine = await relay.fetchSpecs({ authors: [pubkey] }); + if (me !== pubkey) return; + + names = new Set( + [...new Set(mine.filter((spec) => !spec.isEmpty).map((spec) => spec.identifier))].slice( + 0, + MAX_WATCHED_NAMES, + ), + ); + if (names.size === 0 || me !== pubkey) return; + + subscriptions.push(relay.subscribeCopies([...names], receiveCopy)); +}; + const close = (): void => { for (const subscription of subscriptions) subscription.close(); subscriptions = []; @@ -119,6 +186,7 @@ const open = async (pubkey: string): Promise => { }), ); publish(); + void watchCopies(pubkey, relay); }; /** @@ -135,6 +203,8 @@ export const startNotices = (pubkey: string): void => { close(); if (me !== pubkey) { events = new Map(); + copies = new Map(); + names = new Set(); state = { ...NO_NOTICES, me: pubkey, status: "loading" }; } me = pubkey; @@ -169,6 +239,8 @@ export const markNoticesSeen = (): void => { export const clearNotices = (): void => { close(); events = new Map(); + copies = new Map(); + names = new Set(); me = null; listed = false; if (timer !== null) clearTimeout(timer); From e1c2db58f036896f8c2858ef2204480dd882e318 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 24 Aug 2026 12:05:33 +0200 Subject: [PATCH 4/6] feat: ask the second question a first answer makes possible --- .../web/app/components/discussion/comment.tsx | 5 +- .../app/components/discussion/discussion.tsx | 19 ++- .../components/notifications/notice-row.tsx | 29 +++- apps/web/app/lib/notifications.test.ts | 161 ++++++++++++++++-- apps/web/app/lib/notifications.ts | 38 +++++ packages/nostr/src/index.ts | 3 + packages/nostr/src/notifications.ts | 118 +++++++++++-- packages/nostr/test/notifications.test.ts | 67 ++++++++ 8 files changed, 403 insertions(+), 37 deletions(-) diff --git a/apps/web/app/components/discussion/comment.tsx b/apps/web/app/components/discussion/comment.tsx index a756718..aac581f 100644 --- a/apps/web/app/components/discussion/comment.tsx +++ b/apps/web/app/components/discussion/comment.tsx @@ -79,7 +79,10 @@ export const CommentThread = ({ const author = authors[comment.pubkey] ?? null; return ( -
    + // Addressable, so a notification can land on the comment it is about rather + // than on the conversation holding it. The margin is what keeps the header + // off it once the browser has scrolled there. +
    split(roots, revisedAt), [roots, revisedAt]); const since = roots[0]?.comment.createdAt ?? null; + /** + * A link from a notification names the comment it is about. The browser looks + * for that anchor when the page loads and does not find it: the conversation + * is fetched from the relays afterwards, and drawn later still. So the scroll + * is done here, once the record stands, and once per hash, or a reader who has + * scrolled away would be dragged back by the next event to arrive. + */ + const { hash } = useLocation(); + const jumped = useRef(null); + useEffect(() => { + const id = hash.slice(1); + if (!ready || id === "" || id === DISCUSSION_ID || jumped.current === hash) return; + jumped.current = hash; + document.getElementById(id)?.scrollIntoView({ block: "start" }); + }, [hash, ready]); + return (
    diff --git a/apps/web/app/components/notifications/notice-row.tsx b/apps/web/app/components/notifications/notice-row.tsx index 2f6ff23..73e9b7d 100644 --- a/apps/web/app/components/notifications/notice-row.tsx +++ b/apps/web/app/components/notifications/notice-row.tsx @@ -24,9 +24,11 @@ const said = (notice: Notice): string => { case "thread": return `replied under ${name}`; case "reaction": - return `reacted to ${name}`; + return notice.onComment ? `reacted to your comment on ${name}` : `reacted to ${name}`; case "zap": - return `zapped ${name}, ${notice.sats} sats`; + return notice.onComment + ? `zapped your comment on ${name}, ${notice.sats} sats` + : `zapped ${name}, ${notice.sats} sats`; case "copy": return `published under the name ${name}`; } @@ -47,12 +49,29 @@ const ReactionMark = ({ notice }: { notice: Notice }) => ); /** - * A conversation is a client rendered part of the document's page, so a link - * into it can only name the section until the comment itself has an address. + * Which comment on the document's page this is about, when it is about one. A + * comment answering me is itself the place to land; a reaction has no place of + * its own, so it lands on what it answered. */ +const anchor = (notice: Notice): string | null => { + switch (notice.kind) { + case "comment": + case "reply": + case "thread": + return notice.id; + case "reaction": + case "zap": + return notice.onComment ? notice.targetId : null; + case "copy": + return null; + } +}; + +/** Falling back to the conversation as a whole, which every document's page has. */ const destination = (notice: Notice): string => { const path = specPath(notice.document); - return notice.kind === "copy" ? path : `${path}#${DISCUSSION_ID}`; + if (notice.kind === "copy") return path; + return `${path}#${anchor(notice) ?? DISCUSSION_ID}`; }; export const NoticeRow = ({ diff --git a/apps/web/app/lib/notifications.test.ts b/apps/web/app/lib/notifications.test.ts index b9c232a..e52e45c 100644 --- a/apps/web/app/lib/notifications.test.ts +++ b/apps/web/app/lib/notifications.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const nostr = vi.hoisted(() => ({ subscribeNotices: vi.fn(), subscribeCopies: vi.fn(), + subscribeRetractions: vi.fn(), + subscribeNamed: vi.fn(), fetchSpecs: vi.fn(), })); @@ -13,7 +15,16 @@ vi.mock("@openspecs/nostr", async (importOriginal) => ({ vi.mock("./relays", () => ({ noticeRelays: vi.fn(async () => []) })); -import { buildComment, type NostrEvent, parseSpec, SPEC_KIND, type Spec } from "@openspecs/nostr"; +import { + buildComment, + buildReaction, + buildRetraction, + COMMENT_KIND, + type NostrEvent, + parseSpec, + SPEC_KIND, + type Spec, +} from "@openspecs/nostr"; import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure"; import { clearNotices, @@ -74,44 +85,69 @@ const fakeStorage = (): Storage => { } as unknown as Storage; }; +const sign = ( + draft: { kind: number; content: string; tags: string[][] }, + by: Uint8Array, + at = 100, +) => finalizeEvent({ ...draft, created_at: at }, by) as NostrEvent; + /** The one subscription, held so a test can play the relays' part by hand. */ type Channel = { send: (event: NostrEvent) => void; eose: () => void }; let channel: Channel; /** The copy subscription, opened only once my own documents are known. */ let copyChannel: Channel | null; +/** Whichever of the two second passes was opened last. */ +let secondPass: Channel | null; +/** Every subscription the store opened, and every one it closed again. */ +let opened = 0; let closed = 0; +/** What a mocked subscription hands back, counted so nothing can be leaked. */ +const handle = () => { + opened += 1; + return { + close: () => { + closed += 1; + }, + }; +}; + beforeEach(() => { vi.stubGlobal("window", {}); vi.stubGlobal("localStorage", fakeStorage()); vi.useFakeTimers(); clearNotices(); forgetSeen(); + opened = 0; closed = 0; copyChannel = null; + secondPass = null; nostr.subscribeNotices.mockClear(); nostr.subscribeCopies.mockClear(); + nostr.subscribeRetractions.mockClear(); + nostr.subscribeNamed.mockClear(); + + // The two second passes deliver on the same channel the first one does, so a + // test plays them by holding on to the callback they were opened with. + for (const second of [nostr.subscribeRetractions, nostr.subscribeNamed]) { + second.mockImplementation((_ids, onEvent) => { + secondPass = { send: onEvent, eose: () => {} }; + return handle(); + }); + } nostr.fetchSpecs.mockReset(); // Nobody has published anything unless a test says so. nostr.fetchSpecs.mockResolvedValue([]); nostr.subscribeCopies.mockImplementation((_names, onEvent, options) => { copyChannel = { send: onEvent, eose: () => options?.onEose?.() }; - return { - close: () => { - closed += 1; - }, - }; + return handle(); }); nostr.subscribeNotices.mockImplementation((_pubkey, onEvent, options) => { channel = { send: onEvent, eose: () => options?.onEose?.() }; - return { - close: () => { - closed += 1; - }, - }; + return handle(); }); }); @@ -150,9 +186,11 @@ describe("startNotices", () => { await settle(); expect(noticesState().notices).toHaveLength(1); + // Everything the previous key had open, second passes included, is closed. + const hadOpen = opened; startNotices(SOMEBODY_ELSE); await settle(); - expect(closed).toBe(1); + expect(closed).toBe(hadOpen); expect(noticesState().me).toBe(SOMEBODY_ELSE); expect(noticesState().notices).toEqual([]); }); @@ -254,7 +292,7 @@ describe("clearNotices", () => { await settle(); clearNotices(); - expect(closed).toBe(1); + expect(closed).toBe(opened); expect(noticesState().me).toBeNull(); expect(noticesState().notices).toEqual([]); }); @@ -409,3 +447,100 @@ describe("copies under one of my names", () => { expect(noticesState().notices).toEqual([]); }); }); + +describe("the second passes", () => { + const ROOT_THEIRS = { + coordinate: `${SPEC_KIND}:${SOMEBODY_ELSE}:their-doc`, + pubkey: SOMEBODY_ELSE, + }; + + const send = (channel: Channel | null, event: NostrEvent, what: string) => { + if (channel === null) throw new Error(`the ${what} subscription was never opened`); + channel.send(event); + }; + + it("asks whether what arrived is still standing", async () => { + startNotices(ME); + await settle(); + const written = comment("a note", 2_000) as NostrEvent; + channel.send(written); + await settle(); + + expect(nostr.subscribeRetractions).toHaveBeenCalled(); + expect(nostr.subscribeRetractions.mock.calls[0]?.[0]).toContain(written.id); + }); + + it("asks about an id once and not again on the next event", async () => { + startNotices(ME); + await settle(); + channel.send(comment("one", 2_000) as NostrEvent); + await settle(); + const asked = nostr.subscribeRetractions.mock.calls.length; + + channel.send(comment("two", 2_100) as NostrEvent); + await settle(); + + const second = nostr.subscribeRetractions.mock.calls[asked]?.[0] as string[] | undefined; + expect(second).toHaveLength(1); + }); + + it("drops a row once the answer says its author took it back", async () => { + startNotices(ME); + await settle(); + const written = comment("a note", 2_000) as NostrEvent; + channel.send(written); + await settle(); + expect(noticesState().notices).toHaveLength(1); + + send(secondPass, sign(buildRetraction(written.id), theirKey, 2_100), "retraction"); + await settle(); + expect(noticesState().notices).toEqual([]); + }); + + it("resolves a reaction that named only an event id, and draws it", async () => { + startNotices(ME); + await settle(); + + // My own comment on somebody else's document, and a reaction to it. + const mine = finalizeEvent( + { ...buildComment({ root: ROOT_THEIRS, content: "a point of mine" }), created_at: 1_900 }, + myKey, + ) as NostrEvent; + const reacted = sign( + buildReaction({ id: mine.id, pubkey: ME, kind: COMMENT_KIND }, "+"), + theirKey, + 2_000, + ); + + channel.send(reacted); + await settle(); + // Nothing yet: the id names something this browser cannot see is mine. + expect(noticesState().notices).toEqual([]); + expect(nostr.subscribeNamed.mock.calls[0]?.[0]).toEqual([mine.id]); + + send(secondPass, mine, "named"); + await settle(); + + const [notice] = noticesState().notices; + expect(notice?.kind).toBe("reaction"); + expect(notice?.onComment).toBe(true); + }); + + it("leaves it dropped when the answer says the comment was somebody else's", async () => { + startNotices(ME); + await settle(); + + const theirs = finalizeEvent( + { ...buildComment({ root: ROOT_THEIRS, content: "not mine" }), created_at: 1_900 }, + theirKey, + ) as NostrEvent; + channel.send( + sign(buildReaction({ id: theirs.id, pubkey: ME, kind: COMMENT_KIND }, "+"), otherKey, 2_000), + ); + await settle(); + + send(secondPass, theirs, "named"); + await settle(); + expect(noticesState().notices).toEqual([]); + }); +}); diff --git a/apps/web/app/lib/notifications.ts b/apps/web/app/lib/notifications.ts index b977ba7..b3a7355 100644 --- a/apps/web/app/lib/notifications.ts +++ b/apps/web/app/lib/notifications.ts @@ -42,6 +42,8 @@ let events = new Map(); let copies = new Map(); /** The names I publish under, which is what makes a copy a copy of mine. */ let names = new Set(); +/** Ids a second pass already covers, so widening it asks only about the new ones. */ +let asked = new Set(); let subscriptions: Subscription[] = []; let timer: ReturnType | null = null; @@ -84,12 +86,44 @@ const recompute = (): NoticesState => { }; }; +/** + * The two questions that can only be asked once the answers to the first have + * arrived: whether what is held has since been taken back, and what the events + * a reaction pointed at without explaining actually are. + * + * Every new id opens one more subscription rather than replacing the one + * running: the relays have already sent what they hold for the ids asked + * before, and asking again pays for it twice. + * + * Unlike the conversation under a document, nothing here waits on these. A + * conversation is the page, so it is worth four seconds not to show a comment + * about to be taken away again. A notification is a glance, and a bell with no + * number on it for four seconds of every page load is the worse lie. + */ +const askAbout = (notices: Notice[]): void => { + if (nostr === null) return; + + const want = (ids: string[]) => ids.filter((id) => !asked.has(id)); + + // Zaps are left out: a receipt is signed by somebody's LNURL server, and a + // deletion of it by anybody else is not the author taking their words back. + const standing = want( + notices.filter((notice) => notice.kind !== "copy" && notice.kind !== "zap").map((n) => n.id), + ); + const named = want(nostr.unnamedTargets([...events.values()])); + + for (const id of [...standing, ...named]) asked.add(id); + if (standing.length > 0) subscriptions.push(nostr.subscribeRetractions(standing, receive)); + if (named.length > 0) subscriptions.push(nostr.subscribeNamed(named, receive)); +}; + const publish = (): void => { if (timer !== null) { clearTimeout(timer); timer = null; } state = recompute(); + askAbout(state.notices); notify(); }; @@ -209,6 +243,9 @@ export const startNotices = (pubkey: string): void => { } me = pubkey; listed = false; + // Cleared even when the events are kept: the subscriptions watching these ids + // were just closed, and nothing would reopen them. + asked = new Set(); // Before anything is asked for, and deliberately: a relay that answers fast // could otherwise deliver a year of news to a key this browser has never seen @@ -241,6 +278,7 @@ export const clearNotices = (): void => { events = new Map(); copies = new Map(); names = new Set(); + asked = new Set(); me = null; listed = false; if (timer !== null) clearTimeout(timer); diff --git a/packages/nostr/src/index.ts b/packages/nostr/src/index.ts index 78699dc..b439c06 100644 --- a/packages/nostr/src/index.ts +++ b/packages/nostr/src/index.ts @@ -162,12 +162,15 @@ export { type NoticeKind, type NoticeOptions, type NoticeScope, + namedFilters, notificationFilters, sortNotices, subscribeCopies, + subscribeNamed, subscribeNotices, subscribeRetractions, targetFilters, + unnamedTargets, } from "./notifications"; export { closeRelayPool, diff --git a/packages/nostr/src/notifications.ts b/packages/nostr/src/notifications.ts index 66231ac..dd41a97 100644 --- a/packages/nostr/src/notifications.ts +++ b/packages/nostr/src/notifications.ts @@ -51,6 +51,32 @@ export const copyFilters = (identifiers: string[]): Filter[] => export const targetFilters = (ids: string[]): Filter[] => inChunks(ids).map((chunk) => ({ kinds: [DELETION_KIND], "#e": chunk })); +/** The events themselves, for the ids a reaction or a zap named and nothing explained. */ +export const namedFilters = (ids: string[]): Filter[] => + inChunks(ids).map((chunk) => ({ ids: chunk })); + +/** + * The ids a reaction or a zap pointed at without saying which document it was + * about, and which nothing here holds the event for yet. + * + * A client reacting to a comment has no coordinate to name, since a comment is + * not addressable, so it names an id and nothing else. Fetched, that id is + * either a comment I wrote, which makes the reaction mine to hear about, or it + * is not, which makes the `p` tag on it somebody tagging a stranger. + */ +export const unnamedTargets = (events: NostrEvent[]): string[] => { + const held = new Set(events.map((event) => event.id)); + const wanted = new Set(); + + for (const event of events) { + if (event.kind !== REACTION_KIND && event.kind !== ZAP_RECEIPT_KIND) continue; + if (tagValue(event, "a") !== "") continue; + const targetId = tagValue(event, "e"); + if (targetId !== "" && !held.has(targetId)) wanted.add(targetId); + } + return [...wanted]; +}; + /** * What happened, which decides the sentence the row is drawn with. * @@ -71,6 +97,12 @@ export type Notice = { document: { pubkey: string; identifier: string; coordinate: string }; /** What was answered, when it was something other than the document itself. */ targetId: string | null; + /** + * Whether `targetId` is a comment of mine rather than a document of mine. It + * decides both the sentence the row is drawn with and whether the link can + * name a place in the conversation or only the conversation. + */ + onComment: boolean; /** The words, the reaction's symbol, or the copy's title. */ content: string; /** A NIP-30 custom emoji's image, on a reaction that named one. */ @@ -112,41 +144,65 @@ const noticeFromComment = (comment: Comment, me: string): Notice | null => { const document = documentOf(comment.rootCoordinate); if (document === null) return null; - const answered = tagValue(comment.event, "p") === me; - if (document.pubkey !== me && !answered) return null; + const answersMe = tagValue(comment.event, "p") === me; + if (document.pubkey !== me && !answersMe) return null; return { id: comment.id, - kind: comment.parentId === null ? "comment" : answered ? "reply" : "thread", + kind: comment.parentId === null ? "comment" : answersMe ? "reply" : "thread", pubkey: comment.pubkey, createdAt: comment.createdAt, document, targetId: comment.parentId, + onComment: comment.parentId !== null, content: comment.content, emojiUrl: null, sats: null, }; }; +/** A comment of mine, by id, so that what answers it can be attributed. */ +type MyComments = ReadonlyMap>; + /** + * What a reaction or a zap was about, and whether it is mine to hear about. + * * Anyone may put my key in a `p` tag, so that tag decides what a relay sends and - * never what is shown. What is shown is decided by the target: a reaction to a - * document of mine is one I can see is mine. A reaction naming only an event id - * is dropped here rather than trusted, and asking whose event that id is takes a - * second round trip the caller makes when it wants those rows. + * never what is shown. The target decides that. A coordinate naming a document + * of mine is one I can see is mine; an event id is only mine once it has been + * fetched and turns out to be a comment I wrote. Anything else is somebody + * tagging me, and is dropped. */ -const noticeFromReaction = (reaction: Reaction, event: NostrEvent, me: string): Notice | null => { +const answered = ( + target: { targetId: string | null; targetCoordinate: string | null }, + me: string, + mine: MyComments, +): { document: NonNullable>; onComment: boolean } | null => { + const named = documentOf(target.targetCoordinate); + if (named !== null && named.pubkey === me) return { document: named, onComment: false }; + + const comment = target.targetId === null ? undefined : mine.get(target.targetId); + return comment === undefined || comment === null ? null : { document: comment, onComment: true }; +}; + +const noticeFromReaction = ( + reaction: Reaction, + event: NostrEvent, + me: string, + mine: MyComments, +): Notice | null => { if (event.pubkey === me) return null; - const document = documentOf(reaction.targetCoordinate); - if (document === null || document.pubkey !== me) return null; + const about = answered(reaction, me, mine); + if (about === null) return null; return { id: reaction.id, kind: "reaction", pubkey: reaction.pubkey, createdAt: reaction.createdAt, - document, + document: about.document, targetId: reaction.targetId, + onComment: about.onComment, content: reaction.symbol, emojiUrl: reaction.emojiUrl, sats: null, @@ -158,18 +214,19 @@ const noticeFromReaction = (reaction: Reaction, event: NostrEvent, me: string): * known through the request it echoed back. A receipt that carries no request * names nobody, and a row that cannot say who paid is not worth a line. */ -const noticeFromZap = (zap: ZapReceipt, me: string): Notice | null => { +const noticeFromZap = (zap: ZapReceipt, me: string, mine: MyComments): Notice | null => { if (zap.recipient !== me || zap.zapper === null || zap.zapper === me) return null; - const document = documentOf(zap.targetCoordinate); - if (document === null || document.pubkey !== me) return null; + const about = answered(zap, me, mine); + if (about === null) return null; return { id: zap.id, kind: "zap", pubkey: zap.zapper, createdAt: zap.createdAt, - document, + document: about.document, targetId: zap.targetId, + onComment: about.onComment, content: zap.comment, emojiUrl: null, sats: zap.amountSats, @@ -189,6 +246,7 @@ const noticeFromCopy = (spec: Spec, me: string): Notice | null => { coordinate: toCoordinate(spec), }, targetId: null, + onComment: false, content: spec.title, emojiUrl: null, sats: null, @@ -229,6 +287,16 @@ export const sortNotices = (events: NostrEvent[], scope: NoticeScope): Notice[] const deletions: Deletion[] = []; const seen = new Set(); + // My own comments are never news, but they are what a reaction naming an id + // and nothing else has to be read against, so they are indexed before the + // pass that would otherwise throw them away. + const mine = new Map>(); + for (const event of events) { + if (event.kind !== COMMENT_KIND || event.pubkey !== scope.me) continue; + const comment = parseComment(event); + if (comment !== null) mine.set(comment.id, documentOf(comment.rootCoordinate)); + } + for (const event of events) { if (seen.has(event.id)) continue; seen.add(event.id); @@ -239,11 +307,11 @@ export const sortNotices = (events: NostrEvent[], scope: NoticeScope): Notice[] if (notice !== null) notices.push(notice); } else if (event.kind === REACTION_KIND) { const reaction = parseReaction(event); - const notice = reaction === null ? null : noticeFromReaction(reaction, event, scope.me); + const notice = reaction === null ? null : noticeFromReaction(reaction, event, scope.me, mine); if (notice !== null) notices.push(notice); } else if (event.kind === ZAP_RECEIPT_KIND) { const zap = parseZapReceipt(event); - const notice = zap === null ? null : noticeFromZap(zap, scope.me); + const notice = zap === null ? null : noticeFromZap(zap, scope.me, mine); if (notice !== null) notices.push(notice); } else if (event.kind === DELETION_KIND) { const deletion = parseDeletion(event); @@ -322,6 +390,22 @@ export const subscribeCopies = ( options, ); +/** + * The events a reaction or a zap named. Read from the relays that hold the + * conversations, since what is being asked for is a comment. + */ +export const subscribeNamed = ( + ids: string[], + onEvent: (event: NostrEvent) => void, + options: NoticeOptions & { onEose?: () => void } = {}, +): Subscription => + openNotices( + relaySet(options.relays ?? [...DISCUSSION_RELAYS, ...DEFAULT_RELAYS]), + namedFilters(ids), + onEvent, + options, + ); + /** Whether anything held is still standing, asked by the ids it would be taken back by. */ export const subscribeRetractions = ( ids: string[], diff --git a/packages/nostr/test/notifications.test.ts b/packages/nostr/test/notifications.test.ts index e722e5b..bd019a2 100644 --- a/packages/nostr/test/notifications.test.ts +++ b/packages/nostr/test/notifications.test.ts @@ -9,9 +9,11 @@ import { copyFilters, MAX_NOTICES, type Notice, + namedFilters, notificationFilters, sortNotices, targetFilters, + unnamedTargets, } from "../src/notifications"; import { parseSpec, type Spec } from "../src/spec"; import { MAX_IDS_PER_FILTER } from "../src/subscribe"; @@ -75,7 +77,10 @@ const zap = ( ["p", target.pubkey], ["bolt11", INVOICE_21_SATS], ["description", JSON.stringify(request)], + // Copied off the request, which is what NIP-57 asks a paying server to + // do and what the receipt is read by. ...(target.coordinate ? [["a", target.coordinate]] : []), + ...(target.eventId ? [["e", target.eventId]] : []), ], }, serverSecret, @@ -336,3 +341,65 @@ describe("sortNotices, the whole list", () => { expect(sortNotices(many, { me: ME })).toHaveLength(MAX_NOTICES); }); }); + +describe("reactions and zaps that name only an event id", () => { + /** A comment I wrote on somebody else's document, which is what gets answered. */ + const myComment = comment(THEIRS, mySecret, { content: "a point of mine" }); + const onIt = { id: myComment.id, pubkey: ME, kind: COMMENT_KIND }; + + it("asks about an id nothing here explains", () => { + const events = [reaction(onIt, theirSecret)]; + expect(unnamedTargets(events)).toEqual([myComment.id]); + }); + + it("stops asking once the event is held", () => { + expect(unnamedTargets([reaction(onIt, theirSecret), myComment])).toEqual([]); + }); + + it("asks nothing about a reaction that named a coordinate", () => { + const onDocument = { + id: "a".repeat(64), + pubkey: ME, + kind: SPEC_KIND, + coordinate: MINE.coordinate, + }; + expect(unnamedTargets([reaction(onDocument, theirSecret)])).toEqual([]); + }); + + it("asks for the events themselves, chunked", () => { + expect(namedFilters([myComment.id])).toEqual([{ ids: [myComment.id] }]); + }); + + it("draws the reaction once the comment turns out to be mine", () => { + const notices = sortNotices([reaction(onIt, theirSecret), myComment], { me: ME }); + expect(kinds(notices)).toEqual(["reaction"]); + // The document is the one my comment hangs from, which is somebody else's. + expect(notices[0]?.document.pubkey).toBe(THEM); + expect(notices[0]?.onComment).toBe(true); + expect(notices[0]?.targetId).toBe(myComment.id); + }); + + it("keeps dropping it when the comment turns out to be somebody else's", () => { + const theirComment = comment(THEIRS, thirdSecret); + const onTheirs = { id: theirComment.id, pubkey: ME, kind: COMMENT_KIND }; + expect(sortNotices([reaction(onTheirs, theirSecret), theirComment], { me: ME })).toEqual([]); + }); + + it("does the same for a zap on a comment of mine", () => { + const receipt = zap(theirSecret, { pubkey: ME, eventId: myComment.id }); + const notices = sortNotices([receipt, myComment], { me: ME }); + expect(kinds(notices)).toEqual(["zap"]); + expect(notices[0]?.onComment).toBe(true); + expect(notices[0]?.sats).toBe(21); + }); + + it("marks a reaction on a document of mine as not being on a comment", () => { + const onDocument = { + id: "a".repeat(64), + pubkey: ME, + kind: SPEC_KIND, + coordinate: MINE.coordinate, + }; + expect(sortNotices([reaction(onDocument, theirSecret)], { me: ME })[0]?.onComment).toBe(false); + }); +}); From c618ec951adc8fd149ddcffc66111b617d99c335 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 24 Aug 2026 12:24:12 +0200 Subject: [PATCH 5/6] feat: knock at the door when you are in another tab --- .../app/components/discussion/discussion.tsx | 3 +- .../web/app/components/notifications/bell.tsx | 83 +++++++- .../components/notifications/notice-row.tsx | 58 +----- apps/web/app/components/settings/alerts.tsx | 85 ++++++++ apps/web/app/lib/alerts.test.ts | 181 ++++++++++++++++++ apps/web/app/lib/alerts.ts | 151 +++++++++++++++ apps/web/app/lib/notice-copy.ts | 58 ++++++ apps/web/app/lib/notifications.test.ts | 133 ++++++++++++- apps/web/app/lib/notifications.ts | 57 ++++++ apps/web/app/lib/paths.ts | 3 + apps/web/app/routes/settings.tsx | 4 +- apps/web/app/routes/spec.tsx | 4 +- 12 files changed, 757 insertions(+), 63 deletions(-) create mode 100644 apps/web/app/components/settings/alerts.tsx create mode 100644 apps/web/app/lib/alerts.test.ts create mode 100644 apps/web/app/lib/alerts.ts create mode 100644 apps/web/app/lib/notice-copy.ts diff --git a/apps/web/app/components/discussion/discussion.tsx b/apps/web/app/components/discussion/discussion.tsx index 350424e..d03654e 100644 --- a/apps/web/app/components/discussion/discussion.tsx +++ b/apps/web/app/components/discussion/discussion.tsx @@ -9,6 +9,7 @@ import { stopDiscussion, subscribeDiscussionState, } from "~/lib/discussion"; +import { DISCUSSION_ID } from "~/lib/paths"; import { authorName } from "~/lib/profile"; import { authorsState, serverAuthorsState, subscribeAuthors, wantAuthors } from "~/lib/profiles"; import { serverSessionState, sessionState, subscribeSession } from "~/lib/session"; @@ -16,8 +17,6 @@ import { CommentThread } from "./comment"; import { Composer } from "./composer"; import { Tally } from "./tally"; -export const DISCUSSION_ID = "discussion"; - const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? "" : "s"}`; const asDate = (seconds: number): string => new Date(seconds * 1000).toISOString().slice(0, 10); diff --git a/apps/web/app/components/notifications/bell.tsx b/apps/web/app/components/notifications/bell.tsx index a2dcd6d..fb871db 100644 --- a/apps/web/app/components/notifications/bell.tsx +++ b/apps/web/app/components/notifications/bell.tsx @@ -1,6 +1,14 @@ import { useEffect, useState, useSyncExternalStore } from "react"; -import { Link } from "react-router"; +import { Link, useLocation } from "react-router"; import { CHROME, Panel } from "~/components/chrome"; +import { + alertPermission, + alertsWanted, + askToAlert, + serverAlertsWanted, + setAlertsWanted, + subscribeAlerts, +} from "~/lib/alerts"; import { clearNotices, markNoticesSeen, @@ -20,6 +28,70 @@ const PANEL_ROWS = 8; /** Past this the badge says there is a pile rather than how big the pile is. */ const MAX_BADGE = 99; +/** What this puts in front of a page title, and the only thing it takes back off. */ +const BADGE = /^\(\d+\)\s/; + +/** + * The number in front of the tab's own name, while the tab is in the background, + * which is the only time a title is the thing being read. + * + * A page title is set per route, by `meta`, on every navigation, so this cannot + * set it once. It re-applies after each one, and it strips its own prefix before + * writing, so that applying it twice says the same thing as applying it once and + * `(3) (3) Open Specs` cannot happen however the two race. + */ +const useTabBadge = (unread: number): void => { + const location = useLocation(); + const [hidden, setHidden] = useState(false); + + useEffect(() => { + const watch = () => setHidden(document.visibilityState === "hidden"); + watch(); + document.addEventListener("visibilitychange", watch); + return () => document.removeEventListener("visibilitychange", watch); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: the key is the trigger, not a read + useEffect(() => { + const base = document.title.replace(BADGE, ""); + document.title = hidden && unread > 0 ? `(${unread}) ${base}` : base; + return () => { + document.title = document.title.replace(BADGE, ""); + }; + }, [unread, hidden, location.key]); +}; + +/** + * One line, drawn only while the browser has neither been asked nor refused, and + * only for somebody who has news to be told about. The permission itself is + * asked from the click and nowhere else. + */ +const Offer = ({ onDone }: { onDone: () => void }) => { + const [asking, setAsking] = useState(false); + + return ( +
    +

    + This can knock while you are in another tab. +

    + +
    + ); +}; + const BellMark = () => (
    diff --git a/apps/web/app/components/notifications/notice-row.tsx b/apps/web/app/components/notifications/notice-row.tsx index 73e9b7d..8c919e0 100644 --- a/apps/web/app/components/notifications/notice-row.tsx +++ b/apps/web/app/components/notifications/notice-row.tsx @@ -1,39 +1,13 @@ import type { Notice } from "@openspecs/nostr"; -import { specPath, toNpub } from "@openspecs/nostr"; +import { toNpub } from "@openspecs/nostr"; import { Link } from "react-router"; import { AuthorAvatar } from "~/components/author-avatar"; -import { DISCUSSION_ID } from "~/components/discussion/discussion"; import { keyTextColor } from "~/lib/color"; +import { noticePath, said } from "~/lib/notice-copy"; import { type Authors, authorName } from "~/lib/profile"; const asDate = (seconds: number): string => new Date(seconds * 1000).toISOString().slice(0, 10); -/** - * What happened, said the way somebody would say it out loud. The document is - * named by its identifier rather than by its title: a title costs one relay - * query per row, and the identifier is the name this site files a document - * under anyway. - */ -const said = (notice: Notice): string => { - const name = notice.document.identifier; - switch (notice.kind) { - case "comment": - return `commented on ${name}`; - case "reply": - return `replied to you on ${name}`; - case "thread": - return `replied under ${name}`; - case "reaction": - return notice.onComment ? `reacted to your comment on ${name}` : `reacted to ${name}`; - case "zap": - return notice.onComment - ? `zapped your comment on ${name}, ${notice.sats} sats` - : `zapped ${name}, ${notice.sats} sats`; - case "copy": - return `published under the name ${name}`; - } -}; - /** A reaction's symbol, drawn the way the tally under a document draws it. */ const ReactionMark = ({ notice }: { notice: Notice }) => notice.emojiUrl === null ? ( @@ -48,32 +22,6 @@ const ReactionMark = ({ notice }: { notice: Notice }) => /> ); -/** - * Which comment on the document's page this is about, when it is about one. A - * comment answering me is itself the place to land; a reaction has no place of - * its own, so it lands on what it answered. - */ -const anchor = (notice: Notice): string | null => { - switch (notice.kind) { - case "comment": - case "reply": - case "thread": - return notice.id; - case "reaction": - case "zap": - return notice.onComment ? notice.targetId : null; - case "copy": - return null; - } -}; - -/** Falling back to the conversation as a whole, which every document's page has. */ -const destination = (notice: Notice): string => { - const path = specPath(notice.document); - if (notice.kind === "copy") return path; - return `${path}#${anchor(notice) ?? DISCUSSION_ID}`; -}; - export const NoticeRow = ({ notice, authors, @@ -93,7 +41,7 @@ export const NoticeRow = ({ return (
  • { + const wanted = useSyncExternalStore(subscribeAlerts, alertsWanted, serverAlertsWanted); + const [permission, setPermission] = useState("unsupported"); + useEffect(() => setPermission(alertPermission()), []); + + const turn = async (on: boolean) => { + if (!on) { + setAlertsWanted(false); + return; + } + const answer = await askToAlert(); + setPermission(answer); + if (answer === "granted") setAlertsWanted(true); + }; + + const on = wanted && permission === "granted"; + + return ( +
    +

    + Being told +

    + +
    + + Knock when something arrives + + +
    + +

    + It's like leaving the door on the latch. On, and the browser taps you on the shoulder when + somebody answers something of yours while you are looking at another tab, at most once every + half minute, and what came in between is counted rather than repeated. Off, and the bell in + the corner waits for you to look at it. Nothing leaves this browser either way: it is + reading the relays and telling you, not a server keeping a list of you. +

    + + {permission === "denied" && ( +

    + This browser is refusing them and will not ask again. Its own settings for this site are + the only place left to change that. +

    + )} + {permission === "unsupported" && ( +

    This browser does not do that sort of knocking.

    + )} +
    + ); +}; diff --git a/apps/web/app/lib/alerts.test.ts b/apps/web/app/lib/alerts.test.ts new file mode 100644 index 0000000..bb35d34 --- /dev/null +++ b/apps/web/app/lib/alerts.test.ts @@ -0,0 +1,181 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +/** A fake with the one behaviour that matters: it can be written to and read back. */ +const fakeStorage = (broken = false): Storage => { + const held = new Map(); + return { + getItem: (key: string) => { + if (broken) throw new Error("this browser is in a private window"); + return held.get(key) ?? null; + }, + setItem: (key: string, value: string) => { + if (broken) throw new Error("this browser is in a private window"); + held.set(key, value); + }, + removeItem: (key: string) => { + if (broken) throw new Error("this browser is in a private window"); + held.delete(key); + }, + clear: () => held.clear(), + key: () => null, + length: 0, + } as unknown as Storage; +}; + +/** Read once and kept, so every case needs the module as it is on a fresh page. */ +const load = async () => { + vi.resetModules(); + return await import("./alerts"); +}; + +type Knock = { title: string; body: string; tag?: string }; + +let knocks: Knock[]; + +/** Standing in for the browser's own, which vitest runs without. */ +const fakeNotification = (permission: NotificationPermission, granting = permission) => { + const fake = function (this: Record, title: string, options: Knock) { + knocks.push({ title, body: options.body, tag: options.tag }); + this.close = () => {}; + } as unknown as typeof Notification; + Object.defineProperty(fake, "permission", { value: permission, configurable: true }); + Object.defineProperty(fake, "requestPermission", { + value: async () => granting, + configurable: true, + }); + return fake; +}; + +beforeEach(() => { + knocks = []; + vi.stubGlobal("localStorage", fakeStorage()); + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("the setting", () => { + it("is off for a browser that has never been asked", async () => { + const { alertsWanted } = await load(); + expect(alertsWanted()).toBe(false); + }); + + it("is on again on the next page load, once it has been turned on", async () => { + const first = await load(); + first.setAlertsWanted(true); + expect((await load()).alertsWanted()).toBe(true); + }); + + it("forgets it rather than storing a no", async () => { + const { alertsWanted, setAlertsWanted } = await load(); + setAlertsWanted(true); + setAlertsWanted(false); + expect(alertsWanted()).toBe(false); + expect(localStorage.getItem("openspecs:alerts")).toBeNull(); + }); + + it("stays off, rather than throwing, where storage is refused", async () => { + vi.stubGlobal("localStorage", fakeStorage(true)); + const { alertsWanted, setAlertsWanted } = await load(); + expect(() => setAlertsWanted(true)).not.toThrow(); + expect(alertsWanted()).toBe(true); + }); + + it("is off on a server, which knocks on nobody's door", async () => { + expect((await load()).serverAlertsWanted()).toBe(false); + }); + + it("tells a subscriber that it changed", async () => { + const { setAlertsWanted, subscribeAlerts } = await load(); + const listener = vi.fn(); + const stop = subscribeAlerts(listener); + setAlertsWanted(true); + expect(listener).toHaveBeenCalledTimes(1); + stop(); + setAlertsWanted(false); + expect(listener).toHaveBeenCalledTimes(1); + }); +}); + +describe("the permission", () => { + it("reads a browser with no such thing as unsupported, which is not a refusal", async () => { + vi.stubGlobal("Notification", undefined); + const { alertPermission, askToAlert } = await load(); + expect(alertPermission()).toBe("unsupported"); + expect(await askToAlert()).toBe("unsupported"); + }); + + it("asks a browser that has not been asked", async () => { + vi.stubGlobal("Notification", fakeNotification("default", "granted")); + expect(await (await load()).askToAlert()).toBe("granted"); + }); + + it("never asks one that already refused, since it would not show the prompt", async () => { + const notification = fakeNotification("denied", "granted"); + const asked = vi.spyOn(notification, "requestPermission"); + vi.stubGlobal("Notification", notification); + + expect(await (await load()).askToAlert()).toBe("denied"); + expect(asked).not.toHaveBeenCalled(); + }); +}); + +describe("knocking", () => { + const alert = (body: string) => ({ body, path: "/spec/npub1/nip-07" }); + + beforeEach(() => { + vi.stubGlobal("Notification", fakeNotification("granted")); + vi.stubGlobal("window", { focus: () => {}, location: { assign: () => {} } }); + }); + + it("knocks straight away the first time", async () => { + const { showAlert } = await load(); + showAlert(alert("alice commented on nip-07")); + expect(knocks.map((k) => k.body)).toEqual(["alice commented on nip-07"]); + }); + + it("holds the rest of the window and says how many, rather than knocking five times", async () => { + const { showAlert, ALERT_GAP_MS } = await load(); + showAlert(alert("one")); + for (const body of ["two", "three", "four"]) showAlert(alert(body)); + expect(knocks).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(ALERT_GAP_MS); + expect(knocks.map((k) => k.body)).toEqual(["one", "3 new notifications"]); + }); + + it("says the one thing that happened when only one did", async () => { + const { showAlert, ALERT_GAP_MS } = await load(); + showAlert(alert("one")); + showAlert(alert("two")); + await vi.advanceTimersByTimeAsync(ALERT_GAP_MS); + expect(knocks.map((k) => k.body)).toEqual(["one", "two"]); + }); + + it("knocks again once the window has passed with nothing in it", async () => { + const { showAlert, ALERT_GAP_MS } = await load(); + showAlert(alert("one")); + await vi.advanceTimersByTimeAsync(ALERT_GAP_MS); + showAlert(alert("two")); + expect(knocks).toHaveLength(2); + }); + + it("replaces the last in the tray rather than stacking beside it", async () => { + const { showAlert } = await load(); + showAlert(alert("one")); + expect(knocks[0]?.tag).toBe("openspecs"); + expect(knocks[0]?.title).toBe("Open Specs"); + }); + + it("forgets what it was holding when asked", async () => { + const { showAlert, clearAlerts, ALERT_GAP_MS } = await load(); + showAlert(alert("one")); + showAlert(alert("two")); + clearAlerts(); + await vi.advanceTimersByTimeAsync(ALERT_GAP_MS); + expect(knocks).toHaveLength(1); + }); +}); diff --git a/apps/web/app/lib/alerts.ts b/apps/web/app/lib/alerts.ts new file mode 100644 index 0000000..390b436 --- /dev/null +++ b/apps/web/app/lib/alerts.ts @@ -0,0 +1,151 @@ +const ALERTS_KEY = "openspecs:alerts"; + +/** The one value that means yes. Anything else, absence included, means no. */ +const ON = "on"; + +/** + * How rarely the browser is allowed to knock. What arrives inside a closed + * window is not dropped, it is counted, and the next knock says the count: five + * reactions in a minute is one thing worth being told, not five. + */ +export const ALERT_GAP_MS = 30_000; + +/** Every alert replaces the last in the tray rather than stacking beside it. */ +const TAG = "openspecs"; + +/** Constant, so the operating system files them together under one heading. */ +const TITLE = "Open Specs"; + +const store = (): Storage | undefined => + typeof localStorage === "undefined" ? undefined : localStorage; + +const listeners = new Set<() => void>(); + +let wanted = false; +let read = false; + +export const subscribeAlerts = (listener: () => void): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +const notify = (): void => { + for (const listener of listeners) listener(); +}; + +/** + * Whether this browser may knock when something arrives while you are elsewhere. + * + * Off unless asked for, like every other setting here. A page that asks the + * browser for permission the moment it loads is a page asking for something + * before it has anything to say, and the answer to that question can only be + * given once. + */ +export const alertsWanted = (): boolean => { + if (!read) { + read = true; + try { + wanted = store()?.getItem(ALERTS_KEY) === ON; + } catch { + wanted = false; + } + } + return wanted; +}; + +/** The server knocks on nobody's door, and its snapshot is what the page hydrates to. */ +export const serverAlertsWanted = (): boolean => false; + +export const setAlertsWanted = (on: boolean): void => { + wanted = on; + read = true; + try { + if (on) store()?.setItem(ALERTS_KEY, ON); + else store()?.removeItem(ALERTS_KEY); + } catch {} + notify(); +}; + +export type AlertPermission = "unsupported" | NotificationPermission; + +/** `unsupported` where there is no such thing to permit, which is not a refusal. */ +export const alertPermission = (): AlertPermission => + typeof Notification === "undefined" ? "unsupported" : Notification.permission; + +/** + * Asked from a click and never from a page load. A browser that has refused is + * not asked again: it would not show the prompt, and the setting says where to + * change its mind instead. + */ +export const askToAlert = async (): Promise => { + if (typeof Notification === "undefined") return "unsupported"; + if (Notification.permission !== "default") return Notification.permission; + try { + return await Notification.requestPermission(); + } catch { + return Notification.permission; + } +}; + +export type Alert = { body: string; path: string }; + +let pending: Alert[] = []; +let lastAt = 0; +let timer: ReturnType | null = null; + +const knock = (alert: Alert): void => { + try { + const notification = new Notification(TITLE, { + body: alert.body, + tag: TAG, + icon: "/apple-touch-icon.png", + }); + notification.onclick = () => { + window.focus(); + notification.close(); + // A whole page load rather than a client side navigation. No context + // reaches a router from here, and the tab this arrives at is in the + // background, where a reload costs nobody anything. + window.location.assign(alert.path); + }; + } catch { + // A browser that refuses to construct one has said no by other means. + } +}; + +const flush = (): void => { + timer = null; + const held = pending; + pending = []; + if (held.length === 0) return; + + lastAt = Date.now(); + const only = held[0]; + if (held.length === 1 && only !== undefined) knock(only); + else knock({ body: `${held.length} new notifications`, path: "/notifications" }); +}; + +/** + * One knock per window, carrying either what happened or how much of it did. + * The caller decides whether anything should be knocked about at all; this only + * decides how often. + */ +export const showAlert = (alert: Alert): void => { + pending.push(alert); + const since = Date.now() - lastAt; + if (since >= ALERT_GAP_MS) { + flush(); + return; + } + if (timer === null) timer = setTimeout(flush, ALERT_GAP_MS - since); +}; + +/** Test seam, and what a browser signing out has no more use for. */ +export const clearAlerts = (): void => { + pending = []; + lastAt = 0; + if (timer !== null) clearTimeout(timer); + timer = null; +}; diff --git a/apps/web/app/lib/notice-copy.ts b/apps/web/app/lib/notice-copy.ts new file mode 100644 index 0000000..491a588 --- /dev/null +++ b/apps/web/app/lib/notice-copy.ts @@ -0,0 +1,58 @@ +import type { Notice } from "@openspecs/nostr"; +import { specPath } from "@openspecs/nostr"; +import { DISCUSSION_ID } from "./paths"; + +/** + * What happened, said the way somebody would say it out loud. The document is + * named by its identifier rather than by its title: a title costs one relay + * query per row, and the identifier is the name this site files a document + * under anyway. + */ +export const said = (notice: Notice): string => { + const name = notice.document.identifier; + switch (notice.kind) { + case "comment": + return `commented on ${name}`; + case "reply": + return `replied to you on ${name}`; + case "thread": + return `replied under ${name}`; + case "reaction": + return notice.onComment ? `reacted to your comment on ${name}` : `reacted to ${name}`; + case "zap": + return notice.onComment + ? `zapped your comment on ${name}, ${notice.sats} sats` + : `zapped ${name}, ${notice.sats} sats`; + case "copy": + return `published under the name ${name}`; + } +}; + +/** The same, with who did it in front, which is how a knock at the door reads. */ +export const alertLine = (notice: Notice, name: string): string => `${name} ${said(notice)}`; + +/** + * Which comment on the document's page this is about, when it is about one. A + * comment answering me is itself the place to land; a reaction has no place of + * its own, so it lands on what it answered. + */ +const anchor = (notice: Notice): string | null => { + switch (notice.kind) { + case "comment": + case "reply": + case "thread": + return notice.id; + case "reaction": + case "zap": + return notice.onComment ? notice.targetId : null; + case "copy": + return null; + } +}; + +/** Falling back to the conversation as a whole, which every document's page has. */ +export const noticePath = (notice: Notice): string => { + const path = specPath(notice.document); + if (notice.kind === "copy") return path; + return `${path}#${anchor(notice) ?? DISCUSSION_ID}`; +}; diff --git a/apps/web/app/lib/notifications.test.ts b/apps/web/app/lib/notifications.test.ts index e52e45c..5070f64 100644 --- a/apps/web/app/lib/notifications.test.ts +++ b/apps/web/app/lib/notifications.test.ts @@ -15,6 +15,15 @@ vi.mock("@openspecs/nostr", async (importOriginal) => ({ vi.mock("./relays", () => ({ noticeRelays: vi.fn(async () => []) })); +const alerts = vi.hoisted(() => ({ showAlert: vi.fn(), granted: true, wanted: true })); + +vi.mock("./alerts", async (importOriginal) => ({ + ...(await importOriginal()), + showAlert: alerts.showAlert, + alertsWanted: () => alerts.wanted, + alertPermission: () => (alerts.granted ? "granted" : "default"), +})); + import { buildComment, buildReaction, @@ -45,8 +54,8 @@ const SOMEBODY_ELSE = getPublicKey(otherKey); const ROOT = { coordinate: `${SPEC_KIND}:${ME}:a-specification`, pubkey: ME }; -const comment = (content: string, at: number) => - finalizeEvent({ ...buildComment({ root: ROOT, content }), created_at: at }, theirKey); +const comment = (content: string, at: number, root = ROOT) => + finalizeEvent({ ...buildComment({ root, content }), created_at: at }, theirKey); const specEvent = ( by: Uint8Array, @@ -121,6 +130,11 @@ beforeEach(() => { forgetSeen(); opened = 0; closed = 0; + alerts.showAlert.mockClear(); + alerts.wanted = true; + alerts.granted = true; + // Somebody looking at another tab, which is the only time a knock is wanted. + vi.stubGlobal("document", { visibilityState: "hidden" }); copyChannel = null; secondPass = null; nostr.subscribeNotices.mockClear(); @@ -544,3 +558,118 @@ describe("the second passes", () => { expect(noticesState().notices).toEqual([]); }); }); + +describe("knocking while you are elsewhere", () => { + /** Everything the gate needs open, plus a mark so what arrives counts as new. */ + const ready = async () => { + noteKey(ME, 1_000); + startNotices(ME); + await settle(); + channel.eose(); + // The backfill window, which nothing is knocked about inside of. + await vi.advanceTimersByTimeAsync(3_000); + alerts.showAlert.mockClear(); + }; + + it("knocks about what arrives once the backfill is over", async () => { + await ready(); + channel.send(comment("a note", 2_000) as NostrEvent); + await settle(); + expect(alerts.showAlert).toHaveBeenCalledTimes(1); + }); + + it("says nothing about the backfill itself, however much of it there is", async () => { + noteKey(ME, 1_000); + startNotices(ME); + await settle(); + + // A year of it, arriving in the first second, all of it dated after the mark. + for (let index = 0; index < 5; index += 1) { + channel.send(comment(`old ${index}`, 2_000 + index) as NostrEvent); + } + channel.eose(); + await settle(); + + expect(noticesState().notices).toHaveLength(5); + expect(alerts.showAlert).not.toHaveBeenCalled(); + }); + + it("does not knock later about something it stayed quiet on", async () => { + noteKey(ME, 1_000); + startNotices(ME); + await settle(); + channel.send(comment("during the backfill", 2_000) as NostrEvent); + channel.eose(); + await settle(); + expect(alerts.showAlert).not.toHaveBeenCalled(); + + // The window passes and something else arrives, on another document of mine + // so that the one knock can be told apart from the one that stayed quiet. + await vi.advanceTimersByTimeAsync(3_000); + const other = { coordinate: `${SPEC_KIND}:${ME}:another-one`, pubkey: ME }; + channel.send(comment("after it", 2_100, other) as NostrEvent); + await settle(); + + expect(alerts.showAlert).toHaveBeenCalledTimes(1); + expect(alerts.showAlert.mock.calls[0]?.[0]?.body).toContain("another-one"); + }); + + it("stays quiet until the relays have said what they hold, however long they take", async () => { + noteKey(ME, 1_000); + startNotices(ME); + await settle(); + + // The window has passed and the relays are still listing. Both have to be + // over: the ones added by widening announce nothing and keep sending stored + // events, so a clock alone cannot tell a backfill from what is happening. + await vi.advanceTimersByTimeAsync(3_000); + channel.send(comment("still the backfill", 2_000) as NostrEvent); + await settle(); + expect(alerts.showAlert).not.toHaveBeenCalled(); + + channel.eose(); + await settle(); + channel.send(comment("news", 2_100) as NostrEvent); + await settle(); + expect(alerts.showAlert).toHaveBeenCalledTimes(1); + }); + + it("stays quiet while the reader is looking at the page", async () => { + await ready(); + vi.stubGlobal("document", { visibilityState: "visible" }); + channel.send(comment("a note", 2_000) as NostrEvent); + await settle(); + expect(alerts.showAlert).not.toHaveBeenCalled(); + }); + + it("stays quiet when the setting is off", async () => { + await ready(); + alerts.wanted = false; + channel.send(comment("a note", 2_000) as NostrEvent); + await settle(); + expect(alerts.showAlert).not.toHaveBeenCalled(); + }); + + it("stays quiet when the browser has not granted it", async () => { + await ready(); + alerts.granted = false; + channel.send(comment("a note", 2_000) as NostrEvent); + await settle(); + expect(alerts.showAlert).not.toHaveBeenCalled(); + }); + + it("stays quiet about what was already read in another tab", async () => { + await ready(); + // Older than the mark, so it is news this browser has already been shown. + channel.send(comment("old news", 500) as NostrEvent); + await settle(); + expect(alerts.showAlert).not.toHaveBeenCalled(); + }); + + it("leads to the document it is about", async () => { + await ready(); + channel.send(comment("a note", 2_000) as NostrEvent); + await settle(); + expect(alerts.showAlert.mock.calls[0]?.[0]?.path).toContain("/spec/"); + }); +}); diff --git a/apps/web/app/lib/notifications.ts b/apps/web/app/lib/notifications.ts index b3a7355..6f4b261 100644 --- a/apps/web/app/lib/notifications.ts +++ b/apps/web/app/lib/notifications.ts @@ -1,4 +1,8 @@ import type { NostrEvent, Notice, Spec, Subscription } from "@openspecs/nostr"; +import { alertPermission, alertsWanted, clearAlerts, showAlert } from "./alerts"; +import { alertLine, noticePath } from "./notice-copy"; +import { authorName } from "./profile"; +import { authorsState } from "./profiles"; import { markSeen, noteKey, seenAt, unreadCount } from "./seen"; export type NoticesStatus = "idle" | "loading" | "ready"; @@ -33,6 +37,16 @@ const NOTIFY_MS = 150; */ const MAX_WATCHED_NAMES = 200; +/** + * How long after connecting nothing is knocked about, however loudly it arrives. + * + * A backfill is not news: the relays are sending a year of it in the first + * second. The end of stored events is not enough on its own either, since the + * relays added by widening announce nothing and keep dribbling stored events + * afterwards, so both this and that first announcement have to have passed. + */ +const ALERT_AFTER_MS = 3000; + let state = NO_NOTICES; let me: string | null = null; /** The relays have listed what they hold. Not that events stopped arriving. */ @@ -44,6 +58,10 @@ let copies = new Map(); let names = new Set(); /** Ids a second pass already covers, so widening it asks only about the new ones. */ let asked = new Set(); +/** Everything already weighed for a knock, whether or not it got one. */ +let weighed = new Set(); +/** When the backfill stops counting as backfill. */ +let alertsFrom = 0; let subscriptions: Subscription[] = []; let timer: ReturnType | null = null; @@ -117,6 +135,40 @@ const askAbout = (notices: Notice[]): void => { if (named.length > 0) subscriptions.push(nostr.subscribeNamed(named, receive)); }; +/** + * Whether the browser may knock about what just arrived. Every one of these has + * to hold, and the awkward one is the third: news that arrives while somebody is + * looking at the page has already been delivered by the page. + */ +const mayAlert = (): boolean => + listed && + Date.now() >= alertsFrom && + alertsWanted() && + alertPermission() === "granted" && + typeof document !== "undefined" && + document.visibilityState === "hidden"; + +/** + * Everything is weighed exactly once, whether or not it knocks. So a backfill, + * which fails the gate on arrival, is not knocked about later when the reader + * switches away from the tab and something new comes in behind it. + */ +const knockAbout = (notices: Notice[], mark: number): void => { + const fresh = notices.filter((notice) => !weighed.has(notice.id)); + for (const notice of fresh) weighed.add(notice.id); + if (!mayAlert()) return; + + const authors = authorsState(); + for (const notice of fresh) { + if (notice.createdAt <= mark) continue; + const npub = nostr === null ? notice.pubkey : nostr.toNpub(notice.pubkey); + showAlert({ + body: alertLine(notice, authorName(authors[notice.pubkey] ?? null, npub)), + path: noticePath(notice), + }); + } +}; + const publish = (): void => { if (timer !== null) { clearTimeout(timer); @@ -124,6 +176,7 @@ const publish = (): void => { } state = recompute(); askAbout(state.notices); + knockAbout(state.notices, state.seenAt); notify(); }; @@ -246,6 +299,8 @@ export const startNotices = (pubkey: string): void => { // Cleared even when the events are kept: the subscriptions watching these ids // were just closed, and nothing would reopen them. asked = new Set(); + weighed = new Set(); + alertsFrom = Date.now() + ALERT_AFTER_MS; // Before anything is asked for, and deliberately: a relay that answers fast // could otherwise deliver a year of news to a key this browser has never seen @@ -279,7 +334,9 @@ export const clearNotices = (): void => { copies = new Map(); names = new Set(); asked = new Set(); + weighed = new Set(); me = null; + clearAlerts(); listed = false; if (timer !== null) clearTimeout(timer); timer = null; diff --git a/apps/web/app/lib/paths.ts b/apps/web/app/lib/paths.ts index b63f13c..289b8b8 100644 --- a/apps/web/app/lib/paths.ts +++ b/apps/web/app/lib/paths.ts @@ -80,6 +80,9 @@ export const diffPath = (npub: string, identifier: string, otherNpub: string): s /** A document nobody has written yet, which belongs to whichever key is connected. */ export const newSpecPath = (): string => "/new"; +/** The conversation under a document, which is the one part of its page with a name. */ +export const DISCUSSION_ID = "discussion"; + /** What was addressed to whichever key is connected. Nobody else has this page. */ export const notificationsPath = (): string => "/notifications"; diff --git a/apps/web/app/routes/settings.tsx b/apps/web/app/routes/settings.tsx index 9875514..25891db 100644 --- a/apps/web/app/routes/settings.tsx +++ b/apps/web/app/routes/settings.tsx @@ -13,6 +13,7 @@ import { toNpub, } from "@openspecs/nostr"; import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { Alerts } from "~/components/settings/alerts"; import { ClientTag } from "~/components/settings/client-tag"; import { ProfileForm } from "~/components/settings/profile-form"; import { RelayList } from "~/components/settings/relay-list"; @@ -199,7 +200,8 @@ export default function SettingsRoute() { {/* Outside every branch above: this one is a setting of this browser, and a key that could not be read is no reason to hide it. */} -
    +
    +
    diff --git a/apps/web/app/routes/spec.tsx b/apps/web/app/routes/spec.tsx index c2a0d78..2fa0b66 100644 --- a/apps/web/app/routes/spec.tsx +++ b/apps/web/app/routes/spec.tsx @@ -11,7 +11,7 @@ import { data, Link, redirect } from "react-router"; import { AnnotatedDoc } from "~/components/annotated-doc"; import { AuthorAvatar } from "~/components/author-avatar"; import { CopyButton } from "~/components/copy-button"; -import { DISCUSSION_ID, Discussion } from "~/components/discussion/discussion"; +import { Discussion } from "~/components/discussion/discussion"; import { EditLink } from "~/components/editor/edit-link"; import { Withdraw } from "~/components/editor/withdraw"; import { ErrorPage } from "~/components/error-page"; @@ -23,7 +23,7 @@ import { keyTextColor } from "~/lib/color"; import { NOT_FOUND_HEADERS, PAGE_HEADERS } from "~/lib/http"; import { useLiveRevision } from "~/lib/live-revision"; import { publicOrigin } from "~/lib/origin.server"; -import { eventPath, oembedPath, ogImagePath } from "~/lib/paths"; +import { DISCUSSION_ID, eventPath, oembedPath, ogImagePath } from "~/lib/paths"; import type { LinkPreview } from "~/lib/preview"; import { loadLinkPreviews } from "~/lib/preview.server"; import type { Author } from "~/lib/profile"; From e2b947dea895dfdf309646f28dd0c2fcd1d0ebb8 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Mon, 24 Aug 2026 12:54:11 +0200 Subject: [PATCH 6/6] fix: read a conversation where this site sends one --- packages/nostr/src/discussion.ts | 19 +++++++++++- packages/nostr/src/index.ts | 1 + packages/nostr/test/discussion.test.ts | 43 ++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/packages/nostr/src/discussion.ts b/packages/nostr/src/discussion.ts index 6862b71..b54f2f7 100644 --- a/packages/nostr/src/discussion.ts +++ b/packages/nostr/src/discussion.ts @@ -14,6 +14,7 @@ import { import { parseZapReceipt, ZAP_RECEIPT_KIND, type ZapReceipt } from "./nip57"; import { fetchRelayList, type RelayListOptions } from "./nip65"; import { queryRelays, type RelayOptions, relaySet } from "./pool"; +import { DEFAULT_RELAYS } from "./relay"; import { inChunks, openWidening, type Subscription, without } from "./subscribe"; /** @@ -23,6 +24,9 @@ import { inChunks, openWidening, type Subscription, without } from "./subscribe" * them means showing half a conversation and calling it the discussion. * `relay.nmail.li` is this project's own, first in `DEFAULT_RELAYS` and the one * the crawler mirrors to. + * + * This is where the other clients put a conversation. It is not the whole of + * where one is: see `CONVERSATION_RELAYS` below. */ export const DISCUSSION_RELAYS = [ "wss://relay.ditto.pub", @@ -164,9 +168,22 @@ export type DiscussionOptions = RelayOptions & outbox?: boolean; }; +/** + * Everywhere a conversation about a document is looked for. + * + * `DEFAULT_RELAYS` belongs here and was missing, which was a hole this site dug + * itself: `writeRelays` sends a comment to both lists, and this read only the + * first. A comment that landed on a relay in the second and nowhere else, which + * is what happens when it was written from another client to a relay this + * project reads but nostrhub does not, was addressed to the author, delivered to + * the author, counted in the author's notifications, and invisible on the page + * it was about. + */ +export const CONVERSATION_RELAYS = relaySet(DISCUSSION_RELAYS, DEFAULT_RELAYS); + /** `options.relays` replaces the defaults, the way it does everywhere else here. */ const relaysFor = (pointer: DiscussionPointer, options: DiscussionOptions): string[] => - relaySet(options.relays ?? DISCUSSION_RELAYS, pointer.relays ?? []); + relaySet(options.relays ?? CONVERSATION_RELAYS, pointer.relays ?? []); /** * Both sides of the author's NIP-65 list, because a conversation about their diff --git a/packages/nostr/src/index.ts b/packages/nostr/src/index.ts index b439c06..7a0af46 100644 --- a/packages/nostr/src/index.ts +++ b/packages/nostr/src/index.ts @@ -44,6 +44,7 @@ export { } from "./corpus"; export { authorRelays, + CONVERSATION_RELAYS, DISCUSSION_RELAYS, type Discussion, type DiscussionOptions, diff --git a/packages/nostr/test/discussion.test.ts b/packages/nostr/test/discussion.test.ts index 999308c..8c986e1 100644 --- a/packages/nostr/test/discussion.test.ts +++ b/packages/nostr/test/discussion.test.ts @@ -4,6 +4,7 @@ import { finalizeEvent, generateSecretKey, getPublicKey } from "nostr-tools/pure import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { authorRelays, + DISCUSSION_RELAYS, discussionFilters, fetchDiscussion, referenceFilters, @@ -15,6 +16,7 @@ import { buildComment, COMMENT_KIND, threadComments } from "../src/nip22"; import { buildReaction, REACTION_KIND } from "../src/nip25"; import { clearRelayListCache } from "../src/nip65"; import { relaySet } from "../src/pool"; +import { DEFAULT_RELAYS } from "../src/relay"; import { discussionCase, discussionEvents } from "./fixtures"; const authorKey = generateSecretKey(); @@ -356,3 +358,44 @@ describe("the fixtures thread as the other clients thread them", () => { expect(roots.length).toBeLessThan(events.length); }); }); + +describe("where a conversation is read from", () => { + /** Records what it was asked and answers nothing, so only the relay set is under test. */ + const recordingPool = () => { + const asked: string[] = []; + return { + asked, + pool: { + querySync: async (relays: string[]) => { + asked.push(...relays); + return []; + }, + } as unknown as SimplePool, + }; + }; + + it("asks every relay this app sends a comment to", async () => { + const { asked, pool } = recordingPool(); + await fetchDiscussion( + { coordinate: ROOT.coordinate, specEventId: SPEC_EVENT_ID }, + { pool, outbox: false }, + ); + + // `writeRelays` sends a comment to both lists. Reading only the first is how + // a comment ends up counted in a notification and missing from the page it + // is about, which is what this asserts can no longer happen. + // Compared through `relaySet`, which is what normalises them on the way out. + for (const relay of relaySet(DISCUSSION_RELAYS, DEFAULT_RELAYS)) { + expect(asked).toContain(relay); + } + }); + + it("lets a caller name its own relays instead, the way everything else here does", async () => { + const { asked, pool } = recordingPool(); + await fetchDiscussion( + { coordinate: ROOT.coordinate, specEventId: SPEC_EVENT_ID }, + { pool, outbox: false, relays: ["wss://only.example"] }, + ); + expect([...new Set(asked)]).toEqual(relaySet(["wss://only.example"])); + }); +});