mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
fix(app): honour @silent in the message composer (#2668)
This commit is contained in:
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};
|
||||
}
|
||||
Reference in new issue
Block a user