feat(channels): redesign the create channel modal (#3253)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 15:26:11 +02:00
1 parent ba59a13149
commit c748c8af4e
40 files changed
+6559 -4366

No files matched your search

@@ -0,0 +1,189 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
getOverrideMemberLabel,
selectOverrideMembers,
} from '@app/features/app/components/dialogs/shared/AddOverrideMemberSearch';
import {DEFAULT_ROLE_COLOR_HEX, getRoleColor} from '@app/features/app/components/dialogs/shared/PermissionComponents';
import styles from '@app/features/channel/components/modals/ChannelCreateModal.module.css';
import type {ChannelCreateOverwrite} from '@app/features/channel/utils/ChannelCreateModalUtils';
import Guilds from '@app/features/guild/state/Guilds';
import {MEMBERS_DESCRIPTOR, ROLES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import GuildMembers from '@app/features/member/state/GuildMembers';
import MemberSearch from '@app/features/member/state/MemberSearch';
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
import {Checkbox} from '@app/features/ui/checkbox/Checkbox';
import {Avatar} from '@app/features/ui/components/Avatar';
import {Input} from '@app/features/ui/components/form/FormInput';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {MagnifyingGlassIcon} from '@phosphor-icons/react';
import {matchSorter} from 'match-sorter';
import {observer} from 'mobx-react-lite';
import {useEffect, useMemo, useState} from 'react';
const SEARCH_MEMBERS_OR_ROLES_DESCRIPTOR = msg({
message: 'Search members or roles',
comment: 'Placeholder of the search box in the add members or roles step of the create channel modal.',
});
const ACCESS_HINT_DESCRIPTOR = msg({
message: 'Only the members and roles you pick, plus admins, can view this channel.',
comment: 'Hint under the search box in the add members or roles step of the create channel modal.',
});
const NO_MATCHES_DESCRIPTOR = msg({
message: 'No matching members or roles',
comment: 'Empty state in the add members or roles step of the create channel modal.',
});
const MEMBER_LIMIT = 25;
const SEARCH_DEBOUNCE_MS = 300;
function overwriteKey(entry: ChannelCreateOverwrite): string {
return `${entry.type}:${entry.id}`;
}
interface ChannelCreateAccessStepProps {
guildId: string;
selected: ReadonlyArray<ChannelCreateOverwrite>;
onChange: (selected: Array<ChannelCreateOverwrite>) => void;
}
export const ChannelCreateAccessStep = observer(({guildId, selected, onChange}: ChannelCreateAccessStepProps) => {
const {i18n} = useLingui();
const guild = Guilds.getGuild(guildId);
const [query, setQuery] = useState('');
const [searchIds, setSearchIds] = useState<Array<string>>([]);
useEffect(() => {
const trimmed = query.trim();
if (!trimmed) {
setSearchIds([]);
return;
}
const context = MemberSearch.getSearchContext((results) => {
setSearchIds(results.map((result) => result.id));
}, MEMBER_LIMIT);
context.beginSearch(trimmed, {guild: guildId});
const timer = GuildMembers.isGuildFullyLoaded(guildId)
? null
: setTimeout(() => void MemberSearch.fetchMembersInBackground(trimmed, [guildId], guildId), SEARCH_DEBOUNCE_MS);
return () => {
if (timer) clearTimeout(timer);
context.destroy();
};
}, [query, guildId]);
const selectedKeys = useMemo(() => new Set(selected.map(overwriteKey)), [selected]);
const roles = useMemo(() => {
if (!guild) return [];
const all = Object.values(guild.roles)
.filter((role) => role.id !== guildId)
.sort((a, b) => b.position - a.position);
const trimmed = query.trim();
return trimmed ? matchSorter(all, trimmed, {keys: ['name']}) : all;
}, [guild, guildId, query]);
const members = selectOverrideMembers({
cachedMembers: GuildMembers.getMembers(guildId),
workerMemberIds: searchIds,
resolveMember: (userId) => GuildMembers.getMember(guildId, userId),
excludedIds: new Set(),
guildId,
query,
limit: MEMBER_LIMIT,
});
const toggle = (entry: ChannelCreateOverwrite, checked: boolean) => {
const key = overwriteKey(entry);
onChange(checked ? [...selected, entry] : selected.filter((item) => overwriteKey(item) !== key));
};
return (
<div className={styles.accessStep} data-flx="channel.channel-create-access-step.access-step">
<div className={styles.accessSearch} data-flx="channel.channel-create-access-step.access-search">
<Input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={i18n._(SEARCH_MEMBERS_OR_ROLES_DESCRIPTOR)}
aria-label={i18n._(SEARCH_MEMBERS_OR_ROLES_DESCRIPTOR)}
autoComplete="off"
leftIcon={
<MagnifyingGlassIcon
size={remFromPx(16)}
weight="bold"
data-flx="channel.channel-create-access-step.magnifying-glass-icon"
/>
}
data-flx="channel.channel-create-access-step.search-input.text"
/>
<p className={styles.accessHint} data-flx="channel.channel-create-access-step.access-hint">
{i18n._(ACCESS_HINT_DESCRIPTOR)}
</p>
</div>
<div className={styles.accessList} data-flx="channel.channel-create-access-step.access-list">
{roles.length > 0 && (
<section className={styles.accessSection} data-flx="channel.channel-create-access-step.roles">
<h3 className={styles.accessHeading} data-flx="channel.channel-create-access-step.roles-heading">
{i18n._(ROLES_DESCRIPTOR)}
</h3>
{roles.map((role) => {
const entry: ChannelCreateOverwrite = {id: role.id, type: 0};
return (
<div key={role.id} className={styles.accessRow} data-flx="channel.channel-create-access-step.role-row">
<Checkbox
checked={selectedKeys.has(overwriteKey(entry))}
onChange={(checked) => toggle(entry, checked)}
data-flx="channel.channel-create-access-step.checkbox.role"
>
<span className={styles.accessLabel} data-flx="channel.channel-create-access-step.role-label">
<span
className={styles.roleDot}
style={{backgroundColor: role.color === 0 ? DEFAULT_ROLE_COLOR_HEX : getRoleColor(role.color)}}
data-flx="channel.channel-create-access-step.role-dot"
/>
{role.name}
</span>
</Checkbox>
</div>
);
})}
</section>
)}
{members.length > 0 && (
<section className={styles.accessSection} data-flx="channel.channel-create-access-step.members">
<h3 className={styles.accessHeading} data-flx="channel.channel-create-access-step.members-heading">
{i18n._(MEMBERS_DESCRIPTOR)}
</h3>
{members.map((member) => {
const entry: ChannelCreateOverwrite = {id: member.user.id, type: 1};
return (
<div
key={member.user.id}
className={styles.accessRow}
data-flx="channel.channel-create-access-step.member-row"
>
<Checkbox
checked={selectedKeys.has(overwriteKey(entry))}
onChange={(checked) => toggle(entry, checked)}
data-flx="channel.channel-create-access-step.checkbox.member"
>
<span className={styles.accessLabel} data-flx="channel.channel-create-access-step.member-label">
<Avatar
user={member.user}
size={24}
guildId={guildId}
data-flx="channel.channel-create-access-step.avatar"
/>
{getOverrideMemberLabel(member, guildId)}
</span>
</Checkbox>
</div>
);
})}
</section>
)}
{roles.length === 0 && members.length === 0 && (
<p className={styles.accessEmpty} data-flx="channel.channel-create-access-step.access-empty">
{i18n._(NO_MATCHES_DESCRIPTOR)}
</p>
)}
</div>
</div>
);
});
@@ -7,27 +7,157 @@
padding-bottom: var(--spacing-4);
}
.channelTypeSection {
display: block;
margin-bottom: var(--spacing-4);
}
.channelTypeLabel {
margin-bottom: 0.5rem;
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary);
}
.footer {
align-items: center;
.section {
display: flex;
flex-wrap: wrap;
width: 100%;
flex-direction: column;
gap: 0.5rem;
}
.footer > * {
flex: 1;
min-width: fit-content;
.sectionLabel {
margin: 0;
font-weight: 500;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary);
}
.typeGroup {
gap: 0.375rem;
}
.typeOption {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.typeIcon,
.typeIconSelected {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background-color: var(--background-modifier-hover);
color: var(--text-primary-muted);
}
.typeIconSelected {
color: var(--text-primary);
}
.typeGlyph {
width: 1.25rem;
height: 1.25rem;
}
.typeText {
display: flex;
flex-direction: column;
min-width: 0;
}
.typeName {
font-size: 0.9375rem;
font-weight: 600;
line-height: 1.25rem;
color: var(--text-primary);
}
.typeDesc {
font-size: 0.8125rem;
line-height: 1.125rem;
color: var(--text-primary-muted);
}
.nameIcon {
width: 1rem;
height: 1rem;
}
.privateLabel {
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.privateIcon {
flex-shrink: 0;
color: var(--text-primary-muted);
}
.accessStep {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
}
.accessSearch {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.accessHint {
margin: 0;
font-size: 0.8125rem;
line-height: 1.125rem;
color: var(--text-primary-muted);
}
.accessList {
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 20rem;
overflow-y: auto;
}
.accessSection {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.accessHeading {
margin: 0 0 0.25rem;
font-size: 0.75rem;
font-weight: 600;
line-height: 1rem;
color: var(--text-primary-muted);
}
.accessRow {
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
}
.accessRow:hover {
background-color: var(--background-modifier-hover);
}
.accessLabel {
display: inline-flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
color: var(--text-primary);
}
.roleDot {
flex-shrink: 0;
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
}
.accessEmpty {
margin: 0;
padding: 1rem 0;
text-align: center;
color: var(--text-primary-muted);
}
@@ -3,24 +3,37 @@
import * as Modal from '@app/features/app/components/dialogs/Modal';
import {EXAMPLE_CHANNEL_NAME, EXAMPLE_URL} from '@app/features/app/config/I18nDisplayConstants';
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
import {ChannelCreateAccessStep} from '@app/features/channel/components/modals/ChannelCreateAccessStep';
import styles from '@app/features/channel/components/modals/ChannelCreateModal.module.css';
import {
buildPrivateChannelOverwrites,
type ChannelCreateOverwrite,
createChannel,
type FormInputs,
getChannelTypeOptions,
getDefaultValues,
} from '@app/features/channel/utils/ChannelCreateModalUtils';
import {CANCEL_DESCRIPTOR, CREATE_CHANNEL_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import {
CANCEL_DESCRIPTOR,
CREATE_CHANNEL_DESCRIPTOR,
GO_BACK_DESCRIPTOR,
NEXT_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import ThreadGuilds from '@app/features/threads/state/ThreadGuilds';
import {Button} from '@app/features/ui/button/Button';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {Form} from '@app/features/ui/components/form/Form';
import {Input} from '@app/features/ui/components/form/FormInput';
import {Switch} from '@app/features/ui/components/form/FormSwitch';
import {RadioGroup} from '@app/features/ui/radio_group/RadioGroup';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import Users from '@app/features/user/state/Users';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {LockSimpleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useState} from 'react';
import {Controller, useForm} from 'react-hook-form';
const CHANNEL_TYPE_DESCRIPTOR = msg({
@@ -31,87 +44,207 @@ const CHANNEL_TYPE_SELECTION_DESCRIPTOR = msg({
message: 'Channel type selection',
comment: 'Short label in the channel create modal. Keep it concise.',
});
const NAME_DESCRIPTOR = msg({
message: 'Name',
comment: 'Short label in the channel create modal. Keep it concise.',
const CHANNEL_NAME_DESCRIPTOR = msg({
message: 'Channel name',
comment: 'Label of the name field in the channel create modal.',
});
const URL_DESCRIPTOR = msg({
message: 'URL',
comment: 'Short label in the channel create modal. Keep it concise.',
});
const PRIVATE_CHANNEL_DESCRIPTOR = msg({
message: 'Private channel',
comment: 'Switch in the channel create modal that limits who can view the new channel.',
});
const PRIVATE_CHANNEL_HINT_DESCRIPTOR = msg({
message: 'Only selected members and roles will be able to view this channel.',
comment: 'Description under the private channel switch in the channel create modal.',
});
const ADD_MEMBERS_OR_ROLES_DESCRIPTOR = msg({
message: 'Add members or roles',
comment: 'Title of the second step of the channel create modal when the channel is private.',
});
const SKIP_DESCRIPTOR = msg({
message: 'Skip',
comment: 'Creates the private channel without adding anyone in the channel create modal.',
});
type Step = 'details' | 'access';
export const ChannelCreateModal = observer(({guildId, parentId}: {guildId: string; parentId?: string}) => {
const {i18n} = useLingui();
const [step, setStep] = useState<Step>('details');
const [isPrivate, setIsPrivate] = useState(false);
const [access, setAccess] = useState<Array<ChannelCreateOverwrite>>([]);
const form = useForm<FormInputs>({
defaultValues: getDefaultValues(),
});
const channelType = Number(form.watch('type') || '0');
const canBePrivate = channelType !== ChannelTypes.GUILD_LINK;
const privateSelected = isPrivate && canBePrivate;
const onSubmit = async (data: FormInputs) => {
await createChannel(guildId, data, parentId);
if (privateSelected && step === 'details') {
setStep('access');
return;
}
const currentUserId = Users.currentUser?.id;
const members =
currentUserId && !access.some((entry) => entry.type === 1 && entry.id === currentUserId)
? [...access, {id: currentUserId, type: 1 as const}]
: access;
await createChannel(
guildId,
data,
parentId,
privateSelected ? buildPrivateChannelOverwrites(guildId, Number(data.type), members) : undefined,
);
};
const {handleSubmit} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'name',
});
const typeIcon = ChannelUtils.getIcon(
{
type: channelType,
guildId,
permissionOverwrites: privateSelected ? {[guildId]: {deny: Permissions.VIEW_CHANNEL}} : undefined,
},
{className: styles.nameIcon},
);
const submitting = form.formState.isSubmitting;
return (
<Modal.Root size="small" centered data-flx="channel.channel-create-modal.modal-root">
<Form form={form} onSubmit={handleSubmit} data-flx="channel.channel-create-modal.form.submit">
<Modal.Header title={i18n._(CREATE_CHANNEL_DESCRIPTOR)} data-flx="channel.channel-create-modal.modal-header" />
<Modal.Header
title={i18n._(step === 'access' ? ADD_MEMBERS_OR_ROLES_DESCRIPTOR : CREATE_CHANNEL_DESCRIPTOR)}
data-flx="channel.channel-create-modal.modal-header"
/>
<Modal.Content contentClassName={styles.content} data-flx="channel.channel-create-modal.modal-content">
<div className={styles.channelTypeSection} data-flx="channel.channel-create-modal.channel-type-section">
<div className={styles.channelTypeLabel} data-flx="channel.channel-create-modal.channel-type-label">
{i18n._(CHANNEL_TYPE_DESCRIPTOR)}
</div>
<Controller
name="type"
control={form.control}
render={({field}) => (
<RadioGroup
aria-label={i18n._(CHANNEL_TYPE_SELECTION_DESCRIPTOR)}
value={Number(field.value)}
onChange={(value) => field.onChange(value.toString())}
options={getChannelTypeOptions(i18n, {forums: ThreadGuilds.isActive(guildId)})}
data-flx="channel.channel-create-modal.radio-group.change"
{step === 'access' ? (
<ChannelCreateAccessStep
guildId={guildId}
selected={access}
onChange={setAccess}
data-flx="channel.channel-create-modal.channel-create-access-step"
/>
) : (
<>
<section className={styles.section} data-flx="channel.channel-create-modal.channel-type-section">
<h3 className={styles.sectionLabel} data-flx="channel.channel-create-modal.channel-type-label">
{i18n._(CHANNEL_TYPE_DESCRIPTOR)}
</h3>
<Controller
name="type"
control={form.control}
render={({field}) => (
<RadioGroup
aria-label={i18n._(CHANNEL_TYPE_SELECTION_DESCRIPTOR)}
value={Number(field.value)}
onChange={(value) => field.onChange(value.toString())}
options={getChannelTypeOptions(i18n, {forums: ThreadGuilds.isActive(guildId)})}
className={styles.typeGroup}
optionAlign="center"
renderContent={(option, checked) => (
<span className={styles.typeOption} data-flx="channel.channel-create-modal.type-option">
<span
className={checked ? styles.typeIconSelected : styles.typeIcon}
data-flx="channel.channel-create-modal.type-icon"
>
{ChannelUtils.getIcon({type: option.value}, {className: styles.typeGlyph})}
</span>
<span className={styles.typeText} data-flx="channel.channel-create-modal.type-text">
<span className={styles.typeName} data-flx="channel.channel-create-modal.type-name">
{option.name}
</span>
<span className={styles.typeDesc} data-flx="channel.channel-create-modal.type-desc">
{option.desc}
</span>
</span>
</span>
)}
data-flx="channel.channel-create-modal.radio-group.change"
/>
)}
data-flx="channel.channel-create-modal.controller"
/>
</section>
<Input
data-flx="channel.channel-create-modal.input"
{...form.register('name')}
autoComplete="off"
autoFocus={true}
error={form.formState.errors.name?.message}
label={i18n._(CHANNEL_NAME_DESCRIPTOR)}
leftIcon={typeIcon}
maxLength={100}
minLength={1}
placeholder={EXAMPLE_CHANNEL_NAME}
required={true}
/>
{channelType === ChannelTypes.GUILD_LINK && (
<Input
data-flx="channel.channel-create-modal.input.url"
{...form.register('url')}
error={form.formState.errors.url?.message}
label={i18n._(URL_DESCRIPTOR)}
maxLength={1024}
placeholder={EXAMPLE_URL}
required={true}
type="url"
/>
)}
data-flx="channel.channel-create-modal.controller"
/>
</div>
<Input
data-flx="channel.channel-create-modal.input"
{...form.register('name')}
autoComplete="off"
autoFocus={true}
error={form.formState.errors.name?.message}
label={i18n._(NAME_DESCRIPTOR)}
maxLength={100}
minLength={1}
placeholder={EXAMPLE_CHANNEL_NAME}
required={true}
/>
{Number(form.watch('type') || '0') === ChannelTypes.GUILD_LINK && (
<Input
data-flx="channel.channel-create-modal.input.url"
{...form.register('url')}
error={form.formState.errors.url?.message}
label={i18n._(URL_DESCRIPTOR)}
maxLength={1024}
placeholder={EXAMPLE_URL}
required={true}
type="url"
/>
{canBePrivate && (
<Switch
label={
<span className={styles.privateLabel} data-flx="channel.channel-create-modal.private-label">
<LockSimpleIcon
size={16}
weight="fill"
className={styles.privateIcon}
aria-hidden
data-flx="channel.channel-create-modal.lock-icon"
/>
{i18n._(PRIVATE_CHANNEL_DESCRIPTOR)}
</span>
}
description={i18n._(PRIVATE_CHANNEL_HINT_DESCRIPTOR)}
value={isPrivate}
onChange={setIsPrivate}
data-flx="channel.channel-create-modal.switch.private"
/>
)}
</>
)}
</Modal.Content>
<Modal.Footer data-flx="channel.channel-create-modal.modal-footer">
<Button onClick={ModalCommands.pop} variant="secondary" data-flx="channel.channel-create-modal.button.pop">
{i18n._(CANCEL_DESCRIPTOR)}
</Button>
<Button
type="submit"
submitting={form.formState.isSubmitting}
data-flx="channel.channel-create-modal.button.submit"
>
{i18n._(CREATE_CHANNEL_DESCRIPTOR)}
</Button>
{step === 'access' ? (
<>
<Button
onClick={() => setStep('details')}
variant="secondary"
data-flx="channel.channel-create-modal.button.back"
>
{i18n._(GO_BACK_DESCRIPTOR)}
</Button>
<Button type="submit" submitting={submitting} data-flx="channel.channel-create-modal.button.submit">
{i18n._(access.length === 0 ? SKIP_DESCRIPTOR : CREATE_CHANNEL_DESCRIPTOR)}
</Button>
</>
) : (
<>
<Button
onClick={ModalCommands.pop}
variant="secondary"
data-flx="channel.channel-create-modal.button.pop"
>
{i18n._(CANCEL_DESCRIPTOR)}
</Button>
<Button type="submit" submitting={submitting} data-flx="channel.channel-create-modal.button.submit">
{i18n._(privateSelected ? NEXT_DESCRIPTOR : CREATE_CHANNEL_DESCRIPTOR)}
</Button>
</>
)}
</Modal.Footer>
</Form>
</Modal.Root>
@@ -6,13 +6,14 @@ import {getForumChannelTypeOptions} from '@app/features/forum/utils/ForumChannel
import {selectChannel} from '@app/features/navigation/commands/NavigationCommands';
import ThreadGuilds from '@app/features/threads/state/ThreadGuilds';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {
VOICE_CHANNEL_BITRATE_DEFAULT,
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
} from '@fluxer/constants/src/LimitConstants';
import {THREAD_ONLY_CHANNEL_TYPES} from '@fluxer/constants/src/ThreadConstants';
import type {I18n} from '@lingui/core';
import type {I18n, MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
export interface FormInputs {
name: string;
@@ -34,35 +35,82 @@ export interface ChannelTypeOption {
desc: string;
}
const CHANNEL_TYPE_OPTIONS: ReadonlyArray<ChannelTypeOption> = [
{
value: ChannelTypes.GUILD_TEXT,
name: 'Text Channel',
desc: 'Send messages, images, GIFs, and emoji',
},
{
value: ChannelTypes.GUILD_ANNOUNCEMENT,
name: 'Announcement Channel',
desc: 'Post updates that other communities can follow into their own channels',
},
{
value: ChannelTypes.GUILD_VOICE,
name: 'Voice Channel',
desc: 'Hang out together with voice, video, and screen share',
},
{
value: ChannelTypes.GUILD_LINK,
name: 'Link Channel',
desc: 'Quick access to an external website or resource',
},
const TEXT_TYPE_DESCRIPTOR = msg({
message: 'Text',
comment: 'Channel type option in the create channel modal.',
});
const TEXT_TYPE_DESC_DESCRIPTOR = msg({
message: 'Send messages, images, GIFs and emoji',
comment: 'One-line description of the text channel type in the create channel modal.',
});
const ANNOUNCEMENT_TYPE_DESCRIPTOR = msg({
message: 'Announcement',
comment: 'Channel type option in the create channel modal.',
});
const ANNOUNCEMENT_TYPE_DESC_DESCRIPTOR = msg({
message: 'Post updates other communities can follow',
comment: 'One-line description of the announcement channel type in the create channel modal.',
});
const VOICE_TYPE_DESCRIPTOR = msg({
message: 'Voice',
comment: 'Channel type option in the create channel modal.',
});
const VOICE_TYPE_DESC_DESCRIPTOR = msg({
message: 'Hang out with voice, video and screen share',
comment: 'One-line description of the voice channel type in the create channel modal.',
});
const LINK_TYPE_DESCRIPTOR = msg({
message: 'Link',
comment: 'Channel type option in the create channel modal.',
});
const LINK_TYPE_DESC_DESCRIPTOR = msg({
message: 'Shortcut to an external website',
comment: 'One-line description of the link channel type in the create channel modal.',
});
const CHANNEL_TYPE_OPTIONS: ReadonlyArray<{value: number; name: MessageDescriptor; desc: MessageDescriptor}> = [
{value: ChannelTypes.GUILD_TEXT, name: TEXT_TYPE_DESCRIPTOR, desc: TEXT_TYPE_DESC_DESCRIPTOR},
{value: ChannelTypes.GUILD_VOICE, name: VOICE_TYPE_DESCRIPTOR, desc: VOICE_TYPE_DESC_DESCRIPTOR},
{value: ChannelTypes.GUILD_ANNOUNCEMENT, name: ANNOUNCEMENT_TYPE_DESCRIPTOR, desc: ANNOUNCEMENT_TYPE_DESC_DESCRIPTOR},
{value: ChannelTypes.GUILD_LINK, name: LINK_TYPE_DESCRIPTOR, desc: LINK_TYPE_DESC_DESCRIPTOR},
];
export function getChannelTypeOptions(i18n: I18n, {forums = false}: {forums?: boolean} = {}): Array<ChannelTypeOption> {
if (!forums) return [...CHANNEL_TYPE_OPTIONS];
return [...CHANNEL_TYPE_OPTIONS, ...getForumChannelTypeOptions(i18n)];
const options = CHANNEL_TYPE_OPTIONS.map((option) => ({
value: option.value,
name: i18n._(option.name),
desc: i18n._(option.desc),
}));
if (!forums) return options;
return [...options, ...getForumChannelTypeOptions(i18n)];
}
export async function createChannel(guildId: string, data: FormInputs, parentId?: string): Promise<void> {
export interface ChannelCreateOverwrite {
id: string;
type: 0 | 1;
}
export function buildPrivateChannelOverwrites(
guildId: string,
channelType: number,
members: ReadonlyArray<ChannelCreateOverwrite>,
): Array<{id: string; type: 0 | 1; allow: string; deny: string}> {
const access =
channelType === ChannelTypes.GUILD_VOICE
? Permissions.VIEW_CHANNEL | Permissions.CONNECT
: Permissions.VIEW_CHANNEL;
return [
{id: guildId, type: 0, allow: '0', deny: Permissions.VIEW_CHANNEL.toString()},
...members.map((member) => ({id: member.id, type: member.type, allow: access.toString(), deny: '0'})),
];
}
export async function createChannel(
guildId: string,
data: FormInputs,
parentId?: string,
permissionOverwrites?: Array<{id: string; type: 0 | 1; allow: string; deny: string}>,
): Promise<void> {
const channelType = Number(data.type);
if (THREAD_ONLY_CHANNEL_TYPES.has(channelType) && !ThreadGuilds.isActive(guildId)) return;
const channel = await ChannelCommands.create(guildId, {
@@ -73,6 +121,7 @@ export async function createChannel(guildId: string, data: FormInputs, parentId?
bitrate: channelType === ChannelTypes.GUILD_VOICE ? VOICE_CHANNEL_BITRATE_DEFAULT : null,
user_limit: channelType === ChannelTypes.GUILD_VOICE ? 0 : null,
voice_connection_limit: channelType === ChannelTypes.GUILD_VOICE ? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT : null,
...(permissionOverwrites ? {permission_overwrites: permissionOverwrites} : {}),
});
if (GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) || THREAD_ONLY_CHANNEL_TYPES.has(channel.type)) {
setTimeout(() => {