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

This commit is contained in:
Hampus
2026-09-06 21:05:57 +02:00
committed by GitHub
parent d91b5bec66
commit fbf0f6adfe
16 changed files with 322 additions and 57 deletions
+2 -1
View File
@@ -22705,7 +22705,8 @@
"type": "string",
"description": "Maximum number of saved messages to return (1-100, default 25)"
}
}
},
{"name": "before", "in": "query", "required": false, "schema": {"$ref": "#/components/schemas/SnowflakeType"}}
]
},
"post": {
@@ -123,9 +123,11 @@ export function UserContentController(app: HonoApp) {
'Retrieves all messages saved by the current user. Messages are saved privately for easy reference. Returns paginated list of saved messages with metadata.',
}),
async (ctx) => {
const {limit, before} = ctx.req.valid('query');
const response = await ctx.get('userContentRequestService').listSavedMessages({
userId: ctx.get('user').id,
limit: ctx.req.valid('query').limit,
limit,
before: before ? createMessageID(before) : undefined,
requestCache: ctx.get('requestCache'),
});
return ctx.json(response, 200);
@@ -46,6 +46,7 @@ interface UserMentionsReadParams {
interface SavedMessagesParams {
userId: UserID;
limit: number;
before?: MessageID;
requestCache: RequestCache;
}
@@ -108,7 +109,11 @@ export class UserContentRequestService {
}
async listSavedMessages(params: SavedMessagesParams): Promise<SavedMessageEntryListResponse> {
const entries = await this.userContentService.getSavedMessages({userId: params.userId, limit: params.limit});
const entries = await this.userContentService.getSavedMessages({
userId: params.userId,
limit: params.limit,
before: params.before,
});
const messages = entries.map((entry) => entry.message).filter((message): message is Message => message != null);
const responses = await this.userContentService.buildMessageResponsesForUser(params.userId, messages);
const responseByMessageId = new Map(responses.map((response) => [response.id, response] as const));
@@ -100,6 +100,7 @@ function createUserContentService({
}) {
const batchCalls: Array<ChannelBatchCall> = [];
const deletedSavedMessageIds: Array<string> = [];
const savedMessageListCalls: Array<{limit?: number; before?: MessageID}> = [];
const readableByChannel = new Map<string, Map<string, Message>>();
for (const entry of readable) {
const key = entry.channelId.toString();
@@ -109,7 +110,10 @@ function createUserContentService({
}
const userRepository = {
listRecentMentions: async () => entries,
listSavedMessages: async () => entries,
listSavedMessages: async (_userId: UserID, limit?: number, before?: MessageID) => {
savedMessageListCalls.push({limit, before});
return entries;
},
deleteSavedMessage: async (_userId: UserID, messageId: MessageID) => {
deletedSavedMessageIds.push(messageId.toString());
},
@@ -146,7 +150,7 @@ function createUserContentService({
{} as unknown as KVBulkMessageDeletionQueueService,
{} as unknown as LimitConfigService,
);
return {service, batchCalls, deletedSavedMessageIds};
return {service, batchCalls, deletedSavedMessageIds, savedMessageListCalls};
}
const CHANNEL_A = createChannelID(100n);
@@ -238,6 +242,15 @@ describe('getRecentMentions', () => {
});
describe('getSavedMessages', () => {
it('passes the page cursor to the repository', async () => {
const entries = [{channelId: CHANNEL_A, messageId: createMessageID(11n)}];
const {service, savedMessageListCalls} = createUserContentService({entries, readable: entries});
await service.getSavedMessages({userId: VIEWER_ID, limit: 50, before: createMessageID(20n)});
expect(savedMessageListCalls).toEqual([{limit: 50, before: createMessageID(20n)}]);
});
it('marks every entry of an unreachable channel as missing permissions without deleting it', async () => {
const entries = [
{channelId: CHANNEL_A, messageId: createMessageID(11n)},
@@ -229,8 +229,16 @@ export class UserContentService {
);
}
async getSavedMessages({userId, limit}: {userId: UserID; limit: number}): Promise<Array<SavedMessageEntry>> {
const savedMessages = await this.userRepository.listSavedMessages(userId, limit);
async getSavedMessages({
userId,
limit,
before,
}: {
userId: UserID;
limit: number;
before?: MessageID;
}): Promise<Array<SavedMessageEntry>> {
const savedMessages = await this.userRepository.listSavedMessages(userId, limit, before);
const messagesByChannel = await this.readMessagesByChannel(userId, savedMessages);
const results: Array<SavedMessageEntry> = [];
const staleMessageIds: Array<MessageID> = [];
@@ -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 {
@@ -178,13 +178,14 @@ A matching entry is deleted and [Recent Mention Delete](/gateway/events/#recent-
Lists the caller's saved message collection. Returns an array of [saved message](#saved-message-object) objects in descending message ID order.
The response is a bare array with no cursor metadata, so `limit` is the only control and an account holding more than 100 entries cannot page past the first 100. An entry whose channel the caller can no longer reach, or whose message the caller can no longer read, comes back with status `missing_permissions` and a null message. An entry whose message no longer exists is deleted and omitted. The array can hold fewer entries than `limit`.
The response is a bare array with no cursor metadata, so `before` and `limit` are the only pagination controls. An entry whose channel the caller can no longer reach, or whose message the caller can no longer read, comes back with status `missing_permissions` and a null message. An entry whose message no longer exists is deleted and omitted. The array can hold fewer entries than `limit`.
### Query parameters
| Field | Type | Description |
| --- | --- | --- |
| limit?<sup>1</sup> <sup>2</sup> | integer | The maximum number of entries read from the collection (1-100, default 25) |
| before?<sup>2</sup> | snowflake | The upper bound on entry message ID, exclusive |
<sup>1</sup> A value outside the range fails with `VALUE_MUST_BE_INTEGER_IN_RANGE` at the `limit` path
+1
View File
@@ -60,6 +60,7 @@ export const MAX_PRIVATE_CHANNELS_PER_USER = 250;
export const MAX_GROUP_DMS_PER_USER = 150;
export const MAX_BOOKMARKS_PREMIUM = 300;
export const MAX_BOOKMARKS_NON_PREMIUM = 50;
export const SAVED_MESSAGES_PAGE_SIZE = 50;
export const MAX_FAVORITE_MEMES_PREMIUM = 500;
export const MAX_FAVORITE_MEMES_NON_PREMIUM = 50;
export const MAX_FAVORITE_MEME_TAGS = 10;
@@ -590,6 +590,7 @@ export const UserSavedMessagesQueryRequest = z.object({
limit: createQueryIntegerType({minValue: 1, maxValue: 100, defaultValue: 25}).describe(
'Maximum number of saved messages to return (1-100, default 25)',
),
before: SnowflakeType.optional().describe('Get saved messages before this message ID'),
});
export type UserSavedMessagesQueryRequest = z.infer<typeof UserSavedMessagesQueryRequest>;