mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Redesign settings & presence UX: profile popover + modal overlay (#238)
This commit is contained in:
@@ -30,6 +30,7 @@
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-focus-scope": "^1.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
|
||||
27
desktop/pnpm-lock.yaml
generated
27
desktop/pnpm-lock.yaml
generated
@@ -26,6 +26,9 @@ importers:
|
||||
'@radix-ui/react-dropdown-menu':
|
||||
specifier: ^2.1.16
|
||||
version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope':
|
||||
specifier: ^1.1.8
|
||||
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-popover':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -646,6 +649,19 @@ packages:
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.8':
|
||||
resolution: {integrity: sha512-BFjgXkfyRXxFJ0t/Xs4QSsb2wmkDfJ983j4vzC95on81gKPtJdJ+5ESHOuwKGm/umcWd2En33AiEMgyUGSKWQw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-id@1.1.1':
|
||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||
peerDependencies:
|
||||
@@ -2467,6 +2483,17 @@ snapshots:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
|
||||
@@ -65,11 +65,11 @@ import {
|
||||
} from "@/shared/ui/sidebar";
|
||||
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
|
||||
|
||||
type AppView = "home" | "channel" | "settings" | "agents" | "workflows";
|
||||
type MainView = Exclude<AppView, "settings">;
|
||||
type AppView = "home" | "channel" | "agents" | "workflows";
|
||||
export function AppShell() {
|
||||
useWebviewZoomShortcuts();
|
||||
const [selectedView, setSelectedView] = React.useState<AppView>("home");
|
||||
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
||||
const [settingsSection, setSettingsSection] = React.useState<SettingsSection>(
|
||||
DEFAULT_SETTINGS_SECTION,
|
||||
);
|
||||
@@ -92,9 +92,8 @@ export function AppShell() {
|
||||
React.useState<RelayEvent | null>(null);
|
||||
const [replyTargetId, setReplyTargetId] = React.useState<string | null>(null);
|
||||
const [editTargetId, setEditTargetId] = React.useState<string | null>(null);
|
||||
const lastNonSettingsViewRef = React.useRef<MainView>("home");
|
||||
const queryClient = useQueryClient();
|
||||
const selectView = React.useCallback((view: AppView | MainView) => {
|
||||
const selectView = React.useCallback((view: AppView) => {
|
||||
React.startTransition(() => {
|
||||
setSelectedView(view);
|
||||
});
|
||||
@@ -367,22 +366,14 @@ export function AppShell() {
|
||||
setIsSearchOpen(false);
|
||||
setIsChannelManagementOpen(false);
|
||||
setSettingsSection(section);
|
||||
|
||||
React.startTransition(() => {
|
||||
setSelectedView("settings");
|
||||
});
|
||||
setSettingsOpen(true);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCloseSettings = React.useCallback(() => {
|
||||
const nextView: MainView =
|
||||
lastNonSettingsViewRef.current === "channel" && !selectedChannel
|
||||
? "home"
|
||||
: lastNonSettingsViewRef.current;
|
||||
|
||||
selectView(nextView);
|
||||
}, [selectView, selectedChannel]);
|
||||
setSettingsOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenSearchResult = React.useCallback(
|
||||
(hit: SearchHit) => {
|
||||
@@ -441,13 +432,6 @@ export function AppShell() {
|
||||
setReplyTargetId(null);
|
||||
requestedAncestorIdsRef.current.clear();
|
||||
}, [activeChannelId]);
|
||||
React.useEffect(() => {
|
||||
if (selectedView === "settings") {
|
||||
return;
|
||||
}
|
||||
|
||||
lastNonSettingsViewRef.current = selectedView;
|
||||
}, [selectedView]);
|
||||
React.useEffect(() => {
|
||||
if (replyTargetId && !replyTargetMessage) {
|
||||
setReplyTargetId(null);
|
||||
@@ -547,7 +531,7 @@ export function AppShell() {
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
if (selectedView === "settings") {
|
||||
if (settingsOpen) {
|
||||
handleCloseSettings();
|
||||
return;
|
||||
}
|
||||
@@ -559,7 +543,7 @@ export function AppShell() {
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleCloseSettings, handleOpenSettings, selectedView]);
|
||||
}, [handleCloseSettings, handleOpenSettings, settingsOpen]);
|
||||
|
||||
return (
|
||||
<ChannelNavigationProvider
|
||||
@@ -567,257 +551,224 @@ export function AppShell() {
|
||||
onOpenChannel={handleOpenChannel}
|
||||
>
|
||||
<SidebarProvider className="h-dvh overflow-hidden overscroll-none">
|
||||
{selectedView === "settings" ? (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
<SettingsView
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isUpdatingDesktopNotifications={
|
||||
notificationSettings.isUpdatingDesktopEnabled
|
||||
}
|
||||
isPresenceLoading={presenceSession.isLoading}
|
||||
isUpdatingPresence={presenceSession.isPending}
|
||||
notificationErrorMessage={notificationSettings.errorMessage}
|
||||
notificationPermission={notificationSettings.permission}
|
||||
notificationSettings={notificationSettings.settings}
|
||||
onClose={handleCloseSettings}
|
||||
onSectionChange={setSettingsSection}
|
||||
onSetDesktopNotificationsEnabled={
|
||||
notificationSettings.setDesktopEnabled
|
||||
}
|
||||
onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled}
|
||||
onSetMentionNotificationsEnabled={
|
||||
notificationSettings.setMentionsEnabled
|
||||
}
|
||||
onSetNeedsActionNotificationsEnabled={
|
||||
notificationSettings.setNeedsActionEnabled
|
||||
}
|
||||
onSetPresence={presenceSession.setStatus}
|
||||
presenceError={presenceSession.error}
|
||||
presenceStatus={presenceSession.currentStatus}
|
||||
section={settingsSection}
|
||||
<SidebarTrigger className="fixed left-[80px] top-[8px] z-50 h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
|
||||
<AppSidebar
|
||||
channels={memberChannels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={
|
||||
channelsQuery.error instanceof Error
|
||||
? channelsQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeBadgeCount={homeBadgeCount}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
isOpeningDm={openDmMutation.isPending}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
onCreateChannel={async ({ description, name, visibility }) => {
|
||||
const createdChannel = await createChannelMutation.mutateAsync({
|
||||
name,
|
||||
description,
|
||||
channelType: "stream",
|
||||
visibility,
|
||||
});
|
||||
|
||||
openChannelView(createdChannel.id);
|
||||
}}
|
||||
onCreateForum={async ({ description, name, visibility }) => {
|
||||
const createdForum = await createForumMutation.mutateAsync({
|
||||
name,
|
||||
description,
|
||||
channelType: "forum",
|
||||
visibility,
|
||||
});
|
||||
|
||||
openChannelView(createdForum.id);
|
||||
}}
|
||||
onOpenBrowseChannels={() => {
|
||||
setBrowseDialogType("stream");
|
||||
void refetchChannels();
|
||||
}}
|
||||
onOpenBrowseForums={() => {
|
||||
setBrowseDialogType("forum");
|
||||
void refetchChannels();
|
||||
}}
|
||||
onOpenSearch={() => {
|
||||
setIsSearchOpen(true);
|
||||
void refetchChannels();
|
||||
}}
|
||||
onHideDm={handleHideDm}
|
||||
onOpenDm={async ({ pubkeys }) => {
|
||||
const directMessage = await openDmMutation.mutateAsync({
|
||||
pubkeys,
|
||||
});
|
||||
openChannelView(directMessage.id);
|
||||
}}
|
||||
onSelectAgents={() => selectView("agents")}
|
||||
onSelectWorkflows={() => selectView("workflows")}
|
||||
onSelectHome={() => {
|
||||
selectView("home");
|
||||
void homeFeedQuery.refetch();
|
||||
}}
|
||||
onSelectChannel={handleOpenChannel}
|
||||
onSelectSettings={handleOpenSettings}
|
||||
onSetPresenceStatus={(status) => presenceSession.setStatus(status)}
|
||||
isPresencePending={presenceSession.isPending}
|
||||
profile={profileQuery.data}
|
||||
selectedChannelId={selectedChannel?.id ?? null}
|
||||
selectedView={selectedView}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
/>
|
||||
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
{selectedView === "home" ? (
|
||||
<ChatHeader
|
||||
description="Personalized feed for mentions, reminders, channel activity, and agent work."
|
||||
mode="home"
|
||||
title="Home"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<SidebarTrigger className="fixed left-[80px] top-[8px] z-50 h-6 w-6 text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground" />
|
||||
<AppSidebar
|
||||
channels={memberChannels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={
|
||||
channelsQuery.error instanceof Error
|
||||
? channelsQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeBadgeCount={homeBadgeCount}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
isOpeningDm={openDmMutation.isPending}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
onCreateChannel={async ({ description, name, visibility }) => {
|
||||
const createdChannel = await createChannelMutation.mutateAsync({
|
||||
name,
|
||||
description,
|
||||
channelType: "stream",
|
||||
visibility,
|
||||
});
|
||||
|
||||
openChannelView(createdChannel.id);
|
||||
}}
|
||||
onCreateForum={async ({ description, name, visibility }) => {
|
||||
const createdForum = await createForumMutation.mutateAsync({
|
||||
name,
|
||||
description,
|
||||
channelType: "forum",
|
||||
visibility,
|
||||
});
|
||||
|
||||
openChannelView(createdForum.id);
|
||||
}}
|
||||
onOpenBrowseChannels={() => {
|
||||
setBrowseDialogType("stream");
|
||||
void refetchChannels();
|
||||
}}
|
||||
onOpenBrowseForums={() => {
|
||||
setBrowseDialogType("forum");
|
||||
void refetchChannels();
|
||||
}}
|
||||
onOpenSearch={() => {
|
||||
setIsSearchOpen(true);
|
||||
void refetchChannels();
|
||||
}}
|
||||
onHideDm={handleHideDm}
|
||||
onOpenDm={async ({ pubkeys }) => {
|
||||
const directMessage = await openDmMutation.mutateAsync({
|
||||
pubkeys,
|
||||
});
|
||||
openChannelView(directMessage.id);
|
||||
}}
|
||||
onSelectAgents={() => selectView("agents")}
|
||||
onSelectWorkflows={() => selectView("workflows")}
|
||||
onSelectHome={() => {
|
||||
selectView("home");
|
||||
void homeFeedQuery.refetch();
|
||||
}}
|
||||
onSelectChannel={handleOpenChannel}
|
||||
onSelectSettings={handleOpenSettings}
|
||||
profile={profileQuery.data}
|
||||
selectedChannelId={selectedChannel?.id ?? null}
|
||||
selectedView={selectedView}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
) : selectedView === "agents" ? (
|
||||
<ChatHeader
|
||||
description="Create local ACP workers, mint agent tokens, and monitor the relay-visible agent directory."
|
||||
mode="agents"
|
||||
title="Agents"
|
||||
/>
|
||||
|
||||
<SidebarInset className="min-h-0 min-w-0 overflow-hidden">
|
||||
{selectedView === "home" ? (
|
||||
<ChatHeader
|
||||
description="Personalized feed for mentions, reminders, channel activity, and agent work."
|
||||
mode="home"
|
||||
title="Home"
|
||||
/>
|
||||
) : selectedView === "agents" ? (
|
||||
<ChatHeader
|
||||
description="Create local ACP workers, mint agent tokens, and monitor the relay-visible agent directory."
|
||||
mode="agents"
|
||||
title="Agents"
|
||||
/>
|
||||
) : selectedView === "workflows" ? (
|
||||
<ChatHeader
|
||||
description="Create, manage, and monitor automated workflows across your channels."
|
||||
mode="workflows"
|
||||
title="Workflows"
|
||||
/>
|
||||
) : (
|
||||
<ChatHeader
|
||||
actions={
|
||||
activeChannel ? (
|
||||
<ChannelMembersBar
|
||||
channel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onManageChannel={() => {
|
||||
setIsChannelManagementOpen(true);
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
channelType={activeChannel?.channelType}
|
||||
visibility={activeChannel?.visibility}
|
||||
description={channelDescription}
|
||||
statusBadge={
|
||||
activeChannel?.channelType === "dm" &&
|
||||
activeDmPresenceStatus ? (
|
||||
<PresenceBadge
|
||||
data-testid="chat-presence-badge"
|
||||
status={activeDmPresenceStatus}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
title={activeChannelTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className={
|
||||
selectedView === "home"
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
<HomeView
|
||||
availableChannelIds={availableChannelIds}
|
||||
) : selectedView === "workflows" ? (
|
||||
<ChatHeader
|
||||
description="Create, manage, and monitor automated workflows across your channels."
|
||||
mode="workflows"
|
||||
title="Workflows"
|
||||
/>
|
||||
) : (
|
||||
<ChatHeader
|
||||
actions={
|
||||
activeChannel ? (
|
||||
<ChannelMembersBar
|
||||
channel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={
|
||||
homeFeedQuery.error instanceof Error
|
||||
? homeFeedQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
feed={homeFeedQuery.data}
|
||||
isLoading={homeFeedQuery.isLoading}
|
||||
onOpenChannel={handleOpenChannel}
|
||||
onRefresh={() => {
|
||||
void homeFeedQuery.refetch();
|
||||
onManageChannel={() => {
|
||||
setIsChannelManagementOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
selectedView === "agents"
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "hidden"
|
||||
) : null
|
||||
}
|
||||
channelType={activeChannel?.channelType}
|
||||
visibility={activeChannel?.visibility}
|
||||
description={channelDescription}
|
||||
statusBadge={
|
||||
activeChannel?.channelType === "dm" &&
|
||||
activeDmPresenceStatus ? (
|
||||
<PresenceBadge
|
||||
data-testid="chat-presence-badge"
|
||||
status={activeDmPresenceStatus}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
title={activeChannelTitle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className={
|
||||
selectedView === "home"
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
<HomeView
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={
|
||||
homeFeedQuery.error instanceof Error
|
||||
? homeFeedQuery.error.message
|
||||
: undefined
|
||||
}
|
||||
feed={homeFeedQuery.data}
|
||||
isLoading={homeFeedQuery.isLoading}
|
||||
onOpenChannel={handleOpenChannel}
|
||||
onRefresh={() => {
|
||||
void homeFeedQuery.refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
selectedView === "agents"
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
<AgentsView />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
selectedView === "workflows"
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
<WorkflowsView channels={memberChannels} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
selectedView !== "home" &&
|
||||
selectedView !== "agents" &&
|
||||
selectedView !== "workflows"
|
||||
? "flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
{activeChannel?.channelType === "forum" ? (
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
/>
|
||||
) : (
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
editTarget={
|
||||
editTargetMessage
|
||||
? {
|
||||
author: editTargetMessage.author,
|
||||
body: editTargetMessage.body,
|
||||
id: editTargetMessage.id,
|
||||
}
|
||||
: null
|
||||
}
|
||||
>
|
||||
<AgentsView />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
selectedView === "workflows"
|
||||
? "flex min-h-0 flex-1 flex-col"
|
||||
: "hidden"
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelReply={handleCancelReply}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditSave={handleEditSave}
|
||||
onReply={handleReply}
|
||||
onSend={handleSend}
|
||||
onTargetReached={handleTargetReached}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
profiles={messageProfiles}
|
||||
replyTargetId={replyTargetId}
|
||||
replyTargetMessage={replyTargetMessage}
|
||||
targetMessageId={
|
||||
activeChannel &&
|
||||
searchAnchor?.channelId === activeChannel.id
|
||||
? searchAnchor.eventId
|
||||
: null
|
||||
}
|
||||
>
|
||||
<WorkflowsView channels={memberChannels} />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
selectedView !== "home" &&
|
||||
selectedView !== "agents" &&
|
||||
selectedView !== "workflows"
|
||||
? "flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
: "hidden"
|
||||
}
|
||||
>
|
||||
{activeChannel?.channelType === "forum" ? (
|
||||
<ForumView
|
||||
channel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
/>
|
||||
) : (
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fetchOlder={fetchOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
editTarget={
|
||||
editTargetMessage
|
||||
? {
|
||||
author: editTargetMessage.author,
|
||||
body: editTargetMessage.body,
|
||||
id: editTargetMessage.id,
|
||||
}
|
||||
: null
|
||||
}
|
||||
isSending={sendMessageMutation.isPending}
|
||||
isTimelineLoading={isTimelineLoading}
|
||||
messages={timelineMessages}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelReply={handleCancelReply}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
onEditSave={handleEditSave}
|
||||
onReply={handleReply}
|
||||
onSend={handleSend}
|
||||
onTargetReached={handleTargetReached}
|
||||
onToggleReaction={effectiveToggleReaction}
|
||||
profiles={messageProfiles}
|
||||
replyTargetId={replyTargetId}
|
||||
replyTargetMessage={replyTargetMessage}
|
||||
targetMessageId={
|
||||
activeChannel &&
|
||||
searchAnchor?.channelId === activeChannel.id
|
||||
? searchAnchor.eventId
|
||||
: null
|
||||
}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</React.Fragment>
|
||||
)}
|
||||
typingPubkeys={typingPubkeys}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
|
||||
<ChannelBrowserDialog
|
||||
channels={channels}
|
||||
@@ -846,6 +797,31 @@ export function AppShell() {
|
||||
onOpenChange={setIsChannelManagementOpen}
|
||||
open={isChannelManagementOpen && activeChannel !== null}
|
||||
/>
|
||||
{settingsOpen && (
|
||||
<SettingsView
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isUpdatingDesktopNotifications={
|
||||
notificationSettings.isUpdatingDesktopEnabled
|
||||
}
|
||||
notificationErrorMessage={notificationSettings.errorMessage}
|
||||
notificationPermission={notificationSettings.permission}
|
||||
notificationSettings={notificationSettings.settings}
|
||||
onClose={handleCloseSettings}
|
||||
onSectionChange={setSettingsSection}
|
||||
onSetDesktopNotificationsEnabled={
|
||||
notificationSettings.setDesktopEnabled
|
||||
}
|
||||
onSetHomeBadgeEnabled={notificationSettings.setHomeBadgeEnabled}
|
||||
onSetMentionNotificationsEnabled={
|
||||
notificationSettings.setMentionsEnabled
|
||||
}
|
||||
onSetNeedsActionNotificationsEnabled={
|
||||
notificationSettings.setNeedsActionEnabled
|
||||
}
|
||||
section={settingsSection}
|
||||
/>
|
||||
)}
|
||||
</SidebarProvider>
|
||||
</ChannelNavigationProvider>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
Bot,
|
||||
CircleDot,
|
||||
FileText,
|
||||
Hash,
|
||||
Home,
|
||||
Lock,
|
||||
Settings2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { Bot, CircleDot, FileText, Hash, Home, Lock, Zap } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import type { ChannelType, ChannelVisibility } from "@/shared/api/types";
|
||||
@@ -18,7 +9,7 @@ type ChatHeaderProps = {
|
||||
description: string;
|
||||
channelType?: ChannelType;
|
||||
visibility?: ChannelVisibility;
|
||||
mode?: "home" | "channel" | "settings" | "agents" | "workflows";
|
||||
mode?: "home" | "channel" | "agents" | "workflows";
|
||||
statusBadge?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -29,7 +20,7 @@ function ChannelIcon({
|
||||
}: {
|
||||
channelType?: ChannelType;
|
||||
visibility?: ChannelVisibility;
|
||||
mode?: "home" | "channel" | "settings" | "agents" | "workflows";
|
||||
mode?: "home" | "channel" | "agents" | "workflows";
|
||||
}) {
|
||||
if (mode === "home") {
|
||||
return <Home className="h-5 w-5 text-primary" />;
|
||||
@@ -43,10 +34,6 @@ function ChannelIcon({
|
||||
return <Zap className="h-5 w-5 text-primary" />;
|
||||
}
|
||||
|
||||
if (mode === "settings") {
|
||||
return <Settings2 className="h-5 w-5 text-primary" />;
|
||||
}
|
||||
|
||||
if (channelType === "dm") {
|
||||
return <CircleDot className="h-5 w-5 text-primary" />;
|
||||
}
|
||||
|
||||
155
desktop/src/features/profile/ui/ProfilePopover.tsx
Normal file
155
desktop/src/features/profile/ui/ProfilePopover.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import type * as React from "react";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import {
|
||||
PresenceDot,
|
||||
PresenceBadge,
|
||||
} from "@/features/presence/ui/PresenceBadge";
|
||||
import type { PresenceStatus } from "@/shared/api/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ProfilePopoverProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
displayName: string;
|
||||
nip05?: string | null;
|
||||
avatarUrl: string | null;
|
||||
currentStatus: PresenceStatus;
|
||||
isStatusPending?: boolean;
|
||||
onSetStatus: (status: PresenceStatus) => void;
|
||||
onOpenSettings: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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";
|
||||
|
||||
const ALL_STATUSES: PresenceStatus[] = ["online", "away", "offline"];
|
||||
|
||||
const STATUS_ACTION_LABELS: Record<PresenceStatus, string> = {
|
||||
online: "Set yourself as online",
|
||||
away: "Set yourself as away",
|
||||
offline: "Set yourself as offline",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProfilePopover
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ProfilePopover({
|
||||
open,
|
||||
onOpenChange,
|
||||
displayName,
|
||||
nip05,
|
||||
avatarUrl,
|
||||
currentStatus,
|
||||
isStatusPending,
|
||||
onSetStatus,
|
||||
onOpenSettings,
|
||||
children,
|
||||
}: ProfilePopoverProps) {
|
||||
const otherStatuses = ALL_STATUSES.filter((s) => s !== currentStatus);
|
||||
const isMac =
|
||||
typeof navigator !== "undefined" &&
|
||||
/Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-[280px] rounded-xl border border-border bg-popover p-0 shadow-lg"
|
||||
data-testid="profile-popover"
|
||||
>
|
||||
<div aria-label="Profile menu" role="menu">
|
||||
{/* ── Identity block ─────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="relative shrink-0">
|
||||
<ProfileAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
className="h-10 w-10 rounded-2xl text-sm"
|
||||
iconClassName="h-5 w-5"
|
||||
label={displayName}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-popover-foreground">
|
||||
{displayName}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{nip05 ? <span className="truncate">@{nip05}</span> : null}
|
||||
{nip05 ? <span aria-hidden="true">·</span> : null}
|
||||
<PresenceBadge
|
||||
className="border-0 bg-transparent px-0 py-0 text-xs"
|
||||
data-testid="profile-popover-current-status"
|
||||
status={currentStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-1 h-px border-0 bg-border" />
|
||||
|
||||
{/* ── Status options ─────────────────────────────────── */}
|
||||
<div className="px-1.5 py-1">
|
||||
{otherStatuses.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
className={MENU_ITEM_CLASS}
|
||||
data-testid={`profile-popover-status-${status}`}
|
||||
disabled={isStatusPending}
|
||||
onClick={() => {
|
||||
onSetStatus(status);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
>
|
||||
<PresenceDot className="h-2.5 w-2.5" status={status} />
|
||||
<span className="text-sm text-popover-foreground">
|
||||
{STATUS_ACTION_LABELS[status]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<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={() => {
|
||||
onOpenSettings();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
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>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
BellRing,
|
||||
CircleDot,
|
||||
KeyRound,
|
||||
MonitorCog,
|
||||
Moon,
|
||||
@@ -13,12 +12,7 @@ import type {
|
||||
DesktopNotificationPermissionState,
|
||||
NotificationSettings,
|
||||
} from "@/features/notifications/hooks";
|
||||
import {
|
||||
PresenceBadge,
|
||||
PresenceDot,
|
||||
} from "@/features/presence/ui/PresenceBadge";
|
||||
import { TokenSettingsCard } from "@/features/tokens/ui/TokenSettingsCard";
|
||||
import type { PresenceStatus } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import { DoctorSettingsPanel } from "./DoctorSettingsPanel";
|
||||
@@ -27,7 +21,6 @@ import { ProfileSettingsCard } from "./ProfileSettingsCard";
|
||||
|
||||
export type SettingsSection =
|
||||
| "profile"
|
||||
| "presence"
|
||||
| "notifications"
|
||||
| "appearance"
|
||||
| "tokens"
|
||||
@@ -45,8 +38,6 @@ export type SettingsPanelProps = {
|
||||
currentPubkey?: string;
|
||||
fallbackDisplayName?: string;
|
||||
isUpdatingDesktopNotifications: boolean;
|
||||
isPresenceLoading: boolean;
|
||||
isUpdatingPresence: boolean;
|
||||
notificationErrorMessage: string | null;
|
||||
notificationPermission: DesktopNotificationPermissionState;
|
||||
notificationSettings: NotificationSettings;
|
||||
@@ -54,9 +45,6 @@ export type SettingsPanelProps = {
|
||||
onSetHomeBadgeEnabled: (enabled: boolean) => void;
|
||||
onSetMentionNotificationsEnabled: (enabled: boolean) => void;
|
||||
onSetNeedsActionNotificationsEnabled: (enabled: boolean) => void;
|
||||
onSetPresence: (status: PresenceStatus) => Promise<void>;
|
||||
presenceError: Error | null;
|
||||
presenceStatus: PresenceStatus;
|
||||
};
|
||||
|
||||
type ThemeOption = {
|
||||
@@ -71,11 +59,6 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
label: "Profile",
|
||||
icon: UserRound,
|
||||
},
|
||||
{
|
||||
value: "presence",
|
||||
label: "Presence",
|
||||
icon: CircleDot,
|
||||
},
|
||||
{
|
||||
value: "notifications",
|
||||
label: "Notifications",
|
||||
@@ -116,30 +99,6 @@ const themeOptions: ThemeOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const presenceOptions: Array<{
|
||||
value: PresenceStatus;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
value: "online",
|
||||
label: "Online",
|
||||
description:
|
||||
"Automatically active while you use the app and away when idle.",
|
||||
},
|
||||
{
|
||||
value: "away",
|
||||
label: "Away",
|
||||
description:
|
||||
"Forces this desktop session to appear idle until you change it.",
|
||||
},
|
||||
{
|
||||
value: "offline",
|
||||
label: "Offline",
|
||||
description: "Hides this desktop session and stops presence heartbeats.",
|
||||
},
|
||||
];
|
||||
|
||||
function ThemeSettingsCard() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
@@ -187,93 +146,6 @@ function ThemeSettingsCard() {
|
||||
);
|
||||
}
|
||||
|
||||
function PresenceStatusBadge({ status }: { status: PresenceStatus }) {
|
||||
return (
|
||||
<PresenceBadge data-testid="presence-current-status" status={status} />
|
||||
);
|
||||
}
|
||||
|
||||
function PresenceSettingsCard({
|
||||
isLoading,
|
||||
isUpdating,
|
||||
onSetPresence,
|
||||
presenceError,
|
||||
presenceStatus,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
isUpdating: boolean;
|
||||
onSetPresence: (status: PresenceStatus) => Promise<void>;
|
||||
presenceError: Error | null;
|
||||
presenceStatus: PresenceStatus;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="rounded-xl border border-border/80 bg-card/80 p-4 shadow-sm"
|
||||
data-testid="settings-presence"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold tracking-tight">Presence</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose how this desktop session appears on the relay.
|
||||
</p>
|
||||
</div>
|
||||
<PresenceStatusBadge status={presenceStatus} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 md:grid-cols-3">
|
||||
{presenceOptions.map((option) => {
|
||||
const isActive = presenceStatus === option.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-pressed={isActive}
|
||||
className={cn(
|
||||
"flex min-h-24 flex-col items-start justify-between rounded-xl border px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
isActive
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border/80 bg-background/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
)}
|
||||
data-testid={`presence-option-${option.value}`}
|
||||
disabled={isLoading || isUpdating}
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
void onSetPresence(option.value);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<PresenceDot className="h-4 w-4" status={option.value} />
|
||||
<span className="font-medium text-foreground">
|
||||
{option.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{option.description}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{presenceError ? (
|
||||
<p className="mt-4 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{presenceError.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<p
|
||||
className="mt-4 text-sm text-muted-foreground"
|
||||
data-testid="presence-help"
|
||||
>
|
||||
Sprout refreshes presence every minute while it is running. Online will
|
||||
switch to away after a few minutes of inactivity or when the app is
|
||||
hidden. The relay expires presence after 90 seconds.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderSettingsSection(
|
||||
section: SettingsSection,
|
||||
props: SettingsPanelProps,
|
||||
@@ -286,16 +158,6 @@ export function renderSettingsSection(
|
||||
fallbackDisplayName={props.fallbackDisplayName}
|
||||
/>
|
||||
);
|
||||
case "presence":
|
||||
return (
|
||||
<PresenceSettingsCard
|
||||
isLoading={props.isPresenceLoading}
|
||||
isUpdating={props.isUpdatingPresence}
|
||||
onSetPresence={props.onSetPresence}
|
||||
presenceError={props.presenceError}
|
||||
presenceStatus={props.presenceStatus}
|
||||
/>
|
||||
);
|
||||
case "notifications":
|
||||
return (
|
||||
<NotificationSettingsCard
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { FocusScope } from "@radix-ui/react-focus-scope";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
renderSettingsSection,
|
||||
settingsSections,
|
||||
@@ -22,10 +23,14 @@ type SettingsViewProps = SettingsPanelProps & {
|
||||
|
||||
function SettingsSectionButton({
|
||||
active,
|
||||
index,
|
||||
isLoaded,
|
||||
onSelect,
|
||||
section,
|
||||
}: {
|
||||
active: boolean;
|
||||
index: number;
|
||||
isLoaded: boolean;
|
||||
onSelect: (section: SettingsSection) => void;
|
||||
section: (typeof settingsSections)[number];
|
||||
}) {
|
||||
@@ -35,13 +40,15 @@ function SettingsSectionButton({
|
||||
<button
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"group inline-flex min-w-fit items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring lg:w-full lg:justify-start",
|
||||
"group inline-flex min-w-fit items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium whitespace-nowrap motion-safe:transition-all motion-safe:duration-300 motion-safe:ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
active
|
||||
? "border-border bg-background text-foreground shadow-sm"
|
||||
: "border-transparent bg-transparent text-muted-foreground hover:bg-background/70 hover:text-foreground",
|
||||
isLoaded ? "opacity-100 translate-x-0" : "opacity-0 -translate-x-2",
|
||||
)}
|
||||
data-testid={`settings-nav-${section.value}`}
|
||||
onClick={() => onSelect(section.value)}
|
||||
style={{ transitionDelay: isLoaded ? "0ms" : `${index * 40 + 300}ms` }}
|
||||
type="button"
|
||||
>
|
||||
<Icon
|
||||
@@ -61,8 +68,6 @@ export function SettingsView({
|
||||
currentPubkey,
|
||||
fallbackDisplayName,
|
||||
isUpdatingDesktopNotifications,
|
||||
isPresenceLoading,
|
||||
isUpdatingPresence,
|
||||
notificationErrorMessage,
|
||||
notificationPermission,
|
||||
notificationSettings,
|
||||
@@ -72,89 +77,131 @@ export function SettingsView({
|
||||
onSetHomeBadgeEnabled,
|
||||
onSetMentionNotificationsEnabled,
|
||||
onSetNeedsActionNotificationsEnabled,
|
||||
onSetPresence,
|
||||
presenceError,
|
||||
presenceStatus,
|
||||
section,
|
||||
}: SettingsViewProps) {
|
||||
// Entrance animation state
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setIsLoaded(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Escape key to close
|
||||
React.useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && !event.defaultPrevented) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background"
|
||||
data-testid="settings-view"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 flex items-center justify-center motion-safe:transition-opacity motion-safe:duration-300",
|
||||
isLoaded ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
data-testid="settings-overlay"
|
||||
>
|
||||
<header
|
||||
className="flex items-start justify-between gap-4 border-b border-border/80 bg-background px-4 pb-4 pt-8 sm:px-6"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="min-w-0 pt-0.5">
|
||||
<h1
|
||||
className="text-lg font-semibold tracking-tight"
|
||||
data-testid="settings-title"
|
||||
>
|
||||
Settings
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your relay identity, desktop preferences, and local access
|
||||
tokens.
|
||||
</p>
|
||||
</div>
|
||||
{/* Backdrop — click to close */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
<Button
|
||||
aria-label="Close settings"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
data-testid="settings-close"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
title="Close settings"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{/* Modal container */}
|
||||
<FocusScope loop>
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Click stops propagation to backdrop; keyboard dismiss handled by Escape key */}
|
||||
<div
|
||||
aria-labelledby="settings-title"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
"relative mx-auto flex h-[min(600px,calc(100vh-8rem))] w-[calc(100%-4rem)] max-w-3xl flex-col overflow-hidden rounded-xl border border-border bg-background shadow-lg motion-safe:transition-all motion-safe:duration-500 motion-safe:ease-out",
|
||||
isLoaded ? "opacity-100 scale-100" : "opacity-0 scale-95",
|
||||
)}
|
||||
data-testid="settings-view"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden lg:grid-cols-[260px_minmax(0,1fr)] lg:grid-rows-1">
|
||||
<aside className="border-b border-border/70 bg-muted/20 lg:border-b-0 lg:border-r">
|
||||
<nav
|
||||
aria-label="Settings sections"
|
||||
className="flex gap-2 overflow-x-auto px-3 py-4 lg:flex-col lg:overflow-y-auto"
|
||||
{/* Header with title and close button */}
|
||||
<header
|
||||
className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
{settingsSections.map((entry) => (
|
||||
<SettingsSectionButton
|
||||
active={entry.value === section}
|
||||
key={entry.value}
|
||||
onSelect={onSectionChange}
|
||||
section={entry}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<h2
|
||||
className="text-base font-semibold"
|
||||
data-testid="settings-title"
|
||||
id="settings-title"
|
||||
>
|
||||
Settings
|
||||
</h2>
|
||||
<button
|
||||
aria-label="Close settings"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="settings-close"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="min-h-0 overflow-y-auto px-4 py-4 sm:px-6">
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-4xl flex-col gap-4"
|
||||
data-testid={`settings-panel-${section}`}
|
||||
>
|
||||
{renderSettingsSection(section, {
|
||||
currentPubkey,
|
||||
fallbackDisplayName,
|
||||
isUpdatingDesktopNotifications,
|
||||
isPresenceLoading,
|
||||
isUpdatingPresence,
|
||||
notificationErrorMessage,
|
||||
notificationPermission,
|
||||
notificationSettings,
|
||||
onSetDesktopNotificationsEnabled,
|
||||
onSetHomeBadgeEnabled,
|
||||
onSetMentionNotificationsEnabled,
|
||||
onSetNeedsActionNotificationsEnabled,
|
||||
onSetPresence,
|
||||
presenceError,
|
||||
presenceStatus,
|
||||
})}
|
||||
{/* Two-column layout */}
|
||||
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden lg:grid-cols-[220px_minmax(0,1fr)] lg:grid-rows-1">
|
||||
{/* Sidebar nav */}
|
||||
<aside
|
||||
className={cn(
|
||||
"border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-700 motion-safe:ease-out lg:border-b-0 lg: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 lg:flex-col lg:overflow-y-auto lg:pt-1"
|
||||
>
|
||||
{settingsSections.map((entry, index) => (
|
||||
<SettingsSectionButton
|
||||
active={entry.value === section}
|
||||
index={index}
|
||||
isLoaded={isLoaded}
|
||||
key={entry.value}
|
||||
onSelect={onSectionChange}
|
||||
section={entry}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content area */}
|
||||
<section className="min-h-0 overflow-y-auto px-4 py-4 sm:px-6">
|
||||
<div
|
||||
className="mx-auto flex w-full max-w-4xl flex-col gap-4"
|
||||
data-testid={`settings-panel-${section}`}
|
||||
>
|
||||
{renderSettingsSection(section, {
|
||||
currentPubkey,
|
||||
fallbackDisplayName,
|
||||
isUpdatingDesktopNotifications,
|
||||
notificationErrorMessage,
|
||||
notificationPermission,
|
||||
notificationSettings,
|
||||
onSetDesktopNotificationsEnabled,
|
||||
onSetHomeBadgeEnabled,
|
||||
onSetMentionNotificationsEnabled,
|
||||
onSetNeedsActionNotificationsEnabled,
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</FocusScope>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
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 { useDmSidebarMetadata } from "@/features/sidebar/useDmSidebarMetadata";
|
||||
import {
|
||||
ChannelMenuButton,
|
||||
@@ -61,7 +62,7 @@ type AppSidebarProps = {
|
||||
selfPresenceStatus: PresenceStatus;
|
||||
errorMessage?: string;
|
||||
selectedChannelId: string | null;
|
||||
selectedView: "home" | "channel" | "settings" | "agents" | "workflows";
|
||||
selectedView: "home" | "channel" | "agents" | "workflows";
|
||||
unreadChannelIds: Set<string>;
|
||||
onCreateChannel: (input: {
|
||||
name: string;
|
||||
@@ -83,6 +84,8 @@ type AppSidebarProps = {
|
||||
onSelectHome: () => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
onSelectSettings: () => void;
|
||||
onSetPresenceStatus?: (status: "online" | "away" | "offline") => void;
|
||||
isPresencePending?: boolean;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -448,9 +451,12 @@ export function AppSidebar({
|
||||
onSelectHome,
|
||||
onSelectChannel,
|
||||
onSelectSettings,
|
||||
onSetPresenceStatus,
|
||||
isPresencePending,
|
||||
}: AppSidebarProps) {
|
||||
const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"];
|
||||
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
|
||||
const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false);
|
||||
|
||||
const streamForm = useCreateForm(onCreateChannel, "stream");
|
||||
const forumForm = useCreateForm(onCreateForum, "forum");
|
||||
@@ -661,48 +667,57 @@ export function AppSidebar({
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
aria-pressed={selectedView === "settings"}
|
||||
className="h-auto gap-3 rounded-xl px-2 py-2"
|
||||
data-testid="open-settings"
|
||||
isActive={selectedView === "settings"}
|
||||
onClick={() => onSelectSettings()}
|
||||
type="button"
|
||||
<ProfilePopover
|
||||
open={profilePopoverOpen}
|
||||
onOpenChange={setProfilePopoverOpen}
|
||||
displayName={resolvedDisplayName}
|
||||
nip05={profile?.nip05Handle}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
currentStatus={selfPresenceStatus}
|
||||
isStatusPending={isPresencePending}
|
||||
onSetStatus={onSetPresenceStatus ?? (() => {})}
|
||||
onOpenSettings={onSelectSettings}
|
||||
>
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-3"
|
||||
data-testid="sidebar-profile-card"
|
||||
<SidebarMenuButton
|
||||
className="h-auto gap-3 rounded-xl px-2 py-2"
|
||||
data-testid="open-settings"
|
||||
type="button"
|
||||
>
|
||||
<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}
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center gap-3"
|
||||
data-testid="sidebar-profile-card"
|
||||
>
|
||||
<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>
|
||||
<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">
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-current"
|
||||
data-testid="sidebar-profile-name"
|
||||
>
|
||||
{resolvedDisplayName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p
|
||||
className="truncate text-sm font-semibold text-current"
|
||||
data-testid="sidebar-profile-name"
|
||||
>
|
||||
{resolvedDisplayName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuButton>
|
||||
</ProfilePopover>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type Browser } from "@playwright/test";
|
||||
|
||||
import { installRelayBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
import { openSettings } from "../helpers/settings";
|
||||
import { assertRelaySeeded } from "../helpers/seed";
|
||||
|
||||
async function createStream(
|
||||
@@ -35,9 +36,7 @@ async function closeChannelManagement(page: import("@playwright/test").Page) {
|
||||
async function enableDesktopNotifications(
|
||||
page: import("@playwright/test").Page,
|
||||
) {
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await page.getByTestId("settings-nav-notifications").click();
|
||||
await openSettings(page, "notifications");
|
||||
await expect(page.getByTestId("settings-notifications")).toBeVisible();
|
||||
await page.getByTestId("notifications-desktop-toggle").click();
|
||||
await expect(page.getByTestId("notifications-desktop-state")).toContainText(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { openSettings } from "../helpers/settings";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
@@ -244,8 +245,7 @@ test("shows your avatar on your own message when profile avatar is set", async (
|
||||
'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%2300a36c"/%3E%3C/svg%3E';
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-settings").click();
|
||||
await page.getByTestId("settings-nav-profile").click();
|
||||
await openSettings(page, "profile");
|
||||
await page.getByTestId("profile-avatar-url").fill(avatarUrl);
|
||||
await page.getByTestId("profile-save").click();
|
||||
await page.getByTestId("settings-close").click();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { openProfileMenu, openSettings } from "../helpers/settings";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
@@ -13,11 +14,8 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
|
||||
const about = `Coordinating relay profile setup ${stamp}`;
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await page.getByTestId("settings-nav-profile").click();
|
||||
await openSettings(page, "profile");
|
||||
await expect(page.getByTestId("settings-title")).toHaveText("Settings");
|
||||
await expect(page.getByTestId("open-settings")).toHaveCount(0);
|
||||
|
||||
await expect(page.getByTestId("profile-pubkey")).toContainText("deadbeef");
|
||||
await expect(page.getByTestId("profile-nip05")).toContainText("Not set");
|
||||
@@ -38,9 +36,7 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Home");
|
||||
await expect(page.getByTestId("open-settings")).toBeVisible();
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await page.getByTestId("settings-nav-profile").click();
|
||||
await openSettings(page, "profile");
|
||||
await expect(page.getByTestId("profile-display-name")).toHaveValue(
|
||||
displayName,
|
||||
);
|
||||
@@ -49,34 +45,25 @@ test("updates the relay-backed profile from settings", async ({ page }) => {
|
||||
await expect(page.getByTestId("profile-about")).toHaveValue(about);
|
||||
});
|
||||
|
||||
test("updates presence from settings", async ({ page }) => {
|
||||
test("updates presence from the profile menu", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await page.getByTestId("settings-nav-presence").click();
|
||||
await expect(page.getByTestId("presence-current-status")).toContainText(
|
||||
"Offline",
|
||||
);
|
||||
await openProfileMenu(page);
|
||||
await expect(
|
||||
page.getByTestId("profile-popover-current-status"),
|
||||
).toContainText("Offline");
|
||||
|
||||
await page.getByTestId("presence-option-away").click();
|
||||
await expect(page.getByTestId("presence-current-status")).toContainText(
|
||||
"Away",
|
||||
);
|
||||
await page.getByTestId("profile-popover-status-away").click();
|
||||
await openProfileMenu(page);
|
||||
await expect(
|
||||
page.getByTestId("profile-popover-current-status"),
|
||||
).toContainText("Away");
|
||||
|
||||
await page.getByTestId("settings-close").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("Home");
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await page.getByTestId("settings-nav-presence").click();
|
||||
await expect(page.getByTestId("presence-current-status")).toContainText(
|
||||
"Away",
|
||||
);
|
||||
|
||||
await page.getByTestId("presence-option-offline").click();
|
||||
await expect(page.getByTestId("presence-current-status")).toContainText(
|
||||
"Offline",
|
||||
);
|
||||
await page.getByTestId("profile-popover-status-offline").click();
|
||||
await openProfileMenu(page);
|
||||
await expect(
|
||||
page.getByTestId("profile-popover-current-status"),
|
||||
).toContainText("Offline");
|
||||
});
|
||||
|
||||
test("notification settings drive the Home badge and desktop alerts", async ({
|
||||
@@ -85,9 +72,7 @@ test("notification settings drive the Home badge and desktop alerts", async ({
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await page.getByTestId("settings-nav-notifications").click();
|
||||
await openSettings(page, "notifications");
|
||||
await expect(page.getByTestId("settings-notifications")).toBeVisible();
|
||||
await expect(page.getByTestId("notifications-desktop-state")).toContainText(
|
||||
"Off",
|
||||
@@ -171,15 +156,13 @@ test("notification settings drive the Home badge and desktop alerts", async ({
|
||||
},
|
||||
]);
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await page.getByTestId("settings-nav-notifications").click();
|
||||
await openSettings(page, "notifications");
|
||||
await page.getByTestId("notifications-home-badge-toggle").click();
|
||||
await page.getByTestId("settings-close").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
await expect(page.getByTestId("sidebar-home-count")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await page.getByTestId("settings-nav-notifications").click();
|
||||
await openSettings(page, "notifications");
|
||||
await page.getByTestId("notifications-home-badge-toggle").click();
|
||||
await page.getByTestId("settings-close").click();
|
||||
await expect(page.getByTestId("sidebar-home-count")).toHaveText("1");
|
||||
@@ -257,9 +240,7 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
|
||||
test("shows doctor checks for local sprout tooling", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await page.getByTestId("settings-nav-doctor").click();
|
||||
await openSettings(page, "doctor");
|
||||
|
||||
await expect(page.getByTestId("settings-doctor")).toBeVisible();
|
||||
await expect(page.getByTestId("doctor-check-admin")).toContainText(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { openSettings } from "../helpers/settings";
|
||||
|
||||
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
|
||||
const DESIGN_CHANNEL_ID = "b5e2f8a1-3c44-5912-9e67-4a8d1f2b3c4e";
|
||||
@@ -11,9 +12,7 @@ test("creates a channel-scoped token from settings and can revoke it", async ({
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await page.getByTestId("settings-nav-tokens").click();
|
||||
await openSettings(page, "tokens");
|
||||
|
||||
const tokenCard = page.getByTestId("settings-tokens");
|
||||
await tokenCard.getByRole("button", { name: "Create token" }).click();
|
||||
@@ -62,9 +61,7 @@ test("surfaces token mint errors in the dialog", async ({ page }) => {
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
await page.getByTestId("settings-nav-tokens").click();
|
||||
await openSettings(page, "tokens");
|
||||
|
||||
await page
|
||||
.getByTestId("settings-tokens")
|
||||
|
||||
23
desktop/tests/helpers/settings.ts
Normal file
23
desktop/tests/helpers/settings.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
type SettingsSection =
|
||||
| "profile"
|
||||
| "notifications"
|
||||
| "appearance"
|
||||
| "tokens"
|
||||
| "doctor";
|
||||
|
||||
export async function openProfileMenu(page: Page) {
|
||||
await page.getByTestId("open-settings").click();
|
||||
await expect(page.getByTestId("profile-popover")).toBeVisible();
|
||||
}
|
||||
|
||||
export async function openSettings(page: Page, section?: SettingsSection) {
|
||||
await openProfileMenu(page);
|
||||
await page.getByTestId("profile-popover-settings").click();
|
||||
await expect(page.getByTestId("settings-view")).toBeVisible();
|
||||
|
||||
if (section) {
|
||||
await page.getByTestId(`settings-nav-${section}`).click();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user