diff --git a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx index 69e511fb40d..512ee6899fb 100644 --- a/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx +++ b/desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx @@ -78,7 +78,7 @@ export function AddChannelBotTeamsSection({

- +
{teams.map((team) => { const resolution = resolveTeamPersonas(team, personas); diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index d685a961030..cfa84f02e3c 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -10,7 +10,12 @@ import { import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/ui/popover"; import { Shimmer } from "@/shared/ui/Shimmer"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -26,7 +31,6 @@ type BotActivityBarProps = { variant?: "toolbar" | "inline"; }; -const HOVER_OPEN_DELAY_MS = 150; const HOVER_CLOSE_DELAY_MS = 180; const HEADLINE_ROTATION_MS = 2200; @@ -106,7 +110,7 @@ export function BotActivityComposerAction({ clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); - }, HOVER_OPEN_DELAY_MS); + }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); }, [clearHoverTimer]); const closeWithDelay = React.useCallback(() => { diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx index cd530e69081..4d6f2d258ba 100644 --- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx +++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx @@ -39,6 +39,12 @@ import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { useActiveCommunityIcon } from "@/features/communities/useCommunityIcons"; import { EditCommunityDialog } from "./EditCommunityDialog"; +// Community actions is a responsive navigation submenu, not an informational +// disclosure. Keep its short hover dwell explicit rather than inheriting the +// shared 500 ms Popover delay intended to prevent incidental inspection UI. +const PROFILE_MENU_HOVER_OPEN_DELAY_MS = 80; +const PROFILE_MENU_HOVER_CLOSE_DELAY_MS = 160; + const CONNECTION_STATE_LABEL: Record = { idle: "Not connected", connecting: "Connecting…", @@ -128,7 +134,9 @@ export function CommunitySwitcher({ clearProfileMenuHoverTimer(); profileMenuHoverTimer.current = window.setTimeout( () => setDropdownOpen(nextOpen), - nextOpen ? 80 : 160, + nextOpen + ? PROFILE_MENU_HOVER_OPEN_DELAY_MS + : PROFILE_MENU_HOVER_CLOSE_DELAY_MS, ); } diff --git a/desktop/src/features/home/ui/InboxDetailPane.tsx b/desktop/src/features/home/ui/InboxDetailPane.tsx index 9bb593087d0..4b64ad10e06 100644 --- a/desktop/src/features/home/ui/InboxDetailPane.tsx +++ b/desktop/src/features/home/ui/InboxDetailPane.tsx @@ -622,7 +622,7 @@ function InboxMessageDetailPane({
- +
{canOpenChannel && contextChannelId ? ( diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index d4bec8db6c5..fba878dbb5b 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -12,7 +12,12 @@ import { isPositiveEmojiParticle, useEmojiBurst, } from "@/shared/ui/EmojiBurstProvider"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const REACTION_PILL_BASE_CLASSES = @@ -379,7 +384,10 @@ function ReactionPill({ const handleMouseEnter = React.useCallback(() => { if (reaction.users.length === 0) return; clearTimers(); - openTimeout.current = setTimeout(() => setOpen(true), 200); + openTimeout.current = setTimeout( + () => setOpen(true), + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + ); }, [reaction.users.length, clearTimers]); const scheduleClose = React.useCallback(() => { diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx index f8c5395b17a..df9af09e674 100644 --- a/desktop/src/features/messages/ui/MessageTimeline.tsx +++ b/desktop/src/features/messages/ui/MessageTimeline.tsx @@ -692,7 +692,7 @@ const MessageTimelineBase = React.forwardRef< ) : null; return ( - +
{showUnreadPill ? (
- - -

- {displayTime} -

-
- - {formatFullDateTime(createdAt)} - -
- + + +

+ {displayTime} +

+
+ + {formatFullDateTime(createdAt)} + +
); } diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index ca732bb3f1f..257256c5f63 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -28,7 +28,12 @@ import { cn } from "@/shared/lib/cn"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { useProfileInteractionActions } from "@/features/profile/ui/useProfileInteractionActions"; -import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverAnchor, + PopoverContent, +} from "@/shared/ui/popover"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; import { useNow } from "@/shared/lib/useNow"; import { Button } from "@/shared/ui/button"; @@ -51,7 +56,6 @@ type UserProfilePopoverProps = { botIdenticonValue?: string; }; -const HOVER_OPEN_DELAY_MS = 500; const HOVER_CLOSE_DELAY_MS = 200; const RUNTIME_LABELS: Record = { @@ -244,7 +248,7 @@ export function UserProfilePopover({ clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); - }, HOVER_OPEN_DELAY_MS); + }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); }, [clearHoverTimer, enableHoverPopover]); const handleMouseLeave = React.useCallback(() => { diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx index 1c86ac4c36b..e6e24ce27b1 100644 --- a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -16,10 +16,14 @@ import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { useNow } from "@/shared/lib/useNow"; import { Markdown } from "@/shared/ui/markdown"; -import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverAnchor, + PopoverContent, +} from "@/shared/ui/popover"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -const HOVER_OPEN_DELAY_MS = 250; const HOVER_CLOSE_DELAY_MS = 180; const ACTIVITY_POPOVER_MOTION_STYLE = { "--tw-enter-scale": "1", @@ -310,7 +314,7 @@ export function ChannelActivityPopover({ clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); - }, HOVER_OPEN_DELAY_MS); + }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); }, [clearHoverTimer, hasContent]); const openImmediately = React.useCallback(() => { if (!hasContent) return; diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index bbb4c5fa425..1520814559f 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -86,7 +86,7 @@ function renderApp() { enabled={huddleWindowChannelId() === null} > - + diff --git a/desktop/src/shared/ui/PubKey.tsx b/desktop/src/shared/ui/PubKey.tsx index 153801787ba..fb5ac15a441 100644 --- a/desktop/src/shared/ui/PubKey.tsx +++ b/desktop/src/shared/ui/PubKey.tsx @@ -6,9 +6,13 @@ import { cn } from "@/shared/lib/cn"; import { safeNpub } from "@/shared/lib/nostrUtils"; import { truncatePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/ui/popover"; -const HOVER_OPEN_DELAY_MS = 500; const HOVER_CLOSE_DELAY_MS = 200; type PubKeyProps = { @@ -99,7 +103,7 @@ export function PubKey({ clearHoverTimer(); hoverTimerRef.current = setTimeout(() => { setOpen(true); - }, HOVER_OPEN_DELAY_MS); + }, DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS); }, [clearHoverTimer]); const handleMouseLeave = React.useCallback(() => { diff --git a/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx b/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx index 89b482ae0cf..1082ff20322 100644 --- a/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx +++ b/desktop/src/shared/ui/markdown/InlineEmojiPopover.tsx @@ -1,6 +1,11 @@ import * as React from "react"; -import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + Popover, + PopoverContent, + PopoverTrigger, +} from "@/shared/ui/popover"; export function InlineEmojiPopover({ alt, @@ -27,7 +32,10 @@ export function InlineEmojiPopover({ const handleMouseEnter = React.useCallback(() => { clearTimers(); - openTimeout.current = setTimeout(() => setOpen(true), 200); + openTimeout.current = setTimeout( + () => setOpen(true), + DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, + ); }, [clearTimers]); const scheduleClose = React.useCallback(() => { diff --git a/desktop/src/shared/ui/popover.tsx b/desktop/src/shared/ui/popover.tsx index 4c161efe5d8..ede0e15627d 100644 --- a/desktop/src/shared/ui/popover.tsx +++ b/desktop/src/shared/ui/popover.tsx @@ -14,6 +14,10 @@ import { POPOVER_SURFACE_CLASS, } from "@/shared/ui/popoverSurface"; +// Radix Popover has no hover timing API: controlled hover popovers must use this +// shared dwell default themselves. Keep click and keyboard opens immediate. +export const DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS = 500; + const Popover = PopoverPrimitive.Root; const PopoverTrigger = PopoverPrimitive.Trigger; diff --git a/desktop/src/shared/ui/sidebar.tsx b/desktop/src/shared/ui/sidebar.tsx index 41db47b15c6..ddda3d4933e 100644 --- a/desktop/src/shared/ui/sidebar.tsx +++ b/desktop/src/shared/ui/sidebar.tsx @@ -255,7 +255,7 @@ const SidebarProvider = React.forwardRef< return ( - +
) => ( + +); const Tooltip = TooltipPrimitive.Root; diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 3f6d38602ab..effa03ea55e 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -275,10 +275,87 @@ test.describe("community rail", () => { expect(communityBox?.y).toBeLessThan(feedbackBox?.y ?? 0); expect(feedbackBox?.y).toBeLessThan(settingsBox?.y ?? 0); - await page.getByTestId("community-switcher").click(); - const menu = page.getByRole("menu", { name: "Community actions" }); + await communityTrigger.evaluate((trigger) => { + trigger.addEventListener( + "mouseenter", + () => { + trigger.dataset.hoverStartedAt = String(performance.now()); + }, + { once: true }, + ); + trigger.addEventListener("mouseleave", () => { + trigger.dataset.leftAt = String(performance.now()); + }); + const observer = new MutationObserver((records) => { + if ( + trigger.getAttribute("aria-expanded") === "true" && + !trigger.dataset.expandedAt + ) { + trigger.dataset.expandedAt = String(performance.now()); + } + if ( + records.some( + (record) => + record.attributeName === "aria-expanded" && + record.oldValue === "true", + ) + ) { + trigger.dataset.closedAfterOpening = "true"; + } + }); + observer.observe(trigger, { + attributeFilter: ["aria-expanded"], + attributeOldValue: true, + attributes: true, + }); + }); + await communityTrigger.hover(); + await expect(menu).toBeVisible({ timeout: 700 }); + const openDelayMs = await communityTrigger.evaluate((trigger) => { + const hoverStartedAt = Number(trigger.dataset.hoverStartedAt); + const expandedAt = Number(trigger.dataset.expandedAt); + if (!Number.isFinite(hoverStartedAt) || !Number.isFinite(expandedAt)) { + throw new Error("Community actions open timing was not recorded"); + } + return expandedAt - hoverStartedAt; + }); + expect(openDelayMs).toBeGreaterThanOrEqual(40); + expect(openDelayMs).toBeLessThan(300); + + const openTriggerBox = await communityTrigger.boundingBox(); + const menuBox = await menu.boundingBox(); + expect(openTriggerBox).not.toBeNull(); + expect(menuBox).not.toBeNull(); + if (!openTriggerBox || !menuBox) { + throw new Error("Community actions geometry unavailable"); + } + const triggerExitX = openTriggerBox.x + openTriggerBox.width - 1; + const triggerExitY = Math.min( + openTriggerBox.y + openTriggerBox.height - 4, + menuBox.y + menuBox.height - 4, + ); + await page.mouse.move(triggerExitX, triggerExitY); + await page.mouse.move(menuBox.x + 8, menuBox.y - 8); + await page.waitForTimeout(80); + await page.mouse.move(menuBox.x + 8, menuBox.y + 8); + const bridgeDurationMs = await communityTrigger.evaluate((trigger) => { + const leftAt = Number(trigger.dataset.leftAt); + if (!Number.isFinite(leftAt)) { + throw new Error( + "Community actions trigger exit timing was not recorded", + ); + } + return performance.now() - leftAt; + }); + expect(bridgeDurationMs).toBeGreaterThanOrEqual(60); + expect(bridgeDurationMs).toBeLessThan(140); + await page.waitForTimeout(180); await expect(menu).toBeVisible(); + await expect(communityTrigger).not.toHaveAttribute( + "data-closed-after-opening", + "true", + ); await expect( menu.getByRole("menuitem", { name: "Copy community URL" }), ).toBeVisible(); diff --git a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts index 9b0bb051fc9..d6cd05eb0f3 100644 --- a/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts +++ b/desktop/tests/e2e/composer-tooltip-dismiss.spec.ts @@ -12,8 +12,8 @@ test.beforeEach(async ({ page }) => { await installMockBridge(page); }); -/** Hover the trigger, then slide the cursor onto the tooltip popup and - * assert the tooltip dismisses instead of persisting. */ +/** Hover the trigger through the shared dwell, then slide the cursor onto the + * tooltip popup and assert the tooltip dismisses instead of persisting. */ async function expectTooltipDismissesOnLeave( page: import("@playwright/test").Page, trigger: import("@playwright/test").Locator, @@ -22,7 +22,9 @@ async function expectTooltipDismissesOnLeave( await trigger.hover(); const tip = page.getByRole("tooltip", { name: tooltipName }); - await expect(tip).toBeVisible(); + await page.waitForTimeout(400); + await expect(tip).toHaveCount(0); + await expect(tip).toBeVisible({ timeout: 1_000 }); // Slide off the trigger onto the tooltip popup. const box = await tip.boundingBox(); @@ -48,6 +50,26 @@ test("composer toolbar tooltip dismisses when cursor leaves the trigger", async ); }); +test("adjacent composer tooltips each require a fresh dwell", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("message-insert-mention").hover(); + await page.waitForTimeout(400); + const mentionTooltip = page.getByRole("tooltip", { name: "Mention someone" }); + await expect(mentionTooltip).toHaveCount(0); + await expect(mentionTooltip).toBeVisible({ timeout: 1_000 }); + + await page.getByRole("button", { name: "Attach file" }).hover(); + await page.waitForTimeout(400); + const attachTooltip = page.getByRole("tooltip", { name: "Attach file" }); + await expect(attachTooltip).toHaveCount(0); + await expect(attachTooltip).toBeVisible({ timeout: 1_000 }); +}); + test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger", async ({ page, }) => { @@ -60,6 +82,9 @@ test("formatting sub-toolbar tooltip dismisses when cursor leaves the trigger", const bold = page.getByRole("button", { name: "Bold" }); await expect(bold).toBeVisible(); + // The formatting strip animates into place; wait for its delayed entrance to + // settle so the pointer remains over the trigger for the full dwell. + await page.waitForTimeout(300); // Tooltip text is "