From 38e8cc315cc897e70a11c6dc6a6be0e2578e4034 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 10 Jul 2026 18:00:51 +1000 Subject: [PATCH] fix(desktop): make @mention clicks reliably open the profile panel Clicking an @mention chip only worked in the main message timeline, and only when the mention text matched the profile's display name. Chips in forum posts, forum replies, the home feed, and inbox rows were inert, and a mention rendered from a kind-0 `name` alias never resolved to a pubkey. As requested in the plan, mention chips now reliably open the right-hand profile panel everywhere they render interactively: - Resolve mention names and the name->pubkey click map in a single pass (`resolveMentionProps`) from one alias set per mentioned user: display name, kind-0 `name`, and NIP-05 local part. Any alias the app renders now resolves back to its pubkey, case-insensitively. - Carry the kind-0 `name` field through the Tauri profile summary (`UserProfileSummaryInfo.name`) so the frontend sees it; split the profile API wrappers out of `tauri.ts` into `tauriProfiles.ts` to stay under the file-size ratchet (limit banked 1360 -> 1241). - Wire `mentionPubkeysByName` into every interactive Markdown surface: forum post cards, forum thread panel (post + replies), home feed, and inbox rows. `Markdown`'s memo compares the map by value (`shallowRecordEqual`) so call sites that rebuild it inline do not defeat memoization. - Honest affordance: chips get pointer cursor + hover styles only when clicking actually opens a profile (interactive surface and resolved pubkey). - Fetch profiles for mention-tag pubkeys in ForumView - a mentioned user who never authored a post previously rendered as a dead chip. - Host the profile panel in forum channels: new `ForumChannelContent` renders the user-profile auxiliary pane next to ForumView (forums replace ChannelPane, which hosts the panel for message channels, so opening a profile there previously showed nothing). The matching MembersSidebar forum workaround is removed. - Tests: unit coverage for alias resolution (display name, kind-0 name, NIP-05 local part, `_` root skip, mention reference tags, case normalization); Playwright specs clicking mention chips in the timeline (display-name and kind-0-name aliases) and in a forum post, asserting the profile panel opens. - Fix six pre-existing Rust 1.95 clippy lints in desktop/src-tauri (unused import, doc indentation, `slice::from_ref`, `is_none_or`) that blocked `just ci` and the pre-push hook. Known limitation (out of scope per plan): NIP-27 `nostr:npub1...` references are not linkified. Co-Authored-By: Claude Fable 5 Signed-off-by: Matt Toohey --- desktop/scripts/check-file-sizes.mjs | 5 +- .../src-tauri/src/commands/agent_settings.rs | 2 +- .../src-tauri/src/commands/channels_tests.rs | 2 +- .../src-tauri/src/managed_agents/storage.rs | 2 +- .../src/migration_databricks_tests.rs | 6 +- desktop/src-tauri/src/models.rs | 5 + desktop/src-tauri/src/nostr_convert.rs | 1 + .../features/channels/ui/ChannelScreen.tsx | 40 +++-- .../channels/ui/ChannelScreenLazyViews.ts | 5 + .../channels/ui/ForumChannelContent.tsx | 121 +++++++++++++++ .../features/channels/ui/MembersSidebar.tsx | 7 +- .../src/features/forum/ui/ForumPostCard.tsx | 8 +- .../features/forum/ui/ForumThreadPanel.tsx | 14 +- desktop/src/features/forum/ui/ForumView.tsx | 17 +- desktop/src/features/home/lib/inbox.ts | 12 +- desktop/src/features/home/ui/FeedSection.tsx | 8 +- desktop/src/features/home/ui/HomeView.tsx | 10 +- .../src/features/home/ui/InboxDetailPane.tsx | 1 + .../src/features/home/ui/InboxMessageRow.tsx | 1 + .../src/features/messages/ui/MessageRow.tsx | 13 +- desktop/src/features/profile/hooks.ts | 2 +- desktop/src/features/profile/lib/identity.ts | 4 + desktop/src/shared/api/tauri.ts | 118 -------------- desktop/src/shared/api/tauriProfiles.ts | 123 +++++++++++++++ desktop/src/shared/api/types.ts | 4 + .../shared/lib/resolveMentionNames.test.mjs | 146 ++++++++++++++++++ desktop/src/shared/lib/resolveMentionNames.ts | 96 ++++++++---- desktop/src/shared/ui/markdown.tsx | 22 +-- desktop/src/shared/ui/markdownUtils.ts | 20 +++ desktop/src/testing/e2eBridge.ts | 12 ++ desktop/tests/e2e/mentions.spec.ts | 88 ++++++++++- 31 files changed, 703 insertions(+), 212 deletions(-) create mode 100644 desktop/src/features/channels/ui/ForumChannelContent.tsx create mode 100644 desktop/src/shared/api/tauriProfiles.ts create mode 100644 desktop/src/shared/lib/resolveMentionNames.test.mjs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 149b1e08cc..ff880dc069 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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 diff --git a/desktop/src-tauri/src/commands/agent_settings.rs b/desktop/src-tauri/src/commands/agent_settings.rs index e4c52066a2..54b52bd197 100644 --- a/desktop/src-tauri/src/commands/agent_settings.rs +++ b/desktop/src-tauri/src/commands/agent_settings.rs @@ -1,4 +1,4 @@ -use tauri::{AppHandle, Manager, State}; +use tauri::{AppHandle, Manager}; use crate::{ app_state::AppState, diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index b0143daef7..8eac36fca8 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -33,7 +33,7 @@ fn directory_cursor_keeps_same_second_tiebreaker() { let event = ev_at(39000, "{}", vec![], timestamp); let mut filter = serde_json::json!({"kinds": [39000], "limit": DIRECTORY_PAGE_SIZE}); - advance_directory_cursor(&mut filter, &[event.clone()]); + advance_directory_cursor(&mut filter, std::slice::from_ref(&event)); assert_eq!(filter["until"], serde_json::json!(timestamp.as_secs())); assert_eq!(filter["before_id"], serde_json::json!(event.id.to_hex())); diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index ef8fc665fe..abd5c9596b 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -436,7 +436,7 @@ const DEV_MIGRATION_MARKER: &str = "_dev_migration_v1"; /// /// On subsequent boots (marker already present): /// 1. One `dst.load_all_readonly()` — dev blob read (1 keychain prompt) -/// Returns immediately — prod keyring is NEVER accessed. +/// Returns immediately — prod keyring is NEVER accessed. /// /// Idempotency: keys already present in `dst` are not overwritten (the agent /// may have rotated their key in the dev service after initial migration). diff --git a/desktop/src-tauri/src/migration_databricks_tests.rs b/desktop/src-tauri/src/migration_databricks_tests.rs index 2e50fae17d..842507ec83 100644 --- a/desktop/src-tauri/src/migration_databricks_tests.rs +++ b/desktop/src-tauri/src/migration_databricks_tests.rs @@ -32,7 +32,7 @@ fn reconcile_databricks_v1_to_v2_rewrites_v1_provider_on_block_build() { // Stale V1 model must be cleared so the baked DATABRICKS_MODEL is not // shadowed by BUZZ_AGENT_MODEL at spawn time (last-write-wins in Command::env). assert!( - records[0].get("model").map_or(true, |v| v.is_null()), + records[0].get("model").is_none_or(|v| v.is_null()), "stale V1 model field must be cleared when provider is rewritten to V2" ); } @@ -98,12 +98,12 @@ fn reconcile_databricks_v1_to_v2_clears_model_on_provider_rewrite() { // V1 records: provider migrated, model cleared. assert_eq!(records[0]["provider"], "databricks_v2"); assert!( - records[0].get("model").map_or(true, |v| v.is_null()), + records[0].get("model").is_none_or(|v| v.is_null()), "model must be cleared for V1→V2 migrated record A" ); assert_eq!(records[1]["provider"], "databricks_v2"); assert!( - records[1].get("model").map_or(true, |v| v.is_null()), + records[1].get("model").is_none_or(|v| v.is_null()), "model must be cleared for V1→V2 migrated record B" ); // V2 record: model untouched. diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 881cd11a35..8f002fdb1d 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -37,6 +37,11 @@ pub struct ProfileInfo { #[derive(Serialize, Deserialize)] pub struct UserProfileSummaryInfo { pub display_name: Option, + /// 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, pub avatar_url: Option, pub nip05_handle: Option, pub owner_pubkey: Option, diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs index c1db2a1b59..ec4970e0c9 100644 --- a/desktop/src-tauri/src/nostr_convert.rs +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -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(), diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index ac3eded07e..5d1b0e7d63 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -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} - }> - - - + ) : ( } diff --git a/desktop/src/features/channels/ui/ChannelScreenLazyViews.ts b/desktop/src/features/channels/ui/ChannelScreenLazyViews.ts index 4e4fc0f2af..86619a3c57 100644 --- a/desktop/src/features/channels/ui/ChannelScreenLazyViews.ts +++ b/desktop/src/features/channels/ui/ChannelScreenLazyViews.ts @@ -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 }; +}); diff --git a/desktop/src/features/channels/ui/ForumChannelContent.tsx b/desktop/src/features/channels/ui/ForumChannelContent.tsx new file mode 100644 index 0000000000..428413c597 --- /dev/null +++ b/desktop/src/features/channels/ui/ForumChannelContent.tsx @@ -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; + onOpenProfilePanel: (pubkey: string) => void; + onPanelResizeStart: (event: React.PointerEvent) => 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} +
+
+ }> + + +
+ {profilePanelPubkey ? ( + + + + + + ) : null} +
+ + ); +} diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 314d28f41c..48a04b5918 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -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] = diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 4bd5f0f087..311df0f97f 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -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