712 lines
23 KiB
TypeScript

/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as MessageActionCreators from '@app/actions/MessageActionCreators';
import * as ReadStateActionCreators from '@app/actions/ReadStateActionCreators';
import {renderChannelStream} from '@app/components/channel/ChannelMessageStream';
import {ChannelWelcomeSection} from '@app/components/channel/ChannelWelcomeSection';
import styles from '@app/components/channel/Messages.module.css';
import {NewMessagesBar} from '@app/components/channel/NewMessagesBar';
import ScrollFillerSkeleton from '@app/components/channel/ScrollFillerSkeleton';
import {UploadManager} from '@app/components/channel/UploadManager';
import {Scroller} from '@app/components/uikit/Scroller';
import {Spinner} from '@app/components/uikit/Spinner';
import {useMessageListKeyboardNavigation} from '@app/hooks/useMessageListKeyboardNavigation';
import {ChannelMessages} from '@app/lib/ChannelMessages';
import {ComponentDispatch} from '@app/lib/ComponentDispatch';
import {parseAndRenderToPlaintext} from '@app/lib/markdown/Plaintext';
import {getParserFlagsForContext} from '@app/lib/markdown/renderers';
import {MarkdownContext} from '@app/lib/markdown/renderers/RendererTypes';
import {usePlaceholderSpecs} from '@app/lib/PlaceholderSpecs';
import {useScrollManager} from '@app/lib/ScrollManager';
import type {ChannelRecord} from '@app/records/ChannelRecord';
import type {UserRecord} from '@app/records/UserRecord';
import AccessibilityStore from '@app/stores/AccessibilityStore';
import GuildVerificationStore from '@app/stores/GuildVerificationStore';
import GatewayConnectionStore from '@app/stores/gateway/GatewayConnectionStore';
import KeyboardModeStore from '@app/stores/KeyboardModeStore';
import MessageEditStore from '@app/stores/MessageEditStore';
import MessageFocusStore from '@app/stores/MessageFocusStore';
import MessageStore from '@app/stores/MessageStore';
import ModalStore from '@app/stores/ModalStore';
import PermissionStore from '@app/stores/PermissionStore';
import ReadStateStore from '@app/stores/ReadStateStore';
import SelectedChannelStore from '@app/stores/SelectedChannelStore';
import UserSettingsStore from '@app/stores/UserSettingsStore';
import UserStore from '@app/stores/UserStore';
import WindowStore from '@app/stores/WindowStore';
import {type ChannelStreamItem, createChannelStream} from '@app/utils/MessageGroupingUtils';
import {buildMessageSelectionCopyText} from '@app/utils/MessageSelectionCopyUtils';
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 {useLingui} from '@lingui/react/macro';
import {runInAction} from 'mobx';
import {observer, useLocalObservable} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react';
const MESSAGE_COPY_PARSER_FLAGS = getParserFlagsForContext(MarkdownContext.STANDARD_WITHOUT_JUMBO);
function checkPermissions(channel: ChannelRecord) {
const canSendMessages = PermissionStore.can(Permissions.SEND_MESSAGES, channel);
const passesVerification = channel.isPrivate() || GuildVerificationStore.canAccessGuild(channel.guildId || '');
const canChat = channel.isPrivate() || (canSendMessages && passesVerification);
const canAttachFiles = channel.isPrivate()
? canChat
: canChat && PermissionStore.can(Permissions.ATTACH_FILES, channel);
const canManageMessages = PermissionStore.can(Permissions.MANAGE_MESSAGES, channel);
return {canSendMessages, canChat, canAttachFiles, canManageMessages};
}
interface MessagesStoreSnapshot {
unreadCount: number;
oldestUnreadMessageId: string | null;
visualUnreadMessageId: string | null;
ackMessageId: string | null;
lastReadStateMessageId: string | null;
messages: ChannelMessages;
messageVersion: number;
revealedMessageId: string | null;
permissionVersion: number;
messageGroupSpacing: number;
fontSize: number;
messageDisplayCompact: boolean;
editingMessageId: string | null;
currentUser: UserRecord | undefined;
isEstimated: boolean;
isManualAck: boolean;
}
interface MessagesProps {
channel: ChannelRecord;
onBottomBarVisibilityChange?: (visible: boolean) => void;
}
const readFromStores = (channelId: string): MessagesStoreSnapshot => {
const messages = MessageStore.getMessages(channelId);
return {
unreadCount: ReadStateStore.getUnreadCount(channelId),
oldestUnreadMessageId: ReadStateStore.getOldestUnreadMessageId(channelId),
visualUnreadMessageId: ReadStateStore.getVisualUnreadMessageId(channelId),
ackMessageId: ReadStateStore.ackMessageId(channelId),
lastReadStateMessageId: ReadStateStore.lastMessageId(channelId),
messages,
messageVersion: messages.version,
revealedMessageId: messages.revealedMessageId,
permissionVersion: PermissionStore.version,
messageGroupSpacing: AccessibilityStore.messageGroupSpacingValue,
fontSize: AccessibilityStore.fontSize,
messageDisplayCompact: UserSettingsStore.getMessageDisplayCompact(),
editingMessageId: MessageEditStore.getEditingMessageId(channelId),
currentUser: UserStore.currentUser ?? undefined,
isEstimated: ReadStateStore.getIfExists(channelId)?.estimated ?? false,
isManualAck: ReadStateStore.getIfExists(channelId)?.isManualAck ?? 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}: MessagesProps) {
const {i18n} = useLingui();
const messagesWrapperRef = useRef<HTMLDivElement | null>(null);
const scrollerInnerRef = useRef<HTMLDivElement | null>(null);
const lastStoreSnapshotRef = useRef<MessagesStoreSnapshot | null>(null);
const recoveryFetchChannelIdRef = useRef<string | null>(null);
interface MessageState extends MessagesStoreSnapshot {
highlightedMessageId: string | null;
isAtBottom: boolean;
}
const state = useLocalObservable<MessageState>(() => {
const initial = readFromStores(channel.id);
lastStoreSnapshotRef.current = initial;
return {
...initial,
highlightedMessageId: null,
isAtBottom: false,
};
});
const windowId = WindowStore.windowId;
const isWindowFocused = WindowStore.isFocused();
const isModalOpen = ModalStore.hasModalOpen();
const isGatewayConnected = GatewayConnectionStore.isConnected;
const selectedChannelId = SelectedChannelStore.currentChannelId;
const placeholderSpecs = usePlaceholderSpecs(
state.messageDisplayCompact,
state.messageGroupSpacing,
state.fontSize,
channel.id,
);
const safeMessages = state.messages ?? MessageStore.getMessages(channel.id);
const canAutoAck = !state.isManualAck && isWindowFocused && !isModalOpen;
const scrollManager = useScrollManager({
messages: safeMessages,
channel,
compact: state.messageDisplayCompact,
hasUnreads: state.unreadCount > 0,
focusId: null,
placeholderHeight: placeholderSpecs.totalHeight,
canLoadMore: true,
windowId,
handleScrollToBottom: () => {
runInAction(() => {
state.isAtBottom = true;
});
},
handleScrollFromBottom: () => {
runInAction(() => {
state.isAtBottom = false;
});
},
additionalMessagePadding: 48,
canAutoAck,
});
useLayoutEffect(() => {
const node = messagesWrapperRef.current;
if (node) {
node.style.setProperty('--message-group-spacing', `${state.messageGroupSpacing}px`);
}
}, [state.messageGroupSpacing]);
useEffect(() => {
ChannelMessages.retainChannel(channel.id);
return () => {
ChannelMessages.releaseRetainedChannel(channel.id);
};
}, [channel.id]);
const jumpHighlightTimeoutRef = useRef<number | null>(null);
const lastJumpSequenceIdRef = useRef<number | null>(null);
const updateFromStores = useCallback(() => {
const snapshot = readFromStores(channel.id);
const previous = lastStoreSnapshotRef.current;
if (previous && shallowEqual(previous, snapshot)) return;
runInAction(() => {
Object.assign(state, snapshot);
});
lastStoreSnapshotRef.current = snapshot;
}, [channel.id, state]);
const onMessageEdit = useCallback(
(targetNode: HTMLElement) => {
const scrollerNode = scrollManager.ref.current?.getScrollerNode();
if (!scrollerNode) return;
if (scrollManager.isPinned()) {
return;
}
if (KeyboardModeStore.keyboardModeEnabled) {
const focusedMessageId = MessageFocusStore.focusedMessageId;
const focusedChannelId = MessageFocusStore.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?.scrollIntoViewNode({
node: targetNode,
padding: 80,
animate: false,
});
scrollManager.handleScroll();
}
},
[scrollManager, channel.id],
);
const onReveal = useCallback(
(messageId: string | null) => {
MessageActionCreators.revealMessage(channel.id, messageId);
},
[channel.id],
);
const onScrollToPresent = useCallback(() => {
if (state.messages?.hasMoreAfter) {
MessageActionCreators.jumpToPresent(channel.id, MAX_MESSAGES_PER_CHANNEL);
} else {
scrollManager.setScrollToBottom(false);
}
}, [channel.id, state.messages?.hasMoreAfter, scrollManager]);
const onScrollToPresentAndAck = useCallback(() => {
if (state.messages?.hasMoreAfter) {
MessageActionCreators.jumpToPresent(channel.id, MAX_MESSAGES_PER_CHANNEL);
} else {
scrollManager.setScrollToBottom(false);
}
if (state.visualUnreadMessageId != null) {
ReadStateActionCreators.clearStickyUnread(channel.id);
}
if (ReadStateStore.hasUnread(channel.id)) {
ReadStateActionCreators.ack(channel.id, true, false);
}
}, [channel.id, state.messages?.hasMoreAfter, state.visualUnreadMessageId, scrollManager]);
const onRetryLoadMessages = useCallback(() => {
void MessageActionCreators.fetchMessages(channel.id, null, null, MAX_MESSAGES_PER_CHANNEL);
}, [channel.id]);
const onCopySelectedMessages = useCallback(
(event: React.ClipboardEvent<HTMLDivElement>) => {
if (state.messageDisplayCompact) {
return;
}
const scrollerInnerNode = scrollerInnerRef.current;
if (!scrollerInnerNode || !event.clipboardData) {
return;
}
const selection = scrollerInnerNode.ownerDocument.defaultView?.getSelection() ?? null;
const clipboardText = buildMessageSelectionCopyText({
rootElement: scrollerInnerNode,
selection,
getMessagePlaintext: (messageId: string) => {
const message = MessageStore.getMessage(channel.id, messageId);
if (!message || !message.isUserMessage()) {
return null;
}
return parseAndRenderToPlaintext(message.content, MESSAGE_COPY_PARSER_FLAGS, {
channelId: channel.id,
preserveMarkdown: false,
includeEmojiNames: true,
i18n,
});
},
});
if (!clipboardText) {
return;
}
event.preventDefault();
event.clipboardData.setData('text/plain', clipboardText);
},
[state.messageDisplayCompact, channel.id, i18n],
);
useEffect(() => {
const storeUnsubs = [
MessageStore.subscribe(updateFromStores),
ReadStateStore.subscribe(updateFromStores),
UserStore.subscribe(updateFromStores),
PermissionStore.subscribe(updateFromStores),
AccessibilityStore.subscribe(updateFromStores),
UserSettingsStore.subscribe(updateFromStores),
MessageEditStore.subscribe(updateFromStores),
];
const onForceJumpToPresent = () => {
MessageActionCreators.jumpToPresent(channel.id, MAX_MESSAGES_PER_CHANNEL);
};
const onScrollPageUp = () => scrollManager.scrollPageUp(true);
const onScrollPageDown = () => scrollManager.scrollPageDown(true);
const onLayoutResized = (payload?: unknown) => {
const data = payload as {channelId?: string; heightDelta?: number} | undefined;
if (data?.channelId && data.channelId !== channel.id) return;
scrollManager.handleLayoutResized(data?.heightDelta);
};
const onFocusBottommostMessage = (payload?: unknown) => {
const data = (payload ?? {}) as {channelId?: string};
if (!data.channelId || data.channelId !== channel.id) return;
const scroller = scrollManager.ref.current?.getScrollerNode();
if (!scroller) return;
const doc = scroller.ownerDocument ?? document;
const messageElements = Array.from(
doc.querySelectorAll<HTMLElement>(`[data-channel-id="${channel.id}"][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.focusMessage(messageId);
}
}
};
const dispatchUnsubs = [
ComponentDispatch.subscribe('SCROLLTO_PRESENT', onScrollToPresent),
ComponentDispatch.subscribe('FORCE_JUMP_TO_PRESENT', onForceJumpToPresent),
ComponentDispatch.subscribe('ESCAPE_PRESSED', onScrollToPresentAndAck),
ComponentDispatch.subscribe('SCROLL_PAGE_UP', onScrollPageUp),
ComponentDispatch.subscribe('SCROLL_PAGE_DOWN', onScrollPageDown),
ComponentDispatch.subscribe('LAYOUT_RESIZED', onLayoutResized),
ComponentDispatch.subscribe('FOCUS_BOTTOMMOST_MESSAGE', onFocusBottommostMessage),
];
updateFromStores();
return () => {
storeUnsubs.forEach((u) => u());
dispatchUnsubs.forEach((u) => u());
};
}, [channel.id, updateFromStores, onScrollToPresent, onScrollToPresentAndAck, scrollManager]);
useEffect(() => {
const editingMessageId = state.editingMessageId;
if (editingMessageId) {
scrollManager.enterEditMode();
} else {
scrollManager.exitEditMode();
}
}, [state.editingMessageId, scrollManager]);
useEffect(() => {
const messages = state.messages;
if (
!messages ||
messages.ready ||
messages.loadingMore ||
messages.error ||
messages.length > 0 ||
!isGatewayConnected ||
selectedChannelId !== channel.id
) {
if (recoveryFetchChannelIdRef.current === channel.id) {
recoveryFetchChannelIdRef.current = null;
}
return;
}
if (recoveryFetchChannelIdRef.current === channel.id) {
return;
}
recoveryFetchChannelIdRef.current = channel.id;
void MessageActionCreators.fetchMessages(channel.id, null, null, MAX_MESSAGES_PER_CHANNEL).finally(() => {
if (recoveryFetchChannelIdRef.current === channel.id) {
recoveryFetchChannelIdRef.current = null;
}
});
}, [
channel.id,
isGatewayConnected,
selectedChannelId,
state.messages?.ready,
state.messages?.loadingMore,
state.messages?.error,
state.messageVersion,
]);
useMessageListKeyboardNavigation({
containerRef: scrollManager.ref,
channelId: channel.id,
onFocusMessage: (messageId) => {
scrollManager.focusMessage(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: () => {
ComponentDispatch.dispatch('FOCUS_TEXTAREA', {channelId: channel.id});
},
allowWhenInactive: true,
});
useLayoutEffect(() => {
const messages = state.messages;
if (!messages || !messages.ready || !messages.jumped || !messages.jumpTargetId) return;
const jsid = messages.jumpSequenceId;
if (jsid === lastJumpSequenceIdRef.current) return;
lastJumpSequenceIdRef.current = jsid;
if (jumpHighlightTimeoutRef.current != null) {
clearTimeout(jumpHighlightTimeoutRef.current);
jumpHighlightTimeoutRef.current = null;
}
if (messages.jumpFlash && messages.jumpTargetId) {
runInAction(() => {
state.highlightedMessageId = messages.jumpTargetId;
});
jumpHighlightTimeoutRef.current = window.setTimeout(() => {
runInAction(() => {
if (state.highlightedMessageId === messages.jumpTargetId) {
state.highlightedMessageId = null;
}
});
jumpHighlightTimeoutRef.current = null;
}, 2000);
}
}, [state.messages?.ready, state.messages?.jumpSequenceId, state.messages?.jumpTargetId, state]);
useEffect(() => {
if (!canAutoAck || !state.isAtBottom || !state.messages?.ready) return;
if (ReadStateStore.hasUnread(channel.id)) {
ReadStateActionCreators.ackWithStickyUnread(channel.id);
}
}, [canAutoAck, state.isAtBottom, state.messages?.ready, channel.id]);
useEffect(() => {
return () => {
const readState = ReadStateStore.getIfExists(channel.id);
if (readState?.isManualAck) {
ReadStateActionCreators.clearManualAck(channel.id);
}
ReadStateActionCreators.clearStickyUnread(channel.id);
};
}, [channel.id]);
const channelStream = useMemo<Array<ChannelStreamItem>>(() => {
if (!state.messages?.ready) return [];
return createChannelStream({
channel,
messages: state.messages,
oldestUnreadMessageId: state.visualUnreadMessageId,
treatSpam: false,
});
}, [channel, state.messages?.ready, state.messageVersion, state.visualUnreadMessageId]);
const {canAttachFiles} = useMemo(
() => checkPermissions(channel),
[channel.id, channel.guildId, state.permissionVersion],
);
const streamMarkup = useMemo(() => {
if (!state.messages?.ready) return null;
return renderChannelStream({
channelStream,
messages: state.messages,
channel,
highlightedMessageId: state.highlightedMessageId,
messageDisplayCompact: state.messageDisplayCompact,
messageGroupSpacing: state.messageGroupSpacing,
revealedMessageId: state.revealedMessageId,
onMessageEdit,
onReveal,
});
}, [
channelStream,
state.messages?.ready,
channel,
state.highlightedMessageId,
state.messageDisplayCompact,
state.messageGroupSpacing,
state.revealedMessageId,
onMessageEdit,
onReveal,
]);
const hasJumpToPresentBar = Boolean(state.messages?.ready && state.messages.hasMoreAfter);
const hasLoadErrorBar = Boolean(state.messages?.error);
const hasBottomBar = hasJumpToPresentBar || hasLoadErrorBar;
useEffect(() => {
onBottomBarVisibilityChange?.(hasBottomBar);
}, [hasBottomBar, onBottomBarVisibilityChange]);
useEffect(() => {
return () => {
onBottomBarVisibilityChange?.(false);
};
}, [onBottomBarVisibilityChange]);
const jumpToPresentBar = hasJumpToPresentBar ? (
<JumpToPresentBar
loadingMore={state.messages.loadingMore}
jumpedToPresent={state.messages.jumpedToPresent}
onJumpToPresent={onScrollToPresent}
/>
) : null;
const loadErrorBar = hasLoadErrorBar ? (
<LoadErrorBar loading={state.messages.loadingMore} onRetry={onRetryLoadMessages} />
) : null;
const showNewMessagesBar = state.messages?.ready && state.unreadCount > 0;
const topBar = showNewMessagesBar ? (
<NewMessagesBar
unreadCount={state.unreadCount}
oldestUnreadTimestamp={state.oldestUnreadMessageId ? extractTimestamp(state.oldestUnreadMessageId) : 0}
isEstimated={state.isEstimated}
onJumpToNewMessages={onScrollToPresentAndAck}
/>
) : null;
const readyMessages = state.messages?.ready ? state.messages : null;
const scrollerInner = readyMessages ? (
<>
{!readyMessages.hasMoreBefore && <ChannelWelcomeSection channel={channel} />}
{readyMessages.hasMoreBefore && (
<>
<div className={styles.placeholderSpacer} />
<ScrollFillerSkeleton {...placeholderSpecs} />
</>
)}
{streamMarkup}
{readyMessages.hasMoreAfter && <ScrollFillerSkeleton {...placeholderSpecs} />}
<div className={styles.scrollerSpacer} />
</>
) : null;
return (
<div className={styles.messagesWrapper} ref={messagesWrapperRef}>
{canAttachFiles && <UploadManager channel={channel} />}
{topBar}
<div className={styles.scrollerContainer}>
<Scroller
fade={false}
scrollbar="regular"
hideThumbWhenWindowBlurred
ref={scrollManager.ref}
onScroll={scrollManager.handleScroll}
onResize={scrollManager.handleResize}
key={`scroller-${channel.id}`}
>
<div className={styles.scrollerContent}>
<div className={styles.scrollerInner} ref={scrollerInnerRef} onCopy={onCopySelectedMessages}>
{scrollerInner}
</div>
</div>
</Scroller>
</div>
{loadErrorBar ?? jumpToPresentBar}
</div>
);
});
const getBottomBarStyle = (background: string): React.CSSProperties => ({
borderRadius: '0.5rem 0.5rem 0 0',
bottom: '-6px',
background,
paddingBottom: '6px',
paddingTop: 0,
top: 'auto',
});
const JumpToPresentBar = observer(function JumpToPresentBar({
loadingMore,
jumpedToPresent,
onJumpToPresent,
}: {
loadingMore: boolean;
jumpedToPresent: boolean;
onJumpToPresent: () => void;
}) {
const {t} = useLingui();
const isJumping = loadingMore && jumpedToPresent;
return (
<button
type="button"
className={styles.newMessagesBar}
style={{
...getBottomBarStyle('var(--background-secondary-alt)'),
cursor: isJumping ? 'wait' : 'pointer',
}}
onClick={onJumpToPresent}
disabled={isJumping}
aria-busy={isJumping}
>
<span className={styles.newMessagesBarText}>{t`You're viewing older messages`}</span>
<span className={styles.newMessagesBarAction}>{isJumping ? <Spinner size="small" /> : t`Jump to Present`}</span>
</button>
);
});
function LoadErrorBar({loading, onRetry}: {loading: boolean; onRetry: () => void}) {
const {t} = useLingui();
return (
<button
type="button"
aria-busy={loading}
className={styles.newMessagesBar}
disabled={loading}
onClick={onRetry}
style={{
...getBottomBarStyle('var(--status-danger)'),
cursor: loading ? 'wait' : 'pointer',
}}
>
<span className={styles.newMessagesBarText}>{t`Messages failed to load`}</span>
<span className={styles.newMessagesBarAction}>{loading ? <Spinner size="small" /> : t`Try again`}</span>
</button>
);
}