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

@@ -42,6 +42,7 @@ export interface GuildSummary {
name: string | null;
icon: string | null;
permissions?: string | null;
threads_active?: true;
}
export interface GuildWithPermissions {
@@ -49,6 +50,7 @@ export interface GuildWithPermissions {
name: string;
icon: string | null;
canAuthorizeBotInvite: boolean;
threadsActive: boolean;
}
export interface SignedInView {
@@ -2,6 +2,7 @@
import type {GuildComboboxOption} from '@app/features/app/components/dialogs/shared/GuildComboboxRenderers';
import {Endpoints} from '@app/features/app/constants/Endpoints';
import {parseRequestedBotPermissions} from '@app/features/auth/components/pages/oauth_authorize_page/hooks/usePermissionSelection';
import {
type GuildSummary,
type GuildWithPermissions,
@@ -82,6 +83,7 @@ export function mapBotGuilds(
userPermissions: permissionsValue,
requestedPermissions,
}),
threadsActive: guild.threads_active === true,
};
});
}
@@ -165,8 +167,9 @@ export function useBotGuilds(enabled: boolean, requestedPermissions: bigint): Bo
return {status, guilds, options, labelById, error};
}
export function useBotInviteDestinations(enabled: boolean, requestedPermissions: bigint): BotInviteDestinationsResult {
export function useBotInviteDestinations(enabled: boolean, rawPermissions: string | null): BotInviteDestinationsResult {
const {i18n} = useLingui();
const requestedPermissions = useMemo(() => parseRequestedBotPermissions(rawPermissions, false), [rawPermissions]);
const i18nRef = useRef(i18n);
i18nRef.current = i18n;
const [status, setStatus] = useState<BotGuildsStatus>(enabled ? 'loading' : 'idle');
@@ -5,9 +5,10 @@ import {
type BotPermissionOption,
formatBotPermissionsQuery,
getAllBotPermissions,
getPermissionFlagByKey,
} from '@app/features/permissions/utils/PermissionUtils';
import {normalizeBotInvitePermissions} from '@fluxer/constants/src/BotPermissionUtils';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {THREAD_AWARE_ALL_PERMISSIONS} from '@fluxer/constants/src/ThreadPermissionUtils';
import {useLingui} from '@lingui/react/macro';
import {useCallback, useMemo, useState} from 'react';
@@ -22,25 +23,32 @@ export interface PermissionSelection {
toBitfield: () => string | undefined;
}
export function usePermissionSelection(rawPermissions: string | null): PermissionSelection {
export function parseRequestedBotPermissions(rawPermissions: string | null, threadsActive: boolean): bigint {
if (!rawPermissions) return 0n;
try {
const parsed = BigInt(rawPermissions);
if (parsed < 0n) return 0n;
return threadsActive
? normalizeBotInvitePermissions(parsed, THREAD_AWARE_ALL_PERMISSIONS)
: normalizeBotInvitePermissions(parsed);
} catch (err) {
logger.warn('Failed to parse requested permissions', err);
return 0n;
}
}
export function usePermissionSelection(rawPermissions: string | null, threadsActive: boolean): PermissionSelection {
const {i18n} = useLingui();
const options = useMemo(() => getAllBotPermissions(i18n), [i18n.locale]);
const requestedBitfield = useMemo(() => {
if (!rawPermissions) return 0n;
try {
const parsed = BigInt(rawPermissions);
if (parsed < 0n) return 0n;
return normalizeBotInvitePermissions(parsed);
} catch (err) {
logger.warn('Failed to parse requested permissions', err);
return 0n;
}
}, [rawPermissions]);
const options = useMemo(() => getAllBotPermissions(i18n, {threads: threadsActive}), [i18n.locale, threadsActive]);
const requestedBitfield = useMemo(
() => parseRequestedBotPermissions(rawPermissions, threadsActive),
[rawPermissions, threadsActive],
);
const requestedKeys = useMemo<ReadonlyArray<string>>(() => {
if (!rawPermissions) return [];
return options
.filter((opt) => {
const flag = Permissions[opt.id as keyof typeof Permissions];
const flag = getPermissionFlagByKey(opt.id);
return flag != null && (requestedBitfield & flag) === flag;
})
.map((opt) => opt.id);
@@ -31,6 +31,7 @@ import type {BotPermissionOption} from '@app/features/permissions/utils/Permissi
import {http} from '@app/features/platform/transport/RestTransport';
import {HttpError} from '@app/features/platform/types/EndpointError';
import {failureMessage} from '@app/features/platform/utils/ResponseInspection';
import ThreadGuilds from '@app/features/threads/state/ThreadGuilds';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
@@ -183,8 +184,15 @@ export function useAuthorizeFlow(options: UseAuthorizeFlowOptions = {}): Authori
const initialMissingClientId = !params;
const publicAppState = useOAuthPublicApp(params?.clientId ?? null);
const scopeSelection = useScopeSelection(scopes);
const permissionSelection = usePermissionSelection(params?.permissions ?? null);
const destinations = useBotInviteDestinations(hasBotScope, permissionSelection.requestedBitfield);
const destinations = useBotInviteDestinations(hasBotScope, params?.permissions ?? null);
const selectedDestinationTarget = parseBotInviteDestinationKey(selectedDestinationKey);
const selectedGuildId = selectedDestinationTarget?.kind === 'guild' ? selectedDestinationTarget.id : null;
const permissionSelection = usePermissionSelection(
params?.permissions ?? null,
selectedGuildId != null &&
(ThreadGuilds.isActive(selectedGuildId) ||
destinations.guilds.some((guild) => guild.id === selectedGuildId && guild.threadsActive)),
);
const initialReviewStep: ReviewStep = includeAccountStep ? 'account' : 'scopes';
const preFetchValidationError = useMemo(() => {
if (!params) return i18n._(MISSING_CLIENT_ID_DESCRIPTOR);