diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index d3f2708c3..7ed9fa934 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -68,6 +68,7 @@ import { useDueReminderBadgeCount } from "@/features/reminders/hooks"; import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; import { useReminderNotifications } from "@/features/reminders/useReminderNotifications"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; +import { requestFocusedThreadClose } from "@/features/channels/focusedThreadCloseRequest"; import { CommunityRail } from "@/features/sidebar/ui/CommunityRail"; import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes"; import { useChannelStars } from "@/features/sidebar/lib/useChannelStars"; @@ -844,6 +845,7 @@ export function AppShell() { addCommunityDialog.onOpenChange } onNewMessage={handleOpenNewDm} + onBackgroundClick={requestFocusedThreadClose} onCreateChannelOpenChange={setIsCreateChannelOpen} onOpenAddCommunity={addCommunityDialog.openDialog} onSendFeedback={() => setIsSendFeedbackOpen(true)} diff --git a/desktop/src/features/channels/focusedThreadCloseRequest.test.mjs b/desktop/src/features/channels/focusedThreadCloseRequest.test.mjs new file mode 100644 index 000000000..6f30d7ec6 --- /dev/null +++ b/desktop/src/features/channels/focusedThreadCloseRequest.test.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + requestFocusedThreadClose, + subscribeToFocusedThreadCloseRequest, +} from "./focusedThreadCloseRequest.ts"; + +test("focus thread close requests reach active subscribers only", () => { + let calls = 0; + const unsubscribe = subscribeToFocusedThreadCloseRequest(() => { + calls += 1; + }); + + requestFocusedThreadClose(); + assert.equal(calls, 1); + + unsubscribe(); + requestFocusedThreadClose(); + assert.equal(calls, 1); +}); diff --git a/desktop/src/features/channels/focusedThreadCloseRequest.ts b/desktop/src/features/channels/focusedThreadCloseRequest.ts new file mode 100644 index 000000000..3628d7076 --- /dev/null +++ b/desktop/src/features/channels/focusedThreadCloseRequest.ts @@ -0,0 +1,16 @@ +const listeners = new Set<() => void>(); + +/** Request dismissal of an open focus-mode thread drawer. */ +export function requestFocusedThreadClose(): void { + for (const listener of listeners) { + listener(); + } +} + +/** Subscribe the active channel surface to focus-mode dismissal requests. */ +export function subscribeToFocusedThreadCloseRequest( + listener: () => void, +): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 121e211f4..dbce0b6f5 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -514,16 +514,16 @@ export const ChannelPane = React.memo(function ChannelPane({ const isOverlay = useIsThreadPanelOverlay(); const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay; const threadViewMode = useThreadViewMode(); - // Focus mode is a wide-viewport-only alternative to the split thread pane: - // narrow viewports keep their existing single-panel / floating-overlay - // behavior untouched. It applies to the thread panel only — channel - // management, agent session and profile panels always use the split pane. + // Focus mode only replaces the wide split thread pane; narrow threads and + // other auxiliary panels keep their existing presentation. const useFocusThreadDrawer = threadViewMode === "focus" && useSplitAuxiliaryPane && (Boolean(threadHeadMessage) || shouldShowThreadSkeleton); - const { channelIsCovered, markExitComplete } = - useFocusDrawerPresence(useFocusThreadDrawer); + const { channelIsCovered, markExitComplete } = useFocusDrawerPresence( + useFocusThreadDrawer, + onCloseThread, + ); const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = useThreadViewModeSwitch({ externalScrollTargetId: threadScrollTargetId, diff --git a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx index b85718302..d5e287c20 100644 --- a/desktop/src/features/channels/ui/FocusThreadDrawer.tsx +++ b/desktop/src/features/channels/ui/FocusThreadDrawer.tsx @@ -5,6 +5,7 @@ import { THREAD_FOCUS_DRAWER_TRAVEL_PX, THREAD_FOCUS_SLIVER_WIDTH_PX, } from "@/features/channels/lib/threadFocusLayout"; +import { getThreadViewMode } from "@/features/channels/lib/threadViewModePreference"; import { cn } from "@/shared/lib/cn"; type FocusThreadDrawerProps = { @@ -167,7 +168,11 @@ export function FocusThreadDrawer({ return () => { const previousFocus = previousFocusRef.current; requestAnimationFrame(() => { - previousFocus?.focus({ preventScroll: true }); + // A real dismissal keeps focus mode selected; a presentation switch + // has already selected split mode and owns focus inside the new panel. + if (getThreadViewMode() === "focus") { + previousFocus?.focus({ preventScroll: true }); + } }); }; }, []); diff --git a/desktop/src/features/channels/ui/ThreadViewModeToggle.test.mjs b/desktop/src/features/channels/ui/ThreadViewModeToggle.test.mjs new file mode 100644 index 000000000..78416ad22 --- /dev/null +++ b/desktop/src/features/channels/ui/ThreadViewModeToggle.test.mjs @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldRestoreThreadToggleFocus } from "./ThreadViewModeToggle.tsx"; + +test("restores toggle focus for keyboard activation, not pointer clicks", () => { + assert.equal(shouldRestoreThreadToggleFocus(0), true); + assert.equal(shouldRestoreThreadToggleFocus(1), false); + assert.equal(shouldRestoreThreadToggleFocus(2), false); +}); diff --git a/desktop/src/features/channels/ui/ThreadViewModeToggle.tsx b/desktop/src/features/channels/ui/ThreadViewModeToggle.tsx index 6f1c08c93..2f85691e1 100644 --- a/desktop/src/features/channels/ui/ThreadViewModeToggle.tsx +++ b/desktop/src/features/channels/ui/ThreadViewModeToggle.tsx @@ -7,6 +7,11 @@ import { import { Button } from "@/shared/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +/** Preserve focus only when activation did not come from a pointer click. */ +export function shouldRestoreThreadToggleFocus(clickDetail: number): boolean { + return clickDetail === 0; +} + /** * Both glyphs depict the layout the button switches *to*, never the current one. * @@ -50,7 +55,7 @@ const THREAD_VIEW_MODE_TOGGLE = { export function ThreadViewModeToggle({ onChange, }: { - onChange: (mode: ThreadViewMode) => void; + onChange: (mode: ThreadViewMode, restoreFocus: boolean) => void; }) { const viewMode = useThreadViewMode(); const { icon: Icon, label, target } = THREAD_VIEW_MODE_TOGGLE[viewMode]; @@ -62,7 +67,9 @@ export function ThreadViewModeToggle({ aria-label={label} className="shrink-0" data-testid="thread-view-mode-toggle" - onClick={() => onChange(target)} + onClick={(event) => + onChange(target, shouldRestoreThreadToggleFocus(event.detail)) + } size="icon" type="button" variant="ghost" diff --git a/desktop/src/features/channels/ui/useFocusDrawerPresence.ts b/desktop/src/features/channels/ui/useFocusDrawerPresence.ts index 4674360bc..271c867ae 100644 --- a/desktop/src/features/channels/ui/useFocusDrawerPresence.ts +++ b/desktop/src/features/channels/ui/useFocusDrawerPresence.ts @@ -1,13 +1,20 @@ import * as React from "react"; -/** Keeps the covered channel inert until the focus drawer finishes exiting. */ -export function useFocusDrawerPresence(open: boolean) { +import { subscribeToFocusedThreadCloseRequest } from "@/features/channels/focusedThreadCloseRequest"; + +/** Keeps the covered channel inert and owns external dismissal while open. */ +export function useFocusDrawerPresence(open: boolean, onClose: () => void) { const [present, setPresent] = React.useState(false); React.useEffect(() => { if (open) setPresent(true); }, [open]); + React.useEffect(() => { + if (!open) return; + return subscribeToFocusedThreadCloseRequest(onClose); + }, [onClose, open]); + const markExitComplete = React.useCallback(() => setPresent(false), []); return { channelIsCovered: open || present, diff --git a/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts b/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts index 1cc0b36a4..5493eada5 100644 --- a/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts +++ b/desktop/src/features/channels/ui/useThreadViewModeSwitch.ts @@ -48,7 +48,7 @@ export function useThreadViewModeSwitch({ >(null); const changeThreadViewMode = React.useCallback( - (mode: ThreadViewMode) => { + (mode: ThreadViewMode, restoreFocus: boolean) => { const body = document.querySelector( '[data-testid="message-thread-body"]', ); @@ -61,7 +61,9 @@ export function useThreadViewModeSwitch({ requestAnimationFrame(() => { document .querySelector( - '[data-testid="thread-view-mode-toggle"]', + restoreFocus + ? '[data-testid="thread-view-mode-toggle"]' + : '[data-testid="message-thread-body"]', ) ?.focus({ preventScroll: true }); }); diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index c7624e43a..0cd4d6723 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -526,6 +526,7 @@ export function MessageThreadPanel({ data-buzz-conversation-scroll data-testid="message-thread-body" onScroll={onScroll} + tabIndex={-1} ref={threadBodyRef} >
{ + assert.equal(isSidebarBackgroundTarget(null), false); + assert.equal(isSidebarBackgroundTarget({}), false); +}); diff --git a/desktop/src/features/sidebar/lib/sidebarBackgroundTarget.ts b/desktop/src/features/sidebar/lib/sidebarBackgroundTarget.ts new file mode 100644 index 000000000..0d1a00a03 --- /dev/null +++ b/desktop/src/features/sidebar/lib/sidebarBackgroundTarget.ts @@ -0,0 +1,12 @@ +const SIDEBAR_BACKGROUND_ATTRIBUTE = "data-sidebar-background"; + +/** Whether a sidebar click landed directly on an opted-in blank surface. */ +export function isSidebarBackgroundTarget(target: EventTarget | null): boolean { + const element = + typeof Element !== "undefined" && target instanceof Element + ? target + : typeof Node !== "undefined" && target instanceof Node + ? target.parentElement + : null; + return element?.hasAttribute(SIDEBAR_BACKGROUND_ATTRIBUTE) ?? false; +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 8b51e3f56..c4552bb54 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -21,6 +21,7 @@ import { } from "@/features/sidebar/lib/channelSortPreference"; import { useChannelSortPreference } from "@/features/sidebar/lib/useChannelSortPreference"; import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock"; +import { isSidebarBackgroundTarget } from "@/features/sidebar/lib/sidebarBackgroundTarget"; import { useUnreadOverflow } from "@/features/sidebar/lib/useUnreadOverflow"; import { CreateSectionDialog, @@ -162,6 +163,7 @@ type AppSidebarProps = { selfUserStatus?: UserStatus; isPresencePending?: boolean; onNewMessage: () => void; + onBackgroundClick?: () => void; isCreateChannelOpen?: boolean; onCreateChannelOpenChange?: (open: boolean) => void; mutedChannelIds?: ReadonlySet; @@ -179,6 +181,7 @@ export function AppSidebar({ currentPubkey, fallbackDisplayName, homeBadgeCount, + onBackgroundClick, isAddCommunityOpen, isLoading, isCreatingChannel, @@ -550,10 +553,16 @@ export function AppSidebar({ className="!border-r-0" collapsible="offcanvas" data-testid="app-sidebar" + onClick={(event) => { + if (isSidebarBackgroundTarget(event.target)) { + onBackgroundClick?.(); + } + }} variant="sidebar" >
{unreadAboveCount > 0 ? ( @@ -585,10 +595,12 @@ export function AppSidebar({
(null); - const suppressClickRef = React.useRef(false); - const finishResize = React.useCallback( (event: React.PointerEvent) => { const resizeState = resizeStateRef.current; @@ -468,13 +464,6 @@ const SidebarRail = React.forwardRef< document.body.style.userSelect = resizeState.previousUserSelect; setIsResizing(false); resizeStateRef.current = null; - - if (resizeState.hasDragged) { - suppressClickRef.current = true; - window.requestAnimationFrame(() => { - suppressClickRef.current = false; - }); - } }, [setIsResizing], ); @@ -483,25 +472,9 @@ const SidebarRail = React.forwardRef<