mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(channels): redesign the create channel modal (#3253)
This commit is contained in:
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(() => {
|
||||
|
||||
Reference in new issue
Block a user