fix(app): backport rendering improvements (#1580)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-13 19:52:00 +02:00
1 parent 0c291a01da
commit 42cdc1f877
513 files changed
+98743 -70397

No files matched your search

@@ -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;
@@ -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 && (
@@ -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}
/>
)}
</>
);
},
),
);
@@ -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))}
@@ -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>
);
};
@@ -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"
/>
);
};
@@ -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}
@@ -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;
@@ -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"
/>
@@ -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"
/>
@@ -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"
/>