fix(threads): show presence in thread member list (#3368)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-10 15:58:59 +02:00
1 parent bef5e33eec
commit b4c614d21a
4 files changed
+215 -37

No files matched your search

@@ -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<RosterRow>;
thread: Channel;
ownerId: string | null;
}
@@ -57,14 +66,15 @@ const RosterGroup = observer(({id, label, members, thread, ownerId}: RosterGroup
</span>
</div>
<div className={styles.membersList} data-flx="threads.thread-members-panel.roster-group.members-list">
{members.map(({user, online}) => (
{members.map(({user, status, customStatus}) => (
<MemberListItem
key={user.id}
user={user}
channelId={thread.id}
guildId={thread.guildId}
guildMember={thread.guildId ? (GuildMembers.getMember(thread.guildId, user.id) ?? undefined) : undefined}
status={online ? undefined : StatusTypes.OFFLINE}
status={status}
customStatus={customStatus}
isOwner={user.id === ownerId}
disableBackdrop={true}
data-flx="threads.thread-members-panel.roster-group.member-list-item"
@@ -76,14 +86,17 @@ const RosterGroup = observer(({id, label, members, thread, ownerId}: RosterGroup
);
});
function resolveMembers(
guildId: string | undefined,
roster: ReadonlyArray<ThreadRosterMember>,
): Array<{user: User; online: boolean}> {
const members: Array<{user: User; online: boolean}> = [];
function resolveMembers(guildId: string | undefined, roster: ReadonlyArray<ThreadRosterMember>): Array<RosterRow> {
const members: Array<RosterRow> = [];
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}) => {
<RosterGroup
id="online"
label={i18n._(ONLINE_DESCRIPTOR)}
members={members.filter((member) => 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}) => {
<RosterGroup
id="offline"
label={i18n._(OFFLINE_DESCRIPTOR)}
members={members.filter((member) => !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"
@@ -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<string, unknown>}>) {
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', () => {
@@ -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<string>([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);
@@ -25,9 +25,14 @@ class ThreadSubscriptions {
armed = false;
private sent = new Map<string, SentState>();
private lastGuildId: string | null = null;
private listGuilds = new Set<string>();
constructor() {
makeAutoObservable<this, 'sent' | 'lastGuildId'>(this, {sent: false, lastGuildId: false}, {autoBind: true});
makeAutoObservable<this, 'sent' | 'lastGuildId' | 'listGuilds'>(
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<string, {threads?: boolean; thread_member_lists: Array<string>}> = {};
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<string>): 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: []}},
});