mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(threads): show presence in thread member list (#3368)
This commit is contained in:
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: []}},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user