From c481fcf71eddd6ea2f241a613de302a1a82c3a80 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 2 May 2026 10:22:54 -0700 Subject: [PATCH 1/6] feat: add user profile panel with hover popover + click-to-expand Adds a side panel for viewing user profiles, triggered by clicking on avatars or author names in message rows and forum post cards. The existing popover now opens on hover with a 300ms delay and closes the popover on click, opening the full profile panel instead. - UserProfilePanel: new side panel component following existing panel patterns (resize, overlay, escape-to-close) - UserProfilePopover: hover-triggered with delay timers, click opens the profile panel via ProfilePanelContext - ProfilePanelContext: lightweight context to avoid deep prop threading - ChannelScreen/ChannelPane: profile panel state management, DM opening callback via useChannelProfilePanel hook - ForumPostCard: avatar and author name now wrapped with popover - Copy-to-clipboard uses sonner toast matching codebase convention Co-Authored-By: Claude Opus 4.6 --- desktop/scripts/check-file-sizes.mjs | 1 + .../src/features/channels/ui/ChannelPane.tsx | 17 ++ .../features/channels/ui/ChannelScreen.tsx | 197 +++++++------ .../channels/ui/useChannelProfilePanel.ts | 36 +++ .../src/features/forum/ui/ForumPostCard.tsx | 32 +- .../features/profile/ui/UserProfilePanel.tsx | 275 ++++++++++++++++++ .../profile/ui/UserProfilePopover.tsx | 72 ++++- .../shared/context/ProfilePanelContext.tsx | 32 ++ 8 files changed, 566 insertions(+), 96 deletions(-) create mode 100644 desktop/src/features/channels/ui/useChannelProfilePanel.ts create mode 100644 desktop/src/features/profile/ui/UserProfilePanel.tsx create mode 100644 desktop/src/shared/context/ProfilePanelContext.tsx diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 65e9efc1a..5112077fe 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -35,6 +35,7 @@ const overrides = new Map([ ["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], + ["src/features/channels/ui/ChannelScreen.tsx", 520], // profile panel state + DM callback wiring + ProfilePanelProvider context ["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates ["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) ["src/features/settings/ui/SettingsView.tsx", 600], diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 656c2e599..93390768a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -5,6 +5,7 @@ import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; +import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; import { BotActivityBar } from "@/features/channels/ui/BotActivityBar"; @@ -70,6 +71,7 @@ type ChannelPaneProps = { onCancelEdit?: () => void; onCancelThreadReply: () => void; onCloseAgentSession: () => void; + onCloseProfilePanel: () => void; onCloseThread: () => void; onDelete?: (message: TimelineMessage) => void; onEdit?: (message: TimelineMessage) => void; @@ -77,6 +79,7 @@ type ChannelPaneProps = { onExpandThreadReplies: (message: TimelineMessage) => void; onJoinChannel?: () => Promise; onOpenAgentSession: (pubkey: string) => void; + onOpenDm?: (pubkeys: string[]) => void; onOpenThread: (message: TimelineMessage) => void; onSelectThreadReplyTarget: (message: TimelineMessage) => void; onSendMessage: ( @@ -101,6 +104,7 @@ type ChannelPaneProps = { profiles?: UserProfileLookup; openThreadHeadId: string | null; openAgentSessionPubkey: string | null; + profilePanelPubkey?: string | null; threadHeadMessage: TimelineMessage | null; threadMessages: MainTimelineEntry[]; threadTypingPubkeys: string[]; @@ -128,6 +132,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onCancelEdit, onCancelThreadReply, onCloseAgentSession, + onCloseProfilePanel, onCloseThread, onDelete, onEdit, @@ -135,6 +140,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onExpandThreadReplies, onJoinChannel, onOpenAgentSession, + onOpenDm, onOpenThread, onSelectThreadReplyTarget, onSendMessage, @@ -146,6 +152,7 @@ export const ChannelPane = React.memo(function ChannelPane({ profiles, openThreadHeadId, openAgentSessionPubkey, + profilePanelPubkey, targetMessageId, threadHeadMessage, threadMessages, @@ -392,6 +399,16 @@ export const ChannelPane = React.memo(function ChannelPane({ onResizeStart={handleThreadPanelResizeStart} widthPx={threadPanelWidthPx} /> + ) : profilePanelPubkey ? ( + ) : null} ); diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 45ed6d56d..3e2152732 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -46,7 +46,9 @@ import type { import { useChannelFind } from "@/features/search/useChannelFind"; import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; import { AgentSessionProvider } from "@/shared/context/AgentSessionContext"; +import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { useChannelAgentSessions } from "./useChannelAgentSessions"; +import { useChannelProfilePanel } from "./useChannelProfilePanel"; type ChannelScreenProps = { activeChannel: Channel | null; currentIdentity?: Identity; @@ -71,6 +73,13 @@ export function ChannelScreen({ targetMessageId, }: ChannelScreenProps) { const { markChannelRead, openChannelManagement } = useAppShell(); + const { + profilePanelPubkey, + setProfilePanelPubkey, + handleOpenProfilePanel, + handleCloseProfilePanel, + handleOpenDm, + } = useChannelProfilePanel(); const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false); const [openThreadHeadId, setOpenThreadHeadId] = React.useState( null, @@ -360,8 +369,9 @@ export function ChannelScreen({ setThreadReplyTargetId(null); handleCloseAgentSession(); setEditTargetId(null); + setProfilePanelPubkey(null); }, - [handleCloseAgentSession], + [handleCloseAgentSession, setProfilePanelPubkey], ); const handleThreadScrollTargetResolved = React.useCallback(() => { setThreadScrollTargetId(null); @@ -402,98 +412,105 @@ export function ChannelScreen({ return ( - setIsMembersSidebarOpen((prev) => !prev)} - /> + + setIsMembersSidebarOpen((prev) => !prev)} + /> -
- {activeChannel ? ( - activeChannel.channelType === "forum" ? ( - }> - - +
+ {activeChannel ? ( + activeChannel.channelType === "forum" ? ( + }> + + + ) : ( + }> + + + ) ) : ( - }> - - - ) - ) : ( - - )} -
+ + )} +
- + +
); } diff --git a/desktop/src/features/channels/ui/useChannelProfilePanel.ts b/desktop/src/features/channels/ui/useChannelProfilePanel.ts new file mode 100644 index 000000000..cbb6d6c10 --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelProfilePanel.ts @@ -0,0 +1,36 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useOpenDmMutation } from "@/features/channels/hooks"; + +export function useChannelProfilePanel() { + const { goChannel } = useAppNavigation(); + const openDmMutation = useOpenDmMutation(); + const [profilePanelPubkey, setProfilePanelPubkey] = React.useState< + string | null + >(null); + + const handleOpenProfilePanel = React.useCallback((pubkey: string) => { + setProfilePanelPubkey(pubkey); + }, []); + + const handleCloseProfilePanel = React.useCallback(() => { + setProfilePanelPubkey(null); + }, []); + + const handleOpenDm = React.useCallback( + async (pubkeys: string[]) => { + const dm = await openDmMutation.mutateAsync({ pubkeys }); + await goChannel(dm.id); + }, + [goChannel, openDmMutation], + ); + + return { + profilePanelPubkey, + setProfilePanelPubkey, + handleOpenProfilePanel, + handleCloseProfilePanel, + handleOpenDm, + }; +} diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 4f6113aaa..68a7e80ec 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -4,6 +4,7 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { ForumPost } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -67,10 +68,33 @@ export function ForumPostCard({ }} >
- - - {authorLabel} - + {/* biome-ignore lint/a11y/noStaticElementInteractions: presentation wrapper stops click propagation to parent card */} +
e.stopPropagation()} + role="presentation" + > + + + + + + +
{formatRelativeTime(post.createdAt)} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx new file mode 100644 index 000000000..3f3e6d9a1 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -0,0 +1,275 @@ +import * as React from "react"; +import { Activity, Copy, MessageSquare, X } from "lucide-react"; +import { toast } from "sonner"; + +import { useUserProfileQuery } from "@/features/profile/hooks"; +import { + useRelayAgentsQuery, + useManagedAgentsQuery, +} from "@/features/agents/hooks"; +import { usePresenceQuery } from "@/features/presence/hooks"; +import { useUserStatusQuery } from "@/features/user-status/hooks"; +import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; +import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; +import { useAgentSession } from "@/shared/context/AgentSessionContext"; +import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; +import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; +import { cn } from "@/shared/lib/cn"; +import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { Button } from "@/shared/ui/button"; +import { + OverlayPanelBackdrop, + PANEL_BASE_CLASS, + PANEL_OVERLAY_CLASS, +} from "@/shared/ui/OverlayPanelBackdrop"; + +type UserProfilePanelProps = { + canResetWidth: boolean; + onClose: () => void; + onOpenDm?: (pubkeys: string[]) => void; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + pubkey: string; + widthPx: number; +}; + +const RUNTIME_LABELS: Record = { + goose: "Goose", + "claude-code": "Claude Code", + "codex-acp": "Codex", + aider: "Aider", +}; + +function runtimeLabel(command: string): string { + return RUNTIME_LABELS[command] ?? command; +} + +function InfoBadge({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function truncatePubkey(pubkey: string) { + if (pubkey.length <= 16) { + return pubkey; + } + + return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`; +} + +export function UserProfilePanel({ + canResetWidth, + onClose, + onOpenDm, + onResetWidth, + onResizeStart, + pubkey, + widthPx, +}: UserProfilePanelProps) { + const isOverlay = useIsThreadPanelOverlay(); + useEscapeKey(onClose, isOverlay); + + const profileQuery = useUserProfileQuery(pubkey); + const relayAgentsQuery = useRelayAgentsQuery({ enabled: true }); + const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); + const presenceQuery = usePresenceQuery([pubkey]); + const userStatusQuery = useUserStatusQuery([pubkey]); + const { onOpenAgentSession } = useAgentSession(); + + const profile = profileQuery.data; + const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; + const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()]; + + const relayAgent = relayAgentsQuery.data?.find((a) => a.pubkey === pubkey); + const managedAgent = managedAgentsQuery.data?.find( + (a) => a.pubkey === pubkey, + ); + const isBot = Boolean(relayAgent || managedAgent); + const canViewActivity = + isBot && + managedAgent?.backend.type === "local" && + Boolean(onOpenAgentSession); + + const handleCopyPubkey = React.useCallback(() => { + void navigator.clipboard.writeText(pubkey).then(() => { + toast.success("Copied to clipboard"); + }); + }, [pubkey]); + + const handleMessage = React.useCallback(() => { + onOpenDm?.([pubkey]); + onClose(); + }, [onClose, onOpenDm, pubkey]); + + const displayName = profile?.displayName ?? truncatePubkey(pubkey); + + return ( + <> + {isOverlay && } + + + ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 7167e05df..4ce4cec8f 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -11,6 +11,7 @@ import { useUserStatusQuery } from "@/features/user-status/hooks"; import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; +import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; @@ -24,6 +25,9 @@ type UserProfilePopoverProps = { botIdenticonValue?: string; }; +const HOVER_OPEN_DELAY_MS = 300; +const HOVER_CLOSE_DELAY_MS = 200; + const RUNTIME_LABELS: Record = { goose: "Goose", "claude-code": "Claude Code", @@ -58,6 +62,9 @@ export function UserProfilePopover({ botIdenticonValue, }: UserProfilePopoverProps) { const [open, setOpen] = React.useState(false); + const hoverTimerRef = React.useRef | null>( + null, + ); const profileQuery = useUserProfileQuery(open ? pubkey : undefined); const relayAgentsQuery = useRelayAgentsQuery({ enabled: open && role === "bot", @@ -71,6 +78,7 @@ export function UserProfilePopover({ const userStatusQuery = useUserStatusQuery(open ? [pubkey] : []); const { onOpenAgentSession } = useAgentSession(); + const { openProfilePanel } = useProfilePanel(); const relayAgent = relayAgentsQuery.data?.find((a) => a.pubkey === pubkey); const managedAgent = managedAgentsQuery.data?.find( (a) => a.pubkey === pubkey, @@ -83,10 +91,70 @@ export function UserProfilePopover({ const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()]; + const clearHoverTimer = React.useCallback(() => { + if (hoverTimerRef.current !== null) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + }, []); + + const handleTriggerMouseEnter = React.useCallback(() => { + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(true); + }, HOVER_OPEN_DELAY_MS); + }, [clearHoverTimer]); + + const handleMouseLeave = React.useCallback(() => { + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(false); + }, HOVER_CLOSE_DELAY_MS); + }, [clearHoverTimer]); + + const handleContentMouseEnter = React.useCallback(() => { + clearHoverTimer(); + }, [clearHoverTimer]); + + const handleTriggerClick = React.useCallback(() => { + clearHoverTimer(); + setOpen(false); + openProfilePanel?.(pubkey); + }, [clearHoverTimer, openProfilePanel, pubkey]); + + React.useEffect(() => { + return () => clearHoverTimer(); + }, [clearHoverTimer]); + return ( - {children} - + + {/* biome-ignore lint/a11y/useSemanticElements: wrapper div for hover/click behavior */} +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleTriggerClick(); + } + }} + onMouseEnter={handleTriggerMouseEnter} + onMouseLeave={handleMouseLeave} + className="inline-flex" + > + {children} +
+
+
{profile?.avatarUrl ? ( diff --git a/desktop/src/shared/context/ProfilePanelContext.tsx b/desktop/src/shared/context/ProfilePanelContext.tsx new file mode 100644 index 000000000..c62af561a --- /dev/null +++ b/desktop/src/shared/context/ProfilePanelContext.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; + +type ProfilePanelContextValue = { + openProfilePanel: ((pubkey: string) => void) | null; +}; + +const ProfilePanelContext = React.createContext({ + openProfilePanel: null, +}); + +export function ProfilePanelProvider({ + children, + onOpenProfilePanel, +}: { + children: React.ReactNode; + onOpenProfilePanel: (pubkey: string) => void; +}) { + const value = React.useMemo( + () => ({ openProfilePanel: onOpenProfilePanel }), + [onOpenProfilePanel], + ); + + return ( + + {children} + + ); +} + +export function useProfilePanel() { + return React.useContext(ProfilePanelContext); +} From 6f77c93326ed5f930dbad5cace96f50532b10ecb Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 2 May 2026 10:36:45 -0700 Subject: [PATCH 2/6] fix: address review findings for user profile panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix E2E test to hover→popover, click→panel (BLOCK 1) - Hide Message button when viewing own profile (BLOCK 2) - Add mutual exclusion between thread, agent session, and profile panels (CHANGE 1) - Consolidate double UserProfilePopover wrappers in ForumPostCard and MessageRow thread-reply layout to prevent hover flicker (CHANGE 2) - Add UserProfilePopover to ForumThreadPanel post and reply authors (CHANGE 4) - Normalize agent pubkey comparison with toLowerCase() (NIT 1) - Fix tabIndex={-1} → tabIndex={0} on popover trigger (NIT 2) Co-Authored-By: Claude Opus 4.6 --- desktop/scripts/check-file-sizes.mjs | 2 +- .../src/features/channels/ui/ChannelPane.tsx | 1 + .../features/channels/ui/ChannelScreen.tsx | 23 ++-- .../channels/ui/useChannelAgentSessions.ts | 7 +- .../channels/ui/useChannelProfilePanel.ts | 46 ++++++-- .../src/features/forum/ui/ForumPostCard.tsx | 19 +--- .../features/forum/ui/ForumThreadPanel.tsx | 53 ++++++---- .../src/features/messages/ui/MessageRow.tsx | 100 +++++++++++------- .../features/profile/ui/UserProfilePanel.tsx | 17 ++- .../profile/ui/UserProfilePopover.tsx | 2 +- desktop/tests/e2e/mentions.spec.ts | 15 ++- 11 files changed, 183 insertions(+), 102 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 5112077fe..39fdb850c 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -35,7 +35,7 @@ const overrides = new Map([ ["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], - ["src/features/channels/ui/ChannelScreen.tsx", 520], // profile panel state + DM callback wiring + ProfilePanelProvider context + ["src/features/channels/ui/ChannelScreen.tsx", 530], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context ["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates ["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) ["src/features/settings/ui/SettingsView.tsx", 600], diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 93390768a..a82b78513 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -402,6 +402,7 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : profilePanelPubkey ? ( (null); const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false); const [openThreadHeadId, setOpenThreadHeadId] = React.useState( null, @@ -350,12 +346,23 @@ export function ChannelScreen({ managedAgents: managedAgentsQuery.data ?? [], setExpandedThreadReplyIds, setOpenThreadHeadId, + setProfilePanelPubkey, setThreadReplyTargetId, setThreadScrollTargetId, targetMessageId, timelineMessages, }); + const { handleOpenProfilePanel, handleCloseProfilePanel, handleOpenDm } = + useChannelProfilePanel({ + closeAgentSession: handleCloseAgentSession, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + }); + const isTimelineLoading = activeChannel !== null && activeChannel.channelType !== "forum" && @@ -371,7 +378,7 @@ export function ChannelScreen({ setEditTargetId(null); setProfilePanelPubkey(null); }, - [handleCloseAgentSession, setProfilePanelPubkey], + [handleCloseAgentSession], ); const handleThreadScrollTargetResolved = React.useCallback(() => { setThreadScrollTargetId(null); diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index 5a533d480..ec9bdc675 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -12,6 +12,7 @@ type UseChannelAgentSessionsOptions = { managedAgents: ManagedAgent[]; setExpandedThreadReplyIds: (value: Set) => void; setOpenThreadHeadId: (value: string | null) => void; + setProfilePanelPubkey: (value: string | null) => void; setThreadReplyTargetId: (value: string | null) => void; setThreadScrollTargetId: (value: string | null) => void; targetMessageId: string | null; @@ -26,6 +27,7 @@ export function useChannelAgentSessions({ managedAgents, setExpandedThreadReplyIds, setOpenThreadHeadId, + setProfilePanelPubkey, setThreadReplyTargetId, setThreadScrollTargetId, targetMessageId, @@ -62,11 +64,13 @@ export function useChannelAgentSessions({ setExpandedThreadReplyIds(new Set()); setThreadScrollTargetId(null); setThreadReplyTargetId(null); + setProfilePanelPubkey(null); setOpenAgentSessionPubkey(pubkey); }, [ setExpandedThreadReplyIds, setOpenThreadHeadId, + setProfilePanelPubkey, setThreadReplyTargetId, setThreadScrollTargetId, ], @@ -79,9 +83,10 @@ export function useChannelAgentSessions({ const openThreadAndCloseAgentSession = React.useCallback( (message: TimelineMessage) => { setOpenAgentSessionPubkey(null); + setProfilePanelPubkey(null); handleOpenThread(message); }, - [handleOpenThread], + [handleOpenThread, setProfilePanelPubkey], ); React.useEffect(() => { diff --git a/desktop/src/features/channels/ui/useChannelProfilePanel.ts b/desktop/src/features/channels/ui/useChannelProfilePanel.ts index cbb6d6c10..e2039713f 100644 --- a/desktop/src/features/channels/ui/useChannelProfilePanel.ts +++ b/desktop/src/features/channels/ui/useChannelProfilePanel.ts @@ -3,20 +3,48 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useOpenDmMutation } from "@/features/channels/hooks"; -export function useChannelProfilePanel() { +type UseChannelProfilePanelOptions = { + closeAgentSession: () => void; + setExpandedThreadReplyIds: (value: Set) => void; + setOpenThreadHeadId: (value: string | null) => void; + setProfilePanelPubkey: (value: string | null) => void; + setThreadReplyTargetId: (value: string | null) => void; + setThreadScrollTargetId: (value: string | null) => void; +}; + +export function useChannelProfilePanel({ + closeAgentSession, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, +}: UseChannelProfilePanelOptions) { const { goChannel } = useAppNavigation(); const openDmMutation = useOpenDmMutation(); - const [profilePanelPubkey, setProfilePanelPubkey] = React.useState< - string | null - >(null); - const handleOpenProfilePanel = React.useCallback((pubkey: string) => { - setProfilePanelPubkey(pubkey); - }, []); + const handleOpenProfilePanel = React.useCallback( + (pubkey: string) => { + setOpenThreadHeadId(null); + setExpandedThreadReplyIds(new Set()); + setThreadScrollTargetId(null); + setThreadReplyTargetId(null); + closeAgentSession(); + setProfilePanelPubkey(pubkey); + }, + [ + closeAgentSession, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + ], + ); const handleCloseProfilePanel = React.useCallback(() => { setProfilePanelPubkey(null); - }, []); + }, [setProfilePanelPubkey]); const handleOpenDm = React.useCallback( async (pubkeys: string[]) => { @@ -27,8 +55,6 @@ export function useChannelProfilePanel() { ); return { - profilePanelPubkey, - setProfilePanelPubkey, handleOpenProfilePanel, handleCloseProfilePanel, handleOpenDm, diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 68a7e80ec..eb95aeadf 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -69,14 +69,10 @@ export function ForumPostCard({ >
{/* biome-ignore lint/a11y/noStaticElementInteractions: presentation wrapper stops click propagation to parent card */} -
e.stopPropagation()} - role="presentation" - > +
e.stopPropagation()} role="presentation"> - - -
diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 9c858ec2e..a55141dfb 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -5,6 +5,7 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -74,14 +75,21 @@ function ReplyRow({ return (
- - - {replyAuthorLabel} - + + + {formatRelativeTime(reply.createdAt)} @@ -208,18 +216,23 @@ export function ForumThreadPanel({ data-forum-event-id={post.eventId} >
- -
- - {postAuthorLabel} - - - {formatRelativeTime(post.createdAt)} - -
+ + + + + {formatRelativeTime(post.createdAt)} + {canDeletePost && onDeletePost ? ( - - - ) : ( + const avatarNode = ( - - + + {message.author} + ) : (

{message.author} @@ -296,10 +265,30 @@ export const MessageRow = React.memo( {isThreadReplyLayout ? ( <>
-
{avatarNode}
+ {message.pubkey ? ( + + + + ) : ( + <> +
+ {avatarNode} +
+ {authorNode} + + )}
- {authorNode} {message.personaDisplayName && message.personaDisplayName !== message.author ? ( @@ -316,10 +305,43 @@ export const MessageRow = React.memo( ) : ( <> -
{avatarNode}
+ {message.pubkey ? ( + + + + ) : ( +
{avatarNode}
+ )}
- {authorNode} + {message.pubkey ? ( + + + + ) : ( + authorNode + )} {message.personaDisplayName && message.personaDisplayName !== message.author ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 3f3e6d9a1..6ae5c6d8d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -25,6 +25,7 @@ import { type UserProfilePanelProps = { canResetWidth: boolean; + currentPubkey?: string; onClose: () => void; onOpenDm?: (pubkeys: string[]) => void; onResetWidth: () => void; @@ -62,6 +63,7 @@ function truncatePubkey(pubkey: string) { export function UserProfilePanel({ canResetWidth, + currentPubkey, onClose, onOpenDm, onResetWidth, @@ -80,14 +82,19 @@ export function UserProfilePanel({ const { onOpenAgentSession } = useAgentSession(); const profile = profileQuery.data; - const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; - const userStatus = userStatusQuery.data?.[pubkey.toLowerCase()]; + const pubkeyLower = pubkey.toLowerCase(); + const presenceStatus = presenceQuery.data?.[pubkeyLower]; + const userStatus = userStatusQuery.data?.[pubkeyLower]; - const relayAgent = relayAgentsQuery.data?.find((a) => a.pubkey === pubkey); + const relayAgent = relayAgentsQuery.data?.find( + (a) => a.pubkey.toLowerCase() === pubkeyLower, + ); const managedAgent = managedAgentsQuery.data?.find( - (a) => a.pubkey === pubkey, + (a) => a.pubkey.toLowerCase() === pubkeyLower, ); const isBot = Boolean(relayAgent || managedAgent); + const isSelf = + currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); const canViewActivity = isBot && managedAgent?.backend.type === "local" && @@ -242,7 +249,7 @@ export function UserProfilePanel({ {/* Actions */}
- {onOpenDm ? ( + {onOpenDm && !isSelf ? ( + ) : null} {canViewActivity ? (