mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat: user profile panel with hover popover and click-to-expand (#456)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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", 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],
|
||||
|
||||
@@ -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<void>;
|
||||
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,17 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onResizeStart={handleThreadPanelResizeStart}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
) : profilePanelPubkey ? (
|
||||
<UserProfilePanel
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
currentPubkey={currentPubkey}
|
||||
onClose={onCloseProfilePanel}
|
||||
onOpenDm={onOpenDm}
|
||||
onResetWidth={handleThreadPanelWidthReset}
|
||||
onResizeStart={handleThreadPanelResizeStart}
|
||||
pubkey={profilePanelPubkey}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,9 @@ export function ChannelScreen({
|
||||
targetMessageId,
|
||||
}: ChannelScreenProps) {
|
||||
const { markChannelRead, openChannelManagement } = useAppShell();
|
||||
const [profilePanelPubkey, setProfilePanelPubkey] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
|
||||
const [openThreadHeadId, setOpenThreadHeadId] = React.useState<string | null>(
|
||||
null,
|
||||
@@ -341,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" &&
|
||||
@@ -360,6 +376,7 @@ export function ChannelScreen({
|
||||
setThreadReplyTargetId(null);
|
||||
handleCloseAgentSession();
|
||||
setEditTargetId(null);
|
||||
setProfilePanelPubkey(null);
|
||||
},
|
||||
[handleCloseAgentSession],
|
||||
);
|
||||
@@ -402,98 +419,105 @@ export function ChannelScreen({
|
||||
|
||||
return (
|
||||
<AgentSessionProvider onOpenAgentSession={handleOpenAgentSession}>
|
||||
<ChannelScreenHeader
|
||||
activeChannel={activeChannel}
|
||||
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
|
||||
activeChannelTitle={activeChannelTitle}
|
||||
activeDmPresenceStatus={activeDmPresenceStatus}
|
||||
currentPubkey={currentPubkey}
|
||||
isJoining={joinChannelMutation.isPending}
|
||||
onJoinChannel={joinChannelMutation.mutateAsync}
|
||||
onManageChannel={openChannelManagement}
|
||||
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
|
||||
/>
|
||||
<ProfilePanelProvider onOpenProfilePanel={handleOpenProfilePanel}>
|
||||
<ChannelScreenHeader
|
||||
activeChannel={activeChannel}
|
||||
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
|
||||
activeChannelTitle={activeChannelTitle}
|
||||
activeDmPresenceStatus={activeDmPresenceStatus}
|
||||
currentPubkey={currentPubkey}
|
||||
isJoining={joinChannelMutation.isPending}
|
||||
onJoinChannel={joinChannelMutation.mutateAsync}
|
||||
onManageChannel={openChannelManagement}
|
||||
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{activeChannel ? (
|
||||
activeChannel.channelType === "forum" ? (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onClosePost={onCloseForumPost}
|
||||
onSelectPost={onSelectForumPost}
|
||||
selectedPostId={selectedForumPostId}
|
||||
targetReplyId={targetForumReplyId}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{activeChannel ? (
|
||||
activeChannel.channelType === "forum" ? (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="forum" />}>
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
onClosePost={onCloseForumPost}
|
||||
onSelectPost={onSelectForumPost}
|
||||
selectedPostId={selectedForumPostId}
|
||||
targetReplyId={targetForumReplyId}
|
||||
/>
|
||||
</React.Suspense>
|
||||
) : (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="channel" />}>
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
agentSessionAgents={channelAgentSessionAgents}
|
||||
botTypingPubkeys={botTypingPubkeys}
|
||||
channelFind={channelFind}
|
||||
currentPubkey={currentPubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
editTarget={
|
||||
editTargetMessage
|
||||
? {
|
||||
author: editTargetMessage.author,
|
||||
body: editTargetMessage.body,
|
||||
id: editTargetMessage.id,
|
||||
}
|
||||
: null
|
||||
}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelThreadReply={handleCancelThreadReply}
|
||||
onCloseAgentSession={handleCloseAgentSession}
|
||||
onCloseThread={handleCloseThread}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditSave={handleEditSave}
|
||||
onExpandThreadReplies={handleExpandThreadReplies}
|
||||
onOpenAgentSession={handleOpenAgentSession}
|
||||
onOpenDm={handleOpenDm}
|
||||
onCloseProfilePanel={handleCloseProfilePanel}
|
||||
onOpenThread={handleOpenThreadAndCloseAgentSession}
|
||||
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
|
||||
onSendMessage={handleSendMessage}
|
||||
onSendThreadReply={handleSendThreadReply}
|
||||
onThreadScrollTargetResolved={
|
||||
handleThreadScrollTargetResolved
|
||||
}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
openThreadHeadId={openThreadHeadId}
|
||||
profilePanelPubkey={profilePanelPubkey}
|
||||
personaLookup={personaLookup}
|
||||
profiles={messageProfiles}
|
||||
targetMessageId={targetMessageId}
|
||||
threadHeadMessage={openThreadHeadMessage}
|
||||
threadMessages={threadMessages}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
threadReplyTargetId={threadReplyTargetId}
|
||||
threadReplyTargetMessage={threadReplyTargetMessage}
|
||||
threadScrollTargetId={threadScrollTargetId}
|
||||
isJoining={joinChannelMutation.isPending}
|
||||
onJoinChannel={joinChannelMutation.mutateAsync}
|
||||
typingPubkeys={humanTypingPubkeys}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)
|
||||
) : (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="channel" />}>
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
agentSessionAgents={channelAgentSessionAgents}
|
||||
botTypingPubkeys={botTypingPubkeys}
|
||||
channelFind={channelFind}
|
||||
currentPubkey={currentPubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
editTarget={
|
||||
editTargetMessage
|
||||
? {
|
||||
author: editTargetMessage.author,
|
||||
body: editTargetMessage.body,
|
||||
id: editTargetMessage.id,
|
||||
}
|
||||
: null
|
||||
}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelThreadReply={handleCancelThreadReply}
|
||||
onCloseAgentSession={handleCloseAgentSession}
|
||||
onCloseThread={handleCloseThread}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditSave={handleEditSave}
|
||||
onExpandThreadReplies={handleExpandThreadReplies}
|
||||
onOpenAgentSession={handleOpenAgentSession}
|
||||
onOpenThread={handleOpenThreadAndCloseAgentSession}
|
||||
onSelectThreadReplyTarget={handleSelectThreadReplyTarget}
|
||||
onSendMessage={handleSendMessage}
|
||||
onSendThreadReply={handleSendThreadReply}
|
||||
onThreadScrollTargetResolved={handleThreadScrollTargetResolved}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
openThreadHeadId={openThreadHeadId}
|
||||
personaLookup={personaLookup}
|
||||
profiles={messageProfiles}
|
||||
targetMessageId={targetMessageId}
|
||||
threadHeadMessage={openThreadHeadMessage}
|
||||
threadMessages={threadMessages}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
threadReplyTargetId={threadReplyTargetId}
|
||||
threadReplyTargetMessage={threadReplyTargetMessage}
|
||||
threadScrollTargetId={threadScrollTargetId}
|
||||
isJoining={joinChannelMutation.isPending}
|
||||
onJoinChannel={joinChannelMutation.mutateAsync}
|
||||
typingPubkeys={humanTypingPubkeys}
|
||||
/>
|
||||
</React.Suspense>
|
||||
)
|
||||
) : (
|
||||
<ChannelScreenEmptyState />
|
||||
)}
|
||||
</div>
|
||||
<ChannelScreenEmptyState />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<MembersSidebar
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
open={isMembersSidebarOpen}
|
||||
onOpenChange={setIsMembersSidebarOpen}
|
||||
onViewActivity={handleOpenAgentSession}
|
||||
/>
|
||||
<MembersSidebar
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
open={isMembersSidebarOpen}
|
||||
onOpenChange={setIsMembersSidebarOpen}
|
||||
onViewActivity={handleOpenAgentSession}
|
||||
/>
|
||||
</ProfilePanelProvider>
|
||||
</AgentSessionProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type UseChannelAgentSessionsOptions = {
|
||||
managedAgents: ManagedAgent[];
|
||||
setExpandedThreadReplyIds: (value: Set<string>) => 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(() => {
|
||||
@@ -138,6 +143,7 @@ export function useChannelAgentSessions({
|
||||
}
|
||||
|
||||
setOpenAgentSessionPubkey(null);
|
||||
setProfilePanelPubkey(null);
|
||||
setOpenThreadHeadId(threadHeadId);
|
||||
setThreadReplyTargetId(threadHeadId);
|
||||
setThreadScrollTargetId(targetMessageId);
|
||||
@@ -148,6 +154,7 @@ export function useChannelAgentSessions({
|
||||
activeChannelId,
|
||||
setExpandedThreadReplyIds,
|
||||
setOpenThreadHeadId,
|
||||
setProfilePanelPubkey,
|
||||
setThreadReplyTargetId,
|
||||
setThreadScrollTargetId,
|
||||
targetMessageId,
|
||||
|
||||
62
desktop/src/features/channels/ui/useChannelProfilePanel.ts
Normal file
62
desktop/src/features/channels/ui/useChannelProfilePanel.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useOpenDmMutation } from "@/features/channels/hooks";
|
||||
|
||||
type UseChannelProfilePanelOptions = {
|
||||
closeAgentSession: () => void;
|
||||
setExpandedThreadReplyIds: (value: Set<string>) => 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 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[]) => {
|
||||
const dm = await openDmMutation.mutateAsync({ pubkeys });
|
||||
await goChannel(dm.id);
|
||||
},
|
||||
[goChannel, openDmMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
handleOpenProfilePanel,
|
||||
handleCloseProfilePanel,
|
||||
handleOpenDm,
|
||||
};
|
||||
}
|
||||
@@ -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,24 @@ export function ForumPostCard({
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar avatarUrl={avatarUrl} displayName={authorLabel} size="sm" />
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{authorLabel}
|
||||
</span>
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: presentation wrapper stops click propagation to parent card */}
|
||||
<div onClick={(e) => e.stopPropagation()} role="presentation">
|
||||
<UserProfilePopover pubkey={post.pubkey}>
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
displayName={authorLabel}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="truncate text-sm font-medium text-foreground hover:underline">
|
||||
{authorLabel}
|
||||
</span>
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(post.createdAt)}
|
||||
</span>
|
||||
|
||||
@@ -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 (
|
||||
<div className="group px-4 py-3" data-forum-event-id={reply.eventId}>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
avatarUrl={replyAvatarUrl}
|
||||
displayName={replyAuthorLabel}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{replyAuthorLabel}
|
||||
</span>
|
||||
<UserProfilePopover pubkey={reply.pubkey}>
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={replyAvatarUrl}
|
||||
displayName={replyAuthorLabel}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground hover:underline">
|
||||
{replyAuthorLabel}
|
||||
</span>
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(reply.createdAt)}
|
||||
</span>
|
||||
@@ -208,18 +216,23 @@ export function ForumThreadPanel({
|
||||
data-forum-event-id={post.eventId}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
avatarUrl={postAvatarUrl}
|
||||
displayName={postAuthorLabel}
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{postAuthorLabel}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{formatRelativeTime(post.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<UserProfilePopover pubkey={post.pubkey}>
|
||||
<button
|
||||
className="flex items-center gap-2 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={postAvatarUrl}
|
||||
displayName={postAuthorLabel}
|
||||
/>
|
||||
<span className="text-sm font-semibold text-foreground hover:underline">
|
||||
{postAuthorLabel}
|
||||
</span>
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(post.createdAt)}
|
||||
</span>
|
||||
|
||||
{canDeletePost && onDeletePost ? (
|
||||
<DeleteActionMenu
|
||||
|
||||
@@ -133,29 +133,7 @@ export const MessageRow = React.memo(
|
||||
? "rounded-md"
|
||||
: "rounded-xl";
|
||||
|
||||
const avatarNode = message.pubkey ? (
|
||||
<UserProfilePopover
|
||||
pubkey={message.pubkey}
|
||||
role={message.role}
|
||||
botIdenticonValue={message.author}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
avatarButtonRadiusClass,
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
accent={message.accent}
|
||||
avatarUrl={message.avatarUrl ?? null}
|
||||
className={avatarSizeClass}
|
||||
displayName={message.author}
|
||||
testId="message-avatar"
|
||||
/>
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
const avatarNode = (
|
||||
<UserAvatar
|
||||
accent={message.accent}
|
||||
avatarUrl={message.avatarUrl ?? null}
|
||||
@@ -166,18 +144,9 @@ export const MessageRow = React.memo(
|
||||
);
|
||||
|
||||
const authorNode = message.pubkey ? (
|
||||
<UserProfilePopover
|
||||
pubkey={message.pubkey}
|
||||
role={message.role}
|
||||
botIdenticonValue={message.author}
|
||||
>
|
||||
<button
|
||||
className="truncate rounded text-sm font-semibold tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{message.author}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
<span className="truncate text-sm font-semibold tracking-tight hover:underline">
|
||||
{message.author}
|
||||
</span>
|
||||
) : (
|
||||
<h3 className="truncate text-sm font-semibold tracking-tight">
|
||||
{message.author}
|
||||
@@ -296,10 +265,30 @@ export const MessageRow = React.memo(
|
||||
{isThreadReplyLayout ? (
|
||||
<>
|
||||
<div className="flex min-w-0 items-start gap-1.5">
|
||||
<div className="flex shrink-0 items-start">{avatarNode}</div>
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover
|
||||
pubkey={message.pubkey}
|
||||
role={message.role}
|
||||
botIdenticonValue={message.author}
|
||||
>
|
||||
<button
|
||||
className="flex shrink-0 items-center gap-1.5 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{avatarNode}
|
||||
{authorNode}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex shrink-0 items-start">
|
||||
{avatarNode}
|
||||
</div>
|
||||
{authorNode}
|
||||
</>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{authorNode}
|
||||
{message.personaDisplayName &&
|
||||
message.personaDisplayName !== message.author ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
@@ -316,10 +305,43 @@ export const MessageRow = React.memo(
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex shrink-0 items-start">{avatarNode}</div>
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover
|
||||
pubkey={message.pubkey}
|
||||
role={message.role}
|
||||
botIdenticonValue={message.author}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"flex shrink-0 items-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
avatarButtonRadiusClass,
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
{avatarNode}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-start">{avatarNode}</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{authorNode}
|
||||
{message.pubkey ? (
|
||||
<UserProfilePopover
|
||||
pubkey={message.pubkey}
|
||||
role={message.role}
|
||||
botIdenticonValue={message.author}
|
||||
>
|
||||
<button
|
||||
className="truncate rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{authorNode}
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
authorNode
|
||||
)}
|
||||
{message.personaDisplayName &&
|
||||
message.personaDisplayName !== message.author ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
282
desktop/src/features/profile/ui/UserProfilePanel.tsx
Normal file
282
desktop/src/features/profile/ui/UserProfilePanel.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
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;
|
||||
currentPubkey?: string;
|
||||
onClose: () => void;
|
||||
onOpenDm?: (pubkeys: string[]) => void;
|
||||
onResetWidth: () => void;
|
||||
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
pubkey: string;
|
||||
widthPx: number;
|
||||
};
|
||||
|
||||
const RUNTIME_LABELS: Record<string, string> = {
|
||||
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 (
|
||||
<span className="inline-flex items-center rounded-full bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function truncatePubkey(pubkey: string) {
|
||||
if (pubkey.length <= 16) {
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`;
|
||||
}
|
||||
|
||||
export function UserProfilePanel({
|
||||
canResetWidth,
|
||||
currentPubkey,
|
||||
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 pubkeyLower = pubkey.toLowerCase();
|
||||
const presenceStatus = presenceQuery.data?.[pubkeyLower];
|
||||
const userStatus = userStatusQuery.data?.[pubkeyLower];
|
||||
|
||||
const relayAgent = relayAgentsQuery.data?.find(
|
||||
(a) => a.pubkey.toLowerCase() === pubkeyLower,
|
||||
);
|
||||
const managedAgent = managedAgentsQuery.data?.find(
|
||||
(a) => a.pubkey.toLowerCase() === pubkeyLower,
|
||||
);
|
||||
const isBot = Boolean(relayAgent || managedAgent);
|
||||
const isSelf =
|
||||
currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase();
|
||||
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 && <OverlayPanelBackdrop onClose={onClose} />}
|
||||
<aside
|
||||
className={cn(PANEL_BASE_CLASS, isOverlay && PANEL_OVERLAY_CLASS)}
|
||||
data-testid="user-profile-panel"
|
||||
style={{ width: `${widthPx}px` }}
|
||||
>
|
||||
{!isOverlay && (
|
||||
<button
|
||||
aria-label="Resize profile panel"
|
||||
className="group absolute inset-y-0 left-0 z-20 w-3 -translate-x-1/2 cursor-col-resize"
|
||||
data-testid="user-profile-resize-handle"
|
||||
onDoubleClick={canResetWidth ? onResetWidth : undefined}
|
||||
onPointerDown={onResizeStart}
|
||||
title={
|
||||
canResetWidth
|
||||
? "Drag to resize. Double-click to reset width."
|
||||
: "Drag to resize."
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Profile</h2>
|
||||
</div>
|
||||
<Button
|
||||
aria-label="Close profile"
|
||||
data-testid="user-profile-panel-close"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-6">
|
||||
<div className="flex flex-col items-center gap-4 pt-4">
|
||||
{/* Avatar */}
|
||||
{profile?.avatarUrl ? (
|
||||
<img
|
||||
alt={displayName}
|
||||
className="aspect-square w-full rounded-2xl object-cover shadow-sm"
|
||||
referrerPolicy="no-referrer"
|
||||
src={rewriteRelayUrl(profile.avatarUrl)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex aspect-square w-full items-center justify-center rounded-2xl bg-secondary text-5xl font-semibold text-secondary-foreground shadow-sm">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name + bot identicon */}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold">{displayName}</h3>
|
||||
{isBot ? (
|
||||
<BotIdenticon
|
||||
value={displayName}
|
||||
size={20}
|
||||
className="shrink-0 rounded"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{profile?.nip05Handle ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{profile.nip05Handle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Presence */}
|
||||
{presenceStatus ? <PresenceBadge status={presenceStatus} /> : null}
|
||||
|
||||
{/* User status */}
|
||||
{userStatus ? (
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{userStatus.emoji ? (
|
||||
<span className="mr-1">{userStatus.emoji}</span>
|
||||
) : null}
|
||||
{userStatus.text}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Pubkey (copyable) */}
|
||||
<div className="mt-6">
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-border/60 bg-card/50 px-3 py-2 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted/50"
|
||||
data-testid="user-profile-copy-pubkey"
|
||||
onClick={handleCopyPubkey}
|
||||
title="Copy public key"
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{pubkey}</span>
|
||||
<Copy className="h-3.5 w-3.5 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bot info badges */}
|
||||
{isBot && (managedAgent || relayAgent) ? (
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{managedAgent?.agentCommand ? (
|
||||
<InfoBadge>{runtimeLabel(managedAgent.agentCommand)}</InfoBadge>
|
||||
) : relayAgent?.agentType ? (
|
||||
<InfoBadge>{runtimeLabel(relayAgent.agentType)}</InfoBadge>
|
||||
) : null}
|
||||
{managedAgent?.model ? (
|
||||
<InfoBadge>{managedAgent.model}</InfoBadge>
|
||||
) : null}
|
||||
{managedAgent?.acpCommand ? (
|
||||
<InfoBadge>ACP: {managedAgent.acpCommand}</InfoBadge>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* About */}
|
||||
{profile?.about ? (
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-1 text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
|
||||
About
|
||||
</h4>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{profile.about}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
{onOpenDm && !isSelf ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid="user-profile-message"
|
||||
onClick={handleMessage}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Message
|
||||
</Button>
|
||||
) : null}
|
||||
{canViewActivity ? (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-border/60 px-3 py-2 text-left text-xs font-medium text-foreground transition-colors hover:bg-muted/50"
|
||||
data-testid={`user-profile-view-activity-${pubkey}`}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onOpenAgentSession?.(pubkey);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
View activity log
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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<ReturnType<typeof setTimeout> | 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,78 @@ 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(
|
||||
(event: React.MouseEvent) => {
|
||||
clearHoverTimer();
|
||||
if (openProfilePanel) {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
openProfilePanel(pubkey);
|
||||
}
|
||||
},
|
||||
[clearHoverTimer, openProfilePanel, pubkey],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => clearHoverTimer();
|
||||
}, [clearHoverTimer]);
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80" side="top" sideOffset={8}>
|
||||
<PopoverTrigger asChild>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: wrapper div for hover/click behavior */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleTriggerClick}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === "Enter" || e.key === " ") && openProfilePanel) {
|
||||
e.preventDefault();
|
||||
clearHoverTimer();
|
||||
setOpen(false);
|
||||
openProfilePanel(pubkey);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={handleTriggerMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className="inline-flex"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-80"
|
||||
onMouseEnter={handleContentMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{profile?.avatarUrl ? (
|
||||
|
||||
32
desktop/src/shared/context/ProfilePanelContext.tsx
Normal file
32
desktop/src/shared/context/ProfilePanelContext.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
|
||||
type ProfilePanelContextValue = {
|
||||
openProfilePanel: ((pubkey: string) => void) | null;
|
||||
};
|
||||
|
||||
const ProfilePanelContext = React.createContext<ProfilePanelContextValue>({
|
||||
openProfilePanel: null,
|
||||
});
|
||||
|
||||
export function ProfilePanelProvider({
|
||||
children,
|
||||
onOpenProfilePanel,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onOpenProfilePanel: (pubkey: string) => void;
|
||||
}) {
|
||||
const value = React.useMemo(
|
||||
() => ({ openProfilePanel: onOpenProfilePanel }),
|
||||
[onOpenProfilePanel],
|
||||
);
|
||||
|
||||
return (
|
||||
<ProfilePanelContext.Provider value={value}>
|
||||
{children}
|
||||
</ProfilePanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useProfilePanel() {
|
||||
return React.useContext(ProfilePanelContext);
|
||||
}
|
||||
@@ -127,7 +127,7 @@ test("mention text is highlighted in sent messages", async ({ page }) => {
|
||||
await expect(mentionSpan).toBeVisible();
|
||||
});
|
||||
|
||||
test("clicking author name opens user profile popover", async ({ page }) => {
|
||||
test("clicking author name opens user profile panel", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
@@ -139,25 +139,32 @@ test("clicking author name opens user profile popover", async ({ page }) => {
|
||||
});
|
||||
await authorButton.click();
|
||||
|
||||
const popover = page.locator("[data-radix-popper-content-wrapper]");
|
||||
await expect(popover).toBeVisible();
|
||||
await expect(popover).toContainText("deadbeef");
|
||||
// Notes section removed — user status replaces it.
|
||||
// Verify the popover is still functional (pubkey visible confirms data loaded).
|
||||
await expect(popover).toContainText("deadbeef");
|
||||
// Click now opens the full profile panel instead of the popover
|
||||
const panel = page.getByTestId("user-profile-panel");
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(panel).toContainText("deadbeef");
|
||||
});
|
||||
|
||||
test("clicking avatar opens user profile popover", async ({ page }) => {
|
||||
test("hovering avatar opens popover, clicking opens profile panel", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
|
||||
// Click the avatar button on the first message
|
||||
const firstMessage = page.getByTestId("message-row").first();
|
||||
const avatarButton = firstMessage.locator("button").first();
|
||||
await avatarButton.click();
|
||||
|
||||
// Hover should open the popover
|
||||
await avatarButton.hover();
|
||||
await expect(
|
||||
page.locator("[data-radix-popper-content-wrapper]"),
|
||||
).toBeVisible();
|
||||
|
||||
// Click should close the popover and open the profile panel
|
||||
await avatarButton.click();
|
||||
await expect(
|
||||
page.locator("[data-radix-popper-content-wrapper]"),
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user