mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
882 lines
31 KiB
TypeScript
882 lines
31 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
|
import {Channel} from '@app/features/channel/models/Channel';
|
|
import {ACK_BATCH_DELAY_MS, type GatewayReadState} from '@app/features/read_state/state/read_states/shared';
|
|
import {ChannelTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
|
|
import type {Channel as WireChannel} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
|
import type {Message as WireMessage} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
|
import type {UserPartial} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
|
|
const channels = new Map<string, Channel>();
|
|
const guilds = new Map<string, {joinedAt: string | null}>();
|
|
const blockedUserIds = new Set<string>();
|
|
let pinnedToEnd = false;
|
|
let automaticAck = false;
|
|
const loadedMessages: Array<{id: string; author: {id: string}}> = [];
|
|
let hasMoreBefore = false;
|
|
let hasNewestMessages = true;
|
|
|
|
vi.mock('@app/features/app/state/RuntimeConfig', () => ({default: {localInstanceDomain: 'fluxer.test'}}));
|
|
vi.mock('@app/features/channel/state/Channels', () => ({
|
|
default: {getChannel: (id: string) => channels.get(id)},
|
|
}));
|
|
vi.mock('@app/features/messaging/state/MessagingMessages', () => ({
|
|
default: {
|
|
getMessages: () => ({
|
|
get hasMoreBefore() {
|
|
return hasMoreBefore;
|
|
},
|
|
get length() {
|
|
return loadedMessages.length;
|
|
},
|
|
jumpDestinationId: null,
|
|
hasNewestMessages: () => hasNewestMessages,
|
|
has: (id: string) => loadedMessages.some((m) => m.id === id),
|
|
last: () => loadedMessages[loadedMessages.length - 1],
|
|
forEachBuffered: (cb: (m: unknown) => void) => {
|
|
for (const m of loadedMessages) cb(m);
|
|
},
|
|
}),
|
|
},
|
|
}));
|
|
vi.mock('@app/features/user/state/Users', () => ({
|
|
default: {getCurrentUser: () => ({id: 'me'}), cacheUsers: () => {}},
|
|
}));
|
|
vi.mock('@app/features/relationship/state/Relationships', () => ({
|
|
default: {isBlocked: (id: string) => blockedUserIds.has(id)},
|
|
}));
|
|
vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: (id: string) => guilds.get(id)}}));
|
|
vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => null}}));
|
|
vi.mock('@app/features/user/state/UserGuildSettings', () => ({
|
|
default: {
|
|
isEveryoneMentionSuppressed: () => false,
|
|
isRoleMentionSuppressed: () => false,
|
|
isGuildOrChannelMuted: () => false,
|
|
},
|
|
}));
|
|
vi.mock('@app/features/ui/state/Dimension', () => ({default: {channelPinnedToEnd: () => pinnedToEnd}}));
|
|
vi.mock('@app/features/notification/state/NotificationAutoAck', () => ({
|
|
default: {isAutomaticAckEnabled: () => automaticAck, disableForChannel: () => {}},
|
|
}));
|
|
vi.mock('@app/features/platform/transport/RestTransport', () => ({
|
|
http: {post: vi.fn(async () => ({body: {read_states: []}})), get: vi.fn()},
|
|
}));
|
|
|
|
const {default: ReadStates} = await import('@app/features/read_state/state/ReadStates');
|
|
const {http} = await import('@app/features/platform/transport/RestTransport');
|
|
|
|
const ID = {
|
|
ack: '1519773906704011264',
|
|
newer: '1519773906708205568',
|
|
};
|
|
|
|
let nextChannelId = 0;
|
|
|
|
function seedReadChannel() {
|
|
const channelId = `channel-${++nextChannelId}`;
|
|
channels.set(channelId, new Channel({id: channelId, type: ChannelTypes.GUILD_TEXT, guild_id: 'guild-1'}));
|
|
const state = ReadStates.get(channelId);
|
|
state.ackMessageId = ID.ack;
|
|
state.lastMessageId = ID.ack;
|
|
state.unreadCount = 0;
|
|
state.oldestUnreadMessageId = null;
|
|
return {channelId, state};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
loadedMessages.length = 0;
|
|
hasMoreBefore = false;
|
|
hasNewestMessages = true;
|
|
blockedUserIds.clear();
|
|
pinnedToEnd = false;
|
|
automaticAck = false;
|
|
vi.mocked(http.post).mockClear();
|
|
});
|
|
|
|
describe('ReadStates unread invariant', () => {
|
|
it('never reports a positive unread count without an unread anchor after a passive update', () => {
|
|
const {channelId} = seedReadChannel();
|
|
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
|
const count = ReadStates.getUnreadCount(channelId);
|
|
const anchor = ReadStates.getVisualUnreadMessageId(channelId);
|
|
expect(count > 0).toBe(anchor != null);
|
|
});
|
|
|
|
it('keeps the channel unread for the sidebar even with no anchor to draw a divider at', () => {
|
|
const {channelId} = seedReadChannel();
|
|
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
|
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
|
});
|
|
|
|
it('ignores a passive update that walks the last message id back', () => {
|
|
const {channelId} = seedReadChannel();
|
|
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
|
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
|
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.ack}, 'guild-1');
|
|
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
|
|
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
|
});
|
|
|
|
it('never lowers the watermark when an after page comes back empty', () => {
|
|
const {channelId} = seedReadChannel();
|
|
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
|
|
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
|
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: []});
|
|
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
|
|
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
|
});
|
|
|
|
it('keeps a watermark that points at a message no longer in the channel', () => {
|
|
const {channelId, state} = seedReadChannel();
|
|
state.lastMessageId = ID.newer;
|
|
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
|
|
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: []});
|
|
expect(ReadStates.lastMessageId(channelId)).toBe(ID.newer);
|
|
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
|
});
|
|
|
|
it('acks up to the watermark so a deleted newest message cannot keep the channel unread', () => {
|
|
const {channelId, state} = seedReadChannel();
|
|
state.lastMessageId = ID.newer;
|
|
loadedMessages.push({id: ID.ack, author: {id: 'someone'}});
|
|
ReadStates.handleLoadMessages({channelId, isAfter: true, messages: []});
|
|
ReadStates.handleChannelAckWithStickyUnread({channelId});
|
|
expect(ReadStates.ackMessageId(channelId)).toBe(ID.newer);
|
|
expect(ReadStates.hasUnread(channelId)).toBe(false);
|
|
});
|
|
|
|
it('anchors the divider when a window is loaded whose ack sits outside it', () => {
|
|
const {channelId, state} = seedReadChannel();
|
|
state.lastMessageId = ID.newer;
|
|
hasMoreBefore = true;
|
|
loadedMessages.push({id: ID.newer, author: {id: 'someone'}});
|
|
ReadStates.handleLoadMessages({channelId, messages: []});
|
|
expect(ReadStates.getVisualUnreadMessageId(channelId)).toBe(ID.newer);
|
|
expect(ReadStates.getUnreadCount(channelId) > 0).toBe(true);
|
|
});
|
|
});
|
|
|
|
const ME = 'me';
|
|
const TUNA = 'tuna';
|
|
|
|
const CHANNEL = {
|
|
dm: '1485064866382176259',
|
|
groupDm: '1485064866382176260',
|
|
notes: '1485064866382176261',
|
|
guildText: '1485064866382176262',
|
|
newDm: '1547743690000000000',
|
|
};
|
|
|
|
const MESSAGE = {
|
|
older: '1547700000000000000',
|
|
last: '1547743000000000000',
|
|
incoming: '1547743701495717888',
|
|
next: '1547743701495717890',
|
|
own: '1547743701495717892',
|
|
reply: '1547743701495717893',
|
|
};
|
|
|
|
function user(id: string): UserPartial {
|
|
return {id, username: id, discriminator: '0', global_name: null, avatar: null, avatar_color: null, flags: 0};
|
|
}
|
|
|
|
function dm(id: string, lastMessageId: string | null): WireChannel {
|
|
return {
|
|
id,
|
|
type: ChannelTypes.DM,
|
|
last_message_id: lastMessageId,
|
|
last_pin_timestamp: null,
|
|
recipients: [user(TUNA)],
|
|
};
|
|
}
|
|
|
|
function groupDm(lastMessageId: string | null): WireChannel {
|
|
return {
|
|
id: CHANNEL.groupDm,
|
|
type: ChannelTypes.GROUP_DM,
|
|
last_message_id: lastMessageId,
|
|
last_pin_timestamp: null,
|
|
recipients: [user(TUNA)],
|
|
};
|
|
}
|
|
|
|
function guildText(lastMessageId: string): WireChannel {
|
|
return {id: CHANNEL.guildText, type: ChannelTypes.GUILD_TEXT, guild_id: 'guild-1', last_message_id: lastMessageId};
|
|
}
|
|
|
|
function wireMessage(
|
|
id: string,
|
|
channelId: string,
|
|
authorId: string,
|
|
overrides: Partial<WireMessage> = {},
|
|
): WireMessage {
|
|
return {
|
|
id,
|
|
channel_id: channelId,
|
|
author: user(authorId),
|
|
type: MessageTypes.DEFAULT,
|
|
flags: 0,
|
|
pinned: false,
|
|
tts: false,
|
|
mention_everyone: false,
|
|
content: 'test',
|
|
timestamp: '2026-09-11T00:00:00.000Z',
|
|
mentions: [],
|
|
mention_roles: [],
|
|
...overrides,
|
|
} as WireMessage;
|
|
}
|
|
|
|
function recipientAddMessage(): WireMessage {
|
|
return wireMessage(MESSAGE.next, CHANNEL.groupDm, TUNA, {type: MessageTypes.RECIPIENT_ADD, mentions: [user(ME)]});
|
|
}
|
|
|
|
function readState(channelId: string, ackMessageId: string, mentionCount = 0): GatewayReadState {
|
|
return {id: channelId, last_message_id: ackMessageId, mention_count: mentionCount, version: '1'};
|
|
}
|
|
|
|
function ready(readStates: Array<GatewayReadState>, openChannels: Array<WireChannel>): void {
|
|
channels.clear();
|
|
for (const channel of openChannels) {
|
|
channels.set(channel.id, new Channel(channel));
|
|
}
|
|
ReadStates.handleGatewayReady({readState: readStates, channels: openChannels});
|
|
}
|
|
|
|
function channelCreate(channel: WireChannel): void {
|
|
channels.set(channel.id, new Channel(channel));
|
|
ReadStates.handleChannelCreate({channel});
|
|
}
|
|
|
|
function channelDelete(channel: {id: string; type?: number; guild_id?: string}): void {
|
|
channels.delete(channel.id);
|
|
ReadStates.handleChannelDelete({channel});
|
|
}
|
|
|
|
function messageCreate(message: WireMessage): void {
|
|
ReadStates.handleIncomingMessage({channelId: message.channel_id, message});
|
|
}
|
|
|
|
function unreadState(channelId: string) {
|
|
return {
|
|
unread: ReadStates.hasUnread(channelId),
|
|
unreadCount: ReadStates.getUnreadCount(channelId),
|
|
mentionCount: ReadStates.getMentionCount(channelId),
|
|
ackMessageId: ReadStates.ackMessageId(channelId),
|
|
};
|
|
}
|
|
|
|
describe('ReadStates private channel open, close and reopen', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
ReadStates.clearAll();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('shows a DM closed before READY as unread when CHANNEL_CREATE and MESSAGE_CREATE reopen it', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older)], []);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
|
|
expect(ReadStates.hasUnread(CHANNEL.dm)).toBe(true);
|
|
expect(ReadStates.hasUnreadPrivateChannel(CHANNEL.dm)).toBe(true);
|
|
ReadStates.consumePendingChanges();
|
|
const privateChannelVersion = ReadStates.privateChannelVersion;
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
|
|
expect(ReadStates.privateChannelVersion).toBe(privateChannelVersion + 1);
|
|
expect(ReadStates.consumePendingChanges()).toEqual({
|
|
all: false,
|
|
channelIds: [CHANNEL.dm],
|
|
changes: [{channelId: CHANNEL.dm, guildId: null}],
|
|
});
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 1,
|
|
mentionCount: 1,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
expect(ReadStates.hasUnreadPrivateChannel(CHANNEL.dm)).toBe(true);
|
|
expect(ReadStates.getPrivateChannelUnreadCount(CHANNEL.dm)).toBe(1);
|
|
expect(ReadStates.getPrivateChannelMentionCount(CHANNEL.dm)).toBe(1);
|
|
expect(ReadStates.getOldestUnreadMessageId(CHANNEL.dm)).toBe(MESSAGE.incoming);
|
|
expect(ReadStates.mentionChannelIds).toContain(CHANNEL.dm);
|
|
});
|
|
|
|
it('keeps the mentions of a DM READY closed over the limit when a message reopens it', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older, 2)], []);
|
|
expect(unreadState(CHANNEL.dm)).toEqual({unread: false, unreadCount: 0, mentionCount: 0, ackMessageId: null});
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 0,
|
|
mentionCount: 2,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 1,
|
|
mentionCount: 3,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
});
|
|
|
|
it('shows a DM closed earlier in the session as unread when a message reopens it', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older)], [dm(CHANNEL.dm, MESSAGE.older)]);
|
|
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 1,
|
|
mentionCount: 1,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
expect(ReadStates.getOldestUnreadMessageId(CHANNEL.dm)).toBe(MESSAGE.incoming);
|
|
});
|
|
|
|
it('counts only the messages that arrive after a closed DM reopens', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older)], [dm(CHANNEL.dm, MESSAGE.older)]);
|
|
messageCreate(wireMessage(MESSAGE.last, CHANNEL.dm, TUNA));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 1,
|
|
mentionCount: 1,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 1,
|
|
mentionCount: 2,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
expect(ReadStates.getOldestUnreadMessageId(CHANNEL.dm)).toBe(MESSAGE.incoming);
|
|
});
|
|
|
|
it('hides a closed DM and brings its mentions back when a message reopens it', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older, 2)], [dm(CHANNEL.dm, MESSAGE.last)]);
|
|
ReadStates.consumePendingChanges();
|
|
const privateChannelVersion = ReadStates.privateChannelVersion;
|
|
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
|
|
expect(ReadStates.privateChannelVersion).toBe(privateChannelVersion + 1);
|
|
expect(ReadStates.consumePendingChanges().changes).toEqual([{channelId: CHANNEL.dm, guildId: null}]);
|
|
expect(unreadState(CHANNEL.dm)).toEqual({unread: false, unreadCount: 0, mentionCount: 0, ackMessageId: null});
|
|
expect(ReadStates.mentionChannelIds).not.toContain(CHANNEL.dm);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 1,
|
|
mentionCount: 3,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
});
|
|
|
|
it('keeps a closed DM out of every private channel count while its entry survives', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older, 2)], [dm(CHANNEL.dm, MESSAGE.last)]);
|
|
expect(ReadStates.isUnreadOrMentioned(CHANNEL.dm)).toBe(true);
|
|
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
|
|
expect(ReadStates.getChannelIds()).toContain(CHANNEL.dm);
|
|
expect(ReadStates.hasUnreadPrivateChannel(CHANNEL.dm)).toBe(false);
|
|
expect(ReadStates.getPrivateChannelUnreadCount(CHANNEL.dm)).toBe(0);
|
|
expect(ReadStates.getPrivateChannelMentionCount(CHANNEL.dm)).toBe(0);
|
|
expect(ReadStates.isUnreadOrMentioned(CHANNEL.dm)).toBe(false);
|
|
});
|
|
|
|
it('forgets the view state of a DM when it is closed', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older)], [dm(CHANNEL.dm, MESSAGE.last)]);
|
|
const state = ReadStates.get(CHANNEL.dm);
|
|
state.messagesLoaded = true;
|
|
state.ackedManually = true;
|
|
state.stickyUnreadMessageId = MESSAGE.last;
|
|
state.unreadCount = 3;
|
|
state.oldestUnreadMessageId = MESSAGE.last;
|
|
state.estimated = true;
|
|
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
|
|
expect(ReadStates.getIfExists(CHANNEL.dm)).toMatchObject({
|
|
messagesLoaded: false,
|
|
ackedManually: false,
|
|
stickyUnreadMessageId: null,
|
|
ackMessageId: MESSAGE.older,
|
|
unreadCount: 0,
|
|
oldestUnreadMessageId: null,
|
|
estimated: false,
|
|
});
|
|
});
|
|
|
|
it('keeps an ack that arrives while the DM is closed', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older, 1)], [dm(CHANNEL.dm, MESSAGE.last)]);
|
|
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
|
|
ReadStates.handleMessageAck({
|
|
channelId: CHANNEL.dm,
|
|
messageId: MESSAGE.last,
|
|
mentionCount: 0,
|
|
manual: false,
|
|
version: '2',
|
|
});
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 1,
|
|
mentionCount: 1,
|
|
ackMessageId: MESSAGE.last,
|
|
});
|
|
});
|
|
|
|
it('ignores an older ack that arrives while the DM is closed', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.last, 1)], [dm(CHANNEL.dm, MESSAGE.last)]);
|
|
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
|
|
ReadStates.handleMessageAck({
|
|
channelId: CHANNEL.dm,
|
|
messageId: MESSAGE.older,
|
|
mentionCount: 9,
|
|
manual: false,
|
|
version: '2',
|
|
});
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 1,
|
|
mentionCount: 2,
|
|
ackMessageId: MESSAGE.last,
|
|
});
|
|
});
|
|
|
|
it('applies a manual ack that arrives while the DM is closed', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older, 2)], [dm(CHANNEL.dm, MESSAGE.last)]);
|
|
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
|
|
ReadStates.handleMessageAck({
|
|
channelId: CHANNEL.dm,
|
|
messageId: MESSAGE.last,
|
|
mentionCount: 0,
|
|
manual: true,
|
|
version: '2',
|
|
});
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: false,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.last,
|
|
});
|
|
});
|
|
|
|
it('still sends the ack for a DM read and closed within the batch delay', async () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older)], [dm(CHANNEL.dm, MESSAGE.last)]);
|
|
ReadStates.handleChannelAck({channelId: CHANNEL.dm});
|
|
channelDelete({id: CHANNEL.dm, type: ChannelTypes.DM});
|
|
await vi.advanceTimersByTimeAsync(ACK_BATCH_DELAY_MS);
|
|
expect(http.post).toHaveBeenCalledWith(Endpoints.READ_STATES_ACK, {
|
|
body: {read_states: [{channel_id: CHANNEL.dm, message_id: MESSAGE.last}]},
|
|
});
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: false,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.last,
|
|
});
|
|
});
|
|
|
|
it('shows the first messages from a new contact as unread mentions', () => {
|
|
ready([], []);
|
|
channelCreate(dm(CHANNEL.newDm, MESSAGE.incoming));
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.newDm, TUNA));
|
|
expect(unreadState(CHANNEL.newDm)).toEqual({unread: true, unreadCount: 1, mentionCount: 1, ackMessageId: null});
|
|
expect(ReadStates.getOldestUnreadMessageId(CHANNEL.newDm)).toBe(MESSAGE.incoming);
|
|
messageCreate(wireMessage(MESSAGE.next, CHANNEL.newDm, TUNA));
|
|
expect(unreadState(CHANNEL.newDm)).toEqual({unread: true, unreadCount: 2, mentionCount: 2, ackMessageId: null});
|
|
});
|
|
|
|
it('guesses the ack at the newest message for a mention-free DM without read state', () => {
|
|
ready([], []);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
|
|
loadedMessages.push({id: MESSAGE.last, author: {id: TUNA}}, {id: MESSAGE.incoming, author: {id: TUNA}});
|
|
ReadStates.handleLoadMessages({channelId: CHANNEL.dm, messages: []});
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: false,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.incoming,
|
|
});
|
|
});
|
|
|
|
it('characterisation: keeps a self-opened DM read when its read state covers the last message', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.last)], []);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: false,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.last,
|
|
});
|
|
});
|
|
|
|
it('shows a self-opened DM as unread when READY had it unread', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older)], []);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
});
|
|
|
|
it('shows a self-opened DM with history and no read state as unread', () => {
|
|
ready([], []);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({unread: true, unreadCount: 0, mentionCount: 0, ackMessageId: null});
|
|
});
|
|
|
|
it('keeps an open unread DM unread when a duplicate CHANNEL_CREATE arrives', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older)], [dm(CHANNEL.dm, MESSAGE.last)]);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
});
|
|
|
|
it("characterisation: acks the current user's first message in a new DM and counts the reply", () => {
|
|
ready([], []);
|
|
channelCreate(dm(CHANNEL.newDm, null));
|
|
expect(ReadStates.hasUnread(CHANNEL.newDm)).toBe(false);
|
|
messageCreate(wireMessage(MESSAGE.own, CHANNEL.newDm, ME));
|
|
expect(unreadState(CHANNEL.newDm)).toEqual({
|
|
unread: false,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.own,
|
|
});
|
|
messageCreate(wireMessage(MESSAGE.reply, CHANNEL.newDm, TUNA));
|
|
expect(unreadState(CHANNEL.newDm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 1,
|
|
mentionCount: 1,
|
|
ackMessageId: MESSAGE.own,
|
|
});
|
|
});
|
|
|
|
it("acks the current user's message into a reopened DM that READY had unread", () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older)], []);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
|
|
expect(ReadStates.hasUnread(CHANNEL.dm)).toBe(true);
|
|
messageCreate(wireMessage(MESSAGE.own, CHANNEL.dm, ME));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: false,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.own,
|
|
});
|
|
});
|
|
|
|
it("characterisation: acks the current user's message that CHANNEL_CREATE already includes", () => {
|
|
ready([], []);
|
|
channelCreate(dm(CHANNEL.newDm, MESSAGE.own));
|
|
messageCreate(wireMessage(MESSAGE.own, CHANNEL.newDm, ME));
|
|
expect(unreadState(CHANNEL.newDm)).toEqual({
|
|
unread: false,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.own,
|
|
});
|
|
});
|
|
|
|
it('characterisation: counts only the recipient add message when the user joins a group DM with history', () => {
|
|
ready([], []);
|
|
channelCreate(groupDm(MESSAGE.last));
|
|
expect(ReadStates.hasUnread(CHANNEL.groupDm)).toBe(true);
|
|
messageCreate(recipientAddMessage());
|
|
expect(unreadState(CHANNEL.groupDm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 1,
|
|
mentionCount: 1,
|
|
ackMessageId: null,
|
|
});
|
|
});
|
|
|
|
it('characterisation: counts the recipient add message when the user joins an empty group DM', () => {
|
|
ready([], []);
|
|
channelCreate(groupDm(null));
|
|
messageCreate(recipientAddMessage());
|
|
expect(unreadState(CHANNEL.groupDm)).toEqual({unread: true, unreadCount: 1, mentionCount: 1, ackMessageId: null});
|
|
});
|
|
|
|
it('keeps the read state of a group DM the user leaves and rejoins', () => {
|
|
ready([readState(CHANNEL.groupDm, MESSAGE.older)], [groupDm(MESSAGE.older)]);
|
|
channelDelete({id: CHANNEL.groupDm, type: ChannelTypes.GROUP_DM});
|
|
channelCreate(groupDm(MESSAGE.last));
|
|
expect(unreadState(CHANNEL.groupDm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
messageCreate(recipientAddMessage());
|
|
expect(unreadState(CHANNEL.groupDm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 1,
|
|
mentionCount: 1,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
});
|
|
|
|
it('characterisation: drops the pending ack of a group DM the user leaves', async () => {
|
|
ready([readState(CHANNEL.groupDm, MESSAGE.older)], [groupDm(MESSAGE.last)]);
|
|
ReadStates.handleChannelAck({channelId: CHANNEL.groupDm});
|
|
channelDelete({id: CHANNEL.groupDm, type: ChannelTypes.GROUP_DM});
|
|
await vi.advanceTimersByTimeAsync(ACK_BATCH_DELAY_MS);
|
|
expect(http.post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps the read state of personal notes closed and reopened', () => {
|
|
const notes: WireChannel = {
|
|
id: CHANNEL.notes,
|
|
type: ChannelTypes.DM_PERSONAL_NOTES,
|
|
last_message_id: MESSAGE.older,
|
|
last_pin_timestamp: null,
|
|
};
|
|
ready([readState(CHANNEL.notes, MESSAGE.older)], [notes]);
|
|
channelDelete({id: CHANNEL.notes, type: ChannelTypes.DM_PERSONAL_NOTES});
|
|
channelCreate({...notes, last_message_id: MESSAGE.last});
|
|
expect(unreadState(CHANNEL.notes)).toEqual({
|
|
unread: true,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
});
|
|
|
|
it('characterisation: acks own messages in personal notes', () => {
|
|
ready([], []);
|
|
channelCreate({
|
|
id: CHANNEL.notes,
|
|
type: ChannelTypes.DM_PERSONAL_NOTES,
|
|
last_message_id: MESSAGE.own,
|
|
last_pin_timestamp: null,
|
|
});
|
|
messageCreate(wireMessage(MESSAGE.own, CHANNEL.notes, ME));
|
|
expect(unreadState(CHANNEL.notes)).toEqual({
|
|
unread: false,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.own,
|
|
});
|
|
});
|
|
|
|
it('characterisation: auto-acks a message in the open DM pinned to the bottom', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older)], [dm(CHANNEL.dm, MESSAGE.older)]);
|
|
ReadStates.handleLoadMessages({channelId: CHANNEL.dm, messages: []});
|
|
automaticAck = true;
|
|
pinnedToEnd = true;
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: false,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.incoming,
|
|
});
|
|
expect(ReadStates.getIfExists(CHANNEL.dm)?.inFlightAckMessageId).toBe(MESSAGE.incoming);
|
|
});
|
|
|
|
it('characterisation: keeps a DM reopened by a blocked author read', () => {
|
|
blockedUserIds.add(TUNA);
|
|
ready([readState(CHANNEL.dm, MESSAGE.older)], []);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: false,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.incoming,
|
|
});
|
|
});
|
|
|
|
it('characterisation: keeps a first DM from a blocked author read', () => {
|
|
blockedUserIds.add(TUNA);
|
|
ready([], []);
|
|
channelCreate(dm(CHANNEL.newDm, MESSAGE.incoming));
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.newDm, TUNA));
|
|
expect(unreadState(CHANNEL.newDm)).toEqual({
|
|
unread: false,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: MESSAGE.incoming,
|
|
});
|
|
});
|
|
|
|
it('shows the call message that reopens a DM with read state as unread', () => {
|
|
ready([readState(CHANNEL.dm, MESSAGE.older)], []);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA, {type: MessageTypes.CALL}));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 1,
|
|
mentionCount: 1,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
});
|
|
|
|
it('characterisation: shows the call message that reopens a DM without read state as unread', () => {
|
|
ready([], []);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.dm, TUNA, {type: MessageTypes.CALL}));
|
|
expect(unreadState(CHANNEL.dm)).toEqual({
|
|
unread: true,
|
|
unreadCount: 1,
|
|
mentionCount: 1,
|
|
ackMessageId: null,
|
|
});
|
|
});
|
|
|
|
it('shows a guild message unread when there is no read state to cover it', () => {
|
|
ready([], [guildText(MESSAGE.incoming)]);
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.guildText, TUNA, {guild_id: 'guild-1'}));
|
|
expect(unreadState(CHANNEL.guildText)).toEqual({
|
|
unread: true,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: null,
|
|
});
|
|
});
|
|
|
|
it('characterisation: still forgets a guild channel that a visibility change hides and shows again', () => {
|
|
ready([readState(CHANNEL.guildText, MESSAGE.older, 2)], [guildText(MESSAGE.last)]);
|
|
expect(unreadState(CHANNEL.guildText)).toEqual({
|
|
unread: true,
|
|
unreadCount: 0,
|
|
mentionCount: 2,
|
|
ackMessageId: MESSAGE.older,
|
|
});
|
|
channelDelete({id: CHANNEL.guildText, guild_id: 'guild-1'});
|
|
channelCreate(guildText(MESSAGE.last));
|
|
expect(unreadState(CHANNEL.guildText)).toEqual({
|
|
unread: true,
|
|
unreadCount: 0,
|
|
mentionCount: 0,
|
|
ackMessageId: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('ReadStates ack floor for channels with no read state', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
ReadStates.clearAll();
|
|
guilds.clear();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('shows a guild channel unread when its newest message postdates the join', () => {
|
|
guilds.set('guild-1', {joinedAt: '2026-09-01T00:00:00.000Z'});
|
|
ready([], [guildText(MESSAGE.last)]);
|
|
expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(true);
|
|
});
|
|
|
|
it('leaves a guild channel read when its newest message predates the join', () => {
|
|
guilds.set('guild-1', {joinedAt: '2026-09-11T00:00:00.000Z'});
|
|
ready([], [guildText(MESSAGE.last)]);
|
|
expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(false);
|
|
});
|
|
|
|
it('falls back to the channel snowflake while its guild record is missing', () => {
|
|
ready([], [guildText(MESSAGE.last)]);
|
|
expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(true);
|
|
});
|
|
|
|
it('recomputes the floor once the guild record arrives instead of freezing it', () => {
|
|
ready([], [guildText(MESSAGE.last)]);
|
|
expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(true);
|
|
guilds.set('guild-1', {joinedAt: '2026-09-11T00:00:00.000Z'});
|
|
expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(false);
|
|
});
|
|
|
|
it('never lets the floor drift forward with the wall clock', () => {
|
|
guilds.set('guild-1', {joinedAt: null});
|
|
ready([], [guildText(MESSAGE.last)]);
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.guildText, TUNA, {guild_id: 'guild-1'}));
|
|
expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(true);
|
|
vi.advanceTimersByTime(60 * 60 * 1000);
|
|
expect(ReadStates.hasUnread(CHANNEL.guildText)).toBe(true);
|
|
expect(ReadStates.getUnreadCount(CHANNEL.guildText)).toBe(1);
|
|
});
|
|
|
|
it('counts only the messages that postdate the join', () => {
|
|
guilds.set('guild-1', {joinedAt: '2026-09-10T21:00:00.000Z'});
|
|
ready([], [guildText(MESSAGE.incoming)]);
|
|
loadedMessages.push(
|
|
{id: MESSAGE.older, author: {id: TUNA}},
|
|
{id: MESSAGE.last, author: {id: TUNA}},
|
|
{id: MESSAGE.incoming, author: {id: TUNA}},
|
|
);
|
|
ReadStates.handleLoadMessages({channelId: CHANNEL.guildText, messages: []});
|
|
expect(ReadStates.getUnreadCount(CHANNEL.guildText)).toBe(2);
|
|
expect(ReadStates.getOldestUnreadMessageId(CHANNEL.guildText)).toBe(MESSAGE.last);
|
|
});
|
|
|
|
it("never writes an ack for someone else's message in a channel with no read state", () => {
|
|
guilds.set('guild-1', {joinedAt: '2026-09-01T00:00:00.000Z'});
|
|
ready([], [guildText(MESSAGE.last)]);
|
|
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.guildText, TUNA, {guild_id: 'guild-1'}));
|
|
expect(ReadStates.ackMessageId(CHANNEL.guildText)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('ReadStates guessed ack for private channels', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
ReadStates.clearAll();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('takes the newest message as the ack when a DM has no mentions', () => {
|
|
ready([], []);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
|
|
loadedMessages.push({id: MESSAGE.older, author: {id: TUNA}}, {id: MESSAGE.last, author: {id: TUNA}});
|
|
ReadStates.handleLoadMessages({channelId: CHANNEL.dm, messages: []});
|
|
expect(ReadStates.ackMessageId(CHANNEL.dm)).toBe(MESSAGE.last);
|
|
expect(ReadStates.hasUnread(CHANNEL.dm)).toBe(false);
|
|
});
|
|
|
|
it('walks back one message per mention, skipping the current user', () => {
|
|
ready([], []);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.incoming));
|
|
loadedMessages.push(
|
|
{id: MESSAGE.older, author: {id: TUNA}},
|
|
{id: MESSAGE.last, author: {id: ME}},
|
|
{id: MESSAGE.incoming, author: {id: TUNA}},
|
|
);
|
|
const state = ReadStates.get(CHANNEL.dm);
|
|
state.mentionCount = 1;
|
|
state.rebuild();
|
|
expect(ReadStates.ackMessageId(CHANNEL.dm)).toBe(MESSAGE.last);
|
|
});
|
|
|
|
it('leaves the ack null when the window does not reach the newest message', () => {
|
|
ready([], []);
|
|
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
|
|
hasNewestMessages = false;
|
|
loadedMessages.push({id: MESSAGE.older, author: {id: TUNA}});
|
|
ReadStates.handleLoadMessages({channelId: CHANNEL.dm, messages: []});
|
|
expect(ReadStates.ackMessageId(CHANNEL.dm)).toBeNull();
|
|
});
|
|
});
|