mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Claude Fable 5
parent
7fb215c4fc
commit
5f0d8309d2
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 name→pubkey 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;
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -73,3 +73,23 @@ export function shallowArrayEqual(a?: string[], b?: string[]): boolean {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value-equality for the small name→pubkey 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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user