fix(desktop): make @mention clicks reliably open the profile panel (#1705)

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Matt Toohey
2026-07-10 09:15:33 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent 7fb215c4fc
commit 5f0d8309d2
27 changed files with 702 additions and 211 deletions
+4 -1
View File
@@ -153,7 +153,10 @@ const overrides = new Map([
// importIdentity, persistCurrentIdentity) moved to tauriIdentity.ts;
// limit ratcheted down 1380 → 1360 to bank the headroom (absorbs main-side
// growth landed between the split and the rebase).
["src/shared/api/tauri.ts", 1360],
// mention-alias fix: profile wrappers (RawProfile/RawUserProfileSummary types,
// getProfile/updateProfile/getUserProfile/getUsersBatch/searchUsers) moved to
// tauriProfiles.ts; limit ratcheted down 1360 → 1241 to bank the headroom.
["src/shared/api/tauri.ts", 1241],
// readiness-gate: PersonaDialog.tsx threads computeLocalModeGate +
// requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog
// shows required markers and credential amber rows (parity with
+5
View File
@@ -37,6 +37,11 @@ pub struct ProfileInfo {
#[derive(Serialize, Deserialize)]
pub struct UserProfileSummaryInfo {
pub display_name: Option<String>,
/// Kind-0 `name` field, carried separately from `display_name` so clients
/// can match @mention text against either alias (agents and the CLI
/// resolve mentions server-side against `display_name` *or* `name`).
#[serde(default)]
pub name: Option<String>,
pub avatar_url: Option<String>,
pub nip05_handle: Option<String>,
pub owner_pubkey: Option<String>,
+1
View File
@@ -339,6 +339,7 @@ pub fn users_batch_from_events(
.and_then(Value::as_str)
.or_else(|| v.get("name").and_then(Value::as_str))
.map(str::to_string),
name: v.get("name").and_then(Value::as_str).map(str::to_string),
avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string),
nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string),
is_agent: owner_pubkey.is_some(),
@@ -14,10 +14,8 @@ import {
} from "@/features/channels/readState/readStateFormat";
import { ChannelScreenEmptyState } from "@/features/channels/ui/ChannelScreenEmptyState";
import { ChannelScreenHeader } from "@/features/channels/ui/ChannelScreenHeader";
import {
ChannelPane,
ForumView,
} from "@/features/channels/ui/ChannelScreenLazyViews";
import { ChannelPane } from "@/features/channels/ui/ChannelScreenLazyViews";
import { ForumChannelContent } from "@/features/channels/ui/ForumChannelContent";
import { MembersSidebar } from "@/features/channels/ui/MembersSidebar";
import {
useManagedAgentsQuery,
@@ -839,19 +837,27 @@ export function ChannelScreen({
>
{activeChannel ? (
activeChannel.channelType === "forum" ? (
<>
{channelHeader}
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
<ForumView
channel={activeChannel}
currentPubkey={currentPubkey}
onClosePost={onCloseForumPost}
onSelectPost={onSelectForumPost}
selectedPostId={selectedForumPostId}
targetReplyId={targetForumReplyId}
/>
</React.Suspense>
</>
<ForumChannelContent
canResetPanelWidth={canResetThreadPanelWidth}
channel={activeChannel}
currentPubkey={currentPubkey}
header={channelHeader}
onClosePost={onCloseForumPost}
onCloseProfilePanel={handleCloseProfilePanel}
onOpenDm={handleOpenDm}
onOpenProfilePanel={handleOpenProfilePanel}
onPanelResizeStart={handleThreadPanelResizeStart}
onProfilePanelTabChange={setProfilePanelTab}
onProfilePanelViewChange={setProfilePanelView}
onResetPanelWidth={handleThreadPanelWidthReset}
onSelectPost={onSelectForumPost}
panelWidthPx={threadPanelWidthPx}
profilePanelPubkey={profilePanelPubkey}
profilePanelTab={profilePanelTab}
profilePanelView={profilePanelView}
selectedPostId={selectedForumPostId}
targetReplyId={targetForumReplyId}
/>
) : (
<React.Suspense
fallback={<ViewLoadingFallback includeHeader kind="channel" />}
@@ -9,3 +9,8 @@ export const ForumView = React.lazy(async () => {
const module = await import("@/features/forum/ui/ForumView");
return { default: module.ForumView };
});
export const UserProfilePanel = React.lazy(async () => {
const module = await import("@/features/profile/ui/UserProfilePanel");
return { default: module.UserProfilePanel };
});
@@ -0,0 +1,121 @@
import * as React from "react";
import {
ForumView,
UserProfilePanel,
} from "@/features/channels/ui/ChannelScreenLazyViews";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import type {
ProfilePanelTab,
ProfilePanelView,
} from "@/features/profile/ui/UserProfilePanelUtils";
import type { Channel } from "@/shared/api/types";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
type ForumChannelContentProps = {
canResetPanelWidth: boolean;
channel: Channel;
currentPubkey?: string;
header: React.ReactNode;
onClosePost: () => void;
onCloseProfilePanel: () => void;
onOpenDm?: (pubkeys: string[]) => Promise<void> | void;
onOpenProfilePanel: (pubkey: string) => void;
onPanelResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
onProfilePanelTabChange: (
tab: ProfilePanelTab,
options?: { replace?: boolean },
) => void;
onProfilePanelViewChange: (
view: ProfilePanelView,
options?: { replace?: boolean },
) => void;
onResetPanelWidth: () => void;
onSelectPost: (postId: string) => void;
panelWidthPx: number;
profilePanelPubkey?: string | null;
profilePanelTab: ProfilePanelTab;
profilePanelView: ProfilePanelView;
selectedPostId: string | null;
targetReplyId: string | null;
};
/**
* Forum-channel body for ChannelScreen: the post list/thread plus the
* user-profile auxiliary pane. Forums replace ChannelPane (which hosts the
* profile panel for message channels), so without this host, opening a
* profile from a mention chip, avatar, or the members sidebar would set
* state that never renders.
*/
export function ForumChannelContent({
canResetPanelWidth,
channel,
currentPubkey,
header,
onClosePost,
onCloseProfilePanel,
onOpenDm,
onOpenProfilePanel,
onPanelResizeStart,
onProfilePanelTabChange,
onProfilePanelViewChange,
onResetPanelWidth,
onSelectPost,
panelWidthPx,
profilePanelPubkey,
profilePanelTab,
profilePanelView,
selectedPostId,
targetReplyId,
}: ForumChannelContentProps) {
return (
<>
{header}
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
<section
aria-label="Forum posts"
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
>
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
<ForumView
channel={channel}
currentPubkey={currentPubkey}
onClosePost={onClosePost}
onSelectPost={onSelectPost}
selectedPostId={selectedPostId}
targetReplyId={targetReplyId}
/>
</React.Suspense>
</section>
{profilePanelPubkey ? (
<RightAuxiliaryPane
canResetWidth={canResetPanelWidth}
onResetWidth={onResetPanelWidth}
onResizeStart={onPanelResizeStart}
testId="user-profile-panel"
widthPx={panelWidthPx}
>
<React.Suspense fallback={null}>
<UserProfilePanel
callerChannelId={channel.id}
currentPubkey={currentPubkey}
isSinglePanelView={false}
layout="split"
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
onOpenProfile={onOpenProfilePanel}
onTabChange={onProfilePanelTabChange}
onViewChange={onProfilePanelViewChange}
pubkey={profilePanelPubkey}
splitPaneClamp
tab={profilePanelTab}
view={profilePanelView}
widthPx={panelWidthPx}
/>
</React.Suspense>
</RightAuxiliaryPane>
) : null}
</div>
</>
);
}
@@ -511,18 +511,15 @@ export function MembersSidebar({
useFeedbackToasts(actionNoticeMessage, actionErrorMessage);
const { openProfilePanel } = useProfilePanel();
// UserProfilePanel only renders inside ChannelPane, which forums replace
// with ForumView — opening there would close the sheet and show nothing.
const isForumChannel = channel?.channelType === "forum";
const handleOpenProfile = React.useMemo(
() =>
openProfilePanel && !isForumChannel
openProfilePanel
? (pubkey: string) => {
onOpenChange(false);
openProfilePanel(pubkey);
}
: undefined,
[isForumChannel, onOpenChange, openProfilePanel],
[onOpenChange, openProfilePanel],
);
const [editRespondToAgent, setEditRespondToAgent] =
@@ -10,7 +10,7 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
import type { ForumPost } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import { formatRelativeTime } from "../lib/time";
@@ -44,7 +44,10 @@ export function ForumPostCard({
preferResolvedSelfLabel: true,
});
const avatarUrl = profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null;
const mentionNames = resolveMentionNames(post.tags, profiles);
const { mentionNames, mentionPubkeysByName } = resolveMentionProps(
post.tags,
profiles,
);
// Memoize the imeta map: `parseImetaTags` builds a fresh object each render,
// and the `Markdown` memo compares `imetaByUrl` by reference. Without this,
// the post's Markdown (and the FileCard <button> it renders) is rebuilt on
@@ -120,6 +123,7 @@ export function ForumPostCard({
content={previewContent}
imetaByUrl={imetaByUrl}
mentionNames={mentionNames}
mentionPubkeysByName={mentionPubkeysByName}
/>
</div>
@@ -12,7 +12,7 @@ import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { Skeleton } from "@/shared/ui/skeleton";
@@ -72,7 +72,10 @@ function ReplyRow({
const replyAvatarUrl =
profiles?.[reply.pubkey.toLowerCase()]?.avatarUrl ?? null;
const showDelete = onDelete && canDeleteReply(reply, currentPubkey);
const replyMentionNames = resolveMentionNames(reply.tags, profiles);
const {
mentionNames: replyMentionNames,
mentionPubkeysByName: replyMentionPubkeysByName,
} = resolveMentionProps(reply.tags, profiles);
return (
<div
@@ -114,6 +117,7 @@ function ReplyRow({
content={reply.content}
imetaByUrl={parseImetaTags(reply.tags)}
mentionNames={replyMentionNames}
mentionPubkeysByName={replyMentionPubkeysByName}
/>
</div>
</div>
@@ -184,7 +188,10 @@ export function ForumThreadPanel({
}
const { post, replies } = thread;
const postMentionNames = resolveMentionNames(post.tags, profiles);
const {
mentionNames: postMentionNames,
mentionPubkeysByName: postMentionPubkeysByName,
} = resolveMentionProps(post.tags, profiles);
const postAuthorLabel = resolveUserLabel({
pubkey: post.pubkey,
currentPubkey,
@@ -253,6 +260,7 @@ export function ForumThreadPanel({
content={post.content}
imetaByUrl={parseImetaTags(post.tags)}
mentionNames={postMentionNames}
mentionPubkeysByName={postMentionPubkeysByName}
/>
</div>
</div>
+16 -1
View File
@@ -3,6 +3,7 @@ import * as React from "react";
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
import { getMentionTagPubkey } from "@/shared/lib/resolveMentionNames";
import type { Channel } from "@/shared/api/types";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
@@ -66,11 +67,23 @@ export function ForumView({
const posts = postsQuery.data?.posts ?? [];
// Collect all pubkeys from posts and thread for profile resolution
// Collect all pubkeys from posts and thread for profile resolution.
// Mentioned pubkeys (`p`/`mention` tags) must be included too: mention
// chips resolve names from this same lookup, and a mentioned user who
// never authored a post would otherwise render as a dead chip.
const allPubkeys = React.useMemo(() => {
const pubkeys = new Set<string>();
const addMentionPubkeys = (tags?: string[][]) => {
for (const tag of tags ?? []) {
const pubkey = getMentionTagPubkey(tag);
if (pubkey) {
pubkeys.add(pubkey);
}
}
};
for (const post of posts) {
pubkeys.add(post.pubkey);
addMentionPubkeys(post.tags);
if (post.threadSummary?.participants) {
for (const pk of post.threadSummary.participants) {
pubkeys.add(pk);
@@ -79,8 +92,10 @@ export function ForumView({
}
if (threadQuery.data) {
pubkeys.add(threadQuery.data.post.pubkey);
addMentionPubkeys(threadQuery.data.post.tags);
for (const reply of threadQuery.data.replies) {
pubkeys.add(reply.pubkey);
addMentionPubkeys(reply.tags);
}
}
return [...pubkeys];
+9 -3
View File
@@ -14,7 +14,7 @@ import type {
HomeFeedResponse,
RelayEvent,
} from "@/shared/api/types";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
export type InboxFilter =
| "all"
@@ -38,6 +38,7 @@ export type InboxItem = {
isActionRequired: boolean;
latestActivityAt: number;
mentionNames: string[];
mentionPubkeysByName?: Record<string, string>;
preview: string;
senderLabel: string;
subject: string;
@@ -69,6 +70,7 @@ export type InboxContextMessage = InboxReply & {
depth: number;
isSelected: boolean;
mentionNames: string[];
mentionPubkeysByName?: Record<string, string>;
};
export type InboxGroup = {
@@ -428,7 +430,10 @@ export function buildInboxItems({
});
const subject = feedHeadline(item);
const preview = feedPreview(item);
const mentionNames = resolveMentionNames(item.tags, profiles) ?? [];
const { mentionNames, mentionPubkeysByName } = resolveMentionProps(
item.tags,
profiles,
);
const groupChannel = resolveGroupChannel(item, group.items, channelById);
const channelLabel = groupChannel.name;
const displayItem: FeedItem = {
@@ -449,7 +454,8 @@ export function buildInboxItems({
groupItems: group.items,
isActionRequired: categories.includes("needs_action"),
latestActivityAt: group.latestActivityAt,
mentionNames,
mentionNames: mentionNames ?? [],
mentionPubkeysByName,
preview,
senderLabel,
subject,
+6 -2
View File
@@ -17,7 +17,7 @@ import {
KIND_JOB_RESULT,
KIND_REMINDER,
} from "@/shared/constants/kinds";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -166,7 +166,10 @@ export function FeedSection({
const canOpenChannel =
channelId !== null && availableChannelIds.has(channelId);
const isDone = doneSet.has(item.id);
const mentionNames = resolveMentionNames(item.tags, profiles);
const { mentionNames, mentionPubkeysByName } = resolveMentionProps(
item.tags,
profiles,
);
return (
<div
@@ -226,6 +229,7 @@ export function FeedSection({
className="max-w-none text-sm leading-snug text-muted-foreground"
content={feedContent(item)}
mentionNames={mentionNames}
mentionPubkeysByName={mentionPubkeysByName}
/>
</div>
+7 -3
View File
@@ -62,7 +62,7 @@ import { KIND_REACTION } from "@/shared/constants/kinds";
import { topChromeInset } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { useElementWidth } from "@/shared/hooks/use-mobile";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
import { AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX } from "@/shared/layout/AuxiliaryPanel";
@@ -373,6 +373,10 @@ export function HomeView({
const event = eventById.get(message.id);
const authorPubkey =
message.pubkey ?? event?.pubkey ?? selectedItem.item.pubkey;
const { mentionNames, mentionPubkeysByName } = resolveMentionProps(
message.tags ?? [],
feedProfiles,
);
return {
id: message.id,
authorLabel: message.author,
@@ -383,8 +387,8 @@ export function HomeView({
depth: event ? getContextMessageDepth(event, eventById) : message.depth,
fullTimestampLabel: formatInboxFullTimestamp(message.createdAt),
isSelected: message.id === selectedItem.id,
mentionNames:
resolveMentionNames(message.tags ?? [], feedProfiles) ?? [],
mentionNames: mentionNames ?? [],
mentionPubkeysByName,
reactions: message.reactions,
tags: message.tags,
timeLabel: message.time,
@@ -201,6 +201,7 @@ export function InboxDetailPane({
id: item.id,
isSelected: true,
mentionNames: item.mentionNames,
mentionPubkeysByName: item.mentionPubkeysByName,
timeLabel: formatTime(item.item.createdAt),
},
...pendingReplyMessages,
@@ -186,6 +186,7 @@ export function InboxMessageRow({
content={message.content}
customEmoji={customEmoji}
mentionNames={message.mentionNames}
mentionPubkeysByName={message.mentionPubkeysByName}
/>
<MessageReactions
canToggle={canToggleReactions}
@@ -28,10 +28,7 @@ import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"
import { parseImetaTags } from "@/features/messages/lib/parseImeta";
import { useMessageEmoji } from "@/features/messages/lib/useMessageEmoji";
import { parseWaveMessageContent } from "@/features/messages/lib/waveMessage";
import {
resolveMentionNames,
resolveMentionPubkeysByName,
} from "@/shared/lib/resolveMentionNames";
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { MessageActionBar } from "./MessageActionBar";
@@ -164,12 +161,8 @@ export const MessageRow = React.memo(
},
[channelId, openReminder],
);
const mentionNames = React.useMemo(
() => resolveMentionNames(message.tags, profiles),
[profiles, message.tags],
);
const mentionPubkeysByName = React.useMemo(
() => resolveMentionPubkeysByName(message.tags, profiles),
const { mentionNames, mentionPubkeysByName } = React.useMemo(
() => resolveMentionProps(message.tags, profiles),
[profiles, message.tags],
);
// The agent-pubkey set is computed once by the parent (ChannelScreen)
+1 -1
View File
@@ -18,7 +18,7 @@ import {
getUserProfile,
getUsersBatch,
updateProfile,
} from "@/shared/api/tauri";
} from "@/shared/api/tauriProfiles";
import { getContactList, setContactList } from "@/shared/api/social";
import type { ContactListResponse } from "@/shared/api/socialTypes";
import type {
@@ -37,6 +37,7 @@ export function profileLookupsEqual(
if (
next === undefined ||
prev.displayName !== next.displayName ||
prev.name !== next.name ||
prev.avatarUrl !== next.avatarUrl ||
prev.nip05Handle !== next.nip05Handle ||
prev.ownerPubkey !== next.ownerPubkey ||
@@ -75,6 +76,9 @@ export function mergeCurrentProfileIntoLookup(
...(profiles ?? {}),
[normalizePubkey(currentProfile.pubkey)]: {
displayName: currentProfile.displayName,
// `Profile` does not carry the kind-0 `name`; keep whatever the batch
// lookup already resolved so mention aliases survive the merge.
name: profiles?.[normalizePubkey(currentProfile.pubkey)]?.name ?? null,
avatarUrl: currentProfile.avatarUrl,
nip05Handle: currentProfile.nip05Handle,
isAgent: profiles?.[normalizePubkey(currentProfile.pubkey)]?.isAgent,
-118
View File
@@ -21,7 +21,6 @@ import type {
RelayMemberRole,
PresenceLookup,
PresenceStatus,
Profile,
RelayEvent,
SearchMessagesInput,
SearchMessagesResponse,
@@ -32,12 +31,7 @@ import type {
SetChannelTopicInput,
ThreadCursor,
ThreadRepliesResponse,
UpdateProfileInput,
UpdateChannelInput,
UserProfileSummary,
UserSearchPage,
UserSearchResult,
UsersBatchResponse,
CreateManagedAgentInput,
AgentModelsResponse,
UpdateManagedAgentInput,
@@ -49,32 +43,6 @@ import type {
RuntimeConfigSurface,
} from "@/shared/api/types";
type RawProfile = {
pubkey: string;
display_name: string | null;
avatar_url: string | null;
about: string | null;
nip05_handle: string | null;
owner_pubkey: string | null;
has_profile_event?: boolean;
};
type RawUserProfileSummary = Omit<RawProfile, "pubkey" | "about"> & {
is_agent?: boolean;
};
type RawUsersBatchResponse = {
profiles: Record<string, RawUserProfileSummary>;
missing: string[];
};
type RawUserSearchResult = RawUserProfileSummary & { pubkey: string };
type RawSearchUsersResponse = {
users: RawUserSearchResult[];
next_cursor?: string | null;
};
type RawPresenceLookup = Record<string, PresenceStatus>;
type RawChannel = {
@@ -420,92 +388,6 @@ function fromRawSearchHit(hit: RawSearchHit) {
};
}
function fromRawProfile(profile: RawProfile): Profile {
return {
pubkey: profile.pubkey,
displayName: profile.display_name,
avatarUrl: profile.avatar_url,
about: profile.about,
nip05Handle: profile.nip05_handle,
ownerPubkey: profile.owner_pubkey,
hasProfileEvent: profile.has_profile_event ?? false,
};
}
function fromRawUserProfileSummary(
profile: RawUserProfileSummary,
): UserProfileSummary {
return {
displayName: profile.display_name,
avatarUrl: profile.avatar_url,
nip05Handle: profile.nip05_handle,
ownerPubkey: profile.owner_pubkey,
isAgent: profile.is_agent ?? false,
};
}
function fromRawUserSearchResult(user: RawUserSearchResult): UserSearchResult {
return {
pubkey: user.pubkey,
displayName: user.display_name,
avatarUrl: user.avatar_url,
nip05Handle: user.nip05_handle,
ownerPubkey: user.owner_pubkey,
isAgent: user.is_agent ?? false,
};
}
export async function getProfile(): Promise<Profile> {
const profile = await invokeTauri<RawProfile>("get_profile");
return fromRawProfile(profile);
}
export async function updateProfile(
input: UpdateProfileInput,
): Promise<Profile> {
const profile = await invokeTauri<RawProfile>("update_profile", input);
return fromRawProfile(profile);
}
export async function getUserProfile(pubkey?: string): Promise<Profile> {
const profile = await invokeTauri<RawProfile>("get_user_profile", { pubkey });
return fromRawProfile(profile);
}
export async function getUsersBatch(
pubkeys: string[],
): Promise<UsersBatchResponse> {
const response = await invokeTauri<RawUsersBatchResponse>("get_users_batch", {
pubkeys,
});
return {
profiles: Object.fromEntries(
Object.entries(response.profiles).map(([pubkey, profile]) => [
pubkey,
fromRawUserProfileSummary(profile),
]),
),
missing: response.missing,
};
}
export async function searchUsers(
query: string,
limit = 8,
cursor?: string | null,
): Promise<UserSearchPage> {
const response = await invokeTauri<RawSearchUsersResponse>("search_users", {
query,
limit,
cursor: cursor ?? null,
});
return {
users: response.users.map(fromRawUserSearchResult),
nextCursor: response.next_cursor ?? null,
};
}
export async function getPresence(pubkeys: string[]): Promise<PresenceLookup> {
const response = await invokeTauri<RawPresenceLookup>("get_presence", {
pubkeys,
+123
View File
@@ -0,0 +1,123 @@
import { invokeTauri } from "@/shared/api/tauri";
import type {
Profile,
UpdateProfileInput,
UserProfileSummary,
UserSearchPage,
UserSearchResult,
UsersBatchResponse,
} from "@/shared/api/types";
type RawProfile = {
pubkey: string;
display_name: string | null;
avatar_url: string | null;
about: string | null;
nip05_handle: string | null;
owner_pubkey: string | null;
has_profile_event?: boolean;
};
type RawUserProfileSummary = Omit<RawProfile, "pubkey" | "about"> & {
name?: string | null;
is_agent?: boolean;
};
type RawUsersBatchResponse = {
profiles: Record<string, RawUserProfileSummary>;
missing: string[];
};
type RawUserSearchResult = RawUserProfileSummary & { pubkey: string };
type RawSearchUsersResponse = {
users: RawUserSearchResult[];
next_cursor?: string | null;
};
function fromRawProfile(profile: RawProfile): Profile {
return {
pubkey: profile.pubkey,
displayName: profile.display_name,
avatarUrl: profile.avatar_url,
about: profile.about,
nip05Handle: profile.nip05_handle,
ownerPubkey: profile.owner_pubkey,
hasProfileEvent: profile.has_profile_event ?? false,
};
}
function fromRawUserProfileSummary(
profile: RawUserProfileSummary,
): UserProfileSummary {
return {
displayName: profile.display_name,
name: profile.name ?? null,
avatarUrl: profile.avatar_url,
nip05Handle: profile.nip05_handle,
ownerPubkey: profile.owner_pubkey,
isAgent: profile.is_agent ?? false,
};
}
function fromRawUserSearchResult(user: RawUserSearchResult): UserSearchResult {
return {
pubkey: user.pubkey,
displayName: user.display_name,
avatarUrl: user.avatar_url,
nip05Handle: user.nip05_handle,
ownerPubkey: user.owner_pubkey,
isAgent: user.is_agent ?? false,
};
}
export async function getProfile(): Promise<Profile> {
const profile = await invokeTauri<RawProfile>("get_profile");
return fromRawProfile(profile);
}
export async function updateProfile(
input: UpdateProfileInput,
): Promise<Profile> {
const profile = await invokeTauri<RawProfile>("update_profile", input);
return fromRawProfile(profile);
}
export async function getUserProfile(pubkey?: string): Promise<Profile> {
const profile = await invokeTauri<RawProfile>("get_user_profile", { pubkey });
return fromRawProfile(profile);
}
export async function getUsersBatch(
pubkeys: string[],
): Promise<UsersBatchResponse> {
const response = await invokeTauri<RawUsersBatchResponse>("get_users_batch", {
pubkeys,
});
return {
profiles: Object.fromEntries(
Object.entries(response.profiles).map(([pubkey, profile]) => [
pubkey,
fromRawUserProfileSummary(profile),
]),
),
missing: response.missing,
};
}
export async function searchUsers(
query: string,
limit = 8,
cursor?: string | null,
): Promise<UserSearchPage> {
const response = await invokeTauri<RawSearchUsersResponse>("search_users", {
query,
limit,
cursor: cursor ?? null,
});
return {
users: response.users.map(fromRawUserSearchResult),
nextCursor: response.next_cursor ?? null,
};
}
+4
View File
@@ -135,6 +135,10 @@ export type Profile = {
export type UserProfileSummary = {
displayName: string | null;
/** Kind-0 `name` field, kept separate from `displayName` so @mention text
* can be matched against either alias (agents/CLI resolve mentions against
* `display_name` *or* `name` at send time). */
name?: string | null;
avatarUrl: string | null;
nip05Handle: string | null;
ownerPubkey: string | null;
@@ -0,0 +1,146 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
resolveMentionNames,
resolveMentionProps,
resolveMentionPubkeysByName,
} from "./resolveMentionNames.ts";
const PUBKEY = "a".repeat(64);
const OTHER_PUBKEY = "b".repeat(64);
function profile(overrides = {}) {
return {
displayName: null,
name: null,
avatarUrl: null,
nip05Handle: null,
ownerPubkey: null,
...overrides,
};
}
test("returns undefined without tags or profiles", () => {
const profiles = { [PUBKEY]: profile({ displayName: "alice" }) };
assert.deepEqual(resolveMentionProps(undefined, profiles), {
mentionNames: undefined,
mentionPubkeysByName: undefined,
});
assert.deepEqual(resolveMentionProps([["p", PUBKEY]], undefined), {
mentionNames: undefined,
mentionPubkeysByName: undefined,
});
});
test("resolves the display name alias from p tags", () => {
const tags = [["p", PUBKEY]];
const profiles = { [PUBKEY]: profile({ displayName: "Alice" }) };
const { mentionNames, mentionPubkeysByName } = resolveMentionProps(
tags,
profiles,
);
assert.deepEqual(mentionNames, ["Alice"]);
assert.deepEqual(mentionPubkeysByName, { alice: PUBKEY });
});
test("resolves the kind-0 name alias when it differs from the display name", () => {
// The rename / agent-send case: the message text says "@tyler" (kind-0
// name) while the profile's display name is "Tyler Durden". Both aliases
// must render as chips AND resolve to the pubkey.
const tags = [["p", PUBKEY]];
const profiles = {
[PUBKEY]: profile({ displayName: "Tyler Durden", name: "tyler" }),
};
const { mentionNames, mentionPubkeysByName } = resolveMentionProps(
tags,
profiles,
);
assert.deepEqual(mentionNames, ["Tyler Durden", "tyler"]);
assert.deepEqual(mentionPubkeysByName, {
"tyler durden": PUBKEY,
tyler: PUBKEY,
});
});
test("resolves the NIP-05 local part alias", () => {
const tags = [["p", PUBKEY]];
const profiles = {
[PUBKEY]: profile({
displayName: "Tyler Durden",
nip05Handle: "tyler@buzz.example",
}),
};
const { mentionNames, mentionPubkeysByName } = resolveMentionProps(
tags,
profiles,
);
assert.deepEqual(mentionNames, ["Tyler Durden", "tyler"]);
assert.equal(mentionPubkeysByName?.tyler, PUBKEY);
});
test("skips the NIP-05 root identifier and blank aliases", () => {
const tags = [
["p", PUBKEY],
["p", OTHER_PUBKEY],
];
const profiles = {
[PUBKEY]: profile({ displayName: " ", name: "", nip05Handle: "_@root" }),
[OTHER_PUBKEY]: profile({ displayName: "bob" }),
};
const { mentionNames, mentionPubkeysByName } = resolveMentionProps(
tags,
profiles,
);
assert.deepEqual(mentionNames, ["bob"]);
assert.deepEqual(mentionPubkeysByName, { bob: OTHER_PUBKEY });
});
test("includes aliases from mention reference tags", () => {
const tags = [["mention", PUBKEY]];
const profiles = { [PUBKEY]: profile({ displayName: "alice" }) };
assert.deepEqual(resolveMentionNames(tags, profiles), ["alice"]);
assert.deepEqual(resolveMentionPubkeysByName(tags, profiles), {
alice: PUBKEY,
});
});
test("every rendered name resolves to a pubkey (outputs stay in sync)", () => {
const tags = [
["p", PUBKEY],
["p", OTHER_PUBKEY],
];
const profiles = {
[PUBKEY]: profile({
displayName: "Tyler Durden",
name: "tyler",
nip05Handle: "td@buzz.example",
}),
[OTHER_PUBKEY]: profile({ displayName: "bob", name: "bobby" }),
};
const { mentionNames, mentionPubkeysByName } = resolveMentionProps(
tags,
profiles,
);
for (const name of mentionNames ?? []) {
assert.ok(
mentionPubkeysByName?.[name.toLowerCase()],
`alias "${name}" renders as a chip but does not resolve to a pubkey`,
);
}
});
test("uppercases in tag pubkeys are normalized", () => {
const tags = [["p", PUBKEY.toUpperCase()]];
const profiles = { [PUBKEY]: profile({ displayName: "alice" }) };
assert.deepEqual(resolveMentionPubkeysByName(tags, profiles), {
alice: PUBKEY,
});
});
+71 -35
View File
@@ -11,47 +11,65 @@ export function getMentionTagPubkey(tag: string[]): string | null {
}
/**
* Resolves display names for mentioned users from message `p` tags and
* non-notifying `mention` reference tags.
* All names a profile can be @mentioned by. Message text is matched against
* the sender's view of the profile at send time (agents and the CLI resolve
* mentions against `display_name` *or* `name`, and renames happen after the
* fact), so a single-alias match leaves chips that render but never resolve
* to a pubkey. Emitting every known alias display name, kind-0 `name`, and
* the NIP-05 local part keeps rendered chips and pubkey resolution in sync.
*/
function collectProfileAliases(
profile: UserProfileSummary | undefined,
): string[] {
if (!profile) {
return [];
}
const aliases: string[] = [];
const displayName = profile.displayName?.trim();
if (displayName) {
aliases.push(displayName);
}
const name = profile.name?.trim();
if (name) {
aliases.push(name);
}
// "_" is the NIP-05 root identifier, not a mentionable handle.
const nip05Local = profile.nip05Handle?.trim().split("@")[0]?.trim();
if (nip05Local && nip05Local !== "_") {
aliases.push(nip05Local);
}
return aliases;
}
export type ResolvedMentionProps = {
mentionNames: string[] | undefined;
mentionPubkeysByName: Record<string, string> | undefined;
};
/**
* Resolves mention render names and the namepubkey map for mentioned users
* from message `p` tags and non-notifying `mention` reference tags, in one
* pass over the tags.
*
* `p` tags drive notification/search semantics. `mention` tags only preserve
* render metadata for reference-only mentions.
*
* Both outputs come from the same alias set, so any `@name` chip the markdown
* renderer matches is guaranteed to resolve to a pubkey.
*/
export function resolveMentionNames(
export function resolveMentionProps(
tags: string[][] | undefined,
profiles: Record<string, UserProfileSummary> | undefined,
): string[] | undefined {
): ResolvedMentionProps {
if (!profiles || !tags) {
return undefined;
return { mentionNames: undefined, mentionPubkeysByName: undefined };
}
const names = new Set<string>();
for (const tag of tags) {
const pubkey = getMentionTagPubkey(tag);
if (!pubkey) {
continue;
}
const profile = profiles[pubkey];
const displayName = profile?.displayName?.trim();
if (displayName) {
names.add(displayName);
}
}
return names.size > 0 ? [...names] : undefined;
}
export function resolveMentionPubkeysByName(
tags: string[][] | undefined,
profiles: Record<string, UserProfileSummary> | undefined,
): Record<string, string> | undefined {
if (!profiles || !tags) {
return undefined;
}
const pubkeysByName: Record<string, string> = {};
for (const tag of tags) {
@@ -60,11 +78,29 @@ export function resolveMentionPubkeysByName(
continue;
}
const displayName = profiles[pubkey]?.displayName?.trim();
if (displayName) {
pubkeysByName[displayName.toLowerCase()] = pubkey;
for (const alias of collectProfileAliases(profiles[pubkey])) {
names.add(alias);
pubkeysByName[alias.toLowerCase()] = pubkey;
}
}
return Object.keys(pubkeysByName).length > 0 ? pubkeysByName : undefined;
return {
mentionNames: names.size > 0 ? [...names] : undefined,
mentionPubkeysByName:
Object.keys(pubkeysByName).length > 0 ? pubkeysByName : undefined,
};
}
export function resolveMentionNames(
tags: string[][] | undefined,
profiles: Record<string, UserProfileSummary> | undefined,
): string[] | undefined {
return resolveMentionProps(tags, profiles).mentionNames;
}
export function resolveMentionPubkeysByName(
tags: string[][] | undefined,
profiles: Record<string, UserProfileSummary> | undefined,
): Record<string, string> | undefined {
return resolveMentionProps(tags, profiles).mentionPubkeysByName;
}
+13 -9
View File
@@ -53,6 +53,7 @@ import {
hasBlockMedia,
isImageOnlyParagraph,
shallowArrayEqual,
shallowRecordEqual,
} from "./markdownUtils";
import {
CODE_BLOCK_CLASS,
@@ -1807,13 +1808,17 @@ function createMarkdownComponents(
{mentionLabel}
</>
);
// Only chips that actually open a profile get the clickable affordance.
// A mention whose pubkey didn't resolve stays a plain chip — a pointer
// cursor there promises a click that does nothing.
const opensProfile = interactive && pubkey !== undefined;
const mentionNode = (
<span
data-mention=""
className={cn(
"cursor-pointer",
MENTION_CHIP_BASE_CLASSES,
MENTION_CHIP_HOVER_CLASSES,
opensProfile && "cursor-pointer",
opensProfile && MENTION_CHIP_HOVER_CLASSES,
isAgentMention && "agent-mention-highlight",
)}
>
@@ -1821,11 +1826,7 @@ function createMarkdownComponents(
</span>
);
if (!interactive) {
return mentionNode;
}
return pubkey ? (
return opensProfile ? (
<UserProfilePopover
botIdenticonValue={mentionLabel}
pubkey={pubkey}
@@ -2103,8 +2104,11 @@ export const Markdown = React.memo(
prev.customEmoji === next.customEmoji &&
prev.interactive === next.interactive &&
prev.mediaInset === next.mediaInset &&
prev.agentMentionPubkeysByName === next.agentMentionPubkeysByName &&
prev.mentionPubkeysByName === next.mentionPubkeysByName &&
shallowRecordEqual(
prev.agentMentionPubkeysByName,
next.agentMentionPubkeysByName,
) &&
shallowRecordEqual(prev.mentionPubkeysByName, next.mentionPubkeysByName) &&
shallowArrayEqual(prev.mentionNames, next.mentionNames) &&
shallowArrayEqual(prev.channelNames, next.channelNames) &&
prev.imetaByUrl === next.imetaByUrl &&
+20
View File
@@ -73,3 +73,23 @@ export function shallowArrayEqual(a?: string[], b?: string[]): boolean {
}
return true;
}
/**
* Value-equality for the small namepubkey mention maps. Several call sites
* (forum cards, feed rows) rebuild the map inline on every render comparing
* by value in the `Markdown` memo keeps those fresh-but-identical objects from
* re-rendering (and DOM-swapping) the whole markdown tree.
*/
export function shallowRecordEqual(
a?: Record<string, string>,
b?: Record<string, string>,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
const aKeys = Object.keys(a);
if (aKeys.length !== Object.keys(b).length) return false;
for (const key of aKeys) {
if (a[key] !== b[key]) return false;
}
return true;
}
+12
View File
@@ -215,6 +215,9 @@ type RawRelayMember = {
type RawProfile = {
pubkey: string;
display_name: string | null;
/** Kind-0 `name` field, kept separate from `display_name` so mention
* resolution can match either alias. */
name?: string | null;
avatar_url: string | null;
about: string | null;
nip05_handle: string | null;
@@ -227,6 +230,7 @@ type RawProfile = {
type RawUserProfileSummary = {
display_name: string | null;
name?: string | null;
avatar_url: string | null;
nip05_handle: string | null;
owner_pubkey: string | null;
@@ -868,6 +872,10 @@ const mockAgentPubkeys = new Set([
PROFILE_ONLY_AGENT_PUBKEY,
OWNED_RELAY_AGENT_PUBKEY,
]);
// Kind-0 `name` aliases, distinct from the display name, for exercising the
// alias-tolerant mention resolution path (e.g. a message that says "@bobby"
// while bob's display name is "bob").
const mockKind0Names = new Map<string, string>([[BOB_PUBKEY, "bobby"]]);
function isoMinutesAgo(minutesAgo: number): string {
return new Date(Date.now() - minutesAgo * 60_000).toISOString();
@@ -1851,6 +1859,7 @@ function getMockProfileByPubkey(pubkey: string): RawProfile | null {
return {
pubkey: normalizedPubkey,
display_name: mockDisplayNames.get(normalizedPubkey) ?? null,
name: mockKind0Names.get(normalizedPubkey) ?? null,
avatar_url: null,
about: null,
nip05_handle: null,
@@ -4828,6 +4837,7 @@ async function handleGetUsersBatch(
profiles[normalizedPubkey] = {
display_name: profile.display_name,
name: profile.name ?? null,
avatar_url: profile.avatar_url,
nip05_handle: profile.nip05_handle,
owner_pubkey: profile.owner_pubkey,
@@ -4852,6 +4862,7 @@ async function handleGetUsersBatch(
const content = JSON.parse(ev.content ?? "{}");
profiles[pk] = {
display_name: content.display_name ?? content.name ?? null,
name: content.name ?? null,
avatar_url: content.picture ?? null,
nip05_handle: content.nip05 ?? null,
owner_pubkey:
@@ -4880,6 +4891,7 @@ async function handleGetUsersBatch(
found.add(normalizedPubkey);
profiles[normalizedPubkey] = {
display_name: profile.display_name,
name: profile.name ?? null,
avatar_url: profile.avatar_url,
nip05_handle: profile.nip05_handle,
owner_pubkey: profile.owner_pubkey,
+87 -1
View File
@@ -68,18 +68,20 @@ async function emitMockMessage(
channelName: string,
content: string,
options?: {
kind?: number;
mentionPubkeys?: string[];
parentEventId?: string;
pubkey?: string;
},
) {
const event = await page.evaluate(
({ ch, mentionPubkeys, msg, parentEventId, pubkey }) => {
({ ch, kind, mentionPubkeys, msg, parentEventId, pubkey }) => {
return (
window as Window & {
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
kind?: number;
mentionPubkeys?: string[];
parentEventId?: string | null;
pubkey?: string;
@@ -88,6 +90,7 @@ async function emitMockMessage(
).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: ch,
content: msg,
kind,
mentionPubkeys,
parentEventId: parentEventId ?? undefined,
pubkey: pubkey ?? undefined,
@@ -95,6 +98,7 @@ async function emitMockMessage(
},
{
ch: channelName,
kind: options?.kind,
mentionPubkeys: options?.mentionPubkeys,
msg: content,
parentEventId: options?.parentEventId ?? null,
@@ -1372,6 +1376,88 @@ test("hovering avatar opens popover, clicking opens profile panel", async ({
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
});
test("clicking a mention chip in the timeline opens the profile panel", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await emitMockMessage(page, "general", "Ping @bob about the launch", {
mentionPubkeys: [TEST_IDENTITIES.bob.pubkey],
});
await waitForTimelineSettled(page);
const mentionChip = page
.getByTestId("message-row")
.filter({ hasText: "Ping @bob about the launch" })
.locator("[data-mention]", { hasText: "@bob" });
await expect(mentionChip).toBeVisible();
await mentionChip.click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible();
await expect(panel).toContainText("bob");
});
test("mention text matching the kind-0 name alias resolves and opens the profile panel", async ({
page,
}) => {
// bob's mock profile has display_name "bob" and kind-0 name "bobby". A
// message that says "@bobby" (how agents/CLI resolve mentions at send time)
// must still render a clickable chip bound to bob's pubkey.
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await emitMockMessage(page, "general", "Ask @bobby to review the doc", {
mentionPubkeys: [TEST_IDENTITIES.bob.pubkey],
});
await waitForTimelineSettled(page);
const mentionChip = page
.getByTestId("message-row")
.filter({ hasText: "Ask @bobby to review the doc" })
.locator("[data-mention]", { hasText: "@bobby" });
await expect(mentionChip).toBeVisible();
await mentionChip.click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible();
await expect(panel).toContainText("bob");
});
test("clicking a mention chip in a forum post opens the profile panel", async ({
page,
}) => {
await page.goto("/");
// Seed the forum post before entering the channel — forum views load from
// the mock store on fetch, so no live subscription is needed.
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await emitMockMessage(page, "watercooler", "Welcome aboard @bob!", {
kind: 45001,
mentionPubkeys: [TEST_IDENTITIES.bob.pubkey],
});
await page.getByTestId("channel-watercooler").click();
await expect(page.getByTestId("chat-title")).toHaveText("watercooler");
const mentionChip = page.locator("[data-mention]", { hasText: "@bob" });
await expect(mentionChip).toBeVisible();
await mentionChip.click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible();
await expect(panel).toContainText("bob");
// The chip click must not bubble into the card and open the thread view.
await expect(page.getByRole("button", { name: "Back to posts" })).toHaveCount(
0,
);
});
test("bot profile only exposes message action", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-agents").click();