mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05: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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user