mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(app): backport rendering improvements (#1580)
This commit is contained in:
1 parent
0c291a01da
commit
42cdc1f877
513 files changed
+98743
-70397
No files matched your search
+26
@@ -70,6 +70,32 @@
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.topicInput {
|
||||
--composer-placeholder-top: 0.625rem;
|
||||
--composer-placeholder-right: 0px;
|
||||
--composer-placeholder-left: 1rem;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1 1 0%;
|
||||
align-self: stretch;
|
||||
overflow: hidden;
|
||||
padding-inline-start: 1rem;
|
||||
}
|
||||
|
||||
:global([dir='rtl']) .topicInput {
|
||||
--composer-placeholder-right: 1rem;
|
||||
--composer-placeholder-left: 0px;
|
||||
}
|
||||
|
||||
.topicInput :global([data-channel-textarea]) {
|
||||
box-sizing: border-box;
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
min-height: 5rem;
|
||||
max-height: 8.75rem;
|
||||
padding-block: 0.625rem;
|
||||
}
|
||||
|
||||
.emojiButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+23
-299
@@ -5,63 +5,42 @@ import {EXAMPLE_GENERAL_CHANNEL_NAME, EXAMPLE_URL} from '@app/features/app/confi
|
||||
import {useFormSubmit} from '@app/features/app/hooks/useFormSubmit';
|
||||
import type {ChannelRtcRegion} from '@app/features/channel/commands/ChannelCommands';
|
||||
import * as ChannelCommands from '@app/features/channel/commands/ChannelCommands';
|
||||
import {Autocomplete, getAutocompleteOptionId} from '@app/features/channel/components/Autocomplete';
|
||||
import {showChannelErrorModal} from '@app/features/channel/components/alerts/ChannelErrorModalUtils';
|
||||
import {VoiceRegionsLoadFailedModal} from '@app/features/channel/components/alerts/VoiceRegionsLoadFailedModal';
|
||||
import styles from '@app/features/channel/components/modals/channel_tabs/ChannelOverviewTab.module.css';
|
||||
import {
|
||||
ChannelOverviewTopicEditor,
|
||||
type ChannelOverviewTopicEditorHandle,
|
||||
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/ChannelOverviewTopicEditor';
|
||||
import {MatureContentSection} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/MatureContentSection';
|
||||
import {RtcRegionSelect} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/RtcRegionSelect';
|
||||
import {
|
||||
SlowmodeControl,
|
||||
useSlowmodeOptions,
|
||||
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl';
|
||||
import {SlowmodeControl} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl';
|
||||
import {
|
||||
CHANNEL_OVERVIEW_TAB_ID,
|
||||
type FormInputs,
|
||||
getNearestBitrate,
|
||||
MAX_TOPIC_LENGTH,
|
||||
SETTINGS_AUTOCOMPLETE_Z_INDEX,
|
||||
TOPIC_AUTOCOMPLETE_TRIGGERS,
|
||||
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared';
|
||||
import {
|
||||
VoiceConnectionLimitControl,
|
||||
VoiceSettings,
|
||||
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/VoiceSettings';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
||||
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
|
||||
import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
|
||||
import {useTextareaAutocomplete} from '@app/features/messaging/hooks/useTextareaAutocomplete';
|
||||
import {useTextareaAutocompleteKeyboard} from '@app/features/messaging/hooks/useTextareaAutocompleteKeyboard';
|
||||
import {useTextareaEmojiPicker} from '@app/features/messaging/hooks/useTextareaEmojiPicker';
|
||||
import {useTextareaPaste} from '@app/features/messaging/hooks/useTextareaPaste';
|
||||
import {useTextareaSegments} from '@app/features/messaging/hooks/useTextareaSegments';
|
||||
import {applyMarkdownSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import {CharacterCounter} from '@app/features/ui/character_counter/CharacterCounter';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import * as ToastCommands from '@app/features/ui/commands/ToastCommands';
|
||||
import * as UnsavedChangesCommands from '@app/features/ui/commands/UnsavedChangesCommands';
|
||||
import {Form} from '@app/features/ui/components/form/Form';
|
||||
import {Input, Textarea} from '@app/features/ui/components/form/FormInput';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {Popout} from '@app/features/ui/popover/PopoverPopout';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {setMeaningfulFormValue} from '@app/lib/forms/MeaningfulFormValue';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import {useRemoteFormReset} from '@app/lib/forms/RemoteFormReset';
|
||||
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT} from '@fluxer/constants/src/LimitConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {SmileyIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useForm} from 'react-hook-form';
|
||||
|
||||
const CHANNEL_TOPIC_IS_TOO_LONG_DESCRIPTOR = msg({
|
||||
@@ -93,25 +72,9 @@ const URL_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Channel overview settings tab label, control, or validation message (name, topic, slowmode, voice region, mature content gate).',
|
||||
});
|
||||
const TOPIC_DESCRIPTOR = msg({
|
||||
message: 'Topic',
|
||||
comment:
|
||||
'Channel overview settings tab label, control, or validation message (name, topic, slowmode, voice region, mature content gate).',
|
||||
});
|
||||
const ADD_A_TOPIC_TO_THIS_CHANNEL_DESCRIPTOR = msg({
|
||||
message: 'Add a topic to this channel',
|
||||
comment:
|
||||
'Channel overview settings tab label, control, or validation message (name, topic, slowmode, voice region, mature content gate).',
|
||||
});
|
||||
const INSERT_EMOJI_DESCRIPTOR = msg({
|
||||
message: 'Insert emoji',
|
||||
comment:
|
||||
'Channel overview settings tab label, control, or validation message (name, topic, slowmode, voice region, mature content gate).',
|
||||
});
|
||||
const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId}) => {
|
||||
const {i18n} = useLingui();
|
||||
const channel = Channels.getChannel(channelId);
|
||||
const mobileLayout = MobileLayout;
|
||||
const guildId = channel?.guildId ?? null;
|
||||
const guild = guildId ? Guilds.getGuild(guildId) : null;
|
||||
const canUpdateRtcRegion =
|
||||
@@ -120,7 +83,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
const isVoiceChannel = channel?.type === ChannelTypes.GUILD_VOICE;
|
||||
const [rtcRegions, setRtcRegions] = useState<Array<ChannelRtcRegion>>([]);
|
||||
const [isLoadingRegions, setIsLoadingRegions] = useState(false);
|
||||
const slowmodeOptions = useSlowmodeOptions();
|
||||
const remoteSlowmodeSeconds = channel == null ? 0 : channel.rateLimitPerUser;
|
||||
const form = useForm<FormInputs>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
@@ -145,7 +108,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
nsfw_override: channel.nsfwOverride,
|
||||
content_warning_level: channel.contentWarningLevel ?? ContentWarningLevel.INHERIT,
|
||||
content_warning_text: channel.contentWarningText ?? '',
|
||||
bitrate: channel.bitrate ? getNearestBitrate(Math.round(channel.bitrate / 1000)) : 64,
|
||||
bitrate: channel.bitrate ? Math.round(channel.bitrate / 1000) : 64,
|
||||
user_limit: channel.userLimit ?? 0,
|
||||
voice_connection_limit: channel.voiceConnectionLimit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
rtc_region: channel.rtcRegion ?? null,
|
||||
@@ -190,22 +153,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
form.setValue('rtc_region', null, {shouldDirty: false, shouldTouch: false});
|
||||
}
|
||||
}, [canUpdateRtcRegion, form, isVoiceChannel, rtcRegions]);
|
||||
useEffect(() => {
|
||||
form.register('topic');
|
||||
return () => {
|
||||
form.unregister('topic');
|
||||
};
|
||||
}, [form]);
|
||||
const topicTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const {segmentManagerRef, previousValueRef, displayToActual, prepareTextChange, handleTextChange, clearSegments} =
|
||||
useTextareaSegments();
|
||||
const [topicValue, setTopicValue] = useState('');
|
||||
const [isTopicInitialized, setIsTopicInitialized] = useState(false);
|
||||
const originalTopicRef = useRef('');
|
||||
const [topicExpressionPickerOpen, setTopicExpressionPickerOpen] = useState(false);
|
||||
const [isTopicFieldFocused, setIsTopicFieldFocused] = useState(false);
|
||||
const topicAutocompleteListId = useId();
|
||||
useMarkdownKeybinds(isTopicFieldFocused);
|
||||
const topicEditorRef = useRef<ChannelOverviewTopicEditorHandle | null>(null);
|
||||
const handleTopicExceedsLimit = useCallback(() => {
|
||||
showChannelErrorModal({
|
||||
title: i18n._(CHANNEL_TOPIC_IS_TOO_LONG_DESCRIPTOR),
|
||||
@@ -213,103 +161,12 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
dataFlx: 'channel.channel-tabs.channel-overview-tab.topic-too-long.generic-error-modal',
|
||||
});
|
||||
}, [i18n]);
|
||||
const {handleEmojiSelect: insertTopicEmoji} = useTextareaEmojiPicker({
|
||||
setValue: setTopicValue,
|
||||
textareaRef: topicTextareaRef,
|
||||
segmentManagerRef,
|
||||
previousValueRef,
|
||||
prepareTextChange,
|
||||
channelId,
|
||||
maxActualLength: MAX_TOPIC_LENGTH,
|
||||
onExceedMaxLength: handleTopicExceedsLimit,
|
||||
});
|
||||
const {
|
||||
autocompleteQuery: topicAutocompleteQuery,
|
||||
autocompleteOptions: topicAutocompleteOptions,
|
||||
autocompleteType: topicAutocompleteType,
|
||||
selectedIndex: topicSelectedIndex,
|
||||
isAutocompleteAttached: topicIsAutocompleteAttached,
|
||||
setSelectedIndex: topicSetSelectedIndex,
|
||||
onCursorMove: topicOnCursorMove,
|
||||
handleSelect: topicHandleSelect,
|
||||
} = useTextareaAutocomplete({
|
||||
channel: channel ?? null,
|
||||
value: topicValue,
|
||||
setValue: setTopicValue,
|
||||
textareaRef: topicTextareaRef,
|
||||
segmentManagerRef,
|
||||
previousValueRef,
|
||||
prepareTextChange,
|
||||
allowedTriggers: TOPIC_AUTOCOMPLETE_TRIGGERS,
|
||||
maxActualLength: MAX_TOPIC_LENGTH,
|
||||
onExceedMaxLength: handleTopicExceedsLimit,
|
||||
});
|
||||
useTextareaPaste({
|
||||
channel: channel ?? null,
|
||||
textareaRef: topicTextareaRef,
|
||||
segmentManagerRef,
|
||||
setValue: setTopicValue,
|
||||
previousValueRef,
|
||||
prepareTextChange,
|
||||
maxMessageLength: MAX_TOPIC_LENGTH,
|
||||
onPasteExceedsLimit: () => handleTopicExceedsLimit(),
|
||||
});
|
||||
const topicContainerRef = useRef<HTMLDivElement>(null);
|
||||
const {handleKeyDown: handleTopicKeyDown} = useTextareaAutocompleteKeyboard({
|
||||
isAutocompleteAttached: topicIsAutocompleteAttached,
|
||||
autocompleteOptions: topicAutocompleteOptions,
|
||||
selectedIndex: topicSelectedIndex,
|
||||
setSelectedIndex: topicSetSelectedIndex,
|
||||
handleSelect: topicHandleSelect,
|
||||
});
|
||||
const topicActiveAutocompleteOptionId =
|
||||
topicIsAutocompleteAttached && topicAutocompleteOptions[topicSelectedIndex]
|
||||
? getAutocompleteOptionId(topicAutocompleteListId, topicSelectedIndex)
|
||||
: undefined;
|
||||
const handleTopicEmojiSelect = useCallback(
|
||||
(emoji: FlatEmoji, shiftKey?: boolean) => {
|
||||
const didInsert = insertTopicEmoji(emoji, shiftKey);
|
||||
if (didInsert && !shiftKey) {
|
||||
setTopicExpressionPickerOpen(false);
|
||||
}
|
||||
return didInsert;
|
||||
},
|
||||
[insertTopicEmoji],
|
||||
);
|
||||
const actualTopic = useMemo(() => displayToActual(topicValue), [displayToActual, topicValue]);
|
||||
const topicDisplayMaxLength = Math.max(0, topicValue.length + (MAX_TOPIC_LENGTH - actualTopic.length));
|
||||
const syncTopicFromMarkdown = useCallback(
|
||||
(markdown: string | null | undefined) => {
|
||||
setIsTopicInitialized(false);
|
||||
clearSegments();
|
||||
const rawTopic = markdown ?? '';
|
||||
const displayTopic = rawTopic ? applyMarkdownSegments(rawTopic, guildId, segmentManagerRef.current) : '';
|
||||
originalTopicRef.current = rawTopic;
|
||||
previousValueRef.current = displayTopic;
|
||||
setTopicValue(displayTopic);
|
||||
form.setValue('topic', rawTopic, {shouldDirty: false, shouldTouch: false});
|
||||
setIsTopicInitialized(true);
|
||||
},
|
||||
[clearSegments, form, guildId, previousValueRef, segmentManagerRef],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isTopicInitialized) return;
|
||||
const isDirty = actualTopic !== originalTopicRef.current;
|
||||
setMeaningfulFormValue({
|
||||
setValue: form.setValue,
|
||||
name: 'topic',
|
||||
currentValue: actualTopic,
|
||||
cleanValue: originalTopicRef.current,
|
||||
isMeaningfullyDirty: isDirty,
|
||||
});
|
||||
}, [actualTopic, form, isTopicInitialized]);
|
||||
const applyRemoteValues = useCallback(
|
||||
(values: FormInputs) => {
|
||||
syncTopicFromMarkdown(values.topic ?? '');
|
||||
setTopicExpressionPickerOpen(false);
|
||||
},
|
||||
[syncTopicFromMarkdown],
|
||||
);
|
||||
const applyRemoteValues = useCallback((values: FormInputs) => {
|
||||
const topicEditor = topicEditorRef.current;
|
||||
if (topicEditor != null) {
|
||||
topicEditor.syncFromMarkdown(values.topic);
|
||||
}
|
||||
}, []);
|
||||
const {resetToRemoteValues, commitRemoteValues} = useRemoteFormReset<FormInputs>({
|
||||
form,
|
||||
identityKey: channelId,
|
||||
@@ -438,148 +295,15 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
||||
)}
|
||||
{showMessagingSection && (
|
||||
<div className={styles.settingsGroup} data-flx="channel.channel-tabs.channel-overview-tab.settings-group--2">
|
||||
{topicIsAutocompleteAttached && (
|
||||
<Autocomplete
|
||||
type={topicAutocompleteType}
|
||||
onSelect={topicHandleSelect}
|
||||
selectedIndex={topicSelectedIndex}
|
||||
options={topicAutocompleteOptions}
|
||||
setSelectedIndex={topicSetSelectedIndex}
|
||||
referenceElement={topicContainerRef.current}
|
||||
query={topicAutocompleteQuery}
|
||||
zIndex={SETTINGS_AUTOCOMPLETE_Z_INDEX}
|
||||
listboxId={topicAutocompleteListId}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.autocomplete.topic-handle-select"
|
||||
/>
|
||||
)}
|
||||
<div ref={topicContainerRef} data-flx="channel.channel-tabs.channel-overview-tab.div--2">
|
||||
<Textarea
|
||||
ref={topicTextareaRef}
|
||||
label={i18n._(TOPIC_DESCRIPTOR)}
|
||||
placeholder={i18n._(ADD_A_TOPIC_TO_THIS_CHANNEL_DESCRIPTOR)}
|
||||
maxLength={topicDisplayMaxLength}
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
showCharacterCount={true}
|
||||
value={topicValue}
|
||||
onChange={(event) => {
|
||||
const newValue = event.target.value;
|
||||
const nativeEvent = event.nativeEvent as InputEvent;
|
||||
handleTextChange(
|
||||
newValue,
|
||||
previousValueRef.current,
|
||||
typeof nativeEvent.inputType === 'string' ? nativeEvent.inputType : undefined,
|
||||
);
|
||||
setTopicValue(newValue);
|
||||
}}
|
||||
onFocus={() => setIsTopicFieldFocused(true)}
|
||||
onBlur={() => setIsTopicFieldFocused(false)}
|
||||
onKeyDown={handleTopicKeyDown}
|
||||
onKeyUp={topicOnCursorMove}
|
||||
onClick={topicOnCursorMove}
|
||||
error={form.formState.errors.topic?.message}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={topicIsAutocompleteAttached ? topicAutocompleteListId : undefined}
|
||||
aria-expanded={topicIsAutocompleteAttached}
|
||||
aria-haspopup="listbox"
|
||||
aria-activedescendant={topicActiveAutocompleteOptionId}
|
||||
innerActionButton={
|
||||
mobileLayout.enabled ? (
|
||||
<FocusRing offset={-2} data-flx="channel.channel-tabs.channel-overview-tab.focus-ring">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTopicExpressionPickerOpen(true)}
|
||||
className={clsx(
|
||||
styles.emojiButton,
|
||||
topicExpressionPickerOpen ? styles.emojiButtonActive : styles.emojiButtonInactive,
|
||||
)}
|
||||
aria-label={i18n._(INSERT_EMOJI_DESCRIPTOR)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={topicExpressionPickerOpen}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.emoji-button.set-topic-expression-picker-open"
|
||||
>
|
||||
<SmileyIcon
|
||||
size={20}
|
||||
weight="fill"
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.smiley-icon"
|
||||
/>
|
||||
</button>
|
||||
</FocusRing>
|
||||
) : (
|
||||
<Popout
|
||||
position="bottom-end"
|
||||
animationType="none"
|
||||
offsetMainAxis={8}
|
||||
offsetCrossAxis={-32}
|
||||
onOpen={() => setTopicExpressionPickerOpen(true)}
|
||||
onClose={() => setTopicExpressionPickerOpen(false)}
|
||||
returnFocusRef={topicTextareaRef}
|
||||
render={({onClose}) => (
|
||||
<ExpressionPickerPopout
|
||||
channelId={channelId}
|
||||
onEmojiSelect={(emoji, shift) => {
|
||||
const didInsert = handleTopicEmojiSelect(emoji, shift);
|
||||
if (didInsert && !shift) onClose();
|
||||
}}
|
||||
onClose={onClose}
|
||||
visibleTabs={['emojis']}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.expression-picker-popout"
|
||||
/>
|
||||
)}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.popout"
|
||||
>
|
||||
<FocusRing offset={-2} data-flx="channel.channel-tabs.channel-overview-tab.focus-ring--2">
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
styles.emojiButton,
|
||||
topicExpressionPickerOpen ? styles.emojiButtonActive : styles.emojiButtonInactive,
|
||||
)}
|
||||
aria-label={i18n._(INSERT_EMOJI_DESCRIPTOR)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={topicExpressionPickerOpen}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.emoji-button"
|
||||
>
|
||||
<SmileyIcon
|
||||
size={20}
|
||||
weight="fill"
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.smiley-icon--2"
|
||||
/>
|
||||
</button>
|
||||
</FocusRing>
|
||||
</Popout>
|
||||
)
|
||||
}
|
||||
characterCountTooltip={() => (
|
||||
<CharacterCounter
|
||||
currentLength={actualTopic.length}
|
||||
maxLength={MAX_TOPIC_LENGTH}
|
||||
canUpgrade={false}
|
||||
premiumMaxLength={MAX_TOPIC_LENGTH}
|
||||
onUpgradeClick={() => undefined}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.character-counter"
|
||||
/>
|
||||
)}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.textarea.topic-on-cursor-move"
|
||||
/>
|
||||
</div>
|
||||
{mobileLayout.enabled && (
|
||||
<ExpressionPickerSheet
|
||||
isOpen={topicExpressionPickerOpen}
|
||||
onClose={() => setTopicExpressionPickerOpen(false)}
|
||||
onEmojiSelect={(emoji, shiftKey) => {
|
||||
handleTopicEmojiSelect(emoji, shiftKey);
|
||||
}}
|
||||
visibleTabs={['emojis']}
|
||||
channelId={channelId}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.expression-picker-sheet"
|
||||
/>
|
||||
)}
|
||||
<SlowmodeControl
|
||||
<ChannelOverviewTopicEditor
|
||||
ref={topicEditorRef}
|
||||
channel={channel}
|
||||
guildId={guildId}
|
||||
form={form}
|
||||
slowmodeOptions={slowmodeOptions}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.slowmode-control"
|
||||
initialTopic={remoteValues != null && remoteValues.topic != null ? remoteValues.topic : ''}
|
||||
onTopicExceedsLimit={handleTopicExceedsLimit}
|
||||
/>
|
||||
<SlowmodeControl form={form} remoteSlowmodeSeconds={remoteSlowmodeSeconds} />
|
||||
</div>
|
||||
)}
|
||||
{showVoiceSection && (
|
||||
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/channel/components/modals/channel_tabs/ChannelOverviewTab.module.css';
|
||||
import {
|
||||
type FormInputs,
|
||||
MAX_TOPIC_LENGTH,
|
||||
TOPIC_AUTOCOMPLETE_TRIGGERS,
|
||||
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
||||
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
|
||||
import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
|
||||
import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
|
||||
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
||||
import {convertMarkdownToSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils';
|
||||
import {getParserFlagsForContext} from '@app/features/messaging/utils/markdown/MarkdownParserFlags';
|
||||
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {CharacterCounter} from '@app/features/ui/character_counter/CharacterCounter';
|
||||
import formStyles from '@app/features/ui/components/form/FormInput.module.css';
|
||||
import surfaceStyles from '@app/features/ui/components/form/FormSurface.module.css';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {Popout} from '@app/features/ui/popover/PopoverPopout';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {setMeaningfulFormValue} from '@app/lib/forms/MeaningfulFormValue';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {SmileyIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {forwardRef, useCallback, useEffect, useId, useImperativeHandle, useRef, useState} from 'react';
|
||||
import type {UseFormReturn} from 'react-hook-form';
|
||||
|
||||
const TOPIC_DESCRIPTOR = msg({message: 'Topic'});
|
||||
const ADD_A_TOPIC_TO_THIS_CHANNEL_DESCRIPTOR = msg({message: 'Add a topic to this channel'});
|
||||
const INSERT_EMOJI_DESCRIPTOR = msg({message: 'Insert emoji'});
|
||||
const TOPIC_MARKDOWN_PARSER_FLAGS = getParserFlagsForContext(MarkdownContext.STANDARD_WITHOUT_JUMBO);
|
||||
const COMPOSER_SURFACE_INTERACTIVE_SELECTOR =
|
||||
'[data-channel-textarea], button, a, input, textarea, select, [role="button"]';
|
||||
|
||||
interface TopicHydration {
|
||||
display: string;
|
||||
segments: Array<MentionSegment>;
|
||||
key: number;
|
||||
}
|
||||
|
||||
interface ChannelOverviewTopicEditorProps {
|
||||
channel: Channel;
|
||||
guildId: string | null;
|
||||
form: UseFormReturn<FormInputs>;
|
||||
initialTopic: string;
|
||||
onTopicExceedsLimit: () => void;
|
||||
}
|
||||
|
||||
export interface ChannelOverviewTopicEditorHandle {
|
||||
syncFromMarkdown(markdown: string | null | undefined): void;
|
||||
}
|
||||
|
||||
function buildHydration(rawTopic: string, guildId: string | null): TopicHydration {
|
||||
const converted = convertMarkdownToSegments(rawTopic, guildId);
|
||||
const segments = converted.segments.map((segment) => ({
|
||||
type: segment.type,
|
||||
id: segment.id,
|
||||
displayText: segment.displayText,
|
||||
actualText: segment.actualText,
|
||||
start: segment.start,
|
||||
end: segment.start + segment.displayText.length,
|
||||
}));
|
||||
return {display: converted.displayText, segments, key: 0};
|
||||
}
|
||||
|
||||
export const ChannelOverviewTopicEditor = observer(
|
||||
forwardRef<ChannelOverviewTopicEditorHandle, ChannelOverviewTopicEditorProps>(
|
||||
({channel, guildId, form, initialTopic, onTopicExceedsLimit}, forwardedRef) => {
|
||||
const {i18n} = useLingui();
|
||||
const topicLabelId = useId();
|
||||
const topicErrorId = useId();
|
||||
const topicEditableId = useId();
|
||||
const composerRef = useRef<LexicalRichInputHandle | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const editableRef = useRef<HTMLElement | null>(null);
|
||||
const [topicExpressionPickerOpen, setTopicExpressionPickerOpen] = useState(false);
|
||||
const [topicHydration, setTopicHydration] = useState<TopicHydration>(() => buildHydration(initialTopic, guildId));
|
||||
const [actualTopic, setActualTopic] = useState(initialTopic);
|
||||
const [isTopicInitialized, setIsTopicInitialized] = useState(true);
|
||||
const originalTopicRef = useRef(initialTopic);
|
||||
const topicError = form.formState.errors.topic;
|
||||
const hasTopicError = topicError != null && topicError.message != null && topicError.message.length > 0;
|
||||
let topicErrorMessage: string | null = null;
|
||||
if (hasTopicError && topicError != null && topicError.message != null) {
|
||||
topicErrorMessage = topicError.message;
|
||||
}
|
||||
let topicErrorMessageId: string | null = null;
|
||||
if (hasTopicError) {
|
||||
topicErrorMessageId = topicErrorId;
|
||||
}
|
||||
let topicFieldClass = formStyles.focusable;
|
||||
if (hasTopicError) {
|
||||
topicFieldClass = formStyles.error;
|
||||
}
|
||||
const isMobile = MobileLayout.enabled;
|
||||
useEffect(() => {
|
||||
form.register('topic');
|
||||
return () => form.unregister('topic');
|
||||
}, [form]);
|
||||
useEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (wrapper == null) {
|
||||
editableRef.current = null;
|
||||
return;
|
||||
}
|
||||
const editable = wrapper.querySelector<HTMLElement>('[data-channel-textarea]');
|
||||
if (editable == null) {
|
||||
editableRef.current = null;
|
||||
return;
|
||||
}
|
||||
editableRef.current = editable;
|
||||
}, [topicHydration.key]);
|
||||
const syncFromMarkdown = useCallback(
|
||||
(markdown: string | null | undefined) => {
|
||||
setIsTopicInitialized(false);
|
||||
let rawTopic = '';
|
||||
if (markdown != null) {
|
||||
rawTopic = markdown;
|
||||
}
|
||||
const next = buildHydration(rawTopic, guildId);
|
||||
originalTopicRef.current = rawTopic;
|
||||
setActualTopic(rawTopic);
|
||||
setTopicHydration((previous) => ({display: next.display, segments: next.segments, key: previous.key + 1}));
|
||||
form.setValue('topic', rawTopic, {shouldDirty: false, shouldTouch: false});
|
||||
setIsTopicInitialized(true);
|
||||
},
|
||||
[form, guildId],
|
||||
);
|
||||
useImperativeHandle(forwardedRef, () => ({syncFromMarkdown}), [syncFromMarkdown]);
|
||||
useEffect(() => {
|
||||
if (!isTopicInitialized) {
|
||||
return;
|
||||
}
|
||||
setMeaningfulFormValue({
|
||||
setValue: form.setValue,
|
||||
name: 'topic',
|
||||
currentValue: actualTopic,
|
||||
cleanValue: originalTopicRef.current,
|
||||
isMeaningfullyDirty: actualTopic !== originalTopicRef.current,
|
||||
});
|
||||
}, [actualTopic, form, isTopicInitialized]);
|
||||
const handleTopicChange = useCallback((_display: string, _segments: Array<MentionSegment>, wire: string) => {
|
||||
setActualTopic(wire);
|
||||
}, []);
|
||||
const handleTopicEmojiSelect = useCallback((emoji: FlatEmoji, shiftKey: boolean) => {
|
||||
const composer = composerRef.current;
|
||||
if (composer == null) {
|
||||
return false;
|
||||
}
|
||||
const didInsert = composer.insertEmoji(emoji);
|
||||
if (didInsert && shiftKey !== true) {
|
||||
setTopicExpressionPickerOpen(false);
|
||||
}
|
||||
return didInsert;
|
||||
}, []);
|
||||
const emojiButton = isMobile ? (
|
||||
<FocusRing offset={-2}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTopicExpressionPickerOpen(true)}
|
||||
className={clsx(
|
||||
styles.emojiButton,
|
||||
topicExpressionPickerOpen ? styles.emojiButtonActive : styles.emojiButtonInactive,
|
||||
)}
|
||||
aria-label={i18n._(INSERT_EMOJI_DESCRIPTOR)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={topicExpressionPickerOpen}
|
||||
>
|
||||
<SmileyIcon size={remFromPx(20)} weight="fill" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
) : (
|
||||
<Popout
|
||||
position="bottom-end"
|
||||
animationType="none"
|
||||
offsetMainAxis={8}
|
||||
offsetCrossAxis={-32}
|
||||
onOpen={() => setTopicExpressionPickerOpen(true)}
|
||||
onClose={() => setTopicExpressionPickerOpen(false)}
|
||||
returnFocusRef={editableRef}
|
||||
render={({onClose}) => (
|
||||
<ExpressionPickerPopout
|
||||
channelId={channel.id}
|
||||
onEmojiSelect={(emoji, shiftKey) => {
|
||||
const didInsert = handleTopicEmojiSelect(emoji, shiftKey === true);
|
||||
if (didInsert && shiftKey !== true) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
onClose={onClose}
|
||||
visibleTabs={['emojis']}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<FocusRing offset={-2}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
styles.emojiButton,
|
||||
topicExpressionPickerOpen ? styles.emojiButtonActive : styles.emojiButtonInactive,
|
||||
)}
|
||||
aria-label={i18n._(INSERT_EMOJI_DESCRIPTOR)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={topicExpressionPickerOpen}
|
||||
>
|
||||
<SmileyIcon size={remFromPx(20)} weight="fill" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
</Popout>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<fieldset className={formStyles.fieldset}>
|
||||
<div className={formStyles.labelContainer}>
|
||||
<label
|
||||
id={topicLabelId}
|
||||
className={formStyles.label}
|
||||
htmlFor={topicEditableId}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
const composer = composerRef.current;
|
||||
if (composer != null) {
|
||||
composer.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{i18n._(TOPIC_DESCRIPTOR)}
|
||||
</label>
|
||||
</div>
|
||||
<div className={formStyles.inputGroup}>
|
||||
<FocusRing within={true} ringTarget={wrapperRef} focusTarget={wrapperRef} offset={-2}>
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={clsx(formStyles.textareaWrapper, surfaceStyles.surface, topicFieldClass)}
|
||||
onPointerDown={(event) => {
|
||||
const target = event.target;
|
||||
if (target instanceof Element && target.closest(COMPOSER_SURFACE_INTERACTIVE_SELECTOR) != null) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const composer = composerRef.current;
|
||||
if (composer != null) {
|
||||
composer.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LexicalRichInput
|
||||
key={topicHydration.key}
|
||||
initialValue={topicHydration.display}
|
||||
initialSegments={topicHydration.segments}
|
||||
placeholder={i18n._(ADD_A_TOPIC_TO_THIS_CHANNEL_DESCRIPTOR)}
|
||||
disabled={false}
|
||||
channel={channel}
|
||||
allowedTriggers={[...TOPIC_AUTOCOMPLETE_TRIGGERS]}
|
||||
markdown={true}
|
||||
markdownParserFlags={TOPIC_MARKDOWN_PARSER_FLAGS}
|
||||
singleLine={false}
|
||||
size="form"
|
||||
maxLength={MAX_TOPIC_LENGTH}
|
||||
onExceedMaxLength={onTopicExceedsLimit}
|
||||
className={styles.topicInput}
|
||||
autocompleteAnchor={wrapperRef.current}
|
||||
id={topicEditableId}
|
||||
ariaLabelledBy={topicLabelId}
|
||||
ariaInvalid={hasTopicError}
|
||||
ariaErrorMessage={topicErrorMessageId == null ? undefined : topicErrorMessageId}
|
||||
ariaDescribedBy={topicErrorMessageId == null ? undefined : topicErrorMessageId}
|
||||
richInputRef={composerRef}
|
||||
onChange={handleTopicChange}
|
||||
i18n={i18n}
|
||||
/>
|
||||
<div className={formStyles.textareaActions}>
|
||||
{emojiButton}
|
||||
<div className={formStyles.characterCountContainer}>
|
||||
<CharacterCounter
|
||||
currentLength={actualTopic.length}
|
||||
maxLength={MAX_TOPIC_LENGTH}
|
||||
canUpgrade={false}
|
||||
premiumMaxLength={MAX_TOPIC_LENGTH}
|
||||
onUpgradeClick={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FocusRing>
|
||||
{hasTopicError && (
|
||||
<span id={topicErrorId} className={formStyles.errorText}>
|
||||
{topicErrorMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
{isMobile && (
|
||||
<ExpressionPickerSheet
|
||||
isOpen={topicExpressionPickerOpen}
|
||||
onClose={() => setTopicExpressionPickerOpen(false)}
|
||||
onEmojiSelect={(emoji, shiftKey) => {
|
||||
handleTopicEmojiSelect(emoji, shiftKey === true);
|
||||
}}
|
||||
visibleTabs={['emojis']}
|
||||
channelId={channel.id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
+1
-35
@@ -4,10 +4,7 @@ import type {FormInputs} from '@app/features/channel/components/modals/channel_t
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import {MATURE_CONTENT_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {
|
||||
getEffectiveChannelContentWarning,
|
||||
resolveEffectiveChannelMatureContent,
|
||||
} from '@app/features/messaging/utils/ContentWarningUtils';
|
||||
import {getEffectiveChannelContentWarning} from '@app/features/messaging/utils/ContentWarningUtils';
|
||||
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
|
||||
import {Textarea} from '@app/features/ui/components/form/FormInput';
|
||||
import {Switch} from '@app/features/ui/components/form/FormSwitch';
|
||||
@@ -18,22 +15,6 @@ import {useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {Controller, type UseFormReturn} from 'react-hook-form';
|
||||
|
||||
const CATEGORY_DESCRIPTOR = msg({
|
||||
message: 'category',
|
||||
comment: 'Lowercase source label for an inherited mature-content setting.',
|
||||
});
|
||||
const COMMUNITY_DESCRIPTOR = msg({
|
||||
message: 'community',
|
||||
comment: 'Lowercase source label for an inherited mature-content setting.',
|
||||
});
|
||||
const INHERITS_FROM_ON_DESCRIPTOR = msg({
|
||||
message: 'Inherits from {inheritedSourceLabel}: on',
|
||||
comment: 'Mature-content setting summary showing that the inherited setting is enabled.',
|
||||
});
|
||||
const INHERITS_FROM_OFF_DESCRIPTOR = msg({
|
||||
message: 'Inherits from {inheritedSourceLabel}: off',
|
||||
comment: 'Mature-content setting summary showing that the inherited setting is disabled.',
|
||||
});
|
||||
const INHERIT_DESCRIPTOR = msg({
|
||||
message: 'Inherit',
|
||||
comment: 'Mature-content option that uses the parent or community setting.',
|
||||
@@ -81,21 +62,7 @@ export const MatureContentSection: React.FC<MatureContentSectionProps> = ({form,
|
||||
contentWarningLevel: ContentWarningLevel.INHERIT,
|
||||
contentWarningText: null,
|
||||
} as typeof channel;
|
||||
const inheritedMatureContent = resolveEffectiveChannelMatureContent(stubForInherit, guild);
|
||||
const inheritedWarning = getEffectiveChannelContentWarning(stubForInherit, guild);
|
||||
const inheritedSourceLabel =
|
||||
inheritedMatureContent.source === 'parent'
|
||||
? i18n._(CATEGORY_DESCRIPTOR)
|
||||
: inheritedMatureContent.source === 'guild'
|
||||
? i18n._(COMMUNITY_DESCRIPTOR)
|
||||
: i18n._(COMMUNITY_DESCRIPTOR);
|
||||
const inheritedDescription = inheritedMatureContent.value
|
||||
? i18n._(INHERITS_FROM_ON_DESCRIPTOR, {
|
||||
inheritedSourceLabel,
|
||||
})
|
||||
: i18n._(INHERITS_FROM_OFF_DESCRIPTOR, {
|
||||
inheritedSourceLabel,
|
||||
});
|
||||
const matureContentOptions: ReadonlyArray<ComboboxOption<string>> = [
|
||||
{
|
||||
value: 'inherit',
|
||||
@@ -121,7 +88,6 @@ export const MatureContentSection: React.FC<MatureContentSectionProps> = ({form,
|
||||
render={({field}) => (
|
||||
<CompactComboboxRow<string>
|
||||
label={i18n._(MATURE_CONTENT_DESCRIPTOR)}
|
||||
description={inheritedDescription}
|
||||
value={overrideToKey(field.value)}
|
||||
options={matureContentOptions}
|
||||
onChange={(v) => field.onChange(keyToOverride(v))}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/channel/components/modals/channel_tabs/ChannelOverviewTab.module.css';
|
||||
import type React from 'react';
|
||||
|
||||
interface SettingsControlRowProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
dataFlx: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SettingsControlRow: React.FC<SettingsControlRowProps> = ({label, description, dataFlx, children}) => {
|
||||
return (
|
||||
<div className={styles.settingsControlRow} data-flx={`${dataFlx}.row`}>
|
||||
<div className={styles.settingsControlText} data-flx={`${dataFlx}.text`}>
|
||||
<div className={styles.settingsControlTitleRow} data-flx={`${dataFlx}.title-row`}>
|
||||
<span className={styles.settingsControlLabel} data-flx={`${dataFlx}.label`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{description != null && (
|
||||
<p className={styles.settingsControlDescription} data-flx={`${dataFlx}.description`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+93
-123
@@ -1,28 +1,22 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/channel/components/modals/channel_tabs/ChannelOverviewTab.module.css';
|
||||
import {SettingsControlRow} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/SettingsControlRow';
|
||||
import type {FormInputs} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared';
|
||||
import {formatPermissionLabel} from '@app/features/permissions/utils/PermissionUtils';
|
||||
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
|
||||
import {CompactComboboxRow} from '@app/features/user/components/modals/tabs/components/CompactComboboxRow';
|
||||
import {RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR, Slider} from '@app/features/ui/components/Slider';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {CHANNEL_RATE_LIMIT_PER_USER_MAX, CHANNEL_RATE_LIMIT_PER_USER_MIN} from '@fluxer/constants/src/LimitConstants';
|
||||
import {SECONDS_PER_HOUR, SECONDS_PER_MINUTE} from '@fluxer/date_utils/src/DateConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {useMemo} from 'react';
|
||||
import {Controller, type UseFormReturn} from 'react-hook-form';
|
||||
|
||||
const SECONDS_DESCRIPTOR = msg({
|
||||
message: '{seconds} seconds',
|
||||
comment:
|
||||
'Channel overview settings tab label, control, or validation message (name, topic, slowmode, voice region, mature content gate).',
|
||||
});
|
||||
const MINUTES_DESCRIPTOR = msg({
|
||||
message: '{minutes} minutes',
|
||||
comment:
|
||||
'Channel overview settings tab label, control, or validation message (name, topic, slowmode, voice region, mature content gate).',
|
||||
});
|
||||
const HOURS_DESCRIPTOR = msg({
|
||||
message: '{hours} hours',
|
||||
const SLOWMODE_DESCRIPTOR = msg({
|
||||
message: 'Slowmode',
|
||||
comment:
|
||||
'Channel overview settings tab label, control, or validation message (name, topic, slowmode, voice region, mature content gate).',
|
||||
});
|
||||
@@ -31,142 +25,118 @@ const OFF_DESCRIPTOR = msg({
|
||||
comment:
|
||||
'Channel overview settings tab label, control, or validation message (name, topic, slowmode, voice region, mature content gate).',
|
||||
});
|
||||
const MINUTE_DESCRIPTOR = msg({
|
||||
message: '{oneMinute} minute',
|
||||
comment:
|
||||
'Channel overview settings tab label, control, or validation message (name, topic, slowmode, voice region, mature content gate).',
|
||||
});
|
||||
const HOUR_DESCRIPTOR = msg({
|
||||
message: '{oneHour} hour',
|
||||
comment:
|
||||
'Channel overview settings tab label, control, or validation message (name, topic, slowmode, voice region, mature content gate).',
|
||||
});
|
||||
const SLOWMODE_DESCRIPTOR = msg({
|
||||
message: 'Slowmode',
|
||||
comment:
|
||||
'Channel overview settings tab label, control, or validation message (name, topic, slowmode, voice region, mature content gate).',
|
||||
});
|
||||
const SLOWMODE_DESCRIPTION_DESCRIPTOR = msg({
|
||||
message: 'Wait between messages. "{bypassSlowmodePermissionLabel}" can bypass it.',
|
||||
comment:
|
||||
'Description under the slowmode select in channel settings. bypassSlowmodePermissionLabel is the localized Bypass Slowmode permission name.',
|
||||
'Description under the slowmode slider in channel settings. bypassSlowmodePermissionLabel is the localized Bypass Slowmode permission name.',
|
||||
});
|
||||
const SLOWMODE_SECONDS_DESCRIPTOR = msg({
|
||||
message: '{count, plural, one {# second} other {# seconds}}',
|
||||
comment: 'Slowmode duration expressed in seconds, shown on the channel settings slowmode slider.',
|
||||
});
|
||||
const SLOWMODE_MINUTES_DESCRIPTOR = msg({
|
||||
message: '{count, plural, one {# minute} other {# minutes}}',
|
||||
comment: 'Slowmode duration expressed in minutes, shown on the channel settings slowmode slider.',
|
||||
});
|
||||
const SLOWMODE_HOURS_DESCRIPTOR = msg({
|
||||
message: '{count, plural, one {# hour} other {# hours}}',
|
||||
comment: 'Slowmode duration expressed in hours, shown on the channel settings slowmode slider.',
|
||||
});
|
||||
|
||||
const getNearestSlowmodeValue = (value: number, options: ReadonlyArray<ComboboxOption<number>>): number => {
|
||||
let nearest = options[0]?.value ?? 0;
|
||||
const SLOWMODE_STOP_SECONDS: ReadonlyArray<number> = [
|
||||
0, 5, 10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 21600,
|
||||
];
|
||||
const SLOWMODE_MARKER_SECONDS: ReadonlyArray<number> = [0, 60, 3600, 21600];
|
||||
|
||||
function buildSlowmodeStops(remoteSeconds: number): Array<number> {
|
||||
const boundedSeconds = Math.min(
|
||||
Math.max(Math.round(remoteSeconds), CHANNEL_RATE_LIMIT_PER_USER_MIN),
|
||||
CHANNEL_RATE_LIMIT_PER_USER_MAX,
|
||||
);
|
||||
if (SLOWMODE_STOP_SECONDS.includes(boundedSeconds)) {
|
||||
return [...SLOWMODE_STOP_SECONDS];
|
||||
}
|
||||
return [...SLOWMODE_STOP_SECONDS, boundedSeconds].sort((left, right) => left - right);
|
||||
}
|
||||
|
||||
function findNearestStopIndex(stops: ReadonlyArray<number>, seconds: number): number {
|
||||
let nearestIndex = 0;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (const option of options) {
|
||||
const distance = Math.abs(option.value - value);
|
||||
for (let index = 0; index < stops.length; index++) {
|
||||
const distance = Math.abs(stops[index] - seconds);
|
||||
if (distance < nearestDistance) {
|
||||
nearest = option.value;
|
||||
nearestIndex = index;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
};
|
||||
return nearestIndex;
|
||||
}
|
||||
|
||||
const parseSlowmodeInputSeconds = (inputValue: string): number | undefined => {
|
||||
const normalized = inputValue.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
if (normalized === 'off' || normalized === 'none' || normalized === 'disabled') return 0;
|
||||
const numericMatch = normalized.match(/([0-9]+(?:\.[0-9]+)?)/);
|
||||
if (!numericMatch) return undefined;
|
||||
const parsedValue = Number(numericMatch[1]);
|
||||
if (!Number.isFinite(parsedValue)) return undefined;
|
||||
const unitMatch = normalized.match(/[0-9.]\s*([a-z]+)/);
|
||||
const unit = unitMatch?.[1] ?? '';
|
||||
if (unit.startsWith('h')) return parsedValue * 3600;
|
||||
if (unit.startsWith('m')) return parsedValue * 60;
|
||||
return parsedValue;
|
||||
};
|
||||
|
||||
const resolveSlowmodeInput = (
|
||||
inputValue: string,
|
||||
options: ReadonlyArray<ComboboxOption<number>>,
|
||||
): number | undefined => {
|
||||
const seconds = parseSlowmodeInputSeconds(inputValue);
|
||||
if (seconds === undefined) return undefined;
|
||||
return getNearestSlowmodeValue(seconds, options);
|
||||
};
|
||||
|
||||
export function useSlowmodeOptions(): Array<ComboboxOption<number>> {
|
||||
const {i18n} = useLingui();
|
||||
return useMemo(() => {
|
||||
const secondsLabel = (seconds: number) => i18n._(SECONDS_DESCRIPTOR, {seconds});
|
||||
const minutesLabel = (minutes: number) => i18n._(MINUTES_DESCRIPTOR, {minutes});
|
||||
const hoursLabel = (hours: number) => i18n._(HOURS_DESCRIPTOR, {hours});
|
||||
const oneMinute = 1;
|
||||
const oneHour = 1;
|
||||
return [
|
||||
{value: 0, label: i18n._(OFF_DESCRIPTOR)},
|
||||
{value: 5, label: secondsLabel(5)},
|
||||
{value: 10, label: secondsLabel(10)},
|
||||
{value: 15, label: secondsLabel(15)},
|
||||
{value: 30, label: secondsLabel(30)},
|
||||
{value: 60, label: i18n._(MINUTE_DESCRIPTOR, {oneMinute})},
|
||||
{value: 120, label: minutesLabel(2)},
|
||||
{value: 300, label: minutesLabel(5)},
|
||||
{value: 600, label: minutesLabel(10)},
|
||||
{value: 900, label: minutesLabel(15)},
|
||||
{value: 1800, label: minutesLabel(30)},
|
||||
{value: 3600, label: i18n._(HOUR_DESCRIPTOR, {oneHour})},
|
||||
{value: 7200, label: hoursLabel(2)},
|
||||
{value: 21600, label: hoursLabel(6)},
|
||||
];
|
||||
}, [i18n.locale]);
|
||||
function formatSlowmodeDuration(i18n: I18n, seconds: number): string {
|
||||
if (seconds === 0) {
|
||||
return i18n._(OFF_DESCRIPTOR);
|
||||
}
|
||||
if (seconds % SECONDS_PER_HOUR === 0) {
|
||||
return i18n._(SLOWMODE_HOURS_DESCRIPTOR, {count: seconds / SECONDS_PER_HOUR});
|
||||
}
|
||||
if (seconds % SECONDS_PER_MINUTE === 0) {
|
||||
return i18n._(SLOWMODE_MINUTES_DESCRIPTOR, {count: seconds / SECONDS_PER_MINUTE});
|
||||
}
|
||||
return i18n._(SLOWMODE_SECONDS_DESCRIPTOR, {count: seconds});
|
||||
}
|
||||
|
||||
interface SlowmodeControlProps {
|
||||
form: UseFormReturn<FormInputs>;
|
||||
slowmodeOptions: Array<ComboboxOption<number>>;
|
||||
remoteSlowmodeSeconds: number;
|
||||
}
|
||||
|
||||
const SlowmodeSelectField: React.FC<{
|
||||
value: number | undefined;
|
||||
onChange: (value: number) => void;
|
||||
options: ReadonlyArray<ComboboxOption<number>>;
|
||||
description: string;
|
||||
}> = ({value, onChange, options, description}) => {
|
||||
const {i18n} = useLingui();
|
||||
const currentValue = value ?? 0;
|
||||
const selectedValue = getNearestSlowmodeValue(currentValue, options);
|
||||
useEffect(() => {
|
||||
if (currentValue !== selectedValue) onChange(selectedValue);
|
||||
}, [currentValue, onChange, selectedValue]);
|
||||
return (
|
||||
<CompactComboboxRow<number>
|
||||
label={i18n._(SLOWMODE_DESCRIPTOR)}
|
||||
description={description}
|
||||
value={selectedValue}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
autoSelectValueFromInput={resolveSlowmodeInput}
|
||||
controlWidth="medium"
|
||||
dataFlx="channel.channel-tabs.channel-overview-tab.form-select.change-slowmode"
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.slowmode-control.slowmode-select-field.compact-combobox-row.change"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const SlowmodeControl: React.FC<SlowmodeControlProps> = ({form, slowmodeOptions}) => {
|
||||
export const SlowmodeControl: React.FC<SlowmodeControlProps> = ({form, remoteSlowmodeSeconds}) => {
|
||||
const {i18n} = useLingui();
|
||||
const stops = useMemo(() => buildSlowmodeStops(remoteSlowmodeSeconds), [remoteSlowmodeSeconds]);
|
||||
const markerIndexes = useMemo(() => SLOWMODE_MARKER_SECONDS.map((seconds) => stops.indexOf(seconds)), [stops]);
|
||||
const slowmodeLabel = i18n._(SLOWMODE_DESCRIPTOR);
|
||||
const bypassSlowmodePermissionLabel = formatPermissionLabel(i18n, Permissions.BYPASS_SLOWMODE);
|
||||
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
|
||||
return (
|
||||
<Controller
|
||||
name="slowmode"
|
||||
control={form.control}
|
||||
render={({field}) => {
|
||||
let currentSeconds: number;
|
||||
if (typeof field.value === 'number') {
|
||||
currentSeconds = field.value;
|
||||
} else {
|
||||
currentSeconds = 0;
|
||||
}
|
||||
const currentIndex = findNearestStopIndex(stops, currentSeconds);
|
||||
return (
|
||||
<SlowmodeSelectField
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
options={slowmodeOptions}
|
||||
<SettingsControlRow
|
||||
label={slowmodeLabel}
|
||||
description={i18n._(SLOWMODE_DESCRIPTION_DESCRIPTOR, {bypassSlowmodePermissionLabel})}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.slowmode-control.slowmode-select-field.change"
|
||||
/>
|
||||
dataFlx="channel.channel-tabs.channel-overview-tab.slowmode-control"
|
||||
>
|
||||
<div className={styles.settingsSliderControl}>
|
||||
<Slider
|
||||
value={currentIndex}
|
||||
defaultValue={currentIndex}
|
||||
factoryDefaultValue={0}
|
||||
minValue={0}
|
||||
maxValue={stops.length - 1}
|
||||
step={1}
|
||||
markers={markerIndexes}
|
||||
ariaLabel={slowmodeLabel}
|
||||
ariaValueText={formatSlowmodeDuration(i18n, currentSeconds)}
|
||||
onMarkerRender={(index) => formatSlowmodeDuration(i18n, stops[index])}
|
||||
onValueRender={(index) => formatSlowmodeDuration(i18n, stops[Math.round(index)])}
|
||||
onValueChange={(index) => field.onChange(stops[Math.round(index)])}
|
||||
showResetButton={true}
|
||||
onReset={() => field.onChange(0)}
|
||||
resetTooltip={resetSliderLabel}
|
||||
/>
|
||||
</div>
|
||||
</SettingsControlRow>
|
||||
);
|
||||
}}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.controller"
|
||||
/>
|
||||
);
|
||||
};
|
||||
+36
-84
@@ -1,14 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/channel/components/modals/channel_tabs/ChannelOverviewTab.module.css';
|
||||
import {SettingsControlRow} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/SettingsControlRow';
|
||||
import {
|
||||
BITRATE_OPTIONS,
|
||||
BITRATE_KBPS_MARKERS,
|
||||
BITRATE_KBPS_MAX,
|
||||
BITRATE_KBPS_MIN,
|
||||
type FormInputs,
|
||||
getNearestBitrate,
|
||||
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared';
|
||||
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
|
||||
import {RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR, Slider} from '@app/features/ui/components/Slider';
|
||||
import {CompactComboboxRow} from '@app/features/user/components/modals/tabs/components/CompactComboboxRow';
|
||||
import {
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_MAX,
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {Controller, type UseFormReturn} from 'react-hook-form';
|
||||
|
||||
const PARTICIPANT_LIMIT_LABEL_DESCRIPTOR = msg({
|
||||
@@ -49,40 +48,10 @@ const CONNECTION_LIMIT_VALUE_DESCRIPTOR = msg({
|
||||
comment: 'Displayed value for the voice channel connection limit slider.',
|
||||
});
|
||||
|
||||
const resolveVoiceBitrateInput = (
|
||||
inputValue: string,
|
||||
options: ReadonlyArray<ComboboxOption<number>>,
|
||||
): number | undefined => {
|
||||
const normalized = inputValue.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
const numericMatch = normalized.match(/([0-9]+(?:\.[0-9]+)?)/);
|
||||
if (!numericMatch) return undefined;
|
||||
const parsedValue = Number(numericMatch[1]);
|
||||
if (!Number.isFinite(parsedValue)) return undefined;
|
||||
const valueInKbps = normalized.includes('mbps') ? parsedValue * 1000 : parsedValue;
|
||||
let nearest = options[0]?.value;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (const option of options) {
|
||||
const distance = Math.abs(option.value - valueInKbps);
|
||||
if (distance < nearestDistance) {
|
||||
nearest = option.value;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
}
|
||||
return nearest;
|
||||
};
|
||||
|
||||
interface VoiceSettingsProps {
|
||||
form: UseFormReturn<FormInputs>;
|
||||
}
|
||||
|
||||
interface SettingsControlRowProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
dataFlx: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const formatIntegerValue = (value: number): string => String(Math.round(value));
|
||||
|
||||
const formatParticipantLimitMarker = (value: number): string => {
|
||||
@@ -95,51 +64,42 @@ const formatConnectionLimitMarker = (value: number): string | null => {
|
||||
return formatIntegerValue(value);
|
||||
};
|
||||
|
||||
const SettingsControlRow: React.FC<SettingsControlRowProps> = ({label, description, dataFlx, children}) => {
|
||||
return (
|
||||
<div className={styles.settingsControlRow} data-flx={`${dataFlx}.row`}>
|
||||
<div className={styles.settingsControlText} data-flx={`${dataFlx}.text`}>
|
||||
<div className={styles.settingsControlTitleRow} data-flx={`${dataFlx}.title-row`}>
|
||||
<span className={styles.settingsControlLabel} data-flx={`${dataFlx}.label`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
{description != null && (
|
||||
<p className={styles.settingsControlDescription} data-flx={`${dataFlx}.description`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const VoiceBitrateSelectField: React.FC<{
|
||||
const VoiceBitrateSlider: React.FC<{
|
||||
value: number | undefined;
|
||||
onChange: (value: number) => void;
|
||||
}> = ({value, onChange}) => {
|
||||
const {i18n} = useLingui();
|
||||
const options: ReadonlyArray<ComboboxOption<number>> = useMemo(
|
||||
() => BITRATE_OPTIONS.map((kilobits) => ({value: kilobits, label: i18n._(KBPS_DESCRIPTOR, {kilobits})})),
|
||||
[i18n.locale],
|
||||
);
|
||||
const currentValue = typeof value === 'number' ? value : BITRATE_OPTIONS[1];
|
||||
const selectedValue = getNearestBitrate(currentValue);
|
||||
useEffect(() => {
|
||||
if (currentValue !== selectedValue) onChange(selectedValue);
|
||||
}, [currentValue, onChange, selectedValue]);
|
||||
const voiceQualityLabel = i18n._(VOICE_QUALITY_DESCRIPTOR);
|
||||
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
|
||||
let currentValue = value;
|
||||
if (typeof currentValue !== 'number') {
|
||||
currentValue = 64;
|
||||
}
|
||||
return (
|
||||
<CompactComboboxRow<number>
|
||||
label={i18n._(VOICE_QUALITY_DESCRIPTOR)}
|
||||
value={selectedValue}
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
autoSelectValueFromInput={resolveVoiceBitrateInput}
|
||||
controlWidth="small"
|
||||
dataFlx="channel.channel-tabs.channel-overview-tab.form-select.voice-quality"
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings.voice-bitrate-select-field.compact-select-row.change"
|
||||
/>
|
||||
<SettingsControlRow
|
||||
label={voiceQualityLabel}
|
||||
dataFlx="channel.channel-tabs.channel-overview-tab.voice-settings.voice-bitrate-slider"
|
||||
>
|
||||
<div className={styles.settingsSliderControl}>
|
||||
<Slider
|
||||
value={currentValue}
|
||||
defaultValue={currentValue}
|
||||
factoryDefaultValue={64}
|
||||
minValue={BITRATE_KBPS_MIN}
|
||||
maxValue={BITRATE_KBPS_MAX}
|
||||
step={1}
|
||||
markers={[...BITRATE_KBPS_MARKERS]}
|
||||
ariaLabel={voiceQualityLabel}
|
||||
ariaValueText={i18n._(KBPS_DESCRIPTOR, {kilobits: Math.round(currentValue)})}
|
||||
onMarkerRender={formatIntegerValue}
|
||||
onValueRender={(kilobits) => i18n._(KBPS_DESCRIPTOR, {kilobits: Math.round(kilobits)})}
|
||||
onValueChange={(kilobits) => onChange(Math.round(kilobits))}
|
||||
showResetButton={true}
|
||||
onReset={() => onChange(64)}
|
||||
resetTooltip={resetSliderLabel}
|
||||
/>
|
||||
</div>
|
||||
</SettingsControlRow>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -153,13 +113,7 @@ export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
|
||||
<Controller
|
||||
name="bitrate"
|
||||
control={form.control}
|
||||
render={({field}) => (
|
||||
<VoiceBitrateSelectField
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings.voice-bitrate-select-field.change"
|
||||
/>
|
||||
)}
|
||||
render={({field}) => <VoiceBitrateSlider value={field.value} onChange={field.onChange} />}
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.controller--2"
|
||||
/>
|
||||
</div>
|
||||
@@ -173,7 +127,6 @@ export const VoiceSettings: React.FC<VoiceSettingsProps> = ({form}) => {
|
||||
<SettingsControlRow
|
||||
label={participantLimitLabel}
|
||||
dataFlx="channel.channel-tabs.channel-overview-tab.participant-limit"
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings.settings-control-row.participant-limit"
|
||||
>
|
||||
<div
|
||||
className={styles.settingsSliderControl}
|
||||
@@ -224,7 +177,6 @@ export const VoiceConnectionLimitControl: React.FC<VoiceSettingsProps> = ({form}
|
||||
label={connectionLimitLabel}
|
||||
description={connectionLimitDescription}
|
||||
dataFlx="channel.channel-tabs.channel-overview-tab.voice-connection-limit"
|
||||
data-flx="channel.channel-tabs.channel-overview-tab.voice-settings.voice-connection-limit-control.settings-control-row"
|
||||
>
|
||||
<div
|
||||
className={styles.settingsSliderControl}
|
||||
|
||||
+4
-7
@@ -3,6 +3,7 @@
|
||||
import type {ChannelRtcRegion} from '@app/features/channel/commands/ChannelCommands';
|
||||
import type {TriggerType} from '@app/features/messaging/hooks/useTextareaAutocomplete';
|
||||
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
|
||||
import {VOICE_CHANNEL_BITRATE_MAX, VOICE_CHANNEL_BITRATE_MIN} from '@fluxer/constants/src/LimitConstants';
|
||||
|
||||
export interface FormInputs {
|
||||
name: string;
|
||||
@@ -19,15 +20,11 @@ export interface FormInputs {
|
||||
}
|
||||
|
||||
export const CHANNEL_OVERVIEW_TAB_ID = 'overview';
|
||||
export const SETTINGS_AUTOCOMPLETE_Z_INDEX = 10001;
|
||||
export const BITRATE_OPTIONS = [8, 64, 96, 128] as const;
|
||||
export const BITRATE_KBPS_MIN = VOICE_CHANNEL_BITRATE_MIN / 1000;
|
||||
export const BITRATE_KBPS_MAX = VOICE_CHANNEL_BITRATE_MAX / 1000;
|
||||
export const BITRATE_KBPS_MARKERS: ReadonlyArray<number> = [BITRATE_KBPS_MIN, 64, 128, 256, BITRATE_KBPS_MAX];
|
||||
export const MAX_TOPIC_LENGTH = 1024;
|
||||
export const TOPIC_AUTOCOMPLETE_TRIGGERS: Array<TriggerType> = ['emoji', 'mention', 'channel'];
|
||||
export const getNearestBitrate = (value: number): number => {
|
||||
return BITRATE_OPTIONS.reduce((closest, option) => {
|
||||
return Math.abs(option - value) < Math.abs(closest - value) ? option : closest;
|
||||
});
|
||||
};
|
||||
|
||||
export interface RtcRegionOption extends ComboboxOption<string | null> {
|
||||
region: ChannelRtcRegion | null;
|
||||
|
||||
+12
-3
@@ -2,6 +2,7 @@
|
||||
|
||||
import styles from '@app/features/channel/components/modals/channel_tabs/ChannelPermissionsTab.module.css';
|
||||
import PermissionLayout from '@app/features/permissions/state/PermissionLayout';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -61,9 +62,17 @@ export const LayoutToggleButtons: React.FC = observer(() => {
|
||||
data-flx="channel.channel-tabs.channel-permissions-tab.layout-button.toggle-layout-mode"
|
||||
>
|
||||
{PermissionLayout.isComfy ? (
|
||||
<RowsIcon size={20} weight="bold" data-flx="channel.channel-tabs.channel-permissions-tab.rows-icon" />
|
||||
<RowsIcon
|
||||
size={remFromPx(20)}
|
||||
weight="bold"
|
||||
data-flx="channel.channel-tabs.channel-permissions-tab.rows-icon"
|
||||
/>
|
||||
) : (
|
||||
<ListIcon size={20} weight="bold" data-flx="channel.channel-tabs.channel-permissions-tab.list-icon" />
|
||||
<ListIcon
|
||||
size={remFromPx(20)}
|
||||
weight="bold"
|
||||
data-flx="channel.channel-tabs.channel-permissions-tab.list-icon"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
@@ -83,7 +92,7 @@ export const LayoutToggleButtons: React.FC = observer(() => {
|
||||
data-flx="channel.channel-tabs.channel-permissions-tab.layout-button.toggle-grid-mode"
|
||||
>
|
||||
<GridFourIcon
|
||||
size={20}
|
||||
size={remFromPx(20)}
|
||||
weight={PermissionLayout.isGrid ? 'fill' : 'bold'}
|
||||
data-flx="channel.channel-tabs.channel-permissions-tab.grid-four-icon"
|
||||
/>
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@
|
||||
import {DEFAULT_ROLE_COLOR_HEX, getRoleColor} from '@app/features/app/components/dialogs/shared/PermissionComponents';
|
||||
import styles from '@app/features/channel/components/modals/channel_tabs/ChannelPermissionsTab.module.css';
|
||||
import type {PermissionOverwrite} from '@app/features/channel/components/modals/channel_tabs/channel_permissions_tab/shared';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {openRoleContextMenu} from '@app/features/ui/action_menu/RoleContextMenu';
|
||||
import {Avatar} from '@app/features/ui/components/Avatar';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
@@ -68,7 +69,7 @@ export const MobileOverrideRow: React.FC<MobileOverrideRowProps> = ({
|
||||
</span>
|
||||
<CaretRightIcon
|
||||
className={styles.mobileOverrideChevron}
|
||||
size={20}
|
||||
size={remFromPx(20)}
|
||||
weight="bold"
|
||||
data-flx="channel.channel-tabs.channel-permissions-tab.mobile-override-chevron"
|
||||
/>
|
||||
|
||||
+5
-2
@@ -9,6 +9,7 @@ import styles from '@app/features/channel/components/modals/channel_tabs/Channel
|
||||
import {LayoutToggleButtons} from '@app/features/channel/components/modals/channel_tabs/channel_permissions_tab/LayoutToggleButtons';
|
||||
import type {PermissionOverwrite} from '@app/features/channel/components/modals/channel_tabs/channel_permissions_tab/shared';
|
||||
import type * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -93,7 +94,9 @@ export const PermissionEditorPanel: React.FC<PermissionEditorPanelProps> = ({
|
||||
<Button
|
||||
variant="secondary"
|
||||
small={true}
|
||||
leftIcon={<TrashIcon size={18} data-flx="channel.channel-tabs.channel-permissions-tab.trash-icon" />}
|
||||
leftIcon={
|
||||
<TrashIcon size={remFromPx(18)} data-flx="channel.channel-tabs.channel-permissions-tab.trash-icon" />
|
||||
}
|
||||
onClick={onDeleteOverride}
|
||||
disabled={!canManageChannels || !canManageRoles}
|
||||
data-flx="channel.channel-tabs.channel-permissions-tab.button.delete-override"
|
||||
@@ -123,7 +126,7 @@ export const PermissionEditorPanel: React.FC<PermissionEditorPanelProps> = ({
|
||||
onChange={(e) => setPermissionSearchQuery(e.target.value)}
|
||||
leftIcon={
|
||||
<MagnifyingGlassIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="channel.channel-tabs.channel-permissions-tab.magnifying-glass-icon"
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user