fix(app): honour @silent in the message composer (#2668)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-11 13:16:13 +02:00
1 parent 87fdfd9c34
commit fc0e2628a4
13 files changed
+853 -27

No files matched your search

@@ -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,
)}
<MessageCharacterCounter
currentLength={trimmedMessageContent.length}
currentLength={composerMessageContent.length}
maxLength={maxMessageLength}
canUpgrade={maxMessageLength < premiumMaxLength}
premiumMaxLength={premiumMaxLength}
@@ -6,6 +6,7 @@ import {
MarkdownHl,
type MarkdownHlFormat,
type MarkdownSpan,
markSilentMessagePrefix,
} from '@app/features/lexical/composer/markdownSpans';
import {$isComposerCommandNode} from '@app/features/lexical/composer/nodes/ComposerCommandNode';
import {$isComposerCustomEmojiNode} from '@app/features/lexical/composer/nodes/ComposerCustomEmojiNode';
@@ -45,6 +46,7 @@ const STYLE_BY_BIT: ReadonlyArray<{bit: number; style: string}> = [
{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<Array<LexicalNode>> = [];
let line: Array<LexicalNode> = [];
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]!;
@@ -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<MentionSegment> = []): 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<Array<[string, string] | string>> {
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<ElementNode>().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', '']]]);
});
});
@@ -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(
@@ -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<MarkdownSpan>, source: string): Array<MarkdownSpan> {
const prefix = parseSilentMessagePrefix(source);
if (prefix == null) {
return spans;
}
const pieces: Array<MarkdownSpan> = [];
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<MarkdownSpan>,
recovered: Array<MarkdownRecoveredRange>,
@@ -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<unknown> => []),
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}),
);
});
});
@@ -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;
@@ -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) {
@@ -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'});
});
});
@@ -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;
@@ -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,
});
});
});
@@ -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);
});
});
@@ -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};
}