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",
"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": {
"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"

View file

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

View file

@ -307,12 +307,22 @@ message DataMessage {
}
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;
// oneof associatedValue {
optional string mentionUuid = 3;
//}
oneof associatedValue {
string mentionUuid = 3;
Style style = 4;
}
}
message GroupCallUpdate {
@ -399,6 +409,7 @@ message StoryMessage {
TextAttachment textAttachment = 4;
}
optional bool allowsReplies = 5;
repeated BodyRange bodyRanges = 6;
}
message TextAttachment {

View file

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

View file

@ -53,6 +53,10 @@
&--outgoing {
background-color: $color-black-alpha-40;
}
&--invisible {
visibility: hidden;
}
}
&__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/MessageAudio.scss';
@import './components/MessageBody.scss';
@import './components/MessageTextRenderer.scss';
@import './components/MessageDetail.scss';
@import './components/MiniPlayer.scss';
@import './components/Modal.scss';

View file

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

View file

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

View file

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

View file

@ -391,7 +391,11 @@ ConversationTypingStatus.story = {
export const ConversationWithDraft = (): JSX.Element =>
renderConversation({
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 = {

View file

@ -1,7 +1,7 @@
// Copyright 2023 Signal Messenger, LLC
// 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 type { AttachmentType } from '../types/Attachment';
@ -86,6 +86,14 @@ export function EditHistoryMessagesModal({
[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 (
<Modal
hasXButton
@ -95,20 +103,41 @@ export function EditHistoryMessagesModal({
title={i18n('icu:EditHistoryMessagesModal__title')}
>
<div ref={containerElementRef}>
{editHistoryMessages.map(messageAttributes => (
<Message
{...MESSAGE_DEFAULT_PROPS}
{...messageAttributes}
containerElementRef={containerElementRef}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
platform={platform}
key={messageAttributes.timestamp}
kickOffAttachmentDownload={kickOffAttachmentDownload}
showLightbox={closeAndShowLightbox}
theme={theme}
/>
))}
{editHistoryMessages.map(messageAttributes => {
const syntheticId = `${messageAttributes.id}.${messageAttributes.timestamp}`;
return (
<Message
{...MESSAGE_DEFAULT_PROPS}
{...messageAttributes}
id={syntheticId}
containerElementRef={containerElementRef}
displayLimit={displayLimitById[syntheticId]}
getPreferredBadge={getPreferredBadge}
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>
</Modal>
);

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import type { RenderTextCallbackType } from '../../types/Util';
export type Props = {
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;
};

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 => ({
bodyRanges: overrideProps.bodyRanges,
mentions: overrideProps.mentions,
direction: overrideProps.direction || 'incoming',
showConversation: action('showConversation'),
text: overrideProps.text || '',
@ -28,7 +28,7 @@ export function NoMentions(): JSX.Element {
}
export function MultipleMentions(): JSX.Element {
const bodyRanges = [
const mentions = [
{
start: 4,
length: 1,
@ -52,16 +52,16 @@ export function MultipleMentions(): JSX.Element {
},
];
const props = createProps({
bodyRanges,
mentions,
direction: 'outgoing',
text: AtMentionify.preprocessMentions('\uFFFC \uFFFC \uFFFC', bodyRanges),
text: AtMentionify.preprocessMentions('\uFFFC \uFFFC \uFFFC', mentions),
});
return <AtMentionify {...props} />;
}
export function ComplexMentions(): JSX.Element {
const bodyRanges = [
const mentions = [
{
start: 80,
length: 1,
@ -86,10 +86,10 @@ export function ComplexMentions(): JSX.Element {
];
const props = createProps({
bodyRanges,
mentions,
text: AtMentionify.preprocessMentions(
'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 {
const bodyRanges = [
const mentions = [
{
start: 4,
length: 1,
@ -108,10 +108,10 @@ export function WithOddCharacter(): JSX.Element {
];
const props = createProps({
bodyRanges,
mentions,
text: AtMentionify.preprocessMentions(
'Hey \uFFFC - Check out │https://www.signal.org│',
bodyRanges
mentions
),
});

View file

@ -3,15 +3,14 @@
import React from 'react';
import { sortBy } from 'lodash';
import { Emojify } from './Emojify';
import type {
BodyRangesType,
HydratedBodyRangeType,
HydratedBodyRangesType,
} from '../../types/Util';
HydratedBodyRangeMention,
BodyRange,
} from '../../types/BodyRange';
import { AtMention } from './AtMention';
export type Props = {
bodyRanges?: HydratedBodyRangesType;
mentions?: ReadonlyArray<HydratedBodyRangeMention>;
direction?: 'incoming' | 'outgoing';
showConversation?: (options: {
conversationId: string;
@ -21,12 +20,12 @@ export type Props = {
};
export function AtMentionify({
bodyRanges,
mentions,
direction,
showConversation,
text,
}: Props): JSX.Element {
if (!bodyRanges) {
if (!mentions) {
return <>{text}</>;
}
@ -35,8 +34,8 @@ export function AtMentionify({
let match = MENTIONS_REGEX.exec(text);
let last = 0;
const rangeStarts = new Map<number, HydratedBodyRangeType>();
bodyRanges.forEach(range => {
const rangeStarts = new Map<number, HydratedBodyRangeMention>();
mentions.forEach(range => {
rangeStarts.set(range.start, range);
});
@ -52,9 +51,10 @@ export function AtMentionify({
if (range) {
results.push(
<span
className={`MessageBody__at-mention MessageBody__at-mention--${direction}`}
<AtMention
key={range.start}
direction={direction}
isInvisible={false}
onClick={() => {
if (showConversation) {
showConversation({ conversationId: range.conversationID });
@ -69,16 +69,9 @@ export function AtMentionify({
showConversation({ conversationId: range.conversationID });
}
}}
tabIndex={0}
role="link"
data-id={range.conversationID}
data-title={range.replacementText}
>
<bdi>
@
<Emojify text={range.replacementText} />
</bdi>
</span>
id={range.conversationID}
name={range.replacementText}
/>
);
}
@ -101,16 +94,16 @@ export function AtMentionify({
// 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
// string so we can later pull it off when rendering the @mention.
AtMentionify.preprocessMentions = (
AtMentionify.preprocessMentions = <T extends BodyRange.Mention>(
text: string,
bodyRanges?: BodyRangesType
mentions?: ReadonlyArray<BodyRange<T>>
): string => {
if (!bodyRanges || !bodyRanges.length) {
if (!mentions || !mentions.length) {
return text;
}
// 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 encodedMention = `\uFFFC@${range.start}`;
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/quill/emoji/blot.tsx
function getImageTag({
isInvisible,
key,
match,
sizeClass,
key,
}: {
isInvisible?: boolean;
key: string | number;
match: string;
sizeClass?: SizeClassType;
key: string | number;
}): JSX.Element | string {
const img = emojiToImage(match);
@ -35,18 +37,24 @@ function getImageTag({
key={key}
src={img}
aria-label={match}
className={classNames('emoji', sizeClass)}
className={classNames(
'emoji',
sizeClass,
isInvisible ? 'emoji--invisible' : null
)}
alt={match}
/>
);
}
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 */
sizeClass?: SizeClassType;
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
renderNonEmoji?: RenderTextCallbackType;
text: string;
};
const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text;
@ -54,14 +62,15 @@ const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text;
export class Emojify extends React.Component<Props> {
public override render(): null | Array<JSX.Element | string | null> {
const {
text,
sizeClass,
isInvisible,
renderNonEmoji = defaultRenderNonEmoji,
sizeClass,
text,
} = this.props;
return splitByEmoji(text).map(({ type, value: match }, index) => {
if (type === 'emoji') {
return getImageTag({ match, sizeClass, key: index });
return getImageTag({ isInvisible, match, sizeClass, key: index });
}
if (type === 'text') {

View file

@ -10,7 +10,7 @@ import { isLinkSneaky, shouldLinkifyMessage } from '../../types/LinkPreview';
import { splitByEmoji } from '../../util/emoji';
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]
// except for those domains marked as [a test domain][1].
//
@ -319,7 +319,7 @@ export type Props = {
renderNonLink?: RenderTextCallbackType;
};
const SUPPORTED_PROTOCOLS = /^(http|https):/i;
export const SUPPORTED_PROTOCOLS = /^(http|https):/i;
const defaultRenderNonLink: RenderTextCallbackType = ({ text }) => text;

View file

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

View file

@ -2,13 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { boolean, text } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import type { Props } from './MessageBody';
import { MessageBody } from './MessageBody';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import { BodyRange } from '../../types/BodyRange';
import { RenderLocation } from './MessageTextRenderer';
const i18n = setupI18n('en', enMessages);
@ -18,16 +19,18 @@ export default {
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
bodyRanges: overrideProps.bodyRanges,
disableJumbomoji: boolean(
'disableJumbomoji',
overrideProps.disableJumbomoji || false
),
disableLinks: boolean('disableLinks', overrideProps.disableLinks || false),
disableJumbomoji: overrideProps.disableJumbomoji || false,
disableLinks: overrideProps.disableLinks || false,
direction: 'incoming',
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 || {
pending: boolean('textPending', false),
pending: false,
},
});
@ -156,7 +159,13 @@ export function MultipleMentions(): JSX.Element {
text: '\uFFFC \uFFFC \uFFFC',
});
return <MessageBody {...props} />;
return (
<>
<MessageBody {...props} />
<hr />
<MessageBody {...props} disableLinks />
</>
);
}
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',
});
return <MessageBody {...props} />;
return (
<>
<MessageBody {...props} />
<hr />
<MessageBody {...props} disableLinks />
</>
);
}
ComplexMessageBody.story = {
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 { canBeDownloaded } from '../../types/Attachment';
import type { SizeClassType } from '../emoji/lib';
import { getSizeClass } from '../emoji/lib';
import { AtMentionify } from './AtMentionify';
import { Emojify } from './Emojify';
import { AddNewLines } from './AddNewLines';
import { Linkify } from './Linkify';
import type { ShowConversationType } from '../../state/ducks/conversations';
import type {
HydratedBodyRangesType,
LocalizerType,
RenderTextCallbackType,
} from '../../types/Util';
import type { HydratedBodyRangesType } from '../../types/BodyRange';
import type { LocalizerType } from '../../types/Util';
import { MessageTextRenderer } from './MessageTextRenderer';
import type { RenderLocation } from './MessageTextRenderer';
export type Props = {
author?: string;
bodyRanges?: HydratedBodyRangesType;
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;
/** 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;
i18n: LocalizerType;
isSpoilerExpanded: boolean;
kickOffBodyDownload?: () => void;
onExpandSpoiler?: () => unknown;
onIncreaseTextLength?: () => unknown;
prefix?: string;
renderLocation: RenderLocation;
showConversation?: ShowConversationType;
text: string;
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
* components: `Emojify`, `Linkify`, and `AddNewLines`. Because each of them is fully
@ -69,8 +48,12 @@ export function MessageBody({
disableJumbomoji,
disableLinks,
i18n,
isSpoilerExpanded,
kickOffBodyDownload,
onExpandSpoiler,
onIncreaseTextLength,
prefix,
renderLocation,
showConversation,
text,
textAttachment,
@ -80,31 +63,6 @@ export function MessageBody({
textAttachment?.pending || hasReadMore ? `${text}...` : 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;
if (hasReadMore) {
@ -145,39 +103,35 @@ export function MessageBody({
{author && (
<>
<span className="MessageBody__author">
{renderEmoji({
i18n,
text: author,
sizeClass,
key: 0,
renderNonEmoji: renderNewLines,
})}
<Emojify text={author} />
</span>
:{' '}
</>
)}
{disableLinks ? (
renderEmoji({
i18n,
text: processedText,
sizeClass,
key: 0,
renderNonEmoji: renderNewLines,
})
) : (
<Linkify
text={processedText}
renderNonLink={({ key, text: nonLinkText }) => {
return renderEmoji({
i18n,
text: nonLinkText,
sizeClass,
key,
renderNonEmoji: renderNewLines,
});
}}
/>
{prefix && (
<>
<span className="MessageBody__prefix">
<Emojify text={prefix} />
</span>{' '}
</>
)}
<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}
{onIncreaseTextLength ? (
<button

View file

@ -4,12 +4,14 @@
import React, { useState } from 'react';
import { action } from '@storybook/addon-actions';
import { text } from '@storybook/addon-knobs';
import type { Props } from './MessageBodyReadMore';
import { MessageBodyReadMore } from './MessageBodyReadMore';
import { setupI18n } from '../../util/setupI18n';
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);
@ -23,20 +25,34 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
displayLimit: overrideProps.displayLimit,
i18n,
id: 'some-id',
isSpoilerExpanded: overrideProps.isSpoilerExpanded === true,
messageExpanded: action('messageExpanded'),
text: text('text', overrideProps.text || ''),
onExpandSpoiler: overrideProps.onExpandSpoiler || action('onExpandSpoiler'),
renderLocation: RenderLocation.Timeline,
text: overrideProps.text || '',
});
function MessageBodyReadMoreTest({
bodyRanges,
isSpoilerExpanded,
onExpandSpoiler,
text: messageBodyText,
}: {
bodyRanges?: HydratedBodyRangesType;
isSpoilerExpanded?: boolean;
onExpandSpoiler?: () => void;
text: string;
}): JSX.Element {
const [displayLimit, setDisplayLimit] = useState<number | undefined>();
return (
<MessageBodyReadMore
{...createProps({ text: messageBodyText })}
{...createProps({
bodyRanges,
isSpoilerExpanded,
onExpandSpoiler,
text: messageBodyText,
})}
displayLimit={displayLimit}
messageExpanded={(_, newDisplayLimit) => setDisplayLimit(newDisplayLimit)}
/>
@ -69,6 +85,74 @@ export function LeafyNotBuffered(): JSX.Element {
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 = {
name: 'Leafy not buffered',
};

View file

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

View file

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

View file

@ -81,6 +81,7 @@ export type PropsReduxActions = Pick<
| 'doubleCheckMissingQuoteReference'
| 'kickOffAttachmentDownload'
| 'markAttachmentAsCorrupted'
| 'messageExpanded'
| 'openGiftBadge'
| 'pushPanelForConversation'
| 'saveAttachment'
@ -90,6 +91,7 @@ export type PropsReduxActions = Pick<
| 'showExpiredOutgoingTapToViewToast'
| 'showLightbox'
| 'showLightboxForViewOnceMedia'
| 'showSpoiler'
| 'startConversation'
| 'viewStory'
> & {
@ -296,13 +298,13 @@ export class MessageDetail extends React.Component<Props> {
checkForAccount,
clearTargetedMessage,
contactNameColor,
showLightboxForViewOnceMedia,
doubleCheckMissingQuoteReference,
getPreferredBadge,
i18n,
interactionMode,
kickOffAttachmentDownload,
markAttachmentAsCorrupted,
messageExpanded,
openGiftBadge,
platform,
pushPanelForConversation,
@ -313,6 +315,8 @@ export class MessageDetail extends React.Component<Props> {
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showLightbox,
showLightboxForViewOnceMedia,
showSpoiler,
startConversation,
theme,
viewStory,
@ -347,16 +351,17 @@ export class MessageDetail extends React.Component<Props> {
interactionMode={interactionMode}
kickOffAttachmentDownload={kickOffAttachmentDownload}
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
messageExpanded={noop}
platform={platform}
showConversation={showConversation}
messageExpanded={messageExpanded}
openGiftBadge={openGiftBadge}
platform={platform}
pushPanelForConversation={pushPanelForConversation}
renderAudioAttachment={renderAudioAttachment}
saveAttachment={saveAttachment}
shouldCollapseAbove={false}
shouldCollapseBelow={false}
shouldHideMetadata={false}
showConversation={showConversation}
showSpoiler={showSpoiler}
scrollToQuotedMessage={() => {
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,
isSelected: false,
isSelectMode: false,
isSpoilerExpanded: false,
toggleSelectMessage: action('toggleSelectMessage'),
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
@ -135,6 +136,7 @@ const defaultMessageProps: TimelineMessagesProps = {
shouldCollapseAbove: false,
shouldCollapseBelow: false,
shouldHideMetadata: false,
showSpoiler: action('showSpoiler'),
pushPanelForConversation: action('default--pushPanelForConversation'),
showContactModal: action('default--showContactModal'),
showExpiredIncomingTapToViewToast: action(

View file

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

View file

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

View file

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

View file

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

View file

@ -19,6 +19,7 @@ import type { LocalizerType, ThemeType } from '../../types/Util';
import type { ConversationType } from '../../state/ducks/conversations';
import type { BadgeType } from '../../badges/types';
import { isSignalConversation } from '../../util/isSignalConversation';
import { RenderLocation } from '../conversation/MessageTextRenderer';
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')}
</span>
<MessageBody
text={truncateMessageText(draftPreview)}
bodyRanges={draftPreview.bodyRanges}
disableJumbomoji
disableLinks
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) {
messageText = (
<MessageBody
text={truncateMessageText(lastMessage.text)}
author={type === 'group' ? lastMessage.author : undefined}
bodyRanges={lastMessage.bodyRanges}
disableJumbomoji
disableLinks
i18n={i18n}
isSpoilerExpanded={false}
prefix={lastMessage.prefix}
renderLocation={RenderLocation.ConversationList}
text={lastMessage.text}
/>
);
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 { action } from '@storybook/addon-actions';
import { boolean, text } from '@storybook/addon-knobs';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
@ -13,6 +12,7 @@ import { getFakeBadge } from '../../test-both/helpers/getFakeBadge';
import type { PropsType } from './MessageSearchResult';
import { MessageSearchResult } from './MessageSearchResult';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { BodyRange } from '../../types/BodyRange';
const i18n = setupI18n('en', enMessages);
@ -43,21 +43,15 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
id: '',
conversationId: '',
sentAt: Date.now() - 24 * 60 * 1000,
snippet: text(
'snippet',
overrideProps.snippet || "What's <<left>>going<<right>> on?"
),
body: text('body', overrideProps.body || "What's going on?"),
snippet: overrideProps.snippet || "What's <<left>>going<<right>> on?",
body: overrideProps.body || "What's going on?",
bodyRanges: overrideProps.bodyRanges || [],
from: overrideProps.from as PropsType['from'],
to: overrideProps.to as PropsType['to'],
getPreferredBadge: overrideProps.getPreferredBadge || (() => undefined),
isSelected: boolean('isSelected', overrideProps.isSelected || false),
isSelected: overrideProps.isSelected || false,
showConversation: action('showConversation'),
isSearchingInConversation: boolean(
'isSearchingInConversation',
overrideProps.isSearchingInConversation || false
),
isSearchingInConversation: overrideProps.isSearchingInConversation || false,
theme: React.useContext(StorybookThemeContext),
});
@ -220,7 +214,7 @@ export function Mention(): JSX.Element {
from: someone,
to: me,
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} />;
@ -245,7 +239,7 @@ export function MentionRegexp(): JSX.Element {
from: someone,
to: me,
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} />;
@ -301,7 +295,7 @@ export const _MentionNoMatches = (): JSX.Element => {
from: someone,
to: me,
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} />;
@ -313,7 +307,7 @@ _MentionNoMatches.story = {
export function DoubleMention(): JSX.Element {
const props = useProps({
body: 'Hey \uFFFC \uFFFC test',
body: 'Hey \uFFFC \uFFFC --- test! Two mentions!',
bodyRanges: [
{
length: 1,
@ -332,7 +326,7 @@ export function DoubleMention(): JSX.Element {
],
from: someone,
to: me,
snippet: '<<left>>Hey<<right>> \uFFFC \uFFFC <<left>>test<<right>>',
snippet: '<<left>>Hey<<right>> \uFFFC \uFFFC --- test! <<truncation>>',
});
return <MessageSearchResult {...props} />;
@ -341,3 +335,41 @@ export function DoubleMention(): JSX.Element {
DoubleMention.story = {
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 React, { useCallback } from 'react';
import { escapeRegExp } from 'lodash';
import { MessageBodyHighlight } from './MessageBodyHighlight';
import { ContactName } from '../conversation/ContactName';
import { assertDev } from '../../util/assert';
import type {
HydratedBodyRangesType,
LocalizerType,
ThemeType,
} from '../../types/Util';
import type { BodyRangesForDisplayType } from '../../types/BodyRange';
import { processBodyRangesForSearchResult } from '../../types/BodyRange';
import type { LocalizerType, ThemeType } from '../../types/Util';
import { BaseConversationListItem } from './BaseConversationListItem';
import type {
ConversationType,
@ -21,6 +16,10 @@ import type {
} from '../../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
import { Intl } from '../Intl';
import {
MessageTextRenderer,
RenderLocation,
} from '../conversation/MessageTextRenderer';
export type PropsDataType = {
isSelected?: boolean;
@ -32,7 +31,7 @@ export type PropsDataType = {
snippet: string;
body: string;
bodyRanges: HydratedBodyRangesType;
bodyRanges: BodyRangesForDisplayType;
from: Pick<
ConversationType,
@ -73,68 +72,6 @@ const renderPerson = (
): ReactNode =>
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(
function MessageSearchResult({
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 = (
<MessageBodyHighlight
text={snippet}
bodyRanges={snippetBodyRanges}
<MessageTextRenderer
messageText={cleanedSnippet}
bodyRanges={displayBodyRanges}
direction={undefined}
disableLinks
emojiSizeClass={undefined}
i18n={i18n}
isSpoilerExpanded={false}
onMentionTrigger={() => null}
renderLocation={RenderLocation.SearchResult}
textLength={cleanedSnippet.length}
/>
);

View file

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

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

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

View file

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

View file

@ -115,8 +115,8 @@ import {
isUniversalTimerNotification,
isUnsupportedMessage,
isVerifiedChange,
processBodyRanges,
isConversationMerge,
extractHydratedMentions,
} from '../state/selectors/message';
import {
isInCall,
@ -185,6 +185,8 @@ import * as Edits from '../messageModifiers/Edits';
import { handleEditMessage } from '../util/handleEditMessage';
import { getQuoteBodyText } from '../util/getQuoteBodyText';
import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser';
import type { RawBodyRange } from '../types/BodyRange';
import { BodyRange, applyRangesForText } from '../types/BodyRange';
/* eslint-disable more/no-then */
@ -192,7 +194,7 @@ window.Whisper = window.Whisper || {};
const { Message: TypedMessage } = window.Signal.Types;
const { upgradeMessageSchema } = window.Signal.Migrations;
const { getTextWithMentions, GoogleChrome } = window.Signal.Util;
const { GoogleChrome } = window.Signal.Util;
const { getMessageBySender } = window.Signal.Data;
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'));
}
getNotificationData(): { emoji?: string; text: string } {
getNotificationData(): {
emoji?: string;
text: string;
bodyRanges?: ReadonlyArray<RawBodyRange>;
} {
// eslint-disable-next-line prefer-destructuring
const attributes: MessageAttributesType = this.attributes;
@ -654,6 +660,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
const body = (this.get('body') || '').trim();
const bodyRanges = this.get('bodyRanges') || [];
if (attachments.length) {
// 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)) {
return {
text: body || window.i18n('icu:message--getNotificationText--gif'),
bodyRanges,
emoji: '🎡',
text: body || window.i18n('icu:message--getNotificationText--gif'),
};
}
if (Attachment.isImage(attachments)) {
return {
text: body || window.i18n('icu:message--getNotificationText--photo'),
bodyRanges,
emoji: '📷',
text: body || window.i18n('icu:message--getNotificationText--photo'),
};
}
if (Attachment.isVideo(attachments)) {
return {
text: body || window.i18n('icu:message--getNotificationText--video'),
bodyRanges,
emoji: '🎥',
text: body || window.i18n('icu:message--getNotificationText--video'),
};
}
if (Attachment.isVoiceMessage(attachment)) {
return {
bodyRanges,
emoji: '🎤',
text:
body ||
window.i18n('icu:message--getNotificationText--voice-message'),
emoji: '🎤',
};
}
if (Attachment.isAudio(attachments)) {
return {
bodyRanges,
emoji: '🔈',
text:
body ||
window.i18n('icu:message--getNotificationText--audio-message'),
emoji: '🔈',
};
}
return {
bodyRanges,
text: body || window.i18n('icu:message--getNotificationText--file'),
emoji: '📎',
};
@ -793,26 +807,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
if (body) {
return { text: body };
return {
text: body,
bodyRanges,
};
}
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 {
// if it's outgoing, it must be self-authored
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');
}
let modifiedText = text;
const bodyRanges = processBodyRanges(attributes, {
conversationSelector: findAndFormatContact,
});
if (bodyRanges && bodyRanges.length) {
modifiedText = getTextWithMentions(bodyRanges, modifiedText);
}
const mentions =
extractHydratedMentions(attributes, {
conversationSelector: findAndFormatContact,
}) || [];
const spoilers = (attributes.bodyRanges || []).filter(
range =>
BodyRange.isFormatting(range) && range.style === BodyRange.Style.SPOILER
) 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
// the `text`, which can contain emoji.)
@ -886,7 +889,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
emoji,
});
}
return modifiedText;
return modifiedText || '';
}
// General

View file

@ -6,7 +6,8 @@ import Delta from 'quill-delta';
import type { LeafBlot, DeltaOperation } from 'quill';
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';
export type MentionBlotValue = {
@ -61,8 +62,8 @@ export const getTextFromOps = (ops: Array<DeltaOperation>): string =>
export const getTextAndMentionsFromOps = (
ops: Array<Op>
): [string, DraftBodyRangesType] => {
const mentions: Array<DraftBodyRangeType> = [];
): [string, ReadonlyArray<DraftBodyRangeMention>] => {
const mentions: Array<DraftBodyRangeMention> = [];
const text = ops
.reduce((acc, op, index) => {
@ -168,11 +169,11 @@ export const getDeltaToRemoveStaleMentions = (
export const insertMentionOps = (
incomingOps: Array<Op>,
bodyRanges: DraftBodyRangesType
bodyRanges: ReadonlyArray<DraftBodyRangeMention>
): Array<Op> => {
const ops = [...incomingOps];
const sortableBodyRanges: Array<DraftBodyRangeType> = bodyRanges.slice();
const sortableBodyRanges: Array<DraftBodyRangeMention> = bodyRanges.slice();
// Working backwards through bodyRanges (to avoid offsetting later mentions),
// 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
sortableBodyRanges
.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();
if (op) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,7 +7,7 @@ import { isEqual, pick } from 'lodash';
import type { ReadonlyDeep } from 'type-fest';
import * as Errors from '../../types/errors';
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 {
MessageChangedActionType,
@ -77,6 +77,7 @@ export type StoryDataType = ReadonlyDeep<
startedDownload?: boolean;
} & Pick<
MessageAttributesType,
| 'bodyRanges'
| 'canReplyToStory'
| 'conversationId'
| 'deletedForEveryone'
@ -558,7 +559,7 @@ function reactToStory(
function replyToStory(
conversationId: string,
messageBody: string,
mentions: DraftBodyRangesType,
mentions: ReadonlyArray<DraftBodyRangeMention>,
timestamp: number,
story: StoryViewType
): ThunkAction<void, RootStateType, unknown, StoryChangedActionType> {
@ -1442,6 +1443,7 @@ export function reducer(
if (action.type === STORY_CHANGED) {
const newStory = pick(action.payload, [
'attachment',
'bodyRanges',
'canReplyToStory',
'conversationId',
'deletedForEveryone',
@ -1468,17 +1470,24 @@ export function reducer(
if (prevStoryIndex >= 0) {
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 hasAttachmentDownloaded =
!isDownloaded(prevStory.attachment) &&
isDownloaded(newStory.attachment);
const hasAttachmentFailed =
hasFailed(newStory.attachment) && !hasFailed(prevStory.attachment);
const hasExpirationChanged =
(newStory.expirationStartTimestamp &&
!prevStory.expirationStartTimestamp) ||
(newStory.expireTimer && !prevStory.expireTimer);
const readStatusChanged = prevStory.readStatus !== newStory.readStatus;
const reactionsChanged =
prevStory.reactions?.length !== newStory.reactions?.length;
@ -1493,6 +1502,7 @@ export function reducer(
prevStory.hasRepliesFromSelf !== newStory.hasRepliesFromSelf;
const shouldReplace =
bodyRangesChanged ||
isDownloadingAttachment ||
hasAttachmentDownloaded ||
hasAttachmentFailed ||

View file

@ -44,7 +44,12 @@ import type { UUIDStringType } from '../../types/UUID';
import type { EmbeddedContactType } 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 { getMentionsRegex } from '../../types/Message';
import { CallMode } from '../../types/Calling';
@ -312,7 +317,33 @@ export const processBodyRanges = (
}
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 => {
const { conversationSelector } = options;
const conversation = conversationSelector(range.mentionUuid);
@ -724,11 +755,12 @@ export const getPropsForMessage = (
isBlocked: conversation.isBlocked || false,
isEditedMessage: Boolean(message.editHistory),
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
isTargeted,
isTargetedCounter: isTargeted ? targetedMessageCounter : undefined,
isSelected,
isSelectMode,
isSpoilerExpanded: message.isSpoilerExpanded,
isSticker: Boolean(sticker),
isTargeted,
isTargetedCounter: isTargeted ? targetedMessageCounter : undefined,
isTapToView: isMessageTapToView,
isTapToViewError:
isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid,

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1527,6 +1527,7 @@ describe('both/state/ducks/conversations', () => {
...getDefaultMessage(messageId),
body: 'changed',
displayLimit: undefined,
isSpoilerExpanded: undefined,
};
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 = {
attachments,
// We need to remove all of the extra stuff on these objects so serialize properly
bodyRanges: msg.bodyRanges?.map(item => ({ ...item })),
preview,
canReplyToStory: Boolean(msg.allowsReplies),
expireTimer: DurationInSeconds.DAY,

View file

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

View file

@ -175,7 +175,8 @@ export function processQuote(
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,
};
}
@ -348,7 +349,8 @@ export function processDataMessage(
isViewOnce: Boolean(message.isViewOnce),
reaction: processReaction(message.reaction),
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),
storyContext: dropNull(message.storyContext),
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
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 { ConversationType } from '../state/ducks/conversations';
import type { ReadStatus } from '../messages/MessageReadStatus';
@ -71,6 +72,7 @@ export type StorySendStateType = {
export type StoryViewType = {
attachment?: AttachmentType;
bodyRanges?: HydratedBodyRangesType;
canReply?: boolean;
isHidden?: boolean;
isUnread?: boolean;

View file

@ -4,32 +4,6 @@
import type { IntlShape } from 'react-intl';
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 = {
authorUuid?: UUIDStringType;
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 { downloadAttachment } from './downloadAttachment';
import { getStringForProfileChange } from './getStringForProfileChange';
import { getTextWithMentions } from './getTextWithMentions';
import { getUuidsForE164s } from './getUuidsForE164s';
import { getUserAgent } from './getUserAgent';
import {
@ -53,7 +52,6 @@ export {
flushMessageCounter,
fromWebSafeBase64,
getStringForProfileChange,
getTextWithMentions,
getUserAgent,
incrementMessageCounter,
initializeMessageCounter,

View file

@ -16,7 +16,7 @@ import { isNotNil } from './isNotNil';
import { resetLinkPreview } from '../services/LinkPreview';
import { getRecipientsByConversation } from './getRecipientsByConversation';
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 { drop } from './drop';
import { toLogFormat } from '../types/errors';
@ -168,7 +168,7 @@ export async function maybeForwardMessages(
attachments: Array<AttachmentType>;
body: string | undefined;
contact?: Array<ContactWithHydratedAvatar>;
mentions?: BodyRangesType;
mentions?: Array<DraftBodyRangeMention>;
preview?: Array<LinkPreviewType>;
quote?: QuotedMessageType;
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"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
typescript@5.0.2:
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:
typescript@4.9.5, typescript@^4.9.5:
version "4.9.5"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==