fix(desktop): open profiles from avatars (#3751)

## Summary
- show profile descriptions in hover cards as a single truncated line
- open the profile panel when avatars are clicked across desktop
surfaces
- make the direct-message intro avatar clickable

## Validation
- Desktop static checks
- 3,807 desktop tests via pre-push

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-07-31 15:24:51 +01:00
committed by GitHub
parent 61ba9dfaa0
commit 39ce3dfc3c
10 changed files with 388 additions and 275 deletions

View File

@@ -18,12 +18,10 @@ const rules = [
// Non-display uses: array windows over pubkey lists, color/initials // Non-display uses: array windows over pubkey lists, color/initials
// derivation where the value is never presented as an identity. // derivation where the value is never presented as an identity.
const overrides = new Set([ const overrides = new Set([
// ProfileAvatar fallback label — decorative glyphs inside an avatar disc.
"src/features/huddle/components/ParticipantList.tsx:92",
// HexAvatar: 6-char badge + hue derivation inside a color-coded disc, // HexAvatar: 6-char badge + hue derivation inside a color-coded disc,
// clearly decorative (paired with a full truncatePubkey aria-label). // clearly decorative (paired with a full truncatePubkey aria-label).
"src/features/huddle/components/ParticipantList.tsx:143", "src/features/huddle/components/ParticipantList.tsx:150",
"src/features/huddle/components/ParticipantList.tsx:144", "src/features/huddle/components/ParticipantList.tsx:151",
// clientId (not a pubkey) sliced in a debug log next to the real thing. // clientId (not a pubkey) sliced in a debug log next to the real thing.
"src/features/channels/readState/readStateManager.ts:338", "src/features/channels/readState/readStateManager.ts:338",
// Array windows (first N pubkeys), not string truncation. // Array windows (first N pubkeys), not string truncation.

View File

@@ -0,0 +1,25 @@
import type * as React from "react";
import { HuddleBar } from "@/features/huddle";
import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider";
type AppHuddleBarProps = Pick<
React.ComponentProps<typeof HuddleBar>,
"onOpenThread" | "onVisibilityChange"
>;
export function AppHuddleBar({
onOpenThread,
onVisibilityChange,
}: AppHuddleBarProps) {
return (
<AppProfilePanelProvider>
<HuddleBar
className="h-full"
onOpenThread={onOpenThread}
onVisibilityChange={onVisibilityChange}
/>
</AppProfilePanelProvider>
);
}

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
export function AppProfilePanelProvider({
children,
}: Readonly<{ children: React.ReactNode }>) {
const { goProfile } = useAppNavigation();
const handleOpenProfilePanel = React.useCallback(
(pubkey: string) => {
void goProfile(pubkey);
},
[goProfile],
);
return (
<ProfilePanelProvider onOpenProfilePanel={handleOpenProfilePanel}>
{children}
</ProfilePanelProvider>
);
}

View File

@@ -63,7 +63,8 @@ import {
type SettingsSection, type SettingsSection,
isSettingsSection, isSettingsSection,
} from "@/features/settings/ui/SettingsPanels"; } from "@/features/settings/ui/SettingsPanels";
import { HuddleBar, HuddleProvider } from "@/features/huddle"; import { HuddleProvider } from "@/features/huddle";
import { AppHuddleBar } from "@/app/AppHuddleBar";
import { useDueReminderBadgeCount } from "@/features/reminders/hooks"; import { useDueReminderBadgeCount } from "@/features/reminders/hooks";
import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider";
import { useReminderNotifications } from "@/features/reminders/useReminderNotifications"; import { useReminderNotifications } from "@/features/reminders/useReminderNotifications";
@@ -97,7 +98,7 @@ import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay";
import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu"; import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu";
import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider";
const LazySettingsScreen = React.lazy(async () => { const LazySettingsScreen = React.lazy(async () => {
const module = await import("@/features/settings/ui/SettingsScreen"); const module = await import("@/features/settings/ui/SettingsScreen");
return { default: module.SettingsScreen }; return { default: module.SettingsScreen };
@@ -160,7 +161,6 @@ export function AppShell() {
? locationSearchSection ? locationSearchSection
: DEFAULT_SETTINGS_SECTION; : DEFAULT_SETTINGS_SECTION;
const startupReady = useDeferredStartup(); const startupReady = useDeferredStartup();
const identityQuery = useIdentityQuery(); const identityQuery = useIdentityQuery();
const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes( const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes(
identityQuery.data?.pubkey, identityQuery.data?.pubkey,
@@ -303,7 +303,6 @@ export function AppShell() {
? (channels.find((channel) => channel.id === targetChannelId) ?? null) ? (channels.find((channel) => channel.id === targetChannelId) ?? null)
: null; : null;
}, [channels, managedChannelId, selectedChannelId]); }, [channels, managedChannelId, selectedChannelId]);
const { const {
handleChannelNotification, handleChannelNotification,
handleDmNotification, handleDmNotification,
@@ -518,7 +517,6 @@ export function AppShell() {
}, },
[applyAgents, applyCanvas, createChannelMutation, goChannel], [applyAgents, applyCanvas, createChannelMutation, goChannel],
); );
const handleCreateForum = React.useCallback( const handleCreateForum = React.useCallback(
async ({ async ({
description, description,
@@ -586,7 +584,6 @@ export function AppShell() {
}, },
[goHome, hideDmMutation, selectedChannelId], [goHome, hideDmMutation, selectedChannelId],
); );
const handleOpenSettings = React.useCallback( const handleOpenSettings = React.useCallback(
(section: SettingsSection = DEFAULT_SETTINGS_SECTION) => { (section: SettingsSection = DEFAULT_SETTINGS_SECTION) => {
setIsChannelManagementOpen(false); setIsChannelManagementOpen(false);
@@ -594,12 +591,10 @@ export function AppShell() {
}, },
[goSettings], [goSettings],
); );
const handleCloseSettings = React.useCallback( const handleCloseSettings = React.useCallback(
() => closeSettings(), () => closeSettings(),
[closeSettings], [closeSettings],
); );
// Section switches rewrite the settings entry rather than stacking one // Section switches rewrite the settings entry rather than stacking one
// history entry per section, so back always exits settings in one step. // history entry per section, so back always exits settings in one step.
const handleSettingsSectionChange = React.useCallback( const handleSettingsSectionChange = React.useCallback(
@@ -620,11 +615,8 @@ export function AppShell() {
unreadChannelIds, unreadChannelIds,
unreadChannelNotificationCount, unreadChannelNotificationCount,
}); });
// Dispatch `buzz://message` deep links into the router.
useMessageDeepLinks(); useMessageDeepLinks();
const handleOpenNewDm = React.useCallback(
() => void goNewMessage(),
[goNewMessage],
);
const handleOpenCreateChannel = React.useCallback( const handleOpenCreateChannel = React.useCallback(
() => setIsCreateChannelOpen(true), () => setIsCreateChannelOpen(true),
[], [],
@@ -657,7 +649,7 @@ export function AppShell() {
if (key === "k" && event.shiftKey) { if (key === "k" && event.shiftKey) {
event.preventDefault(); event.preventDefault();
handleOpenNewDm(); void goNewMessage();
return; return;
} }
@@ -686,9 +678,9 @@ export function AppShell() {
}; };
}, [ }, [
handleOpenBrowseChannels, handleOpenBrowseChannels,
handleOpenNewDm,
handleOpenCreateChannel, handleOpenCreateChannel,
handleOpenSearch, handleOpenSearch,
goNewMessage,
goHome, goHome,
settingsOpen, settingsOpen,
]); ]);
@@ -770,216 +762,224 @@ export function AppShell() {
/> />
) : null} ) : null}
<SidebarProvider className="min-h-0 flex-1 flex-col overflow-hidden"> <SidebarProvider className="min-h-0 flex-1 flex-col overflow-hidden">
{!settingsOpen ? ( <AppProfilePanelProvider>
<AppTopChrome {!settingsOpen ? (
canGoBack={canGoBack} <AppTopChrome
canGoForward={canGoForward} canGoBack={canGoBack}
hasCommunityRail={hasCommunityRail} canGoForward={canGoForward}
onGoBack={goBack} hasCommunityRail={hasCommunityRail}
onGoForward={goForward} onGoBack={goBack}
/> onGoForward={goForward}
) : null} />
{settingsOpen ? ( ) : null}
<div className="flex min-h-0 flex-1 overflow-hidden"> {settingsOpen ? (
<React.Suspense fallback={null}> <div className="flex min-h-0 flex-1 overflow-hidden">
<LazySettingsScreen <React.Suspense fallback={null}>
<LazySettingsScreen
currentPubkey={identityQuery.data?.pubkey}
fallbackDisplayName={
identityQuery.data?.displayName
}
isUpdatingDesktopNotifications={
notificationSettings.isUpdatingDesktopEnabled
}
notificationErrorMessage={
notificationSettings.errorMessage
}
notificationPermission={
notificationSettings.permission
}
notificationSettings={
notificationSettings.settings
}
onClose={handleCloseSettings}
onSectionChange={handleSettingsSectionChange}
onSetDesktopNotificationsEnabled={
notificationSettings.setDesktopEnabled
}
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetSlotAlertsEnabled={
notificationSettings.setSlotAlertsEnabled
}
onSetNotifyWhileViewing={
notificationSettings.setNotifyWhileViewing
}
onSetAllSlotAlertsEnabled={
notificationSettings.setAllSlotAlertsEnabled
}
onSetSoundForSlot={
notificationSettings.setSoundForSlot
}
section={settingsSection}
/>
</React.Suspense>
</div>
) : (
<div className="flex min-h-0 flex-1 overflow-hidden">
<AppSidebar
activeCommunity={communitiesHook.activeCommunity}
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey} currentPubkey={identityQuery.data?.pubkey}
errorMessage={channelsErrorMessage}
fallbackDisplayName={ fallbackDisplayName={
identityQuery.data?.displayName identityQuery.data?.displayName
} }
isUpdatingDesktopNotifications={ homeBadgeCount={homeBadgeCount + dueReminderBadge}
notificationSettings.isUpdatingDesktopEnabled addCommunityPrefill={addCommunityDialog.prefill}
} isAddCommunityOpen={addCommunityDialog.open}
notificationErrorMessage={ relayConnectionCard={relayConnectionCard}
notificationSettings.errorMessage isCreatingChannel={createChannelMutation.isPending}
} isCreatingForum={createForumMutation.isPending}
notificationPermission={ isLoading={channelsQuery.isLoading}
notificationSettings.permission isCreateChannelOpen={isCreateChannelOpen}
} isPresencePending={presenceSession.isPending}
notificationSettings={notificationSettings.settings} onAddCommunity={(community) => {
onClose={handleCloseSettings} const id = communitiesHook.addCommunity({
onSectionChange={handleSettingsSectionChange} ...community,
onSetDesktopNotificationsEnabled={ pubkey:
notificationSettings.setDesktopEnabled community.pubkey ??
} identityQuery.data?.pubkey,
onSetHomeBadgeEnabled={
notificationSettings.setHomeBadgeEnabled
}
onSetSlotAlertsEnabled={
notificationSettings.setSlotAlertsEnabled
}
onSetNotifyWhileViewing={
notificationSettings.setNotifyWhileViewing
}
onSetAllSlotAlertsEnabled={
notificationSettings.setAllSlotAlertsEnabled
}
onSetSoundForSlot={
notificationSettings.setSoundForSlot
}
section={settingsSection}
/>
</React.Suspense>
</div>
) : (
<div className="flex min-h-0 flex-1 overflow-hidden">
<AppSidebar
activeCommunity={communitiesHook.activeCommunity}
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={channelsErrorMessage}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount + dueReminderBadge}
addCommunityPrefill={addCommunityDialog.prefill}
isAddCommunityOpen={addCommunityDialog.open}
relayConnectionCard={relayConnectionCard}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isCreateChannelOpen={isCreateChannelOpen}
isPresencePending={presenceSession.isPending}
onAddCommunity={(community) => {
const id = communitiesHook.addCommunity({
...community,
pubkey:
community.pubkey ?? identityQuery.data?.pubkey,
});
handleSwitchCommunity(id);
}}
onAddCommunityOpenChange={
addCommunityDialog.onOpenChange
}
onNewMessage={handleOpenNewDm}
onBackgroundClick={requestFocusedThreadClose}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddCommunity={addCommunityDialog.openDialog}
onSendFeedback={() => setIsSendFeedbackOpen(true)}
onUpdateCommunity={communitiesHook.updateCommunity}
onRemoveCommunity={(id) =>
void handleRemoveCommunity(id)
}
onSwitchCommunity={handleSwitchCommunity}
onCreateAgent={() => requestOpenCreateAgent()}
selfPresenceStatus={presenceSession.currentStatus}
communities={communitiesHook.communities}
onCreateChannel={handleCreateChannel}
onCreateForum={handleCreateForum}
onHideDm={handleHideDm}
onMarkAllChannelsRead={markAllChannelsRead}
onMarkChannelRead={markChannelRead}
onMarkChannelUnread={markChannelUnread}
onBrowseChannels={handleOpenBrowseChannels}
onOpenDm={async ({ pubkeys }) => {
const directMessage =
await openDmMutation.mutateAsync({
pubkeys,
}); });
await goChannel(directMessage.id); handleSwitchCommunity(id);
}} }}
onSelectAgents={() => void goAgents()} onAddCommunityOpenChange={
onSelectChannel={(channelId) => addCommunityDialog.onOpenChange
void goChannel(channelId) }
} onNewMessage={goNewMessage}
onOpenSearchResult={handleOpenSearchResult} onBackgroundClick={requestFocusedThreadClose}
searchChannels={channels} onCreateChannelOpenChange={setIsCreateChannelOpen}
searchFocusRequest={searchFocusRequest} onOpenAddCommunity={addCommunityDialog.openDialog}
onSelectHome={() => void goHome()} onSendFeedback={() => setIsSendFeedbackOpen(true)}
onSelectProjects={() => void goProjects()} onUpdateCommunity={communitiesHook.updateCommunity}
onSelectPulse={() => void goPulse()} onRemoveCommunity={(id) =>
onSelectSettings={handleOpenSettings} void handleRemoveCommunity(id)
onSelectWorkflows={() => void goWorkflows()} }
onSetPresenceStatus={(status) => onSwitchCommunity={handleSwitchCommunity}
presenceSession.setStatus(status) onCreateAgent={() => requestOpenCreateAgent()}
} selfPresenceStatus={presenceSession.currentStatus}
onSetUserStatus={(text, emoji) => communities={communitiesHook.communities}
setUserStatusMutation.mutate({ text, emoji }) onCreateChannel={handleCreateChannel}
} onCreateForum={handleCreateForum}
onClearUserStatus={() => onHideDm={handleHideDm}
setUserStatusMutation.mutate({ onMarkAllChannelsRead={markAllChannelsRead}
text: "", onMarkChannelRead={markChannelRead}
emoji: "", onMarkChannelUnread={markChannelUnread}
}) onBrowseChannels={handleOpenBrowseChannels}
} onOpenDm={async ({ pubkeys }) => {
profile={profileQuery.data} const directMessage =
selfUserStatus={ await openDmMutation.mutateAsync({
deferredPubkey pubkeys,
? (selfStatusQuery.data?.[ });
deferredPubkey.toLowerCase() await goChannel(directMessage.id);
] ?? undefined) }}
: undefined onSelectAgents={() => void goAgents()}
} onSelectChannel={(channelId) =>
selectedChannelId={selectedChannelId} void goChannel(channelId)
selectedView={selectedView} }
unreadChannelIds={unreadChannelIds} onOpenSearchResult={handleOpenSearchResult}
unreadChannelCounts={unreadChannelCounts} searchChannels={channels}
mutedChannelIds={mutedChannelIds} searchFocusRequest={searchFocusRequest}
onMuteChannel={muteChannel} onSelectHome={() => void goHome()}
onUnmuteChannel={unmuteChannel} onSelectProjects={() => void goProjects()}
starredChannelIds={starredChannelIds} onSelectPulse={() => void goPulse()}
onStarChannel={starChannel} onSelectSettings={handleOpenSettings}
onUnstarChannel={unstarChannel} onSelectWorkflows={() => void goWorkflows()}
/> onSetPresenceStatus={(status) =>
<MainInsetProvider mainInsetRef={mainInsetRef}> presenceSession.setStatus(status)
<SidebarInset }
ref={mainInsetRef} onSetUserStatus={(text, emoji) =>
className="isolate min-h-0 min-w-0 overflow-hidden bg-sidebar" setUserStatusMutation.mutate({ text, emoji })
data-buzz-glass-inset }
data-buzz-shadow-viewport onClearUserStatus={() =>
style={chromeCssVarDefaults as React.CSSProperties} setUserStatusMutation.mutate({
> text: "",
<BuzzTheme.ContentSurface> emoji: "",
<Outlet /> })
</BuzzTheme.ContentSurface> }
</SidebarInset> profile={profileQuery.data}
</MainInsetProvider> selfUserStatus={
<RelayConnectionOverlay deferredPubkey
card={relayConnectionCard} ? (selfStatusQuery.data?.[
errorMessage={channelsErrorMessage} deferredPubkey.toLowerCase()
hasCommunityRail={hasCommunityRail} ] ?? undefined)
isHuddleDrawerOpen={isHuddleDrawerOpen} : undefined
/> }
</div> selectedChannelId={selectedChannelId}
)} selectedView={selectedView}
<RequestedAgentCreateDialogs /> unreadChannelIds={unreadChannelIds}
<AgentManagementDialogs /> unreadChannelCounts={unreadChannelCounts}
<AppShellOverlays mutedChannelIds={mutedChannelIds}
activeChannel={managedChannel} onMuteChannel={muteChannel}
browseDialogType={browseDialogType} onUnmuteChannel={unmuteChannel}
channels={channels} starredChannelIds={starredChannelIds}
currentPubkey={identityQuery.data?.pubkey} onStarChannel={starChannel}
isChannelManagementOpen={isChannelManagementOpen} onUnstarChannel={unstarChannel}
isCreatingBrowseChannel={ />
createChannelMutation.isPending || <MainInsetProvider mainInsetRef={mainInsetRef}>
createForumMutation.isPending <SidebarInset
} ref={mainInsetRef}
onBrowseChannelJoin={handleBrowseChannelJoin} className="isolate min-h-0 min-w-0 overflow-hidden bg-sidebar"
onBrowseChannelCreate={handleBrowseChannelCreate} data-buzz-glass-inset
onBrowseDialogOpenChange={handleBrowseDialogOpenChange} data-buzz-shadow-viewport
onChannelManagementOpenChange={(open) => { style={
setIsChannelManagementOpen(open); chromeCssVarDefaults as React.CSSProperties
if (!open) { }
setManagedChannelId(null); >
<BuzzTheme.ContentSurface>
<Outlet />
</BuzzTheme.ContentSurface>
</SidebarInset>
</MainInsetProvider>
<RelayConnectionOverlay
card={relayConnectionCard}
errorMessage={channelsErrorMessage}
hasCommunityRail={hasCommunityRail}
isHuddleDrawerOpen={isHuddleDrawerOpen}
/>
</div>
)}
<RequestedAgentCreateDialogs />
<AgentManagementDialogs />
<AppShellOverlays
activeChannel={managedChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
isCreatingBrowseChannel={
createChannelMutation.isPending ||
createForumMutation.isPending
} }
}} onBrowseChannelJoin={handleBrowseChannelJoin}
onDeleteActiveChannel={() => { onBrowseChannelCreate={handleBrowseChannelCreate}
setIsChannelManagementOpen(false); onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
setManagedChannelId(null); onChannelManagementOpenChange={(open) => {
void goHome({ replace: true }); setIsChannelManagementOpen(open);
}} if (!open) {
onSelectChannel={(channelId) => { setManagedChannelId(null);
void goChannel(channelId); }
}} }}
/> onDeleteActiveChannel={() => {
<SendFeedbackController setIsChannelManagementOpen(false);
onOpenChange={setIsSendFeedbackOpen} setManagedChannelId(null);
open={isSendFeedbackOpen} void goHome({ replace: true });
/> }}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
/>
<SendFeedbackController
onOpenChange={setIsSendFeedbackOpen}
open={isSendFeedbackOpen}
/>
</AppProfilePanelProvider>
</SidebarProvider> </SidebarProvider>
</div> </div>
<div className="absolute inset-x-0 bottom-0 z-0 h-(--buzz-huddle-drawer-height)"> <div className="absolute inset-x-0 bottom-0 z-0 h-(--buzz-huddle-drawer-height)">
<HuddleBar <AppHuddleBar
className="h-full"
onOpenThread={(channelId, messageId) => { onOpenThread={(channelId, messageId) => {
void goChannel(channelId, { void goChannel(channelId, {
messageId, messageId,

View File

@@ -79,6 +79,18 @@ export function useAppNavigation() {
[commitNavigation], [commitNavigation],
); );
const goProfile = React.useCallback(
(pubkey: string, behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/pulse",
search: { profile: pubkey },
},
behavior,
),
[commitNavigation],
);
const goProjects = React.useCallback( const goProjects = React.useCallback(
(behavior?: NavigationBehavior) => (behavior?: NavigationBehavior) =>
commitNavigation( commitNavigation(
@@ -303,6 +315,7 @@ export function useAppNavigation() {
goProject, goProject,
goProjects, goProjects,
goPulse, goPulse,
goProfile,
goSettings, goSettings,
goWorkflow, goWorkflow,
goWorkflows, goWorkflows,

View File

@@ -13,6 +13,7 @@ import {
ProfileAvatarWithStatus, ProfileAvatarWithStatus,
scaleProfileAvatarStatusGeometry, scaleProfileAvatarStatusGeometry,
} from "@/features/profile/ui/ProfileAvatarWithStatus"; } from "@/features/profile/ui/ProfileAvatarWithStatus";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import type { Channel, PresenceStatus } from "@/shared/api/types"; import type { Channel, PresenceStatus } from "@/shared/api/types";
import { UserAvatar } from "@/shared/ui/UserAvatar"; import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -65,6 +66,7 @@ export function ChannelScreenHeader({
const isGroupDm = const isGroupDm =
activeChannel?.channelType === "dm" && activeChannel?.channelType === "dm" &&
activeDmHeaderParticipants.length > 1; activeDmHeaderParticipants.length > 1;
const activeDmParticipant = activeDmHeaderParticipants[0] ?? null;
const showJoinButton = const showJoinButton =
activeChannel !== null && activeChannel !== null &&
!activeChannel.isMember && !activeChannel.isMember &&
@@ -113,6 +115,25 @@ export function ChannelScreenHeader({
<DmHeaderParticipantStack <DmHeaderParticipantStack
participants={activeDmHeaderParticipants} participants={activeDmHeaderParticipants}
/> />
) : activeDmParticipant ? (
<UserProfilePopover
pubkey={activeDmParticipant.pubkey}
triggerAriaLabel={`Open profile for ${activeChannelTitle}`}
triggerElement="span"
>
<ProfileAvatarWithStatus
avatarClassName="text-xs"
avatarUrl={activeDmAvatarUrl}
className="mr-1.5 h-8 w-8"
geometry={DM_HEADER_AVATAR_STATUS_GEOMETRY}
iconClassName="h-4 w-4"
label={activeChannelTitle}
size={DM_HEADER_AVATAR_SIZE}
status={activeDmPresenceStatus ?? "offline"}
statusTestId="chat-presence-badge"
testId="chat-header-dm-avatar"
/>
</UserProfilePopover>
) : ( ) : (
<ProfileAvatarWithStatus <ProfileAvatarWithStatus
avatarClassName="text-xs" avatarClassName="text-xs"
@@ -152,31 +173,36 @@ function DmHeaderParticipantStack({
return ( return (
<div <div
aria-hidden="true"
className="mr-1.5 flex shrink-0 items-center" className="mr-1.5 flex shrink-0 items-center"
data-testid="chat-header-dm-avatar-stack" data-testid="chat-header-dm-avatar-stack"
> >
{visibleParticipants.map((participant, index) => ( {visibleParticipants.map((participant, index) => (
<div <UserProfilePopover
className={index > 0 ? "-ml-2" : ""}
data-testid="chat-header-dm-avatar-stack-participant"
key={participant.pubkey} key={participant.pubkey}
style={{ pubkey={participant.pubkey}
zIndex: index + 1, triggerAriaLabel={`Open profile for ${participant.displayName}`}
...(index < stackItemCount - 1 && { triggerElement="span"
mask: "radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
"radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
}),
}}
> >
<UserAvatar <span
avatarUrl={participant.avatarUrl} className={index > 0 ? "-ml-2" : ""}
className="h-8 w-8 text-xs" data-testid="chat-header-dm-avatar-stack-participant"
displayName={participant.displayName} style={{
size="sm" zIndex: index + 1,
/> ...(index < stackItemCount - 1 && {
</div> mask: "radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
"radial-gradient(circle 18px at calc(100% + 4px) 50%, transparent 99%, #fff 100%)",
}),
}}
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-8 w-8 text-xs"
displayName={participant.displayName}
size="sm"
/>
</span>
</UserProfilePopover>
))} ))}
{hiddenCount > 0 ? ( {hiddenCount > 0 ? (
<div <div

View File

@@ -11,6 +11,7 @@ import {
} from "@/features/community-members/hooks"; } from "@/features/community-members/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
import type { import type {
RelayMember, RelayMember,
@@ -129,11 +130,17 @@ function RelayMemberRow({
className="group/member flex min-h-14 items-center gap-3 px-1 py-2.5" className="group/member flex min-h-14 items-center gap-3 px-1 py-2.5"
data-testid={`relay-member-row-${member.pubkey}`} data-testid={`relay-member-row-${member.pubkey}`}
> >
<ProfileAvatar <UserProfilePopover
avatarUrl={profile?.avatarUrl ?? null} pubkey={member.pubkey}
className="h-9 w-9 text-xs shadow-none" triggerAriaLabel={`Open profile for ${displayName}`}
label={displayName} triggerElement="span"
/> >
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="h-9 w-9 text-xs shadow-none"
label={displayName}
/>
</UserProfilePopover>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5 text-sm font-medium"> <div className="flex min-w-0 items-center gap-1.5 text-sm font-medium">
<HoverMemberIdentity <HoverMemberIdentity

View File

@@ -3,6 +3,7 @@ import * as React from "react";
import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn"; import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
@@ -86,19 +87,25 @@ export function HuddleParticipantsControl({
className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5" className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5"
key={pubkey} key={pubkey}
> >
{profile?.displayName || profile?.avatarUrl ? ( <UserProfilePopover
<ProfileAvatar pubkey={pubkey}
avatarUrl={profile.avatarUrl ?? null} triggerAriaLabel={`Open profile for ${displayName}`}
label={profile.displayName || pubkey.slice(0, 6)} triggerElement="span"
className={cn( >
"h-8 w-8 rounded-full text-2xs", {profile?.displayName || profile?.avatarUrl ? (
isActive && <ProfileAvatar
"ring-2 ring-green-500 ring-offset-1 ring-offset-background", avatarUrl={profile.avatarUrl ?? null}
)} label={profile.displayName || truncatePubkey(pubkey)}
/> className={cn(
) : ( "h-8 w-8 rounded-full text-2xs",
<HexAvatar pubkey={pubkey} isActive={isActive} size="lg" /> isActive &&
)} "ring-2 ring-green-500 ring-offset-1 ring-offset-background",
)}
/>
) : (
<HexAvatar pubkey={pubkey} isActive={isActive} size="lg" />
)}
</UserProfilePopover>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium"> <div className="truncate text-sm font-medium">

View File

@@ -1,4 +1,5 @@
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay"; import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { UserAvatar } from "@/shared/ui/UserAvatar"; import { UserAvatar } from "@/shared/ui/UserAvatar";
export type DirectMessageIntroParticipant = { export type DirectMessageIntroParticipant = {
@@ -18,31 +19,36 @@ export function DirectMessageIntroAvatarStack({
return ( return (
<div <div
aria-hidden="true"
className="flex shrink-0 items-center" className="flex shrink-0 items-center"
data-testid="message-dm-intro-avatar-stack" data-testid="message-dm-intro-avatar-stack"
> >
{visibleParticipants.map((participant, index) => ( {visibleParticipants.map((participant, index) => (
<div <UserProfilePopover
className={index > 0 ? "-ml-5" : ""}
data-testid="message-dm-intro-avatar-stack-participant"
key={participant.pubkey} key={participant.pubkey}
style={{ pubkey={participant.pubkey}
zIndex: index + 1, triggerAriaLabel={`Open profile for ${participant.displayName}`}
...(index < stackItemCount - 1 && { triggerElement="span"
mask: "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
"radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
}),
}}
> >
<UserAvatar <span
avatarUrl={participant.avatarUrl} className={index > 0 ? "-ml-5" : ""}
className="h-[60px] w-[60px] text-base" data-testid="message-dm-intro-avatar-stack-participant"
displayName={participant.displayName} style={{
size="md" zIndex: index + 1,
/> ...(index < stackItemCount - 1 && {
</div> mask: "radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
WebkitMask:
"radial-gradient(circle 34px at calc(100% + 10px) 50%, transparent 99%, #fff 100%)",
}),
}}
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-[60px] w-[60px] text-base"
displayName={participant.displayName}
size="md"
/>
</span>
</UserProfilePopover>
))} ))}
{hiddenCount > 0 ? ( {hiddenCount > 0 ? (
<div <div

View File

@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations"; import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge"; import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings"; import { openSettings } from "../helpers/settings";
const OUTDIR = "test-results/invites-settings"; const OUTDIR = "test-results/invites-settings";
@@ -36,6 +36,15 @@ test.beforeEach(async ({ page }, testInfo) => {
await openSettings(page, "community-members"); await openSettings(page, "community-members");
}); });
test("opens a profile from a community member avatar", async ({ page }) => {
await page.getByRole("button", { name: "Open profile for alice" }).click();
await expect(page).toHaveURL(
new RegExp(`/pulse\\?profile=${TEST_IDENTITIES.alice.pubkey}$`),
);
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
});
test("capture: consolidated invites settings", async ({ page }) => { test("capture: consolidated invites settings", async ({ page }) => {
const panel = page.getByTestId("settings-panel-community-members"); const panel = page.getByTestId("settings-panel-community-members");