fix(app): format typed @everyone and @here in the composer (#2672)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-11 13:18:48 +02:00
1 parent 395ec1d60f
commit 84aa8880f5
12 files changed
+1131 -25

No files matched your search

@@ -497,6 +497,7 @@ export const LexicalChannelTextareaContent = observer(
isSlotMenu, isSlotMenu,
onCursorMove, onCursorMove,
handleSelect, handleSelect,
specialMentionsAllowed,
} = useLexicalAutocomplete({ } = useLexicalAutocomplete({
channel, channel,
handleRef, handleRef,
@@ -1348,6 +1349,7 @@ export const LexicalChannelTextareaContent = observer(
initialSegments={initialDraftRef.current.segments} initialSegments={initialDraftRef.current.segments}
slotResolvers={slotResolvers} slotResolvers={slotResolvers}
emojiShortcodeResolver={composerEmojiResolver} emojiShortcodeResolver={composerEmojiResolver}
specialMentionsAllowed={specialMentionsAllowed}
channelId={channel.id} channelId={channel.id}
guildId={channel.guildId} guildId={channel.guildId}
submitOnEnter={!mobileLayout.enabled} submitOnEnter={!mobileLayout.enabled}
@@ -32,7 +32,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
import ContextMenuState from '@app/features/ui/state/ContextMenu'; import ContextMenuState from '@app/features/ui/state/ContextMenu';
import KeyboardMode from '@app/features/ui/state/KeyboardMode'; import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import Users from '@app/features/user/state/Users'; import Users from '@app/features/user/state/Users';
import {COMMAND_PRIORITY_HIGH, PASTE_COMMAND} from 'lexical'; import {$addUpdateTag, COMMAND_PRIORITY_HIGH, PASTE_COMMAND, PASTE_TAG} from 'lexical';
import type React from 'react'; import type React from 'react';
import {useCallback, useEffect} from 'react'; import {useCallback, useEffect} from 'react';
@@ -233,6 +233,7 @@ export function useChannelComposerPaste({
if (!insertPastedText(pastedText)) { if (!insertPastedText(pastedText)) {
return false; return false;
} }
$addUpdateTag(PASTE_TAG);
event.preventDefault(); event.preventDefault();
return true; return true;
}; };
@@ -272,7 +272,7 @@ function mentionPresentation(format: MarkdownHlFormat): ComposerMentionPresentat
return presentation; return presentation;
} }
function $nodeWireText(node: LexicalNode): string { export function $nodeWireText(node: LexicalNode): string {
if ( if (
$isComposerMentionNode(node) || $isComposerMentionNode(node) ||
$isComposerCustomEmojiNode(node) || $isComposerCustomEmojiNode(node) ||
@@ -32,6 +32,7 @@ import {
parseSlashSlotStateSegment, parseSlashSlotStateSegment,
parseSlashSlotStateSegmentId, parseSlashSlotStateSegmentId,
} from '@app/features/lexical/composer/SlashSlotPersistence'; } from '@app/features/lexical/composer/SlashSlotPersistence';
import {isSpecialMentionKind} from '@app/features/lexical/composer/specialMentions';
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager'; import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
import { import {
$createLineBreakNode, $createLineBreakNode,
@@ -92,9 +93,7 @@ function isValidSegmentWire(segment: MentionSegment): boolean {
} }
case 'special': case 'special':
return ( return (
(segment.id === 'everyone' || segment.id === 'here') && isSpecialMentionKind(segment.id) && segment.actualText === segment.id && segment.displayText === segment.id
segment.actualText === `@${segment.id}` &&
segment.displayText === segment.actualText
); );
} }
} }
@@ -259,7 +259,7 @@ describe('registerComposerMarkdownHighlight with silentMessagePrefix', () => {
it('keeps an autocompleted @everyone mention after @silent', () => { it('keeps an autocompleted @everyone mention after @silent', () => {
const segment: MentionSegment = { const segment: MentionSegment = {
type: 'special', type: 'special',
id: 'everyone', id: '@everyone',
displayText: '@everyone', displayText: '@everyone',
actualText: '@everyone', actualText: '@everyone',
start: 8, start: 8,
@@ -0,0 +1,672 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
$getComposerClipboardSelection,
$insertComposerClipboardSlice,
} from '@app/features/lexical/composer/ComposerClipboard';
import {resetComposerHistory} from '@app/features/lexical/composer/ComposerHistory';
import {registerComposerMarkdownHighlight} from '@app/features/lexical/composer/ComposerMarkdownHighlight';
import {registerComposerPlainText} from '@app/features/lexical/composer/ComposerPlainText';
import {
$hydrateComposerFromDraft,
$projectComposer,
isValidComposerSegment,
} from '@app/features/lexical/composer/ComposerSerialization';
import {
COMPOSER_RESCAN_TAG,
registerComposerSpecialMention,
} from '@app/features/lexical/composer/ComposerSpecialMention';
import {
$captureSelectionOffsets,
$replaceComposerRange,
$selectComposerOffset,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
import {
$createComposerCommandNode,
ComposerCommandNode,
} from '@app/features/lexical/composer/nodes/ComposerCommandNode';
import {ComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
import {$isComposerMentionNode, ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {ComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {$createSlashSlotNode, SlashSlotNode} from '@app/features/lexical/composer/nodes/SlashSlotNode';
import {SlashSlotPlaceholderNode} from '@app/features/lexical/composer/nodes/SlashSlotPlaceholderNode';
import {SyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
import {createSpecialMentionPayload, type SpecialMentionKind} from '@app/features/lexical/composer/specialMentions';
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
import {createEmptyHistoryState, registerHistory} from '@lexical/history';
import {
$addUpdateTag,
$createParagraphNode,
$createRangeSelection,
$createTextNode,
$getRoot,
$getSelection,
$isRangeSelection,
$setCompositionKey,
$setSelection,
createEditor,
type ElementNode,
HISTORY_MERGE_TAG,
type LexicalEditor,
type LexicalNode,
PASTE_TAG,
REDO_COMMAND,
UNDO_COMMAND,
} from 'lexical';
import {describe, expect, it, vi} from 'vitest';
vi.mock('@app/features/lexical/composer/nodes/ComposerMentionPill', () => ({ComposerMentionPill: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerCustomEmoji', () => ({ComposerCustomEmoji: () => null}));
vi.mock('@app/features/lexical/composer/nodes/ComposerStandardEmoji', () => ({ComposerStandardEmoji: () => null}));
const ALICE: MentionSegment = {type: 'user', id: '1', displayText: '@Alice', actualText: '<@1>', start: 0, end: 6};
function specialSegment(kind: SpecialMentionKind, start: number): MentionSegment {
return {type: 'special', id: kind, displayText: kind, actualText: kind, start, end: start + kind.length};
}
function update(editor: LexicalEditor, fn: () => void, tag?: string): void {
editor.update(fn, {discrete: true, tag});
}
function createComposer(
display: string,
segments: ReadonlyArray<MentionSegment> = [],
plainText = false,
): LexicalEditor {
const editor = createEditor({
namespace: 'test',
nodes: [
ComposerMentionNode,
ComposerCustomEmojiNode,
ComposerPlainSegmentNode,
ComposerCommandNode,
SlashSlotNode,
SlashSlotPlaceholderNode,
SyntaxMarkerNode,
],
onError: (error) => {
throw error;
},
});
update(editor, () => {
$hydrateComposerFromDraft(display, segments, plainText);
$selectComposerOffset(display.length);
});
return editor;
}
function register(editor: LexicalEditor, allowed: boolean, plainText = false): () => void {
const unregister = registerComposerSpecialMention(editor, allowed, plainText);
editor.read(() => undefined);
return unregister;
}
function type(editor: LexicalEditor, text: string): void {
for (const character of text) {
update(editor, () => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText(character);
}
});
}
}
function insertLineBreak(editor: LexicalEditor): void {
update(editor, () => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertLineBreak();
}
});
}
function paste(editor: LexicalEditor, insert: () => void): void {
update(editor, () => {
insert();
$addUpdateTag(PASTE_TAG);
});
}
function insertRawText(text: string): void {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertRawText(text);
}
}
function describeNode(node: LexicalNode): string {
return `${node.getType()}:${node.getTextContent()}`;
}
function $paragraph(): ElementNode {
return $getRoot().getFirstChildOrThrow<ElementNode>();
}
function children(editor: LexicalEditor): Array<string> {
return editor.read(() => $paragraph().getChildren().map(describeNode));
}
function snapshot(editor: LexicalEditor) {
return editor.read(() => ({
children: $paragraph().getChildren().map(describeNode),
projection: $projectComposer(),
selection: $captureSelectionOffsets(),
}));
}
function childrenJSON(editor: LexicalEditor) {
return editor.read(() =>
$paragraph()
.getChildren()
.map((node) => node.exportJSON()),
);
}
describe('typed special mentions', () => {
it('converts @everyone once a space follows it', () => {
const editor = createComposer('');
register(editor, true);
type(editor, 'hi @everyone ');
expect(snapshot(editor)).toEqual({
children: ['text:hi ', 'composer-mention:@everyone', 'text: '],
projection: {display: 'hi @everyone ', segments: [specialSegment('@everyone', 3)], wire: 'hi @everyone '},
selection: {anchor: 13, focus: 13},
});
});
it('produces exactly the nodes and projection the autocomplete inserts', () => {
const typed = createComposer('');
register(typed, true);
type(typed, 'hi @everyone ');
const picked = createComposer('hi @every');
register(picked, true);
update(picked, () => $replaceComposerRange(3, 9, createSpecialMentionPayload('@everyone')));
expect(childrenJSON(typed)).toEqual(childrenJSON(picked));
expect(snapshot(typed)).toEqual(snapshot(picked));
});
it('keeps a token at the caret as text, so @herenow never converts', () => {
const editor = createComposer('');
register(editor, true);
type(editor, 'hi @here');
expect(snapshot(editor)).toEqual({
children: ['text:hi @here'],
projection: {display: 'hi @here', segments: [], wire: 'hi @here'},
selection: {anchor: 8, focus: 8},
});
type(editor, 'now ');
expect(snapshot(editor)).toEqual({
children: ['text:hi @herenow '],
projection: {display: 'hi @herenow ', segments: [], wire: 'hi @herenow '},
selection: {anchor: 12, focus: 12},
});
});
it('converts a finished token when a line break follows it', () => {
const editor = createComposer('');
register(editor, true);
type(editor, 'hi @everyone');
insertLineBreak(editor);
expect(snapshot(editor)).toEqual({
children: ['text:hi ', 'composer-mention:@everyone', 'linebreak:\n'],
projection: {display: 'hi @everyone\n', segments: [specialSegment('@everyone', 3)], wire: 'hi @everyone\n'},
selection: {anchor: 13, focus: 13},
});
});
it('does not convert when only the caret moves away, and converts on the next edit', () => {
const editor = createComposer('');
register(editor, true);
type(editor, '@here');
update(editor, () => $selectComposerOffset(0));
expect(children(editor)).toEqual(['text:@here']);
type(editor, ' ');
expect(snapshot(editor)).toEqual({
children: ['text: ', 'composer-mention:@here'],
projection: {display: ' @here', segments: [specialSegment('@here', 1)], wire: ' @here'},
selection: {anchor: 1, focus: 1},
});
});
it('converts a token completed with the caret inside it and moves the caret past the mention', () => {
const editor = createComposer('@hre');
register(editor, true);
update(editor, () => $selectComposerOffset(2));
type(editor, 'e');
expect(snapshot(editor)).toEqual({
children: ['composer-mention:@here'],
projection: {display: '@here', segments: [specialSegment('@here', 0)], wire: '@here'},
selection: {anchor: 5, focus: 5},
});
});
it('treats a preceding or following mention or emoji as a boundary', () => {
const emoji: MentionSegment = {
type: 'emoji',
id: '2',
displayText: ':wave:',
actualText: '<:wave:2>',
start: 0,
end: 6,
};
const alice = {...ALICE, start: 6, end: 12};
const editor = createComposer(':wave:@Alice', [emoji, alice]);
register(editor, true);
type(editor, '@here ');
expect(snapshot(editor).projection).toEqual({
display: ':wave:@Alice@here ',
segments: [emoji, alice, specialSegment('@here', 12)],
wire: '<:wave:2><@1>@here ',
});
const before = createComposer('@here@Alice', [{...ALICE, start: 5, end: 11}]);
register(before, true);
expect(children(before)).toEqual(['composer-mention:@here', 'composer-mention:@Alice']);
});
it('treats a preceding special mention pill or slash command as a boundary', () => {
const editor = createComposer('@here', [specialSegment('@here', 0)]);
register(editor, true);
type(editor, '@everyone ');
expect(snapshot(editor).projection).toEqual({
display: '@here@everyone ',
segments: [specialSegment('@here', 0), specialSegment('@everyone', 5)],
wire: '@here@everyone ',
});
const command = createComposer('');
update(command, () => {
$paragraph().append($createComposerCommandNode('/giphy'), $createTextNode('@here '));
});
register(command, true);
expect(children(command)).toEqual(['composer-command:/giphy', 'composer-mention:@here', 'text: ']);
});
it('converts a token in a second paragraph', () => {
const editor = createComposer('a');
register(editor, true);
update(editor, () => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode('@everyone x'));
$getRoot().append(paragraph);
});
expect(snapshot(editor).projection).toEqual({
display: 'a\n@everyone x',
segments: [specialSegment('@everyone', 2)],
wire: 'a\n@everyone x',
});
});
it('keeps the caret after a later pill when a conversion splits the text before it', () => {
const editor = createComposer('@here @Alice', [{...ALICE, start: 6, end: 12}]);
register(editor, true);
expect(snapshot(editor)).toEqual({
children: ['composer-mention:@here', 'text: ', 'composer-mention:@Alice'],
projection: {
display: '@here @Alice',
segments: [specialSegment('@here', 0), {...ALICE, start: 6, end: 12}],
wire: '@here <@1>',
},
selection: {anchor: 12, focus: 12},
});
});
it('keeps an element caret between a pill and the text a conversion splits', () => {
const editor = createComposer('@Alice@here x', [ALICE]);
update(editor, () => {
const selection = $createRangeSelection();
selection.anchor.set($paragraph().getKey(), 1, 'element');
selection.focus.set($paragraph().getKey(), 1, 'element');
$setSelection(selection);
});
register(editor, true);
expect(snapshot(editor)).toEqual({
children: ['composer-mention:@Alice', 'composer-mention:@here', 'text: x'],
projection: {
display: '@Alice@here x',
segments: [ALICE, specialSegment('@here', 6)],
wire: '<@1>@here x',
},
selection: {anchor: 6, focus: 6},
});
});
it('gives the conversion one undo entry that restores the text before the keystroke, and redo restores it', () => {
const editor = createComposer('');
const history = createEmptyHistoryState();
registerHistory(editor, history, 1000);
register(editor, true);
type(editor, 'hi @everyone');
const depth = history.undoStack.length;
type(editor, ' ');
expect(history.undoStack).toHaveLength(depth + 1);
editor.dispatchCommand(UNDO_COMMAND, undefined);
expect(snapshot(editor)).toEqual({
children: ['text:hi @everyone'],
projection: {display: 'hi @everyone', segments: [], wire: 'hi @everyone'},
selection: {anchor: 12, focus: 12},
});
editor.dispatchCommand(REDO_COMMAND, undefined);
expect(snapshot(editor)).toEqual({
children: ['text:hi ', 'composer-mention:@everyone', 'text: '],
projection: {display: 'hi @everyone ', segments: [specialSegment('@everyone', 3)], wire: 'hi @everyone '},
selection: {anchor: 13, focus: 13},
});
});
it('leaves a token at the caret alone when the mention menu opens or closes', () => {
const editor = createComposer('');
const history = createEmptyHistoryState();
registerHistory(editor, history, 1000);
register(editor, true);
type(editor, 'hi @here');
const depth = history.undoStack.length;
update(editor, () => $getRoot().markDirty(), HISTORY_MERGE_TAG);
expect(children(editor)).toEqual(['text:hi @here']);
expect(history.undoStack).toHaveLength(depth);
});
it('converts a token at the caret when the transform is registered, without adding an undo entry', () => {
const editor = createComposer('hi @here');
const history = createEmptyHistoryState();
registerHistory(editor, history, 1000);
const depth = history.undoStack.length;
register(editor, true);
expect(children(editor)).toEqual(['text:hi ', 'composer-mention:@here']);
expect(history.undoStack).toHaveLength(depth);
});
it('converts a hydrated draft whose token ends at the caret without resetting the history', () => {
const editor = createComposer('');
register(editor, true);
update(
editor,
() => {
$hydrateComposerFromDraft('hi @everyone', []);
$selectComposerOffset(12);
},
COMPOSER_RESCAN_TAG,
);
expect(snapshot(editor)).toEqual({
children: ['text:hi ', 'composer-mention:@everyone'],
projection: {display: 'hi @everyone', segments: [specialSegment('@everyone', 3)], wire: 'hi @everyone'},
selection: {anchor: 12, focus: 12},
});
});
it('converts a token at the caret after a render mode switch', () => {
const editor = createComposer('');
const unregister = register(editor, true);
type(editor, '@here');
unregister();
update(
editor,
() => {
const selection = $captureSelectionOffsets()!;
const projection = $projectComposer();
$hydrateComposerFromDraft(projection.display, projection.segments, true);
$selectComposerRange(selection.anchor, selection.focus);
},
'composer-render-mode',
);
resetComposerHistory(editor);
registerComposerPlainText(editor);
register(editor, true, true);
expect(snapshot(editor)).toEqual({
children: ['composer-plain-segment:@here'],
projection: {display: '@here', segments: [specialSegment('@here', 0)], wire: '@here'},
selection: {anchor: 5, focus: 5},
});
});
it('converts to the plain segment the autocomplete inserts in plain text mode', () => {
const typed = createComposer('', [], true);
registerComposerPlainText(typed);
register(typed, true, true);
type(typed, 'hi @everyone ');
const picked = createComposer('hi @every', [], true);
registerComposerPlainText(picked);
register(picked, true, true);
update(picked, () => $replaceComposerRange(3, 9, createSpecialMentionPayload('@everyone'), undefined, true));
expect(childrenJSON(typed)).toEqual(childrenJSON(picked));
expect(snapshot(typed)).toEqual({
children: ['text:hi ', 'composer-plain-segment:@everyone', 'text: '],
projection: {display: 'hi @everyone ', segments: [specialSegment('@everyone', 3)], wire: 'hi @everyone '},
selection: {anchor: 13, focus: 13},
});
});
it('converts a pasted token at the caret and several pasted tokens at once', () => {
const tail = createComposer('ping ');
register(tail, true);
paste(tail, () => insertRawText('@here'));
expect(snapshot(tail)).toEqual({
children: ['text:ping ', 'composer-mention:@here'],
projection: {display: 'ping @here', segments: [specialSegment('@here', 5)], wire: 'ping @here'},
selection: {anchor: 10, focus: 10},
});
const several = createComposer('');
register(several, true);
paste(several, () => insertRawText('@everyone and @here'));
expect(snapshot(several)).toEqual({
children: ['composer-mention:@everyone', 'text: and ', 'composer-mention:@here'],
projection: {
display: '@everyone and @here',
segments: [specialSegment('@everyone', 0), specialSegment('@here', 14)],
wire: '@everyone and @here',
},
selection: {anchor: 19, focus: 19},
});
});
it('converts typed tokens beside wire mentions in a channel paste slice', () => {
const editor = createComposer('hi ');
register(editor, true);
paste(editor, () => $insertComposerClipboardSlice({display: '@Alice @everyone', segments: [ALICE]}, false));
expect(snapshot(editor)).toEqual({
children: ['text:hi ', 'composer-mention:@Alice', 'text: ', 'composer-mention:@everyone'],
projection: {
display: 'hi @Alice @everyone',
segments: [{...ALICE, start: 3, end: 9}, specialSegment('@everyone', 10)],
wire: 'hi <@1> @everyone',
},
selection: {anchor: 19, focus: 19},
});
});
it('gives a channel paste its own undo entry once it carries the paste tag', () => {
const editor = createComposer('');
const history = createEmptyHistoryState();
registerHistory(editor, history, 1000);
register(editor, true);
type(editor, 'a');
paste(editor, () => $insertComposerClipboardSlice({display: 'b', segments: []}, false));
type(editor, 'c');
editor.dispatchCommand(UNDO_COMMAND, undefined);
expect(editor.read(() => $projectComposer().display)).toBe('ab');
editor.dispatchCommand(UNDO_COMMAND, undefined);
expect(editor.read(() => $projectComposer().display)).toBe('a');
});
it('waits for IME composition to end before converting', () => {
const editor = createComposer('hi ');
register(editor, true);
update(editor, () => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.insertText('@everyone ');
$setCompositionKey(selection.anchor.key);
}
});
expect(children(editor)).toEqual(['text:hi @everyone ']);
update(editor, () => $setCompositionKey(null));
expect(children(editor)).toEqual(['text:hi ', 'composer-mention:@everyone', 'text: ']);
});
it('leaves code formatted text and slash command slot text alone', () => {
const editor = createComposer('');
register(editor, true);
update(editor, () => {
const code = $createTextNode('@everyone ').toggleFormat('code');
const slot = $createSlashSlotNode('message', 'string', true).append($createTextNode('@here '));
$paragraph().append(code, slot);
code.select(10, 10);
});
expect(editor.read(() => $paragraph().getChildren().map(describeNode))).toEqual([
'text:@everyone ',
'slash-slot:@here ',
]);
expect(editor.read(() => $paragraph().getLastChildOrThrow<ElementNode>().getChildren().map(describeNode))).toEqual([
'text:@here ',
]);
});
it('restores a draft special mention and projects it back unchanged', () => {
const segments = [specialSegment('@everyone', 0)];
const editor = createComposer('@everyone hi', segments);
expect(snapshot(editor)).toEqual({
children: ['composer-mention:@everyone', 'text: hi'],
projection: {display: '@everyone hi', segments, wire: '@everyone hi'},
selection: {anchor: 12, focus: 12},
});
});
it('includes the special segment in the clipboard selection', () => {
const editor = createComposer('');
register(editor, true);
type(editor, 'hi @everyone ');
update(editor, () => $selectComposerRange(0, 13));
expect(editor.read(() => $getComposerClipboardSelection())).toEqual({
display: 'hi @everyone ',
segments: [specialSegment('@everyone', 3)],
textPlain: 'hi @everyone ',
});
});
});
describe('typed special mentions with the markdown highlight', () => {
function createHighlightedComposer(): LexicalEditor {
const editor = createComposer('');
registerComposerMarkdownHighlight(editor);
register(editor, true);
return editor;
}
it.each([
['an escaped token', '\\@everyone '],
['closed inline code', '`@everyone` '],
['open inline code', '`@everyone '],
['a url', 'https://fluxer.app/@everyone '],
])('leaves %s as text', (_name, text) => {
const editor = createHighlightedComposer();
type(editor, text);
expect(snapshot(editor).projection).toEqual({display: text, segments: [], wire: text});
});
it('leaves a token inside a fenced code block as text and converts one after it', () => {
const editor = createHighlightedComposer();
type(editor, '```');
insertLineBreak(editor);
type(editor, '@everyone ');
insertLineBreak(editor);
type(editor, '```');
insertLineBreak(editor);
type(editor, '@here ');
expect(snapshot(editor).projection).toEqual({
display: '```\n@everyone \n```\n@here ',
segments: [specialSegment('@here', 19)],
wire: '```\n@everyone \n```\n@here ',
});
});
it('converts a bold token into a pill that is not literal', () => {
const editor = createHighlightedComposer();
type(editor, '**@everyone** ');
expect(snapshot(editor).projection).toEqual({
display: '**@everyone** ',
segments: [specialSegment('@everyone', 2)],
wire: '**@everyone** ',
});
expect(
editor.read(() =>
$paragraph()
.getChildren()
.filter($isComposerMentionNode)
.map((node) => node.isLiteral()),
),
).toEqual([false]);
});
});
describe('special mentions where they are not allowed', () => {
it('turns a restored special mention into text', () => {
const editor = createComposer('@everyone hi', [specialSegment('@everyone', 0)]);
register(editor, false);
expect(snapshot(editor)).toEqual({
children: ['text:@everyone hi'],
projection: {display: '@everyone hi', segments: [], wire: '@everyone hi'},
selection: {anchor: 12, focus: 12},
});
});
it('turns a pasted special mention into text', () => {
const editor = createComposer('hi ');
register(editor, false);
paste(editor, () =>
$insertComposerClipboardSlice({display: '@here', segments: [specialSegment('@here', 0)]}, false),
);
expect(snapshot(editor)).toEqual({
children: ['text:hi @here'],
projection: {display: 'hi @here', segments: [], wire: 'hi @here'},
selection: {anchor: 8, focus: 8},
});
});
it('turns a restored special mention into text in plain text mode', () => {
const editor = createComposer('@everyone hi', [specialSegment('@everyone', 0)], true);
registerComposerPlainText(editor);
register(editor, false, true);
expect(snapshot(editor)).toEqual({
children: ['text:@everyone hi'],
projection: {display: '@everyone hi', segments: [], wire: '@everyone hi'},
selection: {anchor: 12, focus: 12},
});
});
it('turns a pasted special mention into text in plain text mode', () => {
const editor = createComposer('hi ', [], true);
registerComposerPlainText(editor);
register(editor, false, true);
paste(editor, () =>
$insertComposerClipboardSlice({display: '@here', segments: [specialSegment('@here', 0)]}, true),
);
expect(snapshot(editor)).toEqual({
children: ['text:hi @here'],
projection: {display: 'hi @here', segments: [], wire: 'hi @here'},
selection: {anchor: 8, focus: 8},
});
});
it('leaves typed tokens as text, then converts them once special mentions become allowed', () => {
const editor = createComposer('');
const unregister = register(editor, false);
type(editor, '@here ');
expect(children(editor)).toEqual(['text:@here ']);
unregister();
register(editor, true);
expect(children(editor)).toEqual(['composer-mention:@here', 'text: ']);
});
});
describe('isValidComposerSegment', () => {
it('accepts the special mention ids the autocomplete writes', () => {
expect(isValidComposerSegment('@everyone hi', specialSegment('@everyone', 0))).toBe(true);
expect(isValidComposerSegment('hi @here', specialSegment('@here', 3))).toBe(true);
});
it('rejects the bare id form and a mismatched id', () => {
expect(isValidComposerSegment('@everyone hi', {...specialSegment('@everyone', 0), id: 'everyone'})).toBe(false);
expect(isValidComposerSegment('@everyone hi', {...specialSegment('@everyone', 0), id: '@here'})).toBe(false);
});
});
@@ -0,0 +1,141 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {$nodeWireText} from '@app/features/lexical/composer/ComposerMarkdownHighlight';
import {
$captureSelectionOffsets,
$createComposerInsertNode,
$selectComposerRange,
} from '@app/features/lexical/composer/composerOffsets';
import {ComposerMentionNode} from '@app/features/lexical/composer/nodes/ComposerMentionNode';
import {ComposerPlainSegmentNode} from '@app/features/lexical/composer/nodes/ComposerPlainSegmentNode';
import {$isSyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode';
import {
createSpecialMentionPayload,
findTypedSpecialMention,
isSpecialMentionKind,
SPECIAL_MENTION_PATTERN,
} from '@app/features/lexical/composer/specialMentions';
import {mergeRegister} from '@lexical/utils';
import {
$addUpdateTag,
$createTextNode,
$getRoot,
$hasUpdateTag,
$isElementNode,
$isLineBreakNode,
$isTextNode,
HISTORY_MERGE_TAG,
HISTORY_PUSH_TAG,
type LexicalEditor,
type LexicalNode,
PASTE_TAG,
RootNode,
type TextNode,
} from 'lexical';
export const COMPOSER_RESCAN_TAG = 'composer-rescan';
interface SpecialMentionCandidate {
node: TextNode;
sourceStart: number;
displayStart: number;
}
export function registerComposerSpecialMention(
editor: LexicalEditor,
allowed: boolean,
plainText: boolean,
): () => void {
if (!allowed) {
return mergeRegister(
editor.registerNodeTransform(ComposerMentionNode, (node) => {
if (node.getSegmentType() === 'special') {
node.replace($createTextNode(node.getTextContent()));
}
}),
editor.registerNodeTransform(ComposerPlainSegmentNode, (node) => {
if (node.getSegmentType() === 'special' && isSpecialMentionKind(node.getSegmentId())) {
node.replace($createTextNode(node.getTextContent()));
}
}),
);
}
const unregister = editor.registerNodeTransform(RootNode, (root) => {
if (!editor.isComposing()) {
$convertSpecialMention(root, plainText);
}
});
editor.update(
() => {
$getRoot().markDirty();
},
{discrete: true, tag: [COMPOSER_RESCAN_TAG, HISTORY_MERGE_TAG]},
);
return unregister;
}
function $sourceText(node: LexicalNode): string {
const wire = $nodeWireText(node);
if ($isLineBreakNode(node) || $isSyntaxMarkerNode(node) || ($isTextNode(node) && node.isSimpleText())) {
return wire;
}
return ' '.repeat(wire.length);
}
function $convertSpecialMention(root: RootNode, plainText: boolean): void {
let source = '';
let displayLength = 0;
const candidates: Array<SpecialMentionCandidate> = [];
const blocks = root.getChildren();
for (let index = 0; index < blocks.length; index += 1) {
const block = blocks[index]!;
if (index > 0) {
source += '\n';
displayLength += 1;
}
for (const child of $isElementNode(block) ? block.getChildren() : [block]) {
if (
$isTextNode(child) &&
child.isSimpleText() &&
!child.hasFormat('code') &&
SPECIAL_MENTION_PATTERN.test(child.getTextContent())
) {
candidates.push({node: child, sourceStart: source.length, displayStart: displayLength});
}
source += $sourceText(child);
displayLength += child.getTextContentSize();
}
}
if (candidates.length === 0) {
return;
}
const selection = $captureSelectionOffsets();
const caret =
selection == null ||
selection.anchor !== selection.focus ||
$hasUpdateTag(PASTE_TAG) ||
$hasUpdateTag(COMPOSER_RESCAN_TAG)
? null
: selection.anchor;
for (const {node, sourceStart, displayStart} of candidates) {
const match = findTypedSpecialMention(
source,
sourceStart,
sourceStart + node.getTextContentSize(),
caret == null ? null : caret - displayStart + sourceStart,
);
if (match != null) {
const parts = node.splitText(match.start - sourceStart, match.end - sourceStart);
parts[match.start > sourceStart ? 1 : 0]!.replace(
$createComposerInsertNode(createSpecialMentionPayload(match.kind), plainText),
);
if (selection != null) {
$selectComposerRange(selection.anchor, selection.focus);
}
if (!$hasUpdateTag(HISTORY_MERGE_TAG)) {
$addUpdateTag(HISTORY_PUSH_TAG);
}
return;
}
}
}
@@ -16,6 +16,10 @@ import {ComposerMentionContext} from '@app/features/lexical/composer/ComposerMen
import {registerComposerPlainText} from '@app/features/lexical/composer/ComposerPlainText'; import {registerComposerPlainText} from '@app/features/lexical/composer/ComposerPlainText';
import {$hydrateComposerFromDraft, $projectComposer} from '@app/features/lexical/composer/ComposerSerialization'; import {$hydrateComposerFromDraft, $projectComposer} from '@app/features/lexical/composer/ComposerSerialization';
import {registerComposerSoftWrapDeletion} from '@app/features/lexical/composer/ComposerSoftWrapDeletion'; import {registerComposerSoftWrapDeletion} from '@app/features/lexical/composer/ComposerSoftWrapDeletion';
import {
COMPOSER_RESCAN_TAG,
registerComposerSpecialMention,
} from '@app/features/lexical/composer/ComposerSpecialMention';
import { import {
type ComposerTypeaheadActiveState, type ComposerTypeaheadActiveState,
registerComposerTypeaheadModifierGuard, registerComposerTypeaheadModifierGuard,
@@ -127,6 +131,7 @@ export interface LexicalComposerInputProps {
markdownParserFlags?: number; markdownParserFlags?: number;
silentMessagePrefix?: boolean; silentMessagePrefix?: boolean;
emojiShortcodeResolver?: ComposerEmojiResolver; emojiShortcodeResolver?: ComposerEmojiResolver;
specialMentionsAllowed: boolean;
channelId?: string; channelId?: string;
guildId?: string; guildId?: string;
selectionToolbar?: boolean; selectionToolbar?: boolean;
@@ -233,6 +238,7 @@ const ComposerInner = ({
markdownParserFlags, markdownParserFlags,
silentMessagePrefix = false, silentMessagePrefix = false,
emojiShortcodeResolver, emojiShortcodeResolver,
specialMentionsAllowed,
selectionToolbar = true, selectionToolbar = true,
submitOnEnter = true, submitOnEnter = true,
focusRingTarget, focusRingTarget,
@@ -481,7 +487,7 @@ const ComposerInner = ({
$hydrateComposerFromDraft(display, segments, plainTextRef.current); $hydrateComposerFromDraft(display, segments, plainTextRef.current);
$selectComposerOffset(display.length); $selectComposerOffset(display.length);
}, },
{discrete: true}, {discrete: true, tag: COMPOSER_RESCAN_TAG},
); );
resetComposerHistory(editor); resetComposerHistory(editor);
}, },
@@ -547,6 +553,11 @@ const ComposerInner = ({
return mergeRegister(...cleanups); return mergeRegister(...cleanups);
}, [editor, markdown, markdownParserFlags, plainText, silentMessagePrefix]); }, [editor, markdown, markdownParserFlags, plainText, silentMessagePrefix]);
useLayoutEffect(
() => registerComposerSpecialMention(editor, specialMentionsAllowed, plainText),
[editor, plainText, specialMentionsAllowed],
);
useEffect(() => { useEffect(() => {
return mergeRegister( return mergeRegister(
registerContextMenuUndoRedo(editor), registerContextMenuUndoRedo(editor),
@@ -128,17 +128,24 @@ export const LexicalRichInput = ({
[onChange, previousValueRef, rememberSegmentsForValue, segmentManagerRef], [onChange, previousValueRef, rememberSegmentsForValue, segmentManagerRef],
); );
const {autocompleteQuery, autocompleteOptions, autocompleteType, isSlotMenu, onCursorMove, handleSelect} = const {
useLexicalAutocomplete({ autocompleteQuery,
channel, autocompleteOptions,
handleRef, autocompleteType,
allowedTriggers: allowedTriggers ?? (channel == null ? SAFE_CONTEXT_FREE_TRIGGERS : SAFE_CHANNEL_TRIGGERS), isSlotMenu,
allowSpecialMentions, onCursorMove,
allowMediaOptions: false, handleSelect,
maxActualLength: maxLength, specialMentionsAllowed,
onExceedMaxLength, } = useLexicalAutocomplete({
i18n, channel,
}); handleRef,
allowedTriggers: allowedTriggers ?? (channel == null ? SAFE_CONTEXT_FREE_TRIGGERS : SAFE_CHANNEL_TRIGGERS),
allowSpecialMentions,
allowMediaOptions: false,
maxActualLength: maxLength,
onExceedMaxLength,
i18n,
});
const insertEmoji = useCallback( const insertEmoji = useCallback(
(emoji: FlatEmoji) => (emoji: FlatEmoji) =>
@@ -224,6 +231,7 @@ export const LexicalRichInput = ({
markdown={markdown} markdown={markdown}
markdownParserFlags={markdownParserFlags} markdownParserFlags={markdownParserFlags}
emojiShortcodeResolver={emojiShortcodeResolver} emojiShortcodeResolver={emojiShortcodeResolver}
specialMentionsAllowed={specialMentionsAllowed}
channelId={channel == null ? undefined : channel.id} channelId={channel == null ? undefined : channel.id}
guildId={channel == null ? undefined : channel.guildId} guildId={channel == null ? undefined : channel.guildId}
submitOnEnter={submitOnEnter ?? singleLine} submitOnEnter={submitOnEnter ?? singleLine}
@@ -0,0 +1,173 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DEFAULT_COMPOSER_MARKDOWN_FLAGS} from '@app/features/lexical/composer/markdownSpans';
import {
areSpecialMentionsAllowed,
createSpecialMentionPayload,
findTypedSpecialMention,
hasOpenCodeFence,
isSpecialMentionKind,
type SpecialMentionKind,
type TypedSpecialMention,
} from '@app/features/lexical/composer/specialMentions';
import {parseMarkdownAstWithWasm} from '@app/features/messaging/utils/markdown/parser/MarkdownParserWasm';
import {describe, expect, it} from 'vitest';
const GUILD_CHANNEL = {guildId: '1', isPersonalNotes: () => false};
const DM_CHANNEL = {guildId: undefined, isPersonalNotes: () => false};
const PERSONAL_NOTES = {guildId: undefined, isPersonalNotes: () => true};
function typedSpecialMentions(text: string): Array<SpecialMentionKind> {
const kinds: Array<SpecialMentionKind> = [];
let match = findTypedSpecialMention(text, 0, text.length, null);
while (match != null) {
kinds.push(match.kind);
match = findTypedSpecialMention(text, match.end, text.length, null);
}
return kinds;
}
function collectParsedSpecialMentions(value: unknown, kinds: Array<SpecialMentionKind>): Array<SpecialMentionKind> {
if (Array.isArray(value)) {
for (const item of value) {
collectParsedSpecialMentions(item, kinds);
}
} else if (typeof value === 'object' && value != null) {
if ('type' in value && value.type === 'Mention' && 'kind' in value) {
const mention = JSON.stringify(value.kind);
if (mention === '{"kind":"Everyone"}') {
kinds.push('@everyone');
} else if (mention === '{"kind":"Here"}') {
kinds.push('@here');
}
}
for (const child of Object.values(value)) {
collectParsedSpecialMentions(child, kinds);
}
}
return kinds;
}
describe('findTypedSpecialMention', () => {
it.each<[string, string, number | null, TypedSpecialMention]>([
['@everyone followed by a space', '@everyone ', 10, {start: 0, end: 9, kind: '@everyone'}],
['@here followed by a space', '@here ', 6, {start: 0, end: 5, kind: '@here'}],
['a finished token with no caret', 'hi @everyone', null, {start: 3, end: 12, kind: '@everyone'}],
['a finished token with the caret before it', 'hi @everyone', 0, {start: 3, end: 12, kind: '@everyone'}],
['a finished token with the caret inside it', '@here', 3, {start: 0, end: 5, kind: '@here'}],
['a token inside parentheses', '(@everyone)', null, {start: 1, end: 10, kind: '@everyone'}],
['a token inside bold markers', '**@everyone** ', 14, {start: 2, end: 11, kind: '@everyone'}],
['a token inside underline markers', '__@here__ ', 10, {start: 2, end: 7, kind: '@here'}],
['a token followed by an apostrophe', "@here's ", 8, {start: 0, end: 5, kind: '@here'}],
['a token followed by an underscore', '@here_now ', null, {start: 0, end: 5, kind: '@here'}],
['a token after a second @', '@@everyone ', null, {start: 1, end: 10, kind: '@everyone'}],
['a token after a mention wire', '<@1>@here ', null, {start: 4, end: 9, kind: '@here'}],
['a token after closed inline code', '`x` @everyone ', null, {start: 4, end: 13, kind: '@everyone'}],
['a token after a closed fence', '```\ncode\n```\n@everyone ', null, {start: 13, end: 22, kind: '@everyone'}],
['a token on a new line after an inline code line', '`x`\n@here ', null, {start: 4, end: 9, kind: '@here'}],
])('converts %s', (_name, text, caret, expected) => {
expect(findTypedSpecialMention(text, 0, text.length, caret)).toEqual(expected);
});
it.each<[string, string, number | null]>([
['a token whose end is the caret', 'hi @everyone', 12],
['a token joined to a following word', '@herenow ', 9],
['a token with a trailing letter', '@everyones ', 11],
['a token with a leading word', 'foo@everyone ', 13],
['a token inside an email address', 'mail [email protected] ', null],
['a token touching a digit', '1@here 2', null],
['a token touching a non-latin letter', 'café@here ', null],
['a token after one backslash', '\\@everyone ', null],
['a token after two backslashes', '\\\\@everyone ', null],
['a token inside open inline code', '`@everyone ', null],
['a token inside double backtick code', '`` @everyone ``', null],
['a token inside an open fence', '```\n@everyone \n', null],
['a token inside a url', 'https://fluxer.app/@everyone ', null],
['a spaced @ and word', '@ everyone ', null],
])('leaves %s alone', (_name, text, caret) => {
expect(findTypedSpecialMention(text, 0, text.length, caret)).toBeNull();
});
it('only considers tokens that start at from and end by to', () => {
expect(findTypedSpecialMention('@everyone and @here ', 10, 20, 20)).toEqual({start: 14, end: 19, kind: '@here'});
expect(findTypedSpecialMention('@everyone ', 0, 5, null)).toBeNull();
});
});
describe('findTypedSpecialMention against the markdown parser', () => {
it.each<[string, Array<SpecialMentionKind>, Array<SpecialMentionKind>]>([
['@everyone ', ['@everyone'], ['@everyone']],
['@here ', ['@here'], ['@here']],
['(@everyone)', ['@everyone'], ['@everyone']],
['**@everyone** ', ['@everyone'], ['@everyone']],
['__@here__ ', ['@here'], ['@here']],
["@here's ", ['@here'], ['@here']],
['@here_now ', ['@here'], ['@here']],
['@@everyone ', ['@everyone'], ['@everyone']],
['<@1>@here ', ['@here'], ['@here']],
['`x` @everyone ', ['@everyone'], ['@everyone']],
['```\ncode\n```\n@everyone ', ['@everyone'], ['@everyone']],
['\\@everyone ', [], []],
['\\\\@everyone ', [], []],
['`` @everyone ``', [], []],
['https://fluxer.app/@everyone ', [], []],
['@ everyone ', [], []],
['@herenow ', [], ['@here']],
['@everyones ', [], ['@everyone']],
['foo@everyone ', [], ['@everyone']],
['mail [email protected] ', [], ['@here']],
['café@here ', [], ['@here']],
['@everyone@here', ['@everyone'], ['@everyone', '@here']],
['`@everyone ', [], ['@everyone']],
['```\n@everyone \n', [], ['@everyone']],
])('%j converts %j where the parser pings %j', (text, typed, parsed) => {
expect(typedSpecialMentions(text)).toEqual(typed);
expect(
collectParsedSpecialMentions(parseMarkdownAstWithWasm(text, DEFAULT_COMPOSER_MARKDOWN_FLAGS).nodes, []),
).toEqual(parsed);
});
});
describe('areSpecialMentionsAllowed', () => {
it.each<[string, Parameters<typeof areSpecialMentionsAllowed>, boolean]>([
['a guild channel with permission', [GUILD_CHANNEL, undefined, undefined, true], true],
['a guild channel without permission', [GUILD_CHANNEL, undefined, undefined, false], false],
['a direct message', [DM_CHANNEL, undefined, undefined, false], true],
['personal notes', [PERSONAL_NOTES, undefined, undefined, true], false],
['a composer with no channel', [null, true, ['emoji'], true], false],
['a guild channel with special mentions switched off', [GUILD_CHANNEL, false, undefined, true], false],
['a direct message with special mentions switched off', [DM_CHANNEL, false, undefined, false], false],
['a guild channel without the mention trigger', [GUILD_CHANNEL, true, ['emoji'], true], false],
[
'a guild channel with the rich input triggers',
[GUILD_CHANNEL, true, ['emoji', 'mention', 'channel'], true],
true,
],
])('answers for %s', (_name, args, expected) => {
expect(areSpecialMentionsAllowed(...args)).toBe(expected);
});
});
describe('createSpecialMentionPayload', () => {
it('builds the autocomplete mention payload', () => {
expect(createSpecialMentionPayload('@everyone')).toEqual({
kind: 'mention',
mentionType: 'special',
id: '@everyone',
display: '@everyone',
wire: '@everyone',
});
});
});
describe('isSpecialMentionKind', () => {
it('recognises only @everyone and @here', () => {
expect(['@everyone', '@here', 'here', '@someone'].map(isSpecialMentionKind)).toEqual([true, true, false, false]);
});
});
describe('hasOpenCodeFence', () => {
it('reports an odd number of fences', () => {
expect(['```js\nconst a', '```a```', ''].map(hasOpenCodeFence)).toEqual([true, false, false]);
});
});
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Channel} from '@app/features/channel/models/Channel';
import type {ComposerInsertPayload} from '@app/features/lexical/composer/composerOffsets';
import {isAutocompleteTriggerAllowed, type TriggerType} from '@app/features/messaging/utils/AutocompleteTriggerPolicy';
import {findUrlSpans, isInsideSpan, type TextSpan} from '@app/features/messaging/utils/markdown/UrlSpanUtils';
export type SpecialMentionKind = '@everyone' | '@here';
export interface TypedSpecialMention {
start: number;
end: number;
kind: SpecialMentionKind;
}
export const SPECIAL_MENTION_PATTERN = /@(?:everyone|here)/;
const WORD_CHARACTER = /[\p{L}\p{N}]/u;
export function isSpecialMentionKind(value: string): value is SpecialMentionKind {
return value === '@everyone' || value === '@here';
}
export function createSpecialMentionPayload(kind: SpecialMentionKind): ComposerInsertPayload {
return {kind: 'mention', mentionType: 'special', id: kind, display: kind, wire: kind};
}
export function areSpecialMentionsAllowed(
channel: Pick<Channel, 'guildId' | 'isPersonalNotes'> | null,
allowSpecialMentions: boolean | undefined,
allowedTriggers: ReadonlyArray<TriggerType> | undefined,
canMentionEveryone: boolean,
): boolean {
if (channel == null || allowSpecialMentions === false || !isAutocompleteTriggerAllowed('mention', allowedTriggers)) {
return false;
}
return channel.guildId == null ? !channel.isPersonalNotes() : canMentionEveryone;
}
export function hasOpenCodeFence(text: string): boolean {
const fences = text.match(/```/g);
return fences != null && fences.length % 2 === 1;
}
function hasOpenInlineCode(text: string, index: number): boolean {
let openRun = 0;
for (const run of text.slice(text.lastIndexOf('\n', index - 1) + 1, index).matchAll(/`+/g)) {
if (openRun === 0) {
openRun = run[0].length;
} else if (run[0].length === openRun) {
openRun = 0;
}
}
return openRun > 0;
}
export function findTypedSpecialMention(
text: string,
from: number,
to: number,
caret: number | null,
): TypedSpecialMention | null {
const pattern = new RegExp(SPECIAL_MENTION_PATTERN.source, 'g');
pattern.lastIndex = from;
let urlSpans: Array<TextSpan> | null = null;
let match = pattern.exec(text);
while (match != null && match.index + match[0].length <= to) {
const start = match.index;
const end = start + match[0].length;
const previous = text.charAt(start - 1);
if (
end !== caret &&
previous !== '\\' &&
!WORD_CHARACTER.test(previous) &&
!WORD_CHARACTER.test(text.charAt(end)) &&
!hasOpenCodeFence(text.slice(0, start)) &&
!hasOpenInlineCode(text, start)
) {
if (urlSpans === null) {
urlSpans = findUrlSpans(text);
}
if (!isInsideSpan(urlSpans, start)) {
return {start, end, kind: match[0] as SpecialMentionKind};
}
}
match = pattern.exec(text);
}
return null;
}
@@ -39,6 +39,11 @@ import {applyComposerReplacement, createComposerEmojiPayload} from '@app/feature
import type {ComposerInsertPayload} from '@app/features/lexical/composer/composerOffsets'; import type {ComposerInsertPayload} from '@app/features/lexical/composer/composerOffsets';
import {normalizeSlotAutocompleteQuery as normalizeSlotQuery} from '@app/features/lexical/composer/SlashSlotAutocompleteQuery'; import {normalizeSlotAutocompleteQuery as normalizeSlotQuery} from '@app/features/lexical/composer/SlashSlotAutocompleteQuery';
import type {SlashOptionalContext, SlashSlotAutocompleteContext} from '@app/features/lexical/composer/slashSlots'; import type {SlashOptionalContext, SlashSlotAutocompleteContext} from '@app/features/lexical/composer/slashSlots';
import {
areSpecialMentionsAllowed,
createSpecialMentionPayload,
hasOpenCodeFence,
} from '@app/features/lexical/composer/specialMentions';
import { import {
type GifAutocompleteSearchState, type GifAutocompleteSearchState,
selectAutocompleteGifResults, selectAutocompleteGifResults,
@@ -256,10 +261,7 @@ export function useLexicalAutocomplete({
? `${autocompleteTrigger.type}:${autocompleteTrigger.match.index == null ? -1 : autocompleteTrigger.match.index}:${autocompleteTrigger.match[0]}:${autocompleteTrigger.matchedText}` ? `${autocompleteTrigger.type}:${autocompleteTrigger.match.index == null ? -1 : autocompleteTrigger.match.index}:${autocompleteTrigger.match[0]}:${autocompleteTrigger.matchedText}`
: ''; : '';
const autocompleteQuery = useMemo(() => getAutocompleteQuery(autocompleteTrigger), [autocompleteTrigger]); const autocompleteQuery = useMemo(() => getAutocompleteQuery(autocompleteTrigger), [autocompleteTrigger]);
const hasOpenCodeBlock = useMemo(() => { const hasOpenCodeBlock = useMemo(() => hasOpenCodeFence(textUpToCursor), [textUpToCursor]);
const match = textUpToCursor.match(/```/g);
return match != null && match.length > 0 && match.length % 2 !== 0;
}, [textUpToCursor]);
const memberSearchRank = useMemo(() => buildMemberSearchRank(memberSearchResults), [memberSearchResults]); const memberSearchRank = useMemo(() => buildMemberSearchRank(memberSearchResults), [memberSearchResults]);
const slotMemberSearchRank = useMemo(() => buildMemberSearchRank(slotMemberSearchResults), [slotMemberSearchResults]); const slotMemberSearchRank = useMemo(() => buildMemberSearchRank(slotMemberSearchResults), [slotMemberSearchResults]);
@@ -291,6 +293,12 @@ export function useLexicalAutocomplete({
const canMentionEveryone = const canMentionEveryone =
allowSpecialMentions !== false && channel != null && Permission.can(Permissions.MENTION_EVERYONE, channel); allowSpecialMentions !== false && channel != null && Permission.can(Permissions.MENTION_EVERYONE, channel);
const specialMentionsAllowed = areSpecialMentionsAllowed(
channel,
allowSpecialMentions,
allowedTriggers,
canMentionEveryone,
);
const canUseCommand = useCallback( const canUseCommand = useCallback(
(command: Command) => { (command: Command) => {
if (command.type === 'simple') { if (command.type === 'simple') {
@@ -373,7 +381,7 @@ export function useLexicalAutocomplete({
.map((id) => Users.getUser(id)) .map((id) => Users.getUser(id))
.filter((user): user is User => user != null); .filter((user): user is User => user != null);
const userOptions = filterDMUsers(users, parsedQuery); const userOptions = filterDMUsers(users, parsedQuery);
options = channel.isPersonalNotes() ? userOptions : [...userOptions, ...SPECIAL_MENTIONS]; options = specialMentionsAllowed ? [...userOptions, ...SPECIAL_MENTIONS] : userOptions;
} else { } else {
const recentSpeakers = const recentSpeakers =
matchedText.length === 0 ? buildRecentSpeakerOptions(channel, MENTION_RESULT_LIMIT) : []; matchedText.length === 0 ? buildRecentSpeakerOptions(channel, MENTION_RESULT_LIMIT) : [];
@@ -399,7 +407,7 @@ export function useLexicalAutocomplete({
kind: 'role' as const, kind: 'role' as const,
role, role,
})); }));
const specialMentions = canMentionEveryone const specialMentions = specialMentionsAllowed
? SPECIAL_MENTIONS.filter((mention) => { ? SPECIAL_MENTIONS.filter((mention) => {
if (queryForMatching.length === 0) { if (queryForMatching.length === 0) {
return true; return true;
@@ -538,6 +546,7 @@ export function useLexicalAutocomplete({
memberSearchRank, memberSearchRank,
memberSearchResults, memberSearchResults,
permissionVersion, permissionVersion,
specialMentionsAllowed,
]); ]);
useEffect(() => { useEffect(() => {
@@ -751,6 +760,7 @@ export function useLexicalAutocomplete({
handleSelect, handleSelect,
autocompleteQuery: resolvedAutocompleteQuery, autocompleteQuery: resolvedAutocompleteQuery,
isSlotMenu, isSlotMenu,
specialMentionsAllowed,
}; };
} }
@@ -960,7 +970,7 @@ function optionToPayload(option: AutocompleteOption, channel: Channel | null): C
}; };
} }
if (isSpecialMention(option)) { if (isSpecialMention(option)) {
return {kind: 'mention', mentionType: 'special', id: option.kind, display: option.kind, wire: option.kind}; return createSpecialMentionPayload(option.kind);
} }
if (isChannel(option)) { if (isChannel(option)) {
return { return {