mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +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
@@ -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};
|
||||
}
|
||||
Reference in new issue
Block a user