From 5ca9a76c79f44ecfedfc69117f38123e3691c3d8 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 18 Mar 2026 19:29:42 -0700 Subject: [PATCH 1/4] docs: note stable relay key for forum posts --- .env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env.example b/.env.example index 85eaab8..637978e 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,9 @@ TYPESENSE_URL=http://localhost:8108 SPROUT_BIND_ADDR=0.0.0.0:3000 # Public WebSocket URL — used in NIP-42 auth challenges RELAY_URL=ws://localhost:3000 +# Stable relay signing key. Set this in dev if you want REST-created forum posts +# to keep resolving to the original author across relay restarts. +# SPROUT_RELAY_PRIVATE_KEY=<32-byte hex private key> # Set to true in production to require bearer token authentication SPROUT_REQUIRE_AUTH_TOKEN=false From 82a015646ef912ffccefe60801501407b7668839 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 18 Mar 2026 19:36:01 -0700 Subject: [PATCH 2/4] fix(forum): preserve resolved authors across restarts --- desktop/src/features/forum/ui/ForumView.tsx | 34 ++++++++++++++++----- desktop/src/shared/api/forum.ts | 16 ++++++++-- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/desktop/src/features/forum/ui/ForumView.tsx b/desktop/src/features/forum/ui/ForumView.tsx index 1a0fb94..196a1de 100644 --- a/desktop/src/features/forum/ui/ForumView.tsx +++ b/desktop/src/features/forum/ui/ForumView.tsx @@ -1,7 +1,8 @@ import { MessageSquareText } from "lucide-react"; import * as React from "react"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { Channel } from "@/shared/api/types"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -35,6 +36,7 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) { ); const [isComposerOpen, setIsComposerOpen] = React.useState(false); + const profileQuery = useProfileQuery(); const postsQuery = useForumPostsQuery(channel); const threadQuery = useForumThreadQuery( expandedPostId ? channel.id : null, @@ -73,6 +75,24 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) { const profilesQuery = useUsersBatchQuery(allPubkeys, { enabled: allPubkeys.length > 0, }); + const effectiveCurrentPubkey = currentPubkey ?? profileQuery.data?.pubkey; + const profiles = React.useMemo(() => { + const batchProfiles = profilesQuery.data?.profiles; + const currentProfile = profileQuery.data; + + if (!currentProfile) { + return batchProfiles; + } + + return { + ...(batchProfiles ?? {}), + [currentProfile.pubkey.toLowerCase()]: { + displayName: currentProfile.displayName, + avatarUrl: currentProfile.avatarUrl, + nip05Handle: currentProfile.nip05Handle, + }, + }; + }, [profileQuery.data, profilesQuery.data?.profiles]); // Reset expanded post when channel changes const previousChannelIdRef = React.useRef(channel.id); @@ -85,13 +105,13 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) { if (expandedPostId) { const threadPost = threadQuery.data?.post; const canDeleteExpandedPost = threadPost - ? canDelete(threadPost.pubkey, currentPubkey) + ? canDelete(threadPost.pubkey, effectiveCurrentPubkey) : false; return ( ); @@ -181,8 +201,8 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) {
{posts.map((post) => ( ))}
diff --git a/desktop/src/shared/api/forum.ts b/desktop/src/shared/api/forum.ts index d0ac98e..e5c462f 100644 --- a/desktop/src/shared/api/forum.ts +++ b/desktop/src/shared/api/forum.ts @@ -54,10 +54,22 @@ type RawForumThreadResponse = { next_cursor: string | null; }; +function getAttributedAuthorPubkey(pubkey: string, tags: string[][]): string { + const firstTag = tags[0]; + const attributedPubkey = + firstTag?.[0] === "p" ? firstTag[1]?.toLowerCase() : null; + + if (attributedPubkey && /^[0-9a-f]{64}$/.test(attributedPubkey)) { + return attributedPubkey; + } + + return pubkey.toLowerCase(); +} + function fromRawForumPost(post: RawForumPost): ForumPost { return { eventId: post.event_id, - pubkey: post.pubkey, + pubkey: getAttributedAuthorPubkey(post.pubkey, post.tags), content: post.content, kind: post.kind, createdAt: post.created_at, @@ -77,7 +89,7 @@ function fromRawForumPost(post: RawForumPost): ForumPost { function fromRawThreadReply(reply: RawThreadReply): ThreadReply { return { eventId: reply.event_id, - pubkey: reply.pubkey, + pubkey: getAttributedAuthorPubkey(reply.pubkey, reply.tags), content: reply.content, kind: reply.kind, createdAt: reply.created_at, From b1f342a4d34188772ec85b42782a7027a1de447d Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 18 Mar 2026 19:40:52 -0700 Subject: [PATCH 3/4] refactor(desktop): centralize author resolution --- desktop/src/app/AppShell.tsx | 15 ++++- desktop/src/features/forum/ui/ForumView.tsx | 27 +++------ .../messages/lib/formatTimelineMessages.ts | 57 +++++++++++-------- desktop/src/features/profile/lib/identity.ts | 23 +++++++- desktop/src/shared/api/forum.ts | 23 +++----- desktop/src/shared/lib/authors.ts | 56 ++++++++++++++++++ 6 files changed, 142 insertions(+), 59 deletions(-) create mode 100644 desktop/src/shared/lib/authors.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index c76b0a7..ea993af 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -37,6 +37,7 @@ import { usePresenceSession } from "@/features/presence/hooks"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { useHomeFeedNotifications } from "@/features/notifications/hooks"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; +import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; import { ChannelBrowserDialog } from "@/features/channels/ui/ChannelBrowserDialog"; import { SearchDialog } from "@/features/search/ui/SearchDialog"; import { @@ -165,6 +166,14 @@ export function AppShell() { const messageProfilesQuery = useUsersBatchQuery(messageProfilePubkeys, { enabled: messageProfilePubkeys.length > 0, }); + const messageProfiles = React.useMemo( + () => + mergeCurrentProfileIntoLookup( + messageProfilesQuery.data?.profiles, + profileQuery.data, + ), + [messageProfilesQuery.data?.profiles, profileQuery.data], + ); const timelineMessages = React.useMemo( () => @@ -173,13 +182,13 @@ export function AppShell() { activeChannel, identityQuery.data?.pubkey, profileQuery.data?.avatarUrl ?? null, - messageProfilesQuery.data?.profiles, + messageProfiles, ), [ activeChannel, identityQuery.data?.pubkey, + messageProfiles, profileQuery.data?.avatarUrl, - messageProfilesQuery.data?.profiles, resolvedMessages, ], ); @@ -679,7 +688,7 @@ export function AppShell() { } : undefined } - profiles={messageProfilesQuery.data?.profiles} + profiles={messageProfiles} replyTargetId={replyTargetId} replyTargetMessage={replyTargetMessage} targetMessageId={ diff --git a/desktop/src/features/forum/ui/ForumView.tsx b/desktop/src/features/forum/ui/ForumView.tsx index 196a1de..4da7f6c 100644 --- a/desktop/src/features/forum/ui/ForumView.tsx +++ b/desktop/src/features/forum/ui/ForumView.tsx @@ -2,7 +2,7 @@ import { MessageSquareText } from "lucide-react"; import * as React from "react"; import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; import type { Channel } from "@/shared/api/types"; import { Skeleton } from "@/shared/ui/skeleton"; @@ -76,23 +76,14 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) { enabled: allPubkeys.length > 0, }); const effectiveCurrentPubkey = currentPubkey ?? profileQuery.data?.pubkey; - const profiles = React.useMemo(() => { - const batchProfiles = profilesQuery.data?.profiles; - const currentProfile = profileQuery.data; - - if (!currentProfile) { - return batchProfiles; - } - - return { - ...(batchProfiles ?? {}), - [currentProfile.pubkey.toLowerCase()]: { - displayName: currentProfile.displayName, - avatarUrl: currentProfile.avatarUrl, - nip05Handle: currentProfile.nip05Handle, - }, - }; - }, [profileQuery.data, profilesQuery.data?.profiles]); + const profiles = React.useMemo( + () => + mergeCurrentProfileIntoLookup( + profilesQuery.data?.profiles, + profileQuery.data, + ), + [profileQuery.data, profilesQuery.data?.profiles], + ); // Reset expanded post when channel changes const previousChannelIdRef = React.useRef(channel.id); diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 88df69d..9146d7a 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -16,6 +16,7 @@ import { KIND_STREAM_MESSAGE_DIFF, KIND_SYSTEM_MESSAGE, } from "@/shared/constants/kinds"; +import { resolveEventAuthorPubkey } from "@/shared/lib/authors"; const HEX_RE = /^[0-9a-f]+$/i; @@ -60,31 +61,18 @@ function getReactionTargetId(tags: string[][]) { return null; } -function getEffectiveAuthorPubkey(event: RelayEvent) { - const actorTag = event.tags.find((tag) => tag[0] === "actor")?.[1]; - if (actorTag) { - return actorTag; - } - - const [firstTag] = event.tags; - if ( - firstTag?.[0] === "p" && - firstTag[1] && - event.tags.some((tag) => tag[0] === "h") - ) { - return firstTag[1]; - } - - return event.pubkey; -} - function formatMessageAuthor( event: RelayEvent, channel: Channel | null, currentPubkey: string | undefined, profiles: UserProfileLookup | undefined, ) { - const authorPubkey = getEffectiveAuthorPubkey(event); + const authorPubkey = resolveEventAuthorPubkey({ + pubkey: event.pubkey, + tags: event.tags, + preferActorTag: true, + requireChannelTagForPTags: true, + }); const fallbackName = channel?.channelType === "dm" ? (() => { @@ -164,7 +152,12 @@ export function formatTimelineMessages( continue; } - const actorPubkey = getEffectiveAuthorPubkey(event).toLowerCase(); + const actorPubkey = resolveEventAuthorPubkey({ + pubkey: event.pubkey, + tags: event.tags, + preferActorTag: true, + requireChannelTagForPTags: true, + }).toLowerCase(); const emoji = event.content.trim() || "+"; reactionPresence.set(`${targetId}:${actorPubkey}:${emoji}`, { targetId, @@ -202,7 +195,12 @@ export function formatTimelineMessages( return cached; } - const authorPubkey = getEffectiveAuthorPubkey(event); + const authorPubkey = resolveEventAuthorPubkey({ + pubkey: event.pubkey, + tags: event.tags, + preferActorTag: true, + requireChannelTagForPTags: true, + }); const author = formatMessageAuthor(event, channel, currentPubkey, profiles); authorPubkeyByEventId.set(event.id, authorPubkey); @@ -244,7 +242,13 @@ export function formatTimelineMessages( return visibleEvents.map((event) => { const author = getAuthorLabel(event); const authorPubkey = - authorPubkeyByEventId.get(event.id) ?? getEffectiveAuthorPubkey(event); + authorPubkeyByEventId.get(event.id) ?? + resolveEventAuthorPubkey({ + pubkey: event.pubkey, + tags: event.tags, + preferActorTag: true, + requireChannelTagForPTags: true, + }); const thread = getThreadReference(event.tags); return { id: event.id, @@ -307,7 +311,14 @@ export function collectMessageAuthorPubkeys(events: RelayEvent[]) { pubkeys.add(pk); } } else { - pubkeys.add(getEffectiveAuthorPubkey(event).toLowerCase()); + pubkeys.add( + resolveEventAuthorPubkey({ + pubkey: event.pubkey, + tags: event.tags, + preferActorTag: true, + requireChannelTagForPTags: true, + }).toLowerCase(), + ); } } diff --git a/desktop/src/features/profile/lib/identity.ts b/desktop/src/features/profile/lib/identity.ts index f4808ff..c40ae2e 100644 --- a/desktop/src/features/profile/lib/identity.ts +++ b/desktop/src/features/profile/lib/identity.ts @@ -1,4 +1,4 @@ -import type { UserProfileSummary } from "@/shared/api/types"; +import type { Profile, UserProfileSummary } from "@/shared/api/types"; export type UserProfileLookup = Record; @@ -21,6 +21,27 @@ function getResolvedProfile( return profiles[normalizePubkey(pubkey)] ?? null; } +export function mergeCurrentProfileIntoLookup( + profiles: UserProfileLookup | undefined, + currentProfile: + | Pick + | null + | undefined, +) { + if (!currentProfile) { + return profiles; + } + + return { + ...(profiles ?? {}), + [normalizePubkey(currentProfile.pubkey)]: { + displayName: currentProfile.displayName, + avatarUrl: currentProfile.avatarUrl, + nip05Handle: currentProfile.nip05Handle, + }, + }; +} + export function resolveUserLabel(input: { pubkey: string; currentPubkey?: string; diff --git a/desktop/src/shared/api/forum.ts b/desktop/src/shared/api/forum.ts index e5c462f..70beb4c 100644 --- a/desktop/src/shared/api/forum.ts +++ b/desktop/src/shared/api/forum.ts @@ -5,6 +5,7 @@ import type { ThreadReply, } from "@/shared/api/types"; import { KIND_FORUM_POST } from "@/shared/constants/kinds"; +import { resolveEventAuthorPubkey } from "@/shared/lib/authors"; import { invokeTauri } from "./tauri"; @@ -54,22 +55,13 @@ type RawForumThreadResponse = { next_cursor: string | null; }; -function getAttributedAuthorPubkey(pubkey: string, tags: string[][]): string { - const firstTag = tags[0]; - const attributedPubkey = - firstTag?.[0] === "p" ? firstTag[1]?.toLowerCase() : null; - - if (attributedPubkey && /^[0-9a-f]{64}$/.test(attributedPubkey)) { - return attributedPubkey; - } - - return pubkey.toLowerCase(); -} - function fromRawForumPost(post: RawForumPost): ForumPost { return { eventId: post.event_id, - pubkey: getAttributedAuthorPubkey(post.pubkey, post.tags), + pubkey: resolveEventAuthorPubkey({ + pubkey: post.pubkey, + tags: post.tags, + }), content: post.content, kind: post.kind, createdAt: post.created_at, @@ -89,7 +81,10 @@ function fromRawForumPost(post: RawForumPost): ForumPost { function fromRawThreadReply(reply: RawThreadReply): ThreadReply { return { eventId: reply.event_id, - pubkey: getAttributedAuthorPubkey(reply.pubkey, reply.tags), + pubkey: resolveEventAuthorPubkey({ + pubkey: reply.pubkey, + tags: reply.tags, + }), content: reply.content, kind: reply.kind, createdAt: reply.created_at, diff --git a/desktop/src/shared/lib/authors.ts b/desktop/src/shared/lib/authors.ts new file mode 100644 index 0000000..8765824 --- /dev/null +++ b/desktop/src/shared/lib/authors.ts @@ -0,0 +1,56 @@ +const PUBKEY_HEX_RE = /^[0-9a-f]{64}$/i; + +function normalizePubkey(pubkey: string) { + return pubkey.toLowerCase(); +} + +function getTaggedPubkey( + tags: string[][], + tagName: string, + options?: { + firstTagOnly?: boolean; + }, +) { + const candidates = options?.firstTagOnly ? tags.slice(0, 1) : tags; + + for (const tag of candidates) { + const taggedPubkey = tag[0] === tagName ? tag[1]?.toLowerCase() : null; + if (taggedPubkey && PUBKEY_HEX_RE.test(taggedPubkey)) { + return taggedPubkey; + } + } + + return null; +} + +export function resolveEventAuthorPubkey(input: { + pubkey: string; + tags: string[][]; + preferActorTag?: boolean; + requireChannelTagForPTags?: boolean; +}) { + const { + preferActorTag = false, + pubkey, + requireChannelTagForPTags = false, + tags, + } = input; + + if (preferActorTag) { + const actorPubkey = getTaggedPubkey(tags, "actor"); + if (actorPubkey) { + return actorPubkey; + } + } + + const canUseAttributedPTag = + !requireChannelTagForPTags || tags.some((tag) => tag[0] === "h"); + if (canUseAttributedPTag) { + const attributedPubkey = getTaggedPubkey(tags, "p", { firstTagOnly: true }); + if (attributedPubkey) { + return attributedPubkey; + } + } + + return normalizePubkey(pubkey); +} From 4947f27ef25a41134230979be1d1f562067ed560 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 18 Mar 2026 19:50:53 -0700 Subject: [PATCH 4/4] test(desktop): retry seeded relay checks --- desktop/tests/helpers/seed.ts | 67 ++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/desktop/tests/helpers/seed.ts b/desktop/tests/helpers/seed.ts index 9fd8c16..f5a2daa 100644 --- a/desktop/tests/helpers/seed.ts +++ b/desktop/tests/helpers/seed.ts @@ -2,29 +2,64 @@ import { request } from "@playwright/test"; const tylerPubkey = "e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34"; +const relayBaseUrl = + process.env.SPROUT_E2E_RELAY_URL ?? "http://localhost:3000"; +const seedTimeoutMs = Number.parseInt( + process.env.SPROUT_E2E_SEED_TIMEOUT_MS ?? "25000", + 10, +); +const requestTimeoutMs = Number.parseInt( + process.env.SPROUT_E2E_SEED_REQUEST_TIMEOUT_MS ?? "2000", + 10, +); +const retryDelayMs = Number.parseInt( + process.env.SPROUT_E2E_SEED_RETRY_DELAY_MS ?? "1000", + 10, +); + +function delay(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} export async function assertRelaySeeded() { const context = await request.newContext(); + const deadline = Date.now() + seedTimeoutMs; + let lastFailure = "no checks attempted"; try { - const response = await context.get("http://localhost:3000/api/channels", { - headers: { - "X-Pubkey": tylerPubkey, - }, - }); - - if (!response.ok()) { - throw new Error( - "Relay test data is unavailable. Start the relay and run scripts/setup-desktop-test-data.sh.", - ); - } + while (Date.now() < deadline) { + try { + const response = await context.get(`${relayBaseUrl}/api/channels`, { + headers: { + "X-Pubkey": tylerPubkey, + }, + timeout: requestTimeoutMs, + }); - const channels = (await response.json()) as Array<{ name: string }>; - if (!channels.some((channel) => channel.name === "general")) { - throw new Error( - 'Relay test data is incomplete. Expected a "general" channel from scripts/setup-desktop-test-data.sh.', - ); + if (!response.ok()) { + lastFailure = `HTTP ${response.status()} from /api/channels`; + } else { + const channels = (await response.json()) as Array<{ name: string }>; + if (channels.some((channel) => channel.name === "general")) { + return; + } + + lastFailure = + 'seed data missing expected "general" channel from scripts/setup-desktop-test-data.sh'; + } + } catch (error) { + lastFailure = + error instanceof Error ? error.message : "unknown relay check error"; + } + + await delay(retryDelayMs); } + + throw new Error( + `Relay test data was not ready after ${seedTimeoutMs}ms. Last check: ${lastFailure}. Start the relay and run scripts/setup-desktop-test-data.sh.`, + ); } finally { await context.dispose(); }