mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
697 lines
27 KiB
TypeScript
697 lines
27 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {Channel, type ChannelWire} from '@app/features/channel/models/Channel';
|
|
import {ChannelTypes, MessageTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
|
import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils';
|
|
import {observable, runInAction} from 'mobx';
|
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
|
|
const channelStore = new Map<string, Channel>();
|
|
const viewable = new Set<string>();
|
|
const moderatorParents = new Set<string>();
|
|
const navigation = observable({
|
|
guildId: null as string | null,
|
|
channelId: null as string | null,
|
|
threadId: null as string | null,
|
|
});
|
|
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})},
|
|
}));
|
|
vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}}));
|
|
vi.mock('@app/app/Routes', () => ({
|
|
Routes: {guildChannel: (guildId: string, channelId?: string) => `/channels/${guildId}/${channelId ?? ''}`},
|
|
}));
|
|
vi.mock('@app/features/channel/events/ChannelDelete', () => ({
|
|
cleanupChannelLocalState: vi.fn((channel: {id: string}) => channelStore.delete(channel.id)),
|
|
}));
|
|
vi.mock('@app/features/channel/state/Channels', () => ({
|
|
default: {
|
|
getChannel: (id: string) => channelStore.get(id),
|
|
upsertThread: (wire: ChannelWire) => channelStore.set(wire.id, new Channel(wire)),
|
|
removeThread: (id: string) => channelStore.delete(id),
|
|
handleChannelCreate: ({channel}: {channel: Channel | ChannelWire}) =>
|
|
channelStore.set(channel.id, channel instanceof Channel ? channel : new Channel(channel)),
|
|
getGuildChannels: (guildId: string) =>
|
|
[...channelStore.values()].filter((channel) => channel.guildId === guildId && !channel.isThread()),
|
|
},
|
|
}));
|
|
vi.mock('@app/features/guild/state/GuildReadState', () => ({default: {handleGenericUpdate: vi.fn()}}));
|
|
vi.mock('@app/features/read_state/state/ReadStates', () => ({default: {handleChannelCreate: vi.fn()}}));
|
|
vi.mock('@app/features/messaging/state/MessagingMessages', () => ({
|
|
default: {handleCleanup: vi.fn(), handleGuildThreadsPurged: vi.fn()},
|
|
}));
|
|
vi.mock('@app/features/ui/commands/ContextMenuCommands', () => ({close: vi.fn()}));
|
|
vi.mock('@app/features/ui/commands/PopoutCommands', () => ({closeAll: vi.fn()}));
|
|
vi.mock('@app/features/guild/state/Guilds', () => ({default: {getGuild: () => ({ownerId: 'owner', mfaLevel: 0})}}));
|
|
vi.mock('@app/features/member/state/GuildMembers', () => ({
|
|
default: {getMember: () => null, hydrateOrRefresh: () => {}},
|
|
}));
|
|
vi.mock('@app/features/permissions/state/Permission', () => ({
|
|
default: {
|
|
can: (_permission: bigint, channel: {id: string}) => viewable.has(channel.id),
|
|
getChannelPermissions: (id: string) => {
|
|
if (!viewable.has(id)) return 0n;
|
|
const base = Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY | Permissions.SEND_MESSAGES;
|
|
return moderatorParents.has(id) ? base | ThreadPermissionFlags.MANAGE_THREADS : base;
|
|
},
|
|
handleChannelUpdate: vi.fn(),
|
|
},
|
|
}));
|
|
vi.mock('@app/features/navigation/state/Navigation', () => ({default: navigation}));
|
|
vi.mock('@app/features/navigation/state/SelectedGuild', () => ({default: selectedGuild}));
|
|
vi.mock('@app/features/navigation/utils/RouterUtils', () => ({replaceWith: vi.fn(), transitionTo: vi.fn()}));
|
|
vi.mock('@app/features/gateway/transport/GatewayConnection', () => ({default: gateway}));
|
|
vi.mock('@app/features/user/utils/DateFormatting', () => ({getFormattedFullDate: () => ''}));
|
|
vi.mock('@app/features/moderation/state/LocalUserSpamOverride', () => ({
|
|
default: {isUserMarkedAsSpammer: () => false},
|
|
}));
|
|
vi.mock('@app/features/platform/transport/RestTransport', () => ({
|
|
http: {get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn()},
|
|
}));
|
|
|
|
const {default: ChannelThreads} = await import('@app/features/threads/state/ChannelThreads');
|
|
const {default: ThreadGuilds} = await import('@app/features/threads/state/ThreadGuilds');
|
|
const {default: ThreadMemberships} = await import('@app/features/threads/state/ThreadMemberships');
|
|
const {default: ThreadRoster} = await import('@app/features/threads/state/ThreadRoster');
|
|
const {default: ThreadSubscriptions} = await import('@app/features/threads/state/ThreadSubscriptions');
|
|
const {isSyncExcludedChannelId} = await import('@app/features/threads/utils/SyncedPreferenceGuard');
|
|
const {cleanupChannelLocalState} = await import('@app/features/channel/events/ChannelDelete');
|
|
const RouterUtils = await import('@app/features/navigation/utils/RouterUtils');
|
|
const {http} = await import('@app/features/platform/transport/RestTransport');
|
|
const {isNewMessageGroup} = await import('@app/features/messaging/utils/MessageGroupingUtils');
|
|
|
|
const GUILD = '1500000000000000000';
|
|
const PARENT = '1500000000000000001';
|
|
const OTHER_PARENT = '1500000000000000002';
|
|
const FORUM = '1500000000000000003';
|
|
const THREAD_A = '1500000000000000010';
|
|
const THREAD_B = '1500000000000000011';
|
|
const PRIVATE_THREAD = '1500000000000000012';
|
|
const MEMBER = {join_timestamp: '2026-09-28T00:00:00.000Z', flags: 0};
|
|
|
|
function metadata(overrides: Partial<{archived: boolean; locked: boolean}> = {}) {
|
|
return {
|
|
archived: false,
|
|
locked: false,
|
|
auto_archive_duration: 4320,
|
|
archive_timestamp: '2026-09-28T00:00:00.000Z',
|
|
create_timestamp: '2026-09-28T00:00:00.000Z',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function thread(id: string, overrides: Partial<ChannelWire> = {}): ChannelWire {
|
|
return {
|
|
id,
|
|
guild_id: GUILD,
|
|
parent_id: PARENT,
|
|
type: ChannelTypes.PUBLIC_THREAD,
|
|
name: `thread-${id}`,
|
|
last_message_id: id,
|
|
thread_metadata: metadata(),
|
|
message_count: 0,
|
|
total_message_sent: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function seedParents(): void {
|
|
channelStore.set(PARENT, new Channel({id: PARENT, guild_id: GUILD, type: ChannelTypes.GUILD_TEXT, name: 'general'}));
|
|
channelStore.set(OTHER_PARENT, new Channel({id: OTHER_PARENT, guild_id: GUILD, type: ChannelTypes.GUILD_TEXT}));
|
|
channelStore.set(FORUM, new Channel({id: FORUM, guild_id: GUILD, type: ChannelTypes.GUILD_FORUM, name: 'forum'}));
|
|
viewable.add(PARENT);
|
|
viewable.add(OTHER_PARENT);
|
|
viewable.add(FORUM);
|
|
}
|
|
|
|
function guildObject(threads?: Array<ChannelWire>) {
|
|
return {
|
|
id: GUILD,
|
|
properties: {},
|
|
channels: [],
|
|
emojis: [],
|
|
members: [],
|
|
member_count: 0,
|
|
roles: [],
|
|
joined_at: '2026-09-28T00:00:00.000Z',
|
|
...(threads ? {threads} : {}),
|
|
} as unknown as Parameters<typeof ThreadGuilds.handleGuild>[0];
|
|
}
|
|
|
|
beforeEach(() => {
|
|
channelStore.clear();
|
|
viewable.clear();
|
|
moderatorParents.clear();
|
|
ChannelThreads.handleGatewayReady([]);
|
|
ThreadGuilds.reset();
|
|
runInAction(() => {
|
|
navigation.guildId = null;
|
|
navigation.channelId = null;
|
|
navigation.threadId = null;
|
|
selectedGuild.selectedGuildId = null;
|
|
gateway.isReady = false;
|
|
});
|
|
updateGuildSubscriptions.mockClear();
|
|
vi.mocked(cleanupChannelLocalState).mockClear();
|
|
vi.mocked(RouterUtils.replaceWith).mockClear();
|
|
seedParents();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.mocked(http.delete).mockClear();
|
|
});
|
|
|
|
describe('ChannelThreads ingest and upsert', () => {
|
|
beforeEach(() => {
|
|
ThreadGuilds.handleGuild(guildObject([]));
|
|
});
|
|
|
|
it('indexes READY threads by parent and records the viewer membership', () => {
|
|
ChannelThreads.handleGatewayReady([
|
|
{id: GUILD, threads: [thread(THREAD_A, {member: MEMBER}), thread(THREAD_B, {parent_id: OTHER_PARENT})]},
|
|
]);
|
|
expect(ChannelThreads.getThreadIdsForParent(PARENT)).toEqual([THREAD_A]);
|
|
expect(ChannelThreads.getThreadIdsForParent(OTHER_PARENT)).toEqual([THREAD_B]);
|
|
expect(ChannelThreads.getGuildThreadIds(GUILD)).toEqual([THREAD_A, THREAD_B]);
|
|
expect(ThreadMemberships.isMember(THREAD_A)).toBe(true);
|
|
expect(ThreadMemberships.isMember(THREAD_B)).toBe(false);
|
|
expect(channelStore.get(THREAD_A)?.isThread()).toBe(true);
|
|
});
|
|
|
|
it('never walks the last message id back on an update', () => {
|
|
ChannelThreads.upsert(thread(THREAD_A, {last_message_id: '1500000000000000099'}));
|
|
ChannelThreads.upsert(thread(THREAD_A, {last_message_id: '1500000000000000050', name: 'renamed'}));
|
|
expect(channelStore.get(THREAD_A)?.lastMessageId).toBe('1500000000000000099');
|
|
expect(channelStore.get(THREAD_A)?.name).toBe('renamed');
|
|
});
|
|
|
|
it('keeps archived threads out of the active and sidebar lists unless open', () => {
|
|
ChannelThreads.upsert(thread(THREAD_A, {member: MEMBER}));
|
|
ChannelThreads.upsert(thread(THREAD_A, {thread_metadata: metadata({archived: true})}));
|
|
expect(ChannelThreads.getThreadsForParent(PARENT).map((entry) => entry.id)).toEqual([THREAD_A]);
|
|
expect(ChannelThreads.getActiveThreadsForParent(PARENT)).toEqual([]);
|
|
expect(ChannelThreads.getSidebarThreads(PARENT, null)).toEqual([]);
|
|
expect(ChannelThreads.getSidebarThreads(PARENT, THREAD_A).map((entry) => entry.id)).toEqual([THREAD_A]);
|
|
});
|
|
|
|
it('filters every getter on parent VIEW_CHANNEL', () => {
|
|
ChannelThreads.upsert(thread(THREAD_A, {member: MEMBER}));
|
|
viewable.delete(PARENT);
|
|
expect(ChannelThreads.getThread(THREAD_A)).toBeUndefined();
|
|
expect(ChannelThreads.getThreadsForParent(PARENT)).toEqual([]);
|
|
expect(ChannelThreads.getGuildThreads(GUILD)).toEqual([]);
|
|
expect(ChannelThreads.getSidebarThreads(PARENT, THREAD_A)).toEqual([]);
|
|
});
|
|
|
|
it('shows private threads only to members and thread moderators', () => {
|
|
ChannelThreads.upsert(thread(PRIVATE_THREAD, {type: ChannelTypes.PRIVATE_THREAD}));
|
|
expect(ChannelThreads.getThread(PRIVATE_THREAD)).toBeUndefined();
|
|
moderatorParents.add(PARENT);
|
|
expect(ChannelThreads.getThread(PRIVATE_THREAD)?.id).toBe(PRIVATE_THREAD);
|
|
moderatorParents.delete(PARENT);
|
|
ChannelThreads.upsert(thread(PRIVATE_THREAD, {type: ChannelTypes.PRIVATE_THREAD, member: MEMBER}));
|
|
expect(ChannelThreads.getThread(PRIVATE_THREAD)?.id).toBe(PRIVATE_THREAD);
|
|
});
|
|
|
|
it('ingests the thread attached to a loaded source message', () => {
|
|
ChannelThreads.ingestMessageThreads([
|
|
{
|
|
id: THREAD_A,
|
|
channel_id: PARENT,
|
|
type: MessageTypes.DEFAULT,
|
|
thread: thread(THREAD_A),
|
|
} as unknown as Parameters<typeof ChannelThreads.ingestMessageThreads>[0][number],
|
|
]);
|
|
expect(ChannelThreads.getThread(THREAD_A)?.parentId).toBe(PARENT);
|
|
});
|
|
});
|
|
|
|
describe('ChannelThreads cascades', () => {
|
|
beforeEach(() => {
|
|
ThreadGuilds.handleGuild(guildObject([]));
|
|
});
|
|
|
|
it('cleans up every thread of a deleted parent', () => {
|
|
ChannelThreads.upsert(thread(THREAD_A, {member: MEMBER}));
|
|
ChannelThreads.upsert(thread(THREAD_B));
|
|
ChannelThreads.handleParentDelete(PARENT);
|
|
expect(
|
|
vi
|
|
.mocked(cleanupChannelLocalState)
|
|
.mock.calls.map(([channel]) => channel.id)
|
|
.sort(),
|
|
).toEqual([THREAD_A, THREAD_B]);
|
|
expect(ChannelThreads.getThreadIdsForParent(PARENT)).toEqual([]);
|
|
expect(ThreadMemberships.isMember(THREAD_A)).toBe(false);
|
|
});
|
|
|
|
it('drops a deleted thread and its membership', () => {
|
|
ChannelThreads.upsert(thread(THREAD_A, {member: MEMBER}));
|
|
ChannelThreads.handleThreadDelete({id: THREAD_A, guild_id: GUILD, parent_id: PARENT, type: 11});
|
|
expect(ChannelThreads.hasThread(THREAD_A)).toBe(false);
|
|
expect(ThreadMemberships.isMember(THREAD_A)).toBe(false);
|
|
expect(channelStore.has(THREAD_A)).toBe(false);
|
|
});
|
|
|
|
it('returns a full view of a deleted thread to its parent before the generic fallback', () => {
|
|
ChannelThreads.upsert(thread(THREAD_A));
|
|
runInAction(() => {
|
|
navigation.guildId = GUILD;
|
|
navigation.channelId = THREAD_A;
|
|
});
|
|
const replaceWith = vi.mocked(RouterUtils.replaceWith);
|
|
const cleanup = vi.mocked(cleanupChannelLocalState);
|
|
ChannelThreads.handleThreadDelete({id: THREAD_A, guild_id: GUILD, parent_id: PARENT, type: 11});
|
|
expect(replaceWith).toHaveBeenCalledWith(`/channels/${GUILD}/${PARENT}`);
|
|
expect(replaceWith.mock.invocationCallOrder[0]).toBeLessThan(cleanup.mock.invocationCallOrder[0]);
|
|
});
|
|
|
|
it('removes stale active threads only for the synced parents', () => {
|
|
ChannelThreads.upsert(thread(THREAD_A));
|
|
ChannelThreads.upsert(thread(THREAD_B, {parent_id: OTHER_PARENT}));
|
|
ChannelThreads.handleListSync(
|
|
{guild_id: GUILD, channel_ids: [PARENT], threads: [], members: []},
|
|
new Set<string>(),
|
|
);
|
|
expect(ChannelThreads.hasThread(THREAD_A)).toBe(false);
|
|
expect(ChannelThreads.hasThread(THREAD_B)).toBe(true);
|
|
});
|
|
|
|
it('drops stale memberships on a guild resync but keeps the threads', () => {
|
|
ChannelThreads.upsert(thread(THREAD_A, {member: MEMBER}));
|
|
ChannelThreads.upsert(thread(THREAD_B, {member: MEMBER, thread_metadata: metadata({archived: true})}));
|
|
ChannelThreads.ingestGuildThreads(GUILD, []);
|
|
expect(ThreadMemberships.isMember(THREAD_A)).toBe(false);
|
|
expect(ThreadMemberships.isMember(THREAD_B)).toBe(true);
|
|
expect(ChannelThreads.hasThread(THREAD_A)).toBe(true);
|
|
});
|
|
|
|
it('keeps the thread that is open in a view through a list sync', () => {
|
|
ChannelThreads.upsert(thread(THREAD_A));
|
|
ChannelThreads.handleListSync({guild_id: GUILD, threads: [], members: []}, new Set([THREAD_A]));
|
|
expect(ChannelThreads.hasThread(THREAD_A)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('ThreadGuilds flip', () => {
|
|
it('activates from the threads key and purges on the kill', () => {
|
|
ThreadGuilds.handleGuild(guildObject([thread(THREAD_A, {member: MEMBER})]));
|
|
ChannelThreads.ingestGuildThreads(GUILD, [thread(THREAD_A, {member: MEMBER})]);
|
|
expect(ThreadGuilds.isActive(GUILD)).toBe(true);
|
|
runInAction(() => {
|
|
navigation.guildId = GUILD;
|
|
navigation.channelId = PARENT;
|
|
navigation.threadId = THREAD_A;
|
|
});
|
|
ThreadGuilds.handleGuild(guildObject());
|
|
expect(ThreadGuilds.isActive(GUILD)).toBe(false);
|
|
const cleaned = vi.mocked(cleanupChannelLocalState).mock.calls.map(([channel]) => channel.id);
|
|
expect(cleaned).toContain(THREAD_A);
|
|
expect(cleaned).toContain(FORUM);
|
|
expect(cleaned).not.toContain(PARENT);
|
|
expect(ChannelThreads.getGuildThreadIds(GUILD)).toEqual([]);
|
|
expect(ThreadMemberships.isMember(THREAD_A)).toBe(false);
|
|
expect(vi.mocked(RouterUtils.replaceWith)).toHaveBeenCalledWith(`/channels/${GUILD}/${PARENT}`);
|
|
expect(ThreadGuilds.isPurgedEvent({channel_id: THREAD_A})).toBe(true);
|
|
expect(ThreadGuilds.isPurgedEvent({channel_id: PARENT})).toBe(false);
|
|
expect(vi.mocked(http.delete)).not.toHaveBeenCalled();
|
|
ThreadGuilds.handleGuild(guildObject([]));
|
|
expect(ThreadGuilds.isPurgedEvent({channel_id: THREAD_A})).toBe(false);
|
|
});
|
|
|
|
it('routes a forum post panel to the guild root on the kill', () => {
|
|
const post = thread(THREAD_B, {parent_id: FORUM, member: MEMBER});
|
|
ThreadGuilds.handleGuild(guildObject([post]));
|
|
ChannelThreads.ingestGuildThreads(GUILD, [post]);
|
|
runInAction(() => {
|
|
navigation.guildId = GUILD;
|
|
navigation.channelId = FORUM;
|
|
navigation.threadId = THREAD_B;
|
|
});
|
|
ThreadGuilds.handleGuild(guildObject());
|
|
expect(vi.mocked(cleanupChannelLocalState).mock.calls.map(([channel]) => channel.id)).toContain(FORUM);
|
|
expect(vi.mocked(RouterUtils.replaceWith)).toHaveBeenLastCalledWith(`/channels/${GUILD}/`);
|
|
});
|
|
|
|
it('routes a full view forum post to the guild root on the kill', () => {
|
|
const post = thread(THREAD_B, {parent_id: FORUM, member: MEMBER});
|
|
ThreadGuilds.handleGuild(guildObject([post]));
|
|
ChannelThreads.ingestGuildThreads(GUILD, [post]);
|
|
runInAction(() => {
|
|
navigation.guildId = GUILD;
|
|
navigation.channelId = THREAD_B;
|
|
});
|
|
ThreadGuilds.handleGuild(guildObject());
|
|
expect(vi.mocked(RouterUtils.replaceWith)).toHaveBeenLastCalledWith(`/channels/${GUILD}/`);
|
|
});
|
|
|
|
it('routes a full view text thread to its parent on the kill', () => {
|
|
const child = thread(THREAD_A, {member: MEMBER});
|
|
ThreadGuilds.handleGuild(guildObject([child]));
|
|
ChannelThreads.ingestGuildThreads(GUILD, [child]);
|
|
runInAction(() => {
|
|
navigation.guildId = GUILD;
|
|
navigation.channelId = THREAD_A;
|
|
});
|
|
ThreadGuilds.handleGuild(guildObject());
|
|
expect(vi.mocked(RouterUtils.replaceWith)).toHaveBeenLastCalledWith(`/channels/${GUILD}/${PARENT}`);
|
|
});
|
|
|
|
it('routes a thread view to the guild root when a new READY drops its forum', () => {
|
|
runInAction(() => {
|
|
navigation.guildId = GUILD;
|
|
navigation.channelId = FORUM;
|
|
navigation.threadId = THREAD_B;
|
|
});
|
|
const forum = {id: FORUM, guild_id: GUILD, type: ChannelTypes.GUILD_FORUM};
|
|
ThreadGuilds.handleGatewayReady([{...guildObject(), channels: [forum]}]);
|
|
expect(vi.mocked(RouterUtils.replaceWith)).toHaveBeenLastCalledWith(`/channels/${GUILD}/`);
|
|
const parent = {id: PARENT, guild_id: GUILD, type: ChannelTypes.GUILD_TEXT};
|
|
runInAction(() => {
|
|
navigation.channelId = PARENT;
|
|
});
|
|
ThreadGuilds.handleGatewayReady([{...guildObject(), channels: [parent]}]);
|
|
expect(vi.mocked(RouterUtils.replaceWith)).toHaveBeenLastCalledWith(`/channels/${GUILD}/${PARENT}`);
|
|
});
|
|
|
|
it('purges a guild that is missing from a new READY', () => {
|
|
ThreadGuilds.handleGuild(guildObject([thread(THREAD_A, {member: MEMBER})]));
|
|
ChannelThreads.ingestGuildThreads(GUILD, [thread(THREAD_A, {member: MEMBER})]);
|
|
ThreadGuilds.handleGatewayReady([]);
|
|
expect(ThreadGuilds.isActive(GUILD)).toBe(false);
|
|
const cleaned = vi.mocked(cleanupChannelLocalState).mock.calls.map(([channel]) => channel.id);
|
|
expect(cleaned).toContain(FORUM);
|
|
expect(ThreadGuilds.isPurgedEvent({channel_id: THREAD_A})).toBe(true);
|
|
});
|
|
|
|
it('ignores unavailable guild stubs and deletes for the gate', () => {
|
|
ThreadGuilds.handleGuild(guildObject([]));
|
|
ThreadGuilds.handleGatewayReady([
|
|
{id: GUILD, unavailable: true} as unknown as Parameters<typeof ThreadGuilds.handleGuild>[0],
|
|
]);
|
|
ThreadGuilds.handleGuildDelete(GUILD, true);
|
|
expect(ThreadGuilds.isActive(GUILD)).toBe(true);
|
|
ThreadGuilds.handleGuildDelete(GUILD, false);
|
|
expect(ThreadGuilds.isActive(GUILD)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('control arm', () => {
|
|
it('stays inert for guilds without the threads key', async () => {
|
|
ThreadGuilds.handleGatewayReady([guildObject()]);
|
|
runInAction(() => {
|
|
selectedGuild.selectedGuildId = GUILD;
|
|
gateway.isReady = true;
|
|
});
|
|
ThreadSubscriptions.handleConnectionReady();
|
|
await Promise.resolve();
|
|
expect(ThreadGuilds.anyActive).toBe(false);
|
|
expect(ThreadGuilds.isPurgedEvent({channel_id: PARENT})).toBe(false);
|
|
expect(updateGuildSubscriptions).not.toHaveBeenCalled();
|
|
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', async () => {
|
|
ThreadGuilds.handleGuild(guildObject([]));
|
|
runInAction(() => {
|
|
selectedGuild.selectedGuildId = GUILD;
|
|
gateway.isReady = true;
|
|
});
|
|
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', () => {
|
|
const wire = {
|
|
id: PARENT,
|
|
guild_id: GUILD,
|
|
type: ChannelTypes.GUILD_TEXT,
|
|
name: 'general',
|
|
topic: null,
|
|
position: 1,
|
|
parent_id: null,
|
|
last_message_id: null,
|
|
permission_overwrites: [],
|
|
nsfw: false,
|
|
rate_limit_per_user: 0,
|
|
};
|
|
const keys = Object.keys(new Channel(wire).toJSON()).sort();
|
|
expect(keys).toEqual(
|
|
[
|
|
'bitrate',
|
|
'content_warning_level',
|
|
'content_warning_text',
|
|
'guild_id',
|
|
'icon',
|
|
'id',
|
|
'last_message_id',
|
|
'last_pin_timestamp',
|
|
'name',
|
|
'nicks',
|
|
'nsfw',
|
|
'nsfw_override',
|
|
'owner_id',
|
|
'parent_id',
|
|
'permission_overwrites',
|
|
'position',
|
|
'rate_limit_per_user',
|
|
'recipients',
|
|
'rtc_p2p',
|
|
'rtc_region',
|
|
'topic',
|
|
'type',
|
|
'url',
|
|
'user_limit',
|
|
'voice_connection_limit',
|
|
].sort(),
|
|
);
|
|
expect(new Channel(wire).threadFields).toBeNull();
|
|
});
|
|
|
|
it('refuses a thread for a guild that is not active', () => {
|
|
expect(ChannelThreads.upsert(thread(THREAD_A, {member: MEMBER}))).toBeUndefined();
|
|
expect(channelStore.has(THREAD_A)).toBe(false);
|
|
expect(ChannelThreads.hasThread(THREAD_A)).toBe(false);
|
|
expect(ThreadMemberships.isMember(THREAD_A)).toBe(false);
|
|
});
|
|
|
|
it('sends no thread keys on a new connection until READY was ingested', () => {
|
|
ThreadGuilds.handleGuild(guildObject([]));
|
|
runInAction(() => {
|
|
selectedGuild.selectedGuildId = GUILD;
|
|
gateway.isReady = true;
|
|
});
|
|
expect(updateGuildSubscriptions).not.toHaveBeenCalled();
|
|
ThreadGuilds.handleGatewayReady([guildObject()]);
|
|
ThreadSubscriptions.handleConnectionReady();
|
|
expect(updateGuildSubscriptions).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses thread and forum ids for synced preferences only', () => {
|
|
ThreadGuilds.handleGuild(guildObject([]));
|
|
ChannelThreads.upsert(thread(THREAD_A));
|
|
expect(isSyncExcludedChannelId(THREAD_A)).toBe(true);
|
|
expect(isSyncExcludedChannelId(FORUM)).toBe(true);
|
|
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: true, 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', mobile: true, customStatus: null},
|
|
{
|
|
userId: IDLE,
|
|
joinTimestamp: null,
|
|
status: 'idle',
|
|
mobile: false,
|
|
customStatus: expect.objectContaining({text: 'im spooky goop', emojiName: 'nahua_smug'}),
|
|
},
|
|
{userId: OFFLINE, joinTimestamp: null, status: 'offline', mobile: false, 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', mobile: false, customStatus: null},
|
|
]);
|
|
release();
|
|
ThreadRoster.handleListUpdate(listUpdate([{user_id: ONLINE, presence: {status: 'online'}}]));
|
|
expect(ThreadRoster.getMembers(THREAD_A)).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('control arm message grouping', () => {
|
|
const SYSTEM_TYPES = [1, 2, 3, 4, 5, 6, 7, MessageTypes.THREAD_CREATED, MessageTypes.CLIENT_SYSTEM];
|
|
|
|
function fakeMessage(type: number, offsetMs: number) {
|
|
return {
|
|
type,
|
|
author: {id: 'author', username: 'author'},
|
|
webhookId: undefined,
|
|
timestamp: new Date(Date.UTC(2026, 8, 28, 12, 0, 0) + offsetMs),
|
|
mentions: [],
|
|
mentionRoles: [],
|
|
mentionEveryone: false,
|
|
hasFlag: () => false,
|
|
isUserMessage: () =>
|
|
type === MessageTypes.DEFAULT || type === MessageTypes.REPLY || type === MessageTypes.CLIENT_SYSTEM,
|
|
} as unknown as Parameters<typeof isNewMessageGroup>[2];
|
|
}
|
|
|
|
it("keeps today's grouping for system, reply and client messages while inactive", () => {
|
|
expect(ThreadGuilds.anyActive).toBe(false);
|
|
for (const type of SYSTEM_TYPES) {
|
|
expect(isNewMessageGroup(undefined, fakeMessage(MessageTypes.DEFAULT, 0), fakeMessage(type, 1000))).toBe(true);
|
|
expect(isNewMessageGroup(undefined, fakeMessage(type, 0), fakeMessage(MessageTypes.DEFAULT, 1000))).toBe(true);
|
|
expect(isNewMessageGroup(undefined, fakeMessage(type, 0), fakeMessage(type, 1000))).toBe(false);
|
|
}
|
|
expect(
|
|
isNewMessageGroup(undefined, fakeMessage(MessageTypes.DEFAULT, 0), fakeMessage(MessageTypes.REPLY, 1000)),
|
|
).toBe(true);
|
|
expect(
|
|
isNewMessageGroup(undefined, fakeMessage(MessageTypes.DEFAULT, 0), fakeMessage(MessageTypes.DEFAULT, 1000)),
|
|
).toBe(false);
|
|
});
|
|
});
|