Files
fluxer/fluxer_app/src/features/messaging/hooks/useMessageListKeyboardNavigation.ts
T

252 lines
7.9 KiB
TypeScript

// 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';
interface MessageListKeyboardNavigationOptions {
containerRef?: RefObject<ScrollerHandle | HTMLElement | null>;
channelId?: string;
onFocusMessage?: (messageId: string) => void;
onLoadMoreBefore?: () => void;
onLoadMoreAfter?: () => void;
hasMoreBefore?: boolean;
hasMoreAfter?: boolean;
isLoadingMore?: boolean;
onEscape?: () => void;
onNavigatePastNewest?: () => void;
allowWhenInactive?: boolean;
}
const getViewportElement = (value: ScrollerHandle | HTMLElement | null | undefined): HTMLElement | null => {
if (!value) return null;
if ('getViewportElement' in value && typeof value.getViewportElement === 'function') {
return value.getViewportElement();
}
if (value instanceof HTMLElement) {
return value;
}
return null;
};
const isEditableTarget = (target: Element | null): boolean => {
if (!target) return false;
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return true;
return target instanceof HTMLElement && target.isContentEditable;
};
const hasShortcutModifier = (event: KeyboardEvent): boolean =>
event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
const MESSAGE_NODE_CACHE_TTL_MS = 120;
interface MessageNodesSnapshot {
nodes: Array<HTMLElement>;
indexById: Map<string, number>;
root: ParentNode | null;
selector: string;
ts: number;
}
const EMPTY_MESSAGE_NODES_SNAPSHOT: MessageNodesSnapshot = {
nodes: [],
indexById: new Map(),
root: null,
selector: '',
ts: 0,
};
export function useMessageListKeyboardNavigation(options: MessageListKeyboardNavigationOptions): void {
const {
containerRef,
channelId,
onFocusMessage,
onLoadMoreBefore,
onLoadMoreAfter,
hasMoreBefore = false,
hasMoreAfter = false,
isLoadingMore = false,
onEscape,
onNavigatePastNewest,
allowWhenInactive = false,
} = options;
const keyboardModeEnabled = KeyboardMode.keyboardModeEnabled;
const keyboardNavigationEnabled = MessageKeyboardFocusRollout.enabled;
useEffect(() => {
if (!keyboardModeEnabled) return;
let messageNodesCache: MessageNodesSnapshot = EMPTY_MESSAGE_NODES_SNAPSHOT;
let observedRoot: ParentNode | null = null;
let observer: MutationObserver | null = null;
const invalidateMessageNodesCache = () => {
messageNodesCache = {
...messageNodesCache,
nodes: [],
indexById: new Map(),
ts: 0,
};
};
const observeRoot = (root: ParentNode) => {
if (observedRoot === root || typeof MutationObserver === 'undefined') {
return;
}
observer?.disconnect();
observedRoot = root;
const target = root instanceof Document ? (root.body ?? root.documentElement) : root;
observer = new MutationObserver(invalidateMessageNodesCache);
observer.observe(target, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['data-message-id', 'data-channel-id'],
});
};
const getMessageElementsSnapshot = (): MessageNodesSnapshot => {
const now = Date.now();
const cache = messageNodesCache;
const container = getViewportElement(containerRef?.current ?? null);
if (!container && containerRef) {
messageNodesCache = {
...EMPTY_MESSAGE_NODES_SNAPSHOT,
ts: now,
};
return messageNodesCache;
}
const root = container ?? document;
const selector = getMessageSelector(channelId);
if (cache.root === root && cache.selector === selector && now - cache.ts < MESSAGE_NODE_CACHE_TTL_MS) {
return cache;
}
observeRoot(root);
const nodes = Array.from(root.querySelectorAll<HTMLElement>(selector));
const indexById = new Map<string, number>();
for (let i = 0; i < nodes.length; i++) {
const messageId = nodes[i]?.dataset.messageId;
if (messageId) {
indexById.set(messageId, i);
}
}
messageNodesCache = {
nodes,
indexById,
root,
selector,
ts: now,
};
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);
if (!keyboardNavigationEnabled || hasFocusInside(node)) {
return;
}
}
if (keyboardNavigationEnabled && node.tabIndex < 0) {
node.tabIndex = -1;
}
node.focus({preventScroll: true});
node.scrollIntoView({block: 'nearest', inline: 'nearest'});
};
const focusByDelta = (delta: number) => {
const {nodes, indexById} = getMessageElementsSnapshot();
if (!nodes.length) return;
const activeId = MessageFocus.focusedMessageId ?? null;
let idx = activeId ? (indexById.get(activeId) ?? -1) : -1;
if (idx === -1) {
idx = delta > 0 ? -1 : nodes.length;
}
const nextIdx = idx + delta;
if (nextIdx < 0) {
if (hasMoreBefore && onLoadMoreBefore && !isLoadingMore) {
onLoadMoreBefore();
}
return;
}
if (nextIdx >= nodes.length) {
if (hasMoreAfter && onLoadMoreAfter && !isLoadingMore) {
onLoadMoreAfter();
} else if (keyboardNavigationEnabled && !hasMoreAfter && onNavigatePastNewest) {
onNavigatePastNewest();
}
return;
}
const nextNode = nodes[nextIdx];
const nextId = nextNode?.dataset?.messageId;
if (nextId) {
focusNode(nextNode, nextId);
}
};
const isFocusedMessageInContainer = (container: HTMLElement): boolean => {
const focusedId = MessageFocus.focusedMessageId;
if (!focusedId) {
return false;
}
return container.querySelector(getMessageSelector(channelId, focusedId)) != null;
};
const canHandleInsideContainer = (container: HTMLElement): boolean => {
const activeElement = container.ownerDocument?.activeElement ?? document.activeElement;
if (activeElement && container.contains(activeElement)) {
return true;
}
if (isFocusedMessageInContainer(container)) {
return true;
}
return (
allowWhenInactive &&
(activeElement === document.body || activeElement === document.documentElement || !activeElement)
);
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!keyboardModeEnabled) 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;
if (!isNavigationKey && event.key !== 'Escape') return;
const container = getViewportElement(containerRef?.current ?? null);
if (container && !canHandleInsideContainer(container)) {
return;
}
if (isNavigationKey) {
event.preventDefault();
focusByDelta(delta);
return;
}
if (onEscape) {
event.preventDefault();
onEscape();
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => {
window.removeEventListener('keydown', handleKeyDown, true);
observer?.disconnect();
observer = null;
observedRoot = null;
messageNodesCache = EMPTY_MESSAGE_NODES_SNAPSHOT;
};
}, [
keyboardModeEnabled,
keyboardNavigationEnabled,
containerRef,
channelId,
onFocusMessage,
onLoadMoreBefore,
onLoadMoreAfter,
hasMoreBefore,
hasMoreAfter,
isLoadingMore,
onEscape,
onNavigatePastNewest,
allowWhenInactive,
]);
}