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

@@ -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};
}