Support for receiving formatted messages

Co-authored-by: Alvaro Carrasco <alvaro@signal.org>
This commit is contained in:
Scott Nonnenberg 2023-04-10 09:31:45 -07:00 committed by GitHub
parent d34d187f1e
commit d9d820e72a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
72 changed files with 3421 additions and 858 deletions

View file

@ -2349,6 +2349,10 @@
"messageformat": "Select", "messageformat": "Select",
"description": "Shown on the drop-down menu for an individual message, opens the conversation in select mode with the current message selected" "description": "Shown on the drop-down menu for an individual message, opens the conversation in select mode with the current message selected"
}, },
"icu:MessageTextRenderer--spoiler--label": {
"messageformat": "Spoiler",
"description": "Used as a label for screenreaders on 'spoiler' text, which is hidden by default"
},
"retrySend": { "retrySend": {
"message": "Retry Send", "message": "Retry Send",
"description": "(deleted 03/29/2023) Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send" "description": "(deleted 03/29/2023) Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send"

View file

@ -290,7 +290,7 @@
"ts-loader": "4.1.0", "ts-loader": "4.1.0",
"ts-node": "8.3.0", "ts-node": "8.3.0",
"typed-scss-modules": "4.1.1", "typed-scss-modules": "4.1.1",
"typescript": "5.0.2", "typescript": "4.9.5",
"webpack": "5.76.0", "webpack": "5.76.0",
"webpack-cli": "4.9.2", "webpack-cli": "4.9.2",
"webpack-dev-server": "4.11.1" "webpack-dev-server": "4.11.1"

View file

@ -307,12 +307,22 @@ message DataMessage {
} }
message BodyRange { message BodyRange {
optional uint32 start = 1; enum Style {
NONE = 0;
BOLD = 1;
ITALIC = 2;
SPOILER = 3;
STRIKETHROUGH = 4;
MONOSPACE = 5;
}
optional uint32 start = 1;
optional uint32 length = 2; optional uint32 length = 2;
// oneof associatedValue { oneof associatedValue {
optional string mentionUuid = 3; string mentionUuid = 3;
//} Style style = 4;
}
} }
message GroupCallUpdate { message GroupCallUpdate {
@ -399,6 +409,7 @@ message StoryMessage {
TextAttachment textAttachment = 4; TextAttachment textAttachment = 4;
} }
optional bool allowsReplies = 5; optional bool allowsReplies = 5;
repeated BodyRange bodyRanges = 6;
} }
message TextAttachment { message TextAttachment {

View file

@ -73,6 +73,10 @@ img.emoji.max {
height: 56px; height: 56px;
} }
img.emoji--invisible {
visibility: hidden;
}
// we need these, or we'll make conversation items too big in the left-nav // we need these, or we'll make conversation items too big in the left-nav
.conversations img.emoji.small { .conversations img.emoji.small {
width: 1em; width: 1em;

View file

@ -53,6 +53,10 @@
&--outgoing { &--outgoing {
background-color: $color-black-alpha-40; background-color: $color-black-alpha-40;
} }
&--invisible {
visibility: hidden;
}
} }
&__author { &__author {

View file

@ -0,0 +1,112 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.MessageTextRenderer {
&__formatting {
&--bold {
font-weight: 600;
}
&--italic {
font-style: italic;
}
&--monospace {
font-family: monospace;
}
&--strikethrough {
text-decoration: line-through;
bdi {
text-decoration: line-through;
}
}
&--none {
text-decoration: none;
font-weight: 400;
bdi {
text-decoration: none;
}
}
// Note: only used in the left pane for search results, not in message bubbles
&--keywordHighlight {
font-weight: 600;
// To differentiate it from bold formatting, we increase the color contrast
@include light-theme {
color: $color-black; // vs color-gray-60 normally
}
@include dark-theme {
color: $color-white; // vs color-gray-25 normally
}
}
// Note: Spoiler must be last to override any other formatting applied to the section
&--spoiler {
user-select: none;
cursor: pointer;
// make child text invisible
color: transparent;
// fix outline
outline: none;
@include keyboard-mode {
&:focus {
box-shadow: 0 0 0px 1px $color-ultramarine;
}
}
}
&--spoiler--noninteractive {
cursor: default;
box-shadow: none;
}
// The simplest; always in dark mode
&--spoiler-StoryViewer {
background-color: $color-white;
}
// The left pane
&--spoiler-ConversationList,
&--spoiler-SearchResult {
@include light-theme {
background-color: $color-gray-60;
}
@include dark-theme {
background-color: $color-gray-25;
}
}
// The timeline
&--spoiler-Quote {
@include light-theme {
background-color: $color-gray-90;
}
@include dark-theme {
background-color: $color-gray-05;
}
}
&--spoiler-Timeline--incoming {
@include light-theme {
background-color: $color-gray-90;
}
@include dark-theme {
background-color: $color-gray-05;
}
}
&--spoiler-Timeline--outgoing {
@include light-theme {
background-color: rgba(255, 255, 255, 0.9);
}
@include dark-theme {
background-color: rgba(255, 255, 255, 0.9);
}
}
&--invisible {
visibility: hidden;
}
}
}

View file

@ -98,6 +98,7 @@
@import './components/MediaQualitySelector.scss'; @import './components/MediaQualitySelector.scss';
@import './components/MessageAudio.scss'; @import './components/MessageAudio.scss';
@import './components/MessageBody.scss'; @import './components/MessageBody.scss';
@import './components/MessageTextRenderer.scss';
@import './components/MessageDetail.scss'; @import './components/MessageDetail.scss';
@import './components/MiniPlayer.scss'; @import './components/MiniPlayer.scss';
@import './components/Modal.scss'; @import './components/Modal.scss';

View file

@ -38,7 +38,7 @@ export function AnnouncementsOnlyGroupBanner({
{groupAdmins.map(admin => ( {groupAdmins.map(admin => (
<ConversationListItem <ConversationListItem
{...admin} {...admin}
draftPreview="" draftPreview={undefined}
i18n={i18n} i18n={i18n}
lastMessage={undefined} lastMessage={undefined}
lastUpdated={undefined} lastUpdated={undefined}

View file

@ -4,11 +4,8 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { get } from 'lodash'; import { get } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import type { import type { DraftBodyRangeMention } from '../types/BodyRange';
DraftBodyRangesType, import type { LocalizerType, ThemeType } from '../types/Util';
LocalizerType,
ThemeType,
} from '../types/Util';
import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder'; import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder';
import { RecordingState } from '../types/AudioRecorder'; import { RecordingState } from '../types/AudioRecorder';
import type { imageToBlurHash } from '../util/imageToBlurHash'; import type { imageToBlurHash } from '../util/imageToBlurHash';
@ -123,7 +120,7 @@ export type OwnProps = Readonly<{
conversationId: string, conversationId: string,
options: { options: {
draftAttachments?: ReadonlyArray<AttachmentDraftType>; draftAttachments?: ReadonlyArray<AttachmentDraftType>;
mentions?: DraftBodyRangesType; mentions?: ReadonlyArray<DraftBodyRangeMention>;
message?: string; message?: string;
timestamp?: number; timestamp?: number;
voiceNoteAttachment?: InMemoryAttachmentDraftType; voiceNoteAttachment?: InMemoryAttachmentDraftType;
@ -233,8 +230,8 @@ export function CompositionArea({
shouldSendHighQualityAttachments, shouldSendHighQualityAttachments,
// CompositionInput // CompositionInput
clearQuotedMessage, clearQuotedMessage,
draftText,
draftBodyRanges, draftBodyRanges,
draftText,
getPreferredBadge, getPreferredBadge,
getQuotedMessage, getQuotedMessage,
onEditorStateChange, onEditorStateChange,
@ -311,7 +308,11 @@ export function CompositionArea({
}, [inputApiRef, setLarge]); }, [inputApiRef, setLarge]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(message: string, mentions: DraftBodyRangesType, timestamp: number) => { (
message: string,
mentions: ReadonlyArray<DraftBodyRangeMention>,
timestamp: number
) => {
emojiButtonRef.current?.close(); emojiButtonRef.current?.close();
sendMultiMediaMessage(conversationId, { sendMultiMediaMessage(conversationId, {
draftAttachments, draftAttachments,

View file

@ -14,11 +14,8 @@ import { MentionCompletion } from '../quill/mentions/completion';
import { EmojiBlot, EmojiCompletion } from '../quill/emoji'; import { EmojiBlot, EmojiCompletion } from '../quill/emoji';
import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { EmojiPickDataType } from './emoji/EmojiPicker';
import { convertShortName } from './emoji/lib'; import { convertShortName } from './emoji/lib';
import type { import type { DraftBodyRangeMention } from '../types/BodyRange';
LocalizerType, import type { LocalizerType, ThemeType } from '../types/Util';
DraftBodyRangesType,
ThemeType,
} from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import { isValidUuid } from '../types/UUID'; import { isValidUuid } from '../types/UUID';
@ -64,7 +61,7 @@ export type InputApi = {
insertEmoji: (e: EmojiPickDataType) => void; insertEmoji: (e: EmojiPickDataType) => void;
setContents: ( setContents: (
text: string, text: string,
draftBodyRanges?: DraftBodyRangesType, draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>,
cursorToEnd?: boolean cursorToEnd?: boolean
) => void; ) => void;
reset: () => void; reset: () => void;
@ -82,7 +79,7 @@ export type Props = Readonly<{
sendCounter: number; sendCounter: number;
skinTone?: EmojiPickDataType['skinTone']; skinTone?: EmojiPickDataType['skinTone'];
draftText?: string; draftText?: string;
draftBodyRanges?: DraftBodyRangesType; draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>;
moduleClassName?: string; moduleClassName?: string;
theme: ThemeType; theme: ThemeType;
placeholder?: string; placeholder?: string;
@ -90,7 +87,7 @@ export type Props = Readonly<{
scrollerRef?: React.RefObject<HTMLDivElement>; scrollerRef?: React.RefObject<HTMLDivElement>;
onDirtyChange?(dirty: boolean): unknown; onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?(options: { onEditorStateChange?(options: {
bodyRanges: DraftBodyRangesType; bodyRanges: ReadonlyArray<DraftBodyRangeMention>;
caretLocation?: number; caretLocation?: number;
conversationId: string | undefined; conversationId: string | undefined;
messageText: string; messageText: string;
@ -100,7 +97,7 @@ export type Props = Readonly<{
onPickEmoji(o: EmojiPickDataType): unknown; onPickEmoji(o: EmojiPickDataType): unknown;
onSubmit( onSubmit(
message: string, message: string,
mentions: DraftBodyRangesType, mentions: ReadonlyArray<DraftBodyRangeMention>,
timestamp: number timestamp: number
): unknown; ): unknown;
onScroll?: (ev: React.UIEvent<HTMLElement>) => void; onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
@ -164,16 +161,19 @@ export function CompositionInput(props: Props): React.ReactElement {
const generateDelta = ( const generateDelta = (
text: string, text: string,
bodyRanges: DraftBodyRangesType mentions: ReadonlyArray<DraftBodyRangeMention>
): Delta => { ): Delta => {
const initialOps = [{ insert: text }]; const initialOps = [{ insert: text }];
const opsWithMentions = insertMentionOps(initialOps, bodyRanges); const opsWithMentions = insertMentionOps(initialOps, mentions);
const opsWithEmojis = insertEmojiOps(opsWithMentions); const opsWithEmojis = insertEmojiOps(opsWithMentions);
return new Delta(opsWithEmojis); return new Delta(opsWithEmojis);
}; };
const getTextAndMentions = (): [string, DraftBodyRangesType] => { const getTextAndMentions = (): [
string,
ReadonlyArray<DraftBodyRangeMention>
] => {
const quill = quillRef.current; const quill = quillRef.current;
if (quill === undefined) { if (quill === undefined) {
@ -251,7 +251,7 @@ export function CompositionInput(props: Props): React.ReactElement {
const setContents = ( const setContents = (
text: string, text: string,
bodyRanges?: DraftBodyRangesType, mentions?: ReadonlyArray<DraftBodyRangeMention>,
cursorToEnd?: boolean cursorToEnd?: boolean
) => { ) => {
const quill = quillRef.current; const quill = quillRef.current;
@ -260,7 +260,7 @@ export function CompositionInput(props: Props): React.ReactElement {
return; return;
} }
const delta = generateDelta(text || '', bodyRanges || []); const delta = generateDelta(text || '', mentions || []);
canSendRef.current = true; canSendRef.current = true;
// We need to cast here because we use @types/quill@1.3.10 which has types // We need to cast here because we use @types/quill@1.3.10 which has types

View file

@ -9,7 +9,8 @@ import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
import type { InputApi } from './CompositionInput'; import type { InputApi } from './CompositionInput';
import { CompositionInput } from './CompositionInput'; import { CompositionInput } from './CompositionInput';
import { EmojiButton } from './emoji/EmojiButton'; import { EmojiButton } from './emoji/EmojiButton';
import type { DraftBodyRangesType, ThemeType } from '../types/Util'; import type { DraftBodyRangeMention } from '../types/BodyRange';
import type { ThemeType } from '../types/Util';
import type { Props as EmojiButtonProps } from './emoji/EmojiButton'; import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import * as grapheme from '../util/grapheme'; import * as grapheme from '../util/grapheme';
@ -24,13 +25,13 @@ export type CompositionTextAreaProps = {
onPickEmoji: (e: EmojiPickDataType) => void; onPickEmoji: (e: EmojiPickDataType) => void;
onChange: ( onChange: (
messageText: string, messageText: string,
bodyRanges: DraftBodyRangesType, draftBodyRanges: ReadonlyArray<DraftBodyRangeMention>,
caretLocation?: number | undefined caretLocation?: number | undefined
) => void; ) => void;
onSetSkinTone: (tone: number) => void; onSetSkinTone: (tone: number) => void;
onSubmit: ( onSubmit: (
message: string, message: string,
mentions: DraftBodyRangesType, draftBodyRanges: ReadonlyArray<DraftBodyRangeMention>,
timestamp: number timestamp: number
) => void; ) => void;
onTextTooLong: () => void; onTextTooLong: () => void;

View file

@ -391,7 +391,11 @@ ConversationTypingStatus.story = {
export const ConversationWithDraft = (): JSX.Element => export const ConversationWithDraft = (): JSX.Element =>
renderConversation({ renderConversation({
shouldShowDraft: true, shouldShowDraft: true,
draftPreview: "I'm in the middle of typing this...", draftPreview: {
text: "I'm in the middle of typing this...",
prefix: '🎤',
bodyRanges: [],
},
}); });
ConversationWithDraft.story = { ConversationWithDraft.story = {

View file

@ -1,7 +1,7 @@
// Copyright 2023 Signal Messenger, LLC // Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useRef } from 'react'; import React, { useCallback, useState, useRef } from 'react';
import { noop } from 'lodash'; import { noop } from 'lodash';
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
@ -86,6 +86,14 @@ export function EditHistoryMessagesModal({
[closeEditHistoryModal, showLightbox] [closeEditHistoryModal, showLightbox]
); );
// These states aren't in redux; they are meant to last only as long as this dialog.
const [revealedSpoilersById, setRevealedSpoilersById] = useState<
Record<string, boolean | undefined>
>({});
const [displayLimitById, setDisplayLimitById] = useState<
Record<string, number | undefined>
>({});
return ( return (
<Modal <Modal
hasXButton hasXButton
@ -95,20 +103,41 @@ export function EditHistoryMessagesModal({
title={i18n('icu:EditHistoryMessagesModal__title')} title={i18n('icu:EditHistoryMessagesModal__title')}
> >
<div ref={containerElementRef}> <div ref={containerElementRef}>
{editHistoryMessages.map(messageAttributes => ( {editHistoryMessages.map(messageAttributes => {
<Message const syntheticId = `${messageAttributes.id}.${messageAttributes.timestamp}`;
{...MESSAGE_DEFAULT_PROPS}
{...messageAttributes} return (
containerElementRef={containerElementRef} <Message
getPreferredBadge={getPreferredBadge} {...MESSAGE_DEFAULT_PROPS}
i18n={i18n} {...messageAttributes}
platform={platform} id={syntheticId}
key={messageAttributes.timestamp} containerElementRef={containerElementRef}
kickOffAttachmentDownload={kickOffAttachmentDownload} displayLimit={displayLimitById[syntheticId]}
showLightbox={closeAndShowLightbox} getPreferredBadge={getPreferredBadge}
theme={theme} i18n={i18n}
/> isSpoilerExpanded={revealedSpoilersById[syntheticId] || false}
))} key={messageAttributes.timestamp}
kickOffAttachmentDownload={kickOffAttachmentDownload}
messageExpanded={(messageId, displayLimit) => {
const update = {
...displayLimitById,
[messageId]: displayLimit,
};
setDisplayLimitById(update);
}}
platform={platform}
showLightbox={closeAndShowLightbox}
showSpoiler={messageId => {
const update = {
...revealedSpoilersById,
[messageId]: true,
};
setRevealedSpoilersById(update);
}}
theme={theme}
/>
);
})}
</div> </div>
</Modal> </Modal>
); );

View file

@ -10,7 +10,8 @@ import React, {
useState, useState,
} from 'react'; } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import type { DraftBodyRangesType, LocalizerType } from '../types/Util'; import type { DraftBodyRangeMention } from '../types/BodyRange';
import type { LocalizerType } from '../types/Util';
import type { ContextMenuOptionType } from './ContextMenu'; import type { ContextMenuOptionType } from './ContextMenu';
import type { import type {
ConversationType, ConversationType,
@ -27,7 +28,6 @@ import { AnimatedEmojiGalore } from './AnimatedEmojiGalore';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import { ConfirmationDialog } from './ConfirmationDialog'; import { ConfirmationDialog } from './ConfirmationDialog';
import { ContextMenu } from './ContextMenu'; import { ContextMenu } from './ContextMenu';
import { Emojify } from './conversation/Emojify';
import { Intl } from './Intl'; import { Intl } from './Intl';
import { MessageTimestamp } from './conversation/MessageTimestamp'; import { MessageTimestamp } from './conversation/MessageTimestamp';
import { SendStatus } from '../messages/MessageSendState'; import { SendStatus } from '../messages/MessageSendState';
@ -53,6 +53,8 @@ import { useEscapeHandling } from '../hooks/useEscapeHandling';
import { useRetryStorySend } from '../hooks/useRetryStorySend'; import { useRetryStorySend } from '../hooks/useRetryStorySend';
import { resolveStorySendStatus } from '../util/resolveStorySendStatus'; import { resolveStorySendStatus } from '../util/resolveStorySendStatus';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import { MessageBody } from './conversation/MessageBody';
import { RenderLocation } from './conversation/MessageTextRenderer';
function renderStrong(parts: Array<JSX.Element | string>) { function renderStrong(parts: Array<JSX.Element | string>) {
return <strong>{parts}</strong>; return <strong>{parts}</strong>;
@ -95,7 +97,7 @@ export type PropsType = {
onReactToStory: (emoji: string, story: StoryViewType) => unknown; onReactToStory: (emoji: string, story: StoryViewType) => unknown;
onReplyToStory: ( onReplyToStory: (
message: string, message: string,
mentions: DraftBodyRangesType, mentions: ReadonlyArray<DraftBodyRangeMention>,
timestamp: number, timestamp: number,
story: StoryViewType story: StoryViewType
) => unknown; ) => unknown;
@ -184,6 +186,7 @@ export function StoryViewer({
const { const {
attachment, attachment,
bodyRanges,
canReply, canReply,
isHidden, isHidden,
messageId, messageId,
@ -234,6 +237,7 @@ export function StoryViewer({
// Caption related hooks // Caption related hooks
const [hasExpandedCaption, setHasExpandedCaption] = useState<boolean>(false); const [hasExpandedCaption, setHasExpandedCaption] = useState<boolean>(false);
const [isSpoilerExpanded, setIsSpoilerExpanded] = useState<boolean>(false);
const caption = useMemo(() => { const caption = useMemo(() => {
if (!attachment?.caption) { if (!attachment?.caption) {
@ -250,6 +254,7 @@ export function StoryViewer({
// Reset expansion if messageId changes // Reset expansion if messageId changes
useEffect(() => { useEffect(() => {
setHasExpandedCaption(false); setHasExpandedCaption(false);
setIsSpoilerExpanded(false);
}, [messageId]); }, [messageId]);
// messageId is set as a dependency so that we can reset the story duration // messageId is set as a dependency so that we can reset the story duration
@ -333,6 +338,7 @@ export function StoryViewer({
setConfirmDeleteStory(undefined); setConfirmDeleteStory(undefined);
setHasConfirmHideStory(false); setHasConfirmHideStory(false);
setHasExpandedCaption(false); setHasExpandedCaption(false);
setIsSpoilerExpanded(false);
setIsShowingContextMenu(false); setIsShowingContextMenu(false);
setPauseStory(false); setPauseStory(false);
@ -644,7 +650,7 @@ export function StoryViewer({
{hasExpandedCaption && ( {hasExpandedCaption && (
<button <button
aria-label={i18n('icu:close-popup')} aria-label={i18n('icu:close-popup')}
className="StoryViewer__caption__overlay" className="StoryViewer__CAPTION__overlay"
onClick={() => setHasExpandedCaption(false)} onClick={() => setHasExpandedCaption(false)}
type="button" type="button"
/> />
@ -677,7 +683,14 @@ export function StoryViewer({
<div className="StoryViewer__meta"> <div className="StoryViewer__meta">
{caption && ( {caption && (
<div className="StoryViewer__caption"> <div className="StoryViewer__caption">
<Emojify text={caption.text} /> <MessageBody
bodyRanges={bodyRanges}
i18n={i18n}
isSpoilerExpanded={isSpoilerExpanded}
onExpandSpoiler={() => setIsSpoilerExpanded(true)}
renderLocation={RenderLocation.StoryViewer}
text={caption.text}
/>
{caption.hasReadMore && !hasExpandedCaption && ( {caption.hasReadMore && !hasExpandedCaption && (
<button <button
className="MessageBody__read-more" className="MessageBody__read-more"

View file

@ -11,7 +11,8 @@ import React, {
import classNames from 'classnames'; import classNames from 'classnames';
import { noop } from 'lodash'; import { noop } from 'lodash';
import type { DraftBodyRangesType, LocalizerType } from '../types/Util'; import type { DraftBodyRangeMention } from '../types/BodyRange';
import type { LocalizerType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker'; import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { InputApi } from './CompositionInput'; import type { InputApi } from './CompositionInput';
@ -94,7 +95,7 @@ export type PropsType = {
onReact: (emoji: string) => unknown; onReact: (emoji: string) => unknown;
onReply: ( onReply: (
message: string, message: string,
mentions: DraftBodyRangesType, mentions: ReadonlyArray<DraftBodyRangeMention>,
timestamp: number timestamp: number
) => unknown; ) => unknown;
onSetSkinTone: (tone: number) => unknown; onSetSkinTone: (tone: number) => unknown;
@ -147,6 +148,14 @@ export function StoryViewsNRepliesModal({
string | undefined string | undefined
>(undefined); >(undefined);
// These states aren't in redux; they are meant to last only as long as this dialog.
const [revealedSpoilersById, setRevealedSpoilersById] = useState<
Record<string, boolean | undefined>
>({});
const [displayLimitById, setDisplayLimitById] = useState<
Record<string, number | undefined>
>({});
const containerElementRef = useRef<HTMLDivElement | null>(null); const containerElementRef = useRef<HTMLDivElement | null>(null);
const inputApiRef = useRef<InputApi | undefined>(); const inputApiRef = useRef<InputApi | undefined>();
const shouldScrollToBottomRef = useRef(true); const shouldScrollToBottomRef = useRef(true);
@ -287,15 +296,31 @@ export function StoryViewsNRepliesModal({
deleteGroupStoryReplyForEveryone={() => deleteGroupStoryReplyForEveryone={() =>
setDeleteForEveryoneReplyId(reply.id) setDeleteForEveryoneReplyId(reply.id)
} }
displayLimit={displayLimitById[reply.id]}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
i18n={i18n} i18n={i18n}
platform={platform} platform={platform}
id={reply.id} id={reply.id}
isInternalUser={isInternalUser} isInternalUser={isInternalUser}
isSpoilerExpanded={revealedSpoilersById[reply.id] || false}
messageExpanded={(messageId, displayLimit) => {
const update = {
...displayLimitById,
[messageId]: displayLimit,
};
setDisplayLimitById(update);
}}
reply={reply} reply={reply}
shouldCollapseAbove={shouldCollapse(reply, replies[index - 1])} shouldCollapseAbove={shouldCollapse(reply, replies[index - 1])}
shouldCollapseBelow={shouldCollapse(reply, replies[index + 1])} shouldCollapseBelow={shouldCollapse(reply, replies[index + 1])}
showContactModal={showContactModal} showContactModal={showContactModal}
showSpoiler={messageId => {
const update = {
...revealedSpoilersById,
[messageId]: true,
};
setRevealedSpoilersById(update);
}}
/> />
); );
})} })}
@ -465,31 +490,39 @@ type ReplyOrReactionMessageProps = {
containerElementRef: React.RefObject<HTMLElement>; containerElementRef: React.RefObject<HTMLElement>;
deleteGroupStoryReply: (replyId: string) => void; deleteGroupStoryReply: (replyId: string) => void;
deleteGroupStoryReplyForEveryone: (replyId: string) => void; deleteGroupStoryReplyForEveryone: (replyId: string) => void;
displayLimit: number | undefined;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType; i18n: LocalizerType;
platform: string; platform: string;
id: string; id: string;
isInternalUser?: boolean; isInternalUser?: boolean;
isSpoilerExpanded: boolean;
onContextMenu?: (ev: React.MouseEvent) => void; onContextMenu?: (ev: React.MouseEvent) => void;
reply: ReplyType; reply: ReplyType;
shouldCollapseAbove: boolean; shouldCollapseAbove: boolean;
shouldCollapseBelow: boolean; shouldCollapseBelow: boolean;
showContactModal: (contactId: string, conversationId?: string) => void; showContactModal: (contactId: string, conversationId?: string) => void;
messageExpanded: (messageId: string, displayLimit: number) => void;
showSpoiler: (messageId: string) => void;
}; };
function ReplyOrReactionMessage({ function ReplyOrReactionMessage({
containerElementRef,
deleteGroupStoryReply,
deleteGroupStoryReplyForEveryone,
displayLimit,
getPreferredBadge,
i18n, i18n,
id, id,
isInternalUser, isInternalUser,
reply, isSpoilerExpanded,
deleteGroupStoryReply, messageExpanded,
deleteGroupStoryReplyForEveryone,
containerElementRef,
getPreferredBadge,
platform, platform,
reply,
shouldCollapseAbove, shouldCollapseAbove,
shouldCollapseBelow, shouldCollapseBelow,
showContactModal, showContactModal,
showSpoiler,
}: ReplyOrReactionMessageProps) { }: ReplyOrReactionMessageProps) {
const renderContent = (onContextMenu?: (ev: React.MouseEvent) => void) => { const renderContent = (onContextMenu?: (ev: React.MouseEvent) => void) => {
if (reply.reactionEmoji && !reply.deletedForEveryone) { if (reply.reactionEmoji && !reply.deletedForEveryone) {
@ -549,21 +582,25 @@ function ReplyOrReactionMessage({
conversationId={reply.conversationId} conversationId={reply.conversationId}
conversationTitle={reply.author.title} conversationTitle={reply.author.title}
conversationType="group" conversationType="group"
direction="incoming"
deletedForEveryone={reply.deletedForEveryone} deletedForEveryone={reply.deletedForEveryone}
renderMenu={undefined} direction="incoming"
onContextMenu={onContextMenu} displayLimit={displayLimit}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
i18n={i18n} i18n={i18n}
platform={platform} platform={platform}
id={reply.id} id={reply.id}
interactionMode="mouse" interactionMode="mouse"
isSpoilerExpanded={isSpoilerExpanded}
messageExpanded={messageExpanded}
onContextMenu={onContextMenu}
readStatus={reply.readStatus} readStatus={reply.readStatus}
renderingContext="StoryViewsNRepliesModal" renderingContext="StoryViewsNRepliesModal"
renderMenu={undefined}
shouldCollapseAbove={shouldCollapseAbove} shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow} shouldCollapseBelow={shouldCollapseBelow}
shouldHideMetadata={false} shouldHideMetadata={false}
showContactModal={showContactModal} showContactModal={showContactModal}
showSpoiler={showSpoiler}
text={reply.body} text={reply.body}
textDirection={TextDirection.Default} textDirection={TextDirection.Default}
timestamp={reply.timestamp} timestamp={reply.timestamp}

View file

@ -7,7 +7,7 @@ import type { RenderTextCallbackType } from '../../types/Util';
export type Props = { export type Props = {
text: string; text: string;
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */ /** Allows you to customize how non-newlines are rendered. Simplest is just a <span>. */
renderNonNewLine?: RenderTextCallbackType; renderNonNewLine?: RenderTextCallbackType;
}; };

View file

@ -0,0 +1,61 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import type { KeyboardEventHandler } from 'react';
import React from 'react';
import { Emojify } from './Emojify';
export function AtMention({
direction,
id,
isInvisible,
name,
onClick,
onKeyUp,
}: {
direction: 'incoming' | 'outgoing' | undefined;
id: string;
isInvisible: boolean;
name: string;
onClick: () => void;
onKeyUp: KeyboardEventHandler;
}): JSX.Element {
if (isInvisible) {
return (
<span
className={classNames(
'MessageBody__at-mention',
'MessageBody__at-mention--invisible'
)}
data-id={id}
data-title={name}
>
<bdi>
@
<Emojify isInvisible={isInvisible} text={name} />
</bdi>
</span>
);
}
return (
<span
className={classNames(
'MessageBody__at-mention',
`MessageBody__at-mention--${direction}`
)}
onClick={onClick}
onKeyUp={onKeyUp}
tabIndex={0}
role="link"
data-id={id}
data-title={name}
>
<bdi>
@
<Emojify isInvisible={isInvisible} text={name} />
</bdi>
</span>
);
}

View file

@ -13,7 +13,7 @@ export default {
}; };
const createProps = (overrideProps: Partial<Props> = {}): Props => ({ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
bodyRanges: overrideProps.bodyRanges, mentions: overrideProps.mentions,
direction: overrideProps.direction || 'incoming', direction: overrideProps.direction || 'incoming',
showConversation: action('showConversation'), showConversation: action('showConversation'),
text: overrideProps.text || '', text: overrideProps.text || '',
@ -28,7 +28,7 @@ export function NoMentions(): JSX.Element {
} }
export function MultipleMentions(): JSX.Element { export function MultipleMentions(): JSX.Element {
const bodyRanges = [ const mentions = [
{ {
start: 4, start: 4,
length: 1, length: 1,
@ -52,16 +52,16 @@ export function MultipleMentions(): JSX.Element {
}, },
]; ];
const props = createProps({ const props = createProps({
bodyRanges, mentions,
direction: 'outgoing', direction: 'outgoing',
text: AtMentionify.preprocessMentions('\uFFFC \uFFFC \uFFFC', bodyRanges), text: AtMentionify.preprocessMentions('\uFFFC \uFFFC \uFFFC', mentions),
}); });
return <AtMentionify {...props} />; return <AtMentionify {...props} />;
} }
export function ComplexMentions(): JSX.Element { export function ComplexMentions(): JSX.Element {
const bodyRanges = [ const mentions = [
{ {
start: 80, start: 80,
length: 1, length: 1,
@ -86,10 +86,10 @@ export function ComplexMentions(): JSX.Element {
]; ];
const props = createProps({ const props = createProps({
bodyRanges, mentions,
text: AtMentionify.preprocessMentions( text: AtMentionify.preprocessMentions(
'Hey \uFFFC\nCheck out https://www.signal.org I think you will really like it 😍\n\ncc \uFFFC \uFFFC', 'Hey \uFFFC\nCheck out https://www.signal.org I think you will really like it 😍\n\ncc \uFFFC \uFFFC',
bodyRanges mentions
), ),
}); });
@ -97,7 +97,7 @@ export function ComplexMentions(): JSX.Element {
} }
export function WithOddCharacter(): JSX.Element { export function WithOddCharacter(): JSX.Element {
const bodyRanges = [ const mentions = [
{ {
start: 4, start: 4,
length: 1, length: 1,
@ -108,10 +108,10 @@ export function WithOddCharacter(): JSX.Element {
]; ];
const props = createProps({ const props = createProps({
bodyRanges, mentions,
text: AtMentionify.preprocessMentions( text: AtMentionify.preprocessMentions(
'Hey \uFFFC - Check out │https://www.signal.org│', 'Hey \uFFFC - Check out │https://www.signal.org│',
bodyRanges mentions
), ),
}); });

View file

@ -3,15 +3,14 @@
import React from 'react'; import React from 'react';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { Emojify } from './Emojify';
import type { import type {
BodyRangesType, HydratedBodyRangeMention,
HydratedBodyRangeType, BodyRange,
HydratedBodyRangesType, } from '../../types/BodyRange';
} from '../../types/Util'; import { AtMention } from './AtMention';
export type Props = { export type Props = {
bodyRanges?: HydratedBodyRangesType; mentions?: ReadonlyArray<HydratedBodyRangeMention>;
direction?: 'incoming' | 'outgoing'; direction?: 'incoming' | 'outgoing';
showConversation?: (options: { showConversation?: (options: {
conversationId: string; conversationId: string;
@ -21,12 +20,12 @@ export type Props = {
}; };
export function AtMentionify({ export function AtMentionify({
bodyRanges, mentions,
direction, direction,
showConversation, showConversation,
text, text,
}: Props): JSX.Element { }: Props): JSX.Element {
if (!bodyRanges) { if (!mentions) {
return <>{text}</>; return <>{text}</>;
} }
@ -35,8 +34,8 @@ export function AtMentionify({
let match = MENTIONS_REGEX.exec(text); let match = MENTIONS_REGEX.exec(text);
let last = 0; let last = 0;
const rangeStarts = new Map<number, HydratedBodyRangeType>(); const rangeStarts = new Map<number, HydratedBodyRangeMention>();
bodyRanges.forEach(range => { mentions.forEach(range => {
rangeStarts.set(range.start, range); rangeStarts.set(range.start, range);
}); });
@ -52,9 +51,10 @@ export function AtMentionify({
if (range) { if (range) {
results.push( results.push(
<span <AtMention
className={`MessageBody__at-mention MessageBody__at-mention--${direction}`}
key={range.start} key={range.start}
direction={direction}
isInvisible={false}
onClick={() => { onClick={() => {
if (showConversation) { if (showConversation) {
showConversation({ conversationId: range.conversationID }); showConversation({ conversationId: range.conversationID });
@ -69,16 +69,9 @@ export function AtMentionify({
showConversation({ conversationId: range.conversationID }); showConversation({ conversationId: range.conversationID });
} }
}} }}
tabIndex={0} id={range.conversationID}
role="link" name={range.replacementText}
data-id={range.conversationID} />
data-title={range.replacementText}
>
<bdi>
@
<Emojify text={range.replacementText} />
</bdi>
</span>
); );
} }
@ -101,16 +94,16 @@ export function AtMentionify({
// string, therefore we're unable to mark it up with DOM nodes prior to handing // string, therefore we're unable to mark it up with DOM nodes prior to handing
// it off to them. This function will encode the "start" position into the text // it off to them. This function will encode the "start" position into the text
// string so we can later pull it off when rendering the @mention. // string so we can later pull it off when rendering the @mention.
AtMentionify.preprocessMentions = ( AtMentionify.preprocessMentions = <T extends BodyRange.Mention>(
text: string, text: string,
bodyRanges?: BodyRangesType mentions?: ReadonlyArray<BodyRange<T>>
): string => { ): string => {
if (!bodyRanges || !bodyRanges.length) { if (!mentions || !mentions.length) {
return text; return text;
} }
// Sorting by the start index to ensure that we always replace last -> first. // Sorting by the start index to ensure that we always replace last -> first.
return sortBy(bodyRanges, 'start').reduceRight((str, range) => { return sortBy(mentions, 'start').reduceRight((str, range) => {
const textBegin = str.substr(0, range.start); const textBegin = str.substr(0, range.start);
const encodedMention = `\uFFFC@${range.start}`; const encodedMention = `\uFFFC@${range.start}`;
const textEnd = str.substr(range.start + range.length, str.length); const textEnd = str.substr(range.start + range.length, str.length);

View file

@ -16,13 +16,15 @@ import { emojiToImage } from '../emoji/lib';
// ts/components/emoji/Emoji.tsx // ts/components/emoji/Emoji.tsx
// ts/quill/emoji/blot.tsx // ts/quill/emoji/blot.tsx
function getImageTag({ function getImageTag({
isInvisible,
key,
match, match,
sizeClass, sizeClass,
key,
}: { }: {
isInvisible?: boolean;
key: string | number;
match: string; match: string;
sizeClass?: SizeClassType; sizeClass?: SizeClassType;
key: string | number;
}): JSX.Element | string { }): JSX.Element | string {
const img = emojiToImage(match); const img = emojiToImage(match);
@ -35,18 +37,24 @@ function getImageTag({
key={key} key={key}
src={img} src={img}
aria-label={match} aria-label={match}
className={classNames('emoji', sizeClass)} className={classNames(
'emoji',
sizeClass,
isInvisible ? 'emoji--invisible' : null
)}
alt={match} alt={match}
/> />
); );
} }
export type Props = { export type Props = {
text: string; /** When behind a spoiler, this emoji needs to be visibility: hidden */
isInvisible?: boolean;
/** A class name to be added to the generated emoji images */ /** A class name to be added to the generated emoji images */
sizeClass?: SizeClassType; sizeClass?: SizeClassType;
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */ /** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
renderNonEmoji?: RenderTextCallbackType; renderNonEmoji?: RenderTextCallbackType;
text: string;
}; };
const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text; const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text;
@ -54,14 +62,15 @@ const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text;
export class Emojify extends React.Component<Props> { export class Emojify extends React.Component<Props> {
public override render(): null | Array<JSX.Element | string | null> { public override render(): null | Array<JSX.Element | string | null> {
const { const {
text, isInvisible,
sizeClass,
renderNonEmoji = defaultRenderNonEmoji, renderNonEmoji = defaultRenderNonEmoji,
sizeClass,
text,
} = this.props; } = this.props;
return splitByEmoji(text).map(({ type, value: match }, index) => { return splitByEmoji(text).map(({ type, value: match }, index) => {
if (type === 'emoji') { if (type === 'emoji') {
return getImageTag({ match, sizeClass, key: index }); return getImageTag({ isInvisible, match, sizeClass, key: index });
} }
if (type === 'text') { if (type === 'text') {

View file

@ -10,7 +10,7 @@ import { isLinkSneaky, shouldLinkifyMessage } from '../../types/LinkPreview';
import { splitByEmoji } from '../../util/emoji'; import { splitByEmoji } from '../../util/emoji';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
const linkify = LinkifyIt() export const linkify = LinkifyIt()
// This is all TLDs in place in 2010, according to [IANA's root zone database][0] // This is all TLDs in place in 2010, according to [IANA's root zone database][0]
// except for those domains marked as [a test domain][1]. // except for those domains marked as [a test domain][1].
// //
@ -319,7 +319,7 @@ export type Props = {
renderNonLink?: RenderTextCallbackType; renderNonLink?: RenderTextCallbackType;
}; };
const SUPPORTED_PROTOCOLS = /^(http|https):/i; export const SUPPORTED_PROTOCOLS = /^(http|https):/i;
const defaultRenderNonLink: RenderTextCallbackType = ({ text }) => text; const defaultRenderNonLink: RenderTextCallbackType = ({ text }) => text;

View file

@ -72,11 +72,8 @@ import { getIncrement } from '../../util/timer';
import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary'; import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
import { isFileDangerous } from '../../util/isFileDangerous'; import { isFileDangerous } from '../../util/isFileDangerous';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import type { import type { HydratedBodyRangesType } from '../../types/BodyRange';
HydratedBodyRangesType, import type { LocalizerType, ThemeType } from '../../types/Util';
LocalizerType,
ThemeType,
} from '../../types/Util';
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
import type { import type {
@ -98,6 +95,7 @@ import { Emojify } from './Emojify';
import { getPaymentEventDescription } from '../../messages/helpers'; import { getPaymentEventDescription } from '../../messages/helpers';
import { PanelType } from '../../types/Panels'; import { PanelType } from '../../types/Panels';
import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser'; import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser';
import { RenderLocation } from './MessageTextRenderer';
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16; const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18; const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
@ -212,6 +210,7 @@ export type PropsData = {
isTargetedCounter?: number; isTargetedCounter?: number;
isSelected: boolean; isSelected: boolean;
isSelectMode: boolean; isSelectMode: boolean;
isSpoilerExpanded?: boolean;
direction: DirectionType; direction: DirectionType;
timestamp: number; timestamp: number;
status?: MessageStatusType; status?: MessageStatusType;
@ -316,6 +315,7 @@ export type PropsActions = {
openGiftBadge: (messageId: string) => void; openGiftBadge: (messageId: string) => void;
pushPanelForConversation: PushPanelForConversationActionType; pushPanelForConversation: PushPanelForConversationActionType;
showContactModal: (contactId: string, conversationId?: string) => void; showContactModal: (contactId: string, conversationId?: string) => void;
showSpoiler: (messageId: string) => void;
kickOffAttachmentDownload: (options: { kickOffAttachmentDownload: (options: {
attachment: AttachmentType; attachment: AttachmentType;
@ -1735,9 +1735,11 @@ export class Message extends React.PureComponent<Props, State> {
displayLimit, displayLimit,
i18n, i18n,
id, id,
isSpoilerExpanded,
kickOffAttachmentDownload, kickOffAttachmentDownload,
messageExpanded, messageExpanded,
showConversation, showConversation,
showSpoiler,
status, status,
text, text,
textAttachment, textAttachment,
@ -1783,6 +1785,7 @@ export class Message extends React.PureComponent<Props, State> {
displayLimit={displayLimit} displayLimit={displayLimit}
i18n={i18n} i18n={i18n}
id={id} id={id}
isSpoilerExpanded={isSpoilerExpanded || false}
kickOffBodyDownload={() => { kickOffBodyDownload={() => {
if (!textAttachment) { if (!textAttachment) {
return; return;
@ -1794,6 +1797,8 @@ export class Message extends React.PureComponent<Props, State> {
}} }}
messageExpanded={messageExpanded} messageExpanded={messageExpanded}
showConversation={showConversation} showConversation={showConversation}
renderLocation={RenderLocation.Timeline}
onExpandSpoiler={() => showSpoiler(id)}
text={contents || ''} text={contents || ''}
textAttachment={textAttachment} textAttachment={textAttachment}
/> />

View file

@ -2,13 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { boolean, text } from '@storybook/addon-knobs';
import type { Props } from './MessageBody'; import type { Props } from './MessageBody';
import { MessageBody } from './MessageBody'; import { MessageBody } from './MessageBody';
import { setupI18n } from '../../util/setupI18n'; import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import { BodyRange } from '../../types/BodyRange';
import { RenderLocation } from './MessageTextRenderer';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -18,16 +19,18 @@ export default {
const createProps = (overrideProps: Partial<Props> = {}): Props => ({ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
bodyRanges: overrideProps.bodyRanges, bodyRanges: overrideProps.bodyRanges,
disableJumbomoji: boolean( disableJumbomoji: overrideProps.disableJumbomoji || false,
'disableJumbomoji', disableLinks: overrideProps.disableLinks || false,
overrideProps.disableJumbomoji || false
),
disableLinks: boolean('disableLinks', overrideProps.disableLinks || false),
direction: 'incoming', direction: 'incoming',
i18n, i18n,
text: text('text', overrideProps.text || ''), isSpoilerExpanded: overrideProps.isSpoilerExpanded || false,
onExpandSpoiler: overrideProps.onExpandSpoiler || action('onExpandSpoiler'),
renderLocation: RenderLocation.Timeline,
showConversation:
overrideProps.showConversation || action('showConversation'),
text: overrideProps.text || '',
textAttachment: overrideProps.textAttachment || { textAttachment: overrideProps.textAttachment || {
pending: boolean('textPending', false), pending: false,
}, },
}); });
@ -156,7 +159,13 @@ export function MultipleMentions(): JSX.Element {
text: '\uFFFC \uFFFC \uFFFC', text: '\uFFFC \uFFFC \uFFFC',
}); });
return <MessageBody {...props} />; return (
<>
<MessageBody {...props} />
<hr />
<MessageBody {...props} disableLinks />
</>
);
} }
MultipleMentions.story = { MultipleMentions.story = {
@ -193,9 +202,304 @@ export function ComplexMessageBody(): JSX.Element {
text: 'Hey \uFFFC\nCheck out https://www.signal.org I think you will really like it 😍\n\ncc \uFFFC \uFFFC', text: 'Hey \uFFFC\nCheck out https://www.signal.org I think you will really like it 😍\n\ncc \uFFFC \uFFFC',
}); });
return <MessageBody {...props} />; return (
<>
<MessageBody {...props} />
<hr />
<MessageBody {...props} disableLinks />
</>
);
} }
ComplexMessageBody.story = { ComplexMessageBody.story = {
name: 'Complex MessageBody', name: 'Complex MessageBody',
}; };
export function FormattingBasic(): JSX.Element {
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
const props = createProps({
bodyRanges: [
// Abracadabra
{
start: 36,
length: 11,
style: BodyRange.Style.BOLD,
},
// Open Sesame
{
start: 46,
length: 10,
style: BodyRange.Style.ITALIC,
},
// This is the key! And the treasure, too, if we can only get our hands on it!
{
start: 357,
length: 75,
style: BodyRange.Style.MONOSPACE,
},
// The real magic is to understand which words work, and when, and for what
{
start: 138,
length: 73,
style: BodyRange.Style.STRIKETHROUGH,
},
// as if the key to the treasure is the treasure!
{
start: 446,
length: 46,
style: BodyRange.Style.SPOILER,
},
{
start: 110,
length: 27,
style: BodyRange.Style.NONE,
},
],
isSpoilerExpanded,
onExpandSpoiler: () => setIsSpoilerExpanded(true),
text: '… Its in words that the magic is Abracadabra, Open Sesame, and the rest but the magic words in one story arent magical in the next. The real magic is to understand which words work, and when, and for what; the trick is to learn the trick. … And those words are made from the letters of our alphabet: a couple-dozen squiggles we can draw with the pen. This is the key! And the treasure, too, if we can only get our hands on it! Its as if as if the key to the treasure is the treasure!',
});
return (
<>
<MessageBody {...props} />
<hr />
<MessageBody {...props} disableLinks />
</>
);
}
export function FormattingSpoiler(): JSX.Element {
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
const props = createProps({
bodyRanges: [
{
start: 8,
length: 89,
style: BodyRange.Style.SPOILER,
},
{
start: 46,
length: 22,
style: BodyRange.Style.MONOSPACE,
},
{
start: 72,
length: 12,
style: BodyRange.Style.BOLD,
},
{
start: 90,
length: 7,
style: BodyRange.Style.ITALIC,
},
{
start: 54,
length: 1,
mentionUuid: 'a',
conversationID: 'a',
replacementText: '🅰️ Alice',
},
{
start: 60,
length: 1,
mentionUuid: 'b',
conversationID: 'b',
replacementText: '🅱️ Bob',
},
],
isSpoilerExpanded,
onExpandSpoiler: () => setIsSpoilerExpanded(true),
text: "This is a very secret https://somewhere.com 💡 thing, \uFFFC and \uFFFC, that you shouldn't be able to read. Stay away!",
});
return (
<>
<MessageBody {...props} />
<hr />
<MessageBody {...props} disableLinks />
<hr />
<MessageBody {...props} isSpoilerExpanded={false} />
<hr />
<MessageBody {...props} disableLinks isSpoilerExpanded={false} />
</>
);
}
export function FormattingNesting(): JSX.Element {
const props = createProps({
bodyRanges: [
{
start: 0,
length: 40,
style: BodyRange.Style.BOLD,
},
{
start: 0,
length: 111,
style: BodyRange.Style.ITALIC,
},
{
start: 40,
length: 60,
style: BodyRange.Style.STRIKETHROUGH,
},
{
start: 64,
length: 14,
style: BodyRange.Style.MONOSPACE,
},
{
start: 29,
length: 1,
mentionUuid: 'a',
conversationID: 'a',
replacementText: '🅰️ Alice',
},
{
start: 61,
length: 1,
mentionUuid: 'b',
conversationID: 'b',
replacementText: '🅱️ Bob',
},
{
start: 68,
length: 1,
mentionUuid: 'c',
conversationID: 'c',
replacementText: 'Charlie',
},
{
start: 80,
length: 1,
mentionUuid: 'd',
conversationID: 'd',
replacementText: 'Dan',
},
{
start: 105,
length: 1,
mentionUuid: 'e',
conversationID: 'e',
replacementText: 'Eve',
},
],
/* eslint-disable max-len */
// m m
// b bs s
// i i
/* eslint-enable max-len */
text: 'Italic Start and Bold Start .\uFFFC. Bold EndStrikethrough Start .\uFFFC. Mono\uFFFCpace Pop! .\uFFFC. Strikethrough End Ital\uFFFCc End',
});
return (
<>
<MessageBody {...props} />
<hr />
<MessageBody {...props} disableLinks />
</>
);
}
export function FormattingComplex(): JSX.Element {
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
const text =
'Computational processes \uFFFC are abstract beings that inhabit computers. ' +
'As they evolve, processes manipulate other abstract things called data. ' +
'The evolution of a process is directed by a pattern of rules called a program. ' +
'People create programs to direct processes. In effect, we conjure the spirits of ' +
'the computer with our spells.\n\n' +
'link preceded by emoji: 🤖https://signal.org/\n\n' +
'link overlapping strikethrough: https://signal.org/ (up to "...//signal")\n\n' +
'strikethrough going through mention \uFFFC all the way';
const props = createProps({
bodyRanges: [
// mention
{
start: 24,
length: 1,
mentionUuid: 'abc',
conversationID: 'x',
replacementText: '🤖 Hello',
},
// bold wraps mention
{
start: 14,
length: 31,
style: BodyRange.Style.BOLD,
},
// italic overlaps with bold
{
start: 29,
length: 39,
style: BodyRange.Style.ITALIC,
},
// strikethrough overlaps link
{
start: 397,
length: 29,
style: BodyRange.Style.STRIKETHROUGH,
},
// strikethrough over mention
{
start: 465,
length: 31,
style: BodyRange.Style.STRIKETHROUGH,
},
// mention 2
{
start: 491,
length: 1,
mentionUuid: 'abc',
conversationID: 'x',
replacementText: '🤖 Hello',
},
],
isSpoilerExpanded,
onExpandSpoiler: () => setIsSpoilerExpanded(true),
text,
});
return <MessageBody {...props} />;
}
export function ZalgoText(): JSX.Element {
const text = 'T̸͎̆̏̇̊̄͜ͅh̸͙̟͎̯̍͋͜͜i̸̪͚̼̜̦̲̇͒̇͝͝ś̴̡̩͙͜͝ ̴̼̣̩͂͑͠i̸̡̞̯͗s̵͙̔͛͊͑̔ ̶͇̒͝f̴̗͇͙̳͕̅̈́̏̉ò̵̲͉̤̬̖̱ȓ̶̳̫͗͝m̶̗͚̓ą̶̘̳͉̣̿̋t̴͎͎̞̤̱̅̓͝͝t̶̝͊͗é̵̛̥̔̃̀d̸̢̘̹̥̋͆ ̸̘͓͐̓̅̚ẕ̸͉̊̊͝a̴̙̖͎̥̥̅̽́͑͘ͅl̴͔̪͙͔̑̈́g̴͔̝̙̰̊͆̎͌́ǫ̵̪̤̖̖͗̑̎̿̄̎ ̵̪͈̲͇̫̼͌̌͛̚t̸̠́ẽ̴̡̺̖͘x̵͈̰̮͔̃̔͗̑̓͘t';
const props = createProps({
bodyRanges: [
// This
{
start: 0,
length: 39,
style: BodyRange.Style.BOLD,
},
// is
{
start: 49,
length: 13,
style: BodyRange.Style.ITALIC,
},
// formatted
{
start: 65,
length: 73,
style: BodyRange.Style.STRIKETHROUGH,
},
// zalgo text
{
start: 145,
length: 92,
style: BodyRange.Style.MONOSPACE,
},
],
text,
});
return <MessageBody {...props} />;
}

View file

@ -6,56 +6,35 @@ import React from 'react';
import type { AttachmentType } from '../../types/Attachment'; import type { AttachmentType } from '../../types/Attachment';
import { canBeDownloaded } from '../../types/Attachment'; import { canBeDownloaded } from '../../types/Attachment';
import type { SizeClassType } from '../emoji/lib';
import { getSizeClass } from '../emoji/lib'; import { getSizeClass } from '../emoji/lib';
import { AtMentionify } from './AtMentionify';
import { Emojify } from './Emojify'; import { Emojify } from './Emojify';
import { AddNewLines } from './AddNewLines';
import { Linkify } from './Linkify';
import type { ShowConversationType } from '../../state/ducks/conversations'; import type { ShowConversationType } from '../../state/ducks/conversations';
import type { import type { HydratedBodyRangesType } from '../../types/BodyRange';
HydratedBodyRangesType, import type { LocalizerType } from '../../types/Util';
LocalizerType, import { MessageTextRenderer } from './MessageTextRenderer';
RenderTextCallbackType, import type { RenderLocation } from './MessageTextRenderer';
} from '../../types/Util';
export type Props = { export type Props = {
author?: string; author?: string;
bodyRanges?: HydratedBodyRangesType; bodyRanges?: HydratedBodyRangesType;
direction?: 'incoming' | 'outgoing'; direction?: 'incoming' | 'outgoing';
/** If set, all emoji will be the same size. Otherwise, just one emoji will be large. */ // If set, all emoji will be the same size. Otherwise, just one emoji will be large.
disableJumbomoji?: boolean; disableJumbomoji?: boolean;
/** If set, links will be left alone instead of turned into clickable `<a>` tags. */ // If set, interactive elements will be left as plain text: links, mentions, spoilers
disableLinks?: boolean; disableLinks?: boolean;
i18n: LocalizerType; i18n: LocalizerType;
isSpoilerExpanded: boolean;
kickOffBodyDownload?: () => void; kickOffBodyDownload?: () => void;
onExpandSpoiler?: () => unknown;
onIncreaseTextLength?: () => unknown; onIncreaseTextLength?: () => unknown;
prefix?: string;
renderLocation: RenderLocation;
showConversation?: ShowConversationType; showConversation?: ShowConversationType;
text: string; text: string;
textAttachment?: Pick<AttachmentType, 'pending' | 'digest' | 'key'>; textAttachment?: Pick<AttachmentType, 'pending' | 'digest' | 'key'>;
}; };
const renderEmoji = ({
text,
key,
sizeClass,
renderNonEmoji,
}: {
i18n: LocalizerType;
text: string;
key: number;
sizeClass?: SizeClassType;
renderNonEmoji: RenderTextCallbackType;
}) => (
<Emojify
key={key}
text={text}
sizeClass={sizeClass}
renderNonEmoji={renderNonEmoji}
/>
);
/** /**
* This component makes it very easy to use all three of our message formatting * This component makes it very easy to use all three of our message formatting
* components: `Emojify`, `Linkify`, and `AddNewLines`. Because each of them is fully * components: `Emojify`, `Linkify`, and `AddNewLines`. Because each of them is fully
@ -69,8 +48,12 @@ export function MessageBody({
disableJumbomoji, disableJumbomoji,
disableLinks, disableLinks,
i18n, i18n,
isSpoilerExpanded,
kickOffBodyDownload, kickOffBodyDownload,
onExpandSpoiler,
onIncreaseTextLength, onIncreaseTextLength,
prefix,
renderLocation,
showConversation, showConversation,
text, text,
textAttachment, textAttachment,
@ -80,31 +63,6 @@ export function MessageBody({
textAttachment?.pending || hasReadMore ? `${text}...` : text; textAttachment?.pending || hasReadMore ? `${text}...` : text;
const sizeClass = disableJumbomoji ? undefined : getSizeClass(text); const sizeClass = disableJumbomoji ? undefined : getSizeClass(text);
const processedText = AtMentionify.preprocessMentions(
textWithSuffix,
bodyRanges
);
const renderNewLines: RenderTextCallbackType = ({
text: textWithNewLines,
key,
}) => {
return (
<AddNewLines
key={key}
text={textWithNewLines}
renderNonNewLine={({ text: innerText, key: innerKey }) => (
<AtMentionify
key={innerKey}
bodyRanges={bodyRanges}
direction={direction}
showConversation={showConversation}
text={innerText}
/>
)}
/>
);
};
let pendingContent: React.ReactNode; let pendingContent: React.ReactNode;
if (hasReadMore) { if (hasReadMore) {
@ -145,39 +103,35 @@ export function MessageBody({
{author && ( {author && (
<> <>
<span className="MessageBody__author"> <span className="MessageBody__author">
{renderEmoji({ <Emojify text={author} />
i18n,
text: author,
sizeClass,
key: 0,
renderNonEmoji: renderNewLines,
})}
</span> </span>
:{' '} :{' '}
</> </>
)} )}
{disableLinks ? ( {prefix && (
renderEmoji({ <>
i18n, <span className="MessageBody__prefix">
text: processedText, <Emojify text={prefix} />
sizeClass, </span>{' '}
key: 0, </>
renderNonEmoji: renderNewLines,
})
) : (
<Linkify
text={processedText}
renderNonLink={({ key, text: nonLinkText }) => {
return renderEmoji({
i18n,
text: nonLinkText,
sizeClass,
key,
renderNonEmoji: renderNewLines,
});
}}
/>
)} )}
<MessageTextRenderer
bodyRanges={bodyRanges ?? []}
direction={direction}
disableLinks={disableLinks ?? false}
emojiSizeClass={sizeClass}
i18n={i18n}
isSpoilerExpanded={isSpoilerExpanded}
messageText={textWithSuffix}
onMentionTrigger={conversationId =>
showConversation?.({ conversationId })
}
onExpandSpoiler={onExpandSpoiler}
renderLocation={renderLocation}
textLength={text.length}
/>
{pendingContent} {pendingContent}
{onIncreaseTextLength ? ( {onIncreaseTextLength ? (
<button <button

View file

@ -4,12 +4,14 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { text } from '@storybook/addon-knobs';
import type { Props } from './MessageBodyReadMore'; import type { Props } from './MessageBodyReadMore';
import { MessageBodyReadMore } from './MessageBodyReadMore'; import { MessageBodyReadMore } from './MessageBodyReadMore';
import { setupI18n } from '../../util/setupI18n'; import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import type { HydratedBodyRangesType } from '../../types/BodyRange';
import { BodyRange } from '../../types/BodyRange';
import { RenderLocation } from './MessageTextRenderer';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -23,20 +25,34 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
displayLimit: overrideProps.displayLimit, displayLimit: overrideProps.displayLimit,
i18n, i18n,
id: 'some-id', id: 'some-id',
isSpoilerExpanded: overrideProps.isSpoilerExpanded === true,
messageExpanded: action('messageExpanded'), messageExpanded: action('messageExpanded'),
text: text('text', overrideProps.text || ''), onExpandSpoiler: overrideProps.onExpandSpoiler || action('onExpandSpoiler'),
renderLocation: RenderLocation.Timeline,
text: overrideProps.text || '',
}); });
function MessageBodyReadMoreTest({ function MessageBodyReadMoreTest({
bodyRanges,
isSpoilerExpanded,
onExpandSpoiler,
text: messageBodyText, text: messageBodyText,
}: { }: {
bodyRanges?: HydratedBodyRangesType;
isSpoilerExpanded?: boolean;
onExpandSpoiler?: () => void;
text: string; text: string;
}): JSX.Element { }): JSX.Element {
const [displayLimit, setDisplayLimit] = useState<number | undefined>(); const [displayLimit, setDisplayLimit] = useState<number | undefined>();
return ( return (
<MessageBodyReadMore <MessageBodyReadMore
{...createProps({ text: messageBodyText })} {...createProps({
bodyRanges,
isSpoilerExpanded,
onExpandSpoiler,
text: messageBodyText,
})}
displayLimit={displayLimit} displayLimit={displayLimit}
messageExpanded={(_, newDisplayLimit) => setDisplayLimit(newDisplayLimit)} messageExpanded={(_, newDisplayLimit) => setDisplayLimit(newDisplayLimit)}
/> />
@ -69,6 +85,74 @@ export function LeafyNotBuffered(): JSX.Element {
return <MessageBodyReadMoreTest text={`x${'🌿'.repeat(450)}`} />; return <MessageBodyReadMoreTest text={`x${'🌿'.repeat(450)}`} />;
} }
export function LongTextWithMention(): JSX.Element {
const bodyRanges = [
// This is right at boundary for better testing
{
start: 800,
length: 1,
mentionUuid: 'abc',
conversationID: 'x',
replacementText: 'Alice',
},
];
const text = `${'x '.repeat(400)}\uFFFC woo!${'y '.repeat(100)}`;
return <MessageBodyReadMoreTest bodyRanges={bodyRanges} text={text} />;
}
export function LongTextWithFormatting(): JSX.Element {
const bodyRanges = [
{
start: 0,
length: 5,
style: BodyRange.Style.ITALIC,
},
{
start: 7,
length: 3,
style: BodyRange.Style.BOLD,
},
{
start: 1019,
length: 4,
style: BodyRange.Style.BOLD,
},
{
start: 1024,
length: 6,
style: BodyRange.Style.ITALIC,
},
];
const text = `ready? set... g${'o'.repeat(1000)}al! bold italic`;
return <MessageBodyReadMoreTest bodyRanges={bodyRanges} text={text} />;
}
export function LongTextMostlySpoiler(): JSX.Element {
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
const bodyRanges = [
{
start: 7,
length: 1010,
style: BodyRange.Style.SPOILER,
},
];
const text = `ready? set... g${'o'.repeat(1000)}al! bold italic`;
return (
<MessageBodyReadMoreTest
bodyRanges={bodyRanges}
text={text}
isSpoilerExpanded={isSpoilerExpanded}
onExpandSpoiler={() => setIsSpoilerExpanded(true)}
/>
);
}
LeafyNotBuffered.story = { LeafyNotBuffered.story = {
name: 'Leafy not buffered', name: 'Leafy not buffered',
}; };

View file

@ -13,7 +13,10 @@ export type Props = Pick<
| 'direction' | 'direction'
| 'disableLinks' | 'disableLinks'
| 'i18n' | 'i18n'
| 'isSpoilerExpanded'
| 'onExpandSpoiler'
| 'kickOffBodyDownload' | 'kickOffBodyDownload'
| 'renderLocation'
| 'showConversation' | 'showConversation'
| 'text' | 'text'
| 'textAttachment' | 'textAttachment'
@ -38,8 +41,11 @@ export function MessageBodyReadMore({
displayLimit, displayLimit,
i18n, i18n,
id, id,
isSpoilerExpanded,
kickOffBodyDownload, kickOffBodyDownload,
messageExpanded, messageExpanded,
onExpandSpoiler,
renderLocation,
showConversation, showConversation,
text, text,
textAttachment, textAttachment,
@ -64,8 +70,11 @@ export function MessageBodyReadMore({
direction={direction} direction={direction}
disableLinks={disableLinks} disableLinks={disableLinks}
i18n={i18n} i18n={i18n}
isSpoilerExpanded={isSpoilerExpanded}
kickOffBodyDownload={kickOffBodyDownload} kickOffBodyDownload={kickOffBodyDownload}
onExpandSpoiler={onExpandSpoiler}
onIncreaseTextLength={onIncreaseTextLength} onIncreaseTextLength={onIncreaseTextLength}
renderLocation={renderLocation}
showConversation={showConversation} showConversation={showConversation}
text={slicedText} text={slicedText}
textAttachment={textAttachment} textAttachment={textAttachment}

View file

@ -42,6 +42,7 @@ const defaultMessage: MessageDataPropsType = {
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
isSelected: false, isSelected: false,
isSelectMode: false, isSelectMode: false,
isSpoilerExpanded: false,
previews: [], previews: [],
readStatus: ReadStatus.Read, readStatus: ReadStatus.Read,
status: 'sent', status: 'sent',
@ -80,10 +81,12 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'), doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
messageExpanded: action('messageExpanded'),
showConversation: action('showConversation'), showConversation: action('showConversation'),
openGiftBadge: action('openGiftBadge'), openGiftBadge: action('openGiftBadge'),
renderAudioAttachment: () => <div>*AudioAttachment*</div>, renderAudioAttachment: () => <div>*AudioAttachment*</div>,
saveAttachment: action('saveAttachment'), saveAttachment: action('saveAttachment'),
showSpoiler: action('showSpoiler'),
pushPanelForConversation: action('pushPanelForConversation'), pushPanelForConversation: action('pushPanelForConversation'),
showContactModal: action('showContactModal'), showContactModal: action('showContactModal'),
showExpiredIncomingTapToViewToast: action( showExpiredIncomingTapToViewToast: action(

View file

@ -81,6 +81,7 @@ export type PropsReduxActions = Pick<
| 'doubleCheckMissingQuoteReference' | 'doubleCheckMissingQuoteReference'
| 'kickOffAttachmentDownload' | 'kickOffAttachmentDownload'
| 'markAttachmentAsCorrupted' | 'markAttachmentAsCorrupted'
| 'messageExpanded'
| 'openGiftBadge' | 'openGiftBadge'
| 'pushPanelForConversation' | 'pushPanelForConversation'
| 'saveAttachment' | 'saveAttachment'
@ -90,6 +91,7 @@ export type PropsReduxActions = Pick<
| 'showExpiredOutgoingTapToViewToast' | 'showExpiredOutgoingTapToViewToast'
| 'showLightbox' | 'showLightbox'
| 'showLightboxForViewOnceMedia' | 'showLightboxForViewOnceMedia'
| 'showSpoiler'
| 'startConversation' | 'startConversation'
| 'viewStory' | 'viewStory'
> & { > & {
@ -296,13 +298,13 @@ export class MessageDetail extends React.Component<Props> {
checkForAccount, checkForAccount,
clearTargetedMessage, clearTargetedMessage,
contactNameColor, contactNameColor,
showLightboxForViewOnceMedia,
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
getPreferredBadge, getPreferredBadge,
i18n, i18n,
interactionMode, interactionMode,
kickOffAttachmentDownload, kickOffAttachmentDownload,
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
messageExpanded,
openGiftBadge, openGiftBadge,
platform, platform,
pushPanelForConversation, pushPanelForConversation,
@ -313,6 +315,8 @@ export class MessageDetail extends React.Component<Props> {
showExpiredIncomingTapToViewToast, showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast, showExpiredOutgoingTapToViewToast,
showLightbox, showLightbox,
showLightboxForViewOnceMedia,
showSpoiler,
startConversation, startConversation,
theme, theme,
viewStory, viewStory,
@ -347,16 +351,17 @@ export class MessageDetail extends React.Component<Props> {
interactionMode={interactionMode} interactionMode={interactionMode}
kickOffAttachmentDownload={kickOffAttachmentDownload} kickOffAttachmentDownload={kickOffAttachmentDownload}
markAttachmentAsCorrupted={markAttachmentAsCorrupted} markAttachmentAsCorrupted={markAttachmentAsCorrupted}
messageExpanded={noop} messageExpanded={messageExpanded}
platform={platform}
showConversation={showConversation}
openGiftBadge={openGiftBadge} openGiftBadge={openGiftBadge}
platform={platform}
pushPanelForConversation={pushPanelForConversation} pushPanelForConversation={pushPanelForConversation}
renderAudioAttachment={renderAudioAttachment} renderAudioAttachment={renderAudioAttachment}
saveAttachment={saveAttachment} saveAttachment={saveAttachment}
shouldCollapseAbove={false} shouldCollapseAbove={false}
shouldCollapseBelow={false} shouldCollapseBelow={false}
shouldHideMetadata={false} shouldHideMetadata={false}
showConversation={showConversation}
showSpoiler={showSpoiler}
scrollToQuotedMessage={() => { scrollToQuotedMessage={() => {
log.warn('MessageDetail: scrollToQuotedMessage called!'); log.warn('MessageDetail: scrollToQuotedMessage called!');
}} }}

View file

@ -0,0 +1,398 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { ReactElement } from 'react';
import classNames from 'classnames';
import emojiRegex from 'emoji-regex';
import { linkify, SUPPORTED_PROTOCOLS } from './Linkify';
import type {
BodyRange,
BodyRangesForDisplayType,
DisplayNode,
HydratedBodyRangeMention,
RangeNode,
} from '../../types/BodyRange';
import {
insertRange,
collapseRangeTree,
groupContiguousSpoilers,
} from '../../types/BodyRange';
import { AtMention } from './AtMention';
import { isLinkSneaky } from '../../types/LinkPreview';
import { Emojify } from './Emojify';
import { AddNewLines } from './AddNewLines';
import type { SizeClassType } from '../emoji/lib';
import type { LocalizerType } from '../../types/Util';
const EMOJI_REGEXP = emojiRegex();
export enum RenderLocation {
ConversationList = 'ConversationList',
Quote = 'Quote',
SearchResult = 'SearchResult',
StoryViewer = 'StoryViewer',
Timeline = 'Timeline',
}
type Props = {
bodyRanges: BodyRangesForDisplayType;
direction: 'incoming' | 'outgoing' | undefined;
disableLinks: boolean;
emojiSizeClass: SizeClassType | undefined;
i18n: LocalizerType;
isSpoilerExpanded: boolean;
messageText: string;
onExpandSpoiler?: () => void;
onMentionTrigger: (conversationId: string) => void;
renderLocation: RenderLocation;
// Sometimes we're passed a string with a suffix (like '...'); we won't process that
textLength: number;
};
export function MessageTextRenderer({
bodyRanges,
direction,
disableLinks,
emojiSizeClass,
i18n,
isSpoilerExpanded,
messageText,
onExpandSpoiler,
onMentionTrigger,
renderLocation,
textLength,
}: Props): JSX.Element {
const links = disableLinks ? [] : extractLinks(messageText);
const tree = bodyRanges.reduce<ReadonlyArray<RangeNode>>(
(acc, range) => {
// Drop bodyRanges that don't apply. Read More means truncated strings.
if (range.start < textLength) {
return insertRange(range, acc);
}
return acc;
},
links.map(b => ({ ...b, ranges: [] }))
);
const nodes = collapseRangeTree({ tree, text: messageText });
const finalNodes = groupContiguousSpoilers(nodes);
return (
<>
{finalNodes.map(node =>
renderNode({
direction,
disableLinks,
emojiSizeClass,
i18n,
isInvisible: false,
isSpoilerExpanded,
node,
renderLocation,
onMentionTrigger,
onExpandSpoiler,
})
)}
</>
);
}
function renderNode({
direction,
disableLinks,
emojiSizeClass,
i18n,
isInvisible,
isSpoilerExpanded,
node,
onExpandSpoiler,
onMentionTrigger,
renderLocation,
}: {
direction: 'incoming' | 'outgoing' | undefined;
disableLinks: boolean;
emojiSizeClass: SizeClassType | undefined;
i18n: LocalizerType;
isInvisible: boolean;
isSpoilerExpanded: boolean;
node: DisplayNode;
onExpandSpoiler?: () => void;
onMentionTrigger: ((conversationId: string) => void) | undefined;
renderLocation: RenderLocation;
}): ReactElement {
const key = node.start;
if (node.isSpoiler && node.spoilerChildren?.length) {
const isSpoilerHidden = Boolean(node.isSpoiler && !isSpoilerExpanded);
const content = node.spoilerChildren?.map(spoilerNode =>
renderNode({
direction,
disableLinks,
emojiSizeClass,
i18n,
isInvisible: isSpoilerHidden,
isSpoilerExpanded,
node: spoilerNode,
renderLocation,
onMentionTrigger,
onExpandSpoiler,
})
);
if (!isSpoilerHidden) {
return (
<span
key={key}
className="MessageTextRenderer__formatting--spoiler--revealed"
>
{content}
</span>
);
}
return (
<span
key={key}
tabIndex={disableLinks ? undefined : 0}
role={disableLinks ? undefined : 'button'}
aria-label={i18n('icu:MessageTextRenderer--spoiler--label')}
aria-expanded={false}
className={classNames(
'MessageTextRenderer__formatting--spoiler',
`MessageTextRenderer__formatting--spoiler-${renderLocation}`,
direction
? `MessageTextRenderer__formatting--spoiler-${renderLocation}--${direction}`
: null,
disableLinks
? 'MessageTextRenderer__formatting--spoiler--noninteractive'
: null
)}
onClick={
disableLinks
? undefined
: event => {
if (onExpandSpoiler) {
event.preventDefault();
event.stopPropagation();
onExpandSpoiler();
}
}
}
onKeyDown={
disableLinks
? undefined
: event => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
event.stopPropagation();
onExpandSpoiler?.();
}
}
>
<span aria-hidden>{content}</span>
</span>
);
}
const content = renderMentions({
direction,
disableLinks,
emojiSizeClass,
isInvisible,
mentions: node.mentions,
onMentionTrigger,
text: node.text,
});
const formattingClasses = classNames(
node.isBold ? 'MessageTextRenderer__formatting--bold' : null,
node.isItalic ? 'MessageTextRenderer__formatting--italic' : null,
node.isMonospace ? 'MessageTextRenderer__formatting--monospace' : null,
node.isStrikethrough
? 'MessageTextRenderer__formatting--strikethrough'
: null,
node.isKeywordHighlight
? 'MessageTextRenderer__formatting--keywordHighlight'
: null,
isInvisible ? 'MessageTextRenderer__formatting--invisible' : null
);
if (
node.url &&
SUPPORTED_PROTOCOLS.test(node.url) &&
!isLinkSneaky(node.url)
) {
return (
<a key={key} className={formattingClasses} href={node.url}>
{content}
</a>
);
}
return (
<span key={key} className={formattingClasses}>
{content}
</span>
);
}
function renderMentions({
direction,
disableLinks,
emojiSizeClass,
isInvisible,
mentions,
onMentionTrigger,
text,
}: {
emojiSizeClass: SizeClassType | undefined;
isInvisible: boolean;
mentions: ReadonlyArray<HydratedBodyRangeMention>;
text: string;
disableLinks: boolean;
direction: 'incoming' | 'outgoing' | undefined;
onMentionTrigger: ((conversationId: string) => void) | undefined;
}): ReactElement {
const result: Array<ReactElement> = [];
let offset = 0;
for (const mention of mentions) {
// collect any previous text
if (mention.start > offset) {
result.push(
renderText({
isInvisible,
key: result.length.toString(),
emojiSizeClass,
text: text.slice(offset, mention.start),
})
);
}
result.push(
renderMention({
isInvisible,
key: result.length.toString(),
conversationId: mention.conversationID,
disableLinks,
direction,
name: mention.replacementText,
onMentionTrigger,
})
);
offset = mention.start + mention.length;
}
// collect any text after
result.push(
renderText({
isInvisible,
key: result.length.toString(),
emojiSizeClass,
text: text.slice(offset, text.length),
})
);
return <>{result}</>;
}
function renderMention({
conversationId,
name,
isInvisible,
key,
disableLinks,
direction,
onMentionTrigger,
}: {
conversationId: string;
name: string;
isInvisible: boolean;
key: string;
disableLinks: boolean;
direction: 'incoming' | 'outgoing' | undefined;
onMentionTrigger: ((conversationId: string) => void) | undefined;
}): ReactElement {
if (disableLinks) {
return (
<bdi key={key}>
@
<Emojify isInvisible={isInvisible} text={name} />
</bdi>
);
}
return (
<AtMention
key={key}
id={conversationId}
isInvisible={isInvisible}
name={name}
direction={direction}
onClick={() => {
if (onMentionTrigger) {
onMentionTrigger(conversationId);
}
}}
onKeyUp={e => {
if (
e.target === e.currentTarget &&
e.key === 'Enter' &&
onMentionTrigger
) {
onMentionTrigger(conversationId);
}
}}
/>
);
}
/** Render text that does not contain body ranges or is in between body ranges */
function renderText({
text,
emojiSizeClass,
isInvisible,
key,
}: {
text: string;
emojiSizeClass: SizeClassType | undefined;
isInvisible: boolean;
key: string;
}) {
return (
<Emojify
key={key}
isInvisible={isInvisible}
renderNonEmoji={({ text: innerText, key: innerKey }) => (
<AddNewLines key={innerKey} text={innerText} />
)}
sizeClass={emojiSizeClass}
text={text}
/>
);
}
export function extractLinks(
messageText: string
): ReadonlyArray<BodyRange<{ url: string }>> {
// to support emojis immediately before links
// we replace emojis with a space for each byte
const matches = linkify.match(
messageText.replace(EMOJI_REGEXP, s => ' '.repeat(s.length))
);
if (matches == null) {
return [];
}
return matches.map(match => {
return {
start: match.index,
length: match.lastIndex - match.index,
url: match.url,
};
});
}

View file

@ -114,6 +114,7 @@ const defaultMessageProps: TimelineMessagesProps = {
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
isSelected: false, isSelected: false,
isSelectMode: false, isSelectMode: false,
isSpoilerExpanded: false,
toggleSelectMessage: action('toggleSelectMessage'), toggleSelectMessage: action('toggleSelectMessage'),
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'), kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
@ -135,6 +136,7 @@ const defaultMessageProps: TimelineMessagesProps = {
shouldCollapseAbove: false, shouldCollapseAbove: false,
shouldCollapseBelow: false, shouldCollapseBelow: false,
shouldHideMetadata: false, shouldHideMetadata: false,
showSpoiler: action('showSpoiler'),
pushPanelForConversation: action('default--pushPanelForConversation'), pushPanelForConversation: action('default--pushPanelForConversation'),
showContactModal: action('default--showContactModal'), showContactModal: action('default--showContactModal'),
showExpiredIncomingTapToViewToast: action( showExpiredIncomingTapToViewToast: action(

View file

@ -11,7 +11,8 @@ import * as GoogleChrome from '../../util/GoogleChrome';
import { MessageBody } from './MessageBody'; import { MessageBody } from './MessageBody';
import type { AttachmentType, ThumbnailType } from '../../types/Attachment'; import type { AttachmentType, ThumbnailType } from '../../types/Attachment';
import type { HydratedBodyRangesType, LocalizerType } from '../../types/Util'; import type { HydratedBodyRangesType } from '../../types/BodyRange';
import type { LocalizerType } from '../../types/Util';
import type { import type {
ConversationColorType, ConversationColorType,
CustomColorType, CustomColorType,
@ -19,12 +20,12 @@ import type {
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
import { Emojify } from './Emojify'; import { Emojify } from './Emojify';
import { TextAttachment } from '../TextAttachment'; import { TextAttachment } from '../TextAttachment';
import { getTextWithMentions } from '../../util/getTextWithMentions';
import { getClassNamesFor } from '../../util/getClassNamesFor'; import { getClassNamesFor } from '../../util/getClassNamesFor';
import { getCustomColorStyle } from '../../util/getCustomColorStyle'; import { getCustomColorStyle } from '../../util/getCustomColorStyle';
import type { AnyPaymentEvent } from '../../types/Payment'; import type { AnyPaymentEvent } from '../../types/Payment';
import { PaymentEventKind } from '../../types/Payment'; import { PaymentEventKind } from '../../types/Payment';
import { getPaymentEventNotificationText } from '../../messages/helpers'; import { getPaymentEventNotificationText } from '../../messages/helpers';
import { RenderLocation } from './MessageTextRenderer';
export type Props = { export type Props = {
authorTitle: string; authorTitle: string;
@ -366,10 +367,6 @@ export class Quote extends React.Component<Props, State> {
} = this.props; } = this.props;
if (text && !isGiftBadge) { if (text && !isGiftBadge) {
const quoteText = bodyRanges
? getTextWithMentions(bodyRanges, text)
: text;
return ( return (
<div <div
dir="auto" dir="auto"
@ -379,10 +376,13 @@ export class Quote extends React.Component<Props, State> {
)} )}
> >
<MessageBody <MessageBody
bodyRanges={bodyRanges}
disableLinks disableLinks
disableJumbomoji disableJumbomoji
text={quoteText}
i18n={i18n} i18n={i18n}
isSpoilerExpanded={false}
renderLocation={RenderLocation.Quote}
text={text}
/> />
</div> </div>
); );

View file

@ -63,6 +63,7 @@ function mockMessageTimelineItem(
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
isSelected: false, isSelected: false,
isSelectMode: false, isSelectMode: false,
isSpoilerExpanded: false,
previews: [], previews: [],
readStatus: ReadStatus.Read, readStatus: ReadStatus.Read,
canRetryDeleteForEveryone: true, canRetryDeleteForEveryone: true,
@ -291,6 +292,7 @@ const actions = () => ({
kickOffAttachmentDownload: action('kickOffAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
messageExpanded: action('messageExpanded'), messageExpanded: action('messageExpanded'),
showSpoiler: action('showSpoiler'),
showLightbox: action('showLightbox'), showLightbox: action('showLightbox'),
showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'), showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'),
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'), doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),

View file

@ -92,7 +92,7 @@ const getDefaultProps = () => ({
'showExpiredIncomingTapToViewToast' 'showExpiredIncomingTapToViewToast'
), ),
scrollToQuotedMessage: action('scrollToQuotedMessage'), scrollToQuotedMessage: action('scrollToQuotedMessage'),
toggleSafetyNumberModal: action('toggleSafetyNumberModal'), showSpoiler: action('showSpoiler'),
startCallingLobby: action('startCallingLobby'), startCallingLobby: action('startCallingLobby'),
startConversation: action('startConversation'), startConversation: action('startConversation'),
returnToActiveCall: action('returnToActiveCall'), returnToActiveCall: action('returnToActiveCall'),
@ -100,6 +100,7 @@ const getDefaultProps = () => ({
shouldCollapseBelow: false, shouldCollapseBelow: false,
shouldHideMetadata: false, shouldHideMetadata: false,
shouldRenderDateHeader: false, shouldRenderDateHeader: false,
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
now: Date.now(), now: Date.now(),

View file

@ -300,6 +300,9 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isSelectMode: isBoolean(overrideProps.isSelectMode) isSelectMode: isBoolean(overrideProps.isSelectMode)
? overrideProps.isSelectMode ? overrideProps.isSelectMode
: false, : false,
isSpoilerExpanded: isBoolean(overrideProps.isSpoilerExpanded)
? overrideProps.isSpoilerExpanded
: false,
isTapToView: overrideProps.isTapToView, isTapToView: overrideProps.isTapToView,
isTapToViewError: overrideProps.isTapToViewError, isTapToViewError: overrideProps.isTapToViewError,
isTapToViewExpired: overrideProps.isTapToViewExpired, isTapToViewExpired: overrideProps.isTapToViewExpired,
@ -338,6 +341,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
shouldHideMetadata: isBoolean(overrideProps.shouldHideMetadata) shouldHideMetadata: isBoolean(overrideProps.shouldHideMetadata)
? overrideProps.shouldHideMetadata ? overrideProps.shouldHideMetadata
: false, : false,
showSpoiler: action('showSpoiler'),
pushPanelForConversation: action('pushPanelForConversation'), pushPanelForConversation: action('pushPanelForConversation'),
showContactModal: action('showContactModal'), showContactModal: action('showContactModal'),
showExpiredIncomingTapToViewToast: action( showExpiredIncomingTapToViewToast: action(

View file

@ -19,6 +19,7 @@ import type { LocalizerType, ThemeType } from '../../types/Util';
import type { ConversationType } from '../../state/ducks/conversations'; import type { ConversationType } from '../../state/ducks/conversations';
import type { BadgeType } from '../../badges/types'; import type { BadgeType } from '../../badges/types';
import { isSignalConversation } from '../../util/isSignalConversation'; import { isSignalConversation } from '../../util/isSignalConversation';
import { RenderLocation } from '../conversation/MessageTextRenderer';
const MESSAGE_STATUS_ICON_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__status-icon`; const MESSAGE_STATUS_ICON_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__status-icon`;
@ -142,10 +143,14 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
{i18n('icu:ConversationListItem--draft-prefix')} {i18n('icu:ConversationListItem--draft-prefix')}
</span> </span>
<MessageBody <MessageBody
text={truncateMessageText(draftPreview)} bodyRanges={draftPreview.bodyRanges}
disableJumbomoji disableJumbomoji
disableLinks disableLinks
i18n={i18n} i18n={i18n}
isSpoilerExpanded={false}
prefix={draftPreview.prefix}
renderLocation={RenderLocation.ConversationList}
text={draftPreview.text}
/> />
</> </>
); );
@ -158,11 +163,15 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
} else if (lastMessage) { } else if (lastMessage) {
messageText = ( messageText = (
<MessageBody <MessageBody
text={truncateMessageText(lastMessage.text)}
author={type === 'group' ? lastMessage.author : undefined} author={type === 'group' ? lastMessage.author : undefined}
bodyRanges={lastMessage.bodyRanges}
disableJumbomoji disableJumbomoji
disableLinks disableLinks
i18n={i18n} i18n={i18n}
isSpoilerExpanded={false}
prefix={lastMessage.prefix}
renderLocation={RenderLocation.ConversationList}
text={lastMessage.text}
/> />
); );
if (lastMessage.status) { if (lastMessage.status) {
@ -210,13 +219,3 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
); );
} }
); );
// This takes `unknown` because, sometimes, values from the database don't match our
// types. In the long term, we should fix that. In the short term, this smooths over the
// problem.
function truncateMessageText(text: unknown): string {
if (typeof text !== 'string') {
return '';
}
return text.replace(/(?:\r?\n)+/g, ' ');
}

View file

@ -1,87 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { text } from '@storybook/addon-knobs';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import type { Props } from './MessageBodyHighlight';
import { MessageBodyHighlight } from './MessageBodyHighlight';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/MessageBodyHighlight',
};
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
bodyRanges: overrideProps.bodyRanges || [],
i18n,
text: text('text', overrideProps.text || ''),
});
export function Basic(): JSX.Element {
const props = createProps({
text: 'This is before <<left>>Inside<<right>> This is after.',
});
return <MessageBodyHighlight {...props} />;
}
export function NoReplacement(): JSX.Element {
const props = createProps({
text: 'All\nplain\ntext 🔥 http://somewhere.com',
});
return <MessageBodyHighlight {...props} />;
}
export function TwoReplacements(): JSX.Element {
const props = createProps({
text: 'Begin <<left>>Inside #1<<right>> This is between the two <<left>>Inside #2<<right>> End.',
});
return <MessageBodyHighlight {...props} />;
}
export function TwoReplacementsWithAnMention(): JSX.Element {
const props = createProps({
bodyRanges: [
{
length: 1,
mentionUuid: '0ca40892-7b1a-11eb-9439-0242ac130002',
replacementText: 'Jin Sakai',
conversationID: 'x',
start: 33,
},
],
text: 'Begin <<left>>Inside #1<<right>> \uFFFC This is between the two <<left>>Inside #2<<right>> End.',
});
return <MessageBodyHighlight {...props} />;
}
TwoReplacementsWithAnMention.story = {
name: 'Two Replacements with an @mention',
};
export function EmojiNewlinesUrLs(): JSX.Element {
const props = createProps({
text: '\nhttp://somewhere.com\n\n🔥 Before -- <<left>>A 🔥 inside<<right>> -- After 🔥',
});
return <MessageBodyHighlight {...props} />;
}
EmojiNewlinesUrLs.story = {
name: 'Emoji + Newlines + URLs',
};
export function NoJumbomoji(): JSX.Element {
const props = createProps({
text: '🔥',
});
return <MessageBodyHighlight {...props} />;
}

View file

@ -1,143 +0,0 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactNode } from 'react';
import React from 'react';
import { MESSAGE_TEXT_CLASS_NAME } from './BaseConversationListItem';
import { AtMentionify } from '../conversation/AtMentionify';
import { MessageBody } from '../conversation/MessageBody';
import { Emojify } from '../conversation/Emojify';
import { AddNewLines } from '../conversation/AddNewLines';
import type { SizeClassType } from '../emoji/lib';
import type {
HydratedBodyRangesType,
LocalizerType,
RenderTextCallbackType,
} from '../../types/Util';
const CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__message-search-result-contents`;
export type Props = {
bodyRanges: HydratedBodyRangesType;
text: string;
i18n: LocalizerType;
};
const renderEmoji = ({
text,
key,
sizeClass,
renderNonEmoji,
}: {
i18n: LocalizerType;
text: string;
key: number;
sizeClass?: SizeClassType;
renderNonEmoji: RenderTextCallbackType;
}) => (
<Emojify
key={key}
text={text}
sizeClass={sizeClass}
renderNonEmoji={renderNonEmoji}
/>
);
export class MessageBodyHighlight extends React.Component<Props> {
private readonly renderNewLines: RenderTextCallbackType = ({
text: textWithNewLines,
key,
}) => {
const { bodyRanges } = this.props;
return (
<AddNewLines
key={key}
text={textWithNewLines}
renderNonNewLine={({ text, key: innerKey }) => (
<AtMentionify bodyRanges={bodyRanges} key={innerKey} text={text} />
)}
/>
);
};
private renderContents(): ReactNode {
const { bodyRanges, text, i18n } = this.props;
const results: Array<JSX.Element> = [];
const FIND_BEGIN_END = /<<left>>(.+?)<<right>>/g;
const processedText = AtMentionify.preprocessMentions(text, bodyRanges);
let match = FIND_BEGIN_END.exec(processedText);
let last = 0;
let count = 1;
if (!match) {
return (
<MessageBody
bodyRanges={bodyRanges}
disableJumbomoji
disableLinks
text={text}
i18n={i18n}
/>
);
}
const sizeClass = '';
while (match) {
if (last < match.index) {
const beforeText = processedText.slice(last, match.index);
count += 1;
results.push(
renderEmoji({
text: beforeText,
sizeClass,
key: count,
i18n,
renderNonEmoji: this.renderNewLines,
})
);
}
const [, toHighlight] = match;
count += 2;
results.push(
<span className="MessageBody__highlight" key={count - 1}>
{renderEmoji({
text: toHighlight,
sizeClass,
key: count,
i18n,
renderNonEmoji: this.renderNewLines,
})}
</span>
);
last = FIND_BEGIN_END.lastIndex;
match = FIND_BEGIN_END.exec(processedText);
}
if (last < processedText.length) {
count += 1;
results.push(
renderEmoji({
text: processedText.slice(last),
sizeClass,
key: count,
i18n,
renderNonEmoji: this.renderNewLines,
})
);
}
return results;
}
public override render(): ReactNode {
return <div className={CLASS_NAME}>{this.renderContents()}</div>;
}
}

View file

@ -3,7 +3,6 @@
import * as React from 'react'; import * as React from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { boolean, text } from '@storybook/addon-knobs';
import { setupI18n } from '../../util/setupI18n'; import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
@ -13,6 +12,7 @@ import { getFakeBadge } from '../../test-both/helpers/getFakeBadge';
import type { PropsType } from './MessageSearchResult'; import type { PropsType } from './MessageSearchResult';
import { MessageSearchResult } from './MessageSearchResult'; import { MessageSearchResult } from './MessageSearchResult';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { BodyRange } from '../../types/BodyRange';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -43,21 +43,15 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
id: '', id: '',
conversationId: '', conversationId: '',
sentAt: Date.now() - 24 * 60 * 1000, sentAt: Date.now() - 24 * 60 * 1000,
snippet: text( snippet: overrideProps.snippet || "What's <<left>>going<<right>> on?",
'snippet', body: overrideProps.body || "What's going on?",
overrideProps.snippet || "What's <<left>>going<<right>> on?"
),
body: text('body', overrideProps.body || "What's going on?"),
bodyRanges: overrideProps.bodyRanges || [], bodyRanges: overrideProps.bodyRanges || [],
from: overrideProps.from as PropsType['from'], from: overrideProps.from as PropsType['from'],
to: overrideProps.to as PropsType['to'], to: overrideProps.to as PropsType['to'],
getPreferredBadge: overrideProps.getPreferredBadge || (() => undefined), getPreferredBadge: overrideProps.getPreferredBadge || (() => undefined),
isSelected: boolean('isSelected', overrideProps.isSelected || false), isSelected: overrideProps.isSelected || false,
showConversation: action('showConversation'), showConversation: action('showConversation'),
isSearchingInConversation: boolean( isSearchingInConversation: overrideProps.isSearchingInConversation || false,
'isSearchingInConversation',
overrideProps.isSearchingInConversation || false
),
theme: React.useContext(StorybookThemeContext), theme: React.useContext(StorybookThemeContext),
}); });
@ -220,7 +214,7 @@ export function Mention(): JSX.Element {
from: someone, from: someone,
to: me, to: me,
snippet: snippet:
'...forget hair dry diary years no <<left>>results<<right>> \uFFFC <<left>>elephant<<right>> sorry umbrella potato igloo kangaroo home Georgia...', '<<truncation>>forget hair dry diary years no <<left>>results<<right>> \uFFFC <<left>>elephant<<right>> sorry umbrella potato igloo kangaroo home Georgia<<truncation>>',
}); });
return <MessageSearchResult {...props} />; return <MessageSearchResult {...props} />;
@ -245,7 +239,7 @@ export function MentionRegexp(): JSX.Element {
from: someone, from: someone,
to: me, to: me,
snippet: snippet:
'\uFFFC This is a (long) /text/ ^$ that is ... <<left>>specially<<right>> **crafted** to (test) our regexp escaping mechanism...', '\uFFFC This is a (long) /text/ ^$ that is ... <<left>>specially<<right>> **crafted** to (test) our regexp escaping mechanism<<truncation>>',
}); });
return <MessageSearchResult {...props} />; return <MessageSearchResult {...props} />;
@ -301,7 +295,7 @@ export const _MentionNoMatches = (): JSX.Element => {
from: someone, from: someone,
to: me, to: me,
snippet: snippet:
'...forget hair dry diary years no results \uFFFC elephant sorry umbrella potato igloo kangaroo home Georgia...', '<<truncation>>forget hair dry diary years no results \uFFFC elephant sorry umbrella potato igloo kangaroo home Georgia<<truncation>>',
}); });
return <MessageSearchResult {...props} />; return <MessageSearchResult {...props} />;
@ -313,7 +307,7 @@ _MentionNoMatches.story = {
export function DoubleMention(): JSX.Element { export function DoubleMention(): JSX.Element {
const props = useProps({ const props = useProps({
body: 'Hey \uFFFC \uFFFC test', body: 'Hey \uFFFC \uFFFC --- test! Two mentions!',
bodyRanges: [ bodyRanges: [
{ {
length: 1, length: 1,
@ -332,7 +326,7 @@ export function DoubleMention(): JSX.Element {
], ],
from: someone, from: someone,
to: me, to: me,
snippet: '<<left>>Hey<<right>> \uFFFC \uFFFC <<left>>test<<right>>', snippet: '<<left>>Hey<<right>> \uFFFC \uFFFC --- test! <<truncation>>',
}); });
return <MessageSearchResult {...props} />; return <MessageSearchResult {...props} />;
@ -341,3 +335,41 @@ export function DoubleMention(): JSX.Element {
DoubleMention.story = { DoubleMention.story = {
name: 'Double @mention', name: 'Double @mention',
}; };
export function WithFormatting(): JSX.Element {
const props = useProps({
body: "We're playing with formatting in fun ways like you do!",
bodyRanges: [
{
// Overlaps just start
start: 0,
length: 19,
style: BodyRange.Style.BOLD,
},
{
// Contains snippet entirely
start: 0,
length: 54,
style: BodyRange.Style.ITALIC,
},
{
// Contained by snippet
start: 19,
length: 10,
style: BodyRange.Style.MONOSPACE,
},
{
// Overlaps just end
start: 29,
length: 25,
style: BodyRange.Style.STRIKETHROUGH,
},
],
from: someone,
to: me,
snippet:
'<<truncation>>playing with formatting in <<left>>fun<<right>> ways<<truncation>>',
});
return <MessageSearchResult {...props} />;
}

View file

@ -3,17 +3,12 @@
import type { FunctionComponent, ReactNode } from 'react'; import type { FunctionComponent, ReactNode } from 'react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { escapeRegExp } from 'lodash';
import { MessageBodyHighlight } from './MessageBodyHighlight';
import { ContactName } from '../conversation/ContactName'; import { ContactName } from '../conversation/ContactName';
import { assertDev } from '../../util/assert'; import type { BodyRangesForDisplayType } from '../../types/BodyRange';
import type { import { processBodyRangesForSearchResult } from '../../types/BodyRange';
HydratedBodyRangesType, import type { LocalizerType, ThemeType } from '../../types/Util';
LocalizerType,
ThemeType,
} from '../../types/Util';
import { BaseConversationListItem } from './BaseConversationListItem'; import { BaseConversationListItem } from './BaseConversationListItem';
import type { import type {
ConversationType, ConversationType,
@ -21,6 +16,10 @@ import type {
} from '../../state/ducks/conversations'; } from '../../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges'; import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
import { Intl } from '../Intl'; import { Intl } from '../Intl';
import {
MessageTextRenderer,
RenderLocation,
} from '../conversation/MessageTextRenderer';
export type PropsDataType = { export type PropsDataType = {
isSelected?: boolean; isSelected?: boolean;
@ -32,7 +31,7 @@ export type PropsDataType = {
snippet: string; snippet: string;
body: string; body: string;
bodyRanges: HydratedBodyRangesType; bodyRanges: BodyRangesForDisplayType;
from: Pick< from: Pick<
ConversationType, ConversationType,
@ -73,68 +72,6 @@ const renderPerson = (
): ReactNode => ): ReactNode =>
person.isMe ? i18n('icu:you') : <ContactName title={person.title} />; person.isMe ? i18n('icu:you') : <ContactName title={person.title} />;
// This function exists because bodyRanges tells us the character position
// where the at-mention starts at according to the full body text. The snippet
// we get back is a portion of the text and we don't know where it starts. This
// function will find the relevant bodyRanges that apply to the snippet and
// then update the proper start position of each body range.
function getFilteredBodyRanges(
snippet: string,
body: string,
bodyRanges: HydratedBodyRangesType
): HydratedBodyRangesType {
if (!bodyRanges.length) {
return [];
}
// Find where the snippet starts in the full text
const stripped = snippet
.replace(/<<left>>/g, '')
.replace(/<<right>>/g, '')
.replace(/^.../, '')
.replace(/...$/, '');
const rx = new RegExp(escapeRegExp(stripped));
const match = rx.exec(body);
assertDev(Boolean(match), `No match found for "${snippet}" inside "${body}"`);
const delta = match ? match.index + snippet.length : 0;
// Filters out the @mentions that are present inside the snippet
const filteredBodyRanges = bodyRanges.filter(bodyRange => {
return bodyRange.start < delta;
});
const snippetBodyRanges = [];
const MENTIONS_REGEX = /\uFFFC/g;
let bodyRangeMatch = MENTIONS_REGEX.exec(snippet);
let i = 0;
// Find the start position within the snippet so these can later be
// encoded and rendered correctly.
while (bodyRangeMatch) {
const bodyRange = filteredBodyRanges[i];
if (bodyRange) {
snippetBodyRanges.push({
...bodyRange,
start: bodyRangeMatch.index,
});
} else {
assertDev(
false,
`Body range does not exist? Count: ${i}, Length: ${filteredBodyRanges.length}`
);
}
bodyRangeMatch = MENTIONS_REGEX.exec(snippet);
i += 1;
}
return snippetBodyRanges;
}
export const MessageSearchResult: FunctionComponent<PropsType> = React.memo( export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
function MessageSearchResult({ function MessageSearchResult({
body, body,
@ -219,12 +156,20 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
} }
} }
const snippetBodyRanges = getFilteredBodyRanges(snippet, body, bodyRanges); const { cleanedSnippet, bodyRanges: displayBodyRanges } =
processBodyRangesForSearchResult({ snippet, body, bodyRanges });
const messageText = ( const messageText = (
<MessageBodyHighlight <MessageTextRenderer
text={snippet} messageText={cleanedSnippet}
bodyRanges={snippetBodyRanges} bodyRanges={displayBodyRanges}
direction={undefined}
disableLinks
emojiSizeClass={undefined}
i18n={i18n} i18n={i18n}
isSpoilerExpanded={false}
onMentionTrigger={() => null}
renderLocation={RenderLocation.SearchResult}
textLength={cleanedSnippet.length}
/> />
); );

View file

@ -22,7 +22,8 @@ import type {
ReactionType, ReactionType,
} from '../../textsecure/SendMessage'; } from '../../textsecure/SendMessage';
import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import type { BodyRangesType, StoryContextType } from '../../types/Util'; import { BodyRange } from '../../types/BodyRange';
import type { StoryContextType } from '../../types/Util';
import type { LoggerType } from '../../types/Logging'; import type { LoggerType } from '../../types/Logging';
import type { StickerWithHydratedData } from '../../types/Stickers'; import type { StickerWithHydratedData } from '../../types/Stickers';
import type { QuotedMessageType } from '../../model-types.d'; import type { QuotedMessageType } from '../../model-types.d';
@ -471,7 +472,7 @@ async function getMessageSendData({
contact?: Array<ContactWithHydratedAvatar>; contact?: Array<ContactWithHydratedAvatar>;
deletedForEveryoneTimestamp: undefined | number; deletedForEveryoneTimestamp: undefined | number;
expireTimer: undefined | DurationInSeconds; expireTimer: undefined | DurationInSeconds;
mentions: undefined | BodyRangesType; mentions: undefined | ReadonlyArray<BodyRange<BodyRange.Mention>>;
messageTimestamp: number; messageTimestamp: number;
preview: Array<LinkPreviewType>; preview: Array<LinkPreviewType>;
quote: QuotedMessageType | null; quote: QuotedMessageType | null;
@ -538,7 +539,7 @@ async function getMessageSendData({
contact, contact,
deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'), deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'),
expireTimer: message.get('expireTimer'), expireTimer: message.get('expireTimer'),
mentions: message.get('bodyRanges'), mentions: message.get('bodyRanges')?.filter(BodyRange.isMention),
messageTimestamp, messageTimestamp,
preview, preview,
quote, quote,

16
ts/model-types.d.ts vendored
View file

@ -6,7 +6,7 @@
import * as Backbone from 'backbone'; import * as Backbone from 'backbone';
import type { GroupV2ChangeType } from './groups'; import type { GroupV2ChangeType } from './groups';
import type { DraftBodyRangesType, BodyRangesType } from './types/Util'; import type { DraftBodyRangeMention, RawBodyRange } from './types/BodyRange';
import type { CallHistoryDetailsFromDiskType } from './types/Calling'; import type { CallHistoryDetailsFromDiskType } from './types/Calling';
import type { CustomColorType, ConversationColorType } from './types/Colors'; import type { CustomColorType, ConversationColorType } from './types/Colors';
import type { DeviceType } from './textsecure/Types.d'; import type { DeviceType } from './textsecure/Types.d';
@ -82,7 +82,7 @@ export type QuotedMessageType = {
// new messages, but old messages might have this attribute. // new messages, but old messages might have this attribute.
author?: string; author?: string;
authorUuid?: string; authorUuid?: string;
bodyRanges?: BodyRangesType; bodyRanges?: ReadonlyArray<RawBodyRange>;
id: number; id: number;
isGiftBadge?: boolean; isGiftBadge?: boolean;
isViewOnce: boolean; isViewOnce: boolean;
@ -123,14 +123,14 @@ export type MessageReactionType = {
export type EditHistoryType = { export type EditHistoryType = {
attachments?: Array<AttachmentType>; attachments?: Array<AttachmentType>;
body?: string; body?: string;
bodyRanges?: BodyRangesType; bodyRanges?: ReadonlyArray<RawBodyRange>;
preview?: Array<LinkPreviewType>; preview?: Array<LinkPreviewType>;
timestamp: number; timestamp: number;
}; };
export type MessageAttributesType = { export type MessageAttributesType = {
bodyAttachment?: AttachmentType; bodyAttachment?: AttachmentType;
bodyRanges?: BodyRangesType; bodyRanges?: ReadonlyArray<RawBodyRange>;
callHistoryDetails?: CallHistoryDetailsFromDiskType; callHistoryDetails?: CallHistoryDetailsFromDiskType;
canReplyToStory?: boolean; canReplyToStory?: boolean;
changedId?: string; changedId?: string;
@ -298,7 +298,7 @@ export type ConversationAttributesType = {
firstUnregisteredAt?: number; firstUnregisteredAt?: number;
draftChanged?: boolean; draftChanged?: boolean;
draftAttachments?: ReadonlyArray<AttachmentDraftType>; draftAttachments?: ReadonlyArray<AttachmentDraftType>;
draftBodyRanges?: DraftBodyRangesType; draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>;
draftTimestamp?: number | null; draftTimestamp?: number | null;
hideStory?: boolean; hideStory?: boolean;
inbox_position?: number; inbox_position?: number;
@ -310,8 +310,11 @@ export type ConversationAttributesType = {
removalStage?: 'justNotification' | 'messageRequest'; removalStage?: 'justNotification' | 'messageRequest';
isPinned?: boolean; isPinned?: boolean;
lastMessageDeletedForEveryone?: boolean; lastMessageDeletedForEveryone?: boolean;
lastMessageStatus?: LastMessageStatus | null; lastMessage?: string | null;
lastMessageBodyRanges?: ReadonlyArray<RawBodyRange>;
lastMessagePrefix?: string;
lastMessageAuthor?: string | null; lastMessageAuthor?: string | null;
lastMessageStatus?: LastMessageStatus | null;
markedUnread?: boolean; markedUnread?: boolean;
messageCount?: number; messageCount?: number;
messageCountBeforeMessageRequests?: number | null; messageCountBeforeMessageRequests?: number | null;
@ -340,7 +343,6 @@ export type ConversationAttributesType = {
draft?: string | null; draft?: string | null;
hasPostedStory?: boolean; hasPostedStory?: boolean;
isArchived?: boolean; isArchived?: boolean;
lastMessage?: string | null;
name?: string; name?: string;
systemGivenName?: string; systemGivenName?: string;
systemFamilyName?: string; systemFamilyName?: string;

View file

@ -51,6 +51,7 @@ import type {
} from '../textsecure/Types.d'; } from '../textsecure/Types.d';
import type { import type {
ConversationType, ConversationType,
DraftPreviewType,
LastMessageType, LastMessageType,
} from '../state/ducks/conversations'; } from '../state/ducks/conversations';
import type { import type {
@ -83,8 +84,8 @@ import {
deriveAccessKey, deriveAccessKey,
} from '../Crypto'; } from '../Crypto';
import * as Bytes from '../Bytes'; import * as Bytes from '../Bytes';
import type { BodyRangesType, DraftBodyRangesType } from '../types/Util'; import type { DraftBodyRangeMention } from '../types/BodyRange';
import { getTextWithMentions } from '../util/getTextWithMentions'; import { BodyRange } from '../types/BodyRange';
import { migrateColor } from '../util/migrateColor'; import { migrateColor } from '../util/migrateColor';
import { isNotNil } from '../util/isNotNil'; import { isNotNil } from '../util/isNotNil';
import { dropNull } from '../util/dropNull'; import { dropNull } from '../util/dropNull';
@ -153,6 +154,8 @@ import { isMemberPending } from '../util/isMemberPending';
import { imageToBlurHash } from '../util/imageToBlurHash'; import { imageToBlurHash } from '../util/imageToBlurHash';
import { ReceiptType } from '../types/Receipt'; import { ReceiptType } from '../types/Receipt';
import { getQuoteAttachment } from '../util/makeQuote'; import { getQuoteAttachment } from '../util/makeQuote';
import { stripNewlinesForLeftPane } from '../util/stripNewlinesForLeftPane';
import { findAndFormatContact } from '../util/findAndFormatContact';
const EMPTY_ARRAY: Readonly<[]> = []; const EMPTY_ARRAY: Readonly<[]> = [];
const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {}; const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {};
@ -1085,37 +1088,59 @@ export class ConversationModel extends window.Backbone
draftAttachments.length > 0) as boolean; draftAttachments.length > 0) as boolean;
} }
getDraftPreview(): string { getDraftPreview(): DraftPreviewType {
const draft = this.get('draft'); const draft = this.get('draft');
if (draft) { const rawBodyRanges = this.get('draftBodyRanges') || [];
const bodyRanges = this.get('draftBodyRanges') || []; const bodyRanges = rawBodyRanges.map(range => {
// Hydrate user information on mention
if (BodyRange.isMention(range)) {
const conversation = findAndFormatContact(range.mentionUuid);
return getTextWithMentions(bodyRanges, draft); return {
...range,
conversationID: conversation.id,
replacementText: conversation.title,
};
}
if (BodyRange.isFormatting(range)) {
return range;
}
throw missingCaseError(range);
});
if (draft) {
return {
text: stripNewlinesForLeftPane(draft),
bodyRanges,
};
} }
const draftAttachments = this.get('draftAttachments') || []; const draftAttachments = this.get('draftAttachments') || [];
if (draftAttachments.length > 0) { if (draftAttachments.length > 0) {
if (isVoiceMessage(draftAttachments[0])) { if (isVoiceMessage(draftAttachments[0])) {
return window.i18n( return {
'icu:message--getNotificationText--text-with-emoji', text: window.i18n('icu:message--getNotificationText--voice-message'),
{ prefix: '🎤',
text: window.i18n( };
'icu:message--getNotificationText--voice-message'
),
emoji: '🎤',
}
);
} }
return window.i18n('icu:Conversation--getDraftPreview--attachment'); return {
text: window.i18n('icu:Conversation--getDraftPreview--attachment'),
};
} }
const quotedMessageId = this.get('quotedMessageId'); const quotedMessageId = this.get('quotedMessageId');
if (quotedMessageId) { if (quotedMessageId) {
return window.i18n('icu:Conversation--getDraftPreview--quote'); return {
text: window.i18n('icu:Conversation--getDraftPreview--quote'),
};
} }
return window.i18n('icu:Conversation--getDraftPreview--draft'); return {
text: window.i18n('icu:Conversation--getDraftPreview--draft'),
};
} }
bumpTyping(): void { bumpTyping(): void {
@ -3857,7 +3882,7 @@ export class ConversationModel extends window.Backbone
} }
private getDraftBodyRanges = memoizeByThis( private getDraftBodyRanges = memoizeByThis(
(): DraftBodyRangesType | undefined => { (): ReadonlyArray<DraftBodyRangeMention> | undefined => {
return this.get('draftBodyRanges'); return this.get('draftBodyRanges');
} }
); );
@ -3870,11 +3895,37 @@ export class ConversationModel extends window.Backbone
if (!lastMessageText) { if (!lastMessageText) {
return undefined; return undefined;
} }
const rawBodyRanges = this.get('lastMessageBodyRanges') || [];
const bodyRanges = rawBodyRanges.map(range => {
// Hydrate user information on mention
if (BodyRange.isMention(range)) {
const conversation = findAndFormatContact(range.mentionUuid);
return {
...range,
conversationID: conversation.id,
replacementText: conversation.title,
};
}
if (BodyRange.isFormatting(range)) {
return range;
}
throw missingCaseError(range);
});
const text = stripNewlinesForLeftPane(lastMessageText);
const prefix = this.get('lastMessagePrefix');
return { return {
status: dropNull(this.get('lastMessageStatus')),
text: lastMessageText,
author: dropNull(this.get('lastMessageAuthor')), author: dropNull(this.get('lastMessageAuthor')),
bodyRanges,
deletedForEveryone: false, deletedForEveryone: false,
prefix,
status: dropNull(this.get('lastMessageStatus')),
text,
}; };
}); });
@ -4119,7 +4170,7 @@ export class ConversationModel extends window.Backbone
attachments: Array<AttachmentType>; attachments: Array<AttachmentType>;
body: string | undefined; body: string | undefined;
contact?: Array<ContactWithHydratedAvatar>; contact?: Array<ContactWithHydratedAvatar>;
mentions?: BodyRangesType; mentions?: ReadonlyArray<BodyRange<BodyRange.Mention>>;
preview?: Array<LinkPreviewType>; preview?: Array<LinkPreviewType>;
quote?: QuotedMessageType; quote?: QuotedMessageType;
sticker?: StickerWithHydratedData; sticker?: StickerWithHydratedData;
@ -4475,9 +4526,12 @@ export class ConversationModel extends window.Backbone
} }
timestamp = timestamp || currentTimestamp; timestamp = timestamp || currentTimestamp;
const notificationData = previewMessage?.getNotificationData();
this.set({ this.set({
lastMessage: lastMessage: notificationData?.text || '',
(previewMessage ? previewMessage.getNotificationText() : '') || '', lastMessageBodyRanges: notificationData?.bodyRanges,
lastMessagePrefix: notificationData?.emoji,
lastMessageAuthor: previewMessage?.getAuthorText(), lastMessageAuthor: previewMessage?.getAuthorText(),
lastMessageStatus: lastMessageStatus:
(previewMessage (previewMessage
@ -5514,7 +5568,7 @@ export class ConversationModel extends window.Backbone
const ourUuid = window.textsecure.storage.user.getUuid()?.toString(); const ourUuid = window.textsecure.storage.user.getUuid()?.toString();
const mentionsMe = (message.get('bodyRanges') || []).some( const mentionsMe = (message.get('bodyRanges') || []).some(
range => range.mentionUuid && range.mentionUuid === ourUuid range => BodyRange.isMention(range) && range.mentionUuid === ourUuid
); );
if (!mentionsMe) { if (!mentionsMe) {
return; return;

View file

@ -115,8 +115,8 @@ import {
isUniversalTimerNotification, isUniversalTimerNotification,
isUnsupportedMessage, isUnsupportedMessage,
isVerifiedChange, isVerifiedChange,
processBodyRanges,
isConversationMerge, isConversationMerge,
extractHydratedMentions,
} from '../state/selectors/message'; } from '../state/selectors/message';
import { import {
isInCall, isInCall,
@ -185,6 +185,8 @@ import * as Edits from '../messageModifiers/Edits';
import { handleEditMessage } from '../util/handleEditMessage'; import { handleEditMessage } from '../util/handleEditMessage';
import { getQuoteBodyText } from '../util/getQuoteBodyText'; import { getQuoteBodyText } from '../util/getQuoteBodyText';
import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser'; import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser';
import type { RawBodyRange } from '../types/BodyRange';
import { BodyRange, applyRangesForText } from '../types/BodyRange';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
@ -192,7 +194,7 @@ window.Whisper = window.Whisper || {};
const { Message: TypedMessage } = window.Signal.Types; const { Message: TypedMessage } = window.Signal.Types;
const { upgradeMessageSchema } = window.Signal.Migrations; const { upgradeMessageSchema } = window.Signal.Migrations;
const { getTextWithMentions, GoogleChrome } = window.Signal.Util; const { GoogleChrome } = window.Signal.Util;
const { getMessageBySender } = window.Signal.Data; const { getMessageBySender } = window.Signal.Data;
export class MessageModel extends window.Backbone.Model<MessageAttributesType> { export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
@ -415,7 +417,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return window.ConversationController.get(this.get('conversationId')); return window.ConversationController.get(this.get('conversationId'));
} }
getNotificationData(): { emoji?: string; text: string } { getNotificationData(): {
emoji?: string;
text: string;
bodyRanges?: ReadonlyArray<RawBodyRange>;
} {
// eslint-disable-next-line prefer-destructuring // eslint-disable-next-line prefer-destructuring
const attributes: MessageAttributesType = this.attributes; const attributes: MessageAttributesType = this.attributes;
@ -654,6 +660,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
const body = (this.get('body') || '').trim(); const body = (this.get('body') || '').trim();
const bodyRanges = this.get('bodyRanges') || [];
if (attachments.length) { if (attachments.length) {
// This should never happen but we want to be extra-careful. // This should never happen but we want to be extra-careful.
@ -662,39 +669,46 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
if (contentType === MIME.IMAGE_GIF || Attachment.isGIF(attachments)) { if (contentType === MIME.IMAGE_GIF || Attachment.isGIF(attachments)) {
return { return {
text: body || window.i18n('icu:message--getNotificationText--gif'), bodyRanges,
emoji: '🎡', emoji: '🎡',
text: body || window.i18n('icu:message--getNotificationText--gif'),
}; };
} }
if (Attachment.isImage(attachments)) { if (Attachment.isImage(attachments)) {
return { return {
text: body || window.i18n('icu:message--getNotificationText--photo'), bodyRanges,
emoji: '📷', emoji: '📷',
text: body || window.i18n('icu:message--getNotificationText--photo'),
}; };
} }
if (Attachment.isVideo(attachments)) { if (Attachment.isVideo(attachments)) {
return { return {
text: body || window.i18n('icu:message--getNotificationText--video'), bodyRanges,
emoji: '🎥', emoji: '🎥',
text: body || window.i18n('icu:message--getNotificationText--video'),
}; };
} }
if (Attachment.isVoiceMessage(attachment)) { if (Attachment.isVoiceMessage(attachment)) {
return { return {
bodyRanges,
emoji: '🎤',
text: text:
body || body ||
window.i18n('icu:message--getNotificationText--voice-message'), window.i18n('icu:message--getNotificationText--voice-message'),
emoji: '🎤',
}; };
} }
if (Attachment.isAudio(attachments)) { if (Attachment.isAudio(attachments)) {
return { return {
bodyRanges,
emoji: '🔈',
text: text:
body || body ||
window.i18n('icu:message--getNotificationText--audio-message'), window.i18n('icu:message--getNotificationText--audio-message'),
emoji: '🔈',
}; };
} }
return { return {
bodyRanges,
text: body || window.i18n('icu:message--getNotificationText--file'), text: body || window.i18n('icu:message--getNotificationText--file'),
emoji: '📎', emoji: '📎',
}; };
@ -793,26 +807,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
if (body) { if (body) {
return { text: body }; return {
text: body,
bodyRanges,
};
} }
return { text: '' }; return { text: '' };
} }
getRawText(): string {
const body = (this.get('body') || '').trim();
const { attributes } = this;
const bodyRanges = processBodyRanges(attributes, {
conversationSelector: findAndFormatContact,
});
if (bodyRanges) {
return getTextWithMentions(bodyRanges, body);
}
return body;
}
getAuthorText(): string | undefined { getAuthorText(): string | undefined {
// if it's outgoing, it must be self-authored // if it's outgoing, it must be self-authored
const selfAuthor = isOutgoing(this.attributes) const selfAuthor = isOutgoing(this.attributes)
@ -867,15 +870,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return window.i18n('icu:Quote__story-reaction--single'); return window.i18n('icu:Quote__story-reaction--single');
} }
let modifiedText = text; const mentions =
extractHydratedMentions(attributes, {
const bodyRanges = processBodyRanges(attributes, { conversationSelector: findAndFormatContact,
conversationSelector: findAndFormatContact, }) || [];
}); const spoilers = (attributes.bodyRanges || []).filter(
range =>
if (bodyRanges && bodyRanges.length) { BodyRange.isFormatting(range) && range.style === BodyRange.Style.SPOILER
modifiedText = getTextWithMentions(bodyRanges, modifiedText); ) as Array<BodyRange<BodyRange.Formatting>>;
} const modifiedText = applyRangesForText({ text, mentions, spoilers });
// Linux emoji support is mixed, so we disable it. (Note that this doesn't touch // Linux emoji support is mixed, so we disable it. (Note that this doesn't touch
// the `text`, which can contain emoji.) // the `text`, which can contain emoji.)
@ -886,7 +889,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
emoji, emoji,
}); });
} }
return modifiedText;
return modifiedText || '';
} }
// General // General

View file

@ -6,7 +6,8 @@ import Delta from 'quill-delta';
import type { LeafBlot, DeltaOperation } from 'quill'; import type { LeafBlot, DeltaOperation } from 'quill';
import type Op from 'quill-delta/dist/Op'; import type Op from 'quill-delta/dist/Op';
import type { DraftBodyRangeType, DraftBodyRangesType } from '../types/Util'; import type { DraftBodyRangeMention } from '../types/BodyRange';
import { BodyRange } from '../types/BodyRange';
import type { MentionBlot } from './mentions/blot'; import type { MentionBlot } from './mentions/blot';
export type MentionBlotValue = { export type MentionBlotValue = {
@ -61,8 +62,8 @@ export const getTextFromOps = (ops: Array<DeltaOperation>): string =>
export const getTextAndMentionsFromOps = ( export const getTextAndMentionsFromOps = (
ops: Array<Op> ops: Array<Op>
): [string, DraftBodyRangesType] => { ): [string, ReadonlyArray<DraftBodyRangeMention>] => {
const mentions: Array<DraftBodyRangeType> = []; const mentions: Array<DraftBodyRangeMention> = [];
const text = ops const text = ops
.reduce((acc, op, index) => { .reduce((acc, op, index) => {
@ -168,11 +169,11 @@ export const getDeltaToRemoveStaleMentions = (
export const insertMentionOps = ( export const insertMentionOps = (
incomingOps: Array<Op>, incomingOps: Array<Op>,
bodyRanges: DraftBodyRangesType bodyRanges: ReadonlyArray<DraftBodyRangeMention>
): Array<Op> => { ): Array<Op> => {
const ops = [...incomingOps]; const ops = [...incomingOps];
const sortableBodyRanges: Array<DraftBodyRangeType> = bodyRanges.slice(); const sortableBodyRanges: Array<DraftBodyRangeMention> = bodyRanges.slice();
// Working backwards through bodyRanges (to avoid offsetting later mentions), // Working backwards through bodyRanges (to avoid offsetting later mentions),
// Shift off the op with the text to the left of the last mention, // Shift off the op with the text to the left of the last mention,
@ -180,7 +181,13 @@ export const insertMentionOps = (
// Unshift the mention and surrounding text to leave the ops ready for the next range // Unshift the mention and surrounding text to leave the ops ready for the next range
sortableBodyRanges sortableBodyRanges
.sort((a, b) => b.start - a.start) .sort((a, b) => b.start - a.start)
.forEach(({ start, length, mentionUuid, replacementText }) => { .forEach(bodyRange => {
if (!BodyRange.isMention(bodyRange)) {
return;
}
const { start, length, mentionUuid, replacementText } = bodyRange;
const op = ops.shift(); const op = ops.shift();
if (op) { if (op) {

View file

@ -101,6 +101,7 @@ export function getStoryDataFromMessageAttributes(
attachment, attachment,
messageId: message.id, messageId: message.id,
...pick(message, [ ...pick(message, [
'bodyRanges',
'canReplyToStory', 'canReplyToStory',
'conversationId', 'conversationId',
'deletedForEveryone', 'deletedForEveryone',

View file

@ -11,13 +11,14 @@ import type { ReactionType } from '../types/Reactions';
import type { ConversationColorType, CustomColorType } from '../types/Colors'; import type { ConversationColorType, CustomColorType } from '../types/Colors';
import type { StorageAccessType } from '../types/Storage.d'; import type { StorageAccessType } from '../types/Storage.d';
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
import type { BodyRangesType, BytesToStrings } from '../types/Util'; import type { BytesToStrings } from '../types/Util';
import type { QualifiedAddressStringType } from '../types/QualifiedAddress'; import type { QualifiedAddressStringType } from '../types/QualifiedAddress';
import type { UUIDStringType } from '../types/UUID'; import type { UUIDStringType } from '../types/UUID';
import type { BadgeType } from '../badges/types'; import type { BadgeType } from '../badges/types';
import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration'; import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration';
import type { LoggerType } from '../types/Logging'; import type { LoggerType } from '../types/Logging';
import type { ReadStatus } from '../messages/MessageReadStatus'; import type { ReadStatus } from '../messages/MessageReadStatus';
import type { RawBodyRange } from '../types/BodyRange';
import type { GetMessagesBetweenOptions } from './Server'; import type { GetMessagesBetweenOptions } from './Server';
import type { MessageTimestamps } from '../state/ducks/conversations'; import type { MessageTimestamps } from '../state/ducks/conversations';
@ -129,7 +130,7 @@ export type ServerSearchResultMessageType = {
}; };
export type ClientSearchResultMessageType = MessageType & { export type ClientSearchResultMessageType = MessageType & {
json: string; json: string;
bodyRanges: BodyRangesType; bodyRanges: ReadonlyArray<RawBodyRange>;
snippet: string; snippet: string;
}; };

View file

@ -1733,7 +1733,7 @@ async function searchMessages(
` `
SELECT SELECT
messages.json, messages.json,
snippet(messages_fts, -1, '<<left>>', '<<right>>', '...', 10) snippet(messages_fts, -1, '<<left>>', '<<right>>', '<<truncation>>', 10)
AS snippet AS snippet
FROM tmp_filtered_results FROM tmp_filtered_results
INNER JOIN messages_fts INNER JOIN messages_fts

View file

@ -62,8 +62,8 @@ export namespace AudioPlayerContent {
content: ActiveAudioPlayerStateType['content'] content: ActiveAudioPlayerStateType['content']
): content is AudioPlayerContentVoiceNote { ): content is AudioPlayerContentVoiceNote {
return ( return (
('current' as const satisfies keyof AudioPlayerContentVoiceNote) in // satisfies keyof AudioPlayerContentVoiceNote
content ('current' as const) in content
); );
} }
export function isDraft( export function isDraft(

View file

@ -17,10 +17,8 @@ import type {
InMemoryAttachmentDraftType, InMemoryAttachmentDraftType,
} from '../../types/Attachment'; } from '../../types/Attachment';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import type { import type { DraftBodyRangeMention } from '../../types/BodyRange';
DraftBodyRangesType, import type { ReplacementValuesType } from '../../types/Util';
ReplacementValuesType,
} from '../../types/Util';
import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import type { MessageAttributesType } from '../../model-types.d'; import type { MessageAttributesType } from '../../model-types.d';
import type { NoopActionType } from './noop'; import type { NoopActionType } from './noop';
@ -382,7 +380,7 @@ function sendMultiMediaMessage(
conversationId: string, conversationId: string,
options: { options: {
draftAttachments?: ReadonlyArray<AttachmentDraftType>; draftAttachments?: ReadonlyArray<AttachmentDraftType>;
mentions?: DraftBodyRangesType; draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>;
message?: string; message?: string;
timestamp?: number; timestamp?: number;
voiceNoteAttachment?: InMemoryAttachmentDraftType; voiceNoteAttachment?: InMemoryAttachmentDraftType;
@ -406,8 +404,8 @@ function sendMultiMediaMessage(
const { const {
draftAttachments, draftAttachments,
draftBodyRanges,
message = '', message = '',
mentions,
timestamp = Date.now(), timestamp = Date.now(),
voiceNoteAttachment, voiceNoteAttachment,
} = options; } = options;
@ -497,7 +495,7 @@ function sendMultiMediaMessage(
attachments, attachments,
quote, quote,
preview: getLinkPreviewForSend(message), preview: getLinkPreviewForSend(message),
mentions, mentions: draftBodyRanges,
}, },
{ {
sendHQImages, sendHQImages,
@ -816,7 +814,7 @@ function onEditorStateChange({
messageText, messageText,
sendCounter, sendCounter,
}: { }: {
bodyRanges: DraftBodyRangesType; bodyRanges: ReadonlyArray<DraftBodyRangeMention>;
caretLocation?: number; caretLocation?: number;
conversationId: string | undefined; conversationId: string | undefined;
messageText: string; messageText: string;
@ -1171,7 +1169,7 @@ const debouncedSaveDraft = debounce(saveDraft);
function saveDraft( function saveDraft(
conversationId: string, conversationId: string,
messageText: string, messageText: string,
bodyRanges: DraftBodyRangesType mentions: ReadonlyArray<DraftBodyRangeMention>
) { ) {
const conversation = window.ConversationController.get(conversationId); const conversation = window.ConversationController.get(conversationId);
if (!conversation) { if (!conversation) {
@ -1205,7 +1203,7 @@ function saveDraft(
conversation.set({ conversation.set({
active_at: activeAt, active_at: activeAt,
draft: messageText, draft: messageText,
draftBodyRanges: bodyRanges, draftBodyRanges: mentions,
draftChanged: true, draftChanged: true,
timestamp, timestamp,
}); });

View file

@ -55,7 +55,10 @@ import type {
ConversationAttributesType, ConversationAttributesType,
MessageAttributesType, MessageAttributesType,
} from '../../model-types.d'; } from '../../model-types.d';
import type { DraftBodyRangesType } from '../../types/Util'; import type {
DraftBodyRangeMention,
HydratedBodyRangesType,
} from '../../types/BodyRange';
import { CallMode } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
import type { MediaItemType } from '../../types/MediaItem'; import type { MediaItemType } from '../../types/MediaItem';
import type { UUIDStringType } from '../../types/UUID'; import type { UUIDStringType } from '../../types/UUID';
@ -173,6 +176,7 @@ export type MessageType = MessageAttributesType & {
// eslint-disable-next-line local-rules/type-alias-readonlydeep // eslint-disable-next-line local-rules/type-alias-readonlydeep
export type MessageWithUIFieldsType = MessageAttributesType & { export type MessageWithUIFieldsType = MessageAttributesType & {
displayLimit?: number; displayLimit?: number;
isSpoilerExpanded?: boolean;
}; };
export const ConversationTypes = ['direct', 'group'] as const; export const ConversationTypes = ['direct', 'group'] as const;
@ -182,13 +186,20 @@ export type ConversationTypeType = ReadonlyDeep<
export type LastMessageType = ReadonlyDeep< export type LastMessageType = ReadonlyDeep<
| { | {
deletedForEveryone: false;
author?: string;
bodyRanges?: HydratedBodyRangesType;
prefix?: string;
status?: LastMessageStatus; status?: LastMessageStatus;
text: string; text: string;
author?: string;
deletedForEveryone: false;
} }
| { deletedForEveryone: true } | { deletedForEveryone: true }
>; >;
export type DraftPreviewType = ReadonlyDeep<{
text: string;
prefix?: string;
bodyRanges?: HydratedBodyRangesType;
}>;
export type ConversationType = ReadonlyDeep< export type ConversationType = ReadonlyDeep<
{ {
@ -275,9 +286,11 @@ export type ConversationType = ReadonlyDeep<
profileSharing?: boolean; profileSharing?: boolean;
shouldShowDraft?: boolean; shouldShowDraft?: boolean;
// Full information for re-hydrating composition area
draftText?: string; draftText?: string;
draftBodyRanges?: DraftBodyRangesType; draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>;
draftPreview?: string; // Summary for the left pane
draftPreview?: DraftPreviewType;
sharedGroupNames: ReadonlyArray<string>; sharedGroupNames: ReadonlyArray<string>;
groupDescription?: string; groupDescription?: string;
@ -524,6 +537,7 @@ export const MESSAGE_EXPIRED = 'conversations/MESSAGE_EXPIRED';
export const SET_VOICE_NOTE_PLAYBACK_RATE = export const SET_VOICE_NOTE_PLAYBACK_RATE =
'conversations/SET_VOICE_NOTE_PLAYBACK_RATE'; 'conversations/SET_VOICE_NOTE_PLAYBACK_RATE';
export const CONVERSATION_UNLOADED = 'CONVERSATION_UNLOADED'; export const CONVERSATION_UNLOADED = 'CONVERSATION_UNLOADED';
export const SHOW_SPOILER = 'conversations/SHOW_SPOILER';
export type CancelVerificationDataByConversationActionType = ReadonlyDeep<{ export type CancelVerificationDataByConversationActionType = ReadonlyDeep<{
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION; type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
@ -709,6 +723,12 @@ export type MessageExpandedActionType = ReadonlyDeep<{
displayLimit: number; displayLimit: number;
}; };
}>; }>;
export type ShowSpoilerActionType = ReadonlyDeep<{
type: typeof SHOW_SPOILER;
payload: {
id: string;
};
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep -- FIXME // eslint-disable-next-line local-rules/type-alias-readonlydeep -- FIXME
export type MessagesAddedActionType = Readonly<{ export type MessagesAddedActionType = Readonly<{
@ -939,6 +959,7 @@ export type ConversationActionType =
| ShowChooseGroupMembersActionType | ShowChooseGroupMembersActionType
| ShowInboxActionType | ShowInboxActionType
| ShowSendAnywayDialogActionType | ShowSendAnywayDialogActionType
| ShowSpoilerActionType
| StartComposingActionType | StartComposingActionType
| StartSettingGroupMetadataActionType | StartSettingGroupMetadataActionType
| ToggleComposeEditingAvatarActionType | ToggleComposeEditingAvatarActionType
@ -1027,6 +1048,7 @@ export const actions = {
saveAttachmentFromMessage, saveAttachmentFromMessage,
saveAvatarToDisk, saveAvatarToDisk,
scrollToMessage, scrollToMessage,
showSpoiler,
targetMessage, targetMessage,
setAccessControlAddFromInviteLinkSetting, setAccessControlAddFromInviteLinkSetting,
setAccessControlAttributesSetting, setAccessControlAttributesSetting,
@ -2619,6 +2641,15 @@ function messageExpanded(
}, },
}; };
} }
function showSpoiler(id: string): ShowSpoilerActionType {
return {
type: SHOW_SPOILER,
payload: {
id,
},
};
}
function messageExpired(id: string): MessageExpiredActionType { function messageExpired(id: string): MessageExpiredActionType {
return { return {
type: MESSAGE_EXPIRED, type: MESSAGE_EXPIRED,
@ -2770,11 +2801,14 @@ export type PushPanelForConversationActionType = ReadonlyDeep<
function pushPanelForConversation( function pushPanelForConversation(
panel: PanelRequestType panel: PanelRequestType
): ThunkAction<void, RootStateType, unknown, PushPanelActionType> { ): ThunkAction<void, RootStateType, unknown, PushPanelActionType> {
return async dispatch => { return async (dispatch, getState) => {
if (panel.type === PanelType.MessageDetails) { if (panel.type === PanelType.MessageDetails) {
const { messageId } = panel.args; const { messageId } = panel.args;
const state = getState();
const message = await getMessageById(messageId); const message =
state.conversations.messagesLookup[messageId] ||
(await getMessageById(messageId))?.attributes;
if (!message) { if (!message) {
throw new Error( throw new Error(
'pushPanelForConversation: could not find message for MessageDetails' 'pushPanelForConversation: could not find message for MessageDetails'
@ -2785,7 +2819,7 @@ function pushPanelForConversation(
payload: { payload: {
type: PanelType.MessageDetails, type: PanelType.MessageDetails,
args: { args: {
message: message.attributes, message,
}, },
}, },
}); });
@ -4758,11 +4792,17 @@ export function reducer(
? 1 ? 1
: 0; : 0;
const updatedMessage = {
...data,
displayLimit: existingMessage.displayLimit,
isSpoilerExpanded: existingMessage.isSpoilerExpanded,
};
return { return {
...maybeUpdateSelectedMessageForDetails( ...maybeUpdateSelectedMessageForDetails(
{ {
messageId: id, messageId: id,
targetedMessageForDetails: data, targetedMessageForDetails: updatedMessage,
}, },
state state
), ),
@ -4776,10 +4816,7 @@ export function reducer(
}, },
messagesLookup: { messagesLookup: {
...state.messagesLookup, ...state.messagesLookup,
[id]: { [id]: updatedMessage,
...data,
displayLimit: existingMessage.displayLimit,
},
}, },
}; };
} }
@ -4799,17 +4836,55 @@ export function reducer(
return state; return state;
} }
const updatedMessage = {
...existingMessage,
displayLimit,
};
return { return {
...state, ...state,
...maybeUpdateSelectedMessageForDetails(
{
messageId: id,
targetedMessageForDetails: updatedMessage,
},
state
),
messagesLookup: { messagesLookup: {
...state.messagesLookup, ...state.messagesLookup,
[id]: { [id]: updatedMessage,
...existingMessage,
displayLimit,
},
}, },
}; };
} }
if (action.type === SHOW_SPOILER) {
const { id } = action.payload;
const existingMessage = state.messagesLookup[id];
if (!existingMessage) {
return state;
}
const updatedMessage = {
...existingMessage,
isSpoilerExpanded: true,
};
return {
...state,
...maybeUpdateSelectedMessageForDetails(
{
messageId: id,
targetedMessageForDetails: updatedMessage,
},
state
),
messagesLookup: {
...state.messagesLookup,
[id]: updatedMessage,
},
};
}
if (action.type === 'MESSAGES_RESET') { if (action.type === 'MESSAGES_RESET') {
const { const {
conversationId, conversationId,

View file

@ -7,7 +7,7 @@ import { isEqual, pick } from 'lodash';
import type { ReadonlyDeep } from 'type-fest'; import type { ReadonlyDeep } from 'type-fest';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import type { AttachmentType } from '../../types/Attachment'; import type { AttachmentType } from '../../types/Attachment';
import type { DraftBodyRangesType } from '../../types/Util'; import type { DraftBodyRangeMention } from '../../types/BodyRange';
import type { MessageAttributesType } from '../../model-types.d'; import type { MessageAttributesType } from '../../model-types.d';
import type { import type {
MessageChangedActionType, MessageChangedActionType,
@ -77,6 +77,7 @@ export type StoryDataType = ReadonlyDeep<
startedDownload?: boolean; startedDownload?: boolean;
} & Pick< } & Pick<
MessageAttributesType, MessageAttributesType,
| 'bodyRanges'
| 'canReplyToStory' | 'canReplyToStory'
| 'conversationId' | 'conversationId'
| 'deletedForEveryone' | 'deletedForEveryone'
@ -558,7 +559,7 @@ function reactToStory(
function replyToStory( function replyToStory(
conversationId: string, conversationId: string,
messageBody: string, messageBody: string,
mentions: DraftBodyRangesType, mentions: ReadonlyArray<DraftBodyRangeMention>,
timestamp: number, timestamp: number,
story: StoryViewType story: StoryViewType
): ThunkAction<void, RootStateType, unknown, StoryChangedActionType> { ): ThunkAction<void, RootStateType, unknown, StoryChangedActionType> {
@ -1442,6 +1443,7 @@ export function reducer(
if (action.type === STORY_CHANGED) { if (action.type === STORY_CHANGED) {
const newStory = pick(action.payload, [ const newStory = pick(action.payload, [
'attachment', 'attachment',
'bodyRanges',
'canReplyToStory', 'canReplyToStory',
'conversationId', 'conversationId',
'deletedForEveryone', 'deletedForEveryone',
@ -1468,17 +1470,24 @@ export function reducer(
if (prevStoryIndex >= 0) { if (prevStoryIndex >= 0) {
const prevStory = state.stories[prevStoryIndex]; const prevStory = state.stories[prevStoryIndex];
// Stories rarely need to change, here are the following exceptions: // Stories rarely need to change, here are the following exceptions...
// These only change because of initialization order - these fields are updated
// after the model is created:
const bodyRangesChanged =
newStory.bodyRanges?.length !== prevStory.bodyRanges?.length;
const hasExpirationChanged =
(newStory.expirationStartTimestamp &&
!prevStory.expirationStartTimestamp) ||
(newStory.expireTimer && !prevStory.expireTimer);
// These reflect changes in status over time:
const isDownloadingAttachment = isDownloading(newStory.attachment); const isDownloadingAttachment = isDownloading(newStory.attachment);
const hasAttachmentDownloaded = const hasAttachmentDownloaded =
!isDownloaded(prevStory.attachment) && !isDownloaded(prevStory.attachment) &&
isDownloaded(newStory.attachment); isDownloaded(newStory.attachment);
const hasAttachmentFailed = const hasAttachmentFailed =
hasFailed(newStory.attachment) && !hasFailed(prevStory.attachment); hasFailed(newStory.attachment) && !hasFailed(prevStory.attachment);
const hasExpirationChanged =
(newStory.expirationStartTimestamp &&
!prevStory.expirationStartTimestamp) ||
(newStory.expireTimer && !prevStory.expireTimer);
const readStatusChanged = prevStory.readStatus !== newStory.readStatus; const readStatusChanged = prevStory.readStatus !== newStory.readStatus;
const reactionsChanged = const reactionsChanged =
prevStory.reactions?.length !== newStory.reactions?.length; prevStory.reactions?.length !== newStory.reactions?.length;
@ -1493,6 +1502,7 @@ export function reducer(
prevStory.hasRepliesFromSelf !== newStory.hasRepliesFromSelf; prevStory.hasRepliesFromSelf !== newStory.hasRepliesFromSelf;
const shouldReplace = const shouldReplace =
bodyRangesChanged ||
isDownloadingAttachment || isDownloadingAttachment ||
hasAttachmentDownloaded || hasAttachmentDownloaded ||
hasAttachmentFailed || hasAttachmentFailed ||

View file

@ -44,7 +44,12 @@ import type { UUIDStringType } from '../../types/UUID';
import type { EmbeddedContactType } from '../../types/EmbeddedContact'; import type { EmbeddedContactType } from '../../types/EmbeddedContact';
import { embeddedContactSelector } from '../../types/EmbeddedContact'; import { embeddedContactSelector } from '../../types/EmbeddedContact';
import type { AssertProps, HydratedBodyRangesType } from '../../types/Util'; import type {
HydratedBodyRangeMention,
HydratedBodyRangesType,
} from '../../types/BodyRange';
import { BodyRange } from '../../types/BodyRange';
import type { AssertProps } from '../../types/Util';
import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import { getMentionsRegex } from '../../types/Message'; import { getMentionsRegex } from '../../types/Message';
import { CallMode } from '../../types/Calling'; import { CallMode } from '../../types/Calling';
@ -312,7 +317,33 @@ export const processBodyRanges = (
} }
return bodyRanges return bodyRanges
.filter(range => range.mentionUuid) .map(range => {
const { conversationSelector } = options;
if (BodyRange.isMention(range)) {
const conversation = conversationSelector(range.mentionUuid);
return {
...range,
conversationID: conversation.id,
replacementText: conversation.title,
};
}
return range;
})
.sort((a, b) => b.start - a.start);
};
export const extractHydratedMentions = (
{ bodyRanges }: Pick<MessageWithUIFieldsType, 'bodyRanges'>,
options: { conversationSelector: GetConversationByIdType }
): ReadonlyArray<HydratedBodyRangeMention> | undefined => {
if (!bodyRanges) {
return undefined;
}
return bodyRanges
.filter(BodyRange.isMention)
.map(range => { .map(range => {
const { conversationSelector } = options; const { conversationSelector } = options;
const conversation = conversationSelector(range.mentionUuid); const conversation = conversationSelector(range.mentionUuid);
@ -724,11 +755,12 @@ export const getPropsForMessage = (
isBlocked: conversation.isBlocked || false, isBlocked: conversation.isBlocked || false,
isEditedMessage: Boolean(message.editHistory), isEditedMessage: Boolean(message.editHistory),
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true, isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
isTargeted,
isTargetedCounter: isTargeted ? targetedMessageCounter : undefined,
isSelected, isSelected,
isSelectMode, isSelectMode,
isSpoilerExpanded: message.isSpoilerExpanded,
isSticker: Boolean(sticker), isSticker: Boolean(sticker),
isTargeted,
isTargetedCounter: isTargeted ? targetedMessageCounter : undefined,
isTapToView: isMessageTapToView, isTapToView: isMessageTapToView,
isTapToViewError: isTapToViewError:
isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid, isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid,

View file

@ -28,9 +28,11 @@ import {
getConversationSelector, getConversationSelector,
} from './conversations'; } from './conversations';
import type { BodyRangeType, HydratedBodyRangeType } from '../../types/Util'; import type { HydratedBodyRangeType } from '../../types/BodyRange';
import { BodyRange } from '../../types/BodyRange';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { getOwn } from '../../util/getOwn'; import { getOwn } from '../../util/getOwn';
import { missingCaseError } from '../../util';
export const getSearch = (state: StateType): SearchStateType => state.search; export const getSearch = (state: StateType): SearchStateType => state.search;
@ -185,17 +187,24 @@ export const getCachedSelectorForMessageSearchResult = createSelector(
conversationId: message.conversationId, conversationId: message.conversationId,
sentAt: message.sent_at, sentAt: message.sent_at,
snippet: message.snippet || '', snippet: message.snippet || '',
bodyRanges: bodyRanges.map( bodyRanges: bodyRanges.map((range): HydratedBodyRangeType => {
(bodyRange: BodyRangeType): HydratedBodyRangeType => { // Hydrate user information on mention
const conversation = conversationSelector(bodyRange.mentionUuid); if (BodyRange.isMention(range)) {
const conversation = conversationSelector(range.mentionUuid);
return { return {
...bodyRange, ...range,
conversationID: conversation.id, conversationID: conversation.id,
replacementText: conversation.title, replacementText: conversation.title,
}; };
} }
),
if (BodyRange.isFormatting(range)) {
return range;
}
throw missingCaseError(range);
}),
body: message.body || '', body: message.body || '',
isSelected: Boolean( isSelected: Boolean(

View file

@ -42,6 +42,7 @@ import {
reduceStorySendStatus, reduceStorySendStatus,
resolveStorySendStatus, resolveStorySendStatus,
} from '../../util/resolveStorySendStatus'; } from '../../util/resolveStorySendStatus';
import { BodyRange } from '../../types/BodyRange';
export const getStoriesState = (state: StateType): StoriesStateType => export const getStoriesState = (state: StateType): StoriesStateType =>
state.stories; state.stories;
@ -183,6 +184,7 @@ export function getStoryView(
const { const {
attachment, attachment,
bodyRanges,
expirationStartTimestamp, expirationStartTimestamp,
expireTimer, expireTimer,
readAt, readAt,
@ -222,6 +224,7 @@ export function getStoryView(
return { return {
attachment, attachment,
bodyRanges: bodyRanges?.filter(BodyRange.isFormatting),
canReply: canReply(story, ourConversationId, conversationSelector), canReply: canReply(story, ourConversationId, conversationSelector),
isHidden: Boolean(sender.hideStory), isHidden: Boolean(sender.hideStory),
isUnread: story.readStatus === ReadStatus.Unread, isUnread: story.readStatus === ReadStatus.Unread,
@ -305,13 +308,17 @@ export const getStoryReplies = createSelector(
author: getAvatarData(conversation), author: getAvatarData(conversation),
...pick(reply, ['body', 'deletedForEveryone', 'id', 'timestamp']), ...pick(reply, ['body', 'deletedForEveryone', 'id', 'timestamp']),
bodyRanges: bodyRanges?.map(bodyRange => { bodyRanges: bodyRanges?.map(bodyRange => {
const mentionConvo = conversationSelector(bodyRange.mentionUuid); if (BodyRange.isMention(bodyRange)) {
const mentionConvo = conversationSelector(bodyRange.mentionUuid);
return { return {
...bodyRange, ...bodyRange,
conversationID: mentionConvo.id, conversationID: mentionConvo.id,
replacementText: mentionConvo.title, replacementText: mentionConvo.title,
}; };
}
return bodyRange;
}), }),
reactionEmoji: reply.storyReaction?.emoji, reactionEmoji: reply.storyReaction?.emoji,
contactNameColor: contactNameColorSelector( contactNameColor: contactNameColorSelector(

View file

@ -33,9 +33,10 @@ import {
import { useGlobalModalActions } from '../ducks/globalModals'; import { useGlobalModalActions } from '../ducks/globalModals';
import { useLinkPreviewActions } from '../ducks/linkPreviews'; import { useLinkPreviewActions } from '../ducks/linkPreviews';
import { processBodyRanges } from '../selectors/message'; import { processBodyRanges } from '../selectors/message';
import { getTextWithMentions } from '../../util/getTextWithMentions';
import { SmartCompositionTextArea } from './CompositionTextArea'; import { SmartCompositionTextArea } from './CompositionTextArea';
import { useToastActions } from '../ducks/toast'; import { useToastActions } from '../ducks/toast';
import type { HydratedBodyRangeMention } from '../../types/BodyRange';
import { applyRangesForText, BodyRange } from '../../types/BodyRange';
function renderMentions( function renderMentions(
message: ForwardMessagePropsType, message: ForwardMessagePropsType,
@ -52,7 +53,13 @@ function renderMentions(
}); });
if (bodyRanges && bodyRanges.length) { if (bodyRanges && bodyRanges.length) {
return getTextWithMentions(bodyRanges, text); return applyRangesForText({
mentions: bodyRanges.filter<HydratedBodyRangeMention>(
BodyRange.isMention
),
spoilers: [],
text,
});
} }
return text; return text;

View file

@ -42,6 +42,7 @@ export function SmartMessageDetail(): JSX.Element | null {
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
kickOffAttachmentDownload, kickOffAttachmentDownload,
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
messageExpanded,
openGiftBadge, openGiftBadge,
popPanelForConversation, popPanelForConversation,
pushPanelForConversation, pushPanelForConversation,
@ -49,6 +50,7 @@ export function SmartMessageDetail(): JSX.Element | null {
showConversation, showConversation,
showExpiredIncomingTapToViewToast, showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast, showExpiredOutgoingTapToViewToast,
showSpoiler,
startConversation, startConversation,
} = useConversationsActions(); } = useConversationsActions();
const { showContactModal, toggleSafetyNumberModal } = useGlobalModalActions(); const { showContactModal, toggleSafetyNumberModal } = useGlobalModalActions();
@ -87,6 +89,7 @@ export function SmartMessageDetail(): JSX.Element | null {
kickOffAttachmentDownload={kickOffAttachmentDownload} kickOffAttachmentDownload={kickOffAttachmentDownload}
markAttachmentAsCorrupted={markAttachmentAsCorrupted} markAttachmentAsCorrupted={markAttachmentAsCorrupted}
message={message} message={message}
messageExpanded={messageExpanded}
openGiftBadge={openGiftBadge} openGiftBadge={openGiftBadge}
pushPanelForConversation={pushPanelForConversation} pushPanelForConversation={pushPanelForConversation}
receivedAt={receivedAt} receivedAt={receivedAt}
@ -99,6 +102,7 @@ export function SmartMessageDetail(): JSX.Element | null {
showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast} showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast}
showLightbox={showLightbox} showLightbox={showLightbox}
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia} showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
showSpoiler={showSpoiler}
startConversation={startConversation} startConversation={startConversation}
theme={theme} theme={theme}
toggleSafetyNumberModal={toggleSafetyNumberModal} toggleSafetyNumberModal={toggleSafetyNumberModal}

View file

@ -128,6 +128,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
showConversation, showConversation,
showExpiredIncomingTapToViewToast, showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast, showExpiredOutgoingTapToViewToast,
showSpoiler,
startConversation, startConversation,
} = useConversationsActions(); } = useConversationsActions();
@ -198,6 +199,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast} showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast}
showLightbox={showLightbox} showLightbox={showLightbox}
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia} showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
showSpoiler={showSpoiler}
startCallingLobby={startCallingLobby} startCallingLobby={startCallingLobby}
startConversation={startConversation} startConversation={startConversation}
toggleForwardMessagesModal={toggleForwardMessagesModal} toggleForwardMessagesModal={toggleForwardMessagesModal}

File diff suppressed because it is too large Load diff

View file

@ -459,24 +459,33 @@ describe('Message', () => {
attachment: { attachment: {
contentType: 'image/gif', contentType: 'image/gif',
}, },
expectedText: 'GIF', expectedResult: {
expectedEmoji: '🎡', text: 'GIF',
emoji: '🎡',
bodyRanges: [],
},
}, },
{ {
title: 'photo', title: 'photo',
attachment: { attachment: {
contentType: 'image/png', contentType: 'image/png',
}, },
expectedText: 'Photo', expectedResult: {
expectedEmoji: '📷', text: 'Photo',
emoji: '📷',
bodyRanges: [],
},
}, },
{ {
title: 'video', title: 'video',
attachment: { attachment: {
contentType: 'video/mp4', contentType: 'video/mp4',
}, },
expectedText: 'Video', expectedResult: {
expectedEmoji: '🎥', text: 'Video',
emoji: '🎥',
bodyRanges: [],
},
}, },
{ {
title: 'voice message', title: 'voice message',
@ -484,8 +493,11 @@ describe('Message', () => {
contentType: 'audio/ogg', contentType: 'audio/ogg',
flags: Proto.AttachmentPointer.Flags.VOICE_MESSAGE, flags: Proto.AttachmentPointer.Flags.VOICE_MESSAGE,
}, },
expectedText: 'Voice Message', expectedResult: {
expectedEmoji: '🎤', text: 'Voice Message',
emoji: '🎤',
bodyRanges: [],
},
}, },
{ {
title: 'audio message', title: 'audio message',
@ -493,68 +505,75 @@ describe('Message', () => {
contentType: 'audio/ogg', contentType: 'audio/ogg',
fileName: 'audio.ogg', fileName: 'audio.ogg',
}, },
expectedText: 'Audio Message', expectedResult: {
expectedEmoji: '🔈', text: 'Audio Message',
emoji: '🔈',
bodyRanges: [],
},
}, },
{ {
title: 'plain text', title: 'plain text',
attachment: { attachment: {
contentType: 'text/plain', contentType: 'text/plain',
}, },
expectedText: 'File', expectedResult: {
expectedEmoji: '📎', text: 'File',
emoji: '📎',
bodyRanges: [],
},
}, },
{ {
title: 'unspecified-type', title: 'unspecified-type',
attachment: { attachment: {
contentType: null, contentType: null,
}, },
expectedText: 'File', expectedResult: {
expectedEmoji: '📎', text: 'File',
emoji: '📎',
bodyRanges: [],
},
}, },
]; ];
attachmentTestCases.forEach( attachmentTestCases.forEach(({ title, attachment, expectedResult }) => {
({ title, attachment, expectedText, expectedEmoji }) => { it(`handles single ${title} attachments`, () => {
it(`handles single ${title} attachments`, () => { assert.deepEqual(
assert.deepEqual( createMessage({
createMessage({ type: 'incoming',
type: 'incoming', source,
source, attachments: [attachment],
attachments: [attachment], }).getNotificationData(),
}).getNotificationData(), expectedResult
{ text: expectedText, emoji: expectedEmoji } );
); });
});
it(`handles multiple attachments where the first is a ${title}`, () => { it(`handles multiple attachments where the first is a ${title}`, () => {
assert.deepEqual( assert.deepEqual(
createMessage({ createMessage({
type: 'incoming', type: 'incoming',
source, source,
attachments: [ attachments: [
attachment, attachment,
{ {
contentType: 'text/html', contentType: 'text/html',
}, },
], ],
}).getNotificationData(), }).getNotificationData(),
{ text: expectedText, emoji: expectedEmoji } expectedResult
); );
}); });
it(`respects the caption for ${title} attachments`, () => { it(`respects the caption for ${title} attachments`, () => {
assert.deepEqual( assert.deepEqual(
createMessage({ createMessage({
type: 'incoming', type: 'incoming',
source, source,
attachments: [attachment], attachments: [attachment],
body: 'hello world', body: 'hello world',
}).getNotificationData(), }).getNotificationData(),
{ text: 'hello world', emoji: expectedEmoji } { ...expectedResult, text: 'hello world' }
); );
}); });
} });
);
it('handles a "plain" message', () => { it('handles a "plain" message', () => {
assert.deepEqual( assert.deepEqual(
@ -563,7 +582,7 @@ describe('Message', () => {
source, source,
body: 'hello world', body: 'hello world',
}).getNotificationData(), }).getNotificationData(),
{ text: 'hello world' } { text: 'hello world', bodyRanges: [] }
); );
}); });
}); });

View file

@ -1527,6 +1527,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultMessage(messageId), ...getDefaultMessage(messageId),
body: 'changed', body: 'changed',
displayLimit: undefined, displayLimit: undefined,
isSpoilerExpanded: undefined,
}; };
it('updates message data', () => { it('updates message data', () => {

View file

@ -1,50 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { getTextWithMentions } from '../../util/getTextWithMentions';
describe('getTextWithMentions', () => {
describe('given mention replacements', () => {
it('replaces them', () => {
const bodyRanges = [
{
length: 1,
mentionUuid: 'abcdef',
replacementText: 'fred',
conversationID: 'x',
start: 4,
},
];
const text = "Hey \uFFFC, I'm here";
assert.strictEqual(
getTextWithMentions(bodyRanges, text),
"Hey @fred, I'm here"
);
});
it('sorts them to go from back to front', () => {
const bodyRanges = [
{
length: 1,
mentionUuid: 'blarg',
replacementText: 'jerry',
conversationID: 'x',
start: 0,
},
{
length: 1,
mentionUuid: 'abcdef',
replacementText: 'fred',
conversationID: 'x',
start: 7,
},
];
const text = "\uFFFC says \uFFFC, I'm here";
assert.strictEqual(
getTextWithMentions(bodyRanges, text),
"@jerry says @fred, I'm here"
);
});
});
});

View file

@ -2120,6 +2120,8 @@ export default class MessageReceiver
const message: ProcessedDataMessage = { const message: ProcessedDataMessage = {
attachments, attachments,
// We need to remove all of the extra stuff on these objects so serialize properly
bodyRanges: msg.bodyRanges?.map(item => ({ ...item })),
preview, preview,
canReplyToStory: Boolean(msg.allowsReplies), canReplyToStory: Boolean(msg.allowsReplies),
expireTimer: DurationInSeconds.DAY, expireTimer: DurationInSeconds.DAY,

View file

@ -57,7 +57,8 @@ import {
HTTPError, HTTPError,
NoSenderKeyError, NoSenderKeyError,
} from './Errors'; } from './Errors';
import type { BodyRangesType, StoryContextType } from '../types/Util'; import { BodyRange } from '../types/BodyRange';
import type { StoryContextType } from '../types/Util';
import type { import type {
LinkPreviewImage, LinkPreviewImage,
LinkPreviewMetadata, LinkPreviewMetadata,
@ -76,6 +77,7 @@ import {
numberToAddressType, numberToAddressType,
} from '../types/EmbeddedContact'; } from '../types/EmbeddedContact';
import type { StickerWithHydratedData } from '../types/Stickers'; import type { StickerWithHydratedData } from '../types/Stickers';
import { missingCaseError } from '../util/missingCaseError';
export type SendMetadataType = { export type SendMetadataType = {
[identifier: string]: { [identifier: string]: {
@ -192,7 +194,7 @@ export type MessageOptionsType = {
reaction?: ReactionType; reaction?: ReactionType;
deletedForEveryoneTimestamp?: number; deletedForEveryoneTimestamp?: number;
timestamp: number; timestamp: number;
mentions?: BodyRangesType; mentions?: ReadonlyArray<BodyRange<BodyRange.Mention>>;
groupCallUpdate?: GroupCallUpdateType; groupCallUpdate?: GroupCallUpdateType;
storyContext?: StoryContextType; storyContext?: StoryContextType;
}; };
@ -205,7 +207,7 @@ export type GroupSendOptionsType = {
groupCallUpdate?: GroupCallUpdateType; groupCallUpdate?: GroupCallUpdateType;
groupV1?: GroupV1InfoType; groupV1?: GroupV1InfoType;
groupV2?: GroupV2InfoType; groupV2?: GroupV2InfoType;
mentions?: BodyRangesType; mentions?: ReadonlyArray<BodyRange<BodyRange.Mention>>;
messageText?: string; messageText?: string;
preview?: ReadonlyArray<LinkPreviewType>; preview?: ReadonlyArray<LinkPreviewType>;
profileKey?: Uint8Array; profileKey?: Uint8Array;
@ -256,7 +258,7 @@ class Message {
deletedForEveryoneTimestamp?: number; deletedForEveryoneTimestamp?: number;
mentions?: BodyRangesType; mentions?: ReadonlyArray<BodyRange<BodyRange.Mention>>;
groupCallUpdate?: GroupCallUpdateType; groupCallUpdate?: GroupCallUpdateType;
@ -480,7 +482,7 @@ class Message {
if (this.quote) { if (this.quote) {
const { QuotedAttachment } = Proto.DataMessage.Quote; const { QuotedAttachment } = Proto.DataMessage.Quote;
const { BodyRange, Quote } = Proto.DataMessage; const { BodyRange: ProtoBodyRange, Quote } = Proto.DataMessage;
proto.quote = new Quote(); proto.quote = new Quote();
const { quote } = proto; const { quote } = proto;
@ -510,13 +512,17 @@ class Message {
return quotedAttachment; return quotedAttachment;
} }
); );
const bodyRanges: BodyRangesType = this.quote.bodyRanges || []; const bodyRanges = this.quote.bodyRanges || [];
quote.bodyRanges = bodyRanges.map(range => { quote.bodyRanges = bodyRanges.map(range => {
const bodyRange = new BodyRange(); const bodyRange = new ProtoBodyRange();
bodyRange.start = range.start; bodyRange.start = range.start;
bodyRange.length = range.length; bodyRange.length = range.length;
if (range.mentionUuid !== undefined) { if (BodyRange.isMention(range)) {
bodyRange.mentionUuid = range.mentionUuid; bodyRange.mentionUuid = range.mentionUuid;
} else if (BodyRange.isFormatting(range)) {
bodyRange.style = range.style;
} else {
throw missingCaseError(range);
} }
return bodyRange; return bodyRange;
}); });

View file

@ -175,7 +175,8 @@ export function processQuote(
thumbnail: processAttachment(attachment.thumbnail), thumbnail: processAttachment(attachment.thumbnail),
}; };
}), }),
bodyRanges: quote.bodyRanges ?? [], // We need to remove all of the extra stuff on these objects so serialize properly
bodyRanges: quote.bodyRanges?.map(item => ({ ...item })) ?? [],
type: quote.type || Proto.DataMessage.Quote.Type.NORMAL, type: quote.type || Proto.DataMessage.Quote.Type.NORMAL,
}; };
} }
@ -348,7 +349,8 @@ export function processDataMessage(
isViewOnce: Boolean(message.isViewOnce), isViewOnce: Boolean(message.isViewOnce),
reaction: processReaction(message.reaction), reaction: processReaction(message.reaction),
delete: processDelete(message.delete), delete: processDelete(message.delete),
bodyRanges: message.bodyRanges ?? [], // We need to remove all of the extra stuff on these objects so serialize properly
bodyRanges: message.bodyRanges?.map(item => ({ ...item })) ?? [],
groupCallUpdate: dropNull(message.groupCallUpdate), groupCallUpdate: dropNull(message.groupCallUpdate),
storyContext: dropNull(message.storyContext), storyContext: dropNull(message.storyContext),
giftBadge: processGiftBadge(message.giftBadge), giftBadge: processGiftBadge(message.giftBadge),

559
ts/types/BodyRange.ts Normal file
View file

@ -0,0 +1,559 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable @typescript-eslint/no-namespace */
import { escapeRegExp, isNumber, omit } from 'lodash';
import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log';
import { assertDev } from '../util/assert';
import { missingCaseError } from '../util/missingCaseError';
// Cold storage of body ranges
export type BodyRange<T extends object> = {
start: number;
length: number;
} & T;
/** Body range as parsed from proto (No "Link" since those don't come from proto) */
export type RawBodyRange = BodyRange<BodyRange.Mention | BodyRange.Formatting>;
export enum DisplayStyle {
SearchKeywordHighlight = 'SearchKeywordHighlight',
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export namespace BodyRange {
// re-export for convenience
export type Style = Proto.DataMessage.BodyRange.Style;
export const { Style } = Proto.DataMessage.BodyRange;
export type Mention = {
mentionUuid: string;
};
export type Link = {
url: string;
};
export type Formatting = {
style: Style;
};
export type DisplayOnly = {
displayStyle: DisplayStyle;
};
// these overloads help inference along
export function isMention(
bodyRange: HydratedBodyRangeType
): bodyRange is HydratedBodyRangeMention;
export function isMention(
bodyRange: BodyRange<object>
): bodyRange is BodyRange<Mention>;
export function isMention<T extends object, X extends BodyRange<Mention> & T>(
bodyRange: BodyRange<T>
): bodyRange is X {
// satisfies keyof Mention
return ('mentionUuid' as const) in bodyRange;
}
export function isFormatting(
bodyRange: BodyRange<object>
): bodyRange is BodyRange<Formatting> {
// satisfies keyof Formatting
return ('style' as const) in bodyRange;
}
export function isLink<T extends Mention | Link | Formatting | DisplayOnly>(
node: T
): node is T & Link {
// satisfies keyof Link
return ('url' as const) in node;
}
export function isDisplayOnly<
T extends Mention | Link | Formatting | DisplayOnly
>(node: T): node is T & DisplayOnly {
// satisfies keyof DisplayOnly
return ('displayStyle' as const) in node;
}
}
// Used exclusive in CompositionArea and related conversation_view.tsx calls.
export type DraftBodyRangeMention = BodyRange<
BodyRange.Mention & {
replacementText: string;
}
>;
// Fully hydrated body range to be used in UI components.
export type HydratedBodyRangeMention = DraftBodyRangeMention & {
conversationID: string;
};
export type HydratedBodyRangeType =
| HydratedBodyRangeMention
| BodyRange<BodyRange.Formatting>;
export type HydratedBodyRangesType = ReadonlyArray<HydratedBodyRangeType>;
export type DisplayBodyRangeType =
| HydratedBodyRangeType
| BodyRange<BodyRange.DisplayOnly>;
export type BodyRangesForDisplayType = ReadonlyArray<DisplayBodyRangeType>;
type HydratedMention = BodyRange.Mention & {
conversationID: string;
replacementText: string;
};
/**
* A range that can contain other nested ranges
* Inner range start fields are relative to the start of the containing range
*/
export type RangeNode = BodyRange<
(
| HydratedMention
| BodyRange.Link
| BodyRange.Formatting
| BodyRange.DisplayOnly
) & {
ranges: ReadonlyArray<RangeNode>;
}
>;
/**
* Insert a range into an existing range tree, splitting up the range if it intersects
* with an existing range
*
* @param range The range to insert the tree
* @param rangeTree A list of nested non-intersecting range nodes, these starting ranges
* will not be split up
*/
export function insertRange(
range: BodyRange<
| HydratedMention
| BodyRange.Link
| BodyRange.Formatting
| BodyRange.DisplayOnly
>,
rangeTree: ReadonlyArray<RangeNode>
): ReadonlyArray<RangeNode> {
const [current, ...rest] = rangeTree;
if (!current) {
return [{ ...range, ranges: [] }];
}
const rangeEnd = range.start + range.length;
const currentEnd = current.start + current.length;
// ends before current starts
if (rangeEnd <= current.start) {
return [{ ...range, ranges: [] }, current, ...rest];
}
// starts after current one ends
if (range.start >= currentEnd) {
return [current, ...insertRange(range, rest)];
}
// range is contained by first
if (range.start >= current.start && rangeEnd <= currentEnd) {
return [
{
...current,
ranges: insertRange(
{ ...range, start: range.start - current.start },
current.ranges
),
},
...rest,
];
}
// range contains first (but might contain more)
// split range into 3
if (range.start < current.start && rangeEnd > currentEnd) {
return [
{ ...range, length: current.start - range.start, ranges: [] },
{
...current,
ranges: insertRange(
{ ...range, start: 0, length: current.length },
current.ranges
),
},
...insertRange(
{ ...range, start: currentEnd, length: rangeEnd - currentEnd },
rest
),
];
}
// range intersects beginning
// split range into 2
if (range.start < current.start && rangeEnd <= currentEnd) {
return [
{ ...range, length: current.start - range.start, ranges: [] },
{
...current,
ranges: insertRange(
{
...range,
start: 0,
length: range.length - (current.start - range.start),
},
current.ranges
),
},
...rest,
];
}
// range intersects ending
// split range into 2
if (range.start >= current.start && rangeEnd > currentEnd) {
return [
{
...current,
ranges: insertRange(
{
...range,
start: range.start - current.start,
length: currentEnd - range.start,
},
current.ranges
),
},
...insertRange(
{
...range,
start: currentEnd,
length: range.length - (currentEnd - range.start),
},
rest
),
];
}
log.error(`MessageTextRenderer: unhandled range ${range}`);
throw new Error('unhandled range');
}
// A flat list, ready for display
export type DisplayNode = {
text: string;
start: number;
length: number;
mentions: ReadonlyArray<BodyRange<HydratedMention>>;
// Formatting
isBold?: boolean;
isItalic?: boolean;
isMonospace?: boolean;
isSpoiler?: boolean;
isStrikethrough?: boolean;
// Link
url?: string;
// DisplayOnly
isKeywordHighlight?: boolean;
// Only for spoilers, only to represent contiguous groupings
spoilerChildren?: ReadonlyArray<DisplayNode>;
};
type PartialDisplayNode = Omit<
DisplayNode,
'mentions' | 'text' | 'start' | 'length'
>;
function rangeToPartialNode(
range: BodyRange<
BodyRange.Link | BodyRange.Formatting | BodyRange.DisplayOnly
>
): PartialDisplayNode {
if (BodyRange.isFormatting(range)) {
if (range.style === BodyRange.Style.BOLD) {
return { isBold: true };
}
if (range.style === BodyRange.Style.ITALIC) {
return { isItalic: true };
}
if (range.style === BodyRange.Style.MONOSPACE) {
return { isMonospace: true };
}
if (range.style === BodyRange.Style.SPOILER) {
return { isSpoiler: true };
}
if (range.style === BodyRange.Style.STRIKETHROUGH) {
return { isStrikethrough: true };
}
if (range.style === BodyRange.Style.NONE) {
return {};
}
throw missingCaseError(range.style);
}
if (BodyRange.isLink(range)) {
return {
url: range.url,
};
}
if (BodyRange.isDisplayOnly(range)) {
if (range.displayStyle === DisplayStyle.SearchKeywordHighlight) {
return { isKeywordHighlight: true };
}
throw missingCaseError(range.displayStyle);
}
throw missingCaseError(range);
}
/**
* Turns a range tree into a flat list that can be rendered, with a walk across the tree.
*
* * @param rangeTree A list of nested non-intersecting ranges.
*/
export function collapseRangeTree({
parentData,
parentOffset = 0,
text,
tree,
}: {
parentData?: PartialDisplayNode;
parentOffset?: number;
text: string;
tree: ReadonlyArray<RangeNode>;
}): ReadonlyArray<DisplayNode> {
let collapsed: Array<DisplayNode> = [];
let offset = 0;
let mentions: Array<HydratedBodyRangeMention> = [];
tree.forEach(range => {
if (BodyRange.isMention(range)) {
mentions.push({
...omit(range, ['ranges']),
start: range.start - offset,
});
return;
}
// Empty space between start of current
if (range.start > offset) {
collapsed.push({
...parentData,
text: text.slice(offset, range.start),
start: offset + parentOffset,
length: range.start - offset,
mentions,
});
mentions = [];
}
// What sub-breaks can we make within this node?
const partialNode = { ...parentData, ...rangeToPartialNode(range) };
collapsed = collapsed.concat(
collapseRangeTree({
parentData: partialNode,
parentOffset: range.start + parentOffset,
text: text.slice(range.start, range.start + range.length),
tree: range.ranges,
})
);
offset = range.start + range.length;
});
// Empty space after the last range
if (text.length > offset) {
collapsed.push({
...parentData,
text: text.slice(offset, text.length),
start: offset + parentOffset,
length: text.length - offset,
mentions,
});
}
return collapsed;
}
export function groupContiguousSpoilers(
nodes: ReadonlyArray<DisplayNode>
): ReadonlyArray<DisplayNode> {
const result: Array<DisplayNode> = [];
let spoilerContainer: DisplayNode | undefined;
nodes.forEach(node => {
if (node.isSpoiler) {
if (!spoilerContainer) {
spoilerContainer = {
...node,
isSpoiler: true,
spoilerChildren: [],
};
result.push(spoilerContainer);
}
if (spoilerContainer) {
spoilerContainer.spoilerChildren = [
...(spoilerContainer.spoilerChildren || []),
node,
];
}
} else {
spoilerContainer = undefined;
result.push(node);
}
});
return result;
}
const TRUNCATION_CHAR = '...';
const LENGTH_OF_LEFT = '<<left>>'.length;
const TRUNCATION_PLACEHOLDER = '<<truncation>>';
const TRUNCATION_START = /^<<truncation>>/;
const TRUNCATION_END = /<<truncation>>$/;
// This function exists because bodyRanges tells us the character position
// where the at-mention starts at according to the full body text. The snippet
// we get back is a portion of the text and we don't know where it starts. This
// function will find the relevant bodyRanges that apply to the snippet and
// then update the proper start position of each body range.
export function processBodyRangesForSearchResult({
snippet,
body,
bodyRanges,
}: {
snippet: string;
body: string;
bodyRanges: BodyRangesForDisplayType;
}): {
cleanedSnippet: string;
bodyRanges: BodyRangesForDisplayType;
} {
// Find where the snippet starts in the full text
const cleanedSnippet = snippet
.replace(/<<left>>/g, '')
.replace(/<<right>>/g, '');
const withNoStartTruncation = cleanedSnippet.replace(TRUNCATION_START, '');
const withNoEndTruncation = withNoStartTruncation.replace(TRUNCATION_END, '');
const finalSnippet = cleanedSnippet
.replace(TRUNCATION_START, TRUNCATION_CHAR)
.replace(TRUNCATION_END, TRUNCATION_CHAR);
const truncationDelta =
withNoStartTruncation.length !== cleanedSnippet.length
? TRUNCATION_CHAR.length
: 0;
const rx = new RegExp(escapeRegExp(withNoEndTruncation));
const match = rx.exec(body);
assertDev(Boolean(match), `No match found for "${snippet}" inside "${body}"`);
const startOfSnippet = match ? match.index : 0;
const endOfSnippet = startOfSnippet + withNoEndTruncation.length;
// We want only the ranges that include the snippet
const filteredBodyRanges = bodyRanges.filter(range => {
const { start } = range;
const end = range.start + range.length;
return end > startOfSnippet && start < endOfSnippet;
});
// Adjust ranges, with numbers for the original message body, to work with snippet
const adjustedBodyRanges: Array<DisplayBodyRangeType> =
filteredBodyRanges.map(range => {
const normalizedStart = range.start - startOfSnippet + truncationDelta;
const start = Math.max(normalizedStart, truncationDelta);
const end = Math.min(
normalizedStart + range.length,
withNoEndTruncation.length + truncationDelta
);
return {
...range,
start,
length: end - start,
};
});
// To format the match identified by FTS, we create a synthetic BodyRange to mix in with
// all the other formatting embedded in this message.
const startOfKeywordMatch = snippet.match(/<<left>>/)?.index;
const endOfKeywordMatch = snippet.match(/<<right>>/)?.index;
if (isNumber(startOfKeywordMatch) && isNumber(endOfKeywordMatch)) {
adjustedBodyRanges.push({
start:
startOfKeywordMatch +
(truncationDelta
? TRUNCATION_CHAR.length - TRUNCATION_PLACEHOLDER.length
: 0),
length: endOfKeywordMatch - (startOfKeywordMatch + LENGTH_OF_LEFT),
displayStyle: DisplayStyle.SearchKeywordHighlight,
});
}
return {
cleanedSnippet: finalSnippet,
bodyRanges: adjustedBodyRanges,
};
}
const SPOILER_REPLACEMENT = '■■■■';
export function applyRangesForText({
text,
mentions,
spoilers,
}: {
text: string | undefined;
mentions: ReadonlyArray<HydratedBodyRangeMention>;
spoilers: ReadonlyArray<BodyRange<BodyRange.Formatting>>;
}): string | undefined {
if (!text) {
return text;
}
let updatedText = text;
let sortableMentions: Array<HydratedBodyRangeMention> = mentions.slice();
const sortableSpoilers: Array<BodyRange<BodyRange.Formatting>> =
spoilers.slice();
updatedText = sortableSpoilers
.sort((a, b) => b.start - a.start)
.reduce((acc, { start, length }) => {
const left = acc.slice(0, start);
const end = start + length;
const right = acc.slice(end);
// Note: this is a simplified filter because mentions always have length=1
sortableMentions = sortableMentions
.filter(mention => {
return mention.start < start || mention.start >= end;
})
.map(mention => {
if (mention.start >= end) {
return {
...mention,
start: mention.start - (length - SPOILER_REPLACEMENT.length),
};
}
return mention;
});
return `${left}${SPOILER_REPLACEMENT}${right}`;
}, updatedText);
return sortableMentions
.sort((a, b) => b.start - a.start)
.reduce((acc, { start, length, replacementText }) => {
const left = acc.slice(0, start);
const right = acc.slice(start + length);
return `${left}@${replacementText}${right}`;
}, updatedText);
}

View file

@ -2,7 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { AttachmentType } from './Attachment'; import type { AttachmentType } from './Attachment';
import type { HydratedBodyRangesType, LocalizerType } from './Util'; import type { HydratedBodyRangesType } from './BodyRange';
import type { LocalizerType } from './Util';
import type { ContactNameColorType } from './Colors'; import type { ContactNameColorType } from './Colors';
import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationType } from '../state/ducks/conversations';
import type { ReadStatus } from '../messages/MessageReadStatus'; import type { ReadStatus } from '../messages/MessageReadStatus';
@ -71,6 +72,7 @@ export type StorySendStateType = {
export type StoryViewType = { export type StoryViewType = {
attachment?: AttachmentType; attachment?: AttachmentType;
bodyRanges?: HydratedBodyRangesType;
canReply?: boolean; canReply?: boolean;
isHidden?: boolean; isHidden?: boolean;
isUnread?: boolean; isUnread?: boolean;

View file

@ -4,32 +4,6 @@
import type { IntlShape } from 'react-intl'; import type { IntlShape } from 'react-intl';
import type { UUIDStringType } from './UUID'; import type { UUIDStringType } from './UUID';
// Cold storage of body ranges
export type BodyRangeType = {
start: number;
length: number;
mentionUuid: string;
};
export type BodyRangesType = ReadonlyArray<BodyRangeType>;
// Used exclusive in CompositionArea and related conversation_view.tsx calls.
export type DraftBodyRangeType = BodyRangeType & {
replacementText: string;
};
export type DraftBodyRangesType = ReadonlyArray<DraftBodyRangeType>;
// Fully hydrated body range to be used in UI components.
export type HydratedBodyRangeType = DraftBodyRangeType & {
conversationID: string;
};
export type HydratedBodyRangesType = ReadonlyArray<HydratedBodyRangeType>;
export type StoryContextType = { export type StoryContextType = {
authorUuid?: UUIDStringType; authorUuid?: UUIDStringType;
timestamp: number; timestamp: number;

View file

@ -1,18 +0,0 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { DraftBodyRangeType, DraftBodyRangesType } from '../types/Util';
export function getTextWithMentions(
bodyRanges: DraftBodyRangesType,
text: string
): string {
const sortableBodyRanges: Array<DraftBodyRangeType> = bodyRanges.slice();
return sortableBodyRanges
.sort((a, b) => b.start - a.start)
.reduce((acc, { start, length, replacementText }) => {
const left = acc.slice(0, start);
const right = acc.slice(start + length);
return `${left}@${replacementText}${right}`;
}, text);
}

View file

@ -10,7 +10,6 @@ import { createWaitBatcher } from './waitBatcher';
import { deleteForEveryone } from './deleteForEveryone'; import { deleteForEveryone } from './deleteForEveryone';
import { downloadAttachment } from './downloadAttachment'; import { downloadAttachment } from './downloadAttachment';
import { getStringForProfileChange } from './getStringForProfileChange'; import { getStringForProfileChange } from './getStringForProfileChange';
import { getTextWithMentions } from './getTextWithMentions';
import { getUuidsForE164s } from './getUuidsForE164s'; import { getUuidsForE164s } from './getUuidsForE164s';
import { getUserAgent } from './getUserAgent'; import { getUserAgent } from './getUserAgent';
import { import {
@ -53,7 +52,6 @@ export {
flushMessageCounter, flushMessageCounter,
fromWebSafeBase64, fromWebSafeBase64,
getStringForProfileChange, getStringForProfileChange,
getTextWithMentions,
getUserAgent, getUserAgent,
incrementMessageCounter, incrementMessageCounter,
initializeMessageCounter, initializeMessageCounter,

View file

@ -16,7 +16,7 @@ import { isNotNil } from './isNotNil';
import { resetLinkPreview } from '../services/LinkPreview'; import { resetLinkPreview } from '../services/LinkPreview';
import { getRecipientsByConversation } from './getRecipientsByConversation'; import { getRecipientsByConversation } from './getRecipientsByConversation';
import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage'; import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage';
import type { BodyRangesType } from '../types/Util'; import type { DraftBodyRangeMention } from '../types/BodyRange';
import type { StickerWithHydratedData } from '../types/Stickers'; import type { StickerWithHydratedData } from '../types/Stickers';
import { drop } from './drop'; import { drop } from './drop';
import { toLogFormat } from '../types/errors'; import { toLogFormat } from '../types/errors';
@ -168,7 +168,7 @@ export async function maybeForwardMessages(
attachments: Array<AttachmentType>; attachments: Array<AttachmentType>;
body: string | undefined; body: string | undefined;
contact?: Array<ContactWithHydratedAvatar>; contact?: Array<ContactWithHydratedAvatar>;
mentions?: BodyRangesType; mentions?: Array<DraftBodyRangeMention>;
preview?: Array<LinkPreviewType>; preview?: Array<LinkPreviewType>;
quote?: QuotedMessageType; quote?: QuotedMessageType;
sticker?: StickerWithHydratedData; sticker?: StickerWithHydratedData;

View file

@ -0,0 +1,22 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// This takes `unknown` because, sometimes, values from the database don't match our
// types. In the long term, we should fix that. In the short term, this smoothes over
// the problem.
// Note: we really need to keep the string length the same for proper bodyRange handling
export function stripNewlinesForLeftPane(text: unknown): string {
if (typeof text !== 'string') {
return '';
}
return text.replace(/(\r?\n)/g, substring => {
const { length } = substring;
if (length === 2) {
return ' ';
}
if (length === 1) {
return ' ';
}
return '';
});
}

View file

@ -17606,12 +17606,7 @@ typedarray@^0.0.6:
version "0.0.6" version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
typescript@5.0.2: typescript@4.9.5, typescript@^4.9.5:
version "5.0.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.2.tgz#891e1a90c5189d8506af64b9ef929fca99ba1ee5"
integrity sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==
typescript@^4.9.5:
version "4.9.5" version "4.9.5"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g== integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==