mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(app): load more bookmarks as the list scrolls (#2556)
This commit is contained in:
1 parent
d91b5bec66
commit
fbf0f6adfe
16 files changed
+322
-57
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user