mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish desktop profile menu interactions (#836)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
Cursor
Wes
Brain
parent
0d9b8148f8
commit
a13691b620
@@ -12,6 +12,7 @@ import { AppTopChrome } from "@/app/AppTopChrome";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
|
||||
import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts";
|
||||
import { useSettingsShortcuts } from "@/app/useSettingsShortcuts";
|
||||
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
|
||||
import {
|
||||
channelsQueryKey,
|
||||
@@ -170,6 +171,9 @@ export function AppShell() {
|
||||
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
|
||||
DEFAULT_SETTINGS_SECTION,
|
||||
);
|
||||
const [settingsMode, setSettingsMode] = React.useState<
|
||||
"profile" | "preferences"
|
||||
>("preferences");
|
||||
|
||||
const [isChannelManagementOpen, setIsChannelManagementOpen] =
|
||||
React.useState(false);
|
||||
@@ -407,8 +411,9 @@ export function AppShell() {
|
||||
);
|
||||
|
||||
const handleOpenSettings = React.useCallback(
|
||||
(section: SettingsSection = DEFAULT_SETTINGS_SECTION) => {
|
||||
(section: SettingsSection = "appearance") => {
|
||||
setIsChannelManagementOpen(false);
|
||||
setSettingsMode(section === "profile" ? "profile" : "preferences");
|
||||
setSettingsSection(section);
|
||||
setSettingsOpen(true);
|
||||
},
|
||||
@@ -601,32 +606,11 @@ export function AppShell() {
|
||||
settingsOpen,
|
||||
]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const isSettingsShortcut =
|
||||
(event.key === "," || event.code === "Comma") &&
|
||||
hasPrimaryShortcutModifier(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey;
|
||||
|
||||
if (!isSettingsShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
if (settingsOpen) {
|
||||
handleCloseSettings();
|
||||
return;
|
||||
}
|
||||
|
||||
handleOpenSettings();
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleCloseSettings, handleOpenSettings, settingsOpen]);
|
||||
useSettingsShortcuts({
|
||||
onClose: handleCloseSettings,
|
||||
onOpenSettings: handleOpenSettings,
|
||||
open: settingsOpen,
|
||||
});
|
||||
|
||||
useMarkAsReadShortcuts({
|
||||
activeChannelId: activeChannel?.id ?? null,
|
||||
@@ -862,6 +846,7 @@ export function AppShell() {
|
||||
notificationSettings={notificationSettings.settings}
|
||||
onClose={handleCloseSettings}
|
||||
onSectionChange={setSettingsSection}
|
||||
mode={settingsMode}
|
||||
onSetDesktopNotificationsEnabled={
|
||||
notificationSettings.setDesktopEnabled
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
|
||||
|
||||
type UseSettingsShortcutsOptions = {
|
||||
onClose: () => void;
|
||||
onOpenSettings: () => void;
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
export function useSettingsShortcuts({
|
||||
onClose,
|
||||
onOpenSettings,
|
||||
open,
|
||||
}: UseSettingsShortcutsOptions) {
|
||||
React.useLayoutEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const isSettingsShortcut =
|
||||
hasPrimaryShortcutModifier(event) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
(event.key === "," || event.code === "Comma");
|
||||
|
||||
if (!isSettingsShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
if (open) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenSettings();
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose, onOpenSettings, open]);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { ChevronRight, MessageSquare, Settings } from "lucide-react";
|
||||
import { ChevronRight, Smile } from "lucide-react";
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
@@ -8,6 +8,7 @@ import { getPresenceLabel } from "@/features/presence/lib/presence";
|
||||
import { SetStatusDialog } from "@/features/user-status/ui/SetStatusDialog";
|
||||
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
|
||||
import type { PresenceStatus } from "@/shared/api/types";
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -26,8 +27,16 @@ interface ProfilePopoverProps {
|
||||
onSetStatus: (status: PresenceStatus) => void;
|
||||
onSetUserStatus: (text: string, emoji: string) => void;
|
||||
onClearUserStatus: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenSettings: (section?: "profile" | "appearance") => void;
|
||||
children: React.ReactNode;
|
||||
// Optional outer container whose clicks should NOT close the popover.
|
||||
// Used when auxiliary triggers (avatar, status text) live alongside the
|
||||
// primary PopoverTrigger and toggle the popover via controlled `open`.
|
||||
triggerContainerRef?: React.RefObject<HTMLElement | null>;
|
||||
// Optional slot rendered between the identity block and the menu items.
|
||||
// Used by the sidebar to surface the workspace/relay selector inside the
|
||||
// profile menu instead of on the sidebar card.
|
||||
workspaceSwitcherSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -35,7 +44,7 @@ interface ProfilePopoverProps {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MENU_ITEM_CLASS =
|
||||
"flex w-full items-center gap-3 px-3 py-2.5 rounded-lg text-left hover:bg-accent cursor-pointer transition-colors";
|
||||
"flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-popover-foreground hover:bg-accent focus-visible:bg-accent cursor-pointer transition-colors outline-hidden focus:outline-none focus-visible:outline-none";
|
||||
|
||||
const ALL_STATUSES: PresenceStatus[] = ["online", "away", "offline"];
|
||||
|
||||
@@ -58,14 +67,14 @@ export function ProfilePopover({
|
||||
onClearUserStatus,
|
||||
onOpenSettings,
|
||||
children,
|
||||
triggerContainerRef,
|
||||
workspaceSwitcherSlot,
|
||||
}: ProfilePopoverProps) {
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
|
||||
const [statusDialogOpen, setStatusDialogOpen] = React.useState(false);
|
||||
const [presenceMenuOpen, setPresenceMenuOpen] = React.useState(false);
|
||||
const presenceHoverTimer = React.useRef<number | null>(null);
|
||||
const hasUserStatus = Boolean(userStatusText || userStatusEmoji);
|
||||
const preferencesShortcutLabel = isMacPlatform() ? "⌘," : "Ctrl+,";
|
||||
|
||||
function clearPresenceHoverTimer() {
|
||||
if (presenceHoverTimer.current !== null) {
|
||||
@@ -117,23 +126,32 @@ export function ProfilePopover({
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
sideOffset={-32}
|
||||
className="w-[280px] rounded-xl border border-border bg-popover p-0 shadow-lg"
|
||||
data-testid="profile-popover"
|
||||
onInteractOutside={(event) => {
|
||||
const target = event.target as Node | null;
|
||||
if (target && triggerContainerRef?.current?.contains(target)) {
|
||||
// Click on an auxiliary trigger inside the same card
|
||||
// (e.g. avatar or status) — let that trigger toggle the
|
||||
// controlled state instead of auto-closing here.
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div aria-label="Profile menu" role="menu">
|
||||
{/* ── Identity block ─────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="flex items-center gap-2 px-4 pt-3 pb-2">
|
||||
<div className="relative shrink-0">
|
||||
<ProfileAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
className="h-10 w-10 rounded-2xl text-sm"
|
||||
iconClassName="h-5 w-5"
|
||||
className="h-8 w-8 rounded-xl text-xs"
|
||||
iconClassName="h-4 w-4"
|
||||
label={displayName}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-popover-foreground">
|
||||
<p className="truncate text-sm font-semibold leading-tight text-popover-foreground">
|
||||
{displayName}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
@@ -164,12 +182,10 @@ export function ProfilePopover({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-1 h-px border-0 bg-border" />
|
||||
|
||||
{/* ── User status ──────────────────────────────────── */}
|
||||
<div className="px-1.5 py-1">
|
||||
{/* ── Status input (Slack-style) ──────────────────────── */}
|
||||
<div className="px-3 pt-0 pb-1">
|
||||
<button
|
||||
className={MENU_ITEM_CLASS}
|
||||
className="flex w-full items-center gap-2 rounded-lg border border-input bg-popover px-3 py-2 text-left text-sm outline-hidden transition-colors hover:bg-accent focus:outline-none focus-visible:bg-accent focus-visible:outline-none"
|
||||
data-testid="profile-popover-set-status"
|
||||
onClick={() => {
|
||||
closePopover();
|
||||
@@ -180,102 +196,120 @@ export function ProfilePopover({
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-popover-foreground">
|
||||
{hasUserStatus ? "Update status" : "Set a status"}
|
||||
</span>
|
||||
<Smile className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
{hasUserStatus ? (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1 truncate text-popover-foreground">
|
||||
{userStatusEmoji ? (
|
||||
<span className="shrink-0">{userStatusEmoji}</span>
|
||||
) : null}
|
||||
<span className="truncate">{userStatusText}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-1 truncate text-muted-foreground">
|
||||
Update your status
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr className="my-1 h-px border-0 bg-border" />
|
||||
|
||||
{/* ── Presence status options ───────────────────────── */}
|
||||
<div className="px-1.5 py-1">
|
||||
<Popover
|
||||
onOpenChange={setPresenceMenuOpen}
|
||||
open={presenceMenuOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-expanded={presenceMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid="profile-popover-presence-trigger"
|
||||
disabled={isStatusPending}
|
||||
onClick={() => {
|
||||
clearPresenceHoverTimer();
|
||||
setPresenceMenuOpen((prev) => !prev);
|
||||
}}
|
||||
onMouseEnter={() => schedulePresenceMenu(true)}
|
||||
onMouseLeave={() => schedulePresenceMenu(false)}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<PresenceDot
|
||||
className="h-2.5 w-2.5"
|
||||
status={currentStatus}
|
||||
/>
|
||||
<span className="flex-1 text-sm text-popover-foreground">
|
||||
{getPresenceLabel(currentStatus)}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-44 rounded-xl border border-border bg-popover p-1.5 shadow-lg"
|
||||
{/* ── Presence ────────────────────────────────────────── */}
|
||||
<Popover onOpenChange={setPresenceMenuOpen} open={presenceMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-expanded={presenceMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid="profile-popover-presence-trigger"
|
||||
disabled={isStatusPending}
|
||||
onClick={() => {
|
||||
clearPresenceHoverTimer();
|
||||
setPresenceMenuOpen((prev) => !prev);
|
||||
}}
|
||||
onMouseEnter={() => schedulePresenceMenu(true)}
|
||||
onMouseLeave={() => schedulePresenceMenu(false)}
|
||||
side="right"
|
||||
sideOffset={4}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<div aria-label="Presence status" role="menu">
|
||||
{ALL_STATUSES.map((status) => (
|
||||
<button
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid={`profile-popover-status-${status}`}
|
||||
disabled={isStatusPending}
|
||||
key={status}
|
||||
onClick={() => handlePresenceSelect(status)}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<PresenceDot className="h-2.5 w-2.5" status={status} />
|
||||
<span className="text-sm text-popover-foreground">
|
||||
{getPresenceLabel(status)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<PresenceDot className="h-2.5 w-2.5" status={currentStatus} />
|
||||
<span className="flex-1">
|
||||
{getPresenceLabel(currentStatus)}
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-44 rounded-xl border border-border bg-popover p-1 shadow-lg"
|
||||
onMouseEnter={() => schedulePresenceMenu(true)}
|
||||
onMouseLeave={() => schedulePresenceMenu(false)}
|
||||
side="right"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div aria-label="Presence status" role="menu">
|
||||
{ALL_STATUSES.map((status) => (
|
||||
<button
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid={`profile-popover-status-${status}`}
|
||||
disabled={isStatusPending}
|
||||
key={status}
|
||||
onClick={() => handlePresenceSelect(status)}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<PresenceDot className="h-2.5 w-2.5" status={status} />
|
||||
<span>{getPresenceLabel(status)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<hr className="my-1 h-px border-0 bg-border" />
|
||||
|
||||
{/* ── Settings ───────────────────────────────────────── */}
|
||||
<div className="px-1.5 py-1">
|
||||
<button
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid="profile-popover-settings"
|
||||
onClick={() => {
|
||||
closePopover();
|
||||
window.requestAnimationFrame(() => {
|
||||
onOpenSettings();
|
||||
});
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1 text-sm text-popover-foreground">
|
||||
Settings
|
||||
</span>
|
||||
<kbd className="text-xs text-muted-foreground">
|
||||
{isMac ? "⌘," : "Ctrl+,"}
|
||||
</kbd>
|
||||
</button>
|
||||
</div>
|
||||
{/* ── Profile / preferences ──────────────────────────── */}
|
||||
<button
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid="profile-popover-profile"
|
||||
onClick={() => {
|
||||
closePopover();
|
||||
window.requestAnimationFrame(() => {
|
||||
onOpenSettings("profile");
|
||||
});
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<span className="flex-1">Profile</span>
|
||||
</button>
|
||||
<button
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid="profile-popover-settings"
|
||||
onClick={() => {
|
||||
closePopover();
|
||||
window.requestAnimationFrame(() => {
|
||||
onOpenSettings("appearance");
|
||||
});
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<span className="flex-1">Preferences</span>
|
||||
<kbd className="text-xs text-muted-foreground">
|
||||
{preferencesShortcutLabel}
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
{workspaceSwitcherSlot ? (
|
||||
<>
|
||||
<hr className="my-1 h-px border-0 bg-border" />
|
||||
{/* ── Workspace / relay selector ─────────────────── */}
|
||||
<div data-testid="profile-popover-workspace">
|
||||
{workspaceSwitcherSlot}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="h-1" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -216,19 +216,16 @@ function PairingDialog({
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Pairing code
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 break-all rounded-lg border border-border bg-muted/50 px-3 py-2 text-xs">
|
||||
{qrUri}
|
||||
</code>
|
||||
<Button
|
||||
data-testid="copy-pairing-code"
|
||||
onClick={handleCopy}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
className="flex w-full min-w-0 items-center gap-2 break-all rounded-lg border border-border bg-muted/50 px-3 py-2 text-left text-xs transition-colors hover:bg-muted/70"
|
||||
data-testid="copy-pairing-code"
|
||||
onClick={handleCopy}
|
||||
title="Copy pairing code"
|
||||
type="button"
|
||||
>
|
||||
<code className="min-w-0 flex-1 break-all">{qrUri}</code>
|
||||
<Copy className="h-3.5 w-3.5 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { AtSign, Check, UserRound } from "lucide-react";
|
||||
import { AtSign, Check, Copy, UserRound } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
useProfileQuery,
|
||||
useUpdateProfileMutation,
|
||||
} from "@/features/profile/hooks";
|
||||
import { AvatarUpload } from "@/features/profile/ui/AvatarUpload";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
@@ -42,20 +42,41 @@ function ReadOnlyField({
|
||||
label,
|
||||
value,
|
||||
testId,
|
||||
copyValue,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
testId: string;
|
||||
copyValue?: string;
|
||||
}) {
|
||||
const boxClassName =
|
||||
"flex min-w-0 items-center gap-2 rounded-xl border border-border/80 bg-muted/25 px-3 py-2 text-sm text-muted-foreground";
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
<div
|
||||
className="min-w-0 break-all whitespace-normal rounded-xl border border-border/80 bg-muted/25 px-3 py-2 text-sm text-muted-foreground"
|
||||
data-testid={testId}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{copyValue ? (
|
||||
<button
|
||||
aria-label={`Copy ${label}`}
|
||||
className={`${boxClassName} w-full text-left transition-colors hover:bg-muted/50`}
|
||||
data-testid={`copy-${testId}`}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(copyValue);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
title={`Copy ${label}`}
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 flex-1 break-all" data-testid={testId}>
|
||||
{value}
|
||||
</span>
|
||||
<Copy className="h-3.5 w-3.5 shrink-0" />
|
||||
</button>
|
||||
) : (
|
||||
<div className={`${boxClassName} break-all`} data-testid={testId}>
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,26 +138,7 @@ export function ProfileSettingsCard({
|
||||
|
||||
return (
|
||||
<section className="min-w-0" data-testid="settings-profile">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<ProfileAvatar
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
className="h-16 w-16 rounded-3xl text-lg"
|
||||
iconClassName="h-6 w-6"
|
||||
label={resolvedName}
|
||||
/>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div>
|
||||
<h2 className="break-words text-base font-semibold tracking-tight">
|
||||
{resolvedName}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage how your identity appears across Sprout.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-6">
|
||||
<div className="space-y-6">
|
||||
{profileQuery.error instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{profileQuery.error.message}
|
||||
@@ -156,86 +158,66 @@ export function ProfileSettingsCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Section
|
||||
description="These values are stored on the relay for your current identity."
|
||||
title="Profile"
|
||||
<form
|
||||
className="min-w-0 space-y-4"
|
||||
id="profile-settings-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!canSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
void updateProfileMutation.mutateAsync(updatePayload);
|
||||
}}
|
||||
>
|
||||
<form
|
||||
className="min-w-0 space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!canSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
void updateProfileMutation.mutateAsync(updatePayload);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="profile-display-name"
|
||||
>
|
||||
Display name
|
||||
</label>
|
||||
<div className="relative min-w-0">
|
||||
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
data-testid="profile-display-name"
|
||||
disabled={updateProfileMutation.isPending}
|
||||
id="profile-display-name"
|
||||
onChange={(event) => setDisplayNameDraft(event.target.value)}
|
||||
placeholder="How people should see you"
|
||||
value={displayNameDraft}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AvatarUpload
|
||||
avatarUrl={avatarUrlDraft}
|
||||
previewName={resolvedName}
|
||||
onUrlChange={(url) => setAvatarUrlDraft(url)}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
idleHint="Upload or paste a URL to change your avatar."
|
||||
testIdPrefix="profile-avatar"
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="profile-about">
|
||||
About
|
||||
</label>
|
||||
<div className="relative min-w-0">
|
||||
<AtSign className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Textarea
|
||||
className="min-h-28 pl-9"
|
||||
data-testid="profile-about"
|
||||
disabled={updateProfileMutation.isPending}
|
||||
id="profile-about"
|
||||
onChange={(event) => setAboutDraft(event.target.value)}
|
||||
placeholder="A short description for your profile"
|
||||
value={aboutDraft}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
data-testid="profile-save"
|
||||
disabled={!canSave}
|
||||
size="sm"
|
||||
type="submit"
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="profile-display-name"
|
||||
>
|
||||
{updateProfileMutation.isPending ? "Saving..." : "Save profile"}
|
||||
</Button>
|
||||
Display name
|
||||
</label>
|
||||
<div className="relative min-w-0">
|
||||
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
data-testid="profile-display-name"
|
||||
disabled={updateProfileMutation.isPending}
|
||||
id="profile-display-name"
|
||||
onChange={(event) => setDisplayNameDraft(event.target.value)}
|
||||
placeholder="How people should see you"
|
||||
value={displayNameDraft}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasPendingClearRequest ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Clearing existing profile fields is not supported yet. Blank
|
||||
display name, avatar, and about values are ignored for now.
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</Section>
|
||||
<AvatarUpload
|
||||
avatarUrl={avatarUrlDraft}
|
||||
previewName={resolvedName}
|
||||
onUrlChange={(url) => setAvatarUrlDraft(url)}
|
||||
disabled={updateProfileMutation.isPending}
|
||||
idleHint="Upload or paste a URL to change your avatar."
|
||||
testIdPrefix="profile-avatar"
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium" htmlFor="profile-about">
|
||||
About
|
||||
</label>
|
||||
<div className="relative min-w-0">
|
||||
<AtSign className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Textarea
|
||||
className="min-h-28 pl-9"
|
||||
data-testid="profile-about"
|
||||
disabled={updateProfileMutation.isPending}
|
||||
id="profile-about"
|
||||
onChange={(event) => setAboutDraft(event.target.value)}
|
||||
placeholder="A short description for your profile"
|
||||
value={aboutDraft}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -245,11 +227,13 @@ export function ProfileSettingsCard({
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ReadOnlyField
|
||||
copyValue={profile?.pubkey ?? currentPubkey ?? undefined}
|
||||
label="Public key"
|
||||
testId="profile-pubkey"
|
||||
value={resolvedPubkey}
|
||||
/>
|
||||
<ReadOnlyField
|
||||
copyValue={profile?.nip05Handle ?? undefined}
|
||||
label="NIP-05 handle"
|
||||
testId="profile-nip05"
|
||||
value={nip05Handle}
|
||||
@@ -257,6 +241,25 @@ export function ProfileSettingsCard({
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="sticky bottom-0 z-10 -mx-4 -mb-4 mt-6 flex flex-col gap-2 border-t border-border bg-background px-4 pt-4 pb-4 sm:-mx-6 sm:px-6">
|
||||
<Button
|
||||
data-testid="profile-save"
|
||||
disabled={!canSave}
|
||||
form="profile-settings-form"
|
||||
size="sm"
|
||||
type="submit"
|
||||
>
|
||||
{updateProfileMutation.isPending ? "Saving..." : "Save profile"}
|
||||
</Button>
|
||||
|
||||
{hasPendingClearRequest ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Clearing existing profile fields is not supported yet. Blank display
|
||||
name, avatar, and about values are ignored for now.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,6 +78,11 @@ export type SettingsPanelProps = {
|
||||
};
|
||||
|
||||
export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
{
|
||||
value: "appearance",
|
||||
label: "Appearance",
|
||||
icon: MonitorCog,
|
||||
},
|
||||
{
|
||||
value: "profile",
|
||||
label: "Profile",
|
||||
@@ -103,11 +108,6 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
label: "Compute",
|
||||
icon: Cpu,
|
||||
},
|
||||
{
|
||||
value: "appearance",
|
||||
label: "Appearance",
|
||||
icon: MonitorCog,
|
||||
},
|
||||
{
|
||||
value: "shortcuts",
|
||||
label: "Shortcuts",
|
||||
|
||||
@@ -7,6 +7,7 @@ type SettingsScreenProps = {
|
||||
currentPubkey?: string;
|
||||
fallbackDisplayName?: string;
|
||||
isUpdatingDesktopNotifications: boolean;
|
||||
mode: "profile" | "preferences";
|
||||
notificationErrorMessage: string | null;
|
||||
notificationPermission: DesktopNotificationPermissionState;
|
||||
notificationSettings: NotificationSettings;
|
||||
@@ -24,6 +25,7 @@ export function SettingsScreen({
|
||||
currentPubkey,
|
||||
fallbackDisplayName,
|
||||
isUpdatingDesktopNotifications,
|
||||
mode,
|
||||
notificationErrorMessage,
|
||||
notificationPermission,
|
||||
notificationSettings,
|
||||
@@ -41,6 +43,7 @@ export function SettingsScreen({
|
||||
currentPubkey={currentPubkey}
|
||||
fallbackDisplayName={fallbackDisplayName}
|
||||
isUpdatingDesktopNotifications={isUpdatingDesktopNotifications}
|
||||
mode={mode}
|
||||
notificationErrorMessage={notificationErrorMessage}
|
||||
notificationPermission={notificationPermission}
|
||||
notificationSettings={notificationSettings}
|
||||
|
||||
@@ -17,6 +17,7 @@ export {
|
||||
} from "./SettingsPanels";
|
||||
|
||||
type SettingsViewProps = SettingsPanelProps & {
|
||||
mode: "profile" | "preferences";
|
||||
onClose: () => void;
|
||||
onSectionChange: (section: SettingsSection) => void;
|
||||
section: SettingsSection;
|
||||
@@ -69,6 +70,7 @@ export function SettingsView({
|
||||
notificationErrorMessage,
|
||||
notificationPermission,
|
||||
notificationSettings,
|
||||
mode,
|
||||
onClose,
|
||||
onSectionChange,
|
||||
onSetDesktopNotificationsEnabled,
|
||||
@@ -82,6 +84,9 @@ export function SettingsView({
|
||||
const visibleSections = React.useMemo(() => {
|
||||
const membership = myMembershipQuery.data;
|
||||
return settingsSections.filter((s) => {
|
||||
if (mode === "preferences" && s.value === "profile") {
|
||||
return false;
|
||||
}
|
||||
if (s.value === "relay-members") {
|
||||
return (
|
||||
membership != null &&
|
||||
@@ -90,7 +95,7 @@ export function SettingsView({
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [myMembershipQuery.data]);
|
||||
}, [mode, myMembershipQuery.data]);
|
||||
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
const [appVersion, setAppVersion] = React.useState<string | null>(null);
|
||||
@@ -103,10 +108,19 @@ export function SettingsView({
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!visibleSections.some((entry) => entry.value === section)) {
|
||||
onSectionChange("profile");
|
||||
if (mode === "profile") {
|
||||
if (section !== "profile") {
|
||||
onSectionChange("profile");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}, [onSectionChange, section, visibleSections]);
|
||||
|
||||
if (!visibleSections.some((entry) => entry.value === section)) {
|
||||
onSectionChange(visibleSections[0]?.value ?? "appearance");
|
||||
}
|
||||
}, [mode, onSectionChange, section, visibleSections]);
|
||||
|
||||
const showSectionNav = mode === "preferences";
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
@@ -155,7 +169,7 @@ export function SettingsView({
|
||||
data-testid="settings-title"
|
||||
id="settings-title"
|
||||
>
|
||||
Settings
|
||||
{mode === "profile" ? "Profile" : "Settings"}
|
||||
</h2>
|
||||
<button
|
||||
aria-label="Close settings"
|
||||
@@ -168,39 +182,48 @@ export function SettingsView({
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden md:grid-cols-[220px_minmax(0,1fr)] md:grid-rows-1">
|
||||
<aside
|
||||
className={cn(
|
||||
"flex flex-col border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out md:border-b-0 md:border-r",
|
||||
isLoaded
|
||||
? "opacity-100 translate-x-0"
|
||||
: "opacity-0 -translate-x-2",
|
||||
)}
|
||||
>
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
className="flex gap-1 overflow-x-auto px-3 py-3 md:flex-1 md:flex-col md:overflow-y-auto md:pt-1"
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden md:grid-rows-1",
|
||||
showSectionNav
|
||||
? "md:grid-cols-[220px_minmax(0,1fr)]"
|
||||
: "md:grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{showSectionNav ? (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex flex-col border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out md:border-b-0 md:border-r",
|
||||
isLoaded
|
||||
? "opacity-100 translate-x-0"
|
||||
: "opacity-0 -translate-x-2",
|
||||
)}
|
||||
>
|
||||
{visibleSections.map((entry) => (
|
||||
<SettingsSectionButton
|
||||
active={entry.value === section}
|
||||
isLoaded={isLoaded}
|
||||
key={entry.value}
|
||||
onSelect={onSectionChange}
|
||||
section={entry}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
{appVersion ? (
|
||||
<p className="hidden px-3 pb-3 text-xs text-muted-foreground/60 md:block">
|
||||
v{appVersion}
|
||||
</p>
|
||||
) : null}
|
||||
</aside>
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
className="flex gap-1 overflow-x-auto px-3 py-3 md:flex-1 md:flex-col md:overflow-y-auto md:pt-1"
|
||||
>
|
||||
{visibleSections.map((entry) => (
|
||||
<SettingsSectionButton
|
||||
active={entry.value === section}
|
||||
isLoaded={isLoaded}
|
||||
key={entry.value}
|
||||
onSelect={onSectionChange}
|
||||
section={entry}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
{appVersion ? (
|
||||
<p className="hidden px-3 pb-3 text-xs text-muted-foreground/60 md:block">
|
||||
v{appVersion}
|
||||
</p>
|
||||
) : null}
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<section className="min-h-0 overflow-y-auto px-4 py-4 sm:px-6">
|
||||
<section className="flex min-h-0 flex-col overflow-y-auto px-4 pt-4 sm:px-6">
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-4xl flex-col gap-4"
|
||||
className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-4"
|
||||
data-testid={`settings-panel-${section}`}
|
||||
>
|
||||
{renderSettingsSection(section, {
|
||||
|
||||
@@ -15,13 +15,7 @@ import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import { AddWorkspaceDialog } from "@/features/workspaces/ui/AddWorkspaceDialog";
|
||||
import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher";
|
||||
import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup";
|
||||
import { getPresenceLabel } from "@/features/presence/lib/presence";
|
||||
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { ProfilePopover } from "@/features/profile/ui/ProfilePopover";
|
||||
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
|
||||
import {
|
||||
useChannelSections,
|
||||
type ChannelSection,
|
||||
@@ -43,6 +37,7 @@ import {
|
||||
} from "@/features/sidebar/ui/CustomChannelSection";
|
||||
import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
|
||||
import { NewDirectMessageDialog } from "@/features/sidebar/ui/NewDirectMessageDialog";
|
||||
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
|
||||
import type {
|
||||
Channel,
|
||||
ChannelVisibility,
|
||||
@@ -140,7 +135,7 @@ type AppSidebarProps = {
|
||||
onSelectWorkflows: () => void;
|
||||
onSelectHome: () => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
onSelectSettings: () => void;
|
||||
onSelectSettings: (section?: "profile" | "appearance") => void;
|
||||
onSetPresenceStatus?: (status: "online" | "away" | "offline") => void;
|
||||
onSetUserStatus: (text: string, emoji: string) => void;
|
||||
onClearUserStatus: () => void;
|
||||
@@ -213,7 +208,6 @@ export function AppSidebar({
|
||||
const setIsNewDmOpen = onNewDmOpenChange ?? setIsNewDmOpenInternal;
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
useSidebarScrollLock(scrollRef);
|
||||
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
|
||||
const [createDialogKind, setCreateDialogKind] =
|
||||
React.useState<CreateChannelKind | null>(null);
|
||||
|
||||
@@ -665,83 +659,23 @@ export function AppSidebar({
|
||||
<SidebarFooter className="absolute inset-x-0 bottom-0 z-30 bg-sidebar/55 backdrop-blur-xl supports-[backdrop-filter]:bg-sidebar/45 dark:bg-sidebar/45 dark:supports-[backdrop-filter]:bg-sidebar/35">
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<div
|
||||
className="rounded-xl px-2 py-2 transition-colors hover:bg-sidebar-accent/35 focus-within:bg-sidebar-accent/35 dark:hover:bg-sidebar-accent/25 dark:focus-within:bg-sidebar-accent/25"
|
||||
data-testid="sidebar-profile-card"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<ProfileAvatar
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
className="h-10 w-10 rounded-2xl text-sm"
|
||||
iconClassName="h-5 w-5"
|
||||
label={resolvedDisplayName}
|
||||
testId="sidebar-profile-avatar"
|
||||
/>
|
||||
<span
|
||||
aria-label={getPresenceLabel(selfPresenceStatus)}
|
||||
className="absolute -bottom-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-sidebar"
|
||||
data-testid="self-presence-badge"
|
||||
role="img"
|
||||
>
|
||||
<PresenceDot
|
||||
className="h-2.5 w-2.5"
|
||||
status={selfPresenceStatus}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ProfilePopover
|
||||
open={profilePopoverOpen}
|
||||
onOpenChange={setProfilePopoverOpen}
|
||||
displayName={resolvedDisplayName}
|
||||
nip05={profile?.nip05Handle}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
currentStatus={selfPresenceStatus}
|
||||
isStatusPending={isPresencePending}
|
||||
userStatusText={selfUserStatus?.text}
|
||||
userStatusEmoji={selfUserStatus?.emoji}
|
||||
onSetStatus={onSetPresenceStatus ?? (() => {})}
|
||||
onSetUserStatus={onSetUserStatus}
|
||||
onClearUserStatus={onClearUserStatus}
|
||||
onOpenSettings={onSelectSettings}
|
||||
>
|
||||
<button
|
||||
className="block w-full min-w-0 text-left text-sidebar-foreground"
|
||||
data-testid="open-settings"
|
||||
type="button"
|
||||
>
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-current"
|
||||
data-testid="sidebar-profile-name"
|
||||
>
|
||||
{resolvedDisplayName}
|
||||
</p>
|
||||
</button>
|
||||
</ProfilePopover>
|
||||
<WorkspaceSwitcher
|
||||
activeWorkspace={activeWorkspace}
|
||||
onAddWorkspace={onOpenAddWorkspace}
|
||||
onRemoveWorkspace={onRemoveWorkspace}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onUpdateWorkspace={onUpdateWorkspace}
|
||||
variant="profile"
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
{selfUserStatus?.text || selfUserStatus?.emoji ? (
|
||||
<p className="mt-0.5 truncate text-xs text-sidebar-foreground/50">
|
||||
{selfUserStatus.emoji ? (
|
||||
<StatusEmoji
|
||||
className="mr-1 h-3.5 w-3.5"
|
||||
value={selfUserStatus.emoji}
|
||||
/>
|
||||
) : null}
|
||||
{selfUserStatus.text}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SidebarProfileCard
|
||||
activeWorkspace={activeWorkspace}
|
||||
isPresencePending={isPresencePending}
|
||||
onOpenAddWorkspace={onOpenAddWorkspace}
|
||||
onOpenSettings={onSelectSettings}
|
||||
onRemoveWorkspace={onRemoveWorkspace}
|
||||
onSetPresenceStatus={onSetPresenceStatus}
|
||||
onSetUserStatus={onSetUserStatus}
|
||||
onClearUserStatus={onClearUserStatus}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onUpdateWorkspace={onUpdateWorkspace}
|
||||
profile={profile}
|
||||
resolvedDisplayName={resolvedDisplayName}
|
||||
selfPresenceStatus={selfPresenceStatus}
|
||||
selfUserStatus={selfUserStatus}
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { getPresenceLabel } from "@/features/presence/lib/presence";
|
||||
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import { ProfilePopover } from "@/features/profile/ui/ProfilePopover";
|
||||
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
|
||||
import type { Workspace } from "@/features/workspaces/types";
|
||||
import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher";
|
||||
import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
type SidebarProfileCardProps = {
|
||||
activeWorkspace: Workspace | null;
|
||||
isPresencePending?: boolean;
|
||||
onOpenAddWorkspace: () => void;
|
||||
onOpenSettings: (section?: "profile" | "appearance") => void;
|
||||
onRemoveWorkspace: (id: string) => void;
|
||||
onSetPresenceStatus?: (status: PresenceStatus) => void;
|
||||
onSetUserStatus: (text: string, emoji: string) => void;
|
||||
onClearUserStatus: () => void;
|
||||
onSwitchWorkspace: (id: string) => void;
|
||||
onUpdateWorkspace: (
|
||||
id: string,
|
||||
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
|
||||
) => void;
|
||||
profile?: Profile;
|
||||
resolvedDisplayName: string;
|
||||
selfPresenceStatus: PresenceStatus;
|
||||
selfUserStatus?: UserStatus;
|
||||
workspaces: Workspace[];
|
||||
};
|
||||
|
||||
export function SidebarProfileCard({
|
||||
activeWorkspace,
|
||||
isPresencePending,
|
||||
onOpenAddWorkspace,
|
||||
onOpenSettings,
|
||||
onRemoveWorkspace,
|
||||
onSetPresenceStatus,
|
||||
onSetUserStatus,
|
||||
onClearUserStatus,
|
||||
onSwitchWorkspace,
|
||||
onUpdateWorkspace,
|
||||
profile,
|
||||
resolvedDisplayName,
|
||||
selfPresenceStatus,
|
||||
selfUserStatus,
|
||||
workspaces,
|
||||
}: SidebarProfileCardProps) {
|
||||
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
|
||||
const profileCardRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const toggleProfilePopover = React.useCallback(
|
||||
() => setProfilePopoverOpen((prev) => !prev),
|
||||
[],
|
||||
);
|
||||
const handleCardClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = event.target;
|
||||
if (
|
||||
!(target instanceof Node) ||
|
||||
!profileCardRef.current?.contains(target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
toggleProfilePopover();
|
||||
},
|
||||
[toggleProfilePopover],
|
||||
);
|
||||
const hasStatus = Boolean(selfUserStatus?.text || selfUserStatus?.emoji);
|
||||
const workspaceLabel = activeWorkspace?.name ?? "No workspace";
|
||||
const readonlyWorkspaceLabel = (
|
||||
<span className="flex min-w-0 cursor-pointer items-center gap-1 text-xs leading-snug text-sidebar-foreground/70">
|
||||
<span aria-hidden="true" className="shrink-0 text-[10px] leading-none">
|
||||
🌱
|
||||
</span>
|
||||
<span className="truncate">{workspaceLabel}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions lint/a11y/useKeyWithClickEvents: child buttons provide keyboard access; wrapper fills pointer gaps between them.
|
||||
<div
|
||||
className="group/profile-card cursor-pointer rounded-xl px-2 py-2 transition-colors hover:bg-sidebar-border/35 dark:hover:bg-sidebar-border/30"
|
||||
data-testid="sidebar-profile-card"
|
||||
onClick={handleCardClick}
|
||||
ref={profileCardRef}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<button
|
||||
aria-label={`Open profile menu for ${resolvedDisplayName}`}
|
||||
className="relative shrink-0 rounded-xl outline-hidden focus:outline-none focus-visible:outline-none"
|
||||
data-testid="sidebar-profile-avatar-button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleProfilePopover();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<ProfileAvatar
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
className="h-8 w-8 rounded-xl text-xs"
|
||||
iconClassName="h-4 w-4"
|
||||
label={resolvedDisplayName}
|
||||
testId="sidebar-profile-avatar"
|
||||
/>
|
||||
<span
|
||||
aria-label={getPresenceLabel(selfPresenceStatus)}
|
||||
className="absolute -bottom-0.5 -right-0.5 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-sidebar"
|
||||
data-testid="self-presence-badge"
|
||||
role="img"
|
||||
>
|
||||
<PresenceDot className="h-2 w-2" status={selfPresenceStatus} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<ProfilePopover
|
||||
open={profilePopoverOpen}
|
||||
onOpenChange={setProfilePopoverOpen}
|
||||
displayName={resolvedDisplayName}
|
||||
nip05={profile?.nip05Handle}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
currentStatus={selfPresenceStatus}
|
||||
isStatusPending={isPresencePending}
|
||||
userStatusText={selfUserStatus?.text}
|
||||
userStatusEmoji={selfUserStatus?.emoji}
|
||||
onSetStatus={onSetPresenceStatus ?? (() => {})}
|
||||
onSetUserStatus={onSetUserStatus}
|
||||
onClearUserStatus={onClearUserStatus}
|
||||
onOpenSettings={onOpenSettings}
|
||||
triggerContainerRef={profileCardRef}
|
||||
workspaceSwitcherSlot={
|
||||
<WorkspaceSwitcher
|
||||
activeWorkspace={activeWorkspace}
|
||||
onAddWorkspace={onOpenAddWorkspace}
|
||||
onRemoveWorkspace={onRemoveWorkspace}
|
||||
onSwitchWorkspace={onSwitchWorkspace}
|
||||
onUpdateWorkspace={onUpdateWorkspace}
|
||||
variant="profile-menu"
|
||||
workspaces={workspaces}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<button
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleProfilePopover();
|
||||
}}
|
||||
className="block w-full min-w-0 rounded-sm text-left text-sidebar-foreground outline-hidden focus:outline-none focus-visible:outline-none"
|
||||
data-testid="open-settings"
|
||||
type="button"
|
||||
>
|
||||
<p
|
||||
className="truncate text-sm font-semibold leading-tight text-current"
|
||||
data-testid="sidebar-profile-name"
|
||||
>
|
||||
{resolvedDisplayName}
|
||||
</p>
|
||||
</button>
|
||||
</ProfilePopover>
|
||||
|
||||
{hasStatus ? (
|
||||
<div className="relative mt-0.5">
|
||||
<button
|
||||
aria-label={`Open profile menu for ${resolvedDisplayName}`}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center truncate rounded-sm text-left text-xs leading-snug text-sidebar-foreground/70 outline-hidden transition-opacity duration-150 focus:outline-none focus-visible:outline-none group-hover/profile-card:opacity-0",
|
||||
profilePopoverOpen && "opacity-100",
|
||||
)}
|
||||
data-testid="sidebar-profile-user-status"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleProfilePopover();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{selfUserStatus?.emoji ? (
|
||||
<StatusEmoji
|
||||
className="mr-1 h-3.5 w-3.5"
|
||||
value={selfUserStatus.emoji}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{selfUserStatus?.text}</span>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 flex min-w-0 items-center text-xs leading-snug text-sidebar-foreground/70 opacity-0 transition-opacity duration-150 group-hover/profile-card:opacity-100",
|
||||
profilePopoverOpen && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{readonlyWorkspaceLabel}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative mt-0.5">{readonlyWorkspaceLabel}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -166,6 +166,11 @@ export function useSetUserStatusMutation(pubkey?: string) {
|
||||
? { text, emoji, updatedAt: Math.floor(Date.now() / 1_000) }
|
||||
: null;
|
||||
|
||||
queryClient.setQueryData<UserStatusLookup>(
|
||||
userStatusQueryKey([normalizedPubkey]),
|
||||
(old) => ({ ...(old ?? {}), [normalizedPubkey]: status }),
|
||||
);
|
||||
|
||||
queryClient.setQueriesData<UserStatusLookup>(
|
||||
{ queryKey: ["user-status"] },
|
||||
(old) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
WifiOff,
|
||||
@@ -20,13 +21,13 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/shared/ui/sidebar";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import type { ConnectionState } from "@/shared/api/relayClientShared";
|
||||
import {
|
||||
isRelayConnectionDegraded,
|
||||
useRelayConnection,
|
||||
} from "@/shared/api/useRelayConnection";
|
||||
|
||||
import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
|
||||
|
||||
const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
|
||||
@@ -41,7 +42,7 @@ const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
|
||||
type WorkspaceSwitcherProps = {
|
||||
activeWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
variant?: "sidebar" | "profile";
|
||||
variant?: "sidebar" | "profile" | "profile-menu";
|
||||
onSwitchWorkspace: (id: string) => void;
|
||||
onAddWorkspace: () => void;
|
||||
onUpdateWorkspace: (
|
||||
@@ -63,9 +64,47 @@ export function WorkspaceSwitcher({
|
||||
const [editingWorkspace, setEditingWorkspace] =
|
||||
React.useState<Workspace | null>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
||||
const profileMenuHoverTimer = React.useRef<number | null>(null);
|
||||
const connectionState = useRelayConnection();
|
||||
const degraded = isRelayConnectionDegraded(connectionState);
|
||||
const connectionLabel = CONNECTION_STATE_LABEL[connectionState];
|
||||
const isProfileVariant = variant === "profile";
|
||||
|
||||
function clearProfileMenuHoverTimer() {
|
||||
if (profileMenuHoverTimer.current !== null) {
|
||||
window.clearTimeout(profileMenuHoverTimer.current);
|
||||
profileMenuHoverTimer.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleProfileMenu(nextOpen: boolean) {
|
||||
if (variant !== "profile-menu") return;
|
||||
clearProfileMenuHoverTimer();
|
||||
profileMenuHoverTimer.current = window.setTimeout(
|
||||
() => setDropdownOpen(nextOpen),
|
||||
nextOpen ? 80 : 160,
|
||||
);
|
||||
}
|
||||
|
||||
function handleProfileMenuOpenChange(nextOpen: boolean) {
|
||||
if (variant !== "profile-menu") {
|
||||
setDropdownOpen(nextOpen);
|
||||
return;
|
||||
}
|
||||
if (!nextOpen) {
|
||||
clearProfileMenuHoverTimer();
|
||||
}
|
||||
setDropdownOpen(nextOpen);
|
||||
}
|
||||
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (profileMenuHoverTimer.current !== null) {
|
||||
window.clearTimeout(profileMenuHoverTimer.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const triggerContent = (
|
||||
<>
|
||||
@@ -75,26 +114,24 @@ export function WorkspaceSwitcher({
|
||||
<span
|
||||
aria-hidden="false"
|
||||
className={
|
||||
variant === "profile"
|
||||
isProfileVariant
|
||||
? "flex h-5 w-5 shrink-0 animate-pulse items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-destructive"
|
||||
: "flex h-5 w-5 shrink-0 animate-pulse items-center justify-center text-destructive"
|
||||
}
|
||||
data-testid="relay-connection-warning"
|
||||
role="img"
|
||||
>
|
||||
<WifiOff
|
||||
className={variant === "profile" ? "h-3 w-3" : "h-4 w-4"}
|
||||
/>
|
||||
<WifiOff className={isProfileVariant ? "h-3 w-3" : "h-4 w-4"} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={variant === "profile" ? "top" : "bottom"}>
|
||||
<TooltipContent side={isProfileVariant ? "top" : "bottom"}>
|
||||
{connectionLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
variant === "profile"
|
||||
isProfileVariant
|
||||
? "flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-[10px] leading-none"
|
||||
: "flex h-5 w-5 shrink-0 items-center justify-center text-xs leading-none"
|
||||
}
|
||||
@@ -111,16 +148,106 @@ export function WorkspaceSwitcher({
|
||||
>
|
||||
{activeWorkspace?.name ?? "No workspace"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={
|
||||
variant === "profile"
|
||||
? "h-3 w-3 shrink-0 text-sidebar-foreground/45"
|
||||
: "h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50"
|
||||
}
|
||||
/>
|
||||
{variant === "profile-menu" ? (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown
|
||||
className={
|
||||
isProfileVariant
|
||||
? "h-3 w-3 shrink-0 text-sidebar-foreground/45"
|
||||
: "h-3.5 w-3.5 shrink-0 text-sidebar-foreground/50"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const profileMenuPopover =
|
||||
variant === "profile-menu" ? (
|
||||
<Popover open={dropdownOpen} onOpenChange={handleProfileMenuOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
aria-expanded={dropdownOpen}
|
||||
aria-haspopup="menu"
|
||||
aria-label={
|
||||
degraded
|
||||
? `${activeWorkspace?.name ?? "Workspace"} — ${connectionLabel}`
|
||||
: "Switch workspace"
|
||||
}
|
||||
className="flex w-full items-center gap-2 px-4 py-2 text-left text-sm text-popover-foreground outline-hidden transition-colors hover:bg-accent focus:bg-accent focus:outline-none focus-visible:bg-accent focus-visible:outline-none data-[state=open]:bg-accent data-[state=open]:text-popover-foreground"
|
||||
data-testid="workspace-switcher"
|
||||
onMouseEnter={() => scheduleProfileMenu(true)}
|
||||
onMouseLeave={() => scheduleProfileMenu(false)}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
{triggerContent}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
className="w-56 rounded-xl border border-border bg-popover p-1 shadow-lg"
|
||||
onMouseEnter={() => scheduleProfileMenu(true)}
|
||||
onMouseLeave={() => scheduleProfileMenu(false)}
|
||||
side="right"
|
||||
sideOffset={0}
|
||||
>
|
||||
<div aria-label="Workspaces" role="menu">
|
||||
{workspaces.map((workspace) => (
|
||||
<div
|
||||
className="group flex items-center rounded-xs transition-colors hover:bg-accent focus-within:bg-accent"
|
||||
key={workspace.id}
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5 text-left text-sm outline-hidden focus:outline-none"
|
||||
onClick={() => {
|
||||
onSwitchWorkspace(workspace.id);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
{activeWorkspace?.id === workspace.id ? (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{workspace.name}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Edit ${workspace.name}`}
|
||||
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 hover:bg-accent group-hover:opacity-100 group-focus-within:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDropdownOpen(false);
|
||||
setEditingWorkspace(workspace);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="-mx-1 my-1 h-px bg-muted" />
|
||||
<button
|
||||
className="flex w-full items-center gap-2 rounded-xs px-2 py-1.5 text-left text-sm outline-hidden transition-colors hover:bg-accent focus:bg-accent focus:outline-none focus-visible:bg-accent focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
setDropdownOpen(false);
|
||||
onAddWorkspace();
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Add Workspace</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null;
|
||||
|
||||
const switcherDropdown = (
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -131,7 +258,7 @@ export function WorkspaceSwitcher({
|
||||
? `${activeWorkspace?.name ?? "Workspace"} — ${connectionLabel}`
|
||||
: "Switch workspace"
|
||||
}
|
||||
className="flex min-w-0 max-w-full items-center gap-1.5 rounded-md py-0.5 text-left text-xs text-sidebar-foreground/50 transition-colors hover:text-sidebar-foreground data-[state=open]:text-sidebar-foreground"
|
||||
className="flex min-w-0 max-w-full items-center gap-1.5 rounded-md py-0.5 text-left text-xs text-sidebar-foreground/50 outline-hidden transition-colors hover:text-sidebar-foreground focus:outline-none focus-visible:outline-none data-[state=open]:text-sidebar-foreground"
|
||||
data-testid="workspace-switcher"
|
||||
type="button"
|
||||
>
|
||||
@@ -201,6 +328,8 @@ export function WorkspaceSwitcher({
|
||||
<>
|
||||
{variant === "profile" ? (
|
||||
switcherDropdown
|
||||
) : variant === "profile-menu" ? (
|
||||
profileMenuPopover
|
||||
) : (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>{switcherDropdown}</SidebarMenuItem>
|
||||
|
||||
@@ -304,4 +304,28 @@
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: "Geist", "Avenir Next", "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
/*
|
||||
* Tailwind v4's preflight no longer sets `cursor: pointer` on buttons.
|
||||
* Restore it for clearly actionable elements so hover affordance is
|
||||
* consistent across the app. Disabled / aria-disabled elements keep
|
||||
* the default cursor; explicit `cursor-*` utility classes still win
|
||||
* because utilities are in a later layer than base.
|
||||
*/
|
||||
button:not(:disabled),
|
||||
a[href],
|
||||
summary,
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
[role="menuitem"]:not([aria-disabled="true"]),
|
||||
[role="menuitemcheckbox"]:not([aria-disabled="true"]),
|
||||
[role="menuitemradio"]:not([aria-disabled="true"]),
|
||||
[role="tab"]:not([aria-disabled="true"]),
|
||||
[role="link"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
[aria-disabled="true"] {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await openSettings(page, "profile");
|
||||
await expect(page.getByTestId("settings-title")).toHaveText("Settings");
|
||||
await expect(page.getByTestId("settings-title")).toHaveText("Profile");
|
||||
|
||||
await expect(page.getByTestId("profile-pubkey")).toContainText("deadbeef");
|
||||
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
|
||||
|
||||
@@ -21,10 +21,14 @@ export async function openProfileMenu(page: Page) {
|
||||
|
||||
export async function openSettings(page: Page, section?: SettingsSection) {
|
||||
await openProfileMenu(page);
|
||||
await page.getByTestId("profile-popover-settings").click();
|
||||
if (section === "profile") {
|
||||
await page.getByTestId("profile-popover-profile").click();
|
||||
} else {
|
||||
await page.getByTestId("profile-popover-settings").click();
|
||||
}
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
|
||||
if (section) {
|
||||
if (section && section !== "profile") {
|
||||
await page.getByTestId(`settings-nav-${section}`).click();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user