Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 12 additions & 3 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
() =>
Expand All @@ -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,
],
);
Expand Down Expand Up @@ -679,7 +688,7 @@ export function AppShell() {
}
: undefined
}
profiles={messageProfilesQuery.data?.profiles}
profiles={messageProfiles}
replyTargetId={replyTargetId}
replyTargetMessage={replyTargetMessage}
targetMessageId={
Expand Down
25 changes: 18 additions & 7 deletions desktop/src/features/forum/ui/ForumView.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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}
Expand All @@ -113,7 +124,7 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) {
mentionPubkeys,
});
}}
profiles={profilesQuery.data?.profiles}
profiles={profiles}
thread={threadQuery.data}
/>
);
Expand Down Expand Up @@ -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 &&
Expand All @@ -194,7 +205,7 @@ export function ForumView({ channel, currentPubkey }: ForumViewProps) {
deletePostMutation.mutate({ eventId });
}}
post={post}
profiles={profilesQuery.data?.profiles}
profiles={profiles}
/>
))}
</div>
Expand Down
57 changes: 34 additions & 23 deletions desktop/src/features/messages/lib/formatTimelineMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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"
? (() => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
);
}
}

Expand Down
23 changes: 22 additions & 1 deletion desktop/src/features/profile/lib/identity.ts
Original file line number Diff line number Diff line change
@@ -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>;

Expand All @@ -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;
Expand Down
11 changes: 9 additions & 2 deletions desktop/src/shared/api/forum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions desktop/src/shared/lib/authors.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading