diff --git a/fluxer_app/src/features/threads/components/ThreadMembersPanel.tsx b/fluxer_app/src/features/threads/components/ThreadMembersPanel.tsx index 718d809a9..7550bc4f1 100644 --- a/fluxer_app/src/features/threads/components/ThreadMembersPanel.tsx +++ b/fluxer_app/src/features/threads/components/ThreadMembersPanel.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import {OutlineFrame} from '@app/features/app/components/layout/OutlineFrame'; +import Authentication from '@app/features/auth/state/Authentication'; import styles from '@app/features/channel/components/ChannelMembers.module.css'; import {MemberListContainer} from '@app/features/channel/components/MemberListContainer'; import {MemberListItem} from '@app/features/channel/components/MemberListItem'; @@ -10,11 +11,13 @@ import Guilds from '@app/features/guild/state/Guilds'; import {OFFLINE_DESCRIPTOR, ONLINE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors'; import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache'; import GuildMembers from '@app/features/member/state/GuildMembers'; +import LocalPresence from '@app/features/presence/state/LocalPresence'; import ThreadRoster, {type ThreadRosterMember} from '@app/features/threads/state/ThreadRoster'; import type {User} from '@app/features/user/models/User'; +import type {CustomStatus} from '@app/features/user/state/CustomStatus'; import Users from '@app/features/user/state/Users'; import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; -import {StatusTypes} from '@fluxer/constants/src/StatusConstants'; +import {isOfflineStatus, type StatusType} from '@fluxer/constants/src/StatusConstants'; import {useLingui} from '@lingui/react/macro'; import {observer} from 'mobx-react-lite'; import {useEffect} from 'react'; @@ -28,10 +31,16 @@ export function useThreadRosterSubscription(thread: Channel | undefined): void { }, [guildId, threadId]); } +interface RosterRow { + user: User; + status: StatusType; + customStatus: CustomStatus | null; +} + interface RosterGroupProps { id: string; label: string; - members: ReadonlyArray<{user: User; online: boolean}>; + members: ReadonlyArray; thread: Channel; ownerId: string | null; } @@ -57,14 +66,15 @@ const RosterGroup = observer(({id, label, members, thread, ownerId}: RosterGroup
- {members.map(({user, online}) => ( + {members.map(({user, status, customStatus}) => ( , -): Array<{user: User; online: boolean}> { - const members: Array<{user: User; online: boolean}> = []; +function resolveMembers(guildId: string | undefined, roster: ReadonlyArray): Array { + const members: Array = []; for (const entry of roster) { const user = Users.getUser(entry.userId); - if (user) members.push({user, online: entry.online}); + if (!user) continue; + if (user.id === Authentication.currentUserId) { + const status = LocalPresence.getStatus(); + members.push({user, status, customStatus: isOfflineStatus(status) ? null : LocalPresence.customStatus}); + } else { + members.push({user, status: entry.status, customStatus: entry.customStatus}); + } } members.sort((a, b) => NicknameUtils.getNickname(a.user, guildId).localeCompare(NicknameUtils.getNickname(b.user, guildId)), @@ -110,7 +123,7 @@ export const ThreadMembersList = observer(({thread}: {thread: Channel}) => { member.online)} + members={members.filter((member) => !isOfflineStatus(member.status))} thread={thread} ownerId={ownerId} data-flx="threads.thread-members-panel.thread-members-list.roster-group.online" @@ -118,7 +131,7 @@ export const ThreadMembersList = observer(({thread}: {thread: Channel}) => { !member.online)} + members={members.filter((member) => isOfflineStatus(member.status))} thread={thread} ownerId={ownerId} data-flx="threads.thread-members-panel.thread-members-list.roster-group.offline" diff --git a/fluxer_app/src/features/threads/state/ChannelThreads.test.ts b/fluxer_app/src/features/threads/state/ChannelThreads.test.ts index c89f1ddb0..2932c0c54 100644 --- a/fluxer_app/src/features/threads/state/ChannelThreads.test.ts +++ b/fluxer_app/src/features/threads/state/ChannelThreads.test.ts @@ -18,6 +18,10 @@ const selectedGuild = observable({selectedGuildId: null as string | null}); const updateGuildSubscriptions = vi.fn(); const gateway = observable({isReady: false, socket: {isConnected: () => true, updateGuildSubscriptions}}); +vi.mock('@lingui/core/macro', () => ({ + msg: (descriptor: unknown) => descriptor, + t: (descriptor: unknown) => descriptor, +})); vi.mock('@app/features/app/state/RuntimeConfig', () => ({default: {localInstanceDomain: 'fluxer.test'}})); vi.mock('@app/features/user/state/Users', () => ({ default: {getUser: () => undefined, cacheUsers: () => {}, getCurrentUser: () => ({id: 'me', mfaEnabled: true})}, @@ -416,12 +420,13 @@ describe('control arm', () => { expect(vi.mocked(http.get)).not.toHaveBeenCalled(); expect(vi.mocked(http.post)).not.toHaveBeenCalled(); ThreadGuilds.handleGuild(guildObject([])); + await Promise.resolve(); expect(updateGuildSubscriptions).toHaveBeenCalledWith({ subscriptions: {[GUILD]: {threads: true, thread_member_lists: []}}, }); }); - it('sends thread member lists only for subscribed threads of the active guild', () => { + it('sends thread member lists only for subscribed threads of the active guild', async () => { ThreadGuilds.handleGuild(guildObject([])); runInAction(() => { selectedGuild.selectedGuildId = GUILD; @@ -429,10 +434,59 @@ describe('control arm', () => { }); ThreadSubscriptions.handleConnectionReady(); const release = ThreadRoster.subscribe(GUILD, THREAD_A); + await Promise.resolve(); expect(updateGuildSubscriptions).toHaveBeenLastCalledWith({ subscriptions: {[GUILD]: {threads: true, thread_member_lists: [THREAD_A]}}, }); release(); + await Promise.resolve(); + }); + + it('switches thread member lists in a single request', async () => { + ThreadGuilds.handleGuild(guildObject([])); + runInAction(() => { + selectedGuild.selectedGuildId = GUILD; + gateway.isReady = true; + }); + ThreadSubscriptions.handleConnectionReady(); + const releaseA = ThreadRoster.subscribe(GUILD, THREAD_A); + await Promise.resolve(); + updateGuildSubscriptions.mockClear(); + releaseA(); + const releaseB = ThreadRoster.subscribe(GUILD, THREAD_B); + await Promise.resolve(); + expect(updateGuildSubscriptions).toHaveBeenCalledTimes(1); + expect(updateGuildSubscriptions).toHaveBeenCalledWith({ + subscriptions: {[GUILD]: {threads: true, thread_member_lists: [THREAD_B]}}, + }); + releaseB(); + await Promise.resolve(); + }); + + it('clears the previous guild thread member lists when the selected guild changes', async () => { + const otherGuild = '1500000000000000099'; + ThreadGuilds.handleGuild(guildObject([])); + ThreadGuilds.handleGuild({...guildObject([]), id: otherGuild}); + runInAction(() => { + selectedGuild.selectedGuildId = GUILD; + gateway.isReady = true; + }); + ThreadSubscriptions.handleConnectionReady(); + const release = ThreadRoster.subscribe(GUILD, THREAD_A); + await Promise.resolve(); + updateGuildSubscriptions.mockClear(); + runInAction(() => { + selectedGuild.selectedGuildId = otherGuild; + }); + await Promise.resolve(); + expect(updateGuildSubscriptions).toHaveBeenCalledWith({ + subscriptions: { + [GUILD]: {thread_member_lists: []}, + [otherGuild]: {threads: true, thread_member_lists: []}, + }, + }); + release(); + await Promise.resolve(); }); it('serializes a control channel without any new keys', () => { @@ -509,6 +563,101 @@ describe('control arm', () => { expect(isSyncExcludedChannelId(PARENT)).toBe(false); expect(isSyncExcludedChannelId('1500000000000000999')).toBe(false); }); + + it('clears the previous guild thread member lists after a guild switch during a reconnect', async () => { + const otherGuild = '1500000000000000099'; + ThreadGuilds.handleGuild(guildObject([])); + ThreadGuilds.handleGuild({...guildObject([]), id: otherGuild}); + runInAction(() => { + selectedGuild.selectedGuildId = GUILD; + gateway.isReady = true; + }); + ThreadSubscriptions.handleConnectionReady(); + const release = ThreadRoster.subscribe(GUILD, THREAD_A); + await Promise.resolve(); + runInAction(() => { + gateway.isReady = false; + }); + release(); + runInAction(() => { + selectedGuild.selectedGuildId = otherGuild; + }); + await Promise.resolve(); + updateGuildSubscriptions.mockClear(); + runInAction(() => { + gateway.isReady = true; + }); + ThreadSubscriptions.handleConnectionReady(); + await Promise.resolve(); + expect(updateGuildSubscriptions).toHaveBeenCalledTimes(1); + expect(updateGuildSubscriptions).toHaveBeenCalledWith({ + subscriptions: { + [GUILD]: {thread_member_lists: []}, + [otherGuild]: {threads: true, thread_member_lists: []}, + }, + }); + }); +}); + +describe('thread roster', () => { + const ONLINE = '1600000000000000001'; + const IDLE = '1600000000000000002'; + const OFFLINE = '1600000000000000003'; + + function listUpdate(members: Array<{user_id: string; presence: Record}>) { + return { + guild_id: GUILD, + thread_id: THREAD_A, + members: members.map((entry) => ({...entry, join_timestamp: null, flags: 0, member: null})), + }; + } + + it('keeps each member status and custom status from the list update', () => { + const release = ThreadRoster.subscribe(GUILD, THREAD_A); + ThreadRoster.handleListUpdate( + listUpdate([ + {user_id: ONLINE, presence: {status: 'online', afk: false, mobile: false, custom_status: null}}, + { + user_id: IDLE, + presence: { + status: 'idle', + afk: true, + mobile: false, + custom_status: {emoji_id: '1471401583408697449', emoji_name: 'nahua_smug', text: 'im spooky goop'}, + }, + }, + {user_id: OFFLINE, presence: {status: 'offline', afk: false, mobile: false}}, + ]), + ); + expect(ThreadRoster.getMembers(THREAD_A)).toEqual([ + {userId: ONLINE, joinTimestamp: null, status: 'online', customStatus: null}, + { + userId: IDLE, + joinTimestamp: null, + status: 'idle', + customStatus: expect.objectContaining({text: 'im spooky goop', emojiName: 'nahua_smug'}), + }, + {userId: OFFLINE, joinTimestamp: null, status: 'offline', customStatus: null}, + ]); + release(); + }); + + it('replaces the roster on every list update and ignores updates after release', () => { + const release = ThreadRoster.subscribe(GUILD, THREAD_A); + ThreadRoster.handleListUpdate( + listUpdate([ + {user_id: ONLINE, presence: {status: 'online'}}, + {user_id: IDLE, presence: {status: 'idle'}}, + ]), + ); + ThreadRoster.handleListUpdate(listUpdate([{user_id: ONLINE, presence: {status: 'dnd'}}])); + expect(ThreadRoster.getMembers(THREAD_A)).toEqual([ + {userId: ONLINE, joinTimestamp: null, status: 'dnd', customStatus: null}, + ]); + release(); + ThreadRoster.handleListUpdate(listUpdate([{user_id: ONLINE, presence: {status: 'online'}}])); + expect(ThreadRoster.getMembers(THREAD_A)).toBeUndefined(); + }); }); describe('control arm message grouping', () => { diff --git a/fluxer_app/src/features/threads/state/ThreadRoster.ts b/fluxer_app/src/features/threads/state/ThreadRoster.ts index 6b42e56b2..68dd0db7f 100644 --- a/fluxer_app/src/features/threads/state/ThreadRoster.ts +++ b/fluxer_app/src/features/threads/state/ThreadRoster.ts @@ -1,7 +1,12 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import GuildMembers from '@app/features/member/state/GuildMembers'; -import {type StatusType, StatusTypes} from '@fluxer/constants/src/StatusConstants'; +import { + type CustomStatus, + fromGatewayCustomStatus, + type GatewayCustomStatusPayload, +} from '@app/features/user/state/CustomStatus'; +import {isOfflineStatus, normalizeStatus, type StatusType} from '@fluxer/constants/src/StatusConstants'; import type {GuildMemberData} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas'; import {makeAutoObservable, observable} from 'mobx'; @@ -15,20 +20,15 @@ export interface ThreadMemberListPayload { join_timestamp: string | null; flags: number; member: GuildMemberData | null; - presence?: {status?: string} | null; + presence?: {status?: string | null; custom_status?: GatewayCustomStatusPayload | null} | null; }>; } export interface ThreadRosterMember { readonly userId: string; readonly joinTimestamp: string | null; - readonly online: boolean; -} - -const OFFLINE_STATUSES = new Set([StatusTypes.OFFLINE, StatusTypes.INVISIBLE]); - -function isOnline(status: string | undefined): boolean { - return status != null && !OFFLINE_STATUSES.has(status as StatusType); + readonly status: StatusType; + readonly customStatus: CustomStatus | null; } class ThreadRoster { @@ -80,10 +80,12 @@ class ThreadRoster { if (entry.member) { GuildMembers.hydrateIfMissing(payload.guild_id, entry.member); } + const status = normalizeStatus(entry.presence?.status); members.push({ userId: entry.user_id, joinTimestamp: entry.join_timestamp, - online: isOnline(entry.presence?.status), + status, + customStatus: isOfflineStatus(status) ? null : fromGatewayCustomStatus(entry.presence?.custom_status), }); } this.rosters.set(payload.thread_id, members); diff --git a/fluxer_app/src/features/threads/state/ThreadSubscriptions.ts b/fluxer_app/src/features/threads/state/ThreadSubscriptions.ts index 79804f6d5..2caf75f61 100644 --- a/fluxer_app/src/features/threads/state/ThreadSubscriptions.ts +++ b/fluxer_app/src/features/threads/state/ThreadSubscriptions.ts @@ -25,9 +25,14 @@ class ThreadSubscriptions { armed = false; private sent = new Map(); private lastGuildId: string | null = null; + private listGuilds = new Set(); constructor() { - makeAutoObservable(this, {sent: false, lastGuildId: false}, {autoBind: true}); + makeAutoObservable( + this, + {sent: false, lastGuildId: false, listGuilds: false}, + {autoBind: true}, + ); deferUntilModulesLoaded(() => { reaction( (): Desired => { @@ -42,7 +47,7 @@ class ThreadSubscriptions { }; }, (desired) => this.apply(desired), - {equals: compareStructural, fireImmediately: true}, + {equals: compareStructural, fireImmediately: true, scheduler: (run) => queueMicrotask(run)}, ); reaction( () => GatewayConnection.isReady, @@ -74,21 +79,29 @@ class ThreadSubscriptions { if (this.lastGuildId) this.sent.delete(this.lastGuildId); this.lastGuildId = desired.guildId; } - if (!desired.ready || !desired.guildId || !desired.active) return; + if (!desired.ready) return; + const guildId = desired.active ? desired.guildId : null; + const subscriptions: Record}> = {}; + for (const listGuildId of this.listGuilds) { + if (listGuildId !== guildId) subscriptions[listGuildId] = {thread_member_lists: []}; + } const memberLists = desired.memberLists.join(','); - const previous = this.sent.get(desired.guildId); - if (previous?.threads && previous.memberLists === memberLists) return; + const previous = guildId ? this.sent.get(guildId) : undefined; + if (guildId && !(previous?.threads && previous.memberLists === memberLists)) { + subscriptions[guildId] = {threads: true, thread_member_lists: [...desired.memberLists]}; + } + if (Object.keys(subscriptions).length === 0) return; const socket = GatewayConnection.socket; if (!socket?.isConnected()) return; - socket.updateGuildSubscriptions({ - subscriptions: { - [desired.guildId]: { - threads: true, - thread_member_lists: [...desired.memberLists], - }, - }, - }); - this.sent.set(desired.guildId, {threads: true, memberLists}); + socket.updateGuildSubscriptions({subscriptions}); + for (const clearedGuildId of Object.keys(subscriptions)) { + if (clearedGuildId !== guildId) this.listGuilds.delete(clearedGuildId); + } + if (guildId && subscriptions[guildId]) { + this.sent.set(guildId, {threads: true, memberLists}); + if (desired.memberLists.length > 0) this.listGuilds.add(guildId); + else this.listGuilds.delete(guildId); + } } private handleActiveGuildsChanged(activeGuildIds: ReadonlyArray): void { @@ -98,6 +111,7 @@ class ThreadSubscriptions { if (active.has(guildId)) continue; this.sent.delete(guildId); if (socket?.isConnected()) { + this.listGuilds.delete(guildId); socket.updateGuildSubscriptions({ subscriptions: {[guildId]: {threads: false, thread_member_lists: []}}, });