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