fix(app): load more bookmarks as the list scrolls (#2556)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-06 21:05:57 +02:00
1 parent d91b5bec66
commit fbf0f6adfe
16 files changed
+322 -57

No files matched your search

@@ -41,6 +41,13 @@
padding: 0 1rem 1rem;
}
.loadingState {
display: flex;
height: 5rem;
align-items: center;
justify-content: center;
}
.messagePreviewCard {
position: relative;
cursor: pointer;
@@ -17,6 +17,7 @@ import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
import {BottomSheet} from '@app/features/ui/bottom_sheet/BottomSheet';
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
import {Spinner} from '@app/features/ui/components/Spinner';
import type {MenuGroupType} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
import {MenuBottomSheet} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
@@ -44,6 +45,8 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
const {i18n} = useLingui();
const {savedMessages, missingSavedMessages, fetched} = SavedMessages;
const hasBookmarks = savedMessages.length > 0 || missingSavedMessages.length > 0;
const hasMore = SavedMessages.getHasMore();
const isLoadingMore = SavedMessages.getIsLoadingMore();
const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveBookmarksScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
@@ -61,6 +64,13 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
useMessageListKeyboardNavigation({
containerRef: scrollerRef,
});
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
const scrollPercentage = (target.scrollTop + target.offsetHeight) / target.scrollHeight;
if (scrollPercentage > 0.8 && hasMore && !isLoadingMore) {
SavedMessageCommands.loadMore();
}
};
const handleLongPress = (message: Message) => {
setSelectedMessage(message);
setMenuOpen(true);
@@ -127,6 +137,7 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
<NearViewportSurfaceContext.Provider value={resolveBookmarksScrollSurface}>
<Scroller
className={styles.messageList}
onScroll={handleScroll}
key="bookmarks-bottom-sheet-scroller"
ref={scrollerRef}
onCopy={onCopySelectedMessages}
@@ -157,6 +168,11 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
/>
))}
</div>
{isLoadingMore && (
<div className={styles.loadingState} data-flx="channel.bookmarks-bottom-sheet.loading-state">
<Spinner data-flx="channel.bookmarks-bottom-sheet.spinner" />
</div>
)}
</Scroller>
</NearViewportSurfaceContext.Provider>
) : (
@@ -13,6 +13,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
import Users from '@app/features/user/state/Users';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {SAVED_MESSAGES_PAGE_SIZE} from '@fluxer/constants/src/LimitConstants';
import type {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
@@ -31,8 +32,14 @@ interface SaveMessageRequest {
message_id: string;
}
async function requestSavedMessages(): Promise<Array<SavedMessageEntryWire>> {
const response = await http.get<Array<SavedMessageEntryWire>>(Endpoints.USER_SAVED_MESSAGES);
interface SavedMessagesFetchOptions {
before?: string;
}
async function requestSavedMessages(options: SavedMessagesFetchOptions = {}): Promise<Array<SavedMessageEntryWire>> {
const response = await http.get<Array<SavedMessageEntryWire>>(Endpoints.USER_SAVED_MESSAGES, {
query: {limit: SAVED_MESSAGES_PAGE_SIZE, ...options},
});
return response.body ?? [];
}
@@ -78,20 +85,37 @@ function showMaxBookmarksModal(): boolean {
return true;
}
export async function fetch(): Promise<Array<SavedMessageEntry>> {
async function runSavedMessagesFetch(
label: string,
append: boolean,
options: SavedMessagesFetchOptions = {},
): Promise<Array<SavedMessageEntry>> {
const requestId = SavedMessages.handleFetchPending();
try {
logger.debug('Fetching saved messages');
const entries = savedMessageEntries(await requestSavedMessages());
SavedMessages.fetchSuccess(entries);
logger.debug(label);
const entries = savedMessageEntries(await requestSavedMessages(options));
SavedMessages.fetchSuccess(requestId, entries, append);
logger.debug(`Successfully fetched ${entries.length} saved messages`);
return entries;
} catch (error) {
SavedMessages.fetchError();
logger.error('Failed to fetch saved messages:', error);
SavedMessages.fetchError(requestId, append);
logger.error(`${label} failed:`, error);
throw error;
}
}
export async function fetch(): Promise<Array<SavedMessageEntry>> {
return runSavedMessagesFetch('Fetching saved messages', false);
}
export async function loadMore(): Promise<Array<SavedMessageEntry>> {
const before = SavedMessages.getCursor();
if (!before || !SavedMessages.getHasMore() || SavedMessages.getIsLoadingMore()) {
return [];
}
return runSavedMessagesFetch(`Loading more saved messages before ${before}`, true, {before});
}
export async function create(i18n: I18n, channelId: string, messageId: string): Promise<void> {
try {
logger.debug(`Saving message ${messageId} from channel ${channelId}`);
@@ -15,13 +15,14 @@ import {focusChannelTextareaAfterNavigation} from '@app/features/messaging/utils
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
import * as RouterUtils from '@app/features/navigation/utils/RouterUtils';
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
import {Spinner} from '@app/features/ui/components/Spinner';
import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {FlagCheckeredIcon, SparkleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useMemo, useRef} from 'react';
import {useCallback, useMemo, useRef} from 'react';
const YOU_VE_REACHED_THE_END_DESCRIPTOR = msg({
message: "You've reached the end",
@@ -37,6 +38,9 @@ interface MessageListPageProps {
endStateDescription: string;
renderActionButtons: (message: Message) => React.ReactNode;
renderMissingMessage?: (message: Message) => React.ReactNode;
hasMore?: boolean;
isLoadingMore?: boolean;
onLoadMore?: () => void;
}
export const MessageListPage = observer(
@@ -49,6 +53,9 @@ export const MessageListPage = observer(
endStateDescription,
renderActionButtons,
renderMissingMessage,
hasMore = false,
isLoadingMore = false,
onLoadMore,
}: MessageListPageProps) => {
const {i18n} = useLingui();
const scrollerRef = useRef<ScrollerHandle | null>(null);
@@ -66,6 +73,16 @@ export const MessageListPage = observer(
containerRef: scrollerRef,
allowWhenInactive: true,
});
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
const scrollPercentage = (target.scrollTop + target.offsetHeight) / target.scrollHeight;
if (scrollPercentage > 0.8 && hasMore && !isLoadingMore) {
onLoadMore?.();
}
},
[hasMore, isLoadingMore, onLoadMore],
);
return (
<div className={styles.container} data-flx="messaging.message-list-page.container">
<ChannelHeader
@@ -78,6 +95,7 @@ export const MessageListPage = observer(
<NearViewportSurfaceContext.Provider value={resolveListPageScrollSurface}>
<Scroller
className={styles.scroller}
onScroll={handleScroll}
key="message-list-page-scroller"
ref={scrollerRef}
onCopy={onCopySelectedMessages}
@@ -127,25 +145,32 @@ export const MessageListPage = observer(
</div>
);
})}
<div className={styles.endState} data-flx="messaging.message-list-page.end-state">
<div className={styles.endStateContent} data-flx="messaging.message-list-page.end-state-content">
<FlagCheckeredIcon
className={styles.endStateIcon}
data-flx="messaging.message-list-page.end-state-icon"
/>
<div className={styles.endStateText} data-flx="messaging.message-list-page.end-state-text">
<h3 className={styles.endStateTitle} data-flx="messaging.message-list-page.end-state-title">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p
className={styles.endStateDescription}
data-flx="messaging.message-list-page.end-state-description"
>
{endStateDescription}
</p>
{isLoadingMore && (
<div className={previewStyles.loadingState} data-flx="messaging.message-list-page.loading-state">
<Spinner data-flx="messaging.message-list-page.spinner" />
</div>
)}
{!hasMore && !isLoadingMore && (
<div className={styles.endState} data-flx="messaging.message-list-page.end-state">
<div className={styles.endStateContent} data-flx="messaging.message-list-page.end-state-content">
<FlagCheckeredIcon
className={styles.endStateIcon}
data-flx="messaging.message-list-page.end-state-icon"
/>
<div className={styles.endStateText} data-flx="messaging.message-list-page.end-state-text">
<h3 className={styles.endStateTitle} data-flx="messaging.message-list-page.end-state-title">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p
className={styles.endStateDescription}
data-flx="messaging.message-list-page.end-state-description"
>
{endStateDescription}
</p>
</div>
</div>
</div>
</div>
)}
</Scroller>
</NearViewportSurfaceContext.Provider>
) : (
@@ -39,6 +39,8 @@ const THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR = msg({
export const SavedMessagesPage = observer(() => {
const {i18n} = useLingui();
const {savedMessages, missingSavedMessages, fetched} = SavedMessages;
const hasMore = SavedMessages.getHasMore();
const isLoadingMore = SavedMessages.getIsLoadingMore();
useEffect(() => {
if (!fetched) {
SavedMessageCommands.fetch();
@@ -91,6 +93,9 @@ export const SavedMessagesPage = observer(() => {
emptyStateDescription={i18n._(BOOKMARK_MESSAGES_TO_SAVE_THEM_FOR_LATER_DESCRIPTOR)}
endStateDescription={i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)}
renderActionButtons={renderActionButtons}
hasMore={hasMore}
isLoadingMore={isLoadingMore}
onLoadMore={SavedMessageCommands.loadMore}
data-flx="messaging.saved-messages-page.message-list-page"
/>
</div>
@@ -49,6 +49,8 @@ const readonlyBehaviorOverrides = {
export const SavedMessagesContent = observer(() => {
const {i18n} = useLingui();
const {savedMessages, missingSavedMessages, fetched} = SavedMessages;
const hasMore = SavedMessages.getHasMore();
const isLoadingMore = SavedMessages.getIsLoadingMore();
const scrollerRef = useRef<ScrollerHandle | null>(null);
const resolveSavedScrollSurface = useMemo(() => () => scrollerRef.current?.getViewportElement() ?? null, []);
const onCopySelectedMessages = useMessageSelectionCopyForMessages<HTMLDivElement>(savedMessages);
@@ -79,6 +81,16 @@ export const SavedMessagesContent = observer(() => {
goToMessage(channelId, messageId);
InboxCommands.closeInboxAndFocusChannelTextarea(channelId);
}, []);
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const target = event.currentTarget;
const scrollPercentage = (target.scrollTop + target.offsetHeight) / target.scrollHeight;
if (scrollPercentage > 0.8 && hasMore && !isLoadingMore) {
SavedMessageCommands.loadMore();
}
},
[hasMore, isLoadingMore],
);
if (!fetched) {
return (
<div className={previewStyles.emptyState} data-flx="messaging.saved-messages-content.div">
@@ -110,6 +122,7 @@ export const SavedMessagesContent = observer(() => {
<NearViewportSurfaceContext.Provider value={resolveSavedScrollSurface}>
<Scroller
className={styles.scroller}
onScroll={handleScroll}
key="saved-messages-scroller"
ref={scrollerRef}
onCopy={onCopySelectedMessages}
@@ -189,22 +202,29 @@ export const SavedMessagesContent = observer(() => {
</div>
);
})}
<div className={previewStyles.endState} data-flx="messaging.saved-messages-content.div--7">
<div className={previewStyles.endStateContent} data-flx="messaging.saved-messages-content.div--8">
<FlagCheckeredIcon
className={previewStyles.endStateIcon}
data-flx="messaging.saved-messages-content.flag-checkered-icon"
/>
<div className={previewStyles.endStateTextContainer} data-flx="messaging.saved-messages-content.div--9">
<h3 className={previewStyles.endStateTitle} data-flx="messaging.saved-messages-content.h3--2">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p className={previewStyles.endStateDescription} data-flx="messaging.saved-messages-content.p--2">
{i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)}
</p>
{isLoadingMore && (
<div className={previewStyles.loadingState} data-flx="messaging.saved-messages-content.div--10">
<Spinner data-flx="messaging.saved-messages-content.spinner--2" />
</div>
)}
{!hasMore && !isLoadingMore && (
<div className={previewStyles.endState} data-flx="messaging.saved-messages-content.div--7">
<div className={previewStyles.endStateContent} data-flx="messaging.saved-messages-content.div--8">
<FlagCheckeredIcon
className={previewStyles.endStateIcon}
data-flx="messaging.saved-messages-content.flag-checkered-icon"
/>
<div className={previewStyles.endStateTextContainer} data-flx="messaging.saved-messages-content.div--9">
<h3 className={previewStyles.endStateTitle} data-flx="messaging.saved-messages-content.h3--2">
{i18n._(YOU_VE_REACHED_THE_END_DESCRIPTOR)}
</h3>
<p className={previewStyles.endStateDescription} data-flx="messaging.saved-messages-content.p--2">
{i18n._(THERE_S_NOTHING_MORE_TO_SEE_HERE_DESCRIPTOR)}
</p>
</div>
</div>
</div>
</div>
)}
</Scroller>
</NearViewportSurfaceContext.Provider>
);
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {SavedMessageEntry} from '@app/features/messaging/models/SavedMessageEntry';
import SavedMessages from '@app/features/messaging/state/SavedMessages';
import {SAVED_MESSAGES_PAGE_SIZE} from '@fluxer/constants/src/LimitConstants';
import {beforeEach, describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/messaging/models/MessagingMessage', () => ({
Message: class {
readonly id: string;
constructor(data: {id: string}) {
this.id = data.id;
}
},
}));
function entryPage(startId: number, count: number): Array<SavedMessageEntry> {
return Array.from({length: count}, (_unused, index) =>
SavedMessageEntry.fromResponse({
id: String(startId - index),
channel_id: '10',
message_id: String(startId - index),
status: 'missing_permissions',
message: null,
}),
);
}
describe('SavedMessages pagination', () => {
beforeEach(() => {
SavedMessages.handleGatewayReady();
});
it('reports more pages while a full page comes back and tracks the oldest entry', () => {
const requestId = SavedMessages.handleFetchPending();
SavedMessages.fetchSuccess(requestId, entryPage(1000, SAVED_MESSAGES_PAGE_SIZE), false);
expect(SavedMessages.getHasMore()).toBe(true);
expect(SavedMessages.getIsLoadingMore()).toBe(false);
expect(SavedMessages.getCursor()).toBe(String(1000 - (SAVED_MESSAGES_PAGE_SIZE - 1)));
});
it('appends the next page instead of replacing the loaded one', () => {
const first = SavedMessages.handleFetchPending();
SavedMessages.fetchSuccess(first, entryPage(1000, SAVED_MESSAGES_PAGE_SIZE), false);
const second = SavedMessages.handleFetchPending();
SavedMessages.fetchSuccess(second, entryPage(900, 10), true);
expect(SavedMessages.getMissingEntries()).toHaveLength(SAVED_MESSAGES_PAGE_SIZE + 10);
expect(SavedMessages.getHasMore()).toBe(false);
expect(SavedMessages.getCursor()).toBe('891');
});
it('ignores a page that a newer request has superseded', () => {
const stale = SavedMessages.handleFetchPending();
SavedMessages.handleFetchPending();
SavedMessages.fetchSuccess(stale, entryPage(1000, 5), false);
expect(SavedMessages.fetched).toBe(false);
expect(SavedMessages.getMissingEntries()).toHaveLength(0);
});
it('keeps the loaded pages when loading more fails', () => {
const first = SavedMessages.handleFetchPending();
SavedMessages.fetchSuccess(first, entryPage(1000, SAVED_MESSAGES_PAGE_SIZE), false);
const second = SavedMessages.handleFetchPending();
SavedMessages.fetchError(second, true);
expect(SavedMessages.getMissingEntries()).toHaveLength(SAVED_MESSAGES_PAGE_SIZE);
expect(SavedMessages.getIsLoadingMore()).toBe(false);
expect(SavedMessages.fetched).toBe(true);
});
it('clears everything when the first page fails', () => {
const first = SavedMessages.handleFetchPending();
SavedMessages.fetchSuccess(first, entryPage(1000, 5), false);
const retry = SavedMessages.handleFetchPending();
SavedMessages.fetchError(retry, false);
expect(SavedMessages.getMissingEntries()).toHaveLength(0);
expect(SavedMessages.fetched).toBe(false);
expect(SavedMessages.getCursor()).toBeNull();
expect(SavedMessages.getHasMore()).toBe(true);
});
});
@@ -2,14 +2,23 @@
import {Message} from '@app/features/messaging/models/MessagingMessage';
import type {SavedMessageEntry, SavedMessageMissingEntry} from '@app/features/messaging/models/SavedMessageEntry';
import {SAVED_MESSAGES_PAGE_SIZE} from '@fluxer/constants/src/LimitConstants';
import type {Channel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {makeAutoObservable} from 'mobx';
function byIdDescending(a: {id: string}, b: {id: string}): number {
return b.id > a.id ? 1 : a.id > b.id ? -1 : 0;
}
class SavedMessages {
savedMessages: Array<Message> = [];
missingSavedMessages: Array<SavedMessageMissingEntry> = [];
fetched = false;
hasMore = true;
isLoadingMore = false;
cursor: string | null = null;
private fetchGeneration = 0;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
@@ -26,27 +35,74 @@ class SavedMessages {
return this.missingSavedMessages.slice();
}
fetchSuccess(entries: ReadonlyArray<SavedMessageEntry>): void {
this.savedMessages = entries
getHasMore(): boolean {
return this.hasMore;
}
getIsLoadingMore(): boolean {
return this.isLoadingMore;
}
getCursor(): string | null {
return this.cursor;
}
handleFetchPending(): number {
this.isLoadingMore = true;
this.fetchGeneration++;
return this.fetchGeneration;
}
fetchSuccess(requestId: number, entries: ReadonlyArray<SavedMessageEntry>, append: boolean): void {
if (requestId !== this.fetchGeneration) return;
const available = entries
.filter((entry) => entry.status === 'available' && entry.message)
.map((entry) => entry.message!)
.sort((a, b) => (b.id > a.id ? 1 : a.id > b.id ? -1 : 0));
this.missingSavedMessages = entries
.map((entry) => entry.message!);
const missing = entries
.filter((entry) => entry.status === 'missing_permissions' || entry.message === null)
.map((entry) => entry.toMissingEntry());
if (append) {
const knownMessageIds = new Set(this.savedMessages.map((message) => message.id));
const knownMissingIds = new Set(this.missingSavedMessages.map((entry) => entry.id));
this.savedMessages = [
...this.savedMessages,
...available.filter((message) => !knownMessageIds.has(message.id)),
].sort(byIdDescending);
this.missingSavedMessages = [
...this.missingSavedMessages,
...missing.filter((entry) => !knownMissingIds.has(entry.id)),
];
} else {
this.savedMessages = available.sort(byIdDescending);
this.missingSavedMessages = missing;
}
if (entries.length > 0) {
this.cursor = entries[entries.length - 1].messageId;
}
this.hasMore = entries.length === SAVED_MESSAGES_PAGE_SIZE;
this.isLoadingMore = false;
this.fetched = true;
}
fetchError(): void {
fetchError(requestId: number, append: boolean): void {
if (requestId !== this.fetchGeneration) return;
this.isLoadingMore = false;
if (append) return;
this.reset();
}
private reset(): void {
this.savedMessages = [];
this.missingSavedMessages = [];
this.fetched = false;
this.hasMore = true;
this.isLoadingMore = false;
this.cursor = null;
}
handleGatewayReady(): void {
this.savedMessages = [];
this.missingSavedMessages = [];
this.fetched = false;
this.reset();
this.fetchGeneration++;
}
handleChannelDelete(channel: Channel): void {