mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
790 lines
30 KiB
TypeScript
790 lines
30 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
|
import {useMessageListPlaceholderSpecs} from '@app/features/app/components/skeleton/PlaceholderSpecs';
|
|
import {ScrollFillerSkeleton} from '@app/features/app/components/skeleton/ScrollFillerSkeleton';
|
|
import {reportSkeletonMessagePresentation} from '@app/features/app/components/skeleton/SkeletonLayoutMemory';
|
|
import {measureSkeletonHeightPx, useSkeletonLayoutReport} from '@app/features/app/hooks/useSkeletonLayoutMemoryCapture';
|
|
import {renderChannelStream} from '@app/features/channel/components/ChannelMessageStream';
|
|
import styles from '@app/features/channel/components/ChannelMessages.module.css';
|
|
import {ChannelWelcomeSection} from '@app/features/channel/components/ChannelWelcomeSection';
|
|
import {CollapsedMessageVisibilityProvider} from '@app/features/channel/components/CollapsedMessageVisibilityContext';
|
|
import {NewMessagesBar} from '@app/features/channel/components/NewMessagesBar';
|
|
import {UploadManager} from '@app/features/channel/components/UploadManager';
|
|
import type {Channel} from '@app/features/channel/models/Channel';
|
|
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
|
import GuildVerification from '@app/features/guild/state/GuildVerification';
|
|
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
|
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
|
import {useMessageListKeyboardNavigation} from '@app/features/messaging/hooks/useMessageListKeyboardNavigation';
|
|
import {useMessageSelectionCopyForMessageGetter} from '@app/features/messaging/hooks/useMessageSelectionCopy';
|
|
import {NearViewportSurfaceContext} from '@app/features/messaging/hooks/useNearViewport';
|
|
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
|
import {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
|
|
import {
|
|
resolveChannelMessagesWindowStatus,
|
|
selectChannelMessagesFillerVisible,
|
|
selectChannelMessagesSpacerHeight,
|
|
selectChannelMessagesWindowBar,
|
|
} from '@app/features/messaging/state/ChannelMessagesLoadStateMachine';
|
|
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
|
import MessageFocus from '@app/features/messaging/state/MessageFocus';
|
|
import MessagesState from '@app/features/messaging/state/MessagingMessages';
|
|
import {
|
|
type ChannelStreamItem,
|
|
createChannelStream,
|
|
getCollapsedMessageGroupKey,
|
|
} from '@app/features/messaging/utils/MessageGroupingUtils';
|
|
import LocalUserSpamOverride from '@app/features/moderation/state/LocalUserSpamOverride';
|
|
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
|
import Permission from '@app/features/permissions/state/Permission';
|
|
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
|
import {useScrollManager} from '@app/features/platform/utils/ScrollManager';
|
|
import * as ReadStateCommands from '@app/features/read_state/commands/ReadStateCommands';
|
|
import ReadStates from '@app/features/read_state/state/ReadStates';
|
|
import {shouldAutoAck} from '@app/features/read_state/utils/AutoAckPredicate';
|
|
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
|
import {Button} from '@app/features/ui/button/Button';
|
|
import {Scroller} from '@app/features/ui/components/Scroller';
|
|
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
|
import MediaViewer from '@app/features/ui/state/MediaViewer';
|
|
import Modal from '@app/features/ui/state/Modal';
|
|
import type {User} from '@app/features/user/models/User';
|
|
import UserSettings from '@app/features/user/state/UserSettings';
|
|
import Users from '@app/features/user/state/Users';
|
|
import Window from '@app/features/window/state/Window';
|
|
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
|
import {MAX_MESSAGES_PER_CHANNEL} from '@fluxer/constants/src/LimitConstants';
|
|
import {extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
|
|
import {msg} from '@lingui/core/macro';
|
|
import {useLingui} from '@lingui/react/macro';
|
|
import {clsx} from 'clsx';
|
|
import {runInAction} from 'mobx';
|
|
import {observer, useLocalObservable} from 'mobx-react-lite';
|
|
import type React from 'react';
|
|
import {useCallback, useEffect, useMemo, useRef} from 'react';
|
|
|
|
const MESSAGE_LIST_FOR_DESCRIPTOR = msg({
|
|
message: 'Message list for {channelName}',
|
|
comment: 'Label in the channel and chat messages. Preserve {channelName}; it is inserted by code.',
|
|
});
|
|
const MESSAGE_LIST_DESCRIPTOR = msg({
|
|
message: 'Message list',
|
|
comment: 'Short label in the channel and chat messages. Keep it concise.',
|
|
});
|
|
const YOU_RE_VIEWING_OLDER_MESSAGES_DESCRIPTOR = msg({
|
|
message: "You're viewing older messages",
|
|
comment: 'Label in the channel and chat messages.',
|
|
});
|
|
const JUMP_TO_PRESENT_DESCRIPTOR = msg({
|
|
message: 'Jump to present',
|
|
comment: 'Short label in the channel and chat messages. Keep it concise.',
|
|
});
|
|
const MESSAGES_FAILED_TO_LOAD_DESCRIPTOR = msg({
|
|
message: 'Messages failed to load',
|
|
comment: 'Error message in the channel and chat messages.',
|
|
});
|
|
|
|
function checkPermissions(channel: Channel) {
|
|
const canSendMessages = Permission.can(Permissions.SEND_MESSAGES, channel);
|
|
const passesVerification = channel.isPrivate() || GuildVerification.canAccessGuild(channel.guildId || '');
|
|
const canChat = channel.isPrivate() || (canSendMessages && passesVerification);
|
|
const canAttachFiles = channel.isPrivate() ? canChat : canChat && Permission.can(Permissions.ATTACH_FILES, channel);
|
|
const canManageMessages = Permission.can(Permissions.MANAGE_MESSAGES, channel);
|
|
return {canSendMessages, canChat, canAttachFiles, canManageMessages};
|
|
}
|
|
|
|
interface MessagesStateSnapshot {
|
|
unreadCount: number;
|
|
oldestUnreadMessageId: string | null;
|
|
visualUnreadMessageId: string | null;
|
|
ackMessageId: string | null;
|
|
lastReadStateMessageId: string | null;
|
|
messages: ChannelMessages;
|
|
messageVersion: number;
|
|
unblurredMessageId: string | null;
|
|
permissionVersion: number;
|
|
messageGroupSpacing: number;
|
|
fontSize: number;
|
|
messageDisplayCompact: boolean;
|
|
editingMessageId: string | null;
|
|
currentUser: User | undefined;
|
|
isEstimated: boolean;
|
|
ackedManually: boolean;
|
|
}
|
|
|
|
interface MessagesProps {
|
|
channel: Channel;
|
|
onBottomBarVisibilityChange?: (visible: boolean) => void;
|
|
allowAutoAck?: boolean;
|
|
}
|
|
|
|
type MessagesWrapperStyle = React.CSSProperties & {'--message-group-spacing': string};
|
|
|
|
const readFromState = (channelId: string): MessagesStateSnapshot => {
|
|
const messages = MessagesState.getMessages(channelId);
|
|
const messageDisplayCompact = UserSettings.getMessageDisplayCompact();
|
|
return {
|
|
unreadCount: ReadStates.getUnreadCount(channelId),
|
|
oldestUnreadMessageId: ReadStates.getOldestUnreadMessageId(channelId),
|
|
visualUnreadMessageId: ReadStates.getVisualUnreadMessageId(channelId),
|
|
ackMessageId: ReadStates.ackMessageId(channelId),
|
|
lastReadStateMessageId: ReadStates.lastMessageId(channelId),
|
|
messages,
|
|
messageVersion: messages.version,
|
|
unblurredMessageId: messages.unblurredMessageId,
|
|
permissionVersion: Permission.version,
|
|
messageGroupSpacing: Accessibility.getMessageGroupSpacingValue(messageDisplayCompact),
|
|
fontSize: Accessibility.fontSize,
|
|
messageDisplayCompact,
|
|
editingMessageId: MessageEdit.getEditingMessageId(channelId),
|
|
currentUser: Users.currentUser ?? undefined,
|
|
isEstimated: ReadStates.getIfExists(channelId)?.estimated ?? false,
|
|
ackedManually: ReadStates.getIfExists(channelId)?.ackedManually ?? false,
|
|
};
|
|
};
|
|
|
|
function shallowEqual<T extends object>(a: T, b: T): boolean {
|
|
const aKeys = Object.keys(a);
|
|
if (aKeys.length !== Object.keys(b).length) return false;
|
|
for (const key of aKeys) {
|
|
if ((a as Record<string, unknown>)[key] !== (b as Record<string, unknown>)[key]) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export const Messages = observer(function Messages({
|
|
channel,
|
|
onBottomBarVisibilityChange,
|
|
allowAutoAck = true,
|
|
}: MessagesProps) {
|
|
const {i18n} = useLingui();
|
|
const scrollerInnerRef = useRef<HTMLDivElement | null>(null);
|
|
const scrollerContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const lastStateSnapshotRef = useRef<MessagesStateSnapshot | null>(null);
|
|
const recoveryFetchChannelIdRef = useRef<string | null>(null);
|
|
interface MessageState extends MessagesStateSnapshot {
|
|
highlightedMessageId: string | null;
|
|
isAtBottom: boolean;
|
|
}
|
|
const state = useLocalObservable<MessageState>(() => {
|
|
const initial = readFromState(channel.id);
|
|
lastStateSnapshotRef.current = initial;
|
|
return {
|
|
...initial,
|
|
highlightedMessageId: null,
|
|
isAtBottom: false,
|
|
};
|
|
});
|
|
const windowId = Window.windowId;
|
|
const isWindowFocused = Window.isFocused();
|
|
const isModalOpen = Modal.hasModalOpen();
|
|
const isGatewayConnected = GatewayConnection.isConnected;
|
|
const selectedChannelId = SelectedChannel.currentChannelId;
|
|
const placeholderSpecs = useMessageListPlaceholderSpecs({
|
|
channelId: channel.id,
|
|
compact: state.messageDisplayCompact,
|
|
compactAvatarsVisible: Accessibility.showUserAvatarsInCompactMode,
|
|
groupSpacing: state.messageGroupSpacing,
|
|
});
|
|
const safeMessages = state.messages ?? MessagesState.getMessages(channel.id);
|
|
const windowStatus = resolveChannelMessagesWindowStatus({
|
|
ready: safeMessages.ready,
|
|
loading: safeMessages.loadingMore,
|
|
failed: safeMessages.error,
|
|
messageCount: safeMessages.length,
|
|
hasMoreBefore: safeMessages.hasMoreBefore,
|
|
hasMoreAfter: safeMessages.hasMoreAfter,
|
|
});
|
|
const windowBar = selectChannelMessagesWindowBar(windowStatus);
|
|
const windowNeedsPage = windowStatus.needsPage;
|
|
const canAutoAck = shouldAutoAck({
|
|
channelActive: allowAutoAck,
|
|
windowFocused: isWindowFocused,
|
|
atBottom: true,
|
|
textChatVisible: true,
|
|
manualAck: state.ackedManually,
|
|
blockingModalOpen: isModalOpen || MediaViewer.isOpen,
|
|
});
|
|
const jumpHighlightTimeoutRef = useRef<number | null>(null);
|
|
const lastJumpSequenceKeyRef = useRef<string | null>(null);
|
|
const handleJumpHighlight = useCallback(
|
|
(targetMessageId: string | null, jumpTicket: number) => {
|
|
const jumpSequenceKey = `${channel.id}:${jumpTicket}`;
|
|
if (jumpSequenceKey === lastJumpSequenceKeyRef.current) return;
|
|
lastJumpSequenceKeyRef.current = jumpSequenceKey;
|
|
if (jumpHighlightTimeoutRef.current != null) {
|
|
clearTimeout(jumpHighlightTimeoutRef.current);
|
|
jumpHighlightTimeoutRef.current = null;
|
|
}
|
|
runInAction(() => {
|
|
state.highlightedMessageId = targetMessageId;
|
|
});
|
|
if (!targetMessageId) return;
|
|
jumpHighlightTimeoutRef.current = window.setTimeout(() => {
|
|
runInAction(() => {
|
|
if (state.highlightedMessageId === targetMessageId) {
|
|
state.highlightedMessageId = null;
|
|
}
|
|
});
|
|
jumpHighlightTimeoutRef.current = null;
|
|
}, 2000);
|
|
},
|
|
[channel.id, state],
|
|
);
|
|
const scrollManager = useScrollManager({
|
|
messages: safeMessages,
|
|
channel,
|
|
compact: state.messageDisplayCompact,
|
|
hasPendingUnreads: state.unreadCount > 0,
|
|
focusAnchorId: null,
|
|
unloadedSpacerHeight: selectChannelMessagesSpacerHeight(windowStatus, placeholderSpecs.totalHeight),
|
|
allowHistoryFetch: true,
|
|
windowId,
|
|
notifyPinnedToBottom: () => {
|
|
runInAction(() => {
|
|
state.isAtBottom = true;
|
|
});
|
|
},
|
|
notifyUnpinnedFromBottom: () => {
|
|
runInAction(() => {
|
|
state.isAtBottom = false;
|
|
});
|
|
},
|
|
extraListPadding: 48,
|
|
canAutoAck,
|
|
handleJumpHighlight,
|
|
});
|
|
const resolveMessageScrollSurface = useMemo(
|
|
() => () => scrollManager.ref.current?.getViewportElement() ?? null,
|
|
[scrollManager],
|
|
);
|
|
useEffect(() => {
|
|
ChannelMessages.retainChannel(channel.id);
|
|
return () => {
|
|
ChannelMessages.releaseRetainedChannel(channel.id);
|
|
};
|
|
}, [channel.id]);
|
|
const updateFromState = useCallback(() => {
|
|
const snapshot = readFromState(channel.id);
|
|
const previous = lastStateSnapshotRef.current;
|
|
if (previous && shallowEqual(previous, snapshot)) return;
|
|
runInAction(() => {
|
|
Object.assign(state, snapshot);
|
|
});
|
|
lastStateSnapshotRef.current = snapshot;
|
|
}, [channel.id, state]);
|
|
const onMessageEdit = useCallback(
|
|
(targetNode: HTMLElement) => {
|
|
const scrollerNode = scrollManager.ref.current?.getViewportElement();
|
|
if (!scrollerNode) return;
|
|
if (scrollManager.pinIsAtBottomNow()) {
|
|
return;
|
|
}
|
|
if (KeyboardMode.keyboardModeEnabled) {
|
|
const focusedMessageId = MessageFocus.focusedMessageId;
|
|
const focusedChannelId = MessageFocus.focusedChannelId;
|
|
const editedMessageId = targetNode.getAttribute('data-message-id');
|
|
if (focusedChannelId === channel.id && focusedMessageId && editedMessageId === focusedMessageId) {
|
|
return;
|
|
}
|
|
}
|
|
const targetRect = targetNode.getBoundingClientRect();
|
|
const scrollerRect = scrollerNode.getBoundingClientRect();
|
|
const isAbove = targetRect.top < scrollerRect.top;
|
|
const isBelow = targetRect.bottom > scrollerRect.bottom;
|
|
if (isAbove || isBelow) {
|
|
scrollManager.ref.current?.revealElement({
|
|
node: targetNode,
|
|
padding: 80,
|
|
animate: false,
|
|
});
|
|
scrollManager.scrollHandle();
|
|
}
|
|
},
|
|
[scrollManager, channel.id],
|
|
);
|
|
const onReveal = useCallback(
|
|
(messageId: string | null) => {
|
|
MessageCommands.revealMessage(channel.id, messageId);
|
|
},
|
|
[channel.id],
|
|
);
|
|
const onScrollToPresent = useCallback(() => {
|
|
if (state.messages?.hasMoreAfter) {
|
|
MessageCommands.jumpToLiveEdge(channel.id, MAX_MESSAGES_PER_CHANNEL);
|
|
} else {
|
|
scrollManager.scrollSetToBottom(false);
|
|
}
|
|
}, [channel.id, state.messages?.hasMoreAfter, scrollManager]);
|
|
const onMessageSent = useCallback(
|
|
(payload?: unknown) => {
|
|
const data = payload as {channelId?: string} | undefined;
|
|
if (data?.channelId && data.channelId !== channel.id) return;
|
|
if (!Accessibility.scrollToBottomOnMessageSend) return;
|
|
if (state.messages?.hasMoreAfter) return;
|
|
window.requestAnimationFrame(() => {
|
|
scrollManager.scrollSetToBottom(false);
|
|
});
|
|
},
|
|
[channel.id, state.messages?.hasMoreAfter, scrollManager],
|
|
);
|
|
const onJumpToOldestUnread = useCallback(() => {
|
|
const messageId = state.oldestUnreadMessageId ?? state.ackMessageId;
|
|
if (messageId == null) {
|
|
return;
|
|
}
|
|
MessageCommands.jumpToMessage({channelId: channel.id, messageId, flash: true});
|
|
}, [channel.id, state.ackMessageId, state.oldestUnreadMessageId]);
|
|
const onScrollToPresentAndAck = useCallback(() => {
|
|
if (state.messages?.hasMoreAfter) {
|
|
MessageCommands.jumpToLiveEdge(channel.id, MAX_MESSAGES_PER_CHANNEL);
|
|
} else {
|
|
scrollManager.scrollSetToBottom(false);
|
|
}
|
|
if (state.visualUnreadMessageId != null) {
|
|
ReadStateCommands.clearStickyUnread(channel.id);
|
|
}
|
|
if (ReadStates.hasUnread(channel.id)) {
|
|
ReadStateCommands.ack(channel.id, true, false);
|
|
}
|
|
}, [channel.id, state.messages?.hasMoreAfter, state.visualUnreadMessageId, scrollManager]);
|
|
const onEscapePressed = useCallback(
|
|
(payload?: unknown) => {
|
|
const data = payload as {channelId?: string} | undefined;
|
|
if (data?.channelId && data.channelId !== channel.id) return;
|
|
if (scrollManager.jumpReturnToOrigin()) {
|
|
return;
|
|
}
|
|
onScrollToPresentAndAck();
|
|
},
|
|
[channel.id, onScrollToPresentAndAck, scrollManager],
|
|
);
|
|
const onRetryLoadMessages = useCallback(() => {
|
|
void MessageCommands.fetchMessages(channel.id, null, null, MAX_MESSAGES_PER_CHANNEL);
|
|
}, [channel.id]);
|
|
const getSelectionCopyMessage = useCallback(
|
|
(messageId: string) => MessagesState.getMessage(channel.id, messageId),
|
|
[channel.id],
|
|
);
|
|
const onCopySelectedMessages = useMessageSelectionCopyForMessageGetter<HTMLDivElement>(getSelectionCopyMessage);
|
|
useEffect(() => {
|
|
const storeUnsubs = [
|
|
MessagesState.subscribe(updateFromState),
|
|
ReadStates.subscribe(updateFromState),
|
|
Users.subscribe(updateFromState),
|
|
Permission.subscribe(updateFromState),
|
|
Accessibility.subscribe(updateFromState),
|
|
UserSettings.subscribe(updateFromState),
|
|
MessageEdit.subscribe(updateFromState),
|
|
];
|
|
const onForceJumpToPresent = () => {
|
|
MessageCommands.jumpToLiveEdge(channel.id, MAX_MESSAGES_PER_CHANNEL);
|
|
};
|
|
const onScrollPageUp = () => scrollManager.pageBackward(true);
|
|
const onScrollPageDown = () => scrollManager.pageForward(true);
|
|
const onLayoutResized = (payload?: unknown) => {
|
|
const data = payload as {channelId?: string} | undefined;
|
|
if (data?.channelId && data.channelId !== channel.id) return;
|
|
scrollManager.layoutHandleResized();
|
|
};
|
|
const onFocusBottommostMessage = (payload?: unknown) => {
|
|
const data = (payload ?? {}) as {channelId?: string};
|
|
if (!data.channelId || data.channelId !== channel.id) return;
|
|
const scroller = scrollManager.ref.current?.getViewportElement();
|
|
const innerElement = scrollerInnerRef.current;
|
|
if (!scroller || !innerElement) return;
|
|
const messageElements = innerElement.querySelectorAll<HTMLElement>('[data-message-id]');
|
|
if (!messageElements.length) return;
|
|
const scrollerRect = scroller.getBoundingClientRect();
|
|
let bottomMostVisibleMessage: HTMLElement | null = null;
|
|
let bottomMostVisibleY = -Infinity;
|
|
for (const messageEl of messageElements) {
|
|
const rect = messageEl.getBoundingClientRect();
|
|
const messageHeight = rect.height;
|
|
if (messageHeight === 0) continue;
|
|
const visibleTop = Math.max(rect.top, scrollerRect.top);
|
|
const visibleBottom = Math.min(rect.bottom, scrollerRect.bottom);
|
|
const visibleHeight = Math.max(0, visibleBottom - visibleTop);
|
|
const visibilityRatio = visibleHeight / messageHeight;
|
|
if (visibilityRatio >= 0.75) {
|
|
if (rect.bottom > bottomMostVisibleY) {
|
|
bottomMostVisibleY = rect.bottom;
|
|
bottomMostVisibleMessage = messageEl;
|
|
}
|
|
}
|
|
}
|
|
if (bottomMostVisibleMessage) {
|
|
const messageId = bottomMostVisibleMessage.dataset.messageId;
|
|
if (messageId) {
|
|
scrollManager.focusRequestForMessage(messageId);
|
|
}
|
|
}
|
|
};
|
|
const dispatchUnsubs = [
|
|
ComponentBus.subscribe('SCROLL_TO_PRESENT', onScrollToPresent),
|
|
ComponentBus.subscribe('MESSAGE_SENT', onMessageSent),
|
|
ComponentBus.subscribe('FORCE_JUMP_TO_PRESENT', onForceJumpToPresent),
|
|
ComponentBus.subscribe('ESCAPE_PRESSED', onEscapePressed),
|
|
ComponentBus.subscribe('SCROLL_PAGE_UP', onScrollPageUp),
|
|
ComponentBus.subscribe('SCROLL_PAGE_DOWN', onScrollPageDown),
|
|
ComponentBus.subscribe('LAYOUT_RESIZED', onLayoutResized),
|
|
ComponentBus.subscribe('FOCUS_BOTTOMMOST_MESSAGE', onFocusBottommostMessage),
|
|
];
|
|
updateFromState();
|
|
return () => {
|
|
storeUnsubs.forEach((u) => u());
|
|
dispatchUnsubs.forEach((u) => u());
|
|
};
|
|
}, [channel.id, updateFromState, onScrollToPresent, onMessageSent, onEscapePressed, scrollManager]);
|
|
useEffect(() => {
|
|
const editingMessageId = state.editingMessageId;
|
|
if (editingMessageId) {
|
|
scrollManager.editEnter();
|
|
} else {
|
|
scrollManager.editExit();
|
|
}
|
|
}, [state.editingMessageId, scrollManager]);
|
|
useEffect(() => {
|
|
if (!windowNeedsPage || !isGatewayConnected || selectedChannelId !== channel.id) {
|
|
if (recoveryFetchChannelIdRef.current === channel.id) {
|
|
recoveryFetchChannelIdRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
if (recoveryFetchChannelIdRef.current === channel.id) {
|
|
return;
|
|
}
|
|
recoveryFetchChannelIdRef.current = channel.id;
|
|
void MessageCommands.fetchMessages(channel.id, null, null, MAX_MESSAGES_PER_CHANNEL).finally(() => {
|
|
if (recoveryFetchChannelIdRef.current === channel.id) {
|
|
recoveryFetchChannelIdRef.current = null;
|
|
}
|
|
});
|
|
}, [channel.id, isGatewayConnected, selectedChannelId, windowNeedsPage, state.messageVersion]);
|
|
useMessageListKeyboardNavigation({
|
|
containerRef: scrollManager.ref,
|
|
channelId: channel.id,
|
|
onFocusMessage: (messageId) => {
|
|
scrollManager.focusRequestForMessage(messageId);
|
|
},
|
|
onLoadMoreBefore: () => {
|
|
scrollManager.loadMoreForKeyboardNavigation(false);
|
|
},
|
|
onLoadMoreAfter: () => {
|
|
scrollManager.loadMoreForKeyboardNavigation(true);
|
|
},
|
|
hasMoreBefore: state.messages?.hasMoreBefore ?? false,
|
|
hasMoreAfter: state.messages?.hasMoreAfter ?? false,
|
|
isLoadingMore: state.messages?.loadingMore ?? false,
|
|
onEscape: () => {
|
|
if (scrollManager.jumpReturnToOrigin()) {
|
|
return;
|
|
}
|
|
scrollManager.jumpCancel();
|
|
ComponentBus.dispatch('FOCUS_TEXTAREA', {channelId: channel.id});
|
|
},
|
|
allowWhenInactive: true,
|
|
});
|
|
useEffect(() => {
|
|
return () => {
|
|
if (jumpHighlightTimeoutRef.current != null) {
|
|
clearTimeout(jumpHighlightTimeoutRef.current);
|
|
jumpHighlightTimeoutRef.current = null;
|
|
}
|
|
};
|
|
}, []);
|
|
useEffect(() => {
|
|
if (!canAutoAck || !state.isAtBottom || !state.messages?.ready) return;
|
|
if (ReadStates.hasUnread(channel.id)) {
|
|
ReadStateCommands.ackWithStickyUnread(channel.id);
|
|
}
|
|
}, [canAutoAck, state.isAtBottom, state.messages?.ready, channel.id]);
|
|
useEffect(() => {
|
|
return () => {
|
|
const readState = ReadStates.getIfExists(channel.id);
|
|
if (readState?.ackedManually) {
|
|
ReadStateCommands.clearManualAck(channel.id);
|
|
}
|
|
ReadStateCommands.clearStickyUnread(channel.id);
|
|
};
|
|
}, [channel.id]);
|
|
const spammerOverrideVersion = LocalUserSpamOverride.version;
|
|
const channelStream = useMemo<Array<ChannelStreamItem>>(() => {
|
|
if (!state.messages) return [];
|
|
return createChannelStream({
|
|
channel,
|
|
messages: state.messages,
|
|
oldestUnreadMessageId: state.visualUnreadMessageId,
|
|
treatSpam: true,
|
|
});
|
|
}, [channel, state.messages, state.messageVersion, state.visualUnreadMessageId, spammerOverrideVersion]);
|
|
useEffect(() => {
|
|
const messages = state.messages;
|
|
if (!messages?.ready) {
|
|
return;
|
|
}
|
|
void MessageCommands.ensureMembersForMessages(messages.toArray());
|
|
}, [channel.id, state.messages?.ready, state.messageVersion]);
|
|
const immediateHighlightedMessageId = (() => {
|
|
const messages = state.messages;
|
|
if (!messages?.ready || !messages.hasJumped || !messages.jumpHighlight || !messages.jumpDestinationId) {
|
|
return null;
|
|
}
|
|
return messages.jumpDestinationId !== channel.id ? messages.jumpDestinationId : null;
|
|
})();
|
|
const highlightedMessageId = immediateHighlightedMessageId ?? state.highlightedMessageId;
|
|
const collapsedMessageVisibility = useMemo(
|
|
() => ({
|
|
isMessageRevealed: (message: Message) => {
|
|
if (!state.unblurredMessageId || !state.messages || message.channelId !== channel.id) {
|
|
return false;
|
|
}
|
|
return (
|
|
getCollapsedMessageGroupKey({
|
|
channel,
|
|
messages: state.messages,
|
|
messageId: message.id,
|
|
treatSpam: true,
|
|
}) === state.unblurredMessageId
|
|
);
|
|
},
|
|
}),
|
|
[channel, state.messages, state.unblurredMessageId, state.messageVersion, spammerOverrideVersion],
|
|
);
|
|
const {canChat, canAttachFiles} = useMemo(
|
|
() => checkPermissions(channel),
|
|
[channel.id, channel.guildId, state.permissionVersion],
|
|
);
|
|
const streamMarkup = useMemo(() => {
|
|
if (!state.messages) return null;
|
|
return renderChannelStream({
|
|
channelStream,
|
|
messages: state.messages,
|
|
channel,
|
|
highlightedMessageId,
|
|
messageDisplayCompact: state.messageDisplayCompact,
|
|
messageGroupSpacing: state.messageGroupSpacing,
|
|
unblurredMessageId: state.unblurredMessageId,
|
|
onMessageEdit,
|
|
onReveal,
|
|
});
|
|
}, [
|
|
channelStream,
|
|
state.messages,
|
|
channel,
|
|
highlightedMessageId,
|
|
state.messageDisplayCompact,
|
|
state.messageGroupSpacing,
|
|
state.unblurredMessageId,
|
|
onMessageEdit,
|
|
onReveal,
|
|
]);
|
|
const hasBottomBar = windowBar !== 'none';
|
|
useEffect(() => {
|
|
onBottomBarVisibilityChange?.(hasBottomBar);
|
|
}, [hasBottomBar, onBottomBarVisibilityChange]);
|
|
useEffect(() => {
|
|
return () => {
|
|
onBottomBarVisibilityChange?.(false);
|
|
};
|
|
}, [onBottomBarVisibilityChange]);
|
|
const bottomBar =
|
|
windowBar === 'retry' ? (
|
|
<LoadErrorBar
|
|
loading={safeMessages.loadingMore}
|
|
onRetry={onRetryLoadMessages}
|
|
data-flx="channel.messages.load-error-bar"
|
|
/>
|
|
) : windowBar === 'present' ? (
|
|
<JumpToPresentBar
|
|
loadingMore={safeMessages.loadingMore}
|
|
landedAtLiveEdge={safeMessages.landedAtLiveEdge}
|
|
onJumpToPresent={onScrollToPresent}
|
|
data-flx="channel.messages.jump-to-present-bar"
|
|
/>
|
|
) : null;
|
|
const showNewMessagesBar = Boolean(windowStatus.phase === 'stream' && state.unreadCount > 0);
|
|
const unreadTimestampMessageId = state.oldestUnreadMessageId ?? state.ackMessageId;
|
|
const topBar = showNewMessagesBar ? (
|
|
<NewMessagesBar
|
|
unreadCount={state.unreadCount}
|
|
oldestUnreadMessageTimestamp={unreadTimestampMessageId ? extractTimestamp(unreadTimestampMessageId) : 0}
|
|
isEstimated={state.isEstimated}
|
|
onJumpToOldestUnread={onJumpToOldestUnread}
|
|
onJumpToNewMessages={onScrollToPresentAndAck}
|
|
data-flx="channel.messages.new-messages-bar"
|
|
/>
|
|
) : null;
|
|
const messagesWrapperStyle = useMemo<MessagesWrapperStyle>(
|
|
() => ({
|
|
'--message-group-spacing': remFromPx(state.messageGroupSpacing),
|
|
}),
|
|
[state.messageGroupSpacing],
|
|
);
|
|
const compactAvatarsVisible = Accessibility.showUserAvatarsInCompactMode;
|
|
const messageGutter = Accessibility.messageGutter;
|
|
useSkeletonLayoutReport(
|
|
() =>
|
|
reportSkeletonMessagePresentation({
|
|
compact: state.messageDisplayCompact,
|
|
messageGutterPx: messageGutter,
|
|
fontSizePx: state.fontSize,
|
|
groupSpacingPx: state.messageGroupSpacing,
|
|
compactAvatarsVisible,
|
|
viewportHeightPx: measureSkeletonHeightPx(scrollerContainerRef.current),
|
|
}),
|
|
`${state.messageDisplayCompact}|${messageGutter}|${state.fontSize}|${state.messageGroupSpacing}|${compactAvatarsVisible}|${channel.id}|${state.messageVersion}`,
|
|
);
|
|
const messageListLabel = channel.name
|
|
? i18n._(MESSAGE_LIST_FOR_DESCRIPTOR, {channelName: channel.name})
|
|
: i18n._(MESSAGE_LIST_DESCRIPTOR);
|
|
const messageListLiveMode = Accessibility.screenReaderAnnounceNewMessages && state.isAtBottom ? 'polite' : 'off';
|
|
const topFillerVisible = selectChannelMessagesFillerVisible({
|
|
reducedMotion: Accessibility.useReducedMotion,
|
|
scrollManagerInitialized: scrollManager.lifecycleIsInitialized(),
|
|
ready: safeMessages.ready,
|
|
});
|
|
const headFillerVisible = windowStatus.olderPageAvailable && topFillerVisible;
|
|
const tailFillerVisible = windowStatus.newerPageAvailable;
|
|
const streamHasRows = streamMarkup != null && streamMarkup.length > 0;
|
|
const spacerFollowsFiller = tailFillerVisible || (headFillerVisible && !streamHasRows);
|
|
const scrollerInner = (
|
|
<>
|
|
{headFillerVisible && (
|
|
<ScrollFillerSkeleton data-flx="channel.messages.scroll-filler-skeleton" {...placeholderSpecs} />
|
|
)}
|
|
{windowStatus.olderPageAvailable && safeMessages.length > 0 && (
|
|
<div className={styles.fillerBuffer} data-flx="channel.messages.filler-buffer" />
|
|
)}
|
|
{!windowStatus.olderPageAvailable && (
|
|
<ChannelWelcomeSection channel={channel} data-flx="channel.messages.channel-welcome-section" />
|
|
)}
|
|
{streamMarkup}
|
|
{tailFillerVisible && (
|
|
<ScrollFillerSkeleton data-flx="channel.messages.scroll-filler-skeleton--2" {...placeholderSpecs} />
|
|
)}
|
|
<div
|
|
className={clsx(styles.scrollerSpacer, spacerFollowsFiller && styles.scrollerSpacerAfterFiller)}
|
|
data-flx="channel.messages.scroller-spacer"
|
|
/>
|
|
</>
|
|
);
|
|
return (
|
|
<div className={styles.messagesWrapper} style={messagesWrapperStyle} data-flx="channel.messages.messages-wrapper">
|
|
<UploadManager
|
|
channel={channel}
|
|
canAttachFiles={canAttachFiles}
|
|
canSendMessages={canChat}
|
|
data-flx="channel.messages.upload-manager"
|
|
/>
|
|
{topBar}
|
|
<div
|
|
className={styles.scrollerContainer}
|
|
ref={scrollerContainerRef}
|
|
data-flx="channel.messages.scroller-container"
|
|
>
|
|
<Scroller
|
|
fade={false}
|
|
scrollbar="regular"
|
|
hideThumbWhenWindowBlurred
|
|
ref={scrollManager.ref}
|
|
onScroll={scrollManager.scrollHandle}
|
|
onScrollIntent={scrollManager.scrollHandleUserIntent}
|
|
onResize={scrollManager.scrollHandleResize}
|
|
key={`scroller-${channel.id}`}
|
|
data-flx="channel.messages.scroller"
|
|
>
|
|
<div className={styles.scrollerContent} data-flx="channel.messages.scroller-content">
|
|
<div
|
|
className={styles.scrollerInner}
|
|
ref={scrollerInnerRef}
|
|
onCopy={onCopySelectedMessages}
|
|
role="log"
|
|
data-message-selection-root="true"
|
|
aria-label={messageListLabel}
|
|
aria-live={messageListLiveMode}
|
|
aria-relevant="additions text"
|
|
aria-atomic="false"
|
|
aria-busy={safeMessages.loadingMore ? true : undefined}
|
|
data-flx="channel.messages.scroller-inner"
|
|
>
|
|
<NearViewportSurfaceContext.Provider value={resolveMessageScrollSurface}>
|
|
<CollapsedMessageVisibilityProvider
|
|
value={collapsedMessageVisibility}
|
|
data-flx="channel.messages.collapsed-message-visibility-provider"
|
|
>
|
|
{scrollerInner}
|
|
</CollapsedMessageVisibilityProvider>
|
|
</NearViewportSurfaceContext.Provider>
|
|
</div>
|
|
</div>
|
|
</Scroller>
|
|
</div>
|
|
{bottomBar}
|
|
</div>
|
|
);
|
|
});
|
|
const JumpToPresentBar = observer(function JumpToPresentBar({
|
|
loadingMore,
|
|
landedAtLiveEdge,
|
|
onJumpToPresent,
|
|
}: {
|
|
loadingMore: boolean;
|
|
landedAtLiveEdge: boolean;
|
|
onJumpToPresent: () => void;
|
|
}) {
|
|
const {i18n} = useLingui();
|
|
const jumpIsActiveNow = loadingMore && landedAtLiveEdge;
|
|
return (
|
|
<div
|
|
className={[styles.newMessagesBar, styles.messageBottomPill, styles.jumpToPresentBar].join(' ')}
|
|
aria-busy={jumpIsActiveNow}
|
|
data-flx="channel.messages.jump-to-present-bar"
|
|
>
|
|
<span className={styles.newMessagesBarText} data-flx="channel.messages.jump-to-present-bar.new-messages-bar-text">
|
|
{i18n._(YOU_RE_VIEWING_OLDER_MESSAGES_DESCRIPTOR)}
|
|
</span>
|
|
<Button
|
|
variant="primary"
|
|
compact
|
|
fitContent
|
|
className={styles.newMessagesBarAction}
|
|
onClick={onJumpToPresent}
|
|
submitting={jumpIsActiveNow}
|
|
disabled={jumpIsActiveNow}
|
|
data-flx="channel.messages.jump-to-present-bar.new-messages-bar-action"
|
|
>
|
|
{i18n._(JUMP_TO_PRESENT_DESCRIPTOR)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
function LoadErrorBar({loading, onRetry}: {loading: boolean; onRetry: () => void}) {
|
|
const {i18n} = useLingui();
|
|
return (
|
|
<div
|
|
aria-busy={loading}
|
|
className={[styles.newMessagesBar, styles.messageBottomPill, styles.loadErrorBar].join(' ')}
|
|
data-flx="channel.messages.load-error-bar"
|
|
>
|
|
<span className={styles.newMessagesBarText} data-flx="channel.messages.load-error-bar.new-messages-bar-text">
|
|
{i18n._(MESSAGES_FAILED_TO_LOAD_DESCRIPTOR)}
|
|
</span>
|
|
<Button
|
|
variant="primary"
|
|
compact
|
|
fitContent
|
|
className={styles.newMessagesBarAction}
|
|
disabled={loading}
|
|
submitting={loading}
|
|
onClick={onRetry}
|
|
data-flx="channel.messages.load-error-bar.new-messages-bar-action"
|
|
>
|
|
{i18n._(TRY_AGAIN_DESCRIPTOR)}
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|