mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): format typed @everyone and @here in the composer (#2672)
This commit is contained in:
1 parent
395ec1d60f
commit
84aa8880f5
12 files changed
+1131
-25
No files matched your search
@@ -497,6 +497,7 @@ export const LexicalChannelTextareaContent = observer(
|
||||
isSlotMenu,
|
||||
onCursorMove,
|
||||
handleSelect,
|
||||
specialMentionsAllowed,
|
||||
} = useLexicalAutocomplete({
|
||||
channel,
|
||||
handleRef,
|
||||
@@ -1348,6 +1349,7 @@ export const LexicalChannelTextareaContent = observer(
|
||||
initialSegments={initialDraftRef.current.segments}
|
||||
slotResolvers={slotResolvers}
|
||||
emojiShortcodeResolver={composerEmojiResolver}
|
||||
specialMentionsAllowed={specialMentionsAllowed}
|
||||
channelId={channel.id}
|
||||
guildId={channel.guildId}
|
||||
submitOnEnter={!mobileLayout.enabled}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import ContextMenuState from '@app/features/ui/state/ContextMenu';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
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 {useCallback, useEffect} from 'react';
|
||||
|
||||
@@ -233,6 +233,7 @@ export function useChannelComposerPaste({
|
||||
if (!insertPastedText(pastedText)) {
|
||||
return false;
|
||||
}
|
||||
$addUpdateTag(PASTE_TAG);
|
||||
event.preventDefault();
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -272,7 +272,7 @@ function mentionPresentation(format: MarkdownHlFormat): ComposerMentionPresentat
|
||||
return presentation;
|
||||
}
|
||||
|
||||
function $nodeWireText(node: LexicalNode): string {
|
||||
export function $nodeWireText(node: LexicalNode): string {
|
||||
if (
|
||||
$isComposerMentionNode(node) ||
|
||||
$isComposerCustomEmojiNode(node) ||
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
parseSlashSlotStateSegment,
|
||||
parseSlashSlotStateSegmentId,
|
||||
} from '@app/features/lexical/composer/SlashSlotPersistence';
|
||||
import {isSpecialMentionKind} from '@app/features/lexical/composer/specialMentions';
|
||||
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
|
||||
import {
|
||||
$createLineBreakNode,
|
||||
@@ -92,9 +93,7 @@ function isValidSegmentWire(segment: MentionSegment): boolean {
|
||||
}
|
||||
case 'special':
|
||||
return (
|
||||
(segment.id === 'everyone' || segment.id === 'here') &&
|
||||
segment.actualText === `@${segment.id}` &&
|
||||
segment.displayText === segment.actualText
|
||||
isSpecialMentionKind(segment.id) && segment.actualText === segment.id && segment.displayText === segment.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ describe('registerComposerMarkdownHighlight with silentMessagePrefix', () => {
|
||||
it('keeps an autocompleted @everyone mention after @silent', () => {
|
||||
const segment: MentionSegment = {
|
||||
type: 'special',
|
||||
id: 'everyone',
|
||||
id: '@everyone',
|
||||
displayText: '@everyone',
|
||||
actualText: '@everyone',
|
||||
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 {$hydrateComposerFromDraft, $projectComposer} from '@app/features/lexical/composer/ComposerSerialization';
|
||||
import {registerComposerSoftWrapDeletion} from '@app/features/lexical/composer/ComposerSoftWrapDeletion';
|
||||
import {
|
||||
COMPOSER_RESCAN_TAG,
|
||||
registerComposerSpecialMention,
|
||||
} from '@app/features/lexical/composer/ComposerSpecialMention';
|
||||
import {
|
||||
type ComposerTypeaheadActiveState,
|
||||
registerComposerTypeaheadModifierGuard,
|
||||
@@ -127,6 +131,7 @@ export interface LexicalComposerInputProps {
|
||||
markdownParserFlags?: number;
|
||||
silentMessagePrefix?: boolean;
|
||||
emojiShortcodeResolver?: ComposerEmojiResolver;
|
||||
specialMentionsAllowed: boolean;
|
||||
channelId?: string;
|
||||
guildId?: string;
|
||||
selectionToolbar?: boolean;
|
||||
@@ -233,6 +238,7 @@ const ComposerInner = ({
|
||||
markdownParserFlags,
|
||||
silentMessagePrefix = false,
|
||||
emojiShortcodeResolver,
|
||||
specialMentionsAllowed,
|
||||
selectionToolbar = true,
|
||||
submitOnEnter = true,
|
||||
focusRingTarget,
|
||||
@@ -481,7 +487,7 @@ const ComposerInner = ({
|
||||
$hydrateComposerFromDraft(display, segments, plainTextRef.current);
|
||||
$selectComposerOffset(display.length);
|
||||
},
|
||||
{discrete: true},
|
||||
{discrete: true, tag: COMPOSER_RESCAN_TAG},
|
||||
);
|
||||
resetComposerHistory(editor);
|
||||
},
|
||||
@@ -547,6 +553,11 @@ const ComposerInner = ({
|
||||
return mergeRegister(...cleanups);
|
||||
}, [editor, markdown, markdownParserFlags, plainText, silentMessagePrefix]);
|
||||
|
||||
useLayoutEffect(
|
||||
() => registerComposerSpecialMention(editor, specialMentionsAllowed, plainText),
|
||||
[editor, plainText, specialMentionsAllowed],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
registerContextMenuUndoRedo(editor),
|
||||
|
||||
@@ -128,17 +128,24 @@ export const LexicalRichInput = ({
|
||||
[onChange, previousValueRef, rememberSegmentsForValue, segmentManagerRef],
|
||||
);
|
||||
|
||||
const {autocompleteQuery, autocompleteOptions, autocompleteType, isSlotMenu, onCursorMove, handleSelect} =
|
||||
useLexicalAutocomplete({
|
||||
channel,
|
||||
handleRef,
|
||||
allowedTriggers: allowedTriggers ?? (channel == null ? SAFE_CONTEXT_FREE_TRIGGERS : SAFE_CHANNEL_TRIGGERS),
|
||||
allowSpecialMentions,
|
||||
allowMediaOptions: false,
|
||||
maxActualLength: maxLength,
|
||||
onExceedMaxLength,
|
||||
i18n,
|
||||
});
|
||||
const {
|
||||
autocompleteQuery,
|
||||
autocompleteOptions,
|
||||
autocompleteType,
|
||||
isSlotMenu,
|
||||
onCursorMove,
|
||||
handleSelect,
|
||||
specialMentionsAllowed,
|
||||
} = useLexicalAutocomplete({
|
||||
channel,
|
||||
handleRef,
|
||||
allowedTriggers: allowedTriggers ?? (channel == null ? SAFE_CONTEXT_FREE_TRIGGERS : SAFE_CHANNEL_TRIGGERS),
|
||||
allowSpecialMentions,
|
||||
allowMediaOptions: false,
|
||||
maxActualLength: maxLength,
|
||||
onExceedMaxLength,
|
||||
i18n,
|
||||
});
|
||||
|
||||
const insertEmoji = useCallback(
|
||||
(emoji: FlatEmoji) =>
|
||||
@@ -224,6 +231,7 @@ export const LexicalRichInput = ({
|
||||
markdown={markdown}
|
||||
markdownParserFlags={markdownParserFlags}
|
||||
emojiShortcodeResolver={emojiShortcodeResolver}
|
||||
specialMentionsAllowed={specialMentionsAllowed}
|
||||
channelId={channel == null ? undefined : channel.id}
|
||||
guildId={channel == null ? undefined : channel.guildId}
|
||||
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 {normalizeSlotAutocompleteQuery as normalizeSlotQuery} from '@app/features/lexical/composer/SlashSlotAutocompleteQuery';
|
||||
import type {SlashOptionalContext, SlashSlotAutocompleteContext} from '@app/features/lexical/composer/slashSlots';
|
||||
import {
|
||||
areSpecialMentionsAllowed,
|
||||
createSpecialMentionPayload,
|
||||
hasOpenCodeFence,
|
||||
} from '@app/features/lexical/composer/specialMentions';
|
||||
import {
|
||||
type GifAutocompleteSearchState,
|
||||
selectAutocompleteGifResults,
|
||||
@@ -256,10 +261,7 @@ export function useLexicalAutocomplete({
|
||||
? `${autocompleteTrigger.type}:${autocompleteTrigger.match.index == null ? -1 : autocompleteTrigger.match.index}:${autocompleteTrigger.match[0]}:${autocompleteTrigger.matchedText}`
|
||||
: '';
|
||||
const autocompleteQuery = useMemo(() => getAutocompleteQuery(autocompleteTrigger), [autocompleteTrigger]);
|
||||
const hasOpenCodeBlock = useMemo(() => {
|
||||
const match = textUpToCursor.match(/```/g);
|
||||
return match != null && match.length > 0 && match.length % 2 !== 0;
|
||||
}, [textUpToCursor]);
|
||||
const hasOpenCodeBlock = useMemo(() => hasOpenCodeFence(textUpToCursor), [textUpToCursor]);
|
||||
|
||||
const memberSearchRank = useMemo(() => buildMemberSearchRank(memberSearchResults), [memberSearchResults]);
|
||||
const slotMemberSearchRank = useMemo(() => buildMemberSearchRank(slotMemberSearchResults), [slotMemberSearchResults]);
|
||||
@@ -291,6 +293,12 @@ export function useLexicalAutocomplete({
|
||||
|
||||
const canMentionEveryone =
|
||||
allowSpecialMentions !== false && channel != null && Permission.can(Permissions.MENTION_EVERYONE, channel);
|
||||
const specialMentionsAllowed = areSpecialMentionsAllowed(
|
||||
channel,
|
||||
allowSpecialMentions,
|
||||
allowedTriggers,
|
||||
canMentionEveryone,
|
||||
);
|
||||
const canUseCommand = useCallback(
|
||||
(command: Command) => {
|
||||
if (command.type === 'simple') {
|
||||
@@ -373,7 +381,7 @@ export function useLexicalAutocomplete({
|
||||
.map((id) => Users.getUser(id))
|
||||
.filter((user): user is User => user != null);
|
||||
const userOptions = filterDMUsers(users, parsedQuery);
|
||||
options = channel.isPersonalNotes() ? userOptions : [...userOptions, ...SPECIAL_MENTIONS];
|
||||
options = specialMentionsAllowed ? [...userOptions, ...SPECIAL_MENTIONS] : userOptions;
|
||||
} else {
|
||||
const recentSpeakers =
|
||||
matchedText.length === 0 ? buildRecentSpeakerOptions(channel, MENTION_RESULT_LIMIT) : [];
|
||||
@@ -399,7 +407,7 @@ export function useLexicalAutocomplete({
|
||||
kind: 'role' as const,
|
||||
role,
|
||||
}));
|
||||
const specialMentions = canMentionEveryone
|
||||
const specialMentions = specialMentionsAllowed
|
||||
? SPECIAL_MENTIONS.filter((mention) => {
|
||||
if (queryForMatching.length === 0) {
|
||||
return true;
|
||||
@@ -538,6 +546,7 @@ export function useLexicalAutocomplete({
|
||||
memberSearchRank,
|
||||
memberSearchResults,
|
||||
permissionVersion,
|
||||
specialMentionsAllowed,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -751,6 +760,7 @@ export function useLexicalAutocomplete({
|
||||
handleSelect,
|
||||
autocompleteQuery: resolvedAutocompleteQuery,
|
||||
isSlotMenu,
|
||||
specialMentionsAllowed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -960,7 +970,7 @@ function optionToPayload(option: AutocompleteOption, channel: Channel | null): C
|
||||
};
|
||||
}
|
||||
if (isSpecialMention(option)) {
|
||||
return {kind: 'mention', mentionType: 'special', id: option.kind, display: option.kind, wire: option.kind};
|
||||
return createSpecialMentionPayload(option.kind);
|
||||
}
|
||||
if (isChannel(option)) {
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user