fix(forum): preserve resolved authors across restarts (#113)

This commit is contained in:
Wes
2026-03-19 09:20:29 -07:00
committed by GitHub
parent efed5307a8
commit 3eee3f738a
8 changed files with 204 additions and 51 deletions
+3
View File
@@ -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
+12 -3
View File
@@ -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={
+18 -7
View File
@@ -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 { mergeCurrentProfileIntoLookup } 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,15 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) {
const profilesQuery = useUsersBatchQuery(allPubkeys, {
enabled: allPubkeys.length > 0,
});
const effectiveCurrentPubkey = currentPubkey ?? profileQuery.data?.pubkey;
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);
@@ -85,13 +96,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 (
<ForumThreadPanel
canDeletePost={canDeleteExpandedPost}
currentPubkey={currentPubkey}
currentPubkey={effectiveCurrentPubkey}
isDeletingPost={deletePostMutation.isPending}
isLoading={threadQuery.isLoading}
isSendingReply={createReplyMutation.isPending}
@@ -113,7 +124,7 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) {
mentionPubkeys,
});
}}
profiles={profilesQuery.data?.profiles}
profiles={profiles}
thread={threadQuery.data}
/>
);
@@ -181,8 +192,8 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) {
<div className="space-y-3 p-4">
{posts.map((post) => (
<ForumPostCard
canDelete={canDelete(post.pubkey, currentPubkey)}
currentPubkey={currentPubkey}
canDelete={canDelete(post.pubkey, effectiveCurrentPubkey)}
currentPubkey={effectiveCurrentPubkey}
isActive={false}
isDeleting={
deletePostMutation.isPending &&
@@ -194,7 +205,7 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) {
deletePostMutation.mutate({ eventId });
}}
post={post}
profiles={profilesQuery.data?.profiles}
profiles={profiles}
/>
))}
</div>
@@ -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(),
);
}
}
+22 -1
View File
@@ -1,4 +1,4 @@
import type { UserProfileSummary } from "@/shared/api/types";
import type { Profile, UserProfileSummary } from "@/shared/api/types";
export type UserProfileLookup = Record<string, UserProfileSummary>;
@@ -21,6 +21,27 @@ function getResolvedProfile(
return profiles[normalizePubkey(pubkey)] ?? null;
}
export function mergeCurrentProfileIntoLookup(
profiles: UserProfileLookup | undefined,
currentProfile:
| Pick<Profile, "pubkey" | "displayName" | "avatarUrl" | "nip05Handle">
| 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;
+9 -2
View File
@@ -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";
@@ -57,7 +58,10 @@ type RawForumThreadResponse = {
function fromRawForumPost(post: RawForumPost): ForumPost {
return {
eventId: post.event_id,
pubkey: post.pubkey,
pubkey: resolveEventAuthorPubkey({
pubkey: post.pubkey,
tags: post.tags,
}),
content: post.content,
kind: post.kind,
createdAt: post.created_at,
@@ -77,7 +81,10 @@ function fromRawForumPost(post: RawForumPost): ForumPost {
function fromRawThreadReply(reply: RawThreadReply): ThreadReply {
return {
eventId: reply.event_id,
pubkey: reply.pubkey,
pubkey: resolveEventAuthorPubkey({
pubkey: reply.pubkey,
tags: reply.tags,
}),
content: reply.content,
kind: reply.kind,
createdAt: reply.created_at,
+56
View File
@@ -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);
}
+50 -15
View File
@@ -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,
},
});
while (Date.now() < deadline) {
try {
const response = await context.get(`${relayBaseUrl}/api/channels`, {
headers: {
"X-Pubkey": tylerPubkey,
},
timeout: requestTimeoutMs,
});
if (!response.ok()) {
throw new Error(
"Relay test data is unavailable. Start the relay and run 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);
}
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.',
);
}
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();
}