mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(forum): preserve resolved authors across restarts (#113)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user