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
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 1a0fb94..4da7f6c 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 { 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 (
);
@@ -181,8 +192,8 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) {
{posts.map((post) => (
))}
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 d0ac98e..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";
@@ -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,
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);
+}
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();
}