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 14:24:51 +00:00
committed by GitHub
co-authored by Wes Carl
parent 61ba9dfaa0
commit 39ce3dfc3c
10 changed files with 388 additions and 275 deletions
+2 -4
View File
@@ -18,12 +18,10 @@ const rules = [
// Non-display uses: array windows over pubkey lists, color/initials
// derivation where the value is never presented as an identity.
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,
// clearly decorative (paired with a full truncatePubkey aria-label).
"src/features/huddle/components/ParticipantList.tsx:143",
"src/features/huddle/components/ParticipantList.tsx:144",
"src/features/huddle/components/ParticipantList.tsx:150",
"src/features/huddle/components/ParticipantList.tsx:151",
// clientId (not a pubkey) sliced in a debug log next to the real thing.
"src/features/channels/readState/readStateManager.ts:338",
// Array windows (first N pubkeys), not string truncation.
+25
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>
);
}
@@ -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>
);
}
+214 -214
View File
@@ -63,7 +63,8 @@ import {
type SettingsSection,
isSettingsSection,
} 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 { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider";
import { useReminderNotifications } from "@/features/reminders/useReminderNotifications";
@@ -97,7 +98,7 @@ import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay";
import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu";
import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider";
const LazySettingsScreen = React.lazy(async () => {
const module = await import("@/features/settings/ui/SettingsScreen");
return { default: module.SettingsScreen };
@@ -160,7 +161,6 @@ export function AppShell() {
? locationSearchSection
: DEFAULT_SETTINGS_SECTION;
const startupReady = useDeferredStartup();
const identityQuery = useIdentityQuery();
const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes(
identityQuery.data?.pubkey,
@@ -303,7 +303,6 @@ export function AppShell() {
? (channels.find((channel) => channel.id === targetChannelId) ?? null)
: null;
}, [channels, managedChannelId, selectedChannelId]);
const {
handleChannelNotification,
handleDmNotification,
@@ -518,7 +517,6 @@ export function AppShell() {
},
[applyAgents, applyCanvas, createChannelMutation, goChannel],
);
const handleCreateForum = React.useCallback(
async ({
description,
@@ -586,7 +584,6 @@ export function AppShell() {
},
[goHome, hideDmMutation, selectedChannelId],
);
const handleOpenSettings = React.useCallback(
(section: SettingsSection = DEFAULT_SETTINGS_SECTION) => {
setIsChannelManagementOpen(false);
@@ -594,12 +591,10 @@ export function AppShell() {
},
[goSettings],
);
const handleCloseSettings = React.useCallback(
() => closeSettings(),
[closeSettings],
);
// Section switches rewrite the settings entry rather than stacking one
// history entry per section, so back always exits settings in one step.
const handleSettingsSectionChange = React.useCallback(
@@ -620,11 +615,8 @@ export function AppShell() {
unreadChannelIds,
unreadChannelNotificationCount,
});
// Dispatch `buzz://message` deep links into the router.
useMessageDeepLinks();
const handleOpenNewDm = React.useCallback(
() => void goNewMessage(),
[goNewMessage],
);
const handleOpenCreateChannel = React.useCallback(
() => setIsCreateChannelOpen(true),
[],
@@ -657,7 +649,7 @@ export function AppShell() {
if (key === "k" && event.shiftKey) {
event.preventDefault();
handleOpenNewDm();
void goNewMessage();
return;
}
@@ -686,9 +678,9 @@ export function AppShell() {
};
}, [
handleOpenBrowseChannels,
handleOpenNewDm,
handleOpenCreateChannel,
handleOpenSearch,
goNewMessage,
goHome,
settingsOpen,
]);
@@ -770,216 +762,224 @@ export function AppShell() {
/>
) : null}
<SidebarProvider className="min-h-0 flex-1 flex-col overflow-hidden">
{!settingsOpen ? (
<AppTopChrome
canGoBack={canGoBack}
canGoForward={canGoForward}
hasCommunityRail={hasCommunityRail}
onGoBack={goBack}
onGoForward={goForward}
/>
) : null}
{settingsOpen ? (
<div className="flex min-h-0 flex-1 overflow-hidden">
<React.Suspense fallback={null}>
<LazySettingsScreen
<AppProfilePanelProvider>
{!settingsOpen ? (
<AppTopChrome
canGoBack={canGoBack}
canGoForward={canGoForward}
hasCommunityRail={hasCommunityRail}
onGoBack={goBack}
onGoForward={goForward}
/>
) : null}
{settingsOpen ? (
<div className="flex min-h-0 flex-1 overflow-hidden">
<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}
errorMessage={channelsErrorMessage}
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}
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,
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,
});
await goChannel(directMessage.id);
}}
onSelectAgents={() => void goAgents()}
onSelectChannel={(channelId) =>
void goChannel(channelId)
}
onOpenSearchResult={handleOpenSearchResult}
searchChannels={channels}
searchFocusRequest={searchFocusRequest}
onSelectHome={() => void goHome()}
onSelectProjects={() => void goProjects()}
onSelectPulse={() => void goPulse()}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => void goWorkflows()}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
onSetUserStatus={(text, emoji) =>
setUserStatusMutation.mutate({ text, emoji })
}
onClearUserStatus={() =>
setUserStatusMutation.mutate({
text: "",
emoji: "",
})
}
profile={profileQuery.data}
selfUserStatus={
deferredPubkey
? (selfStatusQuery.data?.[
deferredPubkey.toLowerCase()
] ?? undefined)
: undefined
}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
unreadChannelCounts={unreadChannelCounts}
mutedChannelIds={mutedChannelIds}
onMuteChannel={muteChannel}
onUnmuteChannel={unmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={starChannel}
onUnstarChannel={unstarChannel}
/>
<MainInsetProvider mainInsetRef={mainInsetRef}>
<SidebarInset
ref={mainInsetRef}
className="isolate min-h-0 min-w-0 overflow-hidden bg-sidebar"
data-buzz-glass-inset
data-buzz-shadow-viewport
style={chromeCssVarDefaults as React.CSSProperties}
>
<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}
onBrowseChannelCreate={handleBrowseChannelCreate}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={(open) => {
setIsChannelManagementOpen(open);
if (!open) {
setManagedChannelId(null);
handleSwitchCommunity(id);
}}
onAddCommunityOpenChange={
addCommunityDialog.onOpenChange
}
onNewMessage={goNewMessage}
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);
}}
onSelectAgents={() => void goAgents()}
onSelectChannel={(channelId) =>
void goChannel(channelId)
}
onOpenSearchResult={handleOpenSearchResult}
searchChannels={channels}
searchFocusRequest={searchFocusRequest}
onSelectHome={() => void goHome()}
onSelectProjects={() => void goProjects()}
onSelectPulse={() => void goPulse()}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => void goWorkflows()}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
onSetUserStatus={(text, emoji) =>
setUserStatusMutation.mutate({ text, emoji })
}
onClearUserStatus={() =>
setUserStatusMutation.mutate({
text: "",
emoji: "",
})
}
profile={profileQuery.data}
selfUserStatus={
deferredPubkey
? (selfStatusQuery.data?.[
deferredPubkey.toLowerCase()
] ?? undefined)
: undefined
}
selectedChannelId={selectedChannelId}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
unreadChannelCounts={unreadChannelCounts}
mutedChannelIds={mutedChannelIds}
onMuteChannel={muteChannel}
onUnmuteChannel={unmuteChannel}
starredChannelIds={starredChannelIds}
onStarChannel={starChannel}
onUnstarChannel={unstarChannel}
/>
<MainInsetProvider mainInsetRef={mainInsetRef}>
<SidebarInset
ref={mainInsetRef}
className="isolate min-h-0 min-w-0 overflow-hidden bg-sidebar"
data-buzz-glass-inset
data-buzz-shadow-viewport
style={
chromeCssVarDefaults as React.CSSProperties
}
>
<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
}
}}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
setManagedChannelId(null);
void goHome({ replace: true });
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
/>
<SendFeedbackController
onOpenChange={setIsSendFeedbackOpen}
open={isSendFeedbackOpen}
/>
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseChannelCreate={handleBrowseChannelCreate}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={(open) => {
setIsChannelManagementOpen(open);
if (!open) {
setManagedChannelId(null);
}
}}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
setManagedChannelId(null);
void goHome({ replace: true });
}}
onSelectChannel={(channelId) => {
void goChannel(channelId);
}}
/>
<SendFeedbackController
onOpenChange={setIsSendFeedbackOpen}
open={isSendFeedbackOpen}
/>
</AppProfilePanelProvider>
</SidebarProvider>
</div>
<div className="absolute inset-x-0 bottom-0 z-0 h-(--buzz-huddle-drawer-height)">
<HuddleBar
className="h-full"
<AppHuddleBar
onOpenThread={(channelId, messageId) => {
void goChannel(channelId, {
messageId,
@@ -79,6 +79,18 @@ export function useAppNavigation() {
[commitNavigation],
);
const goProfile = React.useCallback(
(pubkey: string, behavior?: NavigationBehavior) =>
commitNavigation(
{
to: "/pulse",
search: { profile: pubkey },
},
behavior,
),
[commitNavigation],
);
const goProjects = React.useCallback(
(behavior?: NavigationBehavior) =>
commitNavigation(
@@ -303,6 +315,7 @@ export function useAppNavigation() {
goProject,
goProjects,
goPulse,
goProfile,
goSettings,
goWorkflow,
goWorkflows,
@@ -13,6 +13,7 @@ import {
ProfileAvatarWithStatus,
scaleProfileAvatarStatusGeometry,
} from "@/features/profile/ui/ProfileAvatarWithStatus";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { Button } from "@/shared/ui/button";
import type { Channel, PresenceStatus } from "@/shared/api/types";
import { UserAvatar } from "@/shared/ui/UserAvatar";
@@ -65,6 +66,7 @@ export function ChannelScreenHeader({
const isGroupDm =
activeChannel?.channelType === "dm" &&
activeDmHeaderParticipants.length > 1;
const activeDmParticipant = activeDmHeaderParticipants[0] ?? null;
const showJoinButton =
activeChannel !== null &&
!activeChannel.isMember &&
@@ -113,6 +115,25 @@ export function ChannelScreenHeader({
<DmHeaderParticipantStack
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
avatarClassName="text-xs"
@@ -152,31 +173,36 @@ function DmHeaderParticipantStack({
return (
<div
aria-hidden="true"
className="mr-1.5 flex shrink-0 items-center"
data-testid="chat-header-dm-avatar-stack"
>
{visibleParticipants.map((participant, index) => (
<div
className={index > 0 ? "-ml-2" : ""}
data-testid="chat-header-dm-avatar-stack-participant"
<UserProfilePopover
key={participant.pubkey}
style={{
zIndex: index + 1,
...(index < stackItemCount - 1 && {
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%)",
}),
}}
pubkey={participant.pubkey}
triggerAriaLabel={`Open profile for ${participant.displayName}`}
triggerElement="span"
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-8 w-8 text-xs"
displayName={participant.displayName}
size="sm"
/>
</div>
<span
className={index > 0 ? "-ml-2" : ""}
data-testid="chat-header-dm-avatar-stack-participant"
style={{
zIndex: index + 1,
...(index < stackItemCount - 1 && {
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 ? (
<div
@@ -11,6 +11,7 @@ import {
} from "@/features/community-members/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
import type {
RelayMember,
@@ -129,11 +130,17 @@ function RelayMemberRow({
className="group/member flex min-h-14 items-center gap-3 px-1 py-2.5"
data-testid={`relay-member-row-${member.pubkey}`}
>
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="h-9 w-9 text-xs shadow-none"
label={displayName}
/>
<UserProfilePopover
pubkey={member.pubkey}
triggerAriaLabel={`Open profile for ${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="flex min-w-0 items-center gap-1.5 text-sm font-medium">
<HoverMemberIdentity
@@ -3,6 +3,7 @@ import * as React from "react";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
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"
key={pubkey}
>
{profile?.displayName || profile?.avatarUrl ? (
<ProfileAvatar
avatarUrl={profile.avatarUrl ?? null}
label={profile.displayName || pubkey.slice(0, 6)}
className={cn(
"h-8 w-8 rounded-full text-2xs",
isActive &&
"ring-2 ring-green-500 ring-offset-1 ring-offset-background",
)}
/>
) : (
<HexAvatar pubkey={pubkey} isActive={isActive} size="lg" />
)}
<UserProfilePopover
pubkey={pubkey}
triggerAriaLabel={`Open profile for ${displayName}`}
triggerElement="span"
>
{profile?.displayName || profile?.avatarUrl ? (
<ProfileAvatar
avatarUrl={profile.avatarUrl ?? null}
label={profile.displayName || truncatePubkey(pubkey)}
className={cn(
"h-8 w-8 rounded-full text-2xs",
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="truncate text-sm font-medium">
@@ -1,4 +1,5 @@
import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { UserAvatar } from "@/shared/ui/UserAvatar";
export type DirectMessageIntroParticipant = {
@@ -18,31 +19,36 @@ export function DirectMessageIntroAvatarStack({
return (
<div
aria-hidden="true"
className="flex shrink-0 items-center"
data-testid="message-dm-intro-avatar-stack"
>
{visibleParticipants.map((participant, index) => (
<div
className={index > 0 ? "-ml-5" : ""}
data-testid="message-dm-intro-avatar-stack-participant"
<UserProfilePopover
key={participant.pubkey}
style={{
zIndex: index + 1,
...(index < stackItemCount - 1 && {
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%)",
}),
}}
pubkey={participant.pubkey}
triggerAriaLabel={`Open profile for ${participant.displayName}`}
triggerElement="span"
>
<UserAvatar
avatarUrl={participant.avatarUrl}
className="h-[60px] w-[60px] text-base"
displayName={participant.displayName}
size="md"
/>
</div>
<span
className={index > 0 ? "-ml-5" : ""}
data-testid="message-dm-intro-avatar-stack-participant"
style={{
zIndex: index + 1,
...(index < stackItemCount - 1 && {
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 ? (
<div
@@ -1,7 +1,7 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
const OUTDIR = "test-results/invites-settings";
@@ -36,6 +36,15 @@ test.beforeEach(async ({ page }, testInfo) => {
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 }) => {
const panel = page.getByTestId("settings-panel-community-members");