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

@@ -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);
});
});