diff --git a/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx b/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx index fa9b4835c..94a9ef022 100644 --- a/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx +++ b/fluxer_app/src/features/channel/components/LexicalChannelTextareaContent.tsx @@ -92,7 +92,7 @@ import {CloudUpload} from '@app/features/messaging/upload/CloudUpload'; import {canAttachFilesInChannel} from '@app/features/messaging/utils/AttachmentPermissionUtils'; import {openFilePicker} from '@app/features/messaging/utils/FilePickerUtils'; import * as FileUploadUtils from '@app/features/messaging/utils/FileUploadUtils'; -import {hasVisibleMessageContent} from '@app/features/messaging/utils/MessageRequestUtils'; +import {getComposerMessageContent, hasVisibleMessageContent} from '@app/features/messaging/utils/MessageRequestUtils'; import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager'; import { resolveTypedEmojiShortcodes, @@ -267,6 +267,7 @@ export const LexicalChannelTextareaContent = observer( const referencedMessage = MessageReply.getReferencedMessage(channel.id); const editingMessage = editingMobileMessageId ? Messages.getMessage(channel.id, editingMobileMessageId) : null; const editingMessageForComposer = editingMessage === undefined ? null : editingMessage; + const isEditingMessageOnMobile = editingMessageForComposer !== null && mobileLayout.enabled; const maxMessageLength = Limits.getMaxMessageLength(); const premiumMaxLength = Limits.getStockValue('max_message_length', maxMessageLength); const maxAttachments = Limits.getMaxAttachmentsPerMessage(); @@ -526,7 +527,11 @@ export const LexicalChannelTextareaContent = observer( () => resolveTypedEmojiContent(wireValue.trim()), [resolveTypedEmojiContent, wireValue], ); - const hasMessageContent = useMemo(() => hasVisibleMessageContent(trimmedMessageContent), [trimmedMessageContent]); + const composerMessageContent = useMemo( + () => getComposerMessageContent(trimmedMessageContent, isEditingMessageOnMobile), + [isEditingMessageOnMobile, trimmedMessageContent], + ); + const hasMessageContent = useMemo(() => hasVisibleMessageContent(composerMessageContent), [composerMessageContent]); const isSubmissionBlockedBySlowmode = useMemo(() => { if (!isSlowmodeActive || isEditingMessageInComposer) { return false; @@ -708,7 +713,7 @@ export const LexicalChannelTextareaContent = observer( }, [channel.id, hasAttachments, hasPendingSticker]); const showAttachments = hasAttachments; const showStickers = hasPendingSticker; - const isOverCharacterLimit = trimmedMessageContent.length > maxMessageLength; + const isOverCharacterLimit = composerMessageContent.length > maxMessageLength; const canSubmit = !textareaInputDisabled && !isSubmissionBlockedBySlowmode && @@ -1346,6 +1351,7 @@ export const LexicalChannelTextareaContent = observer( channelId={channel.id} guildId={channel.guildId} submitOnEnter={!mobileLayout.enabled} + silentMessagePrefix={!isEditingMessageOnMobile} focusRingTarget={containerRef} focusRingEnabled={!textareaInputDisabled && Accessibility.showTextareaFocusRing} className={lexicalStyles.composerEditable} @@ -1406,7 +1412,7 @@ export const LexicalChannelTextareaContent = observer( styles.inputSection, )} = [ {bit: MarkdownHl.subtext, style: 'font-size:0.85em;color:var(--text-muted,var(--text-secondary))'}, {bit: MarkdownHl.link, style: 'color:var(--text-link)'}, {bit: MarkdownHl.codeBlock, style: 'font-size:0.75em'}, + {bit: MarkdownHl.silent, style: 'color:var(--markup-mention-text);font-weight:500'}, ]; const LEXICAL_TEXT_FORMATS: ReadonlyArray<{bit: number; type: TextFormatType}> = [ @@ -77,14 +79,18 @@ function applyMarkdownFormat(node: TextNode, format: MarkdownHlFormat): void { } } -export function registerComposerMarkdownHighlight(editor: LexicalEditor, parserFlags?: number): () => void { +export function registerComposerMarkdownHighlight( + editor: LexicalEditor, + parserFlags?: number, + silentMessagePrefix = false, +): () => void { return editor.registerNodeTransform(RootNode, (root) => { if (editor.isComposing()) { return; } for (const child of root.getChildren()) { if (child instanceof ParagraphNode) { - $reconcileParagraph(child, parserFlags); + $reconcileParagraph(child, parserFlags, silentMessagePrefix && child.is(root.getFirstChild())); } } }); @@ -105,7 +111,7 @@ export function $reconcileLineOf(node: TextNode, parserFlags?: number): void { $reconcileParagraph(parent as ParagraphNode, parserFlags); } -function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number): void { +function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number, silentMessagePrefix = false): void { const lines: Array> = []; let line: Array = []; for (const child of paragraph.getChildren()) { @@ -119,7 +125,8 @@ function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number): vo lines.push(line); const lineSources = lines.map((nodes) => nodes.map($nodeWireText).join('')); const source = lineSources.join('\n'); - const spans = computeMarkdownHighlightSpans(source, parserFlags); + const markdownSpans = computeMarkdownHighlightSpans(source, parserFlags); + const spans = silentMessagePrefix ? markSilentMessagePrefix(markdownSpans, source) : markdownSpans; let lineStart = 0; for (let index = 0; index < lines.length; index += 1) { const nodes = lines[index]!; diff --git a/fluxer_app/src/features/lexical/composer/ComposerSilentMessagePrefix.test.ts b/fluxer_app/src/features/lexical/composer/ComposerSilentMessagePrefix.test.ts new file mode 100644 index 000000000..7152c478c --- /dev/null +++ b/fluxer_app/src/features/lexical/composer/ComposerSilentMessagePrefix.test.ts @@ -0,0 +1,306 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {registerComposerMarkdownHighlight} from '@app/features/lexical/composer/ComposerMarkdownHighlight'; +import {$hydrateComposerFromDraft, $projectComposer} from '@app/features/lexical/composer/ComposerSerialization'; +import {$getComposerSelectionRange, $selectComposerRange} from '@app/features/lexical/composer/composerOffsets'; +import { + computeMarkdownHighlightSpans, + DEFAULT_COMPOSER_MARKDOWN_FLAGS, + MarkdownHl, + markSilentMessagePrefix, +} from '@app/features/lexical/composer/markdownSpans'; +import { + $isComposerMentionNode, + ComposerMentionNode, + ComposerMentionPresentation, +} from '@app/features/lexical/composer/nodes/ComposerMentionNode'; +import {SyntaxMarkerNode} from '@app/features/lexical/composer/nodes/SyntaxMarkerNode'; +import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isElementNode, + $isTextNode, + createEditor, + type ElementNode, + type LexicalEditor, + type RangeSelection, +} 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 SILENT_STYLE = 'color:var(--markup-mention-text);font-weight:500'; + +function createComposer(silentMessagePrefix: boolean): {editor: LexicalEditor; unregister: () => void} { + const editor = createEditor({ + namespace: 'test', + nodes: [ComposerMentionNode, SyntaxMarkerNode], + onError: (error) => { + throw error; + }, + }); + const unregister = registerComposerMarkdownHighlight(editor, DEFAULT_COMPOSER_MARKDOWN_FLAGS, silentMessagePrefix); + return {editor, unregister}; +} + +function hydrate(editor: LexicalEditor, display: string, segments: Array = []): void { + editor.update(() => $hydrateComposerFromDraft(display, segments), {discrete: true}); +} + +function edit(editor: LexicalEditor, start: number, end: number, text: string): void { + editor.update( + () => { + $selectComposerRange(start, end); + ($getSelection() as RangeSelection).insertText(text); + }, + {discrete: true}, + ); +} + +function readLines(editor: LexicalEditor): Array> { + return editor.getEditorState().read( + () => + $getRoot() + .getChildren() + .map((block) => + $isElementNode(block) + ? block + .getChildren() + .map((node) => ($isTextNode(node) ? [node.getTextContent(), node.getStyle()] : node.getType())) + : [], + ), + {editor}, + ); +} + +function readSelection(editor: LexicalEditor): {start: number; end: number} | null { + return editor.getEditorState().read(() => $getComposerSelectionRange(), {editor}); +} + +describe('markSilentMessagePrefix', () => { + it('marks @silent at the start of the message', () => { + const source = '@silent hello'; + expect(markSilentMessagePrefix(computeMarkdownHighlightSpans(source), source)).toEqual([ + {start: 0, end: 7, role: 'content', format: MarkdownHl.silent}, + {start: 7, end: 13, role: 'content', format: MarkdownHl.none}, + ]); + }); + + it('marks @silent on its own', () => { + const source = '@silent'; + expect(markSilentMessagePrefix(computeMarkdownHighlightSpans(source), source)).toEqual([ + {start: 0, end: 7, role: 'content', format: MarkdownHl.silent}, + ]); + }); + + it('marks @silent after leading whitespace', () => { + const source = ' @silent hi'; + expect(markSilentMessagePrefix(computeMarkdownHighlightSpans(source), source)).toEqual([ + {start: 0, end: 2, role: 'content', format: MarkdownHl.none}, + {start: 2, end: 9, role: 'content', format: MarkdownHl.silent}, + {start: 9, end: 12, role: 'content', format: MarkdownHl.none}, + ]); + }); + + it('marks @silent before a line break', () => { + const source = '@silent\nhello'; + expect(markSilentMessagePrefix(computeMarkdownHighlightSpans(source), source)).toEqual([ + {start: 0, end: 7, role: 'content', format: MarkdownHl.silent}, + {start: 7, end: 13, role: 'content', format: MarkdownHl.none}, + ]); + }); + + it('keeps markdown formats after @silent', () => { + const source = '@silent **hi**'; + expect(markSilentMessagePrefix(computeMarkdownHighlightSpans(source), source)).toEqual([ + {start: 0, end: 7, role: 'content', format: MarkdownHl.silent}, + {start: 7, end: 8, role: 'content', format: MarkdownHl.none}, + {start: 8, end: 10, role: 'marker', format: MarkdownHl.bold}, + {start: 10, end: 12, role: 'content', format: MarkdownHl.bold}, + {start: 12, end: 14, role: 'marker', format: MarkdownHl.bold}, + ]); + }); + + it('adds the silent bit to every span the token overlaps', () => { + expect( + markSilentMessagePrefix( + [ + {start: 0, end: 3, role: 'content', format: MarkdownHl.none}, + {start: 3, end: 10, role: 'content', format: MarkdownHl.bold}, + ], + '@silent hi', + ), + ).toEqual([ + {start: 0, end: 3, role: 'content', format: MarkdownHl.silent}, + {start: 3, end: 7, role: 'content', format: MarkdownHl.bold | MarkdownHl.silent}, + {start: 7, end: 10, role: 'content', format: MarkdownHl.bold}, + ]); + }); + + it.each(['@silently hello', '@Silent hello', 'hello @silent', ''])('returns the spans unchanged for %j', (source) => { + const spans = computeMarkdownHighlightSpans(source); + expect(markSilentMessagePrefix(spans, source)).toBe(spans); + }); +}); + +describe('registerComposerMarkdownHighlight with silentMessagePrefix', () => { + it('styles @silent at the start of the message without changing what is sent', () => { + const {editor} = createComposer(true); + hydrate(editor, '@silent hello'); + expect(readLines(editor)).toEqual([ + [ + ['@silent', SILENT_STYLE], + [' hello', ''], + ], + ]); + expect(editor.getEditorState().read(() => $projectComposer(), {editor})).toEqual({ + display: '@silent hello', + segments: [], + wire: '@silent hello', + }); + }); + + it('styles @silent on its own', () => { + const {editor} = createComposer(true); + hydrate(editor, '@silent'); + expect(readLines(editor)).toEqual([[['@silent', SILENT_STYLE]]]); + }); + + it('styles @silent before a line break', () => { + const {editor} = createComposer(true); + hydrate(editor, '@silent\nhello'); + expect(readLines(editor)).toEqual([[['@silent', SILENT_STYLE], 'linebreak', ['hello', '']]]); + }); + + it('styles @silent after leading whitespace', () => { + const {editor} = createComposer(true); + hydrate(editor, ' @silent hello'); + expect(readLines(editor)).toEqual([ + [ + [' ', ''], + ['@silent', SILENT_STYLE], + [' hello', ''], + ], + ]); + }); + + it('styles @silent after a leading line break', () => { + const {editor} = createComposer(true); + hydrate(editor, '\n@silent hello'); + expect(readLines(editor)).toEqual([['linebreak', ['@silent', SILENT_STYLE], [' hello', '']]]); + }); + + it('leaves @silent unstyled when the prefix is off', () => { + const {editor} = createComposer(false); + hydrate(editor, '@silent hello'); + expect(readLines(editor)).toEqual([[['@silent hello', '']]]); + }); + + it('leaves @silent unstyled when it is not at the start', () => { + const {editor} = createComposer(true); + hydrate(editor, 'hello @silent'); + expect(readLines(editor)).toEqual([[['hello @silent', '']]]); + }); + + it('styles only the first paragraph', () => { + const {editor} = createComposer(true); + editor.update( + () => { + $getRoot() + .clear() + .append( + $createParagraphNode().append($createTextNode('@silent hi')), + $createParagraphNode().append($createTextNode('@silent hi')), + ); + }, + {discrete: true}, + ); + expect(readLines(editor)).toEqual([ + [ + ['@silent', SILENT_STYLE], + [' hi', ''], + ], + [['@silent hi', '']], + ]); + }); + + it('restyles the token as it is typed, broken and repaired', () => { + const {editor} = createComposer(true); + hydrate(editor, '@silen'); + edit(editor, 6, 6, 't'); + expect(readLines(editor)).toEqual([[['@silent', SILENT_STYLE]]]); + expect(readSelection(editor)).toEqual({start: 7, end: 7}); + edit(editor, 7, 7, 'l'); + expect(readLines(editor)).toEqual([[['@silentl', '']]]); + expect(readSelection(editor)).toEqual({start: 8, end: 8}); + edit(editor, 7, 8, ''); + expect(readLines(editor)).toEqual([[['@silent', SILENT_STYLE]]]); + expect(readSelection(editor)).toEqual({start: 7, end: 7}); + }); + + it('styles @silent typed in front of existing text', () => { + const {editor} = createComposer(true); + hydrate(editor, 'hello'); + edit(editor, 0, 0, '@silent '); + expect(readLines(editor)).toEqual([ + [ + ['@silent', SILENT_STYLE], + [' hello', ''], + ], + ]); + expect(readSelection(editor)).toEqual({start: 8, end: 8}); + }); + + it('keeps an autocompleted @everyone mention after @silent', () => { + const segment: MentionSegment = { + type: 'special', + id: 'everyone', + displayText: '@everyone', + actualText: '@everyone', + start: 8, + end: 17, + }; + const {editor} = createComposer(true); + hydrate(editor, '@silent @everyone', [segment]); + expect(readLines(editor)).toEqual([[['@silent', SILENT_STYLE], [' ', ''], 'composer-mention']]); + expect(editor.getEditorState().read(() => $projectComposer(), {editor})).toEqual({ + display: '@silent @everyone', + segments: [segment], + wire: '@silent @everyone', + }); + }); + + it('keeps a user mention after @silent as a plain pill', () => { + const {editor} = createComposer(true); + hydrate(editor, '@silent @name', [ + {type: 'user', id: '123', displayText: '@name', actualText: '<@123>', start: 8, end: 13}, + ]); + expect(readLines(editor)).toEqual([[['@silent', SILENT_STYLE], [' ', ''], 'composer-mention']]); + const mention = editor.getEditorState().read(() => { + const pill = $getRoot().getFirstChildOrThrow().getLastChild(); + return $isComposerMentionNode(pill) ? {literal: pill.isLiteral(), presentation: pill.getPresentation()} : null; + }); + expect(mention).toEqual({literal: false, presentation: ComposerMentionPresentation.none}); + }); + + it('removes the style when the prefix is switched off', () => { + const {editor, unregister} = createComposer(true); + hydrate(editor, '@silent hello'); + unregister(); + registerComposerMarkdownHighlight(editor, DEFAULT_COMPOSER_MARKDOWN_FLAGS, false); + editor.update( + () => { + for (const node of $getRoot().getAllTextNodes()) { + node.markDirty(); + } + }, + {discrete: true}, + ); + expect(readLines(editor)).toEqual([[['@silent hello', '']]]); + }); +}); diff --git a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx index b47a4aff5..0427a332a 100644 --- a/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx +++ b/fluxer_app/src/features/lexical/composer/LexicalComposerInput.tsx @@ -125,6 +125,7 @@ export interface LexicalComposerInputProps { slotResolvers?: SlashSlotResolvers; markdown?: boolean; markdownParserFlags?: number; + silentMessagePrefix?: boolean; emojiShortcodeResolver?: ComposerEmojiResolver; channelId?: string; guildId?: string; @@ -230,6 +231,7 @@ const ComposerInner = ({ slotResolvers, markdown = true, markdownParserFlags, + silentMessagePrefix = false, emojiShortcodeResolver, selectionToolbar = true, submitOnEnter = true, @@ -509,7 +511,7 @@ const ComposerInner = ({ cleanups.push(registerSlashSlotPlugin(editor, () => slotResolversRef.current, typeaheadActiveState)); cleanups.push(registerSlashSlotFocus(editor, () => onSlashCommandStateChangeRef.current)); if (markdown) { - cleanups.push(registerComposerMarkdownHighlight(editor, markdownParserFlags)); + cleanups.push(registerComposerMarkdownHighlight(editor, markdownParserFlags, silentMessagePrefix)); } cleanups.push( registerComposerEmojiShortcode(editor, (shortcodeName) => { @@ -543,7 +545,7 @@ const ComposerInner = ({ {discrete: true, tag: HISTORY_MERGE_TAG}, ); return mergeRegister(...cleanups); - }, [editor, markdown, markdownParserFlags, plainText]); + }, [editor, markdown, markdownParserFlags, plainText, silentMessagePrefix]); useEffect(() => { return mergeRegister( diff --git a/fluxer_app/src/features/lexical/composer/markdownSpans.ts b/fluxer_app/src/features/lexical/composer/markdownSpans.ts index 9fac5ed42..cae2663e2 100644 --- a/fluxer_app/src/features/lexical/composer/markdownSpans.ts +++ b/fluxer_app/src/features/lexical/composer/markdownSpans.ts @@ -6,6 +6,7 @@ import {parseMarkdownAstWithWasm} from '@app/features/messaging/utils/markdown/p import type {Node} from '@app/features/messaging/utils/markdown/parser/Nodes'; import {normalizeUrl} from '@app/features/messaging/utils/markdown/parser/UrlUtils'; import {findUrlEnd} from '@app/features/messaging/utils/markdown/UrlSpanUtils'; +import {parseSilentMessagePrefix} from '@app/features/messaging/utils/SilentMessagePrefix'; export const MarkdownHl = { none: 0, @@ -20,6 +21,7 @@ export const MarkdownHl = { subtext: 1 << 8, link: 1 << 9, codeBlock: 1 << 10, + silent: 1 << 11, } as const; export type MarkdownHlFormat = number; @@ -123,6 +125,24 @@ export function computeMarkdownHighlightResult( return {spans: coalesce(spans), recovered}; } +export function markSilentMessagePrefix(spans: Array, source: string): Array { + const prefix = parseSilentMessagePrefix(source); + if (prefix == null) { + return spans; + } + const pieces: Array = []; + for (const span of spans) { + const tokenStart = Math.min(Math.max(prefix.tokenStart, span.start), span.end); + const tokenEnd = Math.min(Math.max(prefix.tokenEnd, span.start), span.end); + pieces.push( + {...span, end: tokenStart}, + {...span, start: tokenStart, end: tokenEnd, format: span.format | MarkdownHl.silent}, + {...span, start: tokenEnd}, + ); + } + return coalesce(pieces); +} + function recoverRange( spans: Array, recovered: Array, diff --git a/fluxer_app/src/features/messaging/hooks/useMessageSubmission.test.tsx b/fluxer_app/src/features/messaging/hooks/useMessageSubmission.test.tsx new file mode 100644 index 000000000..70cece0e2 --- /dev/null +++ b/fluxer_app/src/features/messaging/hooks/useMessageSubmission.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment happy-dom +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Channel} from '@app/features/channel/models/Channel'; +import {type SendMessageFunction, useMessageSubmission} from '@app/features/messaging/hooks/useMessageSubmission'; +import {MessageFlags} from '@fluxer/constants/src/ChannelConstants'; +import {act, createElement} from 'react'; +import {createRoot, type Root} from 'react-dom/client'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; + +const messageCommands = vi.hoisted(() => ({ + reserveSend: vi.fn(() => true), + send: vi.fn(() => Promise.resolve(null)), + createOptimistic: vi.fn(), + stopReply: vi.fn(), +})); +const draftCommands = vi.hoisted(() => ({deleteDraft: vi.fn()})); +const cloudUpload = vi.hoisted(() => ({ + getTextareaAttachments: vi.fn((): Array => []), + claimAttachmentsForMessage: vi.fn(() => []), +})); + +vi.mock('@app/features/messaging/commands/MessageCommands', () => messageCommands); +vi.mock('@app/features/messaging/commands/DraftCommands', () => draftCommands); +vi.mock('@app/features/messaging/upload/CloudUpload', () => ({CloudUpload: cloudUpload})); +vi.mock('@app/features/messaging/models/MessagingMessage', () => ({ + Message: class { + constructor(data: object) { + Object.assign(this, data); + } + toJSON(): object { + return {...this}; + } + }, +})); +vi.mock('@app/features/messaging/state/ChatInputSettings', () => ({default: {convertEmoticons: false}})); +vi.mock('@app/features/user/state/UserSettings', () => ({default: {getSanitizeUrls: () => false}})); +vi.mock('@app/features/messaging/utils/EmoticonConversionUtils', () => ({ + convertEmoticonsToEmoji: (content: string) => content, +})); +vi.mock('@app/features/user/state/Users', () => ({default: {getCurrentUser: () => ({toJSON: () => ({id: 'me'})})}})); +vi.mock('@app/features/permissions/state/Permission', () => ({default: {can: () => true}})); +vi.mock('@app/features/slowmode/state/Slowmode', () => ({default: {getSlowmodeRemaining: () => 0}})); +vi.mock('@app/features/slowmode/commands/SlowmodeCommands', () => ({ + prepareMessageSend: vi.fn(), + recordPendingMessageSend: vi.fn(() => null), + confirmMessageSend: vi.fn(), + discardPendingMessageSend: vi.fn(), +})); +vi.mock('@app/features/slowmode/components/alerts/SlowmodeRateLimitedModal', () => ({ + SlowmodeRateLimitedModal: () => null, +})); +vi.mock('@app/features/ui/commands/ModalCommands', () => ({modal: (render: unknown) => render, push: vi.fn()})); +vi.mock('@app/features/typing/utils/TypingUtils', () => ({TypingUtils: {clear: vi.fn()}})); +vi.mock('@app/features/platform/utils/ComponentBus', () => ({ComponentBus: {dispatch: vi.fn()}})); +vi.mock('@lingui/core/macro', () => ({msg: (value: unknown) => value})); +vi.mock('@lingui/react/macro', () => ({useLingui: () => ({i18n: {_: () => ''}})})); + +const channel = {id: 'c'} as unknown as Channel; + +let host: HTMLDivElement; +let root: Root; + +function renderSendMessage(): SendMessageFunction { + let sendMessage: SendMessageFunction | null = null; + const Probe = () => { + sendMessage = useMessageSubmission({channel, referencedMessage: null, replyingMessage: null}).sendMessage; + return null; + }; + act(() => { + root.render(createElement(Probe)); + }); + return sendMessage!; +} + +beforeEach(() => { + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); +}); + +afterEach(() => { + act(() => { + root.unmount(); + }); + document.body.replaceChildren(); + vi.clearAllMocks(); + cloudUpload.getTextareaAttachments.mockReturnValue([]); + messageCommands.reserveSend.mockReturnValue(true); +}); + +describe('useMessageSubmission', () => { + it('sends nothing and keeps the draft for a message that is only @silent', () => { + const sendMessage = renderSendMessage(); + expect(sendMessage('@silent', false)).toBe(false); + expect(messageCommands.reserveSend).not.toHaveBeenCalled(); + expect(draftCommands.deleteDraft).not.toHaveBeenCalled(); + expect(messageCommands.send).not.toHaveBeenCalled(); + }); + + it('sends nothing and keeps the draft for a /tts command that is only @silent', () => { + const sendMessage = renderSendMessage(); + expect(sendMessage('@silent', false, true)).toBe(false); + expect(messageCommands.reserveSend).not.toHaveBeenCalled(); + expect(draftCommands.deleteDraft).not.toHaveBeenCalled(); + expect(messageCommands.send).not.toHaveBeenCalled(); + }); + + it('sends nothing and keeps the draft for a sticker send that is only @silent', () => { + const sendMessage = renderSendMessage(); + expect(sendMessage('@silent', false, [])).toBe(false); + expect(messageCommands.reserveSend).not.toHaveBeenCalled(); + expect(draftCommands.deleteDraft).not.toHaveBeenCalled(); + }); + + it('sends an attachment captioned only by @silent with no content and the silent flag', () => { + cloudUpload.getTextareaAttachments.mockReturnValue([{id: 1}]); + const sendMessage = renderSendMessage(); + expect(sendMessage('@silent', false)).toBe(true); + expect(draftCommands.deleteDraft).toHaveBeenCalledWith('c'); + expect(messageCommands.send).toHaveBeenCalledWith( + 'c', + expect.objectContaining({content: '', flags: MessageFlags.SUPPRESS_NOTIFICATIONS}), + ); + }); + + it('sends the text after @silent with the silent flag', () => { + const sendMessage = renderSendMessage(); + expect(sendMessage('@silent hi', false)).toBe(true); + expect(draftCommands.deleteDraft).toHaveBeenCalledWith('c'); + expect(messageCommands.send).toHaveBeenCalledWith( + 'c', + expect.objectContaining({content: 'hi', flags: MessageFlags.SUPPRESS_NOTIFICATIONS}), + ); + }); +}); diff --git a/fluxer_app/src/features/messaging/hooks/useMessageSubmission.tsx b/fluxer_app/src/features/messaging/hooks/useMessageSubmission.tsx index 9577a9992..8defb4d06 100644 --- a/fluxer_app/src/features/messaging/hooks/useMessageSubmission.tsx +++ b/fluxer_app/src/features/messaging/hooks/useMessageSubmission.tsx @@ -4,6 +4,8 @@ import type {Channel} from '@app/features/channel/models/Channel'; import * as DraftCommands from '@app/features/messaging/commands/DraftCommands'; import * as MessageCommands from '@app/features/messaging/commands/MessageCommands'; import {Message} from '@app/features/messaging/models/MessagingMessage'; +import {CloudUpload} from '@app/features/messaging/upload/CloudUpload'; +import {canSubmitMessage} from '@app/features/messaging/utils/MessageRequestUtils'; import * as MessageSubmitUtils from '@app/features/messaging/utils/MessageSubmitUtils'; import {formatUploadingAttachmentSummary} from '@app/features/messaging/utils/UploadingAttachmentLabelUtils'; import Permission from '@app/features/permissions/state/Permission'; @@ -91,6 +93,12 @@ export const useMessageSubmission = ({channel, referencedMessage, replyingMessag : undefined; const currentUser = Users.getCurrentUser(); if (!channel || !currentUser) return false; + const hasNonTextContent = + hasAttachments || + stickers.length > 0 || + favoriteMemeId !== undefined || + CloudUpload.getTextareaAttachments(channel.id).length > 0; + if (!canSubmitMessage(content, hasNonTextContent)) return false; if (isBlockedBySlowmode(channel)) return false; const nonce = SnowflakeUtils.fromTimestamp(Date.now()); if (!MessageCommands.reserveSend(channel.id, nonce)) return false; diff --git a/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts b/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts index 743bd5d42..e5ed06f30 100644 --- a/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts +++ b/fluxer_app/src/features/messaging/hooks/useTextareaSubmit.ts @@ -26,7 +26,7 @@ import { buildExistingAttachmentEditReferences, canSubmitEmptyMessageEdit, } from '@app/features/messaging/utils/MessageEditContentUtils'; -import {hasVisibleMessageContent} from '@app/features/messaging/utils/MessageRequestUtils'; +import {canSubmitMessage, hasVisibleMessageContent} from '@app/features/messaging/utils/MessageRequestUtils'; import * as ReplaceCommandUtils from '@app/features/messaging/utils/ReplaceCommandUtils'; import {resolveTypedEmojiShortcodes} from '@app/features/messaging/utils/TypedEmojiShortcodeUtils'; import Permission from '@app/features/permissions/state/Permission'; @@ -513,7 +513,7 @@ export const useTextareaSubmit = ({ ); return; } - if (!hasVisibleMessageContent(resolvedContent) && uploadAttachmentsLength === 0 && !hasPendingSticker) { + if (!canSubmitMessage(resolvedContent, uploadAttachmentsLength > 0 || hasPendingSticker)) { return; } if (replaceCommand) { diff --git a/fluxer_app/src/features/messaging/utils/MessageRequestUtils.test.ts b/fluxer_app/src/features/messaging/utils/MessageRequestUtils.test.ts new file mode 100644 index 000000000..4a2a23815 --- /dev/null +++ b/fluxer_app/src/features/messaging/utils/MessageRequestUtils.test.ts @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { + buildMessageCreateRequest, + buildMessageEditRequest, + canSubmitMessage, + getComposerMessageContent, + hasVisibleMessageContent, + normalizeMessageContent, +} from '@app/features/messaging/utils/MessageRequestUtils'; +import {MessageFlags} from '@fluxer/constants/src/ChannelConstants'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +const chatInputSettings = vi.hoisted(() => ({convertEmoticons: false})); + +vi.mock('@app/features/messaging/state/ChatInputSettings', () => ({default: chatInputSettings})); +vi.mock('@app/features/user/state/UserSettings', () => ({default: {getSanitizeUrls: () => false}})); +vi.mock('@app/features/messaging/utils/EmoticonConversionUtils', () => ({ + convertEmoticonsToEmoji: (content: string) => content.replaceAll(':)', '\u{1F642}'), +})); + +afterEach(() => { + chatInputSettings.convertEmoticons = false; +}); + +describe('normalizeMessageContent', () => { + it('still strips @silent followed by a space and suppresses notifications', () => { + expect(normalizeMessageContent('@silent hello')).toEqual({ + content: 'hello', + flags: MessageFlags.SUPPRESS_NOTIFICATIONS, + }); + }); + + it('strips @silent on its own and suppresses notifications', () => { + expect(normalizeMessageContent('@silent')).toEqual({content: '', flags: MessageFlags.SUPPRESS_NOTIFICATIONS}); + }); + + it.each([ + ['a newline', '\n'], + ['a no-break space', '\u00a0'], + ['a tab', '\t'], + ])('strips @silent followed by %s and suppresses notifications', (_name, whitespace) => { + expect(normalizeMessageContent(`@silent${whitespace}hello`)).toEqual({ + content: 'hello', + flags: MessageFlags.SUPPRESS_NOTIFICATIONS, + }); + }); + + it('strips leading whitespace before @silent', () => { + expect(normalizeMessageContent(' @silent hello')).toEqual({ + content: 'hello', + flags: MessageFlags.SUPPRESS_NOTIFICATIONS, + }); + }); + + it('still strips only one whitespace character and one @silent', () => { + expect(normalizeMessageContent('@silent hello')).toEqual({ + content: ' hello', + flags: MessageFlags.SUPPRESS_NOTIFICATIONS, + }); + expect(normalizeMessageContent('@silent @silent hello')).toEqual({ + content: '@silent hello', + flags: MessageFlags.SUPPRESS_NOTIFICATIONS, + }); + }); + + it('still drops invisible content left after @silent', () => { + expect(normalizeMessageContent('@silent \u200b')).toEqual({ + content: '', + flags: MessageFlags.SUPPRESS_NOTIFICATIONS, + }); + }); + + it.each([ + '@silently hello', + '@Silent hello', + 'hello @silent', + 'hello', + ])('still leaves %j as it is with no flags', (content) => { + expect(normalizeMessageContent(content)).toEqual({content, flags: 0}); + }); + + it('still converts emoticons after stripping @silent', () => { + chatInputSettings.convertEmoticons = true; + expect(normalizeMessageContent('@silent :)')).toEqual({ + content: '\u{1F642}', + flags: MessageFlags.SUPPRESS_NOTIFICATIONS, + }); + }); +}); + +describe('canSubmitMessage', () => { + it('rejects a message that is only @silent', () => { + expect(canSubmitMessage('@silent', false)).toBe(false); + expect(canSubmitMessage('@silent\n', false)).toBe(false); + expect(canSubmitMessage('@silent \u200b', false)).toBe(false); + }); + + it('rejects blank content with nothing attached', () => { + expect(canSubmitMessage(' ', false)).toBe(false); + }); + + it('accepts @silent with attachments, a sticker or a favourite meme', () => { + expect(canSubmitMessage('@silent', true)).toBe(true); + }); + + it('accepts visible content with or without @silent', () => { + expect(canSubmitMessage('@silent hi', false)).toBe(true); + expect(canSubmitMessage('hello', false)).toBe(true); + }); +}); + +describe('getComposerMessageContent', () => { + it('measures the content that will be sent when not editing on mobile', () => { + expect(getComposerMessageContent('@silent', false)).toBe(''); + expect(getComposerMessageContent('@silent hello', false)).toBe('hello'); + expect(hasVisibleMessageContent(getComposerMessageContent('@silent', false))).toBe(false); + }); + + it('strips @silent without running the outgoing conversions', () => { + chatInputSettings.convertEmoticons = true; + expect(getComposerMessageContent('@silent :)', false)).toBe(':)'); + }); + + it('keeps @silent as text when editing on mobile', () => { + expect(getComposerMessageContent('@silent', true)).toBe('@silent'); + expect(getComposerMessageContent('@silent hello', true)).toBe('@silent hello'); + expect(hasVisibleMessageContent(getComposerMessageContent('@silent', true))).toBe(true); + }); +}); + +describe('buildMessageCreateRequest', () => { + it('still omits empty content but keeps the silent flag for an attachment-only message', () => { + const attachment = {id: '1', filename: 'a.png', title: 'a.png'}; + expect( + buildMessageCreateRequest({ + content: '', + nonce: 'n', + attachments: [attachment], + flags: MessageFlags.SUPPRESS_NOTIFICATIONS, + }), + ).toEqual({nonce: 'n', attachments: [attachment], flags: MessageFlags.SUPPRESS_NOTIFICATIONS}); + }); + + it('still sends a /tts message after @silent as silent text-to-speech', () => { + const {content, flags} = normalizeMessageContent('@silent hi'); + expect(buildMessageCreateRequest({content, nonce: 'n', flags, tts: true})).toEqual({ + content: 'hi', + nonce: 'n', + flags: MessageFlags.SUPPRESS_NOTIFICATIONS, + tts: true, + }); + }); +}); + +describe('buildMessageEditRequest', () => { + it('still sends @silent verbatim in an edit with no flags', () => { + expect(buildMessageEditRequest({content: '@silent hello'})).toEqual({content: '@silent hello'}); + expect(buildMessageEditRequest({content: '@silent'})).toEqual({content: '@silent'}); + }); +}); diff --git a/fluxer_app/src/features/messaging/utils/MessageRequestUtils.ts b/fluxer_app/src/features/messaging/utils/MessageRequestUtils.ts index 1432d05ea..61e1e0cd0 100644 --- a/fluxer_app/src/features/messaging/utils/MessageRequestUtils.ts +++ b/fluxer_app/src/features/messaging/utils/MessageRequestUtils.ts @@ -2,6 +2,7 @@ import ChatInputSettings from '@app/features/messaging/state/ChatInputSettings'; import {convertEmoticonsToEmoji} from '@app/features/messaging/utils/EmoticonConversionUtils'; +import {parseSilentMessagePrefix} from '@app/features/messaging/utils/SilentMessagePrefix'; import {maybeSanitizeOutgoingMessage} from '@app/features/messaging/utils/UrlSanitizationUtils'; import {hasVisibleMessageContent} from '@app/features/messaging/utils/VisibleMessageContent'; import {MessageFlags} from '@fluxer/constants/src/ChannelConstants'; @@ -82,14 +83,27 @@ export interface NormalizedMessageContent { } export function normalizeMessageContent(content: string): NormalizedMessageContent { - const withoutSilent = removeSilentFlag(content); + const silentPrefix = parseSilentMessagePrefix(content); + const withoutSilent = silentPrefix == null ? content : content.slice(silentPrefix.end); const converted = applyOutgoingEmoticonConversion(withoutSilent); const sanitized = maybeSanitizeOutgoingMessage(converted); const normalizedContent = hasVisibleMessageContent(sanitized) ? sanitized : ''; - const flags = getMessageFlags(content); + const flags = silentPrefix == null ? 0 : MessageFlags.SUPPRESS_NOTIFICATIONS; return {content: normalizedContent, flags}; } +export function canSubmitMessage(content: string, hasNonTextContent: boolean): boolean { + return hasNonTextContent || normalizeMessageContent(content).content.length > 0; +} + +export function getComposerMessageContent(content: string, isEditingMessageOnMobile: boolean): string { + if (isEditingMessageOnMobile) { + return content; + } + const silentPrefix = parseSilentMessagePrefix(content); + return silentPrefix == null ? content : content.slice(silentPrefix.end); +} + export function normalizeMessageEditContent(content: string): string { return applyOutgoingEmoticonConversion(content); } @@ -146,22 +160,9 @@ export function buildMessageEditRequest(payload: MessageEditPayload): MessageEdi return requestBody; } -const isSilentMessage = (content: string): boolean => { - return content.startsWith('@silent '); -}; -const removeSilentFlag = (content: string): string => { - return content.startsWith('@silent ') ? content.replace('@silent ', '') : content; -}; const applyOutgoingEmoticonConversion = (content: string): string => { return ChatInputSettings.convertEmoticons ? convertEmoticonsToEmoji(content) : content; }; -const getMessageFlags = (content: string): number => { - let flags = 0; - if (isSilentMessage(content)) { - flags |= MessageFlags.SUPPRESS_NOTIFICATIONS; - } - return flags; -}; const shouldIncludeAllowedMentions = (allowedMentions?: AllowedMentions): boolean => { if (!allowedMentions) { return false; diff --git a/fluxer_app/src/features/messaging/utils/MessageSubmitUtils.test.ts b/fluxer_app/src/features/messaging/utils/MessageSubmitUtils.test.ts new file mode 100644 index 000000000..4d9de4931 --- /dev/null +++ b/fluxer_app/src/features/messaging/utils/MessageSubmitUtils.test.ts @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import type {Message} from '@app/features/messaging/models/MessagingMessage'; +import {claimMessageAttachments, createOptimisticMessage} from '@app/features/messaging/utils/MessageSubmitUtils'; +import type {User} from '@app/features/user/models/User'; +import {MessageFlags, MessageStates, MessageTypes} from '@fluxer/constants/src/ChannelConstants'; +import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas'; +import {describe, expect, it, vi} from 'vitest'; + +const claimAttachmentsForMessage = vi.hoisted(() => vi.fn(() => [])); + +vi.mock('@app/features/messaging/models/MessagingMessage', () => ({ + Message: class { + constructor(data: object) { + Object.assign(this, data); + } + }, +})); +vi.mock('@app/features/messaging/upload/CloudUpload', () => ({CloudUpload: {claimAttachmentsForMessage}})); +vi.mock('@app/features/messaging/state/ChatInputSettings', () => ({default: {convertEmoticons: false}})); +vi.mock('@app/features/user/state/UserSettings', () => ({default: {getSanitizeUrls: () => false}})); +vi.mock('@app/features/messaging/utils/EmoticonConversionUtils', () => ({ + convertEmoticonsToEmoji: (content: string) => content, +})); + +const currentUser = {toJSON: () => ({id: 'me'})} as unknown as User; +const referencedMessage = {id: 'ref', author: {toJSON: () => ({id: 'other'})}} as unknown as Message; + +describe('createOptimisticMessage', () => { + it('still strips @silent followed by a space and marks the message silent', () => { + expect( + createOptimisticMessage({content: '@silent hello', channelId: 'c', nonce: 'n', currentUser}, []), + ).toMatchObject({ + content: 'hello', + flags: MessageFlags.SUPPRESS_NOTIFICATIONS, + type: MessageTypes.DEFAULT, + state: MessageStates.SENDING, + }); + }); + + it('strips @silent before a line break and marks the message silent', () => { + expect( + createOptimisticMessage({content: '@silent\nhello', channelId: 'c', nonce: 'n', currentUser}, []), + ).toMatchObject({content: 'hello', flags: MessageFlags.SUPPRESS_NOTIFICATIONS}); + }); + + it('marks an attachment captioned only by @silent as silent with no content', () => { + const attachment = {id: 'upload'} as MessageAttachment; + expect( + createOptimisticMessage({content: '@silent', channelId: 'c', nonce: 'n', currentUser}, [attachment]), + ).toMatchObject({content: '', flags: MessageFlags.SUPPRESS_NOTIFICATIONS, attachments: [attachment]}); + }); + + it('still marks a silent reply', () => { + expect( + createOptimisticMessage( + {content: '@silent hi', channelId: 'c', nonce: 'n', currentUser, referencedMessage, replyMentioning: true}, + [], + ), + ).toMatchObject({ + content: 'hi', + flags: MessageFlags.SUPPRESS_NOTIFICATIONS, + type: MessageTypes.REPLY, + message_reference: {channel_id: 'c', message_id: 'ref', type: 0}, + _allowedMentions: {replied_user: true}, + }); + }); + + it('still leaves @silent later in the message as text', () => { + expect( + createOptimisticMessage({content: 'hello @silent', channelId: 'c', nonce: 'n', currentUser}, []), + ).toMatchObject({content: 'hello @silent', flags: 0}); + }); +}); + +describe('claimMessageAttachments', () => { + it('claims attachments captioned only by @silent as silent with no content', () => { + claimMessageAttachments('c', 'n', '@silent'); + expect(claimAttachmentsForMessage).toHaveBeenLastCalledWith('c', 'n', undefined, { + content: '', + messageReference: undefined, + allowedMentions: {replied_user: true}, + flags: MessageFlags.SUPPRESS_NOTIFICATIONS, + }); + }); + + it('claims attachments with the caption after @silent and a line break', () => { + claimMessageAttachments('c', 'n', '@silent\ncaption'); + expect(claimAttachmentsForMessage).toHaveBeenLastCalledWith('c', 'n', undefined, { + content: 'caption', + messageReference: undefined, + allowedMentions: {replied_user: true}, + flags: MessageFlags.SUPPRESS_NOTIFICATIONS, + }); + }); +}); diff --git a/fluxer_app/src/features/messaging/utils/SilentMessagePrefix.test.ts b/fluxer_app/src/features/messaging/utils/SilentMessagePrefix.test.ts new file mode 100644 index 000000000..13acf989c --- /dev/null +++ b/fluxer_app/src/features/messaging/utils/SilentMessagePrefix.test.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {parseSilentMessagePrefix} from '@app/features/messaging/utils/SilentMessagePrefix'; +import {describe, expect, it} from 'vitest'; + +describe('parseSilentMessagePrefix', () => { + it('matches @silent followed by a space', () => { + expect(parseSilentMessagePrefix('@silent hello')).toEqual({tokenStart: 0, tokenEnd: 7, end: 8}); + }); + + it('matches @silent on its own, with or without a trailing space', () => { + expect(parseSilentMessagePrefix('@silent')).toEqual({tokenStart: 0, tokenEnd: 7, end: 7}); + expect(parseSilentMessagePrefix('@silent ')).toEqual({tokenStart: 0, tokenEnd: 7, end: 8}); + }); + + it.each([ + ['a newline', '\n'], + ['a tab', '\t'], + ['a no-break space', '\u00a0'], + ])('matches @silent followed by %s', (_name, whitespace) => { + expect(parseSilentMessagePrefix(`@silent${whitespace}hello`)).toEqual({tokenStart: 0, tokenEnd: 7, end: 8}); + }); + + it('consumes only one whitespace character after the token', () => { + expect(parseSilentMessagePrefix('@silent hello')).toEqual({tokenStart: 0, tokenEnd: 7, end: 8}); + }); + + it('skips leading whitespace before the token', () => { + expect(parseSilentMessagePrefix(' @silent hello')).toEqual({tokenStart: 2, tokenEnd: 9, end: 10}); + expect(parseSilentMessagePrefix('\n@silent hello')).toEqual({tokenStart: 1, tokenEnd: 8, end: 9}); + }); + + it.each([ + '', + 'hello', + 'hello @silent', + '@silently hello', + '@Silent hello', + '@silent@everyone', + '`@silent` hello', + '> @silent hello', + ])('does not match %j', (content) => { + expect(parseSilentMessagePrefix(content)).toBeNull(); + }); + + it.each([ + '@silent hello', + ' @silent hello', + '\n@silent hello', + '\t@silent hello', + '\u00a0@silent hello', + '\u3000@silent hello', + '\ufeff@silent hello', + '\u200b@silent hello', + '@silent', + '@silent ', + '@silent\u00a0', + '@silent\u200b', + '@silently hello', + 'hello @silent', + ' ', + ])('matches %j the same way before and after trimming', (content) => { + expect(parseSilentMessagePrefix(content) != null).toBe(parseSilentMessagePrefix(content.trim()) != null); + }); +}); diff --git a/fluxer_app/src/features/messaging/utils/SilentMessagePrefix.ts b/fluxer_app/src/features/messaging/utils/SilentMessagePrefix.ts new file mode 100644 index 000000000..eba47a386 --- /dev/null +++ b/fluxer_app/src/features/messaging/utils/SilentMessagePrefix.ts @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +const SILENT_MESSAGE_PREFIX_RE = /^(\s*)(@silent)(?:\s|$)/; + +interface SilentMessagePrefix { + tokenStart: number; + tokenEnd: number; + end: number; +} + +export function parseSilentMessagePrefix(content: string): SilentMessagePrefix | null { + const match = SILENT_MESSAGE_PREFIX_RE.exec(content); + if (match == null) { + return null; + } + const tokenStart = match[1]!.length; + return {tokenStart, tokenEnd: tokenStart + match[2]!.length, end: match[0].length}; +}