diff --git a/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx b/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx index 94a9ef022..33cec22e0 100644 --- a/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx +++ b/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx @@ -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} diff --git a/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx b/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx index 1f7f56f91..05bf6e5b5 100644 --- a/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx +++ b/fluxer_app/src/features/channel/components/useChannelComposerPaste.tsx @@ -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; }; diff --git a/fluxer_app/src/features/lexical/composer/ComposerMarkdownHighlight.ts b/fluxer_app/src/features/lexical/composer/ComposerMarkdownHighlight.ts index 8e5406891..8be4aa724 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerMarkdownHighlight.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerMarkdownHighlight.ts @@ -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) || diff --git a/fluxer_app/src/features/lexical/composer/ComposerSerialization.ts b/fluxer_app/src/features/lexical/composer/ComposerSerialization.ts index 5d76f8075..ee764bf66 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerSerialization.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerSerialization.ts @@ -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 ); } } diff --git a/fluxer_app/src/features/lexical/composer/ComposerSilentMessagePrefix.test.ts b/fluxer_app/src/features/lexical/composer/ComposerSilentMessagePrefix.test.ts index 7152c478c..f38f18397 100644 --- a/fluxer_app/src/features/lexical/composer/ComposerSilentMessagePrefix.test.ts +++ b/fluxer_app/src/features/lexical/composer/ComposerSilentMessagePrefix.test.ts @@ -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, diff --git a/fluxer_app/src/features/lexical/composer/ComposerSpecialMention.test.ts b/fluxer_app/src/features/lexical/composer/ComposerSpecialMention.test.ts new file mode 100644 index 000000000..3e04d4ae6 --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/ComposerSpecialMention.test.ts @@ -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 = [], + 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(); +} + +function children(editor: LexicalEditor): Array { + 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().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); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/ComposerSpecialMention.ts b/fluxer_app/src/features/lexical/composer/ComposerSpecialMention.ts new file mode 100644 index 000000000..fcf23f1a5 --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/ComposerSpecialMention.ts @@ -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 = []; + 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; + } + } +} diff --git a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx index 0427a332a..be22177db 100644 --- a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx +++ b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx @@ -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), diff --git a/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx b/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx index ecb8390d6..06b3a00c6 100644 --- a/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx +++ b/fluxer_app/src/features/lexical/composer/LexicalRichInput.tsx @@ -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} diff --git a/fluxer_app/src/features/lexical/composer/specialMentions.test.ts b/fluxer_app/src/features/lexical/composer/specialMentions.test.ts new file mode 100644 index 000000000..4313719f4 --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/specialMentions.test.ts @@ -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 { + const kinds: Array = []; + 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): Array { + 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 user@here.com ', 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, Array]>([ + ['@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 user@here.com ', [], ['@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, 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]); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/specialMentions.ts b/fluxer_app/src/features/lexical/composer/specialMentions.ts new file mode 100644 index 000000000..a71769db0 --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/specialMentions.ts @@ -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 | null, + allowSpecialMentions: boolean | undefined, + allowedTriggers: ReadonlyArray | 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 | 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; +} diff --git a/fluxer_app/src/features/lexical/composer/useLexicalAutocomplete.ts b/fluxer_app/src/features/lexical/composer/useLexicalAutocomplete.ts index ff3c2e1b0..18bdb4fc8 100644 --- a/fluxer_app/src/features/lexical/composer/useLexicalAutocomplete.ts +++ b/fluxer_app/src/features/lexical/composer/useLexicalAutocomplete.ts @@ -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 {