fix(app): gate single-source message hover behind an experiment (#2761)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-14 15:37:37 +02:00
1 parent 69c42cff90
commit 1281045648
30 files changed
+2107 -360

No files matched your search

@@ -2,12 +2,11 @@
import ContextMenu, {isContextMenuNodeTarget} from '@app/features/ui/state/ContextMenu';
import {autorun} from 'mobx';
import {type RefObject, useEffect, useState} from 'react';
import {type RefObject, useEffect, useRef, useState} from 'react';
interface ContextMenuHoverSubscriber {
readonly elementRef: RefObject<HTMLElement | null>;
readonly setContextMenuOpen: (contextMenuOpen: boolean) => void;
contextMenuOpen: boolean;
}
const contextMenuHoverSubscribers = new Set<ContextMenuHoverSubscriber>();
@@ -28,10 +27,7 @@ function syncContextMenuHoverSubscribers(): void {
const chain = resolveContextMenuTargetChain();
for (const subscriber of Array.from(contextMenuHoverSubscribers)) {
const element = subscriber.elementRef.current;
const contextMenuOpen = chain != null && element != null && chain.has(element);
if (subscriber.contextMenuOpen === contextMenuOpen) continue;
subscriber.contextMenuOpen = contextMenuOpen;
subscriber.setContextMenuOpen(contextMenuOpen);
subscriber.setContextMenuOpen(chain != null && element != null && chain.has(element));
}
}
@@ -52,12 +48,22 @@ function subscribeContextMenuHover(subscriber: ContextMenuHoverSubscriber): () =
export function useContextMenuHoverState(elementRef: RefObject<HTMLElement | null>, enabled: boolean = true): boolean {
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const publishedContextMenuOpenRef = useRef(false);
useEffect(() => {
const publishContextMenuOpen = (nextContextMenuOpen: boolean) => {
if (publishedContextMenuOpenRef.current === nextContextMenuOpen) return;
publishedContextMenuOpenRef.current = nextContextMenuOpen;
setContextMenuOpen(nextContextMenuOpen);
};
if (!enabled) {
setContextMenuOpen(false);
publishContextMenuOpen(false);
return;
}
return subscribeContextMenuHover({elementRef, setContextMenuOpen, contextMenuOpen: false});
const unsubscribe = subscribeContextMenuHover({elementRef, setContextMenuOpen: publishContextMenuOpen});
return () => {
unsubscribe();
publishContextMenuOpen(false);
};
}, [elementRef, enabled]);
return contextMenuOpen;
}
@@ -6,6 +6,7 @@ import {isMediaOnlyEmbed} from '@app/features/channel/components/embeds/EmbedRen
import {MessageActionBar, MessageActionBarCore} from '@app/features/channel/components/MessageActionBar';
import {MessageActionBottomSheet} from '@app/features/channel/components/MessageActionBottomSheet';
import {requestDeleteMessage} from '@app/features/channel/components/MessageActionUtils';
import {useMessageHoverState} from '@app/features/channel/components/MessageHoverState';
import {MessageViewContextProvider} from '@app/features/channel/components/MessageViewContext';
import type {Channel} from '@app/features/channel/models/Channel';
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
@@ -19,7 +20,6 @@ import {getMessageComponent} from '@app/features/messaging/utils/MessageComponen
import {renderAstToPlaintext} from '@app/features/messaging/utils/markdown/Plaintext';
import {NodeType} from '@app/features/messaging/utils/markdown/parser/Enums';
import {SystemMessageUtils} from '@app/features/messaging/utils/SystemMessageUtils';
import {subscribeWindowFocus} from '@app/features/platform/utils/WindowFocusBroadcast';
import * as ReadStateCommands from '@app/features/read_state/commands/ReadStateCommands';
import styles from '@app/features/theme/styles/Message.module.css';
import {MessageContextMenu} from '@app/features/ui/action_menu/MessageContextMenu';
@@ -38,7 +38,7 @@ import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
const ATTACHMENT_DESCRIPTOR = msg({
message: 'attachment',
@@ -164,149 +164,6 @@ const truncateAriaLabelText = (text: string): string => {
}
return `${normalized.slice(0, MAX_ARIA_MESSAGE_TEXT_LENGTH - 1).trimEnd()}...`;
};
const isPointInsideMessageTree = (messageElement: HTMLElement, point: {x: number; y: number}): boolean => {
const target = messageElement.ownerDocument.elementFromPoint(point.x, point.y);
return Boolean(target && messageElement.contains(target));
};
const HOVER_SCROLL_IDLE_MS = 150;
let lastPointerPosition: {x: number; y: number} | null = null;
let pointerPositionNotificationFrame: number | null = null;
let pointerPositionSubscriptionCount = 0;
let hoverInvalidationSubscriptionCount = 0;
let pointerHoverSuspendedByScroll = false;
let pointerHoverScrollIdleTimer: ReturnType<typeof setTimeout> | null = null;
const pointerPositionListeners = new Set<() => void>();
const hoverInvalidationListeners = new Set<() => void>();
const notifyPointerPositionListeners = (): void => {
for (const listener of Array.from(pointerPositionListeners)) {
listener();
}
};
const notifyHoverInvalidationListeners = (): void => {
for (const listener of Array.from(hoverInvalidationListeners)) {
listener();
}
};
const clearPointerHoverScrollIdleTimer = (): void => {
if (pointerHoverScrollIdleTimer == null) {
return;
}
clearTimeout(pointerHoverScrollIdleTimer);
pointerHoverScrollIdleTimer = null;
};
const resumePointerHoverAfterScroll = (): void => {
clearPointerHoverScrollIdleTimer();
if (!pointerHoverSuspendedByScroll) {
return;
}
pointerHoverSuspendedByScroll = false;
notifyHoverInvalidationListeners();
};
const suspendPointerHoverForScroll = (): void => {
clearPointerHoverScrollIdleTimer();
pointerHoverScrollIdleTimer = setTimeout(resumePointerHoverAfterScroll, HOVER_SCROLL_IDLE_MS);
if (pointerHoverSuspendedByScroll) {
return;
}
pointerHoverSuspendedByScroll = true;
if (pointerPositionNotificationFrame != null) {
cancelAnimationFrame(pointerPositionNotificationFrame);
pointerPositionNotificationFrame = null;
}
notifyHoverInvalidationListeners();
};
const schedulePointerPositionNotification = (): void => {
if (pointerPositionNotificationFrame != null) {
return;
}
pointerPositionNotificationFrame = requestAnimationFrame(() => {
pointerPositionNotificationFrame = null;
notifyPointerPositionListeners();
});
};
const updateLastPointerPosition = (event: PointerEvent | MouseEvent): void => {
if (lastPointerPosition?.x === event.clientX && lastPointerPosition.y === event.clientY) {
return;
}
lastPointerPosition = {x: event.clientX, y: event.clientY};
resumePointerHoverAfterScroll();
schedulePointerPositionNotification();
};
const clearLastPointerPosition = (): void => {
if (!lastPointerPosition) {
return;
}
lastPointerPosition = null;
schedulePointerPositionNotification();
};
const clearLastPointerPositionOnWindowBlur = (): void => {
clearLastPointerPosition();
notifyHoverInvalidationListeners();
};
const clearLastPointerPositionOnWindowExit = (event: PointerEvent | MouseEvent): void => {
if (event.relatedTarget == null) {
clearLastPointerPosition();
}
};
const supportsPointerPositionEvents = (): boolean => 'PointerEvent' in window;
const subscribePointerPosition = (listener: () => void): (() => void) => {
if (pointerPositionSubscriptionCount === 0) {
if (supportsPointerPositionEvents()) {
window.addEventListener('pointermove', updateLastPointerPosition, true);
window.addEventListener('pointerdown', updateLastPointerPosition, true);
window.addEventListener('pointerout', clearLastPointerPositionOnWindowExit, true);
} else {
window.addEventListener('mousemove', updateLastPointerPosition, true);
window.addEventListener('mousedown', updateLastPointerPosition, true);
window.addEventListener('mouseout', clearLastPointerPositionOnWindowExit, true);
}
}
pointerPositionSubscriptionCount += 1;
pointerPositionListeners.add(listener);
return () => {
pointerPositionListeners.delete(listener);
pointerPositionSubscriptionCount = Math.max(0, pointerPositionSubscriptionCount - 1);
if (pointerPositionSubscriptionCount !== 0) {
return;
}
lastPointerPosition = null;
if (pointerPositionNotificationFrame != null) {
cancelAnimationFrame(pointerPositionNotificationFrame);
pointerPositionNotificationFrame = null;
}
if (supportsPointerPositionEvents()) {
window.removeEventListener('pointermove', updateLastPointerPosition, true);
window.removeEventListener('pointerdown', updateLastPointerPosition, true);
window.removeEventListener('pointerout', clearLastPointerPositionOnWindowExit, true);
} else {
window.removeEventListener('mousemove', updateLastPointerPosition, true);
window.removeEventListener('mousedown', updateLastPointerPosition, true);
window.removeEventListener('mouseout', clearLastPointerPositionOnWindowExit, true);
}
};
};
const subscribeMessageHoverInvalidation = (listener: () => void): (() => void) => {
if (hoverInvalidationSubscriptionCount === 0) {
window.addEventListener('scroll', suspendPointerHoverForScroll, true);
window.addEventListener('resize', notifyHoverInvalidationListeners);
window.addEventListener('blur', clearLastPointerPositionOnWindowBlur);
}
hoverInvalidationSubscriptionCount += 1;
hoverInvalidationListeners.add(listener);
return () => {
hoverInvalidationListeners.delete(listener);
hoverInvalidationSubscriptionCount = Math.max(0, hoverInvalidationSubscriptionCount - 1);
if (hoverInvalidationSubscriptionCount !== 0) {
return;
}
clearPointerHoverScrollIdleTimer();
pointerHoverSuspendedByScroll = false;
window.removeEventListener('scroll', suspendPointerHoverForScroll, true);
window.removeEventListener('resize', notifyHoverInvalidationListeners);
window.removeEventListener('blur', clearLastPointerPositionOnWindowBlur);
};
};
export type MessageBehaviorOverrides = Partial<{
mobileLayoutEnabled: boolean;
messageGroupSpacing: number;
@@ -362,9 +219,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
const {i18n} = useLingui();
const [showActionBar, setShowActionBar] = useState(false);
const [isLongPressing, setIsLongPressing] = useState(false);
const [isHoveringDesktop, setIsHoveringDesktop] = useState(false);
const [isFocusedWithin, setIsFocusedWithin] = useState(false);
const [isPopoutOpen, setIsPopoutOpen] = useState(false);
const [mobileLongPressLinkUrl, setMobileLongPressLinkUrl] = useState<string | undefined>(undefined);
const messageRef = useRef<HTMLDivElement | null>(null);
const disableContextMenuTracking = behaviorOverrides?.disableContextMenuTracking ?? false;
@@ -503,15 +358,6 @@ export const Message: React.FC<MessageProps> = observer((props) => {
const velocitySamples = useRef<Array<{x: number; y: number; timestamp: number}>>([]);
const highlightTimerRef = useRef<NodeJS.Timeout | null>(null);
const suppressClickUntilRef = useRef(0);
const popoutCloseRafRef = useRef<number | null>(null);
const isHoveringDesktopRef = useRef(false);
const setDesktopHoverState = useCallback((isHovered: boolean) => {
if (isHoveringDesktopRef.current === isHovered) {
return;
}
isHoveringDesktopRef.current = isHovered;
setIsHoveringDesktop(isHovered);
}, []);
const unsubscribeLongPressScrollCancel = useCallback(() => {
unsubscribeLongPressScrollCancelRef.current?.();
unsubscribeLongPressScrollCancelRef.current = null;
@@ -631,50 +477,12 @@ export const Message: React.FC<MessageProps> = observer((props) => {
setMobileLongPressLinkUrl(undefined);
}, []);
const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
const isPointerInsideMessage = useCallback((): boolean => {
if (mobileLayoutEnabled) {
return false;
}
if (pointerHoverSuspendedByScroll) {
return false;
}
const element = messageRef.current;
if (!element) {
return false;
}
if (!lastPointerPosition) {
return element.matches(':hover');
}
return isPointInsideMessageTree(element, lastPointerPosition);
}, [mobileLayoutEnabled]);
const syncPointerHoverState = useCallback((): boolean => {
const isHovered = isPointerInsideMessage();
setDesktopHoverState(isHovered);
return isHovered;
}, [isPointerInsideMessage, setDesktopHoverState]);
const cancelScheduledPopoutClose = useCallback(() => {
if (popoutCloseRafRef.current == null) {
return;
}
cancelAnimationFrame(popoutCloseRafRef.current);
popoutCloseRafRef.current = null;
}, []);
const handleMessagePopoutToggle = useCallback(
(isOpen: boolean) => {
if (isOpen) {
cancelScheduledPopoutClose();
setIsPopoutOpen(true);
return;
}
cancelScheduledPopoutClose();
popoutCloseRafRef.current = requestAnimationFrame(() => {
popoutCloseRafRef.current = null;
syncPointerHoverState();
setIsPopoutOpen(false);
});
},
[cancelScheduledPopoutClose, syncPointerHoverState],
);
const {
isHovering,
isPopoutOpen,
handlePopoutToggle,
trackingEnabled: hoverTrackingEnabled,
} = useMessageHoverState({messageRef, mobileLayoutEnabled, keyboardModeEnabled, contextMenuOpen});
const handleFocusWithin = useCallback(() => {
if (!keyboardModeEnabled) {
return;
@@ -693,58 +501,6 @@ export const Message: React.FC<MessageProps> = observer((props) => {
},
[channel.id, message.id],
);
useEffect(() => {
if (mobileLayoutEnabled || !messageRef.current) return;
const element = messageRef.current;
const handleMouseEnter = (event: MouseEvent) => {
updateLastPointerPosition(event);
if (pointerHoverSuspendedByScroll) {
return;
}
setDesktopHoverState(true);
};
const handleMouseLeave = (event: MouseEvent) => {
updateLastPointerPosition(event);
setDesktopHoverState(false);
};
element.addEventListener('mouseenter', handleMouseEnter);
element.addEventListener('mouseleave', handleMouseLeave);
const unsubscribeFocus = subscribeWindowFocus(syncPointerHoverState);
const unsubscribeHoverInvalidation = subscribeMessageHoverInvalidation(syncPointerHoverState);
const rafId = requestAnimationFrame(syncPointerHoverState);
return () => {
cancelAnimationFrame(rafId);
element.removeEventListener('mouseenter', handleMouseEnter);
element.removeEventListener('mouseleave', handleMouseLeave);
unsubscribeFocus();
unsubscribeHoverInvalidation();
};
}, [mobileLayoutEnabled, keyboardModeEnabled, syncPointerHoverState]);
const shouldTrackActivePointer = !mobileLayoutEnabled && (isHoveringDesktop || isPopoutOpen || contextMenuOpen);
useEffect(() => {
if (!shouldTrackActivePointer) {
return;
}
const rafId = requestAnimationFrame(syncPointerHoverState);
const unsubscribePointerPosition = subscribePointerPosition(syncPointerHoverState);
return () => {
cancelAnimationFrame(rafId);
unsubscribePointerPosition();
};
}, [shouldTrackActivePointer, syncPointerHoverState]);
const wasContextMenuOpenRef = useRef(false);
useLayoutEffect(() => {
const wasOpen = wasContextMenuOpenRef.current;
wasContextMenuOpenRef.current = contextMenuOpen;
if (wasOpen && !contextMenuOpen) {
syncPointerHoverState();
}
}, [contextMenuOpen, syncPointerHoverState]);
useEffect(() => {
return () => {
cancelScheduledPopoutClose();
};
}, [cancelScheduledPopoutClose]);
useEffect(() => {
if (!keyboardModeEnabled) return;
if (contextMenuOpen) {
@@ -775,7 +531,6 @@ export const Message: React.FC<MessageProps> = observer((props) => {
}
};
}, [unsubscribeLongPressScrollCancel]);
const isHovering = mobileLayoutEnabled ? false : isHoveringDesktop;
useEffect(() => {
if (!keyboardModeEnabled) {
setIsFocusedWithin(false);
@@ -810,7 +565,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
shouldRenderSuppressEmbeds: false,
}
: undefined,
onPopoutToggle: handleMessagePopoutToggle,
onPopoutToggle: handlePopoutToggle,
suppressMessageActions,
onHeadingActivate,
}),
@@ -824,7 +579,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
previewContext,
previewOverrides,
previewMode,
handleMessagePopoutToggle,
handlePopoutToggle,
suppressMessageActions,
onHeadingActivate,
],
@@ -841,7 +596,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
astNodes.length === 1 &&
astNodes[0].type === NodeType.Link &&
!message.suppressEmbeds;
const shouldDisableHoverBackground = prefersReducedMotion && !isEditing;
const shouldDisableHoverBackground = !hoverTrackingEnabled && prefersReducedMotion && !isEditing;
const isKeyboardFocused = keyboardModeEnabled && isFocusedWithin;
const isPreview = previewContext != null;
const shouldApplySpacing = !shouldGroup && !removeTopSpacing && previewContext !== MessagePreviewContext.LIST_POPOUT;
@@ -961,6 +716,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
data-flx-compact={messageDisplayCompact ? 'true' : undefined}
data-flx-grouped={shouldGroup && shouldApplyGroupedLayout(message, prevMessage) ? 'true' : undefined}
data-flx-action-bar={shouldShowActionBar ? 'true' : undefined}
data-flx-action-bar-active={shouldShowActionBar && isActionBarActive ? 'true' : undefined}
data-flx-action-bar-forced={shouldShowActionBar && isActionBarForcedVisible ? 'true' : undefined}
tabIndex={keyboardModeEnabled ? -1 : undefined}
className={messageClasses}
@@ -996,7 +752,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
}}
developerMode={false}
isActive={isActionBarActive}
onPopoutToggle={handleMessagePopoutToggle}
onPopoutToggle={handlePopoutToggle}
data-flx="channel.message.message-action-bar-core"
/>
) : (
@@ -1005,7 +761,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
handleDelete={handleDelete}
sourceChannel={channel}
isActive={isActionBarActive}
onPopoutToggle={handleMessagePopoutToggle}
onPopoutToggle={handlePopoutToggle}
data-flx="channel.message.message-action-bar"
/>
))}
@@ -11,28 +11,50 @@
pointer-events: none;
}
:global([data-flx-action-bar]:hover) .actionBarContainer,
:global([data-flx-action-bar]) .actionBarContainer:has(:focus-visible) {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
:global(html:not(.experiment-message-hover-tracking) [data-flx-action-bar]:hover) .actionBarContainer {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
@media (pointer: coarse) {
:global([data-flx-action-bar]:not([data-flx-action-bar-forced]):hover) .actionBarContainer {
:global(html:not(.experiment-message-hover-tracking) [data-flx-action-bar]:not([data-flx-action-bar-forced]):hover)
.actionBarContainer {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
}
:global([data-flx-action-bar][data-flx-action-bar-forced]) .actionBarContainer,
.actionBarContainer.actionBarPinned {
:global(html:not(.experiment-message-hover-tracking) [data-flx-action-bar][data-flx-action-bar-forced])
.actionBarContainer,
:global(html:not(.experiment-message-hover-tracking)) .actionBarContainer.actionBarPinned {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
:global(html.experiment-message-hover-tracking [data-flx-action-bar][data-flx-action-bar-active]) .actionBarContainer {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
@media (pointer: coarse) {
:global(html.experiment-message-hover-tracking [data-flx-action-bar]:not([data-flx-action-bar-forced]))
.actionBarContainer:not(:has(:focus-visible)) {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
}
:global(html:not(.window-focused):not(.unfocused-fully-interactive)) .actionBarContainer,
:global(.window-focus-activation-guard) .actionBarContainer {
opacity: 0 !important;
@@ -0,0 +1,317 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
registerMessageHoverTarget,
resolveMessageHoverTargetsNow,
} from '@app/features/channel/components/MessageHoverTracking';
import MessageHoverTrackingRollout from '@app/features/channel/state/MessageHoverTrackingRollout';
import {subscribeWindowFocus} from '@app/features/platform/utils/WindowFocusBroadcast';
import type React from 'react';
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
const isPointInsideMessageTree = (messageElement: HTMLElement, point: {x: number; y: number}): boolean => {
const target = messageElement.ownerDocument.elementFromPoint(point.x, point.y);
return Boolean(target && messageElement.contains(target));
};
const HOVER_SCROLL_IDLE_MS = 150;
let lastPointerPosition: {x: number; y: number} | null = null;
let pointerPositionNotificationFrame: number | null = null;
let pointerPositionSubscriptionCount = 0;
let hoverInvalidationSubscriptionCount = 0;
let pointerHoverSuspendedByScroll = false;
let pointerHoverScrollIdleTimer: ReturnType<typeof setTimeout> | null = null;
const pointerPositionListeners = new Set<() => void>();
const hoverInvalidationListeners = new Set<() => void>();
const notifyPointerPositionListeners = (): void => {
for (const listener of Array.from(pointerPositionListeners)) {
listener();
}
};
const notifyHoverInvalidationListeners = (): void => {
for (const listener of Array.from(hoverInvalidationListeners)) {
listener();
}
};
const clearPointerHoverScrollIdleTimer = (): void => {
if (pointerHoverScrollIdleTimer == null) {
return;
}
clearTimeout(pointerHoverScrollIdleTimer);
pointerHoverScrollIdleTimer = null;
};
const resumePointerHoverAfterScroll = (): void => {
clearPointerHoverScrollIdleTimer();
if (!pointerHoverSuspendedByScroll) {
return;
}
pointerHoverSuspendedByScroll = false;
notifyHoverInvalidationListeners();
};
const suspendPointerHoverForScroll = (): void => {
clearPointerHoverScrollIdleTimer();
pointerHoverScrollIdleTimer = setTimeout(resumePointerHoverAfterScroll, HOVER_SCROLL_IDLE_MS);
if (pointerHoverSuspendedByScroll) {
return;
}
pointerHoverSuspendedByScroll = true;
if (pointerPositionNotificationFrame != null) {
cancelAnimationFrame(pointerPositionNotificationFrame);
pointerPositionNotificationFrame = null;
}
notifyHoverInvalidationListeners();
};
const schedulePointerPositionNotification = (): void => {
if (pointerPositionNotificationFrame != null) {
return;
}
pointerPositionNotificationFrame = requestAnimationFrame(() => {
pointerPositionNotificationFrame = null;
notifyPointerPositionListeners();
});
};
const updateLastPointerPosition = (event: PointerEvent | MouseEvent): void => {
if (lastPointerPosition?.x === event.clientX && lastPointerPosition.y === event.clientY) {
return;
}
lastPointerPosition = {x: event.clientX, y: event.clientY};
resumePointerHoverAfterScroll();
schedulePointerPositionNotification();
};
const clearLastPointerPosition = (): void => {
if (!lastPointerPosition) {
return;
}
lastPointerPosition = null;
schedulePointerPositionNotification();
};
const clearLastPointerPositionOnWindowBlur = (): void => {
clearLastPointerPosition();
notifyHoverInvalidationListeners();
};
const clearLastPointerPositionOnWindowExit = (event: PointerEvent | MouseEvent): void => {
if (event.relatedTarget == null) {
clearLastPointerPosition();
}
};
const supportsPointerPositionEvents = (): boolean => 'PointerEvent' in window;
const subscribePointerPosition = (listener: () => void): (() => void) => {
if (pointerPositionSubscriptionCount === 0) {
if (supportsPointerPositionEvents()) {
window.addEventListener('pointermove', updateLastPointerPosition, true);
window.addEventListener('pointerdown', updateLastPointerPosition, true);
window.addEventListener('pointerout', clearLastPointerPositionOnWindowExit, true);
} else {
window.addEventListener('mousemove', updateLastPointerPosition, true);
window.addEventListener('mousedown', updateLastPointerPosition, true);
window.addEventListener('mouseout', clearLastPointerPositionOnWindowExit, true);
}
}
pointerPositionSubscriptionCount += 1;
pointerPositionListeners.add(listener);
return () => {
pointerPositionListeners.delete(listener);
pointerPositionSubscriptionCount = Math.max(0, pointerPositionSubscriptionCount - 1);
if (pointerPositionSubscriptionCount !== 0) {
return;
}
lastPointerPosition = null;
if (pointerPositionNotificationFrame != null) {
cancelAnimationFrame(pointerPositionNotificationFrame);
pointerPositionNotificationFrame = null;
}
if (supportsPointerPositionEvents()) {
window.removeEventListener('pointermove', updateLastPointerPosition, true);
window.removeEventListener('pointerdown', updateLastPointerPosition, true);
window.removeEventListener('pointerout', clearLastPointerPositionOnWindowExit, true);
} else {
window.removeEventListener('mousemove', updateLastPointerPosition, true);
window.removeEventListener('mousedown', updateLastPointerPosition, true);
window.removeEventListener('mouseout', clearLastPointerPositionOnWindowExit, true);
}
};
};
const subscribeMessageHoverInvalidation = (listener: () => void): (() => void) => {
if (hoverInvalidationSubscriptionCount === 0) {
window.addEventListener('scroll', suspendPointerHoverForScroll, true);
window.addEventListener('resize', notifyHoverInvalidationListeners);
window.addEventListener('blur', clearLastPointerPositionOnWindowBlur);
}
hoverInvalidationSubscriptionCount += 1;
hoverInvalidationListeners.add(listener);
return () => {
hoverInvalidationListeners.delete(listener);
hoverInvalidationSubscriptionCount = Math.max(0, hoverInvalidationSubscriptionCount - 1);
if (hoverInvalidationSubscriptionCount !== 0) {
return;
}
clearPointerHoverScrollIdleTimer();
pointerHoverSuspendedByScroll = false;
window.removeEventListener('scroll', suspendPointerHoverForScroll, true);
window.removeEventListener('resize', notifyHoverInvalidationListeners);
window.removeEventListener('blur', clearLastPointerPositionOnWindowBlur);
};
};
interface UseMessageHoverStateParams {
messageRef: React.RefObject<HTMLDivElement | null>;
mobileLayoutEnabled: boolean;
keyboardModeEnabled: boolean;
contextMenuOpen: boolean;
}
export interface MessageHoverState {
isHovering: boolean;
isPopoutOpen: boolean;
handlePopoutToggle: (isOpen: boolean) => void;
trackingEnabled: boolean;
}
export function useMessageHoverState({
messageRef,
mobileLayoutEnabled,
keyboardModeEnabled,
contextMenuOpen,
}: UseMessageHoverStateParams): MessageHoverState {
const trackingEnabled = MessageHoverTrackingRollout.enabled;
const [isHoveringDesktop, setIsHoveringDesktop] = useState(false);
const [isPopoutOpen, setIsPopoutOpen] = useState(false);
const isHoveringDesktopRef = useRef(false);
const popoutCloseRafRef = useRef<number | null>(null);
const setDesktopHoverState = useCallback((isHovered: boolean) => {
if (isHoveringDesktopRef.current === isHovered) {
return;
}
isHoveringDesktopRef.current = isHovered;
setIsHoveringDesktop(isHovered);
}, []);
const isPointerInsideMessage = useCallback((): boolean => {
if (mobileLayoutEnabled) {
return false;
}
if (pointerHoverSuspendedByScroll) {
return false;
}
const element = messageRef.current;
if (!element) {
return false;
}
if (!lastPointerPosition) {
return element.matches(':hover');
}
return isPointInsideMessageTree(element, lastPointerPosition);
}, [mobileLayoutEnabled, messageRef]);
const syncPointerHoverState = useCallback((): boolean => {
const isHovered = isPointerInsideMessage();
setDesktopHoverState(isHovered);
return isHovered;
}, [isPointerInsideMessage, setDesktopHoverState]);
const cancelScheduledPopoutClose = useCallback(() => {
if (popoutCloseRafRef.current == null) {
return;
}
cancelAnimationFrame(popoutCloseRafRef.current);
popoutCloseRafRef.current = null;
}, []);
const handlePopoutToggle = useCallback(
(isOpen: boolean) => {
if (trackingEnabled) {
setIsPopoutOpen(isOpen);
return;
}
if (isOpen) {
cancelScheduledPopoutClose();
setIsPopoutOpen(true);
return;
}
cancelScheduledPopoutClose();
popoutCloseRafRef.current = requestAnimationFrame(() => {
popoutCloseRafRef.current = null;
syncPointerHoverState();
setIsPopoutOpen(false);
});
},
[cancelScheduledPopoutClose, syncPointerHoverState, trackingEnabled],
);
useEffect(() => {
if (!trackingEnabled || mobileLayoutEnabled) {
return;
}
const element = messageRef.current;
if (element == null) {
return;
}
return registerMessageHoverTarget(element, setDesktopHoverState);
}, [trackingEnabled, mobileLayoutEnabled, messageRef, setDesktopHoverState]);
useEffect(() => {
if (trackingEnabled || mobileLayoutEnabled || !messageRef.current) return;
const element = messageRef.current;
const handleMouseEnter = (event: MouseEvent) => {
updateLastPointerPosition(event);
if (pointerHoverSuspendedByScroll) {
return;
}
setDesktopHoverState(true);
};
const handleMouseLeave = (event: MouseEvent) => {
updateLastPointerPosition(event);
setDesktopHoverState(false);
};
element.addEventListener('mouseenter', handleMouseEnter);
element.addEventListener('mouseleave', handleMouseLeave);
const unsubscribeFocus = subscribeWindowFocus(syncPointerHoverState);
const unsubscribeHoverInvalidation = subscribeMessageHoverInvalidation(syncPointerHoverState);
const rafId = requestAnimationFrame(syncPointerHoverState);
return () => {
cancelAnimationFrame(rafId);
element.removeEventListener('mouseenter', handleMouseEnter);
element.removeEventListener('mouseleave', handleMouseLeave);
unsubscribeFocus();
unsubscribeHoverInvalidation();
};
}, [
trackingEnabled,
mobileLayoutEnabled,
keyboardModeEnabled,
messageRef,
setDesktopHoverState,
syncPointerHoverState,
]);
const shouldTrackActivePointer =
!trackingEnabled && !mobileLayoutEnabled && (isHoveringDesktop || isPopoutOpen || contextMenuOpen);
useEffect(() => {
if (!shouldTrackActivePointer) {
return;
}
const rafId = requestAnimationFrame(syncPointerHoverState);
const unsubscribePointerPosition = subscribePointerPosition(syncPointerHoverState);
return () => {
cancelAnimationFrame(rafId);
unsubscribePointerPosition();
};
}, [shouldTrackActivePointer, syncPointerHoverState]);
const isOverlayOpen = trackingEnabled ? contextMenuOpen || isPopoutOpen : contextMenuOpen;
const wasOverlayOpenRef = useRef(false);
useLayoutEffect(() => {
const wasOpen = wasOverlayOpenRef.current;
wasOverlayOpenRef.current = isOverlayOpen;
if (!wasOpen || isOverlayOpen) {
return;
}
if (trackingEnabled) {
resolveMessageHoverTargetsNow();
return;
}
syncPointerHoverState();
}, [isOverlayOpen, syncPointerHoverState, trackingEnabled]);
useEffect(() => {
return () => {
cancelScheduledPopoutClose();
};
}, [cancelScheduledPopoutClose]);
return {
isHovering: mobileLayoutEnabled ? false : isHoveringDesktop,
isPopoutOpen,
handlePopoutToggle,
trackingEnabled,
};
}
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import {describe, expect, it} from 'vitest';
interface CssRule {
readonly selector: string;
readonly body: string;
}
function readSource(relativePath: string): string {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8');
}
function parseCssRules(css: string): Array<CssRule> {
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
const rules: Array<CssRule> = [];
for (const match of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
rules.push({selector: match[1].replace(/\s+/g, ''), body: match[2].trim()});
}
return rules;
}
const EXPERIMENT_CLASS = 'experiment-message-hover-tracking';
const TREATMENT_GATE = `html.${EXPERIMENT_CLASS}`;
const CONTROL_GATE = `:not(.${EXPERIMENT_CLASS})`;
const actionBarCss = readSource('./MessageActionBar.module.css');
const messageCss = readSource('../../theme/styles/Message.module.css');
const actionBarSource = readSource('./MessageActionBar.tsx');
const channelMessageSource = readSource('./ChannelMessage.tsx');
const hoverStateSource = readSource('./MessageHoverState.ts');
const rolloutSource = readSource('../state/MessageHoverTrackingRollout.ts');
const appSource = readSource('../../../app/App.tsx');
const actionBarContainerRules = parseCssRules(actionBarCss).filter((rule) =>
rule.selector.includes('.actionBarContainer'),
);
const revealingRules = actionBarContainerRules.filter((rule) => /visibility:\s*visible/.test(rule.body));
const treatmentRevealRules = revealingRules.filter((rule) => rule.selector.includes(TREATMENT_GATE));
const controlRevealRules = revealingRules.filter((rule) => rule.selector.includes(CONTROL_GATE));
describe('message hover style contract', () => {
it('reveals the action bar from the tracked hover state in the experiment arm', () => {
expect(treatmentRevealRules.length).toBeGreaterThan(0);
for (const rule of treatmentRevealRules) {
expect(rule.selector).not.toMatch(/:hover/);
expect(rule.selector).toMatch(/data-flx-action-bar-active/);
}
});
it('keeps the legacy :hover reveal behind the control arm', () => {
expect(controlRevealRules.length).toBeGreaterThan(0);
expect(controlRevealRules.some((rule) => rule.selector.includes(':hover'))).toBe(true);
for (const rule of controlRevealRules) {
expect(rule.selector).not.toMatch(/data-flx-action-bar-active/);
}
});
it('gives every action bar reveal rule exactly one arm', () => {
for (const rule of revealingRules) {
if (rule.selector.includes(':focus-visible')) continue;
const treatment = rule.selector.includes(TREATMENT_GATE);
const control = rule.selector.includes(CONTROL_GATE);
expect(treatment || control).toBe(true);
expect(treatment && control).toBe(false);
}
});
it('never reveals the action bar from the message stylesheet in the experiment arm', () => {
const buttonRevealRules = parseCssRules(messageCss).filter(
(rule) => /\.buttons\b/.test(rule.selector) && /opacity:\s*1/.test(rule.body),
);
expect(buttonRevealRules.length).toBeGreaterThan(0);
for (const rule of buttonRevealRules) {
expect(rule.selector).toContain(CONTROL_GATE);
}
});
it('derives the row highlight and the action bar from the same hover state', () => {
const highlightSource = channelMessageSource.match(/(\w+) && !isPreview && styles\.messageHovered/)?.[1];
const actionBarSourceState = channelMessageSource.match(/const isActionBarActive = (\w+) \|\|/)?.[1];
expect(highlightSource).toBeDefined();
expect(actionBarSourceState).toBe(highlightSource);
expect(channelMessageSource).toMatch(
/data-flx-action-bar-active=\{shouldShowActionBar && isActionBarActive \? 'true' : undefined\}/,
);
});
it('resolves the tracked hover state without the retained :hover chain', () => {
expect(readSource('./MessageHoverTracking.ts')).not.toMatch(/matches\(':hover'\)/);
const legacyOracle = hoverStateSource.match(/const isPointerInsideMessage[\s\S]*?\n\t\};/)?.[0];
expect(legacyOracle).toBeDefined();
expect(legacyOracle).toMatch(/matches\(':hover'\)/);
expect(hoverStateSource).toMatch(/if \(!trackingEnabled \|\| mobileLayoutEnabled\) \{/);
expect(hoverStateSource).toMatch(
/if \(trackingEnabled \|\| mobileLayoutEnabled \|\| !messageRef\.current\) return;/,
);
});
it('drives the stylesheet arm from the rollout assignment', () => {
expect(rolloutSource).toContain(`'${EXPERIMENT_CLASS}'`);
expect(actionBarCss).toContain(EXPERIMENT_CLASS);
expect(messageCss).toContain(EXPERIMENT_CLASS);
expect(appSource).toMatch(
/useDocumentClassToggle\(MESSAGE_HOVER_TRACKING_EXPERIMENT_CLASS, MessageHoverTrackingRollout\.enabled\)/,
);
});
it('keeps the control arm reachable from the action bar component', () => {
expect(actionBarSource).toMatch(/messageStyles\.buttons/);
expect(actionBarSource).toMatch(/styles\.actionBarPinned/);
});
});
@@ -0,0 +1,309 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
refreshMessageHoverTargets,
registerMessageHoverTarget,
resetMessageHoverTrackingForTests,
} from '@app/features/channel/components/MessageHoverTracking';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
let hitTarget: Element | null = null;
let elementFromPointSpy: ReturnType<typeof vi.spyOn>;
let matchesSpy: ReturnType<typeof vi.spyOn>;
function nextFrame(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => resolve());
});
}
function createRow(): {row: HTMLElement; content: HTMLElement} {
const row = document.createElement('div');
const content = document.createElement('span');
row.append(content);
document.body.append(row);
return {row, content};
}
function movePointerTo(target: Element | null, x = 10, y = 10): void {
hitTarget = target;
window.dispatchEvent(new MouseEvent('pointermove', {clientX: x, clientY: y, bubbles: true}));
}
function scrollTo(target: Element | null): void {
hitTarget = target;
window.dispatchEvent(new Event('scroll'));
}
beforeEach(() => {
hitTarget = null;
elementFromPointSpy = vi.spyOn(document, 'elementFromPoint').mockImplementation(() => hitTarget);
matchesSpy = vi.spyOn(Element.prototype, 'matches');
});
afterEach(() => {
vi.useRealTimers();
resetMessageHoverTrackingForTests();
elementFromPointSpy.mockRestore();
matchesSpy.mockRestore();
document.body.replaceChildren();
});
describe('MessageHoverTracking', () => {
it('arms the row that owns the element under the pointer', async () => {
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
await nextFrame();
expect(listener).not.toHaveBeenCalled();
movePointerTo(content);
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
});
it('keeps at most one row armed when the pointer moves between adjacent rows', async () => {
const first = createRow();
const second = createRow();
const firstListener = vi.fn();
const secondListener = vi.fn();
registerMessageHoverTarget(first.row, firstListener);
registerMessageHoverTarget(second.row, secondListener);
movePointerTo(first.content);
await nextFrame();
expect(firstListener).toHaveBeenLastCalledWith(true);
expect(secondListener).not.toHaveBeenCalled();
movePointerTo(second.content);
await nextFrame();
expect(firstListener).toHaveBeenLastCalledWith(false);
expect(secondListener).toHaveBeenLastCalledWith(true);
});
it('does not re-arm a row on window refocus when the pointer left during the blur', async () => {
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
movePointerTo(content);
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
window.dispatchEvent(new Event('blur'));
expect(listener).toHaveBeenLastCalledWith(false);
window.dispatchEvent(new Event('focus'));
refreshMessageHoverTargets();
await nextFrame();
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(false);
expect(listener.mock.calls.filter(([isHovered]) => isHovered === true)).toHaveLength(1);
});
it('never consults the retained :hover chain as a hover oracle', async () => {
const {row, content} = createRow();
registerMessageHoverTarget(row, vi.fn());
movePointerTo(content);
await nextFrame();
window.dispatchEvent(new Event('blur'));
window.dispatchEvent(new Event('focus'));
refreshMessageHoverTargets();
await nextFrame();
expect(matchesSpy.mock.calls.flat()).not.toContain(':hover');
});
it('disarms and does not re-arm while rows scroll under a stationary pointer', async () => {
const first = createRow();
const second = createRow();
const firstListener = vi.fn();
const secondListener = vi.fn();
registerMessageHoverTarget(first.row, firstListener);
registerMessageHoverTarget(second.row, secondListener);
movePointerTo(first.content);
await nextFrame();
expect(firstListener).toHaveBeenLastCalledWith(true);
scrollTo(second.content);
await nextFrame();
expect(firstListener).toHaveBeenLastCalledWith(false);
expect(secondListener).not.toHaveBeenCalled();
});
it('arms the row under the pointer once scrolling settles', async () => {
vi.useFakeTimers();
const first = createRow();
const second = createRow();
const firstListener = vi.fn();
const secondListener = vi.fn();
registerMessageHoverTarget(first.row, firstListener);
registerMessageHoverTarget(second.row, secondListener);
hitTarget = first.content;
window.dispatchEvent(new MouseEvent('pointermove', {clientX: 10, clientY: 10, bubbles: true}));
await vi.advanceTimersByTimeAsync(20);
expect(firstListener).toHaveBeenLastCalledWith(true);
scrollTo(second.content);
await vi.advanceTimersByTimeAsync(20);
expect(secondListener).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(200);
expect(secondListener).toHaveBeenLastCalledWith(true);
});
it('stays suspended for as long as momentum keeps firing scroll events', async () => {
vi.useFakeTimers();
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
hitTarget = content;
window.dispatchEvent(new MouseEvent('pointermove', {clientX: 10, clientY: 10, bubbles: true}));
await vi.advanceTimersByTimeAsync(20);
expect(listener).toHaveBeenLastCalledWith(true);
scrollTo(content);
await vi.advanceTimersByTimeAsync(20);
expect(listener).toHaveBeenLastCalledWith(false);
for (let i = 0; i < 6; i++) {
await vi.advanceTimersByTimeAsync(100);
scrollTo(content);
}
expect(listener).toHaveBeenLastCalledWith(false);
await vi.advanceTimersByTimeAsync(200);
expect(listener).toHaveBeenLastCalledWith(true);
});
it('re-arms immediately when the pointer actually moves during a scroll', async () => {
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
scrollTo(content);
await nextFrame();
expect(listener).not.toHaveBeenCalledWith(true);
movePointerTo(content);
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
});
it('does not let a scroll-driven pointerover re-arm a row', async () => {
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
scrollTo(content);
await nextFrame();
hitTarget = content;
window.dispatchEvent(new MouseEvent('pointerover', {clientX: 10, clientY: 10, bubbles: true}));
await nextFrame();
expect(listener).not.toHaveBeenCalledWith(true);
});
it('re-arms the row underneath after an overlay that covered it is dismissed', async () => {
const {row, content} = createRow();
const overlay = document.createElement('div');
document.body.append(overlay);
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
movePointerTo(content);
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
hitTarget = overlay;
refreshMessageHoverTargets();
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(false);
overlay.remove();
hitTarget = content;
refreshMessageHoverTargets();
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
});
it('does not treat a touch contact as hover', async () => {
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
hitTarget = content;
window.dispatchEvent(new PointerEvent('pointerdown', {clientX: 10, clientY: 10, pointerType: 'touch'}));
await nextFrame();
expect(listener).not.toHaveBeenCalledWith(true);
});
it('disarms when the pointer leaves the window', async () => {
const {row, content} = createRow();
const listener = vi.fn();
registerMessageHoverTarget(row, listener);
movePointerTo(content);
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
window.dispatchEvent(new MouseEvent('pointerout', {relatedTarget: null}));
expect(listener).toHaveBeenLastCalledWith(false);
});
it('releases ownership when an armed row unregisters', async () => {
const {row, content} = createRow();
const listener = vi.fn();
const unregister = registerMessageHoverTarget(row, listener);
const other = createRow();
const otherListener = vi.fn();
registerMessageHoverTarget(other.row, otherListener);
movePointerTo(content);
await nextFrame();
expect(listener).toHaveBeenLastCalledWith(true);
unregister();
hitTarget = other.content;
await nextFrame();
expect(otherListener).toHaveBeenLastCalledWith(true);
expect(listener).not.toHaveBeenLastCalledWith(true);
});
it('detaches every global listener once the last row unregisters', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const {row} = createRow();
const unregister = registerMessageHoverTarget(row, vi.fn());
const added = addEventListenerSpy.mock.calls.map(([type, listener]) => ({type, listener}));
unregister();
const removed = removeEventListenerSpy.mock.calls.map(([type, listener]) => ({type, listener}));
expect(added.length).toBeGreaterThan(0);
for (const addedListener of added) {
expect(
removed.some(
(removedListener) =>
removedListener.type === addedListener.type && removedListener.listener === addedListener.listener,
),
).toBe(true);
}
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});
});
@@ -0,0 +1,217 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
type MessageHoverListener = (isHovered: boolean) => void;
const SCROLL_IDLE_MS = 150;
const hoverTargets = new Map<HTMLElement, MessageHoverListener>();
let pointerPosition: {x: number; y: number} | null = null;
let hoveredTarget: HTMLElement | null = null;
let resolveFrame: number | null = null;
let globalListenersAttached = false;
let scrollIdleTimer: ReturnType<typeof setTimeout> | null = null;
let suspendedByScroll = false;
const resolveHoveredTarget = (): HTMLElement | null => {
if (pointerPosition == null) {
return null;
}
let node: Element | null = document.elementFromPoint(pointerPosition.x, pointerPosition.y);
while (node != null) {
if (node instanceof HTMLElement && hoverTargets.has(node)) {
return node;
}
node = node.parentElement;
}
return null;
};
const setHoveredTarget = (nextHoveredTarget: HTMLElement | null): void => {
if (hoveredTarget === nextHoveredTarget) {
return;
}
const previousHoveredTarget = hoveredTarget;
hoveredTarget = nextHoveredTarget;
if (previousHoveredTarget != null) {
hoverTargets.get(previousHoveredTarget)?.(false);
}
if (nextHoveredTarget != null) {
hoverTargets.get(nextHoveredTarget)?.(true);
}
};
const cancelScheduledResolve = (): void => {
if (resolveFrame == null) {
return;
}
cancelAnimationFrame(resolveFrame);
resolveFrame = null;
};
const scheduleResolve = (): void => {
if (suspendedByScroll || resolveFrame != null) {
return;
}
resolveFrame = requestAnimationFrame(() => {
resolveFrame = null;
setHoveredTarget(resolveHoveredTarget());
});
};
const clearScrollIdleTimer = (): void => {
if (scrollIdleTimer == null) {
return;
}
clearTimeout(scrollIdleTimer);
scrollIdleTimer = null;
};
const resumeAfterScroll = (): void => {
clearScrollIdleTimer();
if (!suspendedByScroll) {
return;
}
suspendedByScroll = false;
scheduleResolve();
};
const forgetPointerPosition = (): void => {
pointerPosition = null;
cancelScheduledResolve();
clearScrollIdleTimer();
suspendedByScroll = false;
setHoveredTarget(null);
};
const isTouchPointerEvent = (event: PointerEvent | MouseEvent): boolean =>
'pointerType' in event && event.pointerType === 'touch';
const handlePointerActivity = (event: PointerEvent | MouseEvent): void => {
if (isTouchPointerEvent(event)) {
return;
}
pointerPosition = {x: event.clientX, y: event.clientY};
scheduleResolve();
};
const handlePointerWindowExit = (event: PointerEvent | MouseEvent): void => {
if (isTouchPointerEvent(event) || event.relatedTarget != null) {
return;
}
forgetPointerPosition();
};
const handlePointerMotion = (event: PointerEvent | MouseEvent): void => {
if (isTouchPointerEvent(event)) {
return;
}
pointerPosition = {x: event.clientX, y: event.clientY};
if (suspendedByScroll) {
resumeAfterScroll();
return;
}
scheduleResolve();
};
const handleScroll = (): void => {
suspendedByScroll = true;
cancelScheduledResolve();
setHoveredTarget(null);
clearScrollIdleTimer();
scrollIdleTimer = setTimeout(resumeAfterScroll, SCROLL_IDLE_MS);
};
const handleLayoutChange = (): void => {
scheduleResolve();
};
const supportsPointerEvents = (): boolean => 'PointerEvent' in window;
const attachGlobalListeners = (): void => {
if (globalListenersAttached) {
return;
}
globalListenersAttached = true;
if (supportsPointerEvents()) {
window.addEventListener('pointermove', handlePointerMotion, true);
window.addEventListener('pointerdown', handlePointerMotion, true);
window.addEventListener('pointerover', handlePointerActivity, true);
window.addEventListener('pointerout', handlePointerWindowExit, true);
} else {
window.addEventListener('mousemove', handlePointerMotion, true);
window.addEventListener('mousedown', handlePointerMotion, true);
window.addEventListener('mouseover', handlePointerActivity, true);
window.addEventListener('mouseout', handlePointerWindowExit, true);
}
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleLayoutChange);
window.addEventListener('blur', forgetPointerPosition);
};
const detachGlobalListeners = (): void => {
if (!globalListenersAttached) {
return;
}
globalListenersAttached = false;
clearScrollIdleTimer();
suspendedByScroll = false;
if (supportsPointerEvents()) {
window.removeEventListener('pointermove', handlePointerMotion, true);
window.removeEventListener('pointerdown', handlePointerMotion, true);
window.removeEventListener('pointerover', handlePointerActivity, true);
window.removeEventListener('pointerout', handlePointerWindowExit, true);
} else {
window.removeEventListener('mousemove', handlePointerMotion, true);
window.removeEventListener('mousedown', handlePointerMotion, true);
window.removeEventListener('mouseover', handlePointerActivity, true);
window.removeEventListener('mouseout', handlePointerWindowExit, true);
}
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleLayoutChange);
window.removeEventListener('blur', forgetPointerPosition);
};
export function registerMessageHoverTarget(element: HTMLElement, listener: MessageHoverListener): () => void {
hoverTargets.set(element, listener);
attachGlobalListeners();
scheduleResolve();
return () => {
if (hoverTargets.get(element) !== listener) {
return;
}
hoverTargets.delete(element);
if (hoveredTarget === element) {
hoveredTarget = null;
listener(false);
}
if (hoverTargets.size > 0) {
scheduleResolve();
return;
}
detachGlobalListeners();
cancelScheduledResolve();
hoveredTarget = null;
};
}
export function refreshMessageHoverTargets(): void {
scheduleResolve();
}
export function resolveMessageHoverTargetsNow(): void {
if (suspendedByScroll) {
return;
}
cancelScheduledResolve();
setHoveredTarget(resolveHoveredTarget());
}
export function resetMessageHoverTrackingForTests(): void {
detachGlobalListeners();
cancelScheduledResolve();
clearScrollIdleTimer();
suspendedByScroll = false;
hoverTargets.clear();
pointerPosition = null;
hoveredTarget = null;
}
@@ -0,0 +1,75 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type ExperimentAssignmentsResponse,
INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE,
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import {
INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT,
type MessageHoverTrackingAssignmentResponse,
} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
import {runInAction} from 'mobx';
import {afterEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/platform/utils/AppLogger', () => ({
Logger: class {
debug = vi.fn();
info = vi.fn();
warn = vi.fn();
error = vi.fn();
},
}));
vi.mock('@app/features/platform/transport/RestTransport', () => ({
http: {get: vi.fn(), post: vi.fn()},
}));
const {ExperimentAssignments} = await import('@app/features/experiment/state/ExperimentAssignments');
const {MessageHoverTrackingRollout} = await import('@app/features/channel/state/MessageHoverTrackingRollout');
function publish(assignment: MessageHoverTrackingAssignmentResponse): void {
const response: ExperimentAssignmentsResponse = {
poll_interval_seconds: 300,
poll_jitter_percent: 15,
assignments: {message_hover_tracking: assignment},
};
runInAction(() => {
ExperimentAssignments.response = response;
});
}
afterEach(() => {
runInAction(() => {
ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
});
});
describe('MessageHoverTrackingRollout', () => {
it('reads the inert assignment out of the inert envelope', () => {
expect(MessageHoverTrackingRollout.assignment).toBe(INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT);
expect(MessageHoverTrackingRollout.enabled).toBe(false);
});
it('stays on the control arm while the rollout is disabled', () => {
publish({enabled: false, config_version: 4, user_targeted: false, source: null});
expect(MessageHoverTrackingRollout.enabled).toBe(false);
});
it('stays on the control arm for an account the rollout did not target', () => {
publish({enabled: true, config_version: 4, user_targeted: false, source: null});
expect(MessageHoverTrackingRollout.enabled).toBe(false);
});
it('moves to the experiment arm for a targeted account', () => {
publish({enabled: true, config_version: 4, user_targeted: true, source: 'canary'});
expect(MessageHoverTrackingRollout.enabled).toBe(true);
});
it('follows the envelope back to the control arm when the store is reset', () => {
publish({enabled: true, config_version: 4, user_targeted: true, source: 'user_rule'});
expect(MessageHoverTrackingRollout.enabled).toBe(true);
ExperimentAssignments.reset();
expect(MessageHoverTrackingRollout.enabled).toBe(false);
});
});
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import {readMessageHoverTrackingAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import type {MessageHoverTrackingAssignmentResponse} from '@fluxer/schema/src/domains/experiment/MessageHoverTrackingSchemas';
export const MESSAGE_HOVER_TRACKING_EXPERIMENT_CLASS = 'experiment-message-hover-tracking';
class MessageHoverTrackingRolloutSelector {
get assignment(): MessageHoverTrackingAssignmentResponse {
return readMessageHoverTrackingAssignment(ExperimentAssignments.response);
}
get enabled(): boolean {
const assignment = this.assignment;
return assignment.enabled && assignment.user_targeted;
}
}
export const MessageHoverTrackingRollout = new MessageHoverTrackingRolloutSelector();
export default MessageHoverTrackingRollout;
@@ -75,18 +75,34 @@
}
}
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.message.messageHovered:not(.messageMentioned):not(.messageReplying):not(.messageHighlight):not(.messagePreview),
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.messageCompact.messageHovered:not(.messageMentioned):not(.messageReplying):not(.messageHighlight):not(
.messagePreview
) {
background-color: var(--background-modifier-hover);
}
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.message.messageMentioned.messageHovered,
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.messageCompact.messageMentioned.messageHovered {
background-color: var(--message-mention-bg-hover);
}
@@ -98,13 +114,29 @@
background-color: var(--message-mention-bg);
}
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.message.messageReplying.messageHovered:not(.messageMentioned),
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.message.messageHighlight.messageHovered:not(.messageMentioned),
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.messageCompact.messageReplying.messageHovered:not(.messageMentioned),
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.messageCompact.messageHighlight.messageHovered:not(.messageMentioned) {
background-color: var(--message-reply-bg);
}
@@ -127,9 +159,17 @@
}
@media (pointer: coarse) {
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.message:hover,
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.messageCompact:hover {
background-color: transparent;
}
@@ -578,7 +618,11 @@
transform: translateX(0.5rem);
}
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.message.messageHovered
.messageTimestampHover {
opacity: 1;
@@ -586,7 +630,11 @@
}
@media (pointer: coarse) {
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.message:hover
.messageTimestampHover {
opacity: 0;
@@ -650,7 +698,11 @@
margin-inline-end: 0;
}
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.messageCompact.messageHovered
.messageTimestampCompactHover {
opacity: 1;
@@ -658,7 +710,11 @@
}
@media (pointer: coarse) {
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.messageCompact:hover
.messageTimestampCompactHover {
opacity: 0;
@@ -678,40 +734,80 @@
min-inline-size: 0;
}
.message .buttons,
.messageCompact .buttons {
:global(html:not(.experiment-message-hover-tracking)) .message .buttons,
:global(html:not(.experiment-message-hover-tracking)) .messageCompact .buttons {
opacity: 0;
pointer-events: none;
}
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.message.messageHovered
.buttons,
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.message
.buttons:hover,
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.message
.buttons:has(:focus-visible),
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.message.selected
.buttons,
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.message
.buttons.emojiPickerOpen,
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.messageCompact.messageHovered
.buttons,
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.messageCompact
.buttons:hover,
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.messageCompact
.buttons:has(:focus-visible),
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.messageCompact.selected
.buttons,
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.messageCompact
.buttons.emojiPickerOpen {
opacity: 1;
@@ -719,10 +815,18 @@
}
@media (pointer: coarse) {
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.message:hover
.buttons,
:global(:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard))
:global(
:where(html.window-focused, html.unfocused-fully-interactive):not(.window-focus-activation-guard):not(
.experiment-message-hover-tracking
)
)
.messageCompact:hover
.buttons {
opacity: 0;
@@ -1422,15 +1526,21 @@
max-inline-size: calc(100% - var(--message-compact-text-start));
}
.contextMenuActive {
:global(html:not(.experiment-message-hover-tracking)) .contextMenuActive,
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
.contextMenuActive {
background-color: var(--background-modifier-hover) !important;
}
:global(html.reduced-motion) .contextMenuActive {
:global(html.reduced-motion:not(.experiment-message-hover-tracking)) .contextMenuActive {
background-color: transparent !important;
}
.contextMenuActive .buttons {
:global(html.reduced-motion.experiment-message-hover-tracking) .contextMenuActive {
background-color: var(--background-modifier-hover) !important;
}
:global(html:not(.experiment-message-hover-tracking)) .contextMenuActive .buttons {
opacity: 1 !important;
pointer-events: auto !important;
}
@@ -1445,7 +1555,9 @@
pointer-events: auto !important;
}
.contextMenuActive.messageMentioned {
:global(html:not(.experiment-message-hover-tracking)) .contextMenuActive.messageMentioned,
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
.contextMenuActive.messageMentioned {
background-color: var(--message-mention-bg-hover) !important;
}
@@ -1453,8 +1565,12 @@
background-color: var(--message-mention-bg) !important;
}
.contextMenuActive.messageReplying:not(.messageMentioned),
.contextMenuActive.messageHighlight:not(.messageMentioned) {
:global(html:not(.experiment-message-hover-tracking)) .contextMenuActive.messageReplying:not(.messageMentioned),
:global(html:not(.experiment-message-hover-tracking)) .contextMenuActive.messageHighlight:not(.messageMentioned),
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
.contextMenuActive.messageReplying:not(.messageMentioned),
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
.contextMenuActive.messageHighlight:not(.messageMentioned) {
background-color: var(--message-reply-bg) !important;
}
@@ -1463,20 +1579,27 @@
background-color: var(--message-reply-bg) !important;
}
.keyboardFocused {
:global(html:not(.experiment-message-hover-tracking)) .keyboardFocused,
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive)) .keyboardFocused {
background-color: var(--background-modifier-hover);
}
.keyboardFocused.messageMentioned {
:global(html:not(.experiment-message-hover-tracking)) .keyboardFocused.messageMentioned,
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
.keyboardFocused.messageMentioned {
background-color: var(--message-mention-bg);
}
.keyboardFocused.messageReplying:not(.messageMentioned),
.keyboardFocused.messageHighlight:not(.messageMentioned) {
:global(html:not(.experiment-message-hover-tracking)) .keyboardFocused.messageReplying:not(.messageMentioned),
:global(html:not(.experiment-message-hover-tracking)) .keyboardFocused.messageHighlight:not(.messageMentioned),
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
.keyboardFocused.messageReplying:not(.messageMentioned),
:global(html.experiment-message-hover-tracking:where(.window-focused, .unfocused-fully-interactive))
.keyboardFocused.messageHighlight:not(.messageMentioned) {
background-color: var(--message-reply-bg);
}
.keyboardFocused .buttons {
:global(html:not(.experiment-message-hover-tracking)) .keyboardFocused .buttons {
opacity: 1;
pointer-events: auto;
}
@@ -0,0 +1,78 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
canUseWindowFocusedHoverControls,
createWindowFocusInteractionGuard,
isWindowFocusActivationGuardActive,
subscribeWindowHoverControlsChange,
WINDOW_FOCUSED_CLASS,
type WindowFocusInteractionGuard,
} from '@app/features/ui/utils/WindowFocusInteractionGuard';
import {afterEach, describe, expect, it} from 'vitest';
const GUARD_TIMEOUT_MS = 10;
let activeGuard: WindowFocusInteractionGuard | null = null;
let unsubscribe: (() => void) | null = null;
function createFocusedGuard(): {guard: WindowFocusInteractionGuard; root: HTMLElement; observed: Array<boolean>} {
const root = document.createElement('div');
root.classList.add(WINDOW_FOCUSED_CLASS);
document.body.append(root);
const guard = createWindowFocusInteractionGuard({
root,
guardTimeoutMs: GUARD_TIMEOUT_MS,
releaseClearDelayMs: 5,
maxGuardTimeoutMs: 200,
});
activeGuard = guard;
const observed: Array<boolean> = [];
unsubscribe = subscribeWindowHoverControlsChange(() => observed.push(canUseWindowFocusedHoverControls(root)));
return {guard, root, observed};
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
afterEach(() => {
unsubscribe?.();
unsubscribe = null;
activeGuard?.destroy();
activeGuard = null;
document.body.replaceChildren();
});
describe('WindowFocusInteractionGuard', () => {
it('never publishes an enabled edge between refocus and the activation guard', async () => {
const {guard, root, observed} = createFocusedGuard();
guard.setFocused(false);
guard.setFocused(true);
expect(observed).toEqual([false]);
expect(isWindowFocusActivationGuardActive(root)).toBe(true);
expect(canUseWindowFocusedHoverControls(root)).toBe(false);
await wait(GUARD_TIMEOUT_MS * 3);
expect(observed).toEqual([false, true]);
expect(canUseWindowFocusedHoverControls(root)).toBe(true);
});
it('does not publish an enabled edge while tearing down a guarded window', () => {
const {guard, observed} = createFocusedGuard();
guard.setFocused(false);
guard.setFocused(true);
observed.length = 0;
guard.destroy();
activeGuard = null;
expect(observed).toEqual([]);
});
});
@@ -141,14 +141,20 @@ export function createWindowFocusInteractionGuard({
return {
setFocused(nextFocused: boolean): void {
focused = nextFocused;
root.classList.toggle(WINDOW_FOCUSED_CLASS, nextFocused);
notifyHoverControlsChange();
if (!nextFocused) {
root.classList.toggle(WINDOW_FOCUSED_CLASS, false);
notifyHoverControlsChange();
wasBlurred = true;
clearActivationGuard();
return;
}
if (wasBlurred) {
const shouldGuardActivation = wasBlurred;
if (shouldGuardActivation) {
root.classList.add(WINDOW_FOCUS_ACTIVATION_GUARD_CLASS);
}
root.classList.toggle(WINDOW_FOCUSED_CLASS, true);
notifyHoverControlsChange();
if (shouldGuardActivation) {
beginActivationGuard(false);
}
wasBlurred = false;
@@ -162,9 +168,8 @@ export function createWindowFocusInteractionGuard({
windowTarget.removeEventListener('pointercancel', handlePointerEnd, true);
windowTarget.removeEventListener('click', handlePointerEnd, true);
classObserver?.disconnect();
clearActivationGuard();
root.classList.remove(WINDOW_FOCUSED_CLASS);
notifyHoverControlsChange();
clearActivationGuard();
},
};
}