Support for receiving formatted messages
Co-authored-by: Alvaro Carrasco <alvaro@signal.org>
This commit is contained in:
parent
d34d187f1e
commit
d9d820e72a
72 changed files with 3421 additions and 858 deletions
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -53,6 +53,10 @@
|
|||
&--outgoing {
|
||||
background-color: $color-black-alpha-40;
|
||||
}
|
||||
|
||||
&--invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
&__author {
|
||||
|
|
112
stylesheets/components/MessageTextRenderer.scss
Normal file
112
stylesheets/components/MessageTextRenderer.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -38,7 +38,7 @@ export function AnnouncementsOnlyGroupBanner({
|
|||
{groupAdmins.map(admin => (
|
||||
<ConversationListItem
|
||||
{...admin}
|
||||
draftPreview=""
|
||||
draftPreview={undefined}
|
||||
i18n={i18n}
|
||||
lastMessage={undefined}
|
||||
lastUpdated={undefined}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
61
ts/components/conversation/AtMention.tsx
Normal file
61
ts/components/conversation/AtMention.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
),
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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: '… It’s in words that the magic is – Abracadabra, Open Sesame, and the rest – but the magic words in one story aren’t 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! It’s 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} />;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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!');
|
||||
}}
|
||||
|
|
398
ts/components/conversation/MessageTextRenderer.tsx
Normal file
398
ts/components/conversation/MessageTextRenderer.tsx
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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(),
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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, ' ');
|
||||
}
|
||||
|
|
|
@ -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} />;
|
||||
}
|
|
@ -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>;
|
||||
}
|
||||
}
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -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
16
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -101,6 +101,7 @@ export function getStoryDataFromMessageAttributes(
|
|||
attachment,
|
||||
messageId: message.id,
|
||||
...pick(message, [
|
||||
'bodyRanges',
|
||||
'canReplyToStory',
|
||||
'conversationId',
|
||||
'deletedForEveryone',
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 ||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
1036
ts/test-both/types/BodyRange_test.ts
Normal file
1036
ts/test-both/types/BodyRange_test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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: [] }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1527,6 +1527,7 @@ describe('both/state/ducks/conversations', () => {
|
|||
...getDefaultMessage(messageId),
|
||||
body: 'changed',
|
||||
displayLimit: undefined,
|
||||
isSpoilerExpanded: undefined,
|
||||
};
|
||||
|
||||
it('updates message data', () => {
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
|
|
|
@ -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
559
ts/types/BodyRange.ts
Normal 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);
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
22
ts/util/stripNewlinesForLeftPane.ts
Normal file
22
ts/util/stripNewlinesForLeftPane.ts
Normal 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 '';
|
||||
});
|
||||
}
|
|
@ -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==
|
||||
|
|
Loading…
Reference in a new issue