mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(app): show DMs opened by an incoming message as unread (#2667)
This commit is contained in:
@@ -1,20 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
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 makeChannel = (id: string) => ({
|
||||
id,
|
||||
type: 0,
|
||||
guildId: 'guild-1',
|
||||
isPrivate: () => false,
|
||||
getGuildId: () => 'guild-1',
|
||||
});
|
||||
const channels = new Map<string, Channel>();
|
||||
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) => makeChannel(id), getBasicChannel: (id: string) => makeChannel(id)},
|
||||
default: {getChannel: (id: string) => channels.get(id)},
|
||||
}));
|
||||
vi.mock('@app/features/messaging/state/MessagingMessages', () => ({
|
||||
default: {
|
||||
@@ -35,8 +40,12 @@ vi.mock('@app/features/messaging/state/MessagingMessages', () => ({
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('@app/features/user/state/Users', () => ({default: {getCurrentUser: () => ({id: 'me'})}}));
|
||||
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
|
||||
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/member/state/GuildMembers', () => ({default: {getMember: () => null}}));
|
||||
vi.mock('@app/features/user/state/UserGuildSettings', () => ({
|
||||
default: {
|
||||
@@ -45,13 +54,16 @@ vi.mock('@app/features/user/state/UserGuildSettings', () => ({
|
||||
isGuildOrChannelMuted: () => false,
|
||||
},
|
||||
}));
|
||||
vi.mock('@app/features/ui/state/Dimension', () => ({default: {channelPinnedToEnd: () => false}}));
|
||||
vi.mock('@app/features/ui/state/Dimension', () => ({default: {channelPinnedToEnd: () => pinnedToEnd}}));
|
||||
vi.mock('@app/features/notification/state/NotificationAutoAck', () => ({
|
||||
default: {isAutomaticAckEnabled: () => false},
|
||||
default: {isAutomaticAckEnabled: () => automaticAck, disableForChannel: () => {}},
|
||||
}));
|
||||
vi.mock('@app/features/platform/transport/RestTransport', () => ({
|
||||
http: {post: vi.fn(async () => ({body: {read_states: []}})), get: vi.fn()},
|
||||
}));
|
||||
vi.mock('@app/features/platform/transport/RestTransport', () => ({http: {post: vi.fn(), 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',
|
||||
@@ -62,6 +74,7 @@ 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.readStateKnown = true;
|
||||
state.ackMessageId = ID.ack;
|
||||
@@ -71,13 +84,17 @@ function seedReadChannel() {
|
||||
return {channelId, state};
|
||||
}
|
||||
|
||||
describe('ReadStates unread invariant', () => {
|
||||
beforeEach(() => {
|
||||
loadedMessages.length = 0;
|
||||
hasMoreBefore = false;
|
||||
hasNewestMessages = true;
|
||||
});
|
||||
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');
|
||||
@@ -162,3 +179,615 @@ describe('ReadStates unread invariant', () => {
|
||||
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);
|
||||
expect(ReadStates.getIfExists(CHANNEL.newDm)?.readStateKnown).toBe(true);
|
||||
messageCreate(wireMessage(MESSAGE.next, CHANNEL.newDm, TUNA));
|
||||
expect(unreadState(CHANNEL.newDm)).toEqual({unread: true, unreadCount: 2, mentionCount: 2, ackMessageId: null});
|
||||
});
|
||||
|
||||
it('keeps a message already loaded into a DM without read state covered', () => {
|
||||
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: null});
|
||||
expect(ReadStates.getIfExists(CHANNEL.dm)?.readStateKnown).toBe(false);
|
||||
});
|
||||
|
||||
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('leaves a self-opened DM without read state unknown', () => {
|
||||
ready([], []);
|
||||
channelCreate(dm(CHANNEL.dm, MESSAGE.last));
|
||||
expect(unreadState(CHANNEL.dm)).toEqual({unread: false, unreadCount: 0, mentionCount: 0, ackMessageId: null});
|
||||
expect(ReadStates.getIfExists(CHANNEL.dm)?.readStateKnown).toBe(false);
|
||||
});
|
||||
|
||||
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 carries", () => {
|
||||
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(false);
|
||||
messageCreate(recipientAddMessage());
|
||||
expect(unreadState(CHANNEL.groupDm)).toEqual({
|
||||
unread: true,
|
||||
unreadCount: 1,
|
||||
mentionCount: 1,
|
||||
ackMessageId: MESSAGE.last,
|
||||
});
|
||||
});
|
||||
|
||||
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: MESSAGE.last,
|
||||
});
|
||||
});
|
||||
|
||||
it('characterisation: keeps a guild message covered when read state is unknown and the watermark already equals it', () => {
|
||||
ready([], [guildText(MESSAGE.incoming)]);
|
||||
messageCreate(wireMessage(MESSAGE.incoming, CHANNEL.guildText, TUNA, {guild_id: 'guild-1'}));
|
||||
expect(unreadState(CHANNEL.guildText)).toEqual({
|
||||
unread: false,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
ackMessageId: null,
|
||||
});
|
||||
expect(ReadStates.getIfExists(CHANNEL.guildText)?.readStateKnown).toBe(false);
|
||||
});
|
||||
|
||||
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: false,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
ackMessageId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -482,7 +482,10 @@ class ReadStates {
|
||||
if (action.message.guild_id != null) {
|
||||
state.storedGuildId = action.message.guild_id;
|
||||
}
|
||||
const previousLastMessageId = state.lastMessageId;
|
||||
const previousLastMessageId =
|
||||
state.isPrivate && !state.messagesLoaded && state.lastMessageId === action.message.id
|
||||
? null
|
||||
: state.lastMessageId;
|
||||
const currentUser = Users.getCurrentUser();
|
||||
const authorBlocked = Relationships.isBlocked(action.message.author.id);
|
||||
const hadUnreadOrMentions = state.isUnreadOrMentioned();
|
||||
@@ -568,17 +571,6 @@ class ReadStates {
|
||||
state.readStateKnown = archivedState.readStateKnown;
|
||||
this.archivedStates.delete(action.channel.id as ChannelId);
|
||||
}
|
||||
if (
|
||||
(action.channel.type === ChannelTypes.DM ||
|
||||
action.channel.type === ChannelTypes.GROUP_DM ||
|
||||
action.channel.type === ChannelTypes.DM_PERSONAL_NOTES) &&
|
||||
action.channel.last_message_id != null
|
||||
) {
|
||||
state.readStateKnown = true;
|
||||
state.ackMessageId = action.channel.last_message_id;
|
||||
} else if (GUILD_TEXT_BASED_CHANNEL_TYPES.has(action.channel.type) && state.hasUnread()) {
|
||||
this.clearUnreadStateIfRead(state);
|
||||
}
|
||||
this.notifyChange(action.channel.id);
|
||||
}
|
||||
|
||||
@@ -626,6 +618,25 @@ class ReadStates {
|
||||
guild_id?: string;
|
||||
};
|
||||
}): void {
|
||||
const state = this.getIfExists(action.channel.id);
|
||||
if (
|
||||
state != null &&
|
||||
(action.channel.type === ChannelTypes.DM ||
|
||||
action.channel.type === ChannelTypes.GROUP_DM ||
|
||||
action.channel.type === ChannelTypes.DM_PERSONAL_NOTES)
|
||||
) {
|
||||
if (action.channel.type === ChannelTypes.GROUP_DM) {
|
||||
this.cancelPendingAck(action.channel.id);
|
||||
}
|
||||
state.messagesLoaded = false;
|
||||
state.ackedManually = false;
|
||||
state.clearStickyUnread();
|
||||
state.estimated = false;
|
||||
state.unreadCount = 0;
|
||||
state.oldestUnreadMessageId = null;
|
||||
this.notifyChange(action.channel.id);
|
||||
return;
|
||||
}
|
||||
if (action.channel.guild_id != null && GUILD_TEXT_BASED_CHANNEL_TYPES.has(action.channel.type ?? -1)) {
|
||||
this.archiveState(action.channel.id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user