fix(app): gate one-Tab message focus behind an experiment (#2762)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-14 15:55:00 +02:00
1 parent 1281045648
commit 4b278a0da8
35 files changed
+1864 -62

No files matched your search

@@ -2,6 +2,7 @@
import {Routes} from '@app/app/Routes';
import {CHANNEL_TEXTAREA_SELECTOR} from '@app/features/app/keybindings/utils/EditableElement';
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
import {useLocation} from '@app/features/platform/components/router/RouterReact';
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager';
@@ -11,7 +12,7 @@ import {
recordPointerActivationFocusTarget,
} from '@app/features/ui/utils/PointerActivationFocus';
import {observer} from 'mobx-react-lite';
import {useEffect, useMemo} from 'react';
import {useEffect, useLayoutEffect, useMemo} from 'react';
const FOCUS_TRAPPING_OVERLAY_SELECTOR = [
'[role="dialog"]',
@@ -86,9 +87,15 @@ export const KeyboardModeListener = observer(() => {
window.removeEventListener('pointerdown', handlePointer, true);
};
}, [isAuthRoute]);
useEffect(() => {
const keyboardNavigationEnabled = MessageKeyboardFocusRollout.enabled;
useLayoutEffect(() => {
if (!keyboardNavigationEnabled) return;
FocusRingManager.setRingsEnabled(keyboardModeEnabled);
}, [keyboardModeEnabled]);
}, [keyboardModeEnabled, keyboardNavigationEnabled]);
useEffect(() => {
if (keyboardNavigationEnabled) return;
FocusRingManager.setRingsEnabled(keyboardModeEnabled);
}, [keyboardModeEnabled, keyboardNavigationEnabled]);
useEffect(() => {
const pendingFrames = new Set<number>();
const handlePointerActivation = (event: MouseEvent) => {
@@ -9,12 +9,15 @@ import {
} from '@app/features/channel/components/MessageGroup';
import type {Channel} from '@app/features/channel/models/Channel';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
import {type ChannelStreamItem, ChannelStreamType} from '@app/features/messaging/utils/MessageGroupingUtils';
import {getMessageSelector} from '@app/features/messaging/utils/MessageNodeSelectors';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import type {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import React, {useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef} from 'react';
const MESSAGE_SCROLLER_SELECTOR = '[data-fluxer-scroll-container="true"]';
const SCROLLER_BOTTOM_EPSILON = 1;
@@ -99,7 +102,13 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
} = props;
const {i18n} = useLingui();
const containerRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const scrollToBottomFrameRef = useRef<number | null>(null);
const wasRevealedRef = useRef(revealed);
const revealedByKeyboardRef = useRef(false);
const focusWithinContentRef = useRef(false);
const contentId = useId();
const messageSummary = useMemo(() => {
let firstMessageId: string | null = null;
let totalMessageCount = 0;
@@ -123,34 +132,93 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
scroller.scrollTop = scroller.scrollHeight;
});
}, []);
const handleClick = useCallback(() => {
const container = containerRef.current;
const scroller = container?.closest(MESSAGE_SCROLLER_SELECTOR) as HTMLElement | null;
if (scroller) {
const wasAtBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLER_BOTTOM_EPSILON;
if (revealed) {
onReveal(null);
if (wasAtBottom) {
scheduleScrollToBottom(scroller);
}
} else {
if (messageSummary.firstMessageId) {
onReveal(messageSummary.firstMessageId);
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
revealedByKeyboardRef.current = event.detail === 0;
const container = containerRef.current;
const scroller = container?.closest(MESSAGE_SCROLLER_SELECTOR) as HTMLElement | null;
if (scroller) {
const wasAtBottom =
scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < SCROLLER_BOTTOM_EPSILON;
if (revealed) {
onReveal(null);
if (wasAtBottom) {
scheduleScrollToBottom(scroller);
}
} else {
if (messageSummary.firstMessageId) {
onReveal(messageSummary.firstMessageId);
if (wasAtBottom) {
scheduleScrollToBottom(scroller);
}
}
}
}
} else {
if (revealed) {
onReveal(null);
} else {
if (messageSummary.firstMessageId) {
onReveal(messageSummary.firstMessageId);
if (revealed) {
onReveal(null);
} else {
if (messageSummary.firstMessageId) {
onReveal(messageSummary.firstMessageId);
}
}
}
},
[messageSummary.firstMessageId, onReveal, revealed, scheduleScrollToBottom],
);
useEffect(() => {
const container = containerRef.current;
if (container == null) {
return;
}
}, [messageSummary.firstMessageId, onReveal, revealed, scheduleScrollToBottom]);
const isInsideContent = (node: EventTarget | null): boolean =>
node instanceof Node && contentRef.current?.contains(node) === true;
const handleFocusIn = (event: FocusEvent) => {
focusWithinContentRef.current = isInsideContent(event.target);
};
const handleFocusOut = (event: FocusEvent) => {
if (isInsideContent(event.relatedTarget)) {
return;
}
focusWithinContentRef.current = false;
};
container.addEventListener('focusin', handleFocusIn);
container.addEventListener('focusout', handleFocusOut);
return () => {
container.removeEventListener('focusin', handleFocusIn);
container.removeEventListener('focusout', handleFocusOut);
};
}, []);
useLayoutEffect(() => {
const wasRevealed = wasRevealedRef.current;
wasRevealedRef.current = revealed;
if (wasRevealed === revealed) {
return;
}
const revealedByKeyboard = revealedByKeyboardRef.current;
revealedByKeyboardRef.current = false;
if (!KeyboardMode.keyboardModeEnabled || !MessageKeyboardFocusRollout.enabled) {
focusWithinContentRef.current = false;
return;
}
if (revealed) {
if (!revealedByKeyboard) {
return;
}
const firstMessage = contentRef.current?.querySelector<HTMLElement>(getMessageSelector(channel.id));
if (firstMessage == null) {
return;
}
if (firstMessage.tabIndex < 0) {
firstMessage.tabIndex = -1;
}
firstMessage.focus({preventScroll: true});
return;
}
if (focusWithinContentRef.current) {
focusWithinContentRef.current = false;
toggleRef.current?.focus({preventScroll: true});
}
}, [channel.id, revealed]);
useEffect(() => {
return () => {
if (scrollToBottomFrameRef.current != null) {
@@ -247,9 +315,12 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
/>
)}
<button
ref={toggleRef}
type="button"
className={styles.toggle}
onClick={handleClick}
aria-expanded={revealed}
aria-controls={contentId}
data-flx="channel.blocked-message-groups.toggle.click.button"
>
{variant === 'spammer'
@@ -257,7 +328,13 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
: i18n._(BLOCKED_MESSAGES_DESCRIPTOR, {count: messageSummary.totalMessageCount})}
</button>
{revealed && (
<div className={styles.content} data-blocked-messages data-flx="channel.blocked-message-groups.content">
<div
ref={contentRef}
id={contentId}
className={styles.content}
data-blocked-messages
data-flx="channel.blocked-message-groups.content"
>
{messageNodes}
</div>
)}
@@ -15,6 +15,7 @@ import {MarkdownContext} from '@app/features/messaging/components/markdown/rende
import type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
import MessageFocus from '@app/features/messaging/state/MessageFocus';
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
import MessageReply from '@app/features/messaging/state/MessageReply';
import {getMessageComponent} from '@app/features/messaging/utils/MessageComponentUtils';
import {renderAstToPlaintext} from '@app/features/messaging/utils/markdown/Plaintext';
@@ -483,6 +484,7 @@ export const Message: React.FC<MessageProps> = observer((props) => {
handlePopoutToggle,
trackingEnabled: hoverTrackingEnabled,
} = useMessageHoverState({messageRef, mobileLayoutEnabled, keyboardModeEnabled, contextMenuOpen});
const keyboardNavigationEnabled = MessageKeyboardFocusRollout.enabled;
const handleFocusWithin = useCallback(() => {
if (!keyboardModeEnabled) {
return;
@@ -512,6 +514,17 @@ export const Message: React.FC<MessageProps> = observer((props) => {
MessageFocus.clearFocusedMessageIfMatches(channel.id, message.id);
}
}, [channel, contextMenuOpen, isFocusedWithin, keyboardModeEnabled, message, message.id]);
const isFocusedWithinRef = useRef(isFocusedWithin);
isFocusedWithinRef.current = isFocusedWithin;
const keyboardNavigationEnabledRef = useRef(keyboardNavigationEnabled);
keyboardNavigationEnabledRef.current = keyboardNavigationEnabled;
useEffect(() => {
return () => {
if (keyboardNavigationEnabledRef.current && isFocusedWithinRef.current) {
MessageFocus.clearFocusedMessageIfMatches(channel.id, message.id);
}
};
}, [channel.id, message.id]);
useEffect(() => {
const wasEditing = wasEditingInPreviousUpdateRef.current;
const justStartedEditing = !wasEditing && isEditing;
@@ -689,7 +702,12 @@ export const Message: React.FC<MessageProps> = observer((props) => {
);
return (
<>
<FocusRing data-flx="channel.message.focus-ring">
<FocusRing
enabled={keyboardNavigationEnabled ? keyboardModeEnabled : undefined}
within={keyboardNavigationEnabled}
offset={keyboardNavigationEnabled ? -2 : undefined}
data-flx="channel.message.focus-ring"
>
<div
role="article"
aria-label={messageAriaLabel}
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
type MessageFocusCandidate,
resolveBottommostFocusableMessageId,
} from '@app/features/channel/components/ChannelMessageFocusTarget';
import {describe, expect, it} from 'vitest';
function candidate(messageId: string, top: number, height: number): MessageFocusCandidate {
return {messageId, top, bottom: top + height, height};
}
const VIEWPORT_TOP = 0;
const VIEWPORT_BOTTOM = 600;
describe('resolveBottommostFocusableMessageId', () => {
it('picks the bottom-most mostly visible message', () => {
const result = resolveBottommostFocusableMessageId(
[candidate('a', 10, 100), candidate('b', 150, 100), candidate('c', 300, 100)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
);
expect(result).toBe('c');
});
it('skips a message that is mostly scrolled past the bottom edge', () => {
const result = resolveBottommostFocusableMessageId(
[candidate('a', 10, 100), candidate('b', 150, 100), candidate('c', 550, 100)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
);
expect(result).toBe('b');
});
it('falls back to the most visible message when nothing clears the visibility threshold', () => {
const result = resolveBottommostFocusableMessageId(
[candidate('a', -900, 1000), candidate('b', 200, 1000)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
);
expect(result).toBe('b');
});
it('prefers the most visible message over the last one in the DOM below the viewport', () => {
const result = resolveBottommostFocusableMessageId(
[candidate('tall', -100, 1000), candidate('offscreen', 2000, 50)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
);
expect(result).toBe('tall');
});
it('breaks a tie on visible height by taking the bottom-most message', () => {
const result = resolveBottommostFocusableMessageId(
[candidate('a', -800, 900), candidate('b', 500, 900)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
);
expect(result).toBe('b');
});
it('falls back to the last message when nothing overlaps the viewport at all', () => {
const result = resolveBottommostFocusableMessageId(
[candidate('a', 900, 100), candidate('b', 1200, 100)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
);
expect(result).toBe('b');
});
it('falls back to the last message when the newest one is hidden behind the composer', () => {
const result = resolveBottommostFocusableMessageId([candidate('only', 400, 400)], VIEWPORT_TOP, VIEWPORT_BOTTOM);
expect(result).toBe('only');
});
it('ignores zero-height rows when scoring visibility', () => {
expect(
resolveBottommostFocusableMessageId(
[candidate('a', 10, 100), candidate('b', 200, 0)],
VIEWPORT_TOP,
VIEWPORT_BOTTOM,
),
).toBe('a');
});
it('returns null when there are no candidates', () => {
expect(resolveBottommostFocusableMessageId([], VIEWPORT_TOP, VIEWPORT_BOTTOM)).toBeNull();
});
});
@@ -9,6 +9,7 @@ import type {Channel} from '@app/features/channel/models/Channel';
import type {Message} from '@app/features/messaging/models/MessagingMessage';
import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
import {type ChannelStreamItem, ChannelStreamType} from '@app/features/messaging/utils/MessageGroupingUtils';
import {CHANNEL_MESSAGE_ID_PREFIX} from '@app/features/messaging/utils/MessageNodeSelectors';
import {IS_DEV} from '@app/features/platform/types/Env';
import {Logger} from '@app/features/platform/utils/AppLogger';
import type {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
@@ -126,7 +127,7 @@ export function renderChannelStream(props: RenderChannelStreamProps): Array<Reac
flashKey={pendingFlashKey}
showUnreadDividerSlots={true}
unreadDividerBeforeMessageId={unreadDividerBeforeMessageId}
idPrefix="chat-messages"
idPrefix={CHANNEL_MESSAGE_ID_PREFIX}
messageRowClassName={messageRowClassName}
messageActionsClassName={messageActionsClassName}
renderMessageActions={renderMessageActions}
@@ -33,12 +33,14 @@ import {
} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
import MessageFocus from '@app/features/messaging/state/MessageFocus';
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
import MessagesState from '@app/features/messaging/state/MessagingMessages';
import {
type ChannelStreamItem,
createChannelStream,
getCollapsedMessageGroupKey,
} from '@app/features/messaging/utils/MessageGroupingUtils';
import {getMessageSelector} from '@app/features/messaging/utils/MessageNodeSelectors';
import LocalUserSpamOverride from '@app/features/moderation/state/LocalUserSpamOverride';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import Permission from '@app/features/permissions/state/Permission';
@@ -398,7 +400,9 @@ export const Messages = observer(function Messages({
const scroller = scrollManager.ref.current?.getViewportElement();
const innerElement = scrollerInnerRef.current;
if (!scroller || !innerElement) return;
const messageElements = innerElement.querySelectorAll<HTMLElement>('[data-message-id]');
const messageElements = innerElement.querySelectorAll<HTMLElement>(
MessageKeyboardFocusRollout.enabled ? getMessageSelector(channel.id) : '[data-message-id]',
);
if (!messageElements.length) return;
const scrollerRect = scroller.getBoundingClientRect();
const candidates: Array<MessageFocusCandidate> = [];
@@ -87,6 +87,7 @@ import {
} from '@app/features/messaging/state/MentionConfirmationStateMachine';
import MessageEdit from '@app/features/messaging/state/MessageEdit';
import MessageEditMobile from '@app/features/messaging/state/MessageEditMobile';
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
import MessageReply from '@app/features/messaging/state/MessageReply';
import Messages from '@app/features/messaging/state/MessagingMessages';
import {CloudUpload} from '@app/features/messaging/upload/CloudUpload';
@@ -885,15 +886,17 @@ export const LexicalChannelTextareaContent = observer(
onSubmit();
}, [canSubmit, channel, hasAttachments, onSubmit]);
const handleArrowUpEmpty = useCallback(() => {
const claimsArrowUp = MessageKeyboardFocusRollout.enabled;
if (KeyboardMode.keyboardModeEnabled) {
ComponentBus.dispatch('FOCUS_BOTTOMMOST_MESSAGE', {channelId: channel.id});
return;
return claimsArrowUp;
}
const message = Messages.getLastEditableMessage(channel.id);
if (!message) {
return;
return false;
}
MessageCommands.startEdit(channel.id, message.id, message.content);
return claimsArrowUp;
}, [channel.id]);
useTextareaDraftAndTyping({
channelId: channel.id,
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {readFileSync} from 'node:fs';
import {fileURLToPath} from 'node:url';
import {describe, expect, it} from 'vitest';
function readSource(relativePath: string): string {
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8');
}
const messageCss = readSource('../../theme/styles/Message.module.css');
const actionBarCss = readSource('./MessageActionBar.module.css');
const focusRingCss = readSource('../../ui/focus_ring/FocusRing.module.css');
const channelMessageSource = readSource('./ChannelMessage.tsx');
const messageFocusRing = channelMessageSource.match(/<FocusRing\b[^>]*>/)?.[0] ?? '';
describe('message focus ring contract', () => {
it('never draws a ring from a bare :focus selector on a message row', () => {
expect(messageCss).not.toMatch(/\.message(Compact)?:focus(?!-visible)/);
});
it('routes the row ring through the FocusRing framework', () => {
expect(channelMessageSource).toMatch(/from '@app\/features\/ui\/focus_ring\/FocusRing'/);
expect(messageFocusRing).not.toBe('');
});
it('reads the ring arm from the keyboard navigation rollout', () => {
expect(channelMessageSource).toMatch(/const keyboardNavigationEnabled = MessageKeyboardFocusRollout\.enabled;/);
});
it('only enables the ring in keyboard navigation mode in the experiment arm', () => {
expect(messageFocusRing).toMatch(/enabled=\{keyboardNavigationEnabled \? keyboardModeEnabled : undefined\}/);
expect(messageFocusRing).toMatch(/within=\{keyboardNavigationEnabled\}/);
});
it('insets the ring inside the row in the experiment arm and keeps the default geometry in control', () => {
expect(focusRingCss).toMatch(/pointer-events:\s*none/);
expect(messageFocusRing).toMatch(/offset=\{keyboardNavigationEnabled \? -2 : undefined\}/);
});
it('stacks the ring below the action bar', () => {
expect(actionBarCss).toMatch(/z-index:\s*var\(--z-index-elevated-1\)/);
});
it('falls back to a system outline under forced colors', () => {
const forcedColors = focusRingCss.match(/@media \(forced-colors: active\) \{\n\t\.focusRing \{([^{}]*)\}/)?.[1];
expect(forcedColors).toBeDefined();
expect(forcedColors).toMatch(/box-shadow:\s*none/);
expect(forcedColors).toMatch(/outline-color:\s*Highlight/);
});
});
@@ -0,0 +1,107 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ComposerHandle} from '@app/features/lexical/composer/ComposerHandle';
import {act, useRef} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
const {canFocusTextareaMock} = vi.hoisted(() => ({canFocusTextareaMock: vi.fn(() => true)}));
vi.mock('@app/features/platform/utils/InputFocusManager', () => ({
canFocusTextarea: canFocusTextareaMock,
}));
const {useChannelComposerDraftFocusRestore} = await import(
'@app/features/channel/components/useChannelComposerDraftFocusRestore'
);
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
let focusSpy: ReturnType<typeof vi.fn>;
interface HarnessProps {
initialDraft: string;
textareaInputDisabled?: boolean;
inlineEditActive?: boolean;
}
function Harness({initialDraft, textareaInputDisabled = false, inlineEditActive = false}: HarnessProps) {
const handleRef = useRef<ComposerHandle | null>(null);
handleRef.current = {focus: focusSpy} as unknown as ComposerHandle;
const editableRef = useRef<HTMLDivElement | null>(null);
useChannelComposerDraftFocusRestore({
handleRef,
editableRef,
initialDraft,
textareaInputDisabled,
inlineEditActive,
});
return (
<div
ref={editableRef}
contentEditable
suppressContentEditableWarning
data-flx="channel.use-channel-composer-draft-focus-restore-test.harness.div"
/>
);
}
function render(props: HarnessProps): void {
act(() => {
root.render(<Harness data-flx="channel.use-channel-composer-draft-focus-restore-test.harness" {...props} />);
});
}
beforeEach(() => {
focusSpy = vi.fn();
canFocusTextareaMock.mockReturnValue(true);
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
canFocusTextareaMock.mockReset();
});
describe('useChannelComposerDraftFocusRestore', () => {
test('focuses the composer when the channel is entered with a pending draft', () => {
render({initialDraft: 'half written'});
expect(focusSpy).toHaveBeenCalledTimes(1);
});
test('leaves focus alone when there is no pending draft', () => {
render({initialDraft: ''});
expect(focusSpy).not.toHaveBeenCalled();
});
test('yields to an active inline message edit', () => {
render({initialDraft: 'half written', inlineEditActive: true});
expect(focusSpy).not.toHaveBeenCalled();
});
test('does nothing when composer input is disabled', () => {
render({initialDraft: 'half written', textareaInputDisabled: true});
expect(focusSpy).not.toHaveBeenCalled();
});
test('respects the shared focus guard that blocks mobile, modals and popouts', () => {
canFocusTextareaMock.mockReturnValue(false);
render({initialDraft: 'half written'});
expect(focusSpy).not.toHaveBeenCalled();
});
test('restores focus only on the channel mount, not on every re-render', () => {
render({initialDraft: 'half written'});
render({initialDraft: 'half written'});
render({initialDraft: 'half written more'});
expect(focusSpy).toHaveBeenCalledTimes(1);
});
});
@@ -160,7 +160,7 @@ export interface LexicalComposerInputProps {
onChange: (display: string, segments: Array<MentionSegment>, wire: string) => void;
onCursorMove: () => void;
onEnter?: () => void;
onArrowUp: () => void;
onArrowUp: () => boolean;
onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void;
onFocus?: () => void;
onBlur?: () => void;
@@ -608,8 +608,9 @@ const ComposerInner = ({
return false;
}
if (event != null && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
if ($isComposerEmpty()) {
cb.current.onArrowUp();
if ($isComposerEmpty() && cb.current.onArrowUp()) {
event.preventDefault();
return true;
}
}
return false;
@@ -58,7 +58,7 @@ export interface LexicalRichInputProps {
i18n: I18n;
}
const NOOP = (): void => {};
const ARROW_UP_UNHANDLED = (): boolean => false;
const SAFE_CHANNEL_TRIGGERS: Array<TriggerType> = ['emoji', 'mention', 'channel'];
const SAFE_CONTEXT_FREE_TRIGGERS: Array<TriggerType> = ['emoji'];
@@ -245,7 +245,7 @@ export const LexicalRichInput = ({
onChange={emitChange}
onCursorMove={onCursorMove}
onEnter={onSubmit == null ? undefined : handleEnter}
onArrowUp={NOOP}
onArrowUp={ARROW_UP_UNHANDLED}
onKeyDown={onKeyDown}
onFocus={onFocus}
onBlur={onBlur}
@@ -0,0 +1,266 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {CHANNEL_MESSAGE_ID_PREFIX, findMessageElement} from '@app/features/messaging/utils/MessageNodeSelectors';
import {act, createElement, type RefObject} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
const keyboardModeMock = {keyboardModeEnabled: true};
const messageFocusMock = {focusedMessageId: null as string | null};
const rolloutMock = {enabled: true};
vi.mock('@app/features/ui/state/KeyboardMode', () => ({default: keyboardModeMock}));
vi.mock('@app/features/messaging/state/MessageFocus', () => ({default: messageFocusMock}));
vi.mock('@app/features/messaging/state/MessageKeyboardFocusRollout', () => ({default: rolloutMock}));
const {useMessageListKeyboardNavigation} = await import(
'@app/features/messaging/hooks/useMessageListKeyboardNavigation'
);
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
const CHANNEL_ID = '900000000000000001';
interface RowSpec {
messageId: string;
idPrefix: string;
}
let viewport: HTMLElement;
let host: HTMLDivElement;
let root: Root;
function mountRows(specs: ReadonlyArray<RowSpec>): void {
for (const spec of specs) {
const row = document.createElement('div');
row.id = `${spec.idPrefix}-${CHANNEL_ID}-${spec.messageId}`;
row.dataset.messageId = spec.messageId;
row.dataset.channelId = CHANNEL_ID;
row.tabIndex = -1;
viewport.append(row);
}
}
function focusedRowId(): string | null {
const active = document.activeElement;
return active instanceof HTMLElement ? (active.dataset.messageId ?? null) : null;
}
function render(onFocusMessage?: (messageId: string) => void): void {
const containerRef: RefObject<HTMLElement | null> = {current: viewport};
function Harness(): null {
useMessageListKeyboardNavigation({containerRef, channelId: CHANNEL_ID, onFocusMessage, allowWhenInactive: true});
return null;
}
act(() => {
root.render(createElement(Harness));
});
}
function pressArrow(key: 'ArrowUp' | 'ArrowDown'): void {
act(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {key, bubbles: true, cancelable: true}));
});
}
beforeEach(() => {
keyboardModeMock.keyboardModeEnabled = true;
messageFocusMock.focusedMessageId = null;
rolloutMock.enabled = true;
viewport = document.createElement('div');
document.body.append(viewport);
viewport.addEventListener('focusin', (event) => {
const target = event.target;
if (target instanceof HTMLElement && target.dataset.messageId) {
messageFocusMock.focusedMessageId = target.dataset.messageId;
}
});
host = document.createElement('div');
document.body.append(host);
root = createRoot(host);
});
afterEach(() => {
act(() => {
root.unmount();
});
document.body.replaceChildren();
vi.restoreAllMocks();
});
describe('useMessageListKeyboardNavigation', () => {
it('walks from the newest message up through a revealed blocked group', () => {
mountRows([
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
{messageId: '2', idPrefix: 'blocked-messages'},
{messageId: '3', idPrefix: 'blocked-messages'},
{messageId: '4', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
]);
render((messageId) => {
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
});
pressArrow('ArrowUp');
expect(focusedRowId()).toBe('4');
pressArrow('ArrowUp');
expect(focusedRowId()).toBe('3');
pressArrow('ArrowUp');
expect(focusedRowId()).toBe('2');
pressArrow('ArrowUp');
expect(focusedRowId()).toBe('1');
});
it('walks back down out of a revealed blocked group', () => {
mountRows([
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
{messageId: '2', idPrefix: 'blocked-messages'},
{messageId: '3', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
]);
render((messageId) => {
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
});
pressArrow('ArrowDown');
expect(focusedRowId()).toBe('1');
pressArrow('ArrowDown');
expect(focusedRowId()).toBe('2');
pressArrow('ArrowDown');
expect(focusedRowId()).toBe('3');
});
it('keeps navigating when the focus delegate cannot resolve the target element', () => {
mountRows([
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
{messageId: '2', idPrefix: 'blocked-messages'},
{messageId: '3', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
]);
const onFocusMessage = vi.fn<(messageId: string) => void>();
render(onFocusMessage);
pressArrow('ArrowUp');
expect(onFocusMessage).toHaveBeenLastCalledWith('3');
expect(focusedRowId()).toBe('3');
pressArrow('ArrowUp');
expect(onFocusMessage).toHaveBeenLastCalledWith('2');
expect(focusedRowId()).toBe('2');
});
it('leaves scrolling to the delegate when the delegate did move focus', () => {
mountRows([{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}]);
const scrollIntoView = vi.fn();
vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
render((messageId) => {
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
});
pressArrow('ArrowUp');
expect(focusedRowId()).toBe('1');
expect(scrollIntoView).not.toHaveBeenCalled();
});
it('scrolls the target into view itself when the delegate did nothing', () => {
mountRows([{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}]);
const scrollIntoView = vi.fn();
vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
render(vi.fn());
pressArrow('ArrowUp');
expect(scrollIntoView).toHaveBeenCalledTimes(1);
});
it('keeps navigating while a checkbox inside the list holds focus', () => {
mountRows([
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
{messageId: '2', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
]);
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
viewport.append(checkbox);
render((messageId) => {
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
});
checkbox.focus();
pressArrow('ArrowUp');
expect(focusedRowId()).toBe('2');
});
it('stops navigating while a text input inside the list holds focus', () => {
mountRows([
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
{messageId: '2', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
]);
const textInput = document.createElement('input');
textInput.type = 'text';
viewport.append(textInput);
render((messageId) => {
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
});
textInput.focus();
pressArrow('ArrowUp');
expect(focusedRowId()).toBeNull();
});
it('skips messages that a collapsed group has removed from the DOM', () => {
mountRows([
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
{messageId: '4', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
]);
render((messageId) => {
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
});
pressArrow('ArrowUp');
expect(focusedRowId()).toBe('4');
pressArrow('ArrowUp');
expect(focusedRowId()).toBe('1');
});
});
describe('useMessageListKeyboardNavigation control arm', () => {
beforeEach(() => {
rolloutMock.enabled = false;
});
it('stops at a row the focus delegate cannot resolve', () => {
mountRows([
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
{messageId: '2', idPrefix: 'blocked-messages'},
]);
const onFocusMessage = vi.fn<(messageId: string) => void>();
render(onFocusMessage);
pressArrow('ArrowUp');
expect(onFocusMessage).toHaveBeenLastCalledWith('2');
expect(focusedRowId()).toBeNull();
});
it('never scrolls the target itself when a delegate is supplied', () => {
mountRows([{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX}]);
const scrollIntoView = vi.fn();
vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(scrollIntoView);
render(vi.fn());
pressArrow('ArrowUp');
expect(scrollIntoView).not.toHaveBeenCalled();
});
it('treats a focused checkbox as editable and stops navigating', () => {
mountRows([
{messageId: '1', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
{messageId: '2', idPrefix: CHANNEL_MESSAGE_ID_PREFIX},
]);
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
viewport.append(checkbox);
render((messageId) => {
findMessageElement(document, viewport, CHANNEL_ID, messageId)?.focus({preventScroll: true});
});
checkbox.focus();
pressArrow('ArrowUp');
expect(focusedRowId()).toBeNull();
});
});
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {isEditableElement} from '@app/features/app/keybindings/utils/EditableElement';
import MessageFocus from '@app/features/messaging/state/MessageFocus';
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
import {getMessageSelector} from '@app/features/messaging/utils/MessageNodeSelectors';
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import {type RefObject, useEffect} from 'react';
@@ -52,17 +55,6 @@ const EMPTY_MESSAGE_NODES_SNAPSHOT: MessageNodesSnapshot = {
selector: '',
ts: 0,
};
const escapeSelectorValue = (value: string): string => {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(value);
}
return value.replace(/\\/gu, '\\\\').replace(/"/gu, '\\"');
};
const getMessageSelector = (channelId?: string, messageId?: string): string => {
const channelSelector = channelId ? `[data-channel-id="${escapeSelectorValue(channelId)}"]` : '[data-channel-id]';
const messageSelector = messageId ? `[data-message-id="${escapeSelectorValue(messageId)}"]` : '[data-message-id]';
return `${channelSelector}${messageSelector}`;
};
export function useMessageListKeyboardNavigation(options: MessageListKeyboardNavigationOptions): void {
const {
@@ -78,6 +70,7 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
allowWhenInactive = false,
} = options;
const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
const keyboardNavigationEnabled = MessageKeyboardFocusRollout.enabled;
useEffect(() => {
if (!keyboardModeEnabled) return;
let messageNodesCache: MessageNodesSnapshot = EMPTY_MESSAGE_NODES_SNAPSHOT;
@@ -140,10 +133,19 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
};
return messageNodesCache;
};
const hasFocusInside = (node: HTMLElement): boolean => {
const activeElement = node.ownerDocument?.activeElement ?? document.activeElement;
return activeElement != null && (activeElement === node || node.contains(activeElement));
};
const focusNode = (node: HTMLElement, messageId: string) => {
if (onFocusMessage) {
onFocusMessage(messageId);
return;
if (!keyboardNavigationEnabled || hasFocusInside(node)) {
return;
}
}
if (keyboardNavigationEnabled && node.tabIndex < 0) {
node.tabIndex = -1;
}
node.focus({preventScroll: true});
node.scrollIntoView({block: 'nearest', inline: 'nearest'});
@@ -197,7 +199,10 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!keyboardModeEnabled) return;
if (isEditableTarget(document.activeElement)) return;
const activeElementIsEditable = keyboardNavigationEnabled
? isEditableElement(document.activeElement)
: isEditableTarget(document.activeElement);
if (activeElementIsEditable) return;
const delta = event.key === 'ArrowUp' ? -1 : event.key === 'ArrowDown' ? 1 : 0;
const isNavigationKey = delta !== 0;
if (isNavigationKey && hasShortcutModifier(event)) return;
@@ -226,6 +231,7 @@ export function useMessageListKeyboardNavigation(options: MessageListKeyboardNav
};
}, [
keyboardModeEnabled,
keyboardNavigationEnabled,
containerRef,
channelId,
onFocusMessage,
@@ -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_KEYBOARD_FOCUS_ASSIGNMENT,
type MessageKeyboardFocusAssignmentResponse,
} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
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 {MessageKeyboardFocusRollout} = await import('@app/features/messaging/state/MessageKeyboardFocusRollout');
function publish(assignment: MessageKeyboardFocusAssignmentResponse): void {
const response: ExperimentAssignmentsResponse = {
poll_interval_seconds: 300,
poll_jitter_percent: 15,
assignments: {message_keyboard_focus: assignment},
};
runInAction(() => {
ExperimentAssignments.response = response;
});
}
afterEach(() => {
runInAction(() => {
ExperimentAssignments.response = INERT_EXPERIMENT_ASSIGNMENTS_RESPONSE;
});
});
describe('MessageKeyboardFocusRollout', () => {
it('reads the inert assignment out of the inert envelope', () => {
expect(MessageKeyboardFocusRollout.assignment).toBe(INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT);
expect(MessageKeyboardFocusRollout.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(MessageKeyboardFocusRollout.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(MessageKeyboardFocusRollout.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(MessageKeyboardFocusRollout.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(MessageKeyboardFocusRollout.enabled).toBe(true);
ExperimentAssignments.reset();
expect(MessageKeyboardFocusRollout.enabled).toBe(false);
});
});
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import ExperimentAssignments from '@app/features/experiment/state/ExperimentAssignments';
import {readMessageKeyboardFocusAssignment} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
import type {MessageKeyboardFocusAssignmentResponse} from '@fluxer/schema/src/domains/experiment/MessageKeyboardFocusSchemas';
class MessageKeyboardFocusRolloutSelector {
get assignment(): MessageKeyboardFocusAssignmentResponse {
return readMessageKeyboardFocusAssignment(ExperimentAssignments.response);
}
get enabled(): boolean {
const assignment = this.assignment;
return assignment.enabled && assignment.user_targeted;
}
}
export const MessageKeyboardFocusRollout = new MessageKeyboardFocusRolloutSelector();
export default MessageKeyboardFocusRollout;
@@ -0,0 +1,120 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
CHANNEL_MESSAGE_ID_PREFIX,
findMessageElement,
getMessageSelector,
} from '@app/features/messaging/utils/MessageNodeSelectors';
import {afterEach, describe, expect, it} from 'vitest';
const CHANNEL_ID = '900000000000000001';
function messageRow(messageId: string, idPrefix: string): HTMLElement {
const row = document.createElement('div');
row.id = `${idPrefix}-${CHANNEL_ID}-${messageId}`;
row.dataset.messageId = messageId;
row.dataset.channelId = CHANNEL_ID;
return row;
}
function viewport(): HTMLElement {
const element = document.createElement('div');
document.body.append(element);
return element;
}
describe('MessageNodeSelectors', () => {
afterEach(() => {
document.body.replaceChildren();
});
it('resolves a normal stream row through the id fast path', () => {
const scroller = viewport();
const row = messageRow('1', CHANNEL_MESSAGE_ID_PREFIX);
scroller.append(row);
expect(findMessageElement(document, scroller, CHANNEL_ID, '1')).toBe(row);
});
it('resolves a revealed blocked group row that carries a different id prefix', () => {
const scroller = viewport();
scroller.append(messageRow('1', CHANNEL_MESSAGE_ID_PREFIX));
const blockedRow = messageRow('2', 'blocked-messages');
scroller.append(blockedRow);
expect(document.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${CHANNEL_ID}-2`)).toBeNull();
expect(findMessageElement(document, scroller, CHANNEL_ID, '2')).toBe(blockedRow);
});
it('resolves a revealed spammer group row', () => {
const scroller = viewport();
const spammerRow = messageRow('3', 'spammer-messages');
scroller.append(spammerRow);
expect(findMessageElement(document, scroller, CHANNEL_ID, '3')).toBe(spammerRow);
});
it('falls back to the viewport-scoped lookup and ignores rows outside it', () => {
const scroller = viewport();
const searchPanel = viewport();
searchPanel.append(messageRow('4', 'blocked-messages'));
expect(document.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${CHANNEL_ID}-4`)).toBeNull();
expect(findMessageElement(document, scroller, CHANNEL_ID, '4')).toBeNull();
});
it('resolves a row through the document-wide id lookup even when it sits outside the viewport', () => {
const scroller = viewport();
const popout = viewport();
const inPopout = messageRow('8', CHANNEL_MESSAGE_ID_PREFIX);
popout.append(inPopout);
expect(findMessageElement(document, scroller, CHANNEL_ID, '8')).toBe(inPopout);
});
it('prefers the row inside the viewport when neither row carries the canonical id', () => {
const scroller = viewport();
const inStream = messageRow('5', 'blocked-messages');
scroller.append(inStream);
const searchPanel = viewport();
const inSearch = messageRow('5', 'search-messages');
searchPanel.append(inSearch);
expect(document.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${CHANNEL_ID}-5`)).toBeNull();
expect(findMessageElement(document, scroller, CHANNEL_ID, '5')).toBe(inStream);
});
it('does not resolve a row belonging to another channel', () => {
const scroller = viewport();
const foreign = document.createElement('div');
foreign.dataset.messageId = '6';
foreign.dataset.channelId = '900000000000000002';
scroller.append(foreign);
expect(findMessageElement(document, scroller, CHANNEL_ID, '6')).toBeNull();
});
it('requires both data attributes so message group row wrappers are not candidates', () => {
const scroller = viewport();
const groupWrapper = document.createElement('div');
groupWrapper.dataset.messageId = '7';
const row = messageRow('7', CHANNEL_MESSAGE_ID_PREFIX);
groupWrapper.append(row);
scroller.append(groupWrapper);
const matches = scroller.querySelectorAll<HTMLElement>(getMessageSelector(CHANNEL_ID));
expect(Array.from(matches)).toEqual([row]);
});
it('leaves snowflake ids untouched instead of emitting identifier escapes', () => {
expect(getMessageSelector(CHANNEL_ID, '900000000000000009')).toBe(
'[data-channel-id="900000000000000001"][data-message-id="900000000000000009"]',
);
});
it('escapes quotes and backslashes so an id can never break out of the attribute selector', () => {
expect(getMessageSelector(undefined, 'a"]b')).toBe('[data-channel-id][data-message-id="a\\"]b"]');
expect(getMessageSelector(undefined, 'a\\b')).toBe('[data-channel-id][data-message-id="a\\\\b"]');
});
});
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export const CHANNEL_MESSAGE_ID_PREFIX = 'chat-messages';
export const escapeSelectorValue = (value: string): string =>
value.replace(/[\\"]/gu, '\\$&').replace(/[\n\r\f]/gu, (char) => `\\${char.charCodeAt(0).toString(16)} `);
export const getMessageSelector = (channelId?: string, messageId?: string): string => {
const channelSelector = channelId ? `[data-channel-id="${escapeSelectorValue(channelId)}"]` : '[data-channel-id]';
const messageSelector = messageId ? `[data-message-id="${escapeSelectorValue(messageId)}"]` : '[data-message-id]';
return `${channelSelector}${messageSelector}`;
};
export const findMessageElement = (
doc: Document | null | undefined,
viewport: HTMLElement | null | undefined,
channelId: string,
messageId: string,
): HTMLElement | null => {
const byId = doc?.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${channelId}-${messageId}`) ?? null;
if (byId != null) {
return byId as HTMLElement;
}
return viewport?.querySelector<HTMLElement>(getMessageSelector(channelId, messageId)) ?? null;
};
@@ -4,7 +4,9 @@ import Accessibility from '@app/features/accessibility/state/Accessibility';
import type {Channel} from '@app/features/channel/models/Channel';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
import MessageKeyboardFocusRollout from '@app/features/messaging/state/MessageKeyboardFocusRollout';
import Messages from '@app/features/messaging/state/MessagingMessages';
import {CHANNEL_MESSAGE_ID_PREFIX, findMessageElement} from '@app/features/messaging/utils/MessageNodeSelectors';
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
import Navigation from '@app/features/navigation/state/Navigation';
import {evaluateScrollPinning, type ScrollPinResult} from '@app/features/platform/utils/ScrollPosition';
@@ -210,10 +212,12 @@ export class ScrollManager {
layoutGetElementFromMessageId(messageId: string): HTMLElement | null {
const doc = this.scrollGetDocument();
const {channel} = this.props;
if (!doc) return null;
const elementId = `chat-messages-${channel.id}-${messageId}`;
return doc.getElementById(elementId) as HTMLElement | null;
const {channel} = this.props;
if (!MessageKeyboardFocusRollout.enabled) {
return doc.getElementById(`${CHANNEL_MESSAGE_ID_PREFIX}-${channel.id}-${messageId}`) as HTMLElement | null;
}
return findMessageElement(doc, this.ref.current?.getViewportElement(), channel.id, messageId);
}
private layoutGetContainerLayout(container: HTMLElement): ContainerLayout {
@@ -0,0 +1,290 @@
// @vitest-environment happy-dom
// SPDX-License-Identifier: AGPL-3.0-or-later
import FocusRingContext, {type FocusRingContextManager} from '@app/features/ui/focus_ring/FocusRingContext';
import FocusRingManager from '@app/features/ui/focus_ring/FocusRingManager';
import FocusRingScope from '@app/features/ui/focus_ring/FocusRingScope';
import {act, useContext, useRef} from 'react';
import {createRoot, type Root} from 'react-dom/client';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
const RING_SELECTOR = '[data-flx="ui.focus-ring.focus-ring-scope.ring.focus-ring"]';
(globalThis as {IS_REACT_ACT_ENVIRONMENT?: boolean}).IS_REACT_ACT_ENVIRONMENT = true;
type Slot = 'primary' | 'secondary';
let container: HTMLDivElement;
let root: Root;
let unmounted = false;
const ringContexts: Partial<Record<Slot, FocusRingContextManager>> = {};
const targets: Partial<Record<Slot, HTMLDivElement>> = {};
function Capture({slot = 'primary'}: {slot?: Slot}) {
ringContexts[slot] = useContext(FocusRingContext);
return (
<div
ref={(element) => {
if (element != null) targets[slot] = element;
}}
data-flx="ui.focus-ring.focus-ring-scope-test.capture.div"
/>
);
}
function Harness({revision = 0}: {revision?: number}) {
const containerRef = useRef<HTMLDivElement>(null);
return (
<div ref={containerRef} data-revision={revision} data-flx="ui.focus-ring.focus-ring-scope-test.harness.div">
<FocusRingScope
containerRef={containerRef}
data-flx="ui.focus-ring.focus-ring-scope-test.harness.focus-ring-scope"
>
<Capture data-flx="ui.focus-ring.focus-ring-scope-test.harness.capture" />
</FocusRingScope>
</div>
);
}
function ScopePair() {
const primaryRef = useRef<HTMLDivElement>(null);
const secondaryRef = useRef<HTMLDivElement>(null);
return (
<>
<div ref={primaryRef} data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.div">
<FocusRingScope
containerRef={primaryRef}
data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.focus-ring-scope"
>
<Capture slot="primary" data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.capture" />
</FocusRingScope>
</div>
<div ref={secondaryRef} data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.div--2">
<FocusRingScope
containerRef={secondaryRef}
data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.focus-ring-scope--2"
>
<Capture slot="secondary" data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair.capture--2" />
</FocusRingScope>
</div>
</>
);
}
function rings(): Array<Element> {
return Array.from(container.querySelectorAll(RING_SELECTOR));
}
function ring(): Element | null {
return container.querySelector(RING_SELECTOR);
}
function requireRing(): HTMLElement {
const element = ring();
if (element == null) throw new Error('The focus ring is not painted');
return element as HTMLElement;
}
function requireRingContext(slot: Slot = 'primary'): FocusRingContextManager {
const value = ringContexts[slot];
if (value == null) throw new Error(`The focus ring context for ${slot} never rendered`);
return value;
}
function requireTarget(slot: Slot = 'primary'): HTMLDivElement {
const value = targets[slot];
if (value == null) throw new Error(`The focus ring target for ${slot} never rendered`);
return value;
}
function requireScopeContainer(): HTMLElement {
const element = container.firstElementChild;
if (element == null) throw new Error('The scope container never rendered');
return element as HTMLElement;
}
function domRect(top: number, left: number, width: number, height: number): DOMRect {
return {
top,
left,
width,
height,
right: left + width,
bottom: top + height,
x: left,
y: top,
toJSON: () => ({}),
} as DOMRect;
}
function stubBoundingRect(element: Element, read: () => DOMRect) {
Object.defineProperty(element, 'getBoundingClientRect', {value: read, configurable: true});
}
function unmountRoot() {
if (unmounted) return;
unmounted = true;
act(() => {
root.unmount();
});
}
beforeEach(() => {
delete ringContexts.primary;
delete ringContexts.secondary;
delete targets.primary;
delete targets.secondary;
unmounted = false;
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});
afterEach(() => {
unmountRoot();
container.remove();
FocusRingManager.setRingsEnabled(true);
});
describe('FocusRingScope', () => {
test('paints the ring once rings are enabled, without a second focus event', () => {
FocusRingManager.setRingsEnabled(false);
act(() => {
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness" />);
});
act(() => {
requireRingContext().showForElement(requireTarget());
});
expect(ring()).toBeNull();
act(() => {
FocusRingManager.setRingsEnabled(true);
});
expect(ring()).not.toBeNull();
});
test('restores the ring for a target that never blurred while rings were disabled', () => {
FocusRingManager.setRingsEnabled(true);
act(() => {
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--2" />);
});
act(() => {
requireRingContext().showForElement(requireTarget());
});
expect(ring()).not.toBeNull();
act(() => {
FocusRingManager.setRingsEnabled(false);
});
expect(ring()).toBeNull();
act(() => {
FocusRingManager.setRingsEnabled(true);
});
expect(ring()).not.toBeNull();
});
test('keeps the ring hidden when nothing is showing', () => {
FocusRingManager.setRingsEnabled(false);
act(() => {
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--3" />);
});
act(() => {
FocusRingManager.setRingsEnabled(true);
});
expect(ring()).toBeNull();
});
test('paints the ring on show and removes it on hide', () => {
act(() => {
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--4" />);
});
expect(ring()).toBeNull();
act(() => {
requireRingContext().showForElement(requireTarget());
});
expect(ring()).not.toBeNull();
act(() => {
requireRingContext().hide();
});
expect(ring()).toBeNull();
});
test('repositions the ring when a parent render moves a target that did not resize', () => {
act(() => {
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--5" />);
});
stubBoundingRect(requireScopeContainer(), () => domRect(0, 0, 800, 600));
let targetTop = 200;
stubBoundingRect(requireTarget(), () => domRect(targetTop, 0, 300, 40));
act(() => {
requireRingContext().showForElement(requireTarget());
});
expect(requireRing().style.top).toBe('200px');
targetTop = 320;
act(() => {
root.render(<Harness revision={1} data-flx="ui.focus-ring.focus-ring-scope-test.harness--6" />);
});
expect(requireRing().style.top).toBe('320px');
});
test('repositions the ring when the target itself resizes, and stops observing on unmount', () => {
const realResizeObserver = globalThis.ResizeObserver;
const realRequestAnimationFrame = globalThis.requestAnimationFrame;
const realCancelAnimationFrame = globalThis.cancelAnimationFrame;
let notifyResize: (() => void) | null = null;
let disconnectCount = 0;
class StubResizeObserver {
constructor(callback: () => void) {
notifyResize = callback;
}
observe() {}
unobserve() {}
disconnect() {
disconnectCount += 1;
}
}
globalThis.ResizeObserver = StubResizeObserver as unknown as typeof ResizeObserver;
globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => {
callback(0);
return 1;
}) as typeof requestAnimationFrame;
globalThis.cancelAnimationFrame = (() => undefined) as typeof cancelAnimationFrame;
try {
act(() => {
root.render(<Harness data-flx="ui.focus-ring.focus-ring-scope-test.harness--7" />);
});
stubBoundingRect(requireScopeContainer(), () => domRect(0, 0, 800, 600));
let targetHeight = 40;
stubBoundingRect(requireTarget(), () => domRect(100, 0, 300, targetHeight));
act(() => {
requireRingContext().showForElement(requireTarget());
});
expect(requireRing().style.height).toBe('40px');
targetHeight = 90;
act(() => {
notifyResize?.();
});
expect(requireRing().style.height).toBe('90px');
unmountRoot();
expect(disconnectCount).toBe(1);
} finally {
globalThis.ResizeObserver = realResizeObserver;
globalThis.requestAnimationFrame = realRequestAnimationFrame;
globalThis.cancelAnimationFrame = realCancelAnimationFrame;
}
});
test('drops the ring from the previous scope when another scope takes focus', () => {
act(() => {
root.render(<ScopePair data-flx="ui.focus-ring.focus-ring-scope-test.scope-pair" />);
});
act(() => {
requireRingContext('primary').showForElement(requireTarget('primary'));
});
expect(rings()).toHaveLength(1);
act(() => {
requireRingContext('secondary').showForElement(requireTarget('secondary'));
});
expect(rings()).toHaveLength(1);
expect(requireRingContext('primary').visible).toBe(false);
expect(requireRingContext('secondary').visible).toBe(true);
});
});