Merge main (post-#3259 squash) into TTS playback speed

Retack after #3259 landed on main as squash c104eecfb. The squash
severed ancestry from this branch's previous merge parent (3259 head
0ef85c21b), manufacturing phantom conflicts in exactly the two known
3259x3194 interaction files. Main's blobs for both files are
byte-identical to 3259's head blobs, so the resolution re-applies the
already-blessed union from e8b00d43a unchanged (verified by blob hash):

- huddle/tts_settings.rs: 099e6ad34 (3259 pocket_voice_reference +
  3194 playback_speed plumbing)
- testing/e2eBridge.ts: a58b76092 (union arms, 245 unique labels)

Also brings in #3751 (avatars fix), previously verified zero overlap
with this PR's surface.

Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
This commit is contained in:
npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d
2026-07-31 11:20:27 -04:00
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");