From 87fdfd9c34fee33536c3f7cf9bf402af7dbf47c3 Mon Sep 17 00:00:00 2001 From: Hampus Date: Fri, 11 Sep 2026 13:14:06 +0200 Subject: [PATCH] fix(app): show DMs opened by an incoming message as unread (#2667) --- .../read_state/state/ReadStates.test.ts | 669 +++++++++++++++++- .../features/read_state/state/ReadStates.ts | 35 +- 2 files changed, 672 insertions(+), 32 deletions(-) diff --git a/fluxer_app/src/features/read_state/state/ReadStates.test.ts b/fluxer_app/src/features/read_state/state/ReadStates.test.ts index a14fbafee..49182c758 100644 --- a/fluxer_app/src/features/read_state/state/ReadStates.test.ts +++ b/fluxer_app/src/features/read_state/state/ReadStates.test.ts @@ -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(); +const blockedUserIds = new Set(); +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 { + 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, openChannels: Array): 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, + }); + }); +}); diff --git a/fluxer_app/src/features/read_state/state/ReadStates.ts b/fluxer_app/src/features/read_state/state/ReadStates.ts index 3dfb232e6..ed12c855a 100644 --- a/fluxer_app/src/features/read_state/state/ReadStates.ts +++ b/fluxer_app/src/features/read_state/state/ReadStates.ts @@ -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); }