feat(app): give the topic and bio composers channel-aware autocomplete (#2002)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-25 12:57:18 +02:00
1 parent 1153327c75
commit 2ca756d219
5 files changed
+25 -14

No files matched your search

@@ -4,7 +4,6 @@ import styles from '@app/features/channel/components/modals/channel_tabs/Channel
import { import {
type FormInputs, type FormInputs,
MAX_TOPIC_LENGTH, MAX_TOPIC_LENGTH,
TOPIC_AUTOCOMPLETE_TRIGGERS,
} from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared'; } from '@app/features/channel/components/modals/channel_tabs/channel_overview_tab/shared';
import type {Channel} from '@app/features/channel/models/Channel'; import type {Channel} from '@app/features/channel/models/Channel';
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes'; import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
@@ -292,7 +291,7 @@ export const ChannelOverviewTopicEditor = observer(
placeholder={i18n._(ADD_A_TOPIC_TO_THIS_CHANNEL_DESCRIPTOR)} placeholder={i18n._(ADD_A_TOPIC_TO_THIS_CHANNEL_DESCRIPTOR)}
disabled={false} disabled={false}
channel={channel} channel={channel}
allowedTriggers={[...TOPIC_AUTOCOMPLETE_TRIGGERS]} allowSpecialMentions={false}
markdown={true} markdown={true}
markdownParserFlags={TOPIC_MARKDOWN_PARSER_FLAGS} markdownParserFlags={TOPIC_MARKDOWN_PARSER_FLAGS}
singleLine={false} singleLine={false}
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
import type {ChannelRtcRegion} from '@app/features/channel/commands/ChannelCommands'; import type {ChannelRtcRegion} from '@app/features/channel/commands/ChannelCommands';
import type {TriggerType} from '@app/features/messaging/utils/AutocompleteTriggerPolicy';
import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox'; import type {ComboboxOption} from '@app/features/ui/components/form/FormCombobox';
import {VOICE_CHANNEL_BITRATE_MAX, VOICE_CHANNEL_BITRATE_MIN} from '@fluxer/constants/src/LimitConstants'; import {VOICE_CHANNEL_BITRATE_MAX, VOICE_CHANNEL_BITRATE_MIN} from '@fluxer/constants/src/LimitConstants';
@@ -24,7 +23,6 @@ export const BITRATE_KBPS_MIN = VOICE_CHANNEL_BITRATE_MIN / 1000;
export const BITRATE_KBPS_MAX = VOICE_CHANNEL_BITRATE_MAX / 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 BITRATE_KBPS_MARKERS: ReadonlyArray<number> = [BITRATE_KBPS_MIN, 64, 128, 256, BITRATE_KBPS_MAX];
export const MAX_TOPIC_LENGTH = 1024; export const MAX_TOPIC_LENGTH = 1024;
export const TOPIC_AUTOCOMPLETE_TRIGGERS: Array<TriggerType> = ['emoji', 'mention', 'channel'];
export interface RtcRegionOption extends ComboboxOption<string | null> { export interface RtcRegionOption extends ComboboxOption<string | null> {
region: ChannelRtcRegion | null; region: ChannelRtcRegion | null;
@@ -34,6 +34,7 @@ export interface LexicalRichInputProps {
disabled?: boolean; disabled?: boolean;
channel?: Channel | null; channel?: Channel | null;
allowedTriggers?: Array<TriggerType>; allowedTriggers?: Array<TriggerType>;
allowSpecialMentions?: boolean;
markdown?: boolean; markdown?: boolean;
markdownParserFlags?: number; markdownParserFlags?: number;
singleLine?: boolean; singleLine?: boolean;
@@ -69,6 +70,7 @@ export const LexicalRichInput = ({
disabled = false, disabled = false,
channel = null, channel = null,
allowedTriggers, allowedTriggers,
allowSpecialMentions = true,
markdown = true, markdown = true,
markdownParserFlags, markdownParserFlags,
singleLine = false, singleLine = false,
@@ -130,12 +132,8 @@ export const LexicalRichInput = ({
useLexicalAutocomplete({ useLexicalAutocomplete({
channel, channel,
handleRef, handleRef,
allowedTriggers: allowedTriggers: allowedTriggers ?? (channel == null ? SAFE_CONTEXT_FREE_TRIGGERS : SAFE_CHANNEL_TRIGGERS),
allowedTriggers == null allowSpecialMentions,
? channel == null
? SAFE_CONTEXT_FREE_TRIGGERS
: SAFE_CHANNEL_TRIGGERS
: allowedTriggers,
allowMediaOptions: false, allowMediaOptions: false,
maxActualLength: maxLength, maxActualLength: maxLength,
onExceedMaxLength, onExceedMaxLength,
@@ -104,6 +104,7 @@ interface UseLexicalAutocompleteParams {
handleRef: RefObject<ComposerHandle | null>; handleRef: RefObject<ComposerHandle | null>;
allowedTriggers?: Array<TriggerType>; allowedTriggers?: Array<TriggerType>;
allowMediaOptions?: boolean; allowMediaOptions?: boolean;
allowSpecialMentions?: boolean;
maxActualLength?: number; maxActualLength?: number;
onExceedMaxLength?: () => void; onExceedMaxLength?: () => void;
i18n: I18n; i18n: I18n;
@@ -161,6 +162,7 @@ export type {TriggerType} from '@app/features/messaging/utils/AutocompleteTrigge
export function useLexicalAutocomplete({ export function useLexicalAutocomplete({
channel, channel,
handleRef, handleRef,
allowSpecialMentions,
allowedTriggers, allowedTriggers,
allowMediaOptions = true, allowMediaOptions = true,
maxActualLength, maxActualLength,
@@ -287,7 +289,8 @@ export function useLexicalAutocomplete({
setState: setGifState, setState: setGifState,
}); });
const canMentionEveryone = channel != null && Permission.can(Permissions.MENTION_EVERYONE, channel); const canMentionEveryone =
allowSpecialMentions !== false && channel != null && Permission.can(Permissions.MENTION_EVERYONE, channel);
const canUseCommand = useCallback( const canUseCommand = useCallback(
(command: Command) => { (command: Command) => {
if (command.type === 'simple') { if (command.type === 'simple') {
@@ -5,7 +5,6 @@ import {ExpressionPickerPopout} from '@app/features/expressions/components/popou
import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput'; import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes'; import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
import {useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds'; import {useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
import type {TriggerType} from '@app/features/messaging/utils/AutocompleteTriggerPolicy';
import {getParserFlagsForContext} from '@app/features/messaging/utils/markdown/MarkdownParserFlags'; import {getParserFlagsForContext} from '@app/features/messaging/utils/markdown/MarkdownParserFlags';
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager'; import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {remFromPx} from '@app/features/theme/layout/RemFromPx';
@@ -15,6 +14,7 @@ import surfaceStyles from '@app/features/ui/components/form/FormSurface.module.c
import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import {Popout} from '@app/features/ui/popover/PopoverPopout'; import {Popout} from '@app/features/ui/popover/PopoverPopout';
import styles from '@app/features/user/components/modals/tabs/my_profile_tab/BioEditor.module.css'; import styles from '@app/features/user/components/modals/tabs/my_profile_tab/BioEditor.module.css';
import {showUserErrorModal} from '@app/features/user/utils/UserErrorModalUtils';
import {msg} from '@lingui/core/macro'; import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro'; import {Trans, useLingui} from '@lingui/react/macro';
import {SmileyIcon} from '@phosphor-icons/react'; import {SmileyIcon} from '@phosphor-icons/react';
@@ -26,7 +26,14 @@ import {useCallback, useEffect, useId, useRef, useState} from 'react';
const ABOUT_ME_DESCRIPTOR = msg({message: 'About me'}); const ABOUT_ME_DESCRIPTOR = msg({message: 'About me'});
const OPEN_EMOJI_PICKER_DESCRIPTOR = msg({message: 'Open emoji picker'}); const OPEN_EMOJI_PICKER_DESCRIPTOR = msg({message: 'Open emoji picker'});
const BIO_MARKDOWN_PARSER_FLAGS = getParserFlagsForContext(MarkdownContext.RESTRICTED_USER_BIO); const BIO_MARKDOWN_PARSER_FLAGS = getParserFlagsForContext(MarkdownContext.RESTRICTED_USER_BIO);
const BIO_ALLOWED_TRIGGERS: ReadonlyArray<TriggerType> = Object.freeze(['emoji']); const ABOUT_ME_IS_TOO_LONG_DESCRIPTOR = msg({
message: 'About me is too long',
comment: 'Error modal title shown when inserting an emoji into the profile bio would exceed the bio limit.',
});
const SHORTEN_YOUR_ABOUT_ME_AND_TRY_AGAIN_DESCRIPTOR = msg({
message: 'Shorten your about me and try again.',
comment: 'Body of the error modal shown when inserting an emoji into the profile bio would exceed the bio limit.',
});
const COMPOSER_SURFACE_INTERACTIVE_SELECTOR = const COMPOSER_SURFACE_INTERACTIVE_SELECTOR =
'[data-channel-textarea], button, a, input, textarea, select, [role="button"]'; '[data-channel-textarea], button, a, input, textarea, select, [role="button"]';
@@ -96,6 +103,12 @@ export const BioEditor = observer(
}, },
[onEmojiPickerOpenChange, onEmojiSelect], [onEmojiPickerOpenChange, onEmojiSelect],
); );
const handleExceedMaxLength = useCallback(() => {
showUserErrorModal(
i18n._(ABOUT_ME_IS_TOO_LONG_DESCRIPTOR),
i18n._(SHORTEN_YOUR_ABOUT_ME_AND_TRY_AGAIN_DESCRIPTOR),
);
}, [i18n]);
let resolvedPlaceholder = ''; let resolvedPlaceholder = '';
if (placeholder != null) { if (placeholder != null) {
resolvedPlaceholder = placeholder; resolvedPlaceholder = placeholder;
@@ -226,12 +239,12 @@ export const BioEditor = observer(
placeholder={resolvedPlaceholder} placeholder={resolvedPlaceholder}
disabled={disabled} disabled={disabled}
channel={null} channel={null}
allowedTriggers={[...BIO_ALLOWED_TRIGGERS]}
markdown={true} markdown={true}
markdownParserFlags={BIO_MARKDOWN_PARSER_FLAGS} markdownParserFlags={BIO_MARKDOWN_PARSER_FLAGS}
singleLine={false} singleLine={false}
size="form" size="form"
maxLength={actualMaxLength} maxLength={actualMaxLength}
onExceedMaxLength={handleExceedMaxLength}
className={styles.editor} className={styles.editor}
autocompleteAnchor={wrapperRef.current} autocompleteAnchor={wrapperRef.current}
id={editableId} id={editableId}