feat(threads): thread and forum UI on web (#3252)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 15:25:43 +02:00
1 parent 3f4160b138
commit ba59a13149
258 files changed
+63052 -15599

No files matched your search

@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Channels from '@app/features/channel/state/Channels';
import {THREAD_FEATURE_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
export function isSyncExcludedChannelId(channelId: string | null | undefined): boolean {
if (channelId == null) return false;
const channel = Channels.getChannel(channelId);
return channel != null && THREAD_FEATURE_CHANNEL_TYPES.has(channel.type);
}
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Authentication from '@app/features/auth/state/Authentication';
import type {Channel} from '@app/features/channel/models/Channel';
import Channels from '@app/features/channel/state/Channels';
import Guilds from '@app/features/guild/state/Guilds';
import GuildMembers from '@app/features/member/state/GuildMembers';
import Permission from '@app/features/permissions/state/Permission';
import ThreadGuilds from '@app/features/threads/state/ThreadGuilds';
import ThreadMemberships from '@app/features/threads/state/ThreadMemberships';
import Users from '@app/features/user/state/Users';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import {TEXT_THREAD_PARENT_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import {
canCreateThread,
canDeleteThread,
canJoinThread,
canLeave,
canRemoveThreadMember,
canUnarchive,
canViewThread,
isThreadModerator,
type ThreadActor,
type ThreadActorContext,
type ThreadCreateKind,
type ThreadPatch,
ThreadPermissionFlags,
type ThreadWriteAction,
threadPatchRequirement,
threadWriteBlock,
withImplicitThreadBits,
} from '@fluxer/constants/src/ThreadPermissionUtils';
function actorFor(guildId: string | undefined, permissions: bigint): ThreadActor {
const userId = Authentication.currentUserId;
const guild = guildId ? Guilds.getGuild(guildId) : null;
const member = guildId && userId ? GuildMembers.getMember(guildId, userId) : null;
const isOwner = guild != null && userId != null && guild.ownerId === userId;
let resolved = withImplicitThreadBits(permissions);
if (!isOwner && guild?.mfaLevel === GuildMFALevel.ELEVATED && !Users.getCurrentUser()?.mfaEnabled) {
resolved &= ~ThreadPermissionFlags.MANAGE_THREADS;
}
return {
permissions: resolved,
isOwner,
timedOut: member?.isTimedOut() ?? false,
};
}
export function getParentActor(parent: Channel): ThreadActor {
return actorFor(parent.guildId, Permission.getChannelPermissions(parent.id) ?? 0n);
}
export function getThreadActorContext(thread: Channel): ThreadActorContext | null {
if (!thread.parentId) return null;
const parent = Channels.getChannel(thread.parentId);
if (!parent) return null;
const actor = getParentActor(parent);
const metadata = thread.threadMetadata;
return {
...actor,
thread: {
type: thread.type,
archived: metadata?.archived ?? false,
locked: metadata?.locked ?? false,
invitable: metadata?.invitable ?? true,
},
isThreadOwner: thread.ownerId != null && thread.ownerId === Authentication.currentUserId,
isMember: ThreadMemberships.isMember(thread.id),
};
}
export function isModeratorOfParent(parent: Channel): boolean {
const actor = getParentActor(parent);
return isThreadModerator(actor.permissions, actor);
}
export function isThreadModeratorFor(thread: Channel): boolean {
const parent = thread.parentId ? Channels.getChannel(thread.parentId) : undefined;
return parent != null && isModeratorOfParent(parent);
}
export function canViewThreadChannel(thread: Channel): boolean {
const ctx = getThreadActorContext(thread);
return ctx != null && canViewThread(ctx) === null;
}
export function canCreateThreadIn(parent: Channel, kind: ThreadCreateKind): boolean {
if (kind === 'private' && parent.type !== ChannelTypes.GUILD_TEXT) return false;
return canCreateThread(getParentActor(parent), kind) === null;
}
export function canStartThreadIn(parent: Channel): boolean {
return (
ThreadGuilds.isActive(parent.guildId) &&
TEXT_THREAD_PARENT_CHANNEL_TYPES.has(parent.type) &&
(canCreateThreadIn(parent, 'public') || canCreateThreadIn(parent, 'private'))
);
}
export function canWriteInThread(thread: Channel, action: ThreadWriteAction): boolean {
const ctx = getThreadActorContext(thread);
return ctx != null && threadWriteBlock(action, ctx) === null;
}
export function canPatchThread(thread: Channel, patch: ThreadPatch): boolean {
const ctx = getThreadActorContext(thread);
return ctx != null && threadPatchRequirement(patch, thread.flags, ctx) === null;
}
export function canUnarchiveThread(thread: Channel): boolean {
const ctx = getThreadActorContext(thread);
return ctx != null && canUnarchive(ctx) === null;
}
export function canJoinThreadChannel(thread: Channel): boolean {
const ctx = getThreadActorContext(thread);
return ctx != null && canJoinThread(ctx) === null;
}
export function canLeaveThreadChannel(thread: Channel): boolean {
const ctx = getThreadActorContext(thread);
return ctx != null && canLeave(ctx) === null;
}
export function canRemoveThreadMembers(thread: Channel): boolean {
const ctx = getThreadActorContext(thread);
return ctx != null && canRemoveThreadMember(ctx) === null;
}
export function canDeleteThreadChannel(thread: Channel): boolean {
const ctx = getThreadActorContext(thread);
return ctx != null && canDeleteThread(ctx) === null;
}
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import * as D from '@app/features/threads/utils/ThreadMessageDescriptors';
import type {RadioOption} from '@app/features/ui/radio_group/RadioGroup';
import type {I18n} from '@lingui/core';
export function getAutoArchiveOptions(i18n: I18n): ReadonlyArray<RadioOption<number>> {
return [
{value: 60, name: i18n._(D.ONE_HOUR_DESCRIPTOR)},
{value: 1440, name: i18n._(D.ONE_DAY_DESCRIPTOR)},
{value: 4320, name: i18n._(D.THREE_DAYS_DESCRIPTOR)},
{value: 10080, name: i18n._(D.ONE_WEEK_DESCRIPTOR)},
];
}
@@ -0,0 +1,289 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {msg} from '@lingui/core/macro';
export const THREADS_DESCRIPTOR = msg({
message: 'Threads',
comment: 'Title of the thread browser and label of the threads button in the channel header.',
});
export const THREAD_DESCRIPTOR = msg({
message: 'Thread',
comment: 'Generic noun for a single thread inside a text channel.',
});
export const CREATE_THREAD_DESCRIPTOR = msg({
message: 'Create thread',
comment: 'Action that opens the new thread pane for a channel or a message.',
});
export const NEW_THREAD_DESCRIPTOR = msg({
message: 'New thread',
comment: 'Title of the pane used to start a new thread.',
});
export const THREAD_NAME_DESCRIPTOR = msg({
message: 'Thread name',
comment: 'Label of the thread name input.',
});
export const PRIVATE_THREAD_DESCRIPTOR = msg({
message: 'Private thread',
comment: 'Label of the toggle that makes a new thread private.',
});
export const PRIVATE_THREAD_HINT_DESCRIPTOR = msg({
message: 'Only people you invite and moderators can see this thread.',
comment: 'Help text under the private thread toggle in the new thread pane.',
});
export const START_CONVERSATION_PLACEHOLDER_DESCRIPTOR = msg({
message: 'Enter a message to start the conversation!',
comment: 'Placeholder of the composer in the new thread pane.',
});
export const THREAD_NAME_REQUIRED_DESCRIPTOR = msg({
message: 'Give the thread a name before you send.',
comment: 'Error shown when the user sends the first message of a new thread without naming it.',
});
export const OPEN_THREAD_DESCRIPTOR = msg({
message: 'Open thread',
comment: 'Action that opens a thread in the side panel.',
});
export const OPEN_FULL_VIEW_DESCRIPTOR = msg({
message: 'Open in full view',
comment: 'Action that opens a thread as the main channel view instead of the side panel.',
});
export const CLOSE_THREAD_PANEL_DESCRIPTOR = msg({
message: 'Close',
comment: 'Button that closes the thread side panel.',
});
export const JOIN_THREAD_DESCRIPTOR = msg({
message: 'Join thread',
comment: 'Action that adds the current user to a thread.',
});
export const LEAVE_THREAD_DESCRIPTOR = msg({
message: 'Leave thread',
comment: 'Action that removes the current user from a thread.',
});
export const REMOVE_FROM_THREAD_DESCRIPTOR = msg({
message: 'Remove from thread',
comment: 'Moderation action that removes the selected member from a thread.',
});
export const ARCHIVE_THREAD_DESCRIPTOR = msg({
message: 'Close thread',
comment: 'Moderator or owner action that archives a thread so it leaves the channel list.',
});
export const UNARCHIVE_THREAD_DESCRIPTOR = msg({
message: 'Open thread again',
comment: 'Action that unarchives an archived thread.',
});
export const LOCK_THREAD_DESCRIPTOR = msg({
message: 'Lock thread',
comment: 'Moderator action that stops non-moderators from sending messages in a thread.',
});
export const UNLOCK_THREAD_DESCRIPTOR = msg({
message: 'Unlock thread',
comment: 'Moderator action that lets everyone send messages in a locked thread again.',
});
export const EDIT_THREAD_DESCRIPTOR = msg({
message: 'Edit thread',
comment: 'Action that opens the thread settings.',
});
export const DELETE_THREAD_DESCRIPTOR = msg({
message: 'Delete thread',
comment: 'Destructive moderator action that deletes a thread and all its messages.',
});
export const DELETE_THREAD_CONFIRM_DESCRIPTOR = msg({
message: 'Are you sure you want to delete {threadName}? This cannot be undone.',
comment: 'Confirmation text before deleting a thread. threadName is the thread name.',
});
export const THREAD_DELETED_DESCRIPTOR = msg({
message: 'Thread deleted',
comment: 'Toast after a thread was deleted.',
});
export const MUTE_THREAD_DESCRIPTOR = msg({
message: 'Mute thread',
comment: 'Action that mutes notifications and unread badges for a thread.',
});
export const UNMUTE_THREAD_DESCRIPTOR = msg({
message: 'Unmute thread',
comment: 'Action that unmutes a thread.',
});
export const COPY_THREAD_LINK_DESCRIPTOR = msg({
message: 'Copy link',
comment: 'Action that copies a link to the thread.',
});
export const COPY_THREAD_ID_DESCRIPTOR = msg({
message: 'Copy thread ID',
comment: 'Developer mode action that copies the thread ID.',
});
export const THREAD_LINK_COPIED_DESCRIPTOR = msg({
message: 'Thread link copied',
comment: 'Toast after the thread link was copied.',
});
export const NOTIFICATION_DEFAULT_DESCRIPTOR = msg({
message: 'Use channel default',
comment: 'Thread notification option that follows the parent channel setting.',
});
export const NOTIFICATION_ALL_DESCRIPTOR = msg({
message: 'All messages',
comment: 'Thread notification option that notifies for every message.',
});
export const NOTIFICATION_MENTIONS_DESCRIPTOR = msg({
message: 'Only @mentions',
comment: 'Thread notification option that notifies only for mentions.',
});
export const NOTIFICATION_NOTHING_DESCRIPTOR = msg({
message: 'Nothing',
comment: 'Thread notification option that never notifies.',
});
export const THREAD_NOTIFICATIONS_DESCRIPTOR = msg({
message: 'Notification settings',
comment: 'Submenu label for the thread notification options.',
});
export const THREAD_ARCHIVED_NOTICE_DESCRIPTOR = msg({
message: 'This thread is closed. Sending a message will open it again.',
comment: 'Notice above the composer of an archived thread.',
});
export const THREAD_LOCKED_NOTICE_DESCRIPTOR = msg({
message: 'This thread is locked. Only moderators can send messages.',
comment: 'Barrier shown instead of the composer in a locked thread.',
});
export const THREAD_JOIN_NOTICE_DESCRIPTOR = msg({
message: 'Join this thread to follow it in your channel list and get notifications.',
comment: 'Notice above the composer when the user is not a member of the thread.',
});
export const THREAD_MEMBERS_DESCRIPTOR = msg({
message: 'Thread members',
comment: 'Title of the thread member list.',
});
export const MESSAGE_COUNT_DESCRIPTOR = msg({
message: '{count, plural, one {# message} other {# messages}}',
comment: 'Message count on a thread chip and in the thread browser. count is the number of messages.',
});
export const NO_RECENT_MESSAGES_DESCRIPTOR = msg({
message: 'There are no recent messages in this thread.',
comment: 'Preview text on a thread chip when the thread has no messages yet.',
});
export const ACTIVE_THREADS_DESCRIPTOR = msg({
message: 'Active',
comment: 'Tab in the thread browser that lists active threads.',
});
export const ARCHIVED_THREADS_DESCRIPTOR = msg({
message: 'Closed',
comment: 'Tab in the thread browser that lists archived threads.',
});
export const JOINED_THREADS_DESCRIPTOR = msg({
message: 'Joined',
comment: 'Section header in the thread browser for threads the user joined.',
});
export const OTHER_THREADS_DESCRIPTOR = msg({
message: 'Other active threads',
comment: 'Section header in the thread browser for active threads the user did not join.',
});
export const ARCHIVED_PRIVATE_THREADS_DESCRIPTOR = msg({
message: 'Closed private',
comment:
'Tab in the thread browser that lists closed private threads. Keep it short, it shares a row with Active and Closed.',
});
export const NO_THREADS_DESCRIPTOR = msg({
message: 'There are no threads',
comment: 'Empty state of the thread browser.',
});
export const NO_THREADS_HINT_DESCRIPTOR = msg({
message: 'Stay focused on a conversation with a thread, a temporary text channel.',
comment: 'Empty state explanation in the thread browser.',
});
export const LOAD_MORE_DESCRIPTOR = msg({
message: 'Load more',
comment: 'Button that loads the next page of archived threads.',
});
export const STARTED_A_THREAD_DESCRIPTOR = msg({
message: 'started a thread',
comment: 'Fragment of the thread created system message. Appears after the author name.',
});
export const SEE_ALL_THREADS_DESCRIPTOR = msg({
message: 'See all threads',
comment: 'Link in the thread created system message that opens the thread browser.',
});
export const STARTER_MESSAGE_DELETED_DESCRIPTOR = msg({
message: 'Original message was deleted.',
comment: 'Shown at the top of a thread when the message it was started from was deleted.',
});
export const HIDE_AFTER_INACTIVITY_DESCRIPTOR = msg({
message: 'Hide after inactivity',
comment: 'Label of the thread auto archive duration setting.',
});
export const HIDE_AFTER_INACTIVITY_HINT_DESCRIPTOR = msg({
message: 'New threads will stop showing in the channel list after this period of inactivity.',
comment: 'Help text for the default thread auto archive duration setting on a text channel.',
});
export const DEFAULT_HIDE_AFTER_INACTIVITY_DESCRIPTOR = msg({
message: 'Default hide after inactivity',
comment: 'Label of the default auto archive duration for new threads in a text channel.',
});
export const DEFAULT_THREAD_SLOWMODE_DESCRIPTOR = msg({
message: 'Default thread slowmode',
comment: 'Label of the slowmode copied onto new threads in a text channel.',
});
export const INVITABLE_DESCRIPTOR = msg({
message: 'Allow anyone to invite',
comment: 'Private thread setting that lets non-moderators add other members.',
});
export const ONE_HOUR_DESCRIPTOR = msg({
message: '1 hour',
comment: 'Thread auto archive duration option.',
});
export const ONE_DAY_DESCRIPTOR = msg({
message: '24 hours',
comment: 'Thread auto archive duration option.',
});
export const THREE_DAYS_DESCRIPTOR = msg({
message: '3 days',
comment: 'Thread auto archive duration option.',
});
export const ONE_WEEK_DESCRIPTOR = msg({
message: '1 week',
comment: 'Thread auto archive duration option.',
});
export const THREAD_SETTINGS_DESCRIPTOR = msg({
message: 'Thread settings',
comment: 'Title of the thread settings modal.',
});
export const SAVE_DESCRIPTOR = msg({
message: 'Save',
comment: 'Save button in the thread settings modal.',
});
export const SLOWMODE_COOLDOWN_DESCRIPTOR = msg({
message: 'You can start another thread in {seconds, plural, one {# second} other {# seconds}}.',
comment: 'Cooldown notice in the new thread pane when the parent channel has slowmode. seconds is the wait time.',
});
export const THREAD_CREATE_FAILED_DESCRIPTOR = msg({
message: 'Could not start the thread: {detail}',
comment: 'Toast when creating a thread failed. detail is the server error message.',
});
export const THREAD_ACTION_FAILED_DESCRIPTOR = msg({
message: 'Something went wrong: {detail}',
comment: 'Toast when a thread action failed. detail is the server error message.',
});
export const THREAD_STARTED_BY_DESCRIPTOR = msg({
message: 'Started by {name}',
comment: 'Subtitle at the top of a thread. name is the display name of the member who started it.',
});
export const SEARCH_THREADS_DESCRIPTOR = msg({
message: 'Search threads',
comment: 'Placeholder and label of the search box at the top of the thread browser.',
});
export const NO_MATCHING_THREADS_DESCRIPTOR = msg({
message: 'No threads match',
comment: 'Empty state of the thread browser while searching.',
});
export const NO_MATCHING_THREADS_HINT_DESCRIPTOR = msg({
message: 'Try a different name or check the other tab.',
comment: 'Hint under the empty search state in the thread browser.',
});
export const THREAD_SEARCH_FAILED_DESCRIPTOR = msg({
message: "Couldn't search threads. Try again later.",
comment: 'Error state of the thread browser search.',
});
export const THREAD_SEARCH_INDEXING_DESCRIPTOR = msg({
message: 'Search is getting ready for this community. Trying again shortly...',
comment: 'Shown in the thread browser while the search index is being built.',
});
export const RETRY_SEARCH_DESCRIPTOR = msg({
message: 'Retry',
comment: 'Button that retries a failed thread search.',
});
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Channel} from '@app/features/channel/models/Channel';
import Channels from '@app/features/channel/state/Channels';
import ThreadMemberships from '@app/features/threads/state/ThreadMemberships';
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
import {MessageNotifications} from '@fluxer/constants/src/NotificationConstants';
import {ThreadMemberFlags} from '@fluxer/constants/src/ThreadConstants';
export const ThreadNotificationSetting = {
DEFAULT: 'default',
ALL_MESSAGES: 'all_messages',
ONLY_MENTIONS: 'only_mentions',
NO_MESSAGES: 'no_messages',
} as const;
export type ThreadNotificationSetting = (typeof ThreadNotificationSetting)[keyof typeof ThreadNotificationSetting];
export function getThreadParent(thread: Channel): Channel | undefined {
return thread.parentId ? Channels.getChannel(thread.parentId) : undefined;
}
export function isThreadParentMuted(thread: Channel): boolean {
return thread.parentId != null && UserGuildSettings.isMutedAtAnyLevel(thread.guildId ?? null, thread.parentId);
}
export function isThreadMuted(thread: Channel): boolean {
return ThreadMemberships.isMuted(thread.id) || isThreadParentMuted(thread);
}
export function getThreadNotificationSetting(threadId: string): ThreadNotificationSetting {
const flags = ThreadMemberships.get(threadId)?.flags ?? 0;
if ((flags & ThreadMemberFlags.ALL_MESSAGES) !== 0) return ThreadNotificationSetting.ALL_MESSAGES;
if ((flags & ThreadMemberFlags.ONLY_MENTIONS) !== 0) return ThreadNotificationSetting.ONLY_MENTIONS;
if ((flags & ThreadMemberFlags.NO_MESSAGES) !== 0) return ThreadNotificationSetting.NO_MESSAGES;
return ThreadNotificationSetting.DEFAULT;
}
export function getThreadNotificationFlag(setting: ThreadNotificationSetting): number {
switch (setting) {
case ThreadNotificationSetting.ALL_MESSAGES:
return ThreadMemberFlags.ALL_MESSAGES;
case ThreadNotificationSetting.ONLY_MENTIONS:
return ThreadMemberFlags.ONLY_MENTIONS;
case ThreadNotificationSetting.NO_MESSAGES:
return ThreadMemberFlags.NO_MESSAGES;
default:
return 0;
}
}
export function resolveThreadNotificationLevel(thread: Channel): number {
switch (getThreadNotificationSetting(thread.id)) {
case ThreadNotificationSetting.ALL_MESSAGES:
return MessageNotifications.ALL_MESSAGES;
case ThreadNotificationSetting.ONLY_MENTIONS:
return MessageNotifications.ONLY_MENTIONS;
case ThreadNotificationSetting.NO_MESSAGES:
return MessageNotifications.NO_MESSAGES;
}
const parent = getThreadParent(thread);
if (!parent) return MessageNotifications.ONLY_MENTIONS;
return UserGuildSettings.resolveEffectiveMessageNotifications({
id: parent.id,
guildId: parent.guildId,
parentId: parent.parentId ?? undefined,
type: parent.type,
});
}
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Channel} from '@app/features/channel/models/Channel';
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils';
import Users from '@app/features/user/state/Users';
import type {MessageAttachment, MessageStickerItem} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
export interface ThreadStarterPayload {
content: string;
nonce: string;
attachments: Array<MessageAttachment>;
hasAttachments: boolean;
stickers: Array<MessageStickerItem>;
favoriteMemeId?: string;
}
export function sendThreadStarter(thread: Channel, payload: ThreadStarterPayload): void {
const currentUser = Users.getCurrentUser();
if (!currentUser || !MessageCommands.reserveSend(thread.id, payload.nonce)) return;
const message = MessageSubmitUtils.createOptimisticMessage(
{
content: payload.content,
channelId: thread.id,
nonce: payload.nonce,
currentUser,
stickers: payload.stickers,
favoriteMemeId: payload.favoriteMemeId,
},
payload.attachments,
);
MessageCommands.createOptimistic(thread.id, message.toJSON());
void MessageCommands.send(thread.id, {
content: message.content,
nonce: payload.nonce,
hasAttachments: payload.attachments.length > 0 || payload.hasAttachments,
allowedMentions: {replied_user: true},
flags: message.flags,
stickers: payload.stickers,
favoriteMemeId: payload.favoriteMemeId,
});
}
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Channels from '@app/features/channel/state/Channels';
import Navigation from '@app/features/navigation/state/Navigation';
import ReadStates from '@app/features/read_state/state/ReadStates';
import ChannelThreads from '@app/features/threads/state/ChannelThreads';
import ThreadGuilds from '@app/features/threads/state/ThreadGuilds';
import ThreadMemberships from '@app/features/threads/state/ThreadMemberships';
export function getOpenThreadIds(): ReadonlySet<string> {
const ids = new Set<string>();
if (Navigation.threadId) ids.add(Navigation.threadId);
const channelId = Navigation.channelId;
if (channelId && Channels.getChannel(channelId)?.isThread()) ids.add(channelId);
return ids;
}
export function getUnreadThreadIds(guildId: string): Array<string> {
if (!ThreadGuilds.isActive(guildId)) return [];
const ids: Array<string> = [];
for (const threadId of ChannelThreads.getGuildThreadIds(guildId)) {
const joinedUnread = ThreadMemberships.isMember(threadId) && ReadStates.hasUnread(threadId);
if (joinedUnread || ReadStates.getMentionCount(threadId) > 0) ids.push(threadId);
}
return ids;
}