mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
fix(app): gate single-source message hover behind an experiment (#2761)
This commit is contained in:
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user