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",
|
"messageformat": "Select",
|
||||||
"description": "Shown on the drop-down menu for an individual message, opens the conversation in select mode with the current message selected"
|
"description": "Shown on the drop-down menu for an individual message, opens the conversation in select mode with the current message selected"
|
||||||
},
|
},
|
||||||
|
"icu:MessageTextRenderer--spoiler--label": {
|
||||||
|
"messageformat": "Spoiler",
|
||||||
|
"description": "Used as a label for screenreaders on 'spoiler' text, which is hidden by default"
|
||||||
|
},
|
||||||
"retrySend": {
|
"retrySend": {
|
||||||
"message": "Retry Send",
|
"message": "Retry Send",
|
||||||
"description": "(deleted 03/29/2023) Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send"
|
"description": "(deleted 03/29/2023) Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send"
|
||||||
|
|
|
@ -290,7 +290,7 @@
|
||||||
"ts-loader": "4.1.0",
|
"ts-loader": "4.1.0",
|
||||||
"ts-node": "8.3.0",
|
"ts-node": "8.3.0",
|
||||||
"typed-scss-modules": "4.1.1",
|
"typed-scss-modules": "4.1.1",
|
||||||
"typescript": "5.0.2",
|
"typescript": "4.9.5",
|
||||||
"webpack": "5.76.0",
|
"webpack": "5.76.0",
|
||||||
"webpack-cli": "4.9.2",
|
"webpack-cli": "4.9.2",
|
||||||
"webpack-dev-server": "4.11.1"
|
"webpack-dev-server": "4.11.1"
|
||||||
|
|
|
@ -307,12 +307,22 @@ message DataMessage {
|
||||||
}
|
}
|
||||||
|
|
||||||
message BodyRange {
|
message BodyRange {
|
||||||
|
enum Style {
|
||||||
|
NONE = 0;
|
||||||
|
BOLD = 1;
|
||||||
|
ITALIC = 2;
|
||||||
|
SPOILER = 3;
|
||||||
|
STRIKETHROUGH = 4;
|
||||||
|
MONOSPACE = 5;
|
||||||
|
}
|
||||||
|
|
||||||
optional uint32 start = 1;
|
optional uint32 start = 1;
|
||||||
optional uint32 length = 2;
|
optional uint32 length = 2;
|
||||||
|
|
||||||
// oneof associatedValue {
|
oneof associatedValue {
|
||||||
optional string mentionUuid = 3;
|
string mentionUuid = 3;
|
||||||
//}
|
Style style = 4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message GroupCallUpdate {
|
message GroupCallUpdate {
|
||||||
|
@ -399,6 +409,7 @@ message StoryMessage {
|
||||||
TextAttachment textAttachment = 4;
|
TextAttachment textAttachment = 4;
|
||||||
}
|
}
|
||||||
optional bool allowsReplies = 5;
|
optional bool allowsReplies = 5;
|
||||||
|
repeated BodyRange bodyRanges = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message TextAttachment {
|
message TextAttachment {
|
||||||
|
|
|
@ -73,6 +73,10 @@ img.emoji.max {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img.emoji--invisible {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
// we need these, or we'll make conversation items too big in the left-nav
|
// we need these, or we'll make conversation items too big in the left-nav
|
||||||
.conversations img.emoji.small {
|
.conversations img.emoji.small {
|
||||||
width: 1em;
|
width: 1em;
|
||||||
|
|
|
@ -53,6 +53,10 @@
|
||||||
&--outgoing {
|
&--outgoing {
|
||||||
background-color: $color-black-alpha-40;
|
background-color: $color-black-alpha-40;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--invisible {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__author {
|
&__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/MediaQualitySelector.scss';
|
||||||
@import './components/MessageAudio.scss';
|
@import './components/MessageAudio.scss';
|
||||||
@import './components/MessageBody.scss';
|
@import './components/MessageBody.scss';
|
||||||
|
@import './components/MessageTextRenderer.scss';
|
||||||
@import './components/MessageDetail.scss';
|
@import './components/MessageDetail.scss';
|
||||||
@import './components/MiniPlayer.scss';
|
@import './components/MiniPlayer.scss';
|
||||||
@import './components/Modal.scss';
|
@import './components/Modal.scss';
|
||||||
|
|
|
@ -38,7 +38,7 @@ export function AnnouncementsOnlyGroupBanner({
|
||||||
{groupAdmins.map(admin => (
|
{groupAdmins.map(admin => (
|
||||||
<ConversationListItem
|
<ConversationListItem
|
||||||
{...admin}
|
{...admin}
|
||||||
draftPreview=""
|
draftPreview={undefined}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
lastMessage={undefined}
|
lastMessage={undefined}
|
||||||
lastUpdated={undefined}
|
lastUpdated={undefined}
|
||||||
|
|
|
@ -4,11 +4,8 @@
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { get } from 'lodash';
|
import { get } from 'lodash';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type {
|
import type { DraftBodyRangeMention } from '../types/BodyRange';
|
||||||
DraftBodyRangesType,
|
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||||
LocalizerType,
|
|
||||||
ThemeType,
|
|
||||||
} from '../types/Util';
|
|
||||||
import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder';
|
import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder';
|
||||||
import { RecordingState } from '../types/AudioRecorder';
|
import { RecordingState } from '../types/AudioRecorder';
|
||||||
import type { imageToBlurHash } from '../util/imageToBlurHash';
|
import type { imageToBlurHash } from '../util/imageToBlurHash';
|
||||||
|
@ -123,7 +120,7 @@ export type OwnProps = Readonly<{
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
options: {
|
options: {
|
||||||
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
|
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
|
||||||
mentions?: DraftBodyRangesType;
|
mentions?: ReadonlyArray<DraftBodyRangeMention>;
|
||||||
message?: string;
|
message?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
voiceNoteAttachment?: InMemoryAttachmentDraftType;
|
voiceNoteAttachment?: InMemoryAttachmentDraftType;
|
||||||
|
@ -233,8 +230,8 @@ export function CompositionArea({
|
||||||
shouldSendHighQualityAttachments,
|
shouldSendHighQualityAttachments,
|
||||||
// CompositionInput
|
// CompositionInput
|
||||||
clearQuotedMessage,
|
clearQuotedMessage,
|
||||||
draftText,
|
|
||||||
draftBodyRanges,
|
draftBodyRanges,
|
||||||
|
draftText,
|
||||||
getPreferredBadge,
|
getPreferredBadge,
|
||||||
getQuotedMessage,
|
getQuotedMessage,
|
||||||
onEditorStateChange,
|
onEditorStateChange,
|
||||||
|
@ -311,7 +308,11 @@ export function CompositionArea({
|
||||||
}, [inputApiRef, setLarge]);
|
}, [inputApiRef, setLarge]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(message: string, mentions: DraftBodyRangesType, timestamp: number) => {
|
(
|
||||||
|
message: string,
|
||||||
|
mentions: ReadonlyArray<DraftBodyRangeMention>,
|
||||||
|
timestamp: number
|
||||||
|
) => {
|
||||||
emojiButtonRef.current?.close();
|
emojiButtonRef.current?.close();
|
||||||
sendMultiMediaMessage(conversationId, {
|
sendMultiMediaMessage(conversationId, {
|
||||||
draftAttachments,
|
draftAttachments,
|
||||||
|
|
|
@ -14,11 +14,8 @@ import { MentionCompletion } from '../quill/mentions/completion';
|
||||||
import { EmojiBlot, EmojiCompletion } from '../quill/emoji';
|
import { EmojiBlot, EmojiCompletion } from '../quill/emoji';
|
||||||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||||
import { convertShortName } from './emoji/lib';
|
import { convertShortName } from './emoji/lib';
|
||||||
import type {
|
import type { DraftBodyRangeMention } from '../types/BodyRange';
|
||||||
LocalizerType,
|
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||||
DraftBodyRangesType,
|
|
||||||
ThemeType,
|
|
||||||
} from '../types/Util';
|
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||||
import { isValidUuid } from '../types/UUID';
|
import { isValidUuid } from '../types/UUID';
|
||||||
|
@ -64,7 +61,7 @@ export type InputApi = {
|
||||||
insertEmoji: (e: EmojiPickDataType) => void;
|
insertEmoji: (e: EmojiPickDataType) => void;
|
||||||
setContents: (
|
setContents: (
|
||||||
text: string,
|
text: string,
|
||||||
draftBodyRanges?: DraftBodyRangesType,
|
draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>,
|
||||||
cursorToEnd?: boolean
|
cursorToEnd?: boolean
|
||||||
) => void;
|
) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
|
@ -82,7 +79,7 @@ export type Props = Readonly<{
|
||||||
sendCounter: number;
|
sendCounter: number;
|
||||||
skinTone?: EmojiPickDataType['skinTone'];
|
skinTone?: EmojiPickDataType['skinTone'];
|
||||||
draftText?: string;
|
draftText?: string;
|
||||||
draftBodyRanges?: DraftBodyRangesType;
|
draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>;
|
||||||
moduleClassName?: string;
|
moduleClassName?: string;
|
||||||
theme: ThemeType;
|
theme: ThemeType;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
@ -90,7 +87,7 @@ export type Props = Readonly<{
|
||||||
scrollerRef?: React.RefObject<HTMLDivElement>;
|
scrollerRef?: React.RefObject<HTMLDivElement>;
|
||||||
onDirtyChange?(dirty: boolean): unknown;
|
onDirtyChange?(dirty: boolean): unknown;
|
||||||
onEditorStateChange?(options: {
|
onEditorStateChange?(options: {
|
||||||
bodyRanges: DraftBodyRangesType;
|
bodyRanges: ReadonlyArray<DraftBodyRangeMention>;
|
||||||
caretLocation?: number;
|
caretLocation?: number;
|
||||||
conversationId: string | undefined;
|
conversationId: string | undefined;
|
||||||
messageText: string;
|
messageText: string;
|
||||||
|
@ -100,7 +97,7 @@ export type Props = Readonly<{
|
||||||
onPickEmoji(o: EmojiPickDataType): unknown;
|
onPickEmoji(o: EmojiPickDataType): unknown;
|
||||||
onSubmit(
|
onSubmit(
|
||||||
message: string,
|
message: string,
|
||||||
mentions: DraftBodyRangesType,
|
mentions: ReadonlyArray<DraftBodyRangeMention>,
|
||||||
timestamp: number
|
timestamp: number
|
||||||
): unknown;
|
): unknown;
|
||||||
onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
|
onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
|
||||||
|
@ -164,16 +161,19 @@ export function CompositionInput(props: Props): React.ReactElement {
|
||||||
|
|
||||||
const generateDelta = (
|
const generateDelta = (
|
||||||
text: string,
|
text: string,
|
||||||
bodyRanges: DraftBodyRangesType
|
mentions: ReadonlyArray<DraftBodyRangeMention>
|
||||||
): Delta => {
|
): Delta => {
|
||||||
const initialOps = [{ insert: text }];
|
const initialOps = [{ insert: text }];
|
||||||
const opsWithMentions = insertMentionOps(initialOps, bodyRanges);
|
const opsWithMentions = insertMentionOps(initialOps, mentions);
|
||||||
const opsWithEmojis = insertEmojiOps(opsWithMentions);
|
const opsWithEmojis = insertEmojiOps(opsWithMentions);
|
||||||
|
|
||||||
return new Delta(opsWithEmojis);
|
return new Delta(opsWithEmojis);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTextAndMentions = (): [string, DraftBodyRangesType] => {
|
const getTextAndMentions = (): [
|
||||||
|
string,
|
||||||
|
ReadonlyArray<DraftBodyRangeMention>
|
||||||
|
] => {
|
||||||
const quill = quillRef.current;
|
const quill = quillRef.current;
|
||||||
|
|
||||||
if (quill === undefined) {
|
if (quill === undefined) {
|
||||||
|
@ -251,7 +251,7 @@ export function CompositionInput(props: Props): React.ReactElement {
|
||||||
|
|
||||||
const setContents = (
|
const setContents = (
|
||||||
text: string,
|
text: string,
|
||||||
bodyRanges?: DraftBodyRangesType,
|
mentions?: ReadonlyArray<DraftBodyRangeMention>,
|
||||||
cursorToEnd?: boolean
|
cursorToEnd?: boolean
|
||||||
) => {
|
) => {
|
||||||
const quill = quillRef.current;
|
const quill = quillRef.current;
|
||||||
|
@ -260,7 +260,7 @@ export function CompositionInput(props: Props): React.ReactElement {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const delta = generateDelta(text || '', bodyRanges || []);
|
const delta = generateDelta(text || '', mentions || []);
|
||||||
|
|
||||||
canSendRef.current = true;
|
canSendRef.current = true;
|
||||||
// We need to cast here because we use @types/quill@1.3.10 which has types
|
// We need to cast here because we use @types/quill@1.3.10 which has types
|
||||||
|
|
|
@ -9,7 +9,8 @@ import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
|
||||||
import type { InputApi } from './CompositionInput';
|
import type { InputApi } from './CompositionInput';
|
||||||
import { CompositionInput } from './CompositionInput';
|
import { CompositionInput } from './CompositionInput';
|
||||||
import { EmojiButton } from './emoji/EmojiButton';
|
import { EmojiButton } from './emoji/EmojiButton';
|
||||||
import type { DraftBodyRangesType, ThemeType } from '../types/Util';
|
import type { DraftBodyRangeMention } from '../types/BodyRange';
|
||||||
|
import type { ThemeType } from '../types/Util';
|
||||||
import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
|
import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
|
||||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||||
import * as grapheme from '../util/grapheme';
|
import * as grapheme from '../util/grapheme';
|
||||||
|
@ -24,13 +25,13 @@ export type CompositionTextAreaProps = {
|
||||||
onPickEmoji: (e: EmojiPickDataType) => void;
|
onPickEmoji: (e: EmojiPickDataType) => void;
|
||||||
onChange: (
|
onChange: (
|
||||||
messageText: string,
|
messageText: string,
|
||||||
bodyRanges: DraftBodyRangesType,
|
draftBodyRanges: ReadonlyArray<DraftBodyRangeMention>,
|
||||||
caretLocation?: number | undefined
|
caretLocation?: number | undefined
|
||||||
) => void;
|
) => void;
|
||||||
onSetSkinTone: (tone: number) => void;
|
onSetSkinTone: (tone: number) => void;
|
||||||
onSubmit: (
|
onSubmit: (
|
||||||
message: string,
|
message: string,
|
||||||
mentions: DraftBodyRangesType,
|
draftBodyRanges: ReadonlyArray<DraftBodyRangeMention>,
|
||||||
timestamp: number
|
timestamp: number
|
||||||
) => void;
|
) => void;
|
||||||
onTextTooLong: () => void;
|
onTextTooLong: () => void;
|
||||||
|
|
|
@ -391,7 +391,11 @@ ConversationTypingStatus.story = {
|
||||||
export const ConversationWithDraft = (): JSX.Element =>
|
export const ConversationWithDraft = (): JSX.Element =>
|
||||||
renderConversation({
|
renderConversation({
|
||||||
shouldShowDraft: true,
|
shouldShowDraft: true,
|
||||||
draftPreview: "I'm in the middle of typing this...",
|
draftPreview: {
|
||||||
|
text: "I'm in the middle of typing this...",
|
||||||
|
prefix: '🎤',
|
||||||
|
bodyRanges: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
ConversationWithDraft.story = {
|
ConversationWithDraft.story = {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2023 Signal Messenger, LLC
|
// Copyright 2023 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, { useCallback, useRef } from 'react';
|
import React, { useCallback, useState, useRef } from 'react';
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
|
|
||||||
import type { AttachmentType } from '../types/Attachment';
|
import type { AttachmentType } from '../types/Attachment';
|
||||||
|
@ -86,6 +86,14 @@ export function EditHistoryMessagesModal({
|
||||||
[closeEditHistoryModal, showLightbox]
|
[closeEditHistoryModal, showLightbox]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// These states aren't in redux; they are meant to last only as long as this dialog.
|
||||||
|
const [revealedSpoilersById, setRevealedSpoilersById] = useState<
|
||||||
|
Record<string, boolean | undefined>
|
||||||
|
>({});
|
||||||
|
const [displayLimitById, setDisplayLimitById] = useState<
|
||||||
|
Record<string, number | undefined>
|
||||||
|
>({});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
hasXButton
|
hasXButton
|
||||||
|
@ -95,20 +103,41 @@ export function EditHistoryMessagesModal({
|
||||||
title={i18n('icu:EditHistoryMessagesModal__title')}
|
title={i18n('icu:EditHistoryMessagesModal__title')}
|
||||||
>
|
>
|
||||||
<div ref={containerElementRef}>
|
<div ref={containerElementRef}>
|
||||||
{editHistoryMessages.map(messageAttributes => (
|
{editHistoryMessages.map(messageAttributes => {
|
||||||
|
const syntheticId = `${messageAttributes.id}.${messageAttributes.timestamp}`;
|
||||||
|
|
||||||
|
return (
|
||||||
<Message
|
<Message
|
||||||
{...MESSAGE_DEFAULT_PROPS}
|
{...MESSAGE_DEFAULT_PROPS}
|
||||||
{...messageAttributes}
|
{...messageAttributes}
|
||||||
|
id={syntheticId}
|
||||||
containerElementRef={containerElementRef}
|
containerElementRef={containerElementRef}
|
||||||
|
displayLimit={displayLimitById[syntheticId]}
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
platform={platform}
|
isSpoilerExpanded={revealedSpoilersById[syntheticId] || false}
|
||||||
key={messageAttributes.timestamp}
|
key={messageAttributes.timestamp}
|
||||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||||
|
messageExpanded={(messageId, displayLimit) => {
|
||||||
|
const update = {
|
||||||
|
...displayLimitById,
|
||||||
|
[messageId]: displayLimit,
|
||||||
|
};
|
||||||
|
setDisplayLimitById(update);
|
||||||
|
}}
|
||||||
|
platform={platform}
|
||||||
showLightbox={closeAndShowLightbox}
|
showLightbox={closeAndShowLightbox}
|
||||||
|
showSpoiler={messageId => {
|
||||||
|
const update = {
|
||||||
|
...revealedSpoilersById,
|
||||||
|
[messageId]: true,
|
||||||
|
};
|
||||||
|
setRevealedSpoilersById(update);
|
||||||
|
}}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
|
@ -10,7 +10,8 @@ import React, {
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import type { DraftBodyRangesType, LocalizerType } from '../types/Util';
|
import type { DraftBodyRangeMention } from '../types/BodyRange';
|
||||||
|
import type { LocalizerType } from '../types/Util';
|
||||||
import type { ContextMenuOptionType } from './ContextMenu';
|
import type { ContextMenuOptionType } from './ContextMenu';
|
||||||
import type {
|
import type {
|
||||||
ConversationType,
|
ConversationType,
|
||||||
|
@ -27,7 +28,6 @@ import { AnimatedEmojiGalore } from './AnimatedEmojiGalore';
|
||||||
import { Avatar, AvatarSize } from './Avatar';
|
import { Avatar, AvatarSize } from './Avatar';
|
||||||
import { ConfirmationDialog } from './ConfirmationDialog';
|
import { ConfirmationDialog } from './ConfirmationDialog';
|
||||||
import { ContextMenu } from './ContextMenu';
|
import { ContextMenu } from './ContextMenu';
|
||||||
import { Emojify } from './conversation/Emojify';
|
|
||||||
import { Intl } from './Intl';
|
import { Intl } from './Intl';
|
||||||
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
import { MessageTimestamp } from './conversation/MessageTimestamp';
|
||||||
import { SendStatus } from '../messages/MessageSendState';
|
import { SendStatus } from '../messages/MessageSendState';
|
||||||
|
@ -53,6 +53,8 @@ import { useEscapeHandling } from '../hooks/useEscapeHandling';
|
||||||
import { useRetryStorySend } from '../hooks/useRetryStorySend';
|
import { useRetryStorySend } from '../hooks/useRetryStorySend';
|
||||||
import { resolveStorySendStatus } from '../util/resolveStorySendStatus';
|
import { resolveStorySendStatus } from '../util/resolveStorySendStatus';
|
||||||
import { strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
|
import { MessageBody } from './conversation/MessageBody';
|
||||||
|
import { RenderLocation } from './conversation/MessageTextRenderer';
|
||||||
|
|
||||||
function renderStrong(parts: Array<JSX.Element | string>) {
|
function renderStrong(parts: Array<JSX.Element | string>) {
|
||||||
return <strong>{parts}</strong>;
|
return <strong>{parts}</strong>;
|
||||||
|
@ -95,7 +97,7 @@ export type PropsType = {
|
||||||
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
|
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
|
||||||
onReplyToStory: (
|
onReplyToStory: (
|
||||||
message: string,
|
message: string,
|
||||||
mentions: DraftBodyRangesType,
|
mentions: ReadonlyArray<DraftBodyRangeMention>,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
story: StoryViewType
|
story: StoryViewType
|
||||||
) => unknown;
|
) => unknown;
|
||||||
|
@ -184,6 +186,7 @@ export function StoryViewer({
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attachment,
|
attachment,
|
||||||
|
bodyRanges,
|
||||||
canReply,
|
canReply,
|
||||||
isHidden,
|
isHidden,
|
||||||
messageId,
|
messageId,
|
||||||
|
@ -234,6 +237,7 @@ export function StoryViewer({
|
||||||
|
|
||||||
// Caption related hooks
|
// Caption related hooks
|
||||||
const [hasExpandedCaption, setHasExpandedCaption] = useState<boolean>(false);
|
const [hasExpandedCaption, setHasExpandedCaption] = useState<boolean>(false);
|
||||||
|
const [isSpoilerExpanded, setIsSpoilerExpanded] = useState<boolean>(false);
|
||||||
|
|
||||||
const caption = useMemo(() => {
|
const caption = useMemo(() => {
|
||||||
if (!attachment?.caption) {
|
if (!attachment?.caption) {
|
||||||
|
@ -250,6 +254,7 @@ export function StoryViewer({
|
||||||
// Reset expansion if messageId changes
|
// Reset expansion if messageId changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHasExpandedCaption(false);
|
setHasExpandedCaption(false);
|
||||||
|
setIsSpoilerExpanded(false);
|
||||||
}, [messageId]);
|
}, [messageId]);
|
||||||
|
|
||||||
// messageId is set as a dependency so that we can reset the story duration
|
// messageId is set as a dependency so that we can reset the story duration
|
||||||
|
@ -333,6 +338,7 @@ export function StoryViewer({
|
||||||
setConfirmDeleteStory(undefined);
|
setConfirmDeleteStory(undefined);
|
||||||
setHasConfirmHideStory(false);
|
setHasConfirmHideStory(false);
|
||||||
setHasExpandedCaption(false);
|
setHasExpandedCaption(false);
|
||||||
|
setIsSpoilerExpanded(false);
|
||||||
setIsShowingContextMenu(false);
|
setIsShowingContextMenu(false);
|
||||||
setPauseStory(false);
|
setPauseStory(false);
|
||||||
|
|
||||||
|
@ -644,7 +650,7 @@ export function StoryViewer({
|
||||||
{hasExpandedCaption && (
|
{hasExpandedCaption && (
|
||||||
<button
|
<button
|
||||||
aria-label={i18n('icu:close-popup')}
|
aria-label={i18n('icu:close-popup')}
|
||||||
className="StoryViewer__caption__overlay"
|
className="StoryViewer__CAPTION__overlay"
|
||||||
onClick={() => setHasExpandedCaption(false)}
|
onClick={() => setHasExpandedCaption(false)}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
/>
|
||||||
|
@ -677,7 +683,14 @@ export function StoryViewer({
|
||||||
<div className="StoryViewer__meta">
|
<div className="StoryViewer__meta">
|
||||||
{caption && (
|
{caption && (
|
||||||
<div className="StoryViewer__caption">
|
<div className="StoryViewer__caption">
|
||||||
<Emojify text={caption.text} />
|
<MessageBody
|
||||||
|
bodyRanges={bodyRanges}
|
||||||
|
i18n={i18n}
|
||||||
|
isSpoilerExpanded={isSpoilerExpanded}
|
||||||
|
onExpandSpoiler={() => setIsSpoilerExpanded(true)}
|
||||||
|
renderLocation={RenderLocation.StoryViewer}
|
||||||
|
text={caption.text}
|
||||||
|
/>
|
||||||
{caption.hasReadMore && !hasExpandedCaption && (
|
{caption.hasReadMore && !hasExpandedCaption && (
|
||||||
<button
|
<button
|
||||||
className="MessageBody__read-more"
|
className="MessageBody__read-more"
|
||||||
|
|
|
@ -11,7 +11,8 @@ import React, {
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
|
|
||||||
import type { DraftBodyRangesType, LocalizerType } from '../types/Util';
|
import type { DraftBodyRangeMention } from '../types/BodyRange';
|
||||||
|
import type { LocalizerType } from '../types/Util';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||||
import type { InputApi } from './CompositionInput';
|
import type { InputApi } from './CompositionInput';
|
||||||
|
@ -94,7 +95,7 @@ export type PropsType = {
|
||||||
onReact: (emoji: string) => unknown;
|
onReact: (emoji: string) => unknown;
|
||||||
onReply: (
|
onReply: (
|
||||||
message: string,
|
message: string,
|
||||||
mentions: DraftBodyRangesType,
|
mentions: ReadonlyArray<DraftBodyRangeMention>,
|
||||||
timestamp: number
|
timestamp: number
|
||||||
) => unknown;
|
) => unknown;
|
||||||
onSetSkinTone: (tone: number) => unknown;
|
onSetSkinTone: (tone: number) => unknown;
|
||||||
|
@ -147,6 +148,14 @@ export function StoryViewsNRepliesModal({
|
||||||
string | undefined
|
string | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
|
// These states aren't in redux; they are meant to last only as long as this dialog.
|
||||||
|
const [revealedSpoilersById, setRevealedSpoilersById] = useState<
|
||||||
|
Record<string, boolean | undefined>
|
||||||
|
>({});
|
||||||
|
const [displayLimitById, setDisplayLimitById] = useState<
|
||||||
|
Record<string, number | undefined>
|
||||||
|
>({});
|
||||||
|
|
||||||
const containerElementRef = useRef<HTMLDivElement | null>(null);
|
const containerElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
const inputApiRef = useRef<InputApi | undefined>();
|
const inputApiRef = useRef<InputApi | undefined>();
|
||||||
const shouldScrollToBottomRef = useRef(true);
|
const shouldScrollToBottomRef = useRef(true);
|
||||||
|
@ -287,15 +296,31 @@ export function StoryViewsNRepliesModal({
|
||||||
deleteGroupStoryReplyForEveryone={() =>
|
deleteGroupStoryReplyForEveryone={() =>
|
||||||
setDeleteForEveryoneReplyId(reply.id)
|
setDeleteForEveryoneReplyId(reply.id)
|
||||||
}
|
}
|
||||||
|
displayLimit={displayLimitById[reply.id]}
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
platform={platform}
|
platform={platform}
|
||||||
id={reply.id}
|
id={reply.id}
|
||||||
isInternalUser={isInternalUser}
|
isInternalUser={isInternalUser}
|
||||||
|
isSpoilerExpanded={revealedSpoilersById[reply.id] || false}
|
||||||
|
messageExpanded={(messageId, displayLimit) => {
|
||||||
|
const update = {
|
||||||
|
...displayLimitById,
|
||||||
|
[messageId]: displayLimit,
|
||||||
|
};
|
||||||
|
setDisplayLimitById(update);
|
||||||
|
}}
|
||||||
reply={reply}
|
reply={reply}
|
||||||
shouldCollapseAbove={shouldCollapse(reply, replies[index - 1])}
|
shouldCollapseAbove={shouldCollapse(reply, replies[index - 1])}
|
||||||
shouldCollapseBelow={shouldCollapse(reply, replies[index + 1])}
|
shouldCollapseBelow={shouldCollapse(reply, replies[index + 1])}
|
||||||
showContactModal={showContactModal}
|
showContactModal={showContactModal}
|
||||||
|
showSpoiler={messageId => {
|
||||||
|
const update = {
|
||||||
|
...revealedSpoilersById,
|
||||||
|
[messageId]: true,
|
||||||
|
};
|
||||||
|
setRevealedSpoilersById(update);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -465,31 +490,39 @@ type ReplyOrReactionMessageProps = {
|
||||||
containerElementRef: React.RefObject<HTMLElement>;
|
containerElementRef: React.RefObject<HTMLElement>;
|
||||||
deleteGroupStoryReply: (replyId: string) => void;
|
deleteGroupStoryReply: (replyId: string) => void;
|
||||||
deleteGroupStoryReplyForEveryone: (replyId: string) => void;
|
deleteGroupStoryReplyForEveryone: (replyId: string) => void;
|
||||||
|
displayLimit: number | undefined;
|
||||||
getPreferredBadge: PreferredBadgeSelectorType;
|
getPreferredBadge: PreferredBadgeSelectorType;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
platform: string;
|
platform: string;
|
||||||
id: string;
|
id: string;
|
||||||
isInternalUser?: boolean;
|
isInternalUser?: boolean;
|
||||||
|
isSpoilerExpanded: boolean;
|
||||||
onContextMenu?: (ev: React.MouseEvent) => void;
|
onContextMenu?: (ev: React.MouseEvent) => void;
|
||||||
reply: ReplyType;
|
reply: ReplyType;
|
||||||
shouldCollapseAbove: boolean;
|
shouldCollapseAbove: boolean;
|
||||||
shouldCollapseBelow: boolean;
|
shouldCollapseBelow: boolean;
|
||||||
showContactModal: (contactId: string, conversationId?: string) => void;
|
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||||
|
messageExpanded: (messageId: string, displayLimit: number) => void;
|
||||||
|
showSpoiler: (messageId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ReplyOrReactionMessage({
|
function ReplyOrReactionMessage({
|
||||||
|
containerElementRef,
|
||||||
|
deleteGroupStoryReply,
|
||||||
|
deleteGroupStoryReplyForEveryone,
|
||||||
|
displayLimit,
|
||||||
|
getPreferredBadge,
|
||||||
i18n,
|
i18n,
|
||||||
id,
|
id,
|
||||||
isInternalUser,
|
isInternalUser,
|
||||||
reply,
|
isSpoilerExpanded,
|
||||||
deleteGroupStoryReply,
|
messageExpanded,
|
||||||
deleteGroupStoryReplyForEveryone,
|
|
||||||
containerElementRef,
|
|
||||||
getPreferredBadge,
|
|
||||||
platform,
|
platform,
|
||||||
|
reply,
|
||||||
shouldCollapseAbove,
|
shouldCollapseAbove,
|
||||||
shouldCollapseBelow,
|
shouldCollapseBelow,
|
||||||
showContactModal,
|
showContactModal,
|
||||||
|
showSpoiler,
|
||||||
}: ReplyOrReactionMessageProps) {
|
}: ReplyOrReactionMessageProps) {
|
||||||
const renderContent = (onContextMenu?: (ev: React.MouseEvent) => void) => {
|
const renderContent = (onContextMenu?: (ev: React.MouseEvent) => void) => {
|
||||||
if (reply.reactionEmoji && !reply.deletedForEveryone) {
|
if (reply.reactionEmoji && !reply.deletedForEveryone) {
|
||||||
|
@ -549,21 +582,25 @@ function ReplyOrReactionMessage({
|
||||||
conversationId={reply.conversationId}
|
conversationId={reply.conversationId}
|
||||||
conversationTitle={reply.author.title}
|
conversationTitle={reply.author.title}
|
||||||
conversationType="group"
|
conversationType="group"
|
||||||
direction="incoming"
|
|
||||||
deletedForEveryone={reply.deletedForEveryone}
|
deletedForEveryone={reply.deletedForEveryone}
|
||||||
renderMenu={undefined}
|
direction="incoming"
|
||||||
onContextMenu={onContextMenu}
|
displayLimit={displayLimit}
|
||||||
getPreferredBadge={getPreferredBadge}
|
getPreferredBadge={getPreferredBadge}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
platform={platform}
|
platform={platform}
|
||||||
id={reply.id}
|
id={reply.id}
|
||||||
interactionMode="mouse"
|
interactionMode="mouse"
|
||||||
|
isSpoilerExpanded={isSpoilerExpanded}
|
||||||
|
messageExpanded={messageExpanded}
|
||||||
|
onContextMenu={onContextMenu}
|
||||||
readStatus={reply.readStatus}
|
readStatus={reply.readStatus}
|
||||||
renderingContext="StoryViewsNRepliesModal"
|
renderingContext="StoryViewsNRepliesModal"
|
||||||
|
renderMenu={undefined}
|
||||||
shouldCollapseAbove={shouldCollapseAbove}
|
shouldCollapseAbove={shouldCollapseAbove}
|
||||||
shouldCollapseBelow={shouldCollapseBelow}
|
shouldCollapseBelow={shouldCollapseBelow}
|
||||||
shouldHideMetadata={false}
|
shouldHideMetadata={false}
|
||||||
showContactModal={showContactModal}
|
showContactModal={showContactModal}
|
||||||
|
showSpoiler={showSpoiler}
|
||||||
text={reply.body}
|
text={reply.body}
|
||||||
textDirection={TextDirection.Default}
|
textDirection={TextDirection.Default}
|
||||||
timestamp={reply.timestamp}
|
timestamp={reply.timestamp}
|
||||||
|
|
|
@ -7,7 +7,7 @@ import type { RenderTextCallbackType } from '../../types/Util';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
text: string;
|
text: string;
|
||||||
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
|
/** Allows you to customize how non-newlines are rendered. Simplest is just a <span>. */
|
||||||
renderNonNewLine?: RenderTextCallbackType;
|
renderNonNewLine?: RenderTextCallbackType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
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 => ({
|
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
bodyRanges: overrideProps.bodyRanges,
|
mentions: overrideProps.mentions,
|
||||||
direction: overrideProps.direction || 'incoming',
|
direction: overrideProps.direction || 'incoming',
|
||||||
showConversation: action('showConversation'),
|
showConversation: action('showConversation'),
|
||||||
text: overrideProps.text || '',
|
text: overrideProps.text || '',
|
||||||
|
@ -28,7 +28,7 @@ export function NoMentions(): JSX.Element {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultipleMentions(): JSX.Element {
|
export function MultipleMentions(): JSX.Element {
|
||||||
const bodyRanges = [
|
const mentions = [
|
||||||
{
|
{
|
||||||
start: 4,
|
start: 4,
|
||||||
length: 1,
|
length: 1,
|
||||||
|
@ -52,16 +52,16 @@ export function MultipleMentions(): JSX.Element {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
bodyRanges,
|
mentions,
|
||||||
direction: 'outgoing',
|
direction: 'outgoing',
|
||||||
text: AtMentionify.preprocessMentions('\uFFFC \uFFFC \uFFFC', bodyRanges),
|
text: AtMentionify.preprocessMentions('\uFFFC \uFFFC \uFFFC', mentions),
|
||||||
});
|
});
|
||||||
|
|
||||||
return <AtMentionify {...props} />;
|
return <AtMentionify {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ComplexMentions(): JSX.Element {
|
export function ComplexMentions(): JSX.Element {
|
||||||
const bodyRanges = [
|
const mentions = [
|
||||||
{
|
{
|
||||||
start: 80,
|
start: 80,
|
||||||
length: 1,
|
length: 1,
|
||||||
|
@ -86,10 +86,10 @@ export function ComplexMentions(): JSX.Element {
|
||||||
];
|
];
|
||||||
|
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
bodyRanges,
|
mentions,
|
||||||
text: AtMentionify.preprocessMentions(
|
text: AtMentionify.preprocessMentions(
|
||||||
'Hey \uFFFC\nCheck out https://www.signal.org I think you will really like it 😍\n\ncc \uFFFC \uFFFC',
|
'Hey \uFFFC\nCheck out https://www.signal.org I think you will really like it 😍\n\ncc \uFFFC \uFFFC',
|
||||||
bodyRanges
|
mentions
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -97,7 +97,7 @@ export function ComplexMentions(): JSX.Element {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WithOddCharacter(): JSX.Element {
|
export function WithOddCharacter(): JSX.Element {
|
||||||
const bodyRanges = [
|
const mentions = [
|
||||||
{
|
{
|
||||||
start: 4,
|
start: 4,
|
||||||
length: 1,
|
length: 1,
|
||||||
|
@ -108,10 +108,10 @@ export function WithOddCharacter(): JSX.Element {
|
||||||
];
|
];
|
||||||
|
|
||||||
const props = createProps({
|
const props = createProps({
|
||||||
bodyRanges,
|
mentions,
|
||||||
text: AtMentionify.preprocessMentions(
|
text: AtMentionify.preprocessMentions(
|
||||||
'Hey \uFFFC - Check out │https://www.signal.org│',
|
'Hey \uFFFC - Check out │https://www.signal.org│',
|
||||||
bodyRanges
|
mentions
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -3,15 +3,14 @@
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy } from 'lodash';
|
||||||
import { Emojify } from './Emojify';
|
|
||||||
import type {
|
import type {
|
||||||
BodyRangesType,
|
HydratedBodyRangeMention,
|
||||||
HydratedBodyRangeType,
|
BodyRange,
|
||||||
HydratedBodyRangesType,
|
} from '../../types/BodyRange';
|
||||||
} from '../../types/Util';
|
import { AtMention } from './AtMention';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
bodyRanges?: HydratedBodyRangesType;
|
mentions?: ReadonlyArray<HydratedBodyRangeMention>;
|
||||||
direction?: 'incoming' | 'outgoing';
|
direction?: 'incoming' | 'outgoing';
|
||||||
showConversation?: (options: {
|
showConversation?: (options: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
@ -21,12 +20,12 @@ export type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AtMentionify({
|
export function AtMentionify({
|
||||||
bodyRanges,
|
mentions,
|
||||||
direction,
|
direction,
|
||||||
showConversation,
|
showConversation,
|
||||||
text,
|
text,
|
||||||
}: Props): JSX.Element {
|
}: Props): JSX.Element {
|
||||||
if (!bodyRanges) {
|
if (!mentions) {
|
||||||
return <>{text}</>;
|
return <>{text}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -35,8 +34,8 @@ export function AtMentionify({
|
||||||
let match = MENTIONS_REGEX.exec(text);
|
let match = MENTIONS_REGEX.exec(text);
|
||||||
let last = 0;
|
let last = 0;
|
||||||
|
|
||||||
const rangeStarts = new Map<number, HydratedBodyRangeType>();
|
const rangeStarts = new Map<number, HydratedBodyRangeMention>();
|
||||||
bodyRanges.forEach(range => {
|
mentions.forEach(range => {
|
||||||
rangeStarts.set(range.start, range);
|
rangeStarts.set(range.start, range);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -52,9 +51,10 @@ export function AtMentionify({
|
||||||
|
|
||||||
if (range) {
|
if (range) {
|
||||||
results.push(
|
results.push(
|
||||||
<span
|
<AtMention
|
||||||
className={`MessageBody__at-mention MessageBody__at-mention--${direction}`}
|
|
||||||
key={range.start}
|
key={range.start}
|
||||||
|
direction={direction}
|
||||||
|
isInvisible={false}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (showConversation) {
|
if (showConversation) {
|
||||||
showConversation({ conversationId: range.conversationID });
|
showConversation({ conversationId: range.conversationID });
|
||||||
|
@ -69,16 +69,9 @@ export function AtMentionify({
|
||||||
showConversation({ conversationId: range.conversationID });
|
showConversation({ conversationId: range.conversationID });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tabIndex={0}
|
id={range.conversationID}
|
||||||
role="link"
|
name={range.replacementText}
|
||||||
data-id={range.conversationID}
|
/>
|
||||||
data-title={range.replacementText}
|
|
||||||
>
|
|
||||||
<bdi>
|
|
||||||
@
|
|
||||||
<Emojify text={range.replacementText} />
|
|
||||||
</bdi>
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,16 +94,16 @@ export function AtMentionify({
|
||||||
// string, therefore we're unable to mark it up with DOM nodes prior to handing
|
// string, therefore we're unable to mark it up with DOM nodes prior to handing
|
||||||
// it off to them. This function will encode the "start" position into the text
|
// it off to them. This function will encode the "start" position into the text
|
||||||
// string so we can later pull it off when rendering the @mention.
|
// string so we can later pull it off when rendering the @mention.
|
||||||
AtMentionify.preprocessMentions = (
|
AtMentionify.preprocessMentions = <T extends BodyRange.Mention>(
|
||||||
text: string,
|
text: string,
|
||||||
bodyRanges?: BodyRangesType
|
mentions?: ReadonlyArray<BodyRange<T>>
|
||||||
): string => {
|
): string => {
|
||||||
if (!bodyRanges || !bodyRanges.length) {
|
if (!mentions || !mentions.length) {
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sorting by the start index to ensure that we always replace last -> first.
|
// Sorting by the start index to ensure that we always replace last -> first.
|
||||||
return sortBy(bodyRanges, 'start').reduceRight((str, range) => {
|
return sortBy(mentions, 'start').reduceRight((str, range) => {
|
||||||
const textBegin = str.substr(0, range.start);
|
const textBegin = str.substr(0, range.start);
|
||||||
const encodedMention = `\uFFFC@${range.start}`;
|
const encodedMention = `\uFFFC@${range.start}`;
|
||||||
const textEnd = str.substr(range.start + range.length, str.length);
|
const textEnd = str.substr(range.start + range.length, str.length);
|
||||||
|
|
|
@ -16,13 +16,15 @@ import { emojiToImage } from '../emoji/lib';
|
||||||
// ts/components/emoji/Emoji.tsx
|
// ts/components/emoji/Emoji.tsx
|
||||||
// ts/quill/emoji/blot.tsx
|
// ts/quill/emoji/blot.tsx
|
||||||
function getImageTag({
|
function getImageTag({
|
||||||
|
isInvisible,
|
||||||
|
key,
|
||||||
match,
|
match,
|
||||||
sizeClass,
|
sizeClass,
|
||||||
key,
|
|
||||||
}: {
|
}: {
|
||||||
|
isInvisible?: boolean;
|
||||||
|
key: string | number;
|
||||||
match: string;
|
match: string;
|
||||||
sizeClass?: SizeClassType;
|
sizeClass?: SizeClassType;
|
||||||
key: string | number;
|
|
||||||
}): JSX.Element | string {
|
}): JSX.Element | string {
|
||||||
const img = emojiToImage(match);
|
const img = emojiToImage(match);
|
||||||
|
|
||||||
|
@ -35,18 +37,24 @@ function getImageTag({
|
||||||
key={key}
|
key={key}
|
||||||
src={img}
|
src={img}
|
||||||
aria-label={match}
|
aria-label={match}
|
||||||
className={classNames('emoji', sizeClass)}
|
className={classNames(
|
||||||
|
'emoji',
|
||||||
|
sizeClass,
|
||||||
|
isInvisible ? 'emoji--invisible' : null
|
||||||
|
)}
|
||||||
alt={match}
|
alt={match}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
text: string;
|
/** When behind a spoiler, this emoji needs to be visibility: hidden */
|
||||||
|
isInvisible?: boolean;
|
||||||
/** A class name to be added to the generated emoji images */
|
/** A class name to be added to the generated emoji images */
|
||||||
sizeClass?: SizeClassType;
|
sizeClass?: SizeClassType;
|
||||||
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
|
/** Allows you to customize now non-newlines are rendered. Simplest is just a <span>. */
|
||||||
renderNonEmoji?: RenderTextCallbackType;
|
renderNonEmoji?: RenderTextCallbackType;
|
||||||
|
text: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text;
|
const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text;
|
||||||
|
@ -54,14 +62,15 @@ const defaultRenderNonEmoji: RenderTextCallbackType = ({ text }) => text;
|
||||||
export class Emojify extends React.Component<Props> {
|
export class Emojify extends React.Component<Props> {
|
||||||
public override render(): null | Array<JSX.Element | string | null> {
|
public override render(): null | Array<JSX.Element | string | null> {
|
||||||
const {
|
const {
|
||||||
text,
|
isInvisible,
|
||||||
sizeClass,
|
|
||||||
renderNonEmoji = defaultRenderNonEmoji,
|
renderNonEmoji = defaultRenderNonEmoji,
|
||||||
|
sizeClass,
|
||||||
|
text,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
return splitByEmoji(text).map(({ type, value: match }, index) => {
|
return splitByEmoji(text).map(({ type, value: match }, index) => {
|
||||||
if (type === 'emoji') {
|
if (type === 'emoji') {
|
||||||
return getImageTag({ match, sizeClass, key: index });
|
return getImageTag({ isInvisible, match, sizeClass, key: index });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'text') {
|
if (type === 'text') {
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { isLinkSneaky, shouldLinkifyMessage } from '../../types/LinkPreview';
|
||||||
import { splitByEmoji } from '../../util/emoji';
|
import { splitByEmoji } from '../../util/emoji';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
|
|
||||||
const linkify = LinkifyIt()
|
export const linkify = LinkifyIt()
|
||||||
// This is all TLDs in place in 2010, according to [IANA's root zone database][0]
|
// This is all TLDs in place in 2010, according to [IANA's root zone database][0]
|
||||||
// except for those domains marked as [a test domain][1].
|
// except for those domains marked as [a test domain][1].
|
||||||
//
|
//
|
||||||
|
@ -319,7 +319,7 @@ export type Props = {
|
||||||
renderNonLink?: RenderTextCallbackType;
|
renderNonLink?: RenderTextCallbackType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUPPORTED_PROTOCOLS = /^(http|https):/i;
|
export const SUPPORTED_PROTOCOLS = /^(http|https):/i;
|
||||||
|
|
||||||
const defaultRenderNonLink: RenderTextCallbackType = ({ text }) => text;
|
const defaultRenderNonLink: RenderTextCallbackType = ({ text }) => text;
|
||||||
|
|
||||||
|
|
|
@ -72,11 +72,8 @@ import { getIncrement } from '../../util/timer';
|
||||||
import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
|
import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary';
|
||||||
import { isFileDangerous } from '../../util/isFileDangerous';
|
import { isFileDangerous } from '../../util/isFileDangerous';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
import { missingCaseError } from '../../util/missingCaseError';
|
||||||
import type {
|
import type { HydratedBodyRangesType } from '../../types/BodyRange';
|
||||||
HydratedBodyRangesType,
|
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||||
LocalizerType,
|
|
||||||
ThemeType,
|
|
||||||
} from '../../types/Util';
|
|
||||||
|
|
||||||
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
|
||||||
import type {
|
import type {
|
||||||
|
@ -98,6 +95,7 @@ import { Emojify } from './Emojify';
|
||||||
import { getPaymentEventDescription } from '../../messages/helpers';
|
import { getPaymentEventDescription } from '../../messages/helpers';
|
||||||
import { PanelType } from '../../types/Panels';
|
import { PanelType } from '../../types/Panels';
|
||||||
import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser';
|
import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser';
|
||||||
|
import { RenderLocation } from './MessageTextRenderer';
|
||||||
|
|
||||||
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16;
|
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16;
|
||||||
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
|
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
|
||||||
|
@ -212,6 +210,7 @@ export type PropsData = {
|
||||||
isTargetedCounter?: number;
|
isTargetedCounter?: number;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isSelectMode: boolean;
|
isSelectMode: boolean;
|
||||||
|
isSpoilerExpanded?: boolean;
|
||||||
direction: DirectionType;
|
direction: DirectionType;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
status?: MessageStatusType;
|
status?: MessageStatusType;
|
||||||
|
@ -316,6 +315,7 @@ export type PropsActions = {
|
||||||
openGiftBadge: (messageId: string) => void;
|
openGiftBadge: (messageId: string) => void;
|
||||||
pushPanelForConversation: PushPanelForConversationActionType;
|
pushPanelForConversation: PushPanelForConversationActionType;
|
||||||
showContactModal: (contactId: string, conversationId?: string) => void;
|
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||||
|
showSpoiler: (messageId: string) => void;
|
||||||
|
|
||||||
kickOffAttachmentDownload: (options: {
|
kickOffAttachmentDownload: (options: {
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
|
@ -1735,9 +1735,11 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
displayLimit,
|
displayLimit,
|
||||||
i18n,
|
i18n,
|
||||||
id,
|
id,
|
||||||
|
isSpoilerExpanded,
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
messageExpanded,
|
messageExpanded,
|
||||||
showConversation,
|
showConversation,
|
||||||
|
showSpoiler,
|
||||||
status,
|
status,
|
||||||
text,
|
text,
|
||||||
textAttachment,
|
textAttachment,
|
||||||
|
@ -1783,6 +1785,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
displayLimit={displayLimit}
|
displayLimit={displayLimit}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
id={id}
|
id={id}
|
||||||
|
isSpoilerExpanded={isSpoilerExpanded || false}
|
||||||
kickOffBodyDownload={() => {
|
kickOffBodyDownload={() => {
|
||||||
if (!textAttachment) {
|
if (!textAttachment) {
|
||||||
return;
|
return;
|
||||||
|
@ -1794,6 +1797,8 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
}}
|
}}
|
||||||
messageExpanded={messageExpanded}
|
messageExpanded={messageExpanded}
|
||||||
showConversation={showConversation}
|
showConversation={showConversation}
|
||||||
|
renderLocation={RenderLocation.Timeline}
|
||||||
|
onExpandSpoiler={() => showSpoiler(id)}
|
||||||
text={contents || ''}
|
text={contents || ''}
|
||||||
textAttachment={textAttachment}
|
textAttachment={textAttachment}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -2,13 +2,14 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
import { boolean, text } from '@storybook/addon-knobs';
|
|
||||||
|
|
||||||
import type { Props } from './MessageBody';
|
import type { Props } from './MessageBody';
|
||||||
import { MessageBody } from './MessageBody';
|
import { MessageBody } from './MessageBody';
|
||||||
import { setupI18n } from '../../util/setupI18n';
|
import { setupI18n } from '../../util/setupI18n';
|
||||||
import enMessages from '../../../_locales/en/messages.json';
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
|
import { BodyRange } from '../../types/BodyRange';
|
||||||
|
import { RenderLocation } from './MessageTextRenderer';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
@ -18,16 +19,18 @@ export default {
|
||||||
|
|
||||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
bodyRanges: overrideProps.bodyRanges,
|
bodyRanges: overrideProps.bodyRanges,
|
||||||
disableJumbomoji: boolean(
|
disableJumbomoji: overrideProps.disableJumbomoji || false,
|
||||||
'disableJumbomoji',
|
disableLinks: overrideProps.disableLinks || false,
|
||||||
overrideProps.disableJumbomoji || false
|
|
||||||
),
|
|
||||||
disableLinks: boolean('disableLinks', overrideProps.disableLinks || false),
|
|
||||||
direction: 'incoming',
|
direction: 'incoming',
|
||||||
i18n,
|
i18n,
|
||||||
text: text('text', overrideProps.text || ''),
|
isSpoilerExpanded: overrideProps.isSpoilerExpanded || false,
|
||||||
|
onExpandSpoiler: overrideProps.onExpandSpoiler || action('onExpandSpoiler'),
|
||||||
|
renderLocation: RenderLocation.Timeline,
|
||||||
|
showConversation:
|
||||||
|
overrideProps.showConversation || action('showConversation'),
|
||||||
|
text: overrideProps.text || '',
|
||||||
textAttachment: overrideProps.textAttachment || {
|
textAttachment: overrideProps.textAttachment || {
|
||||||
pending: boolean('textPending', false),
|
pending: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -156,7 +159,13 @@ export function MultipleMentions(): JSX.Element {
|
||||||
text: '\uFFFC \uFFFC \uFFFC',
|
text: '\uFFFC \uFFFC \uFFFC',
|
||||||
});
|
});
|
||||||
|
|
||||||
return <MessageBody {...props} />;
|
return (
|
||||||
|
<>
|
||||||
|
<MessageBody {...props} />
|
||||||
|
<hr />
|
||||||
|
<MessageBody {...props} disableLinks />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
MultipleMentions.story = {
|
MultipleMentions.story = {
|
||||||
|
@ -193,9 +202,304 @@ export function ComplexMessageBody(): JSX.Element {
|
||||||
text: 'Hey \uFFFC\nCheck out https://www.signal.org I think you will really like it 😍\n\ncc \uFFFC \uFFFC',
|
text: 'Hey \uFFFC\nCheck out https://www.signal.org I think you will really like it 😍\n\ncc \uFFFC \uFFFC',
|
||||||
});
|
});
|
||||||
|
|
||||||
return <MessageBody {...props} />;
|
return (
|
||||||
|
<>
|
||||||
|
<MessageBody {...props} />
|
||||||
|
<hr />
|
||||||
|
<MessageBody {...props} disableLinks />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ComplexMessageBody.story = {
|
ComplexMessageBody.story = {
|
||||||
name: 'Complex MessageBody',
|
name: 'Complex MessageBody',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function FormattingBasic(): JSX.Element {
|
||||||
|
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
|
||||||
|
|
||||||
|
const props = createProps({
|
||||||
|
bodyRanges: [
|
||||||
|
// Abracadabra
|
||||||
|
{
|
||||||
|
start: 36,
|
||||||
|
length: 11,
|
||||||
|
style: BodyRange.Style.BOLD,
|
||||||
|
},
|
||||||
|
// Open Sesame
|
||||||
|
{
|
||||||
|
start: 46,
|
||||||
|
length: 10,
|
||||||
|
style: BodyRange.Style.ITALIC,
|
||||||
|
},
|
||||||
|
// This is the key! And the treasure, too, if we can only get our hands on it!
|
||||||
|
{
|
||||||
|
start: 357,
|
||||||
|
length: 75,
|
||||||
|
style: BodyRange.Style.MONOSPACE,
|
||||||
|
},
|
||||||
|
|
||||||
|
// The real magic is to understand which words work, and when, and for what
|
||||||
|
{
|
||||||
|
start: 138,
|
||||||
|
length: 73,
|
||||||
|
style: BodyRange.Style.STRIKETHROUGH,
|
||||||
|
},
|
||||||
|
// as if the key to the treasure is the treasure!
|
||||||
|
{
|
||||||
|
start: 446,
|
||||||
|
length: 46,
|
||||||
|
style: BodyRange.Style.SPOILER,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: 110,
|
||||||
|
length: 27,
|
||||||
|
style: BodyRange.Style.NONE,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isSpoilerExpanded,
|
||||||
|
onExpandSpoiler: () => setIsSpoilerExpanded(true),
|
||||||
|
text: '… 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 type { AttachmentType } from '../../types/Attachment';
|
||||||
import { canBeDownloaded } from '../../types/Attachment';
|
import { canBeDownloaded } from '../../types/Attachment';
|
||||||
import type { SizeClassType } from '../emoji/lib';
|
|
||||||
import { getSizeClass } from '../emoji/lib';
|
import { getSizeClass } from '../emoji/lib';
|
||||||
import { AtMentionify } from './AtMentionify';
|
|
||||||
import { Emojify } from './Emojify';
|
import { Emojify } from './Emojify';
|
||||||
import { AddNewLines } from './AddNewLines';
|
|
||||||
import { Linkify } from './Linkify';
|
|
||||||
|
|
||||||
import type { ShowConversationType } from '../../state/ducks/conversations';
|
import type { ShowConversationType } from '../../state/ducks/conversations';
|
||||||
import type {
|
import type { HydratedBodyRangesType } from '../../types/BodyRange';
|
||||||
HydratedBodyRangesType,
|
import type { LocalizerType } from '../../types/Util';
|
||||||
LocalizerType,
|
import { MessageTextRenderer } from './MessageTextRenderer';
|
||||||
RenderTextCallbackType,
|
import type { RenderLocation } from './MessageTextRenderer';
|
||||||
} from '../../types/Util';
|
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
author?: string;
|
author?: string;
|
||||||
bodyRanges?: HydratedBodyRangesType;
|
bodyRanges?: HydratedBodyRangesType;
|
||||||
direction?: 'incoming' | 'outgoing';
|
direction?: 'incoming' | 'outgoing';
|
||||||
/** If set, all emoji will be the same size. Otherwise, just one emoji will be large. */
|
// If set, all emoji will be the same size. Otherwise, just one emoji will be large.
|
||||||
disableJumbomoji?: boolean;
|
disableJumbomoji?: boolean;
|
||||||
/** If set, links will be left alone instead of turned into clickable `<a>` tags. */
|
// If set, interactive elements will be left as plain text: links, mentions, spoilers
|
||||||
disableLinks?: boolean;
|
disableLinks?: boolean;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
isSpoilerExpanded: boolean;
|
||||||
kickOffBodyDownload?: () => void;
|
kickOffBodyDownload?: () => void;
|
||||||
|
onExpandSpoiler?: () => unknown;
|
||||||
onIncreaseTextLength?: () => unknown;
|
onIncreaseTextLength?: () => unknown;
|
||||||
|
prefix?: string;
|
||||||
|
renderLocation: RenderLocation;
|
||||||
showConversation?: ShowConversationType;
|
showConversation?: ShowConversationType;
|
||||||
text: string;
|
text: string;
|
||||||
textAttachment?: Pick<AttachmentType, 'pending' | 'digest' | 'key'>;
|
textAttachment?: Pick<AttachmentType, 'pending' | 'digest' | 'key'>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderEmoji = ({
|
|
||||||
text,
|
|
||||||
key,
|
|
||||||
sizeClass,
|
|
||||||
renderNonEmoji,
|
|
||||||
}: {
|
|
||||||
i18n: LocalizerType;
|
|
||||||
text: string;
|
|
||||||
key: number;
|
|
||||||
sizeClass?: SizeClassType;
|
|
||||||
renderNonEmoji: RenderTextCallbackType;
|
|
||||||
}) => (
|
|
||||||
<Emojify
|
|
||||||
key={key}
|
|
||||||
text={text}
|
|
||||||
sizeClass={sizeClass}
|
|
||||||
renderNonEmoji={renderNonEmoji}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component makes it very easy to use all three of our message formatting
|
* This component makes it very easy to use all three of our message formatting
|
||||||
* components: `Emojify`, `Linkify`, and `AddNewLines`. Because each of them is fully
|
* components: `Emojify`, `Linkify`, and `AddNewLines`. Because each of them is fully
|
||||||
|
@ -69,8 +48,12 @@ export function MessageBody({
|
||||||
disableJumbomoji,
|
disableJumbomoji,
|
||||||
disableLinks,
|
disableLinks,
|
||||||
i18n,
|
i18n,
|
||||||
|
isSpoilerExpanded,
|
||||||
kickOffBodyDownload,
|
kickOffBodyDownload,
|
||||||
|
onExpandSpoiler,
|
||||||
onIncreaseTextLength,
|
onIncreaseTextLength,
|
||||||
|
prefix,
|
||||||
|
renderLocation,
|
||||||
showConversation,
|
showConversation,
|
||||||
text,
|
text,
|
||||||
textAttachment,
|
textAttachment,
|
||||||
|
@ -80,31 +63,6 @@ export function MessageBody({
|
||||||
textAttachment?.pending || hasReadMore ? `${text}...` : text;
|
textAttachment?.pending || hasReadMore ? `${text}...` : text;
|
||||||
|
|
||||||
const sizeClass = disableJumbomoji ? undefined : getSizeClass(text);
|
const sizeClass = disableJumbomoji ? undefined : getSizeClass(text);
|
||||||
const processedText = AtMentionify.preprocessMentions(
|
|
||||||
textWithSuffix,
|
|
||||||
bodyRanges
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderNewLines: RenderTextCallbackType = ({
|
|
||||||
text: textWithNewLines,
|
|
||||||
key,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<AddNewLines
|
|
||||||
key={key}
|
|
||||||
text={textWithNewLines}
|
|
||||||
renderNonNewLine={({ text: innerText, key: innerKey }) => (
|
|
||||||
<AtMentionify
|
|
||||||
key={innerKey}
|
|
||||||
bodyRanges={bodyRanges}
|
|
||||||
direction={direction}
|
|
||||||
showConversation={showConversation}
|
|
||||||
text={innerText}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
let pendingContent: React.ReactNode;
|
let pendingContent: React.ReactNode;
|
||||||
if (hasReadMore) {
|
if (hasReadMore) {
|
||||||
|
@ -145,39 +103,35 @@ export function MessageBody({
|
||||||
{author && (
|
{author && (
|
||||||
<>
|
<>
|
||||||
<span className="MessageBody__author">
|
<span className="MessageBody__author">
|
||||||
{renderEmoji({
|
<Emojify text={author} />
|
||||||
i18n,
|
|
||||||
text: author,
|
|
||||||
sizeClass,
|
|
||||||
key: 0,
|
|
||||||
renderNonEmoji: renderNewLines,
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
:{' '}
|
:{' '}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{disableLinks ? (
|
{prefix && (
|
||||||
renderEmoji({
|
<>
|
||||||
i18n,
|
<span className="MessageBody__prefix">
|
||||||
text: processedText,
|
<Emojify text={prefix} />
|
||||||
sizeClass,
|
</span>{' '}
|
||||||
key: 0,
|
</>
|
||||||
renderNonEmoji: renderNewLines,
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<Linkify
|
|
||||||
text={processedText}
|
|
||||||
renderNonLink={({ key, text: nonLinkText }) => {
|
|
||||||
return renderEmoji({
|
|
||||||
i18n,
|
|
||||||
text: nonLinkText,
|
|
||||||
sizeClass,
|
|
||||||
key,
|
|
||||||
renderNonEmoji: renderNewLines,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<MessageTextRenderer
|
||||||
|
bodyRanges={bodyRanges ?? []}
|
||||||
|
direction={direction}
|
||||||
|
disableLinks={disableLinks ?? false}
|
||||||
|
emojiSizeClass={sizeClass}
|
||||||
|
i18n={i18n}
|
||||||
|
isSpoilerExpanded={isSpoilerExpanded}
|
||||||
|
messageText={textWithSuffix}
|
||||||
|
onMentionTrigger={conversationId =>
|
||||||
|
showConversation?.({ conversationId })
|
||||||
|
}
|
||||||
|
onExpandSpoiler={onExpandSpoiler}
|
||||||
|
renderLocation={renderLocation}
|
||||||
|
textLength={text.length}
|
||||||
|
/>
|
||||||
|
|
||||||
{pendingContent}
|
{pendingContent}
|
||||||
{onIncreaseTextLength ? (
|
{onIncreaseTextLength ? (
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -4,12 +4,14 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
import { text } from '@storybook/addon-knobs';
|
|
||||||
|
|
||||||
import type { Props } from './MessageBodyReadMore';
|
import type { Props } from './MessageBodyReadMore';
|
||||||
import { MessageBodyReadMore } from './MessageBodyReadMore';
|
import { MessageBodyReadMore } from './MessageBodyReadMore';
|
||||||
import { setupI18n } from '../../util/setupI18n';
|
import { setupI18n } from '../../util/setupI18n';
|
||||||
import enMessages from '../../../_locales/en/messages.json';
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
|
import type { HydratedBodyRangesType } from '../../types/BodyRange';
|
||||||
|
import { BodyRange } from '../../types/BodyRange';
|
||||||
|
import { RenderLocation } from './MessageTextRenderer';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
@ -23,20 +25,34 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
displayLimit: overrideProps.displayLimit,
|
displayLimit: overrideProps.displayLimit,
|
||||||
i18n,
|
i18n,
|
||||||
id: 'some-id',
|
id: 'some-id',
|
||||||
|
isSpoilerExpanded: overrideProps.isSpoilerExpanded === true,
|
||||||
messageExpanded: action('messageExpanded'),
|
messageExpanded: action('messageExpanded'),
|
||||||
text: text('text', overrideProps.text || ''),
|
onExpandSpoiler: overrideProps.onExpandSpoiler || action('onExpandSpoiler'),
|
||||||
|
renderLocation: RenderLocation.Timeline,
|
||||||
|
text: overrideProps.text || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
function MessageBodyReadMoreTest({
|
function MessageBodyReadMoreTest({
|
||||||
|
bodyRanges,
|
||||||
|
isSpoilerExpanded,
|
||||||
|
onExpandSpoiler,
|
||||||
text: messageBodyText,
|
text: messageBodyText,
|
||||||
}: {
|
}: {
|
||||||
|
bodyRanges?: HydratedBodyRangesType;
|
||||||
|
isSpoilerExpanded?: boolean;
|
||||||
|
onExpandSpoiler?: () => void;
|
||||||
text: string;
|
text: string;
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const [displayLimit, setDisplayLimit] = useState<number | undefined>();
|
const [displayLimit, setDisplayLimit] = useState<number | undefined>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBodyReadMore
|
<MessageBodyReadMore
|
||||||
{...createProps({ text: messageBodyText })}
|
{...createProps({
|
||||||
|
bodyRanges,
|
||||||
|
isSpoilerExpanded,
|
||||||
|
onExpandSpoiler,
|
||||||
|
text: messageBodyText,
|
||||||
|
})}
|
||||||
displayLimit={displayLimit}
|
displayLimit={displayLimit}
|
||||||
messageExpanded={(_, newDisplayLimit) => setDisplayLimit(newDisplayLimit)}
|
messageExpanded={(_, newDisplayLimit) => setDisplayLimit(newDisplayLimit)}
|
||||||
/>
|
/>
|
||||||
|
@ -69,6 +85,74 @@ export function LeafyNotBuffered(): JSX.Element {
|
||||||
return <MessageBodyReadMoreTest text={`x${'🌿'.repeat(450)}`} />;
|
return <MessageBodyReadMoreTest text={`x${'🌿'.repeat(450)}`} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LongTextWithMention(): JSX.Element {
|
||||||
|
const bodyRanges = [
|
||||||
|
// This is right at boundary for better testing
|
||||||
|
{
|
||||||
|
start: 800,
|
||||||
|
length: 1,
|
||||||
|
mentionUuid: 'abc',
|
||||||
|
conversationID: 'x',
|
||||||
|
replacementText: 'Alice',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const text = `${'x '.repeat(400)}\uFFFC woo!${'y '.repeat(100)}`;
|
||||||
|
|
||||||
|
return <MessageBodyReadMoreTest bodyRanges={bodyRanges} text={text} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LongTextWithFormatting(): JSX.Element {
|
||||||
|
const bodyRanges = [
|
||||||
|
{
|
||||||
|
start: 0,
|
||||||
|
length: 5,
|
||||||
|
style: BodyRange.Style.ITALIC,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: 7,
|
||||||
|
length: 3,
|
||||||
|
style: BodyRange.Style.BOLD,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: 1019,
|
||||||
|
length: 4,
|
||||||
|
style: BodyRange.Style.BOLD,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: 1024,
|
||||||
|
length: 6,
|
||||||
|
style: BodyRange.Style.ITALIC,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const text = `ready? set... g${'o'.repeat(1000)}al! bold italic`;
|
||||||
|
|
||||||
|
return <MessageBodyReadMoreTest bodyRanges={bodyRanges} text={text} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LongTextMostlySpoiler(): JSX.Element {
|
||||||
|
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
|
||||||
|
const bodyRanges = [
|
||||||
|
{
|
||||||
|
start: 7,
|
||||||
|
length: 1010,
|
||||||
|
style: BodyRange.Style.SPOILER,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const text = `ready? set... g${'o'.repeat(1000)}al! bold italic`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageBodyReadMoreTest
|
||||||
|
bodyRanges={bodyRanges}
|
||||||
|
text={text}
|
||||||
|
isSpoilerExpanded={isSpoilerExpanded}
|
||||||
|
onExpandSpoiler={() => setIsSpoilerExpanded(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
LeafyNotBuffered.story = {
|
LeafyNotBuffered.story = {
|
||||||
name: 'Leafy not buffered',
|
name: 'Leafy not buffered',
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,7 +13,10 @@ export type Props = Pick<
|
||||||
| 'direction'
|
| 'direction'
|
||||||
| 'disableLinks'
|
| 'disableLinks'
|
||||||
| 'i18n'
|
| 'i18n'
|
||||||
|
| 'isSpoilerExpanded'
|
||||||
|
| 'onExpandSpoiler'
|
||||||
| 'kickOffBodyDownload'
|
| 'kickOffBodyDownload'
|
||||||
|
| 'renderLocation'
|
||||||
| 'showConversation'
|
| 'showConversation'
|
||||||
| 'text'
|
| 'text'
|
||||||
| 'textAttachment'
|
| 'textAttachment'
|
||||||
|
@ -38,8 +41,11 @@ export function MessageBodyReadMore({
|
||||||
displayLimit,
|
displayLimit,
|
||||||
i18n,
|
i18n,
|
||||||
id,
|
id,
|
||||||
|
isSpoilerExpanded,
|
||||||
kickOffBodyDownload,
|
kickOffBodyDownload,
|
||||||
messageExpanded,
|
messageExpanded,
|
||||||
|
onExpandSpoiler,
|
||||||
|
renderLocation,
|
||||||
showConversation,
|
showConversation,
|
||||||
text,
|
text,
|
||||||
textAttachment,
|
textAttachment,
|
||||||
|
@ -64,8 +70,11 @@ export function MessageBodyReadMore({
|
||||||
direction={direction}
|
direction={direction}
|
||||||
disableLinks={disableLinks}
|
disableLinks={disableLinks}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
isSpoilerExpanded={isSpoilerExpanded}
|
||||||
kickOffBodyDownload={kickOffBodyDownload}
|
kickOffBodyDownload={kickOffBodyDownload}
|
||||||
|
onExpandSpoiler={onExpandSpoiler}
|
||||||
onIncreaseTextLength={onIncreaseTextLength}
|
onIncreaseTextLength={onIncreaseTextLength}
|
||||||
|
renderLocation={renderLocation}
|
||||||
showConversation={showConversation}
|
showConversation={showConversation}
|
||||||
text={slicedText}
|
text={slicedText}
|
||||||
textAttachment={textAttachment}
|
textAttachment={textAttachment}
|
||||||
|
|
|
@ -42,6 +42,7 @@ const defaultMessage: MessageDataPropsType = {
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
isSelectMode: false,
|
isSelectMode: false,
|
||||||
|
isSpoilerExpanded: false,
|
||||||
previews: [],
|
previews: [],
|
||||||
readStatus: ReadStatus.Read,
|
readStatus: ReadStatus.Read,
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
|
@ -80,10 +81,12 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
|
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
|
||||||
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
||||||
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
||||||
|
messageExpanded: action('messageExpanded'),
|
||||||
showConversation: action('showConversation'),
|
showConversation: action('showConversation'),
|
||||||
openGiftBadge: action('openGiftBadge'),
|
openGiftBadge: action('openGiftBadge'),
|
||||||
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
|
renderAudioAttachment: () => <div>*AudioAttachment*</div>,
|
||||||
saveAttachment: action('saveAttachment'),
|
saveAttachment: action('saveAttachment'),
|
||||||
|
showSpoiler: action('showSpoiler'),
|
||||||
pushPanelForConversation: action('pushPanelForConversation'),
|
pushPanelForConversation: action('pushPanelForConversation'),
|
||||||
showContactModal: action('showContactModal'),
|
showContactModal: action('showContactModal'),
|
||||||
showExpiredIncomingTapToViewToast: action(
|
showExpiredIncomingTapToViewToast: action(
|
||||||
|
|
|
@ -81,6 +81,7 @@ export type PropsReduxActions = Pick<
|
||||||
| 'doubleCheckMissingQuoteReference'
|
| 'doubleCheckMissingQuoteReference'
|
||||||
| 'kickOffAttachmentDownload'
|
| 'kickOffAttachmentDownload'
|
||||||
| 'markAttachmentAsCorrupted'
|
| 'markAttachmentAsCorrupted'
|
||||||
|
| 'messageExpanded'
|
||||||
| 'openGiftBadge'
|
| 'openGiftBadge'
|
||||||
| 'pushPanelForConversation'
|
| 'pushPanelForConversation'
|
||||||
| 'saveAttachment'
|
| 'saveAttachment'
|
||||||
|
@ -90,6 +91,7 @@ export type PropsReduxActions = Pick<
|
||||||
| 'showExpiredOutgoingTapToViewToast'
|
| 'showExpiredOutgoingTapToViewToast'
|
||||||
| 'showLightbox'
|
| 'showLightbox'
|
||||||
| 'showLightboxForViewOnceMedia'
|
| 'showLightboxForViewOnceMedia'
|
||||||
|
| 'showSpoiler'
|
||||||
| 'startConversation'
|
| 'startConversation'
|
||||||
| 'viewStory'
|
| 'viewStory'
|
||||||
> & {
|
> & {
|
||||||
|
@ -296,13 +298,13 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
checkForAccount,
|
checkForAccount,
|
||||||
clearTargetedMessage,
|
clearTargetedMessage,
|
||||||
contactNameColor,
|
contactNameColor,
|
||||||
showLightboxForViewOnceMedia,
|
|
||||||
doubleCheckMissingQuoteReference,
|
doubleCheckMissingQuoteReference,
|
||||||
getPreferredBadge,
|
getPreferredBadge,
|
||||||
i18n,
|
i18n,
|
||||||
interactionMode,
|
interactionMode,
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
markAttachmentAsCorrupted,
|
markAttachmentAsCorrupted,
|
||||||
|
messageExpanded,
|
||||||
openGiftBadge,
|
openGiftBadge,
|
||||||
platform,
|
platform,
|
||||||
pushPanelForConversation,
|
pushPanelForConversation,
|
||||||
|
@ -313,6 +315,8 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
showExpiredIncomingTapToViewToast,
|
showExpiredIncomingTapToViewToast,
|
||||||
showExpiredOutgoingTapToViewToast,
|
showExpiredOutgoingTapToViewToast,
|
||||||
showLightbox,
|
showLightbox,
|
||||||
|
showLightboxForViewOnceMedia,
|
||||||
|
showSpoiler,
|
||||||
startConversation,
|
startConversation,
|
||||||
theme,
|
theme,
|
||||||
viewStory,
|
viewStory,
|
||||||
|
@ -347,16 +351,17 @@ export class MessageDetail extends React.Component<Props> {
|
||||||
interactionMode={interactionMode}
|
interactionMode={interactionMode}
|
||||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||||
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
|
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
|
||||||
messageExpanded={noop}
|
messageExpanded={messageExpanded}
|
||||||
platform={platform}
|
|
||||||
showConversation={showConversation}
|
|
||||||
openGiftBadge={openGiftBadge}
|
openGiftBadge={openGiftBadge}
|
||||||
|
platform={platform}
|
||||||
pushPanelForConversation={pushPanelForConversation}
|
pushPanelForConversation={pushPanelForConversation}
|
||||||
renderAudioAttachment={renderAudioAttachment}
|
renderAudioAttachment={renderAudioAttachment}
|
||||||
saveAttachment={saveAttachment}
|
saveAttachment={saveAttachment}
|
||||||
shouldCollapseAbove={false}
|
shouldCollapseAbove={false}
|
||||||
shouldCollapseBelow={false}
|
shouldCollapseBelow={false}
|
||||||
shouldHideMetadata={false}
|
shouldHideMetadata={false}
|
||||||
|
showConversation={showConversation}
|
||||||
|
showSpoiler={showSpoiler}
|
||||||
scrollToQuotedMessage={() => {
|
scrollToQuotedMessage={() => {
|
||||||
log.warn('MessageDetail: scrollToQuotedMessage called!');
|
log.warn('MessageDetail: scrollToQuotedMessage called!');
|
||||||
}}
|
}}
|
||||||
|
|
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,
|
isMessageRequestAccepted: true,
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
isSelectMode: false,
|
isSelectMode: false,
|
||||||
|
isSpoilerExpanded: false,
|
||||||
toggleSelectMessage: action('toggleSelectMessage'),
|
toggleSelectMessage: action('toggleSelectMessage'),
|
||||||
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
|
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
|
||||||
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
|
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
|
||||||
|
@ -135,6 +136,7 @@ const defaultMessageProps: TimelineMessagesProps = {
|
||||||
shouldCollapseAbove: false,
|
shouldCollapseAbove: false,
|
||||||
shouldCollapseBelow: false,
|
shouldCollapseBelow: false,
|
||||||
shouldHideMetadata: false,
|
shouldHideMetadata: false,
|
||||||
|
showSpoiler: action('showSpoiler'),
|
||||||
pushPanelForConversation: action('default--pushPanelForConversation'),
|
pushPanelForConversation: action('default--pushPanelForConversation'),
|
||||||
showContactModal: action('default--showContactModal'),
|
showContactModal: action('default--showContactModal'),
|
||||||
showExpiredIncomingTapToViewToast: action(
|
showExpiredIncomingTapToViewToast: action(
|
||||||
|
|
|
@ -11,7 +11,8 @@ import * as GoogleChrome from '../../util/GoogleChrome';
|
||||||
|
|
||||||
import { MessageBody } from './MessageBody';
|
import { MessageBody } from './MessageBody';
|
||||||
import type { AttachmentType, ThumbnailType } from '../../types/Attachment';
|
import type { AttachmentType, ThumbnailType } from '../../types/Attachment';
|
||||||
import type { HydratedBodyRangesType, LocalizerType } from '../../types/Util';
|
import type { HydratedBodyRangesType } from '../../types/BodyRange';
|
||||||
|
import type { LocalizerType } from '../../types/Util';
|
||||||
import type {
|
import type {
|
||||||
ConversationColorType,
|
ConversationColorType,
|
||||||
CustomColorType,
|
CustomColorType,
|
||||||
|
@ -19,12 +20,12 @@ import type {
|
||||||
import { ContactName } from './ContactName';
|
import { ContactName } from './ContactName';
|
||||||
import { Emojify } from './Emojify';
|
import { Emojify } from './Emojify';
|
||||||
import { TextAttachment } from '../TextAttachment';
|
import { TextAttachment } from '../TextAttachment';
|
||||||
import { getTextWithMentions } from '../../util/getTextWithMentions';
|
|
||||||
import { getClassNamesFor } from '../../util/getClassNamesFor';
|
import { getClassNamesFor } from '../../util/getClassNamesFor';
|
||||||
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
|
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
|
||||||
import type { AnyPaymentEvent } from '../../types/Payment';
|
import type { AnyPaymentEvent } from '../../types/Payment';
|
||||||
import { PaymentEventKind } from '../../types/Payment';
|
import { PaymentEventKind } from '../../types/Payment';
|
||||||
import { getPaymentEventNotificationText } from '../../messages/helpers';
|
import { getPaymentEventNotificationText } from '../../messages/helpers';
|
||||||
|
import { RenderLocation } from './MessageTextRenderer';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
authorTitle: string;
|
authorTitle: string;
|
||||||
|
@ -366,10 +367,6 @@ export class Quote extends React.Component<Props, State> {
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
if (text && !isGiftBadge) {
|
if (text && !isGiftBadge) {
|
||||||
const quoteText = bodyRanges
|
|
||||||
? getTextWithMentions(bodyRanges, text)
|
|
||||||
: text;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
dir="auto"
|
dir="auto"
|
||||||
|
@ -379,10 +376,13 @@ export class Quote extends React.Component<Props, State> {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageBody
|
<MessageBody
|
||||||
|
bodyRanges={bodyRanges}
|
||||||
disableLinks
|
disableLinks
|
||||||
disableJumbomoji
|
disableJumbomoji
|
||||||
text={quoteText}
|
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
isSpoilerExpanded={false}
|
||||||
|
renderLocation={RenderLocation.Quote}
|
||||||
|
text={text}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -63,6 +63,7 @@ function mockMessageTimelineItem(
|
||||||
isMessageRequestAccepted: true,
|
isMessageRequestAccepted: true,
|
||||||
isSelected: false,
|
isSelected: false,
|
||||||
isSelectMode: false,
|
isSelectMode: false,
|
||||||
|
isSpoilerExpanded: false,
|
||||||
previews: [],
|
previews: [],
|
||||||
readStatus: ReadStatus.Read,
|
readStatus: ReadStatus.Read,
|
||||||
canRetryDeleteForEveryone: true,
|
canRetryDeleteForEveryone: true,
|
||||||
|
@ -291,6 +292,7 @@ const actions = () => ({
|
||||||
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
|
||||||
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
|
||||||
messageExpanded: action('messageExpanded'),
|
messageExpanded: action('messageExpanded'),
|
||||||
|
showSpoiler: action('showSpoiler'),
|
||||||
showLightbox: action('showLightbox'),
|
showLightbox: action('showLightbox'),
|
||||||
showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'),
|
showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'),
|
||||||
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
|
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
|
||||||
|
|
|
@ -92,7 +92,7 @@ const getDefaultProps = () => ({
|
||||||
'showExpiredIncomingTapToViewToast'
|
'showExpiredIncomingTapToViewToast'
|
||||||
),
|
),
|
||||||
scrollToQuotedMessage: action('scrollToQuotedMessage'),
|
scrollToQuotedMessage: action('scrollToQuotedMessage'),
|
||||||
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
|
showSpoiler: action('showSpoiler'),
|
||||||
startCallingLobby: action('startCallingLobby'),
|
startCallingLobby: action('startCallingLobby'),
|
||||||
startConversation: action('startConversation'),
|
startConversation: action('startConversation'),
|
||||||
returnToActiveCall: action('returnToActiveCall'),
|
returnToActiveCall: action('returnToActiveCall'),
|
||||||
|
@ -100,6 +100,7 @@ const getDefaultProps = () => ({
|
||||||
shouldCollapseBelow: false,
|
shouldCollapseBelow: false,
|
||||||
shouldHideMetadata: false,
|
shouldHideMetadata: false,
|
||||||
shouldRenderDateHeader: false,
|
shouldRenderDateHeader: false,
|
||||||
|
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
|
||||||
|
|
||||||
now: Date.now(),
|
now: Date.now(),
|
||||||
|
|
||||||
|
|
|
@ -300,6 +300,9 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
isSelectMode: isBoolean(overrideProps.isSelectMode)
|
isSelectMode: isBoolean(overrideProps.isSelectMode)
|
||||||
? overrideProps.isSelectMode
|
? overrideProps.isSelectMode
|
||||||
: false,
|
: false,
|
||||||
|
isSpoilerExpanded: isBoolean(overrideProps.isSpoilerExpanded)
|
||||||
|
? overrideProps.isSpoilerExpanded
|
||||||
|
: false,
|
||||||
isTapToView: overrideProps.isTapToView,
|
isTapToView: overrideProps.isTapToView,
|
||||||
isTapToViewError: overrideProps.isTapToViewError,
|
isTapToViewError: overrideProps.isTapToViewError,
|
||||||
isTapToViewExpired: overrideProps.isTapToViewExpired,
|
isTapToViewExpired: overrideProps.isTapToViewExpired,
|
||||||
|
@ -338,6 +341,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||||
shouldHideMetadata: isBoolean(overrideProps.shouldHideMetadata)
|
shouldHideMetadata: isBoolean(overrideProps.shouldHideMetadata)
|
||||||
? overrideProps.shouldHideMetadata
|
? overrideProps.shouldHideMetadata
|
||||||
: false,
|
: false,
|
||||||
|
showSpoiler: action('showSpoiler'),
|
||||||
pushPanelForConversation: action('pushPanelForConversation'),
|
pushPanelForConversation: action('pushPanelForConversation'),
|
||||||
showContactModal: action('showContactModal'),
|
showContactModal: action('showContactModal'),
|
||||||
showExpiredIncomingTapToViewToast: action(
|
showExpiredIncomingTapToViewToast: action(
|
||||||
|
|
|
@ -19,6 +19,7 @@ import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||||
import type { ConversationType } from '../../state/ducks/conversations';
|
import type { ConversationType } from '../../state/ducks/conversations';
|
||||||
import type { BadgeType } from '../../badges/types';
|
import type { BadgeType } from '../../badges/types';
|
||||||
import { isSignalConversation } from '../../util/isSignalConversation';
|
import { isSignalConversation } from '../../util/isSignalConversation';
|
||||||
|
import { RenderLocation } from '../conversation/MessageTextRenderer';
|
||||||
|
|
||||||
const MESSAGE_STATUS_ICON_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__status-icon`;
|
const MESSAGE_STATUS_ICON_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__status-icon`;
|
||||||
|
|
||||||
|
@ -142,10 +143,14 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
||||||
{i18n('icu:ConversationListItem--draft-prefix')}
|
{i18n('icu:ConversationListItem--draft-prefix')}
|
||||||
</span>
|
</span>
|
||||||
<MessageBody
|
<MessageBody
|
||||||
text={truncateMessageText(draftPreview)}
|
bodyRanges={draftPreview.bodyRanges}
|
||||||
disableJumbomoji
|
disableJumbomoji
|
||||||
disableLinks
|
disableLinks
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
isSpoilerExpanded={false}
|
||||||
|
prefix={draftPreview.prefix}
|
||||||
|
renderLocation={RenderLocation.ConversationList}
|
||||||
|
text={draftPreview.text}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -158,11 +163,15 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
||||||
} else if (lastMessage) {
|
} else if (lastMessage) {
|
||||||
messageText = (
|
messageText = (
|
||||||
<MessageBody
|
<MessageBody
|
||||||
text={truncateMessageText(lastMessage.text)}
|
|
||||||
author={type === 'group' ? lastMessage.author : undefined}
|
author={type === 'group' ? lastMessage.author : undefined}
|
||||||
|
bodyRanges={lastMessage.bodyRanges}
|
||||||
disableJumbomoji
|
disableJumbomoji
|
||||||
disableLinks
|
disableLinks
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
isSpoilerExpanded={false}
|
||||||
|
prefix={lastMessage.prefix}
|
||||||
|
renderLocation={RenderLocation.ConversationList}
|
||||||
|
text={lastMessage.text}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
if (lastMessage.status) {
|
if (lastMessage.status) {
|
||||||
|
@ -210,13 +219,3 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// This takes `unknown` because, sometimes, values from the database don't match our
|
|
||||||
// types. In the long term, we should fix that. In the short term, this smooths over the
|
|
||||||
// problem.
|
|
||||||
function truncateMessageText(text: unknown): string {
|
|
||||||
if (typeof text !== 'string') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return text.replace(/(?:\r?\n)+/g, ' ');
|
|
||||||
}
|
|
||||||
|
|
|
@ -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 * as React from 'react';
|
||||||
import { action } from '@storybook/addon-actions';
|
import { action } from '@storybook/addon-actions';
|
||||||
import { boolean, text } from '@storybook/addon-knobs';
|
|
||||||
|
|
||||||
import { setupI18n } from '../../util/setupI18n';
|
import { setupI18n } from '../../util/setupI18n';
|
||||||
import enMessages from '../../../_locales/en/messages.json';
|
import enMessages from '../../../_locales/en/messages.json';
|
||||||
|
@ -13,6 +12,7 @@ import { getFakeBadge } from '../../test-both/helpers/getFakeBadge';
|
||||||
import type { PropsType } from './MessageSearchResult';
|
import type { PropsType } from './MessageSearchResult';
|
||||||
import { MessageSearchResult } from './MessageSearchResult';
|
import { MessageSearchResult } from './MessageSearchResult';
|
||||||
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
|
||||||
|
import { BodyRange } from '../../types/BodyRange';
|
||||||
|
|
||||||
const i18n = setupI18n('en', enMessages);
|
const i18n = setupI18n('en', enMessages);
|
||||||
|
|
||||||
|
@ -43,21 +43,15 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
||||||
id: '',
|
id: '',
|
||||||
conversationId: '',
|
conversationId: '',
|
||||||
sentAt: Date.now() - 24 * 60 * 1000,
|
sentAt: Date.now() - 24 * 60 * 1000,
|
||||||
snippet: text(
|
snippet: overrideProps.snippet || "What's <<left>>going<<right>> on?",
|
||||||
'snippet',
|
body: overrideProps.body || "What's going on?",
|
||||||
overrideProps.snippet || "What's <<left>>going<<right>> on?"
|
|
||||||
),
|
|
||||||
body: text('body', overrideProps.body || "What's going on?"),
|
|
||||||
bodyRanges: overrideProps.bodyRanges || [],
|
bodyRanges: overrideProps.bodyRanges || [],
|
||||||
from: overrideProps.from as PropsType['from'],
|
from: overrideProps.from as PropsType['from'],
|
||||||
to: overrideProps.to as PropsType['to'],
|
to: overrideProps.to as PropsType['to'],
|
||||||
getPreferredBadge: overrideProps.getPreferredBadge || (() => undefined),
|
getPreferredBadge: overrideProps.getPreferredBadge || (() => undefined),
|
||||||
isSelected: boolean('isSelected', overrideProps.isSelected || false),
|
isSelected: overrideProps.isSelected || false,
|
||||||
showConversation: action('showConversation'),
|
showConversation: action('showConversation'),
|
||||||
isSearchingInConversation: boolean(
|
isSearchingInConversation: overrideProps.isSearchingInConversation || false,
|
||||||
'isSearchingInConversation',
|
|
||||||
overrideProps.isSearchingInConversation || false
|
|
||||||
),
|
|
||||||
theme: React.useContext(StorybookThemeContext),
|
theme: React.useContext(StorybookThemeContext),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -220,7 +214,7 @@ export function Mention(): JSX.Element {
|
||||||
from: someone,
|
from: someone,
|
||||||
to: me,
|
to: me,
|
||||||
snippet:
|
snippet:
|
||||||
'...forget hair dry diary years no <<left>>results<<right>> \uFFFC <<left>>elephant<<right>> sorry umbrella potato igloo kangaroo home Georgia...',
|
'<<truncation>>forget hair dry diary years no <<left>>results<<right>> \uFFFC <<left>>elephant<<right>> sorry umbrella potato igloo kangaroo home Georgia<<truncation>>',
|
||||||
});
|
});
|
||||||
|
|
||||||
return <MessageSearchResult {...props} />;
|
return <MessageSearchResult {...props} />;
|
||||||
|
@ -245,7 +239,7 @@ export function MentionRegexp(): JSX.Element {
|
||||||
from: someone,
|
from: someone,
|
||||||
to: me,
|
to: me,
|
||||||
snippet:
|
snippet:
|
||||||
'\uFFFC This is a (long) /text/ ^$ that is ... <<left>>specially<<right>> **crafted** to (test) our regexp escaping mechanism...',
|
'\uFFFC This is a (long) /text/ ^$ that is ... <<left>>specially<<right>> **crafted** to (test) our regexp escaping mechanism<<truncation>>',
|
||||||
});
|
});
|
||||||
|
|
||||||
return <MessageSearchResult {...props} />;
|
return <MessageSearchResult {...props} />;
|
||||||
|
@ -301,7 +295,7 @@ export const _MentionNoMatches = (): JSX.Element => {
|
||||||
from: someone,
|
from: someone,
|
||||||
to: me,
|
to: me,
|
||||||
snippet:
|
snippet:
|
||||||
'...forget hair dry diary years no results \uFFFC elephant sorry umbrella potato igloo kangaroo home Georgia...',
|
'<<truncation>>forget hair dry diary years no results \uFFFC elephant sorry umbrella potato igloo kangaroo home Georgia<<truncation>>',
|
||||||
});
|
});
|
||||||
|
|
||||||
return <MessageSearchResult {...props} />;
|
return <MessageSearchResult {...props} />;
|
||||||
|
@ -313,7 +307,7 @@ _MentionNoMatches.story = {
|
||||||
|
|
||||||
export function DoubleMention(): JSX.Element {
|
export function DoubleMention(): JSX.Element {
|
||||||
const props = useProps({
|
const props = useProps({
|
||||||
body: 'Hey \uFFFC \uFFFC test',
|
body: 'Hey \uFFFC \uFFFC --- test! Two mentions!',
|
||||||
bodyRanges: [
|
bodyRanges: [
|
||||||
{
|
{
|
||||||
length: 1,
|
length: 1,
|
||||||
|
@ -332,7 +326,7 @@ export function DoubleMention(): JSX.Element {
|
||||||
],
|
],
|
||||||
from: someone,
|
from: someone,
|
||||||
to: me,
|
to: me,
|
||||||
snippet: '<<left>>Hey<<right>> \uFFFC \uFFFC <<left>>test<<right>>',
|
snippet: '<<left>>Hey<<right>> \uFFFC \uFFFC --- test! <<truncation>>',
|
||||||
});
|
});
|
||||||
|
|
||||||
return <MessageSearchResult {...props} />;
|
return <MessageSearchResult {...props} />;
|
||||||
|
@ -341,3 +335,41 @@ export function DoubleMention(): JSX.Element {
|
||||||
DoubleMention.story = {
|
DoubleMention.story = {
|
||||||
name: 'Double @mention',
|
name: 'Double @mention',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function WithFormatting(): JSX.Element {
|
||||||
|
const props = useProps({
|
||||||
|
body: "We're playing with formatting in fun ways like you do!",
|
||||||
|
bodyRanges: [
|
||||||
|
{
|
||||||
|
// Overlaps just start
|
||||||
|
start: 0,
|
||||||
|
length: 19,
|
||||||
|
style: BodyRange.Style.BOLD,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Contains snippet entirely
|
||||||
|
start: 0,
|
||||||
|
length: 54,
|
||||||
|
style: BodyRange.Style.ITALIC,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Contained by snippet
|
||||||
|
start: 19,
|
||||||
|
length: 10,
|
||||||
|
style: BodyRange.Style.MONOSPACE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Overlaps just end
|
||||||
|
start: 29,
|
||||||
|
length: 25,
|
||||||
|
style: BodyRange.Style.STRIKETHROUGH,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
from: someone,
|
||||||
|
to: me,
|
||||||
|
snippet:
|
||||||
|
'<<truncation>>playing with formatting in <<left>>fun<<right>> ways<<truncation>>',
|
||||||
|
});
|
||||||
|
|
||||||
|
return <MessageSearchResult {...props} />;
|
||||||
|
}
|
||||||
|
|
|
@ -3,17 +3,12 @@
|
||||||
|
|
||||||
import type { FunctionComponent, ReactNode } from 'react';
|
import type { FunctionComponent, ReactNode } from 'react';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { escapeRegExp } from 'lodash';
|
|
||||||
|
|
||||||
import { MessageBodyHighlight } from './MessageBodyHighlight';
|
|
||||||
import { ContactName } from '../conversation/ContactName';
|
import { ContactName } from '../conversation/ContactName';
|
||||||
|
|
||||||
import { assertDev } from '../../util/assert';
|
import type { BodyRangesForDisplayType } from '../../types/BodyRange';
|
||||||
import type {
|
import { processBodyRangesForSearchResult } from '../../types/BodyRange';
|
||||||
HydratedBodyRangesType,
|
import type { LocalizerType, ThemeType } from '../../types/Util';
|
||||||
LocalizerType,
|
|
||||||
ThemeType,
|
|
||||||
} from '../../types/Util';
|
|
||||||
import { BaseConversationListItem } from './BaseConversationListItem';
|
import { BaseConversationListItem } from './BaseConversationListItem';
|
||||||
import type {
|
import type {
|
||||||
ConversationType,
|
ConversationType,
|
||||||
|
@ -21,6 +16,10 @@ import type {
|
||||||
} from '../../state/ducks/conversations';
|
} from '../../state/ducks/conversations';
|
||||||
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
|
import type { PreferredBadgeSelectorType } from '../../state/selectors/badges';
|
||||||
import { Intl } from '../Intl';
|
import { Intl } from '../Intl';
|
||||||
|
import {
|
||||||
|
MessageTextRenderer,
|
||||||
|
RenderLocation,
|
||||||
|
} from '../conversation/MessageTextRenderer';
|
||||||
|
|
||||||
export type PropsDataType = {
|
export type PropsDataType = {
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
|
@ -32,7 +31,7 @@ export type PropsDataType = {
|
||||||
|
|
||||||
snippet: string;
|
snippet: string;
|
||||||
body: string;
|
body: string;
|
||||||
bodyRanges: HydratedBodyRangesType;
|
bodyRanges: BodyRangesForDisplayType;
|
||||||
|
|
||||||
from: Pick<
|
from: Pick<
|
||||||
ConversationType,
|
ConversationType,
|
||||||
|
@ -73,68 +72,6 @@ const renderPerson = (
|
||||||
): ReactNode =>
|
): ReactNode =>
|
||||||
person.isMe ? i18n('icu:you') : <ContactName title={person.title} />;
|
person.isMe ? i18n('icu:you') : <ContactName title={person.title} />;
|
||||||
|
|
||||||
// This function exists because bodyRanges tells us the character position
|
|
||||||
// where the at-mention starts at according to the full body text. The snippet
|
|
||||||
// we get back is a portion of the text and we don't know where it starts. This
|
|
||||||
// function will find the relevant bodyRanges that apply to the snippet and
|
|
||||||
// then update the proper start position of each body range.
|
|
||||||
function getFilteredBodyRanges(
|
|
||||||
snippet: string,
|
|
||||||
body: string,
|
|
||||||
bodyRanges: HydratedBodyRangesType
|
|
||||||
): HydratedBodyRangesType {
|
|
||||||
if (!bodyRanges.length) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find where the snippet starts in the full text
|
|
||||||
const stripped = snippet
|
|
||||||
.replace(/<<left>>/g, '')
|
|
||||||
.replace(/<<right>>/g, '')
|
|
||||||
.replace(/^.../, '')
|
|
||||||
.replace(/...$/, '');
|
|
||||||
const rx = new RegExp(escapeRegExp(stripped));
|
|
||||||
const match = rx.exec(body);
|
|
||||||
|
|
||||||
assertDev(Boolean(match), `No match found for "${snippet}" inside "${body}"`);
|
|
||||||
|
|
||||||
const delta = match ? match.index + snippet.length : 0;
|
|
||||||
|
|
||||||
// Filters out the @mentions that are present inside the snippet
|
|
||||||
const filteredBodyRanges = bodyRanges.filter(bodyRange => {
|
|
||||||
return bodyRange.start < delta;
|
|
||||||
});
|
|
||||||
|
|
||||||
const snippetBodyRanges = [];
|
|
||||||
const MENTIONS_REGEX = /\uFFFC/g;
|
|
||||||
|
|
||||||
let bodyRangeMatch = MENTIONS_REGEX.exec(snippet);
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
// Find the start position within the snippet so these can later be
|
|
||||||
// encoded and rendered correctly.
|
|
||||||
while (bodyRangeMatch) {
|
|
||||||
const bodyRange = filteredBodyRanges[i];
|
|
||||||
|
|
||||||
if (bodyRange) {
|
|
||||||
snippetBodyRanges.push({
|
|
||||||
...bodyRange,
|
|
||||||
start: bodyRangeMatch.index,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
assertDev(
|
|
||||||
false,
|
|
||||||
`Body range does not exist? Count: ${i}, Length: ${filteredBodyRanges.length}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
bodyRangeMatch = MENTIONS_REGEX.exec(snippet);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return snippetBodyRanges;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
|
export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
|
||||||
function MessageSearchResult({
|
function MessageSearchResult({
|
||||||
body,
|
body,
|
||||||
|
@ -219,12 +156,20 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const snippetBodyRanges = getFilteredBodyRanges(snippet, body, bodyRanges);
|
const { cleanedSnippet, bodyRanges: displayBodyRanges } =
|
||||||
|
processBodyRangesForSearchResult({ snippet, body, bodyRanges });
|
||||||
const messageText = (
|
const messageText = (
|
||||||
<MessageBodyHighlight
|
<MessageTextRenderer
|
||||||
text={snippet}
|
messageText={cleanedSnippet}
|
||||||
bodyRanges={snippetBodyRanges}
|
bodyRanges={displayBodyRanges}
|
||||||
|
direction={undefined}
|
||||||
|
disableLinks
|
||||||
|
emojiSizeClass={undefined}
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
|
isSpoilerExpanded={false}
|
||||||
|
onMentionTrigger={() => null}
|
||||||
|
renderLocation={RenderLocation.SearchResult}
|
||||||
|
textLength={cleanedSnippet.length}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,8 @@ import type {
|
||||||
ReactionType,
|
ReactionType,
|
||||||
} from '../../textsecure/SendMessage';
|
} from '../../textsecure/SendMessage';
|
||||||
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
||||||
import type { BodyRangesType, StoryContextType } from '../../types/Util';
|
import { BodyRange } from '../../types/BodyRange';
|
||||||
|
import type { StoryContextType } from '../../types/Util';
|
||||||
import type { LoggerType } from '../../types/Logging';
|
import type { LoggerType } from '../../types/Logging';
|
||||||
import type { StickerWithHydratedData } from '../../types/Stickers';
|
import type { StickerWithHydratedData } from '../../types/Stickers';
|
||||||
import type { QuotedMessageType } from '../../model-types.d';
|
import type { QuotedMessageType } from '../../model-types.d';
|
||||||
|
@ -471,7 +472,7 @@ async function getMessageSendData({
|
||||||
contact?: Array<ContactWithHydratedAvatar>;
|
contact?: Array<ContactWithHydratedAvatar>;
|
||||||
deletedForEveryoneTimestamp: undefined | number;
|
deletedForEveryoneTimestamp: undefined | number;
|
||||||
expireTimer: undefined | DurationInSeconds;
|
expireTimer: undefined | DurationInSeconds;
|
||||||
mentions: undefined | BodyRangesType;
|
mentions: undefined | ReadonlyArray<BodyRange<BodyRange.Mention>>;
|
||||||
messageTimestamp: number;
|
messageTimestamp: number;
|
||||||
preview: Array<LinkPreviewType>;
|
preview: Array<LinkPreviewType>;
|
||||||
quote: QuotedMessageType | null;
|
quote: QuotedMessageType | null;
|
||||||
|
@ -538,7 +539,7 @@ async function getMessageSendData({
|
||||||
contact,
|
contact,
|
||||||
deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'),
|
deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'),
|
||||||
expireTimer: message.get('expireTimer'),
|
expireTimer: message.get('expireTimer'),
|
||||||
mentions: message.get('bodyRanges'),
|
mentions: message.get('bodyRanges')?.filter(BodyRange.isMention),
|
||||||
messageTimestamp,
|
messageTimestamp,
|
||||||
preview,
|
preview,
|
||||||
quote,
|
quote,
|
||||||
|
|
16
ts/model-types.d.ts
vendored
16
ts/model-types.d.ts
vendored
|
@ -6,7 +6,7 @@
|
||||||
import * as Backbone from 'backbone';
|
import * as Backbone from 'backbone';
|
||||||
|
|
||||||
import type { GroupV2ChangeType } from './groups';
|
import type { GroupV2ChangeType } from './groups';
|
||||||
import type { DraftBodyRangesType, BodyRangesType } from './types/Util';
|
import type { DraftBodyRangeMention, RawBodyRange } from './types/BodyRange';
|
||||||
import type { CallHistoryDetailsFromDiskType } from './types/Calling';
|
import type { CallHistoryDetailsFromDiskType } from './types/Calling';
|
||||||
import type { CustomColorType, ConversationColorType } from './types/Colors';
|
import type { CustomColorType, ConversationColorType } from './types/Colors';
|
||||||
import type { DeviceType } from './textsecure/Types.d';
|
import type { DeviceType } from './textsecure/Types.d';
|
||||||
|
@ -82,7 +82,7 @@ export type QuotedMessageType = {
|
||||||
// new messages, but old messages might have this attribute.
|
// new messages, but old messages might have this attribute.
|
||||||
author?: string;
|
author?: string;
|
||||||
authorUuid?: string;
|
authorUuid?: string;
|
||||||
bodyRanges?: BodyRangesType;
|
bodyRanges?: ReadonlyArray<RawBodyRange>;
|
||||||
id: number;
|
id: number;
|
||||||
isGiftBadge?: boolean;
|
isGiftBadge?: boolean;
|
||||||
isViewOnce: boolean;
|
isViewOnce: boolean;
|
||||||
|
@ -123,14 +123,14 @@ export type MessageReactionType = {
|
||||||
export type EditHistoryType = {
|
export type EditHistoryType = {
|
||||||
attachments?: Array<AttachmentType>;
|
attachments?: Array<AttachmentType>;
|
||||||
body?: string;
|
body?: string;
|
||||||
bodyRanges?: BodyRangesType;
|
bodyRanges?: ReadonlyArray<RawBodyRange>;
|
||||||
preview?: Array<LinkPreviewType>;
|
preview?: Array<LinkPreviewType>;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageAttributesType = {
|
export type MessageAttributesType = {
|
||||||
bodyAttachment?: AttachmentType;
|
bodyAttachment?: AttachmentType;
|
||||||
bodyRanges?: BodyRangesType;
|
bodyRanges?: ReadonlyArray<RawBodyRange>;
|
||||||
callHistoryDetails?: CallHistoryDetailsFromDiskType;
|
callHistoryDetails?: CallHistoryDetailsFromDiskType;
|
||||||
canReplyToStory?: boolean;
|
canReplyToStory?: boolean;
|
||||||
changedId?: string;
|
changedId?: string;
|
||||||
|
@ -298,7 +298,7 @@ export type ConversationAttributesType = {
|
||||||
firstUnregisteredAt?: number;
|
firstUnregisteredAt?: number;
|
||||||
draftChanged?: boolean;
|
draftChanged?: boolean;
|
||||||
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
|
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
|
||||||
draftBodyRanges?: DraftBodyRangesType;
|
draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>;
|
||||||
draftTimestamp?: number | null;
|
draftTimestamp?: number | null;
|
||||||
hideStory?: boolean;
|
hideStory?: boolean;
|
||||||
inbox_position?: number;
|
inbox_position?: number;
|
||||||
|
@ -310,8 +310,11 @@ export type ConversationAttributesType = {
|
||||||
removalStage?: 'justNotification' | 'messageRequest';
|
removalStage?: 'justNotification' | 'messageRequest';
|
||||||
isPinned?: boolean;
|
isPinned?: boolean;
|
||||||
lastMessageDeletedForEveryone?: boolean;
|
lastMessageDeletedForEveryone?: boolean;
|
||||||
lastMessageStatus?: LastMessageStatus | null;
|
lastMessage?: string | null;
|
||||||
|
lastMessageBodyRanges?: ReadonlyArray<RawBodyRange>;
|
||||||
|
lastMessagePrefix?: string;
|
||||||
lastMessageAuthor?: string | null;
|
lastMessageAuthor?: string | null;
|
||||||
|
lastMessageStatus?: LastMessageStatus | null;
|
||||||
markedUnread?: boolean;
|
markedUnread?: boolean;
|
||||||
messageCount?: number;
|
messageCount?: number;
|
||||||
messageCountBeforeMessageRequests?: number | null;
|
messageCountBeforeMessageRequests?: number | null;
|
||||||
|
@ -340,7 +343,6 @@ export type ConversationAttributesType = {
|
||||||
draft?: string | null;
|
draft?: string | null;
|
||||||
hasPostedStory?: boolean;
|
hasPostedStory?: boolean;
|
||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
lastMessage?: string | null;
|
|
||||||
name?: string;
|
name?: string;
|
||||||
systemGivenName?: string;
|
systemGivenName?: string;
|
||||||
systemFamilyName?: string;
|
systemFamilyName?: string;
|
||||||
|
|
|
@ -51,6 +51,7 @@ import type {
|
||||||
} from '../textsecure/Types.d';
|
} from '../textsecure/Types.d';
|
||||||
import type {
|
import type {
|
||||||
ConversationType,
|
ConversationType,
|
||||||
|
DraftPreviewType,
|
||||||
LastMessageType,
|
LastMessageType,
|
||||||
} from '../state/ducks/conversations';
|
} from '../state/ducks/conversations';
|
||||||
import type {
|
import type {
|
||||||
|
@ -83,8 +84,8 @@ import {
|
||||||
deriveAccessKey,
|
deriveAccessKey,
|
||||||
} from '../Crypto';
|
} from '../Crypto';
|
||||||
import * as Bytes from '../Bytes';
|
import * as Bytes from '../Bytes';
|
||||||
import type { BodyRangesType, DraftBodyRangesType } from '../types/Util';
|
import type { DraftBodyRangeMention } from '../types/BodyRange';
|
||||||
import { getTextWithMentions } from '../util/getTextWithMentions';
|
import { BodyRange } from '../types/BodyRange';
|
||||||
import { migrateColor } from '../util/migrateColor';
|
import { migrateColor } from '../util/migrateColor';
|
||||||
import { isNotNil } from '../util/isNotNil';
|
import { isNotNil } from '../util/isNotNil';
|
||||||
import { dropNull } from '../util/dropNull';
|
import { dropNull } from '../util/dropNull';
|
||||||
|
@ -153,6 +154,8 @@ import { isMemberPending } from '../util/isMemberPending';
|
||||||
import { imageToBlurHash } from '../util/imageToBlurHash';
|
import { imageToBlurHash } from '../util/imageToBlurHash';
|
||||||
import { ReceiptType } from '../types/Receipt';
|
import { ReceiptType } from '../types/Receipt';
|
||||||
import { getQuoteAttachment } from '../util/makeQuote';
|
import { getQuoteAttachment } from '../util/makeQuote';
|
||||||
|
import { stripNewlinesForLeftPane } from '../util/stripNewlinesForLeftPane';
|
||||||
|
import { findAndFormatContact } from '../util/findAndFormatContact';
|
||||||
|
|
||||||
const EMPTY_ARRAY: Readonly<[]> = [];
|
const EMPTY_ARRAY: Readonly<[]> = [];
|
||||||
const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {};
|
const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {};
|
||||||
|
@ -1085,37 +1088,59 @@ export class ConversationModel extends window.Backbone
|
||||||
draftAttachments.length > 0) as boolean;
|
draftAttachments.length > 0) as boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
getDraftPreview(): string {
|
getDraftPreview(): DraftPreviewType {
|
||||||
const draft = this.get('draft');
|
const draft = this.get('draft');
|
||||||
|
|
||||||
if (draft) {
|
const rawBodyRanges = this.get('draftBodyRanges') || [];
|
||||||
const bodyRanges = this.get('draftBodyRanges') || [];
|
const bodyRanges = rawBodyRanges.map(range => {
|
||||||
|
// Hydrate user information on mention
|
||||||
|
if (BodyRange.isMention(range)) {
|
||||||
|
const conversation = findAndFormatContact(range.mentionUuid);
|
||||||
|
|
||||||
return getTextWithMentions(bodyRanges, draft);
|
return {
|
||||||
|
...range,
|
||||||
|
conversationID: conversation.id,
|
||||||
|
replacementText: conversation.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BodyRange.isFormatting(range)) {
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw missingCaseError(range);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (draft) {
|
||||||
|
return {
|
||||||
|
text: stripNewlinesForLeftPane(draft),
|
||||||
|
bodyRanges,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const draftAttachments = this.get('draftAttachments') || [];
|
const draftAttachments = this.get('draftAttachments') || [];
|
||||||
if (draftAttachments.length > 0) {
|
if (draftAttachments.length > 0) {
|
||||||
if (isVoiceMessage(draftAttachments[0])) {
|
if (isVoiceMessage(draftAttachments[0])) {
|
||||||
return window.i18n(
|
return {
|
||||||
'icu:message--getNotificationText--text-with-emoji',
|
text: window.i18n('icu:message--getNotificationText--voice-message'),
|
||||||
{
|
prefix: '🎤',
|
||||||
text: window.i18n(
|
};
|
||||||
'icu:message--getNotificationText--voice-message'
|
|
||||||
),
|
|
||||||
emoji: '🎤',
|
|
||||||
}
|
}
|
||||||
);
|
return {
|
||||||
}
|
text: window.i18n('icu:Conversation--getDraftPreview--attachment'),
|
||||||
return window.i18n('icu:Conversation--getDraftPreview--attachment');
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotedMessageId = this.get('quotedMessageId');
|
const quotedMessageId = this.get('quotedMessageId');
|
||||||
if (quotedMessageId) {
|
if (quotedMessageId) {
|
||||||
return window.i18n('icu:Conversation--getDraftPreview--quote');
|
return {
|
||||||
|
text: window.i18n('icu:Conversation--getDraftPreview--quote'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return window.i18n('icu:Conversation--getDraftPreview--draft');
|
return {
|
||||||
|
text: window.i18n('icu:Conversation--getDraftPreview--draft'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
bumpTyping(): void {
|
bumpTyping(): void {
|
||||||
|
@ -3857,7 +3882,7 @@ export class ConversationModel extends window.Backbone
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDraftBodyRanges = memoizeByThis(
|
private getDraftBodyRanges = memoizeByThis(
|
||||||
(): DraftBodyRangesType | undefined => {
|
(): ReadonlyArray<DraftBodyRangeMention> | undefined => {
|
||||||
return this.get('draftBodyRanges');
|
return this.get('draftBodyRanges');
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
@ -3870,11 +3895,37 @@ export class ConversationModel extends window.Backbone
|
||||||
if (!lastMessageText) {
|
if (!lastMessageText) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rawBodyRanges = this.get('lastMessageBodyRanges') || [];
|
||||||
|
const bodyRanges = rawBodyRanges.map(range => {
|
||||||
|
// Hydrate user information on mention
|
||||||
|
if (BodyRange.isMention(range)) {
|
||||||
|
const conversation = findAndFormatContact(range.mentionUuid);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...range,
|
||||||
|
conversationID: conversation.id,
|
||||||
|
replacementText: conversation.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BodyRange.isFormatting(range)) {
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw missingCaseError(range);
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = stripNewlinesForLeftPane(lastMessageText);
|
||||||
|
const prefix = this.get('lastMessagePrefix');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: dropNull(this.get('lastMessageStatus')),
|
|
||||||
text: lastMessageText,
|
|
||||||
author: dropNull(this.get('lastMessageAuthor')),
|
author: dropNull(this.get('lastMessageAuthor')),
|
||||||
|
bodyRanges,
|
||||||
deletedForEveryone: false,
|
deletedForEveryone: false,
|
||||||
|
prefix,
|
||||||
|
status: dropNull(this.get('lastMessageStatus')),
|
||||||
|
text,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -4119,7 +4170,7 @@ export class ConversationModel extends window.Backbone
|
||||||
attachments: Array<AttachmentType>;
|
attachments: Array<AttachmentType>;
|
||||||
body: string | undefined;
|
body: string | undefined;
|
||||||
contact?: Array<ContactWithHydratedAvatar>;
|
contact?: Array<ContactWithHydratedAvatar>;
|
||||||
mentions?: BodyRangesType;
|
mentions?: ReadonlyArray<BodyRange<BodyRange.Mention>>;
|
||||||
preview?: Array<LinkPreviewType>;
|
preview?: Array<LinkPreviewType>;
|
||||||
quote?: QuotedMessageType;
|
quote?: QuotedMessageType;
|
||||||
sticker?: StickerWithHydratedData;
|
sticker?: StickerWithHydratedData;
|
||||||
|
@ -4475,9 +4526,12 @@ export class ConversationModel extends window.Backbone
|
||||||
}
|
}
|
||||||
timestamp = timestamp || currentTimestamp;
|
timestamp = timestamp || currentTimestamp;
|
||||||
|
|
||||||
|
const notificationData = previewMessage?.getNotificationData();
|
||||||
|
|
||||||
this.set({
|
this.set({
|
||||||
lastMessage:
|
lastMessage: notificationData?.text || '',
|
||||||
(previewMessage ? previewMessage.getNotificationText() : '') || '',
|
lastMessageBodyRanges: notificationData?.bodyRanges,
|
||||||
|
lastMessagePrefix: notificationData?.emoji,
|
||||||
lastMessageAuthor: previewMessage?.getAuthorText(),
|
lastMessageAuthor: previewMessage?.getAuthorText(),
|
||||||
lastMessageStatus:
|
lastMessageStatus:
|
||||||
(previewMessage
|
(previewMessage
|
||||||
|
@ -5514,7 +5568,7 @@ export class ConversationModel extends window.Backbone
|
||||||
|
|
||||||
const ourUuid = window.textsecure.storage.user.getUuid()?.toString();
|
const ourUuid = window.textsecure.storage.user.getUuid()?.toString();
|
||||||
const mentionsMe = (message.get('bodyRanges') || []).some(
|
const mentionsMe = (message.get('bodyRanges') || []).some(
|
||||||
range => range.mentionUuid && range.mentionUuid === ourUuid
|
range => BodyRange.isMention(range) && range.mentionUuid === ourUuid
|
||||||
);
|
);
|
||||||
if (!mentionsMe) {
|
if (!mentionsMe) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -115,8 +115,8 @@ import {
|
||||||
isUniversalTimerNotification,
|
isUniversalTimerNotification,
|
||||||
isUnsupportedMessage,
|
isUnsupportedMessage,
|
||||||
isVerifiedChange,
|
isVerifiedChange,
|
||||||
processBodyRanges,
|
|
||||||
isConversationMerge,
|
isConversationMerge,
|
||||||
|
extractHydratedMentions,
|
||||||
} from '../state/selectors/message';
|
} from '../state/selectors/message';
|
||||||
import {
|
import {
|
||||||
isInCall,
|
isInCall,
|
||||||
|
@ -185,6 +185,8 @@ import * as Edits from '../messageModifiers/Edits';
|
||||||
import { handleEditMessage } from '../util/handleEditMessage';
|
import { handleEditMessage } from '../util/handleEditMessage';
|
||||||
import { getQuoteBodyText } from '../util/getQuoteBodyText';
|
import { getQuoteBodyText } from '../util/getQuoteBodyText';
|
||||||
import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser';
|
import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser';
|
||||||
|
import type { RawBodyRange } from '../types/BodyRange';
|
||||||
|
import { BodyRange, applyRangesForText } from '../types/BodyRange';
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
|
|
||||||
|
@ -192,7 +194,7 @@ window.Whisper = window.Whisper || {};
|
||||||
|
|
||||||
const { Message: TypedMessage } = window.Signal.Types;
|
const { Message: TypedMessage } = window.Signal.Types;
|
||||||
const { upgradeMessageSchema } = window.Signal.Migrations;
|
const { upgradeMessageSchema } = window.Signal.Migrations;
|
||||||
const { getTextWithMentions, GoogleChrome } = window.Signal.Util;
|
const { GoogleChrome } = window.Signal.Util;
|
||||||
const { getMessageBySender } = window.Signal.Data;
|
const { getMessageBySender } = window.Signal.Data;
|
||||||
|
|
||||||
export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
|
@ -415,7 +417,11 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return window.ConversationController.get(this.get('conversationId'));
|
return window.ConversationController.get(this.get('conversationId'));
|
||||||
}
|
}
|
||||||
|
|
||||||
getNotificationData(): { emoji?: string; text: string } {
|
getNotificationData(): {
|
||||||
|
emoji?: string;
|
||||||
|
text: string;
|
||||||
|
bodyRanges?: ReadonlyArray<RawBodyRange>;
|
||||||
|
} {
|
||||||
// eslint-disable-next-line prefer-destructuring
|
// eslint-disable-next-line prefer-destructuring
|
||||||
const attributes: MessageAttributesType = this.attributes;
|
const attributes: MessageAttributesType = this.attributes;
|
||||||
|
|
||||||
|
@ -654,6 +660,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (this.get('body') || '').trim();
|
const body = (this.get('body') || '').trim();
|
||||||
|
const bodyRanges = this.get('bodyRanges') || [];
|
||||||
|
|
||||||
if (attachments.length) {
|
if (attachments.length) {
|
||||||
// This should never happen but we want to be extra-careful.
|
// This should never happen but we want to be extra-careful.
|
||||||
|
@ -662,39 +669,46 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
|
|
||||||
if (contentType === MIME.IMAGE_GIF || Attachment.isGIF(attachments)) {
|
if (contentType === MIME.IMAGE_GIF || Attachment.isGIF(attachments)) {
|
||||||
return {
|
return {
|
||||||
text: body || window.i18n('icu:message--getNotificationText--gif'),
|
bodyRanges,
|
||||||
emoji: '🎡',
|
emoji: '🎡',
|
||||||
|
text: body || window.i18n('icu:message--getNotificationText--gif'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (Attachment.isImage(attachments)) {
|
if (Attachment.isImage(attachments)) {
|
||||||
return {
|
return {
|
||||||
text: body || window.i18n('icu:message--getNotificationText--photo'),
|
bodyRanges,
|
||||||
emoji: '📷',
|
emoji: '📷',
|
||||||
|
text: body || window.i18n('icu:message--getNotificationText--photo'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (Attachment.isVideo(attachments)) {
|
if (Attachment.isVideo(attachments)) {
|
||||||
return {
|
return {
|
||||||
text: body || window.i18n('icu:message--getNotificationText--video'),
|
bodyRanges,
|
||||||
emoji: '🎥',
|
emoji: '🎥',
|
||||||
|
text: body || window.i18n('icu:message--getNotificationText--video'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (Attachment.isVoiceMessage(attachment)) {
|
if (Attachment.isVoiceMessage(attachment)) {
|
||||||
return {
|
return {
|
||||||
|
bodyRanges,
|
||||||
|
emoji: '🎤',
|
||||||
text:
|
text:
|
||||||
body ||
|
body ||
|
||||||
window.i18n('icu:message--getNotificationText--voice-message'),
|
window.i18n('icu:message--getNotificationText--voice-message'),
|
||||||
emoji: '🎤',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (Attachment.isAudio(attachments)) {
|
if (Attachment.isAudio(attachments)) {
|
||||||
return {
|
return {
|
||||||
|
bodyRanges,
|
||||||
|
emoji: '🔈',
|
||||||
text:
|
text:
|
||||||
body ||
|
body ||
|
||||||
window.i18n('icu:message--getNotificationText--audio-message'),
|
window.i18n('icu:message--getNotificationText--audio-message'),
|
||||||
emoji: '🔈',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
bodyRanges,
|
||||||
text: body || window.i18n('icu:message--getNotificationText--file'),
|
text: body || window.i18n('icu:message--getNotificationText--file'),
|
||||||
emoji: '📎',
|
emoji: '📎',
|
||||||
};
|
};
|
||||||
|
@ -793,26 +807,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body) {
|
if (body) {
|
||||||
return { text: body };
|
return {
|
||||||
|
text: body,
|
||||||
|
bodyRanges,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { text: '' };
|
return { text: '' };
|
||||||
}
|
}
|
||||||
|
|
||||||
getRawText(): string {
|
|
||||||
const body = (this.get('body') || '').trim();
|
|
||||||
const { attributes } = this;
|
|
||||||
|
|
||||||
const bodyRanges = processBodyRanges(attributes, {
|
|
||||||
conversationSelector: findAndFormatContact,
|
|
||||||
});
|
|
||||||
if (bodyRanges) {
|
|
||||||
return getTextWithMentions(bodyRanges, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
getAuthorText(): string | undefined {
|
getAuthorText(): string | undefined {
|
||||||
// if it's outgoing, it must be self-authored
|
// if it's outgoing, it must be self-authored
|
||||||
const selfAuthor = isOutgoing(this.attributes)
|
const selfAuthor = isOutgoing(this.attributes)
|
||||||
|
@ -867,15 +870,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
return window.i18n('icu:Quote__story-reaction--single');
|
return window.i18n('icu:Quote__story-reaction--single');
|
||||||
}
|
}
|
||||||
|
|
||||||
let modifiedText = text;
|
const mentions =
|
||||||
|
extractHydratedMentions(attributes, {
|
||||||
const bodyRanges = processBodyRanges(attributes, {
|
|
||||||
conversationSelector: findAndFormatContact,
|
conversationSelector: findAndFormatContact,
|
||||||
});
|
}) || [];
|
||||||
|
const spoilers = (attributes.bodyRanges || []).filter(
|
||||||
if (bodyRanges && bodyRanges.length) {
|
range =>
|
||||||
modifiedText = getTextWithMentions(bodyRanges, modifiedText);
|
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
|
// Linux emoji support is mixed, so we disable it. (Note that this doesn't touch
|
||||||
// the `text`, which can contain emoji.)
|
// the `text`, which can contain emoji.)
|
||||||
|
@ -886,7 +889,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
emoji,
|
emoji,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return modifiedText;
|
|
||||||
|
return modifiedText || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// General
|
// General
|
||||||
|
|
|
@ -6,7 +6,8 @@ import Delta from 'quill-delta';
|
||||||
import type { LeafBlot, DeltaOperation } from 'quill';
|
import type { LeafBlot, DeltaOperation } from 'quill';
|
||||||
import type Op from 'quill-delta/dist/Op';
|
import type Op from 'quill-delta/dist/Op';
|
||||||
|
|
||||||
import type { DraftBodyRangeType, DraftBodyRangesType } from '../types/Util';
|
import type { DraftBodyRangeMention } from '../types/BodyRange';
|
||||||
|
import { BodyRange } from '../types/BodyRange';
|
||||||
import type { MentionBlot } from './mentions/blot';
|
import type { MentionBlot } from './mentions/blot';
|
||||||
|
|
||||||
export type MentionBlotValue = {
|
export type MentionBlotValue = {
|
||||||
|
@ -61,8 +62,8 @@ export const getTextFromOps = (ops: Array<DeltaOperation>): string =>
|
||||||
|
|
||||||
export const getTextAndMentionsFromOps = (
|
export const getTextAndMentionsFromOps = (
|
||||||
ops: Array<Op>
|
ops: Array<Op>
|
||||||
): [string, DraftBodyRangesType] => {
|
): [string, ReadonlyArray<DraftBodyRangeMention>] => {
|
||||||
const mentions: Array<DraftBodyRangeType> = [];
|
const mentions: Array<DraftBodyRangeMention> = [];
|
||||||
|
|
||||||
const text = ops
|
const text = ops
|
||||||
.reduce((acc, op, index) => {
|
.reduce((acc, op, index) => {
|
||||||
|
@ -168,11 +169,11 @@ export const getDeltaToRemoveStaleMentions = (
|
||||||
|
|
||||||
export const insertMentionOps = (
|
export const insertMentionOps = (
|
||||||
incomingOps: Array<Op>,
|
incomingOps: Array<Op>,
|
||||||
bodyRanges: DraftBodyRangesType
|
bodyRanges: ReadonlyArray<DraftBodyRangeMention>
|
||||||
): Array<Op> => {
|
): Array<Op> => {
|
||||||
const ops = [...incomingOps];
|
const ops = [...incomingOps];
|
||||||
|
|
||||||
const sortableBodyRanges: Array<DraftBodyRangeType> = bodyRanges.slice();
|
const sortableBodyRanges: Array<DraftBodyRangeMention> = bodyRanges.slice();
|
||||||
|
|
||||||
// Working backwards through bodyRanges (to avoid offsetting later mentions),
|
// Working backwards through bodyRanges (to avoid offsetting later mentions),
|
||||||
// Shift off the op with the text to the left of the last mention,
|
// Shift off the op with the text to the left of the last mention,
|
||||||
|
@ -180,7 +181,13 @@ export const insertMentionOps = (
|
||||||
// Unshift the mention and surrounding text to leave the ops ready for the next range
|
// Unshift the mention and surrounding text to leave the ops ready for the next range
|
||||||
sortableBodyRanges
|
sortableBodyRanges
|
||||||
.sort((a, b) => b.start - a.start)
|
.sort((a, b) => b.start - a.start)
|
||||||
.forEach(({ start, length, mentionUuid, replacementText }) => {
|
.forEach(bodyRange => {
|
||||||
|
if (!BodyRange.isMention(bodyRange)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, length, mentionUuid, replacementText } = bodyRange;
|
||||||
|
|
||||||
const op = ops.shift();
|
const op = ops.shift();
|
||||||
|
|
||||||
if (op) {
|
if (op) {
|
||||||
|
|
|
@ -101,6 +101,7 @@ export function getStoryDataFromMessageAttributes(
|
||||||
attachment,
|
attachment,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
...pick(message, [
|
...pick(message, [
|
||||||
|
'bodyRanges',
|
||||||
'canReplyToStory',
|
'canReplyToStory',
|
||||||
'conversationId',
|
'conversationId',
|
||||||
'deletedForEveryone',
|
'deletedForEveryone',
|
||||||
|
|
|
@ -11,13 +11,14 @@ import type { ReactionType } from '../types/Reactions';
|
||||||
import type { ConversationColorType, CustomColorType } from '../types/Colors';
|
import type { ConversationColorType, CustomColorType } from '../types/Colors';
|
||||||
import type { StorageAccessType } from '../types/Storage.d';
|
import type { StorageAccessType } from '../types/Storage.d';
|
||||||
import type { AttachmentType } from '../types/Attachment';
|
import type { AttachmentType } from '../types/Attachment';
|
||||||
import type { BodyRangesType, BytesToStrings } from '../types/Util';
|
import type { BytesToStrings } from '../types/Util';
|
||||||
import type { QualifiedAddressStringType } from '../types/QualifiedAddress';
|
import type { QualifiedAddressStringType } from '../types/QualifiedAddress';
|
||||||
import type { UUIDStringType } from '../types/UUID';
|
import type { UUIDStringType } from '../types/UUID';
|
||||||
import type { BadgeType } from '../badges/types';
|
import type { BadgeType } from '../badges/types';
|
||||||
import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration';
|
import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration';
|
||||||
import type { LoggerType } from '../types/Logging';
|
import type { LoggerType } from '../types/Logging';
|
||||||
import type { ReadStatus } from '../messages/MessageReadStatus';
|
import type { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
|
import type { RawBodyRange } from '../types/BodyRange';
|
||||||
import type { GetMessagesBetweenOptions } from './Server';
|
import type { GetMessagesBetweenOptions } from './Server';
|
||||||
import type { MessageTimestamps } from '../state/ducks/conversations';
|
import type { MessageTimestamps } from '../state/ducks/conversations';
|
||||||
|
|
||||||
|
@ -129,7 +130,7 @@ export type ServerSearchResultMessageType = {
|
||||||
};
|
};
|
||||||
export type ClientSearchResultMessageType = MessageType & {
|
export type ClientSearchResultMessageType = MessageType & {
|
||||||
json: string;
|
json: string;
|
||||||
bodyRanges: BodyRangesType;
|
bodyRanges: ReadonlyArray<RawBodyRange>;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1733,7 +1733,7 @@ async function searchMessages(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
messages.json,
|
messages.json,
|
||||||
snippet(messages_fts, -1, '<<left>>', '<<right>>', '...', 10)
|
snippet(messages_fts, -1, '<<left>>', '<<right>>', '<<truncation>>', 10)
|
||||||
AS snippet
|
AS snippet
|
||||||
FROM tmp_filtered_results
|
FROM tmp_filtered_results
|
||||||
INNER JOIN messages_fts
|
INNER JOIN messages_fts
|
||||||
|
|
|
@ -62,8 +62,8 @@ export namespace AudioPlayerContent {
|
||||||
content: ActiveAudioPlayerStateType['content']
|
content: ActiveAudioPlayerStateType['content']
|
||||||
): content is AudioPlayerContentVoiceNote {
|
): content is AudioPlayerContentVoiceNote {
|
||||||
return (
|
return (
|
||||||
('current' as const satisfies keyof AudioPlayerContentVoiceNote) in
|
// satisfies keyof AudioPlayerContentVoiceNote
|
||||||
content
|
('current' as const) in content
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export function isDraft(
|
export function isDraft(
|
||||||
|
|
|
@ -17,10 +17,8 @@ import type {
|
||||||
InMemoryAttachmentDraftType,
|
InMemoryAttachmentDraftType,
|
||||||
} from '../../types/Attachment';
|
} from '../../types/Attachment';
|
||||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||||
import type {
|
import type { DraftBodyRangeMention } from '../../types/BodyRange';
|
||||||
DraftBodyRangesType,
|
import type { ReplacementValuesType } from '../../types/Util';
|
||||||
ReplacementValuesType,
|
|
||||||
} from '../../types/Util';
|
|
||||||
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
||||||
import type { MessageAttributesType } from '../../model-types.d';
|
import type { MessageAttributesType } from '../../model-types.d';
|
||||||
import type { NoopActionType } from './noop';
|
import type { NoopActionType } from './noop';
|
||||||
|
@ -382,7 +380,7 @@ function sendMultiMediaMessage(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
options: {
|
options: {
|
||||||
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
|
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
|
||||||
mentions?: DraftBodyRangesType;
|
draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>;
|
||||||
message?: string;
|
message?: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
voiceNoteAttachment?: InMemoryAttachmentDraftType;
|
voiceNoteAttachment?: InMemoryAttachmentDraftType;
|
||||||
|
@ -406,8 +404,8 @@ function sendMultiMediaMessage(
|
||||||
|
|
||||||
const {
|
const {
|
||||||
draftAttachments,
|
draftAttachments,
|
||||||
|
draftBodyRanges,
|
||||||
message = '',
|
message = '',
|
||||||
mentions,
|
|
||||||
timestamp = Date.now(),
|
timestamp = Date.now(),
|
||||||
voiceNoteAttachment,
|
voiceNoteAttachment,
|
||||||
} = options;
|
} = options;
|
||||||
|
@ -497,7 +495,7 @@ function sendMultiMediaMessage(
|
||||||
attachments,
|
attachments,
|
||||||
quote,
|
quote,
|
||||||
preview: getLinkPreviewForSend(message),
|
preview: getLinkPreviewForSend(message),
|
||||||
mentions,
|
mentions: draftBodyRanges,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sendHQImages,
|
sendHQImages,
|
||||||
|
@ -816,7 +814,7 @@ function onEditorStateChange({
|
||||||
messageText,
|
messageText,
|
||||||
sendCounter,
|
sendCounter,
|
||||||
}: {
|
}: {
|
||||||
bodyRanges: DraftBodyRangesType;
|
bodyRanges: ReadonlyArray<DraftBodyRangeMention>;
|
||||||
caretLocation?: number;
|
caretLocation?: number;
|
||||||
conversationId: string | undefined;
|
conversationId: string | undefined;
|
||||||
messageText: string;
|
messageText: string;
|
||||||
|
@ -1171,7 +1169,7 @@ const debouncedSaveDraft = debounce(saveDraft);
|
||||||
function saveDraft(
|
function saveDraft(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageText: string,
|
messageText: string,
|
||||||
bodyRanges: DraftBodyRangesType
|
mentions: ReadonlyArray<DraftBodyRangeMention>
|
||||||
) {
|
) {
|
||||||
const conversation = window.ConversationController.get(conversationId);
|
const conversation = window.ConversationController.get(conversationId);
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
|
@ -1205,7 +1203,7 @@ function saveDraft(
|
||||||
conversation.set({
|
conversation.set({
|
||||||
active_at: activeAt,
|
active_at: activeAt,
|
||||||
draft: messageText,
|
draft: messageText,
|
||||||
draftBodyRanges: bodyRanges,
|
draftBodyRanges: mentions,
|
||||||
draftChanged: true,
|
draftChanged: true,
|
||||||
timestamp,
|
timestamp,
|
||||||
});
|
});
|
||||||
|
|
|
@ -55,7 +55,10 @@ import type {
|
||||||
ConversationAttributesType,
|
ConversationAttributesType,
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
} from '../../model-types.d';
|
} from '../../model-types.d';
|
||||||
import type { DraftBodyRangesType } from '../../types/Util';
|
import type {
|
||||||
|
DraftBodyRangeMention,
|
||||||
|
HydratedBodyRangesType,
|
||||||
|
} from '../../types/BodyRange';
|
||||||
import { CallMode } from '../../types/Calling';
|
import { CallMode } from '../../types/Calling';
|
||||||
import type { MediaItemType } from '../../types/MediaItem';
|
import type { MediaItemType } from '../../types/MediaItem';
|
||||||
import type { UUIDStringType } from '../../types/UUID';
|
import type { UUIDStringType } from '../../types/UUID';
|
||||||
|
@ -173,6 +176,7 @@ export type MessageType = MessageAttributesType & {
|
||||||
// eslint-disable-next-line local-rules/type-alias-readonlydeep
|
// eslint-disable-next-line local-rules/type-alias-readonlydeep
|
||||||
export type MessageWithUIFieldsType = MessageAttributesType & {
|
export type MessageWithUIFieldsType = MessageAttributesType & {
|
||||||
displayLimit?: number;
|
displayLimit?: number;
|
||||||
|
isSpoilerExpanded?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ConversationTypes = ['direct', 'group'] as const;
|
export const ConversationTypes = ['direct', 'group'] as const;
|
||||||
|
@ -182,13 +186,20 @@ export type ConversationTypeType = ReadonlyDeep<
|
||||||
|
|
||||||
export type LastMessageType = ReadonlyDeep<
|
export type LastMessageType = ReadonlyDeep<
|
||||||
| {
|
| {
|
||||||
|
deletedForEveryone: false;
|
||||||
|
author?: string;
|
||||||
|
bodyRanges?: HydratedBodyRangesType;
|
||||||
|
prefix?: string;
|
||||||
status?: LastMessageStatus;
|
status?: LastMessageStatus;
|
||||||
text: string;
|
text: string;
|
||||||
author?: string;
|
|
||||||
deletedForEveryone: false;
|
|
||||||
}
|
}
|
||||||
| { deletedForEveryone: true }
|
| { deletedForEveryone: true }
|
||||||
>;
|
>;
|
||||||
|
export type DraftPreviewType = ReadonlyDeep<{
|
||||||
|
text: string;
|
||||||
|
prefix?: string;
|
||||||
|
bodyRanges?: HydratedBodyRangesType;
|
||||||
|
}>;
|
||||||
|
|
||||||
export type ConversationType = ReadonlyDeep<
|
export type ConversationType = ReadonlyDeep<
|
||||||
{
|
{
|
||||||
|
@ -275,9 +286,11 @@ export type ConversationType = ReadonlyDeep<
|
||||||
profileSharing?: boolean;
|
profileSharing?: boolean;
|
||||||
|
|
||||||
shouldShowDraft?: boolean;
|
shouldShowDraft?: boolean;
|
||||||
|
// Full information for re-hydrating composition area
|
||||||
draftText?: string;
|
draftText?: string;
|
||||||
draftBodyRanges?: DraftBodyRangesType;
|
draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>;
|
||||||
draftPreview?: string;
|
// Summary for the left pane
|
||||||
|
draftPreview?: DraftPreviewType;
|
||||||
|
|
||||||
sharedGroupNames: ReadonlyArray<string>;
|
sharedGroupNames: ReadonlyArray<string>;
|
||||||
groupDescription?: string;
|
groupDescription?: string;
|
||||||
|
@ -524,6 +537,7 @@ export const MESSAGE_EXPIRED = 'conversations/MESSAGE_EXPIRED';
|
||||||
export const SET_VOICE_NOTE_PLAYBACK_RATE =
|
export const SET_VOICE_NOTE_PLAYBACK_RATE =
|
||||||
'conversations/SET_VOICE_NOTE_PLAYBACK_RATE';
|
'conversations/SET_VOICE_NOTE_PLAYBACK_RATE';
|
||||||
export const CONVERSATION_UNLOADED = 'CONVERSATION_UNLOADED';
|
export const CONVERSATION_UNLOADED = 'CONVERSATION_UNLOADED';
|
||||||
|
export const SHOW_SPOILER = 'conversations/SHOW_SPOILER';
|
||||||
|
|
||||||
export type CancelVerificationDataByConversationActionType = ReadonlyDeep<{
|
export type CancelVerificationDataByConversationActionType = ReadonlyDeep<{
|
||||||
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
|
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
|
||||||
|
@ -709,6 +723,12 @@ export type MessageExpandedActionType = ReadonlyDeep<{
|
||||||
displayLimit: number;
|
displayLimit: number;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
export type ShowSpoilerActionType = ReadonlyDeep<{
|
||||||
|
type: typeof SHOW_SPOILER;
|
||||||
|
payload: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
// eslint-disable-next-line local-rules/type-alias-readonlydeep -- FIXME
|
// eslint-disable-next-line local-rules/type-alias-readonlydeep -- FIXME
|
||||||
export type MessagesAddedActionType = Readonly<{
|
export type MessagesAddedActionType = Readonly<{
|
||||||
|
@ -939,6 +959,7 @@ export type ConversationActionType =
|
||||||
| ShowChooseGroupMembersActionType
|
| ShowChooseGroupMembersActionType
|
||||||
| ShowInboxActionType
|
| ShowInboxActionType
|
||||||
| ShowSendAnywayDialogActionType
|
| ShowSendAnywayDialogActionType
|
||||||
|
| ShowSpoilerActionType
|
||||||
| StartComposingActionType
|
| StartComposingActionType
|
||||||
| StartSettingGroupMetadataActionType
|
| StartSettingGroupMetadataActionType
|
||||||
| ToggleComposeEditingAvatarActionType
|
| ToggleComposeEditingAvatarActionType
|
||||||
|
@ -1027,6 +1048,7 @@ export const actions = {
|
||||||
saveAttachmentFromMessage,
|
saveAttachmentFromMessage,
|
||||||
saveAvatarToDisk,
|
saveAvatarToDisk,
|
||||||
scrollToMessage,
|
scrollToMessage,
|
||||||
|
showSpoiler,
|
||||||
targetMessage,
|
targetMessage,
|
||||||
setAccessControlAddFromInviteLinkSetting,
|
setAccessControlAddFromInviteLinkSetting,
|
||||||
setAccessControlAttributesSetting,
|
setAccessControlAttributesSetting,
|
||||||
|
@ -2619,6 +2641,15 @@ function messageExpanded(
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
function showSpoiler(id: string): ShowSpoilerActionType {
|
||||||
|
return {
|
||||||
|
type: SHOW_SPOILER,
|
||||||
|
payload: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function messageExpired(id: string): MessageExpiredActionType {
|
function messageExpired(id: string): MessageExpiredActionType {
|
||||||
return {
|
return {
|
||||||
type: MESSAGE_EXPIRED,
|
type: MESSAGE_EXPIRED,
|
||||||
|
@ -2770,11 +2801,14 @@ export type PushPanelForConversationActionType = ReadonlyDeep<
|
||||||
function pushPanelForConversation(
|
function pushPanelForConversation(
|
||||||
panel: PanelRequestType
|
panel: PanelRequestType
|
||||||
): ThunkAction<void, RootStateType, unknown, PushPanelActionType> {
|
): ThunkAction<void, RootStateType, unknown, PushPanelActionType> {
|
||||||
return async dispatch => {
|
return async (dispatch, getState) => {
|
||||||
if (panel.type === PanelType.MessageDetails) {
|
if (panel.type === PanelType.MessageDetails) {
|
||||||
const { messageId } = panel.args;
|
const { messageId } = panel.args;
|
||||||
|
const state = getState();
|
||||||
|
|
||||||
const message = await getMessageById(messageId);
|
const message =
|
||||||
|
state.conversations.messagesLookup[messageId] ||
|
||||||
|
(await getMessageById(messageId))?.attributes;
|
||||||
if (!message) {
|
if (!message) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'pushPanelForConversation: could not find message for MessageDetails'
|
'pushPanelForConversation: could not find message for MessageDetails'
|
||||||
|
@ -2785,7 +2819,7 @@ function pushPanelForConversation(
|
||||||
payload: {
|
payload: {
|
||||||
type: PanelType.MessageDetails,
|
type: PanelType.MessageDetails,
|
||||||
args: {
|
args: {
|
||||||
message: message.attributes,
|
message,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -4758,11 +4792,17 @@ export function reducer(
|
||||||
? 1
|
? 1
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
const updatedMessage = {
|
||||||
|
...data,
|
||||||
|
displayLimit: existingMessage.displayLimit,
|
||||||
|
isSpoilerExpanded: existingMessage.isSpoilerExpanded,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...maybeUpdateSelectedMessageForDetails(
|
...maybeUpdateSelectedMessageForDetails(
|
||||||
{
|
{
|
||||||
messageId: id,
|
messageId: id,
|
||||||
targetedMessageForDetails: data,
|
targetedMessageForDetails: updatedMessage,
|
||||||
},
|
},
|
||||||
state
|
state
|
||||||
),
|
),
|
||||||
|
@ -4776,10 +4816,7 @@ export function reducer(
|
||||||
},
|
},
|
||||||
messagesLookup: {
|
messagesLookup: {
|
||||||
...state.messagesLookup,
|
...state.messagesLookup,
|
||||||
[id]: {
|
[id]: updatedMessage,
|
||||||
...data,
|
|
||||||
displayLimit: existingMessage.displayLimit,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -4799,17 +4836,55 @@ export function reducer(
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const updatedMessage = {
|
||||||
...state,
|
|
||||||
messagesLookup: {
|
|
||||||
...state.messagesLookup,
|
|
||||||
[id]: {
|
|
||||||
...existingMessage,
|
...existingMessage,
|
||||||
displayLimit,
|
displayLimit,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
...maybeUpdateSelectedMessageForDetails(
|
||||||
|
{
|
||||||
|
messageId: id,
|
||||||
|
targetedMessageForDetails: updatedMessage,
|
||||||
},
|
},
|
||||||
|
state
|
||||||
|
),
|
||||||
|
messagesLookup: {
|
||||||
|
...state.messagesLookup,
|
||||||
|
[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') {
|
if (action.type === 'MESSAGES_RESET') {
|
||||||
const {
|
const {
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { isEqual, pick } from 'lodash';
|
||||||
import type { ReadonlyDeep } from 'type-fest';
|
import type { ReadonlyDeep } from 'type-fest';
|
||||||
import * as Errors from '../../types/errors';
|
import * as Errors from '../../types/errors';
|
||||||
import type { AttachmentType } from '../../types/Attachment';
|
import type { AttachmentType } from '../../types/Attachment';
|
||||||
import type { DraftBodyRangesType } from '../../types/Util';
|
import type { DraftBodyRangeMention } from '../../types/BodyRange';
|
||||||
import type { MessageAttributesType } from '../../model-types.d';
|
import type { MessageAttributesType } from '../../model-types.d';
|
||||||
import type {
|
import type {
|
||||||
MessageChangedActionType,
|
MessageChangedActionType,
|
||||||
|
@ -77,6 +77,7 @@ export type StoryDataType = ReadonlyDeep<
|
||||||
startedDownload?: boolean;
|
startedDownload?: boolean;
|
||||||
} & Pick<
|
} & Pick<
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
|
| 'bodyRanges'
|
||||||
| 'canReplyToStory'
|
| 'canReplyToStory'
|
||||||
| 'conversationId'
|
| 'conversationId'
|
||||||
| 'deletedForEveryone'
|
| 'deletedForEveryone'
|
||||||
|
@ -558,7 +559,7 @@ function reactToStory(
|
||||||
function replyToStory(
|
function replyToStory(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
messageBody: string,
|
messageBody: string,
|
||||||
mentions: DraftBodyRangesType,
|
mentions: ReadonlyArray<DraftBodyRangeMention>,
|
||||||
timestamp: number,
|
timestamp: number,
|
||||||
story: StoryViewType
|
story: StoryViewType
|
||||||
): ThunkAction<void, RootStateType, unknown, StoryChangedActionType> {
|
): ThunkAction<void, RootStateType, unknown, StoryChangedActionType> {
|
||||||
|
@ -1442,6 +1443,7 @@ export function reducer(
|
||||||
if (action.type === STORY_CHANGED) {
|
if (action.type === STORY_CHANGED) {
|
||||||
const newStory = pick(action.payload, [
|
const newStory = pick(action.payload, [
|
||||||
'attachment',
|
'attachment',
|
||||||
|
'bodyRanges',
|
||||||
'canReplyToStory',
|
'canReplyToStory',
|
||||||
'conversationId',
|
'conversationId',
|
||||||
'deletedForEveryone',
|
'deletedForEveryone',
|
||||||
|
@ -1468,17 +1470,24 @@ export function reducer(
|
||||||
if (prevStoryIndex >= 0) {
|
if (prevStoryIndex >= 0) {
|
||||||
const prevStory = state.stories[prevStoryIndex];
|
const prevStory = state.stories[prevStoryIndex];
|
||||||
|
|
||||||
// Stories rarely need to change, here are the following exceptions:
|
// Stories rarely need to change, here are the following exceptions...
|
||||||
|
|
||||||
|
// These only change because of initialization order - these fields are updated
|
||||||
|
// after the model is created:
|
||||||
|
const bodyRangesChanged =
|
||||||
|
newStory.bodyRanges?.length !== prevStory.bodyRanges?.length;
|
||||||
|
const hasExpirationChanged =
|
||||||
|
(newStory.expirationStartTimestamp &&
|
||||||
|
!prevStory.expirationStartTimestamp) ||
|
||||||
|
(newStory.expireTimer && !prevStory.expireTimer);
|
||||||
|
|
||||||
|
// These reflect changes in status over time:
|
||||||
const isDownloadingAttachment = isDownloading(newStory.attachment);
|
const isDownloadingAttachment = isDownloading(newStory.attachment);
|
||||||
const hasAttachmentDownloaded =
|
const hasAttachmentDownloaded =
|
||||||
!isDownloaded(prevStory.attachment) &&
|
!isDownloaded(prevStory.attachment) &&
|
||||||
isDownloaded(newStory.attachment);
|
isDownloaded(newStory.attachment);
|
||||||
const hasAttachmentFailed =
|
const hasAttachmentFailed =
|
||||||
hasFailed(newStory.attachment) && !hasFailed(prevStory.attachment);
|
hasFailed(newStory.attachment) && !hasFailed(prevStory.attachment);
|
||||||
const hasExpirationChanged =
|
|
||||||
(newStory.expirationStartTimestamp &&
|
|
||||||
!prevStory.expirationStartTimestamp) ||
|
|
||||||
(newStory.expireTimer && !prevStory.expireTimer);
|
|
||||||
const readStatusChanged = prevStory.readStatus !== newStory.readStatus;
|
const readStatusChanged = prevStory.readStatus !== newStory.readStatus;
|
||||||
const reactionsChanged =
|
const reactionsChanged =
|
||||||
prevStory.reactions?.length !== newStory.reactions?.length;
|
prevStory.reactions?.length !== newStory.reactions?.length;
|
||||||
|
@ -1493,6 +1502,7 @@ export function reducer(
|
||||||
prevStory.hasRepliesFromSelf !== newStory.hasRepliesFromSelf;
|
prevStory.hasRepliesFromSelf !== newStory.hasRepliesFromSelf;
|
||||||
|
|
||||||
const shouldReplace =
|
const shouldReplace =
|
||||||
|
bodyRangesChanged ||
|
||||||
isDownloadingAttachment ||
|
isDownloadingAttachment ||
|
||||||
hasAttachmentDownloaded ||
|
hasAttachmentDownloaded ||
|
||||||
hasAttachmentFailed ||
|
hasAttachmentFailed ||
|
||||||
|
|
|
@ -44,7 +44,12 @@ import type { UUIDStringType } from '../../types/UUID';
|
||||||
|
|
||||||
import type { EmbeddedContactType } from '../../types/EmbeddedContact';
|
import type { EmbeddedContactType } from '../../types/EmbeddedContact';
|
||||||
import { embeddedContactSelector } from '../../types/EmbeddedContact';
|
import { embeddedContactSelector } from '../../types/EmbeddedContact';
|
||||||
import type { AssertProps, HydratedBodyRangesType } from '../../types/Util';
|
import type {
|
||||||
|
HydratedBodyRangeMention,
|
||||||
|
HydratedBodyRangesType,
|
||||||
|
} from '../../types/BodyRange';
|
||||||
|
import { BodyRange } from '../../types/BodyRange';
|
||||||
|
import type { AssertProps } from '../../types/Util';
|
||||||
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
|
||||||
import { getMentionsRegex } from '../../types/Message';
|
import { getMentionsRegex } from '../../types/Message';
|
||||||
import { CallMode } from '../../types/Calling';
|
import { CallMode } from '../../types/Calling';
|
||||||
|
@ -312,7 +317,33 @@ export const processBodyRanges = (
|
||||||
}
|
}
|
||||||
|
|
||||||
return bodyRanges
|
return bodyRanges
|
||||||
.filter(range => range.mentionUuid)
|
.map(range => {
|
||||||
|
const { conversationSelector } = options;
|
||||||
|
|
||||||
|
if (BodyRange.isMention(range)) {
|
||||||
|
const conversation = conversationSelector(range.mentionUuid);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...range,
|
||||||
|
conversationID: conversation.id,
|
||||||
|
replacementText: conversation.title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return range;
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.start - a.start);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractHydratedMentions = (
|
||||||
|
{ bodyRanges }: Pick<MessageWithUIFieldsType, 'bodyRanges'>,
|
||||||
|
options: { conversationSelector: GetConversationByIdType }
|
||||||
|
): ReadonlyArray<HydratedBodyRangeMention> | undefined => {
|
||||||
|
if (!bodyRanges) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bodyRanges
|
||||||
|
.filter(BodyRange.isMention)
|
||||||
.map(range => {
|
.map(range => {
|
||||||
const { conversationSelector } = options;
|
const { conversationSelector } = options;
|
||||||
const conversation = conversationSelector(range.mentionUuid);
|
const conversation = conversationSelector(range.mentionUuid);
|
||||||
|
@ -724,11 +755,12 @@ export const getPropsForMessage = (
|
||||||
isBlocked: conversation.isBlocked || false,
|
isBlocked: conversation.isBlocked || false,
|
||||||
isEditedMessage: Boolean(message.editHistory),
|
isEditedMessage: Boolean(message.editHistory),
|
||||||
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
|
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
|
||||||
isTargeted,
|
|
||||||
isTargetedCounter: isTargeted ? targetedMessageCounter : undefined,
|
|
||||||
isSelected,
|
isSelected,
|
||||||
isSelectMode,
|
isSelectMode,
|
||||||
|
isSpoilerExpanded: message.isSpoilerExpanded,
|
||||||
isSticker: Boolean(sticker),
|
isSticker: Boolean(sticker),
|
||||||
|
isTargeted,
|
||||||
|
isTargetedCounter: isTargeted ? targetedMessageCounter : undefined,
|
||||||
isTapToView: isMessageTapToView,
|
isTapToView: isMessageTapToView,
|
||||||
isTapToViewError:
|
isTapToViewError:
|
||||||
isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid,
|
isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid,
|
||||||
|
|
|
@ -28,9 +28,11 @@ import {
|
||||||
getConversationSelector,
|
getConversationSelector,
|
||||||
} from './conversations';
|
} from './conversations';
|
||||||
|
|
||||||
import type { BodyRangeType, HydratedBodyRangeType } from '../../types/Util';
|
import type { HydratedBodyRangeType } from '../../types/BodyRange';
|
||||||
|
import { BodyRange } from '../../types/BodyRange';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
import { getOwn } from '../../util/getOwn';
|
import { getOwn } from '../../util/getOwn';
|
||||||
|
import { missingCaseError } from '../../util';
|
||||||
|
|
||||||
export const getSearch = (state: StateType): SearchStateType => state.search;
|
export const getSearch = (state: StateType): SearchStateType => state.search;
|
||||||
|
|
||||||
|
@ -185,17 +187,24 @@ export const getCachedSelectorForMessageSearchResult = createSelector(
|
||||||
conversationId: message.conversationId,
|
conversationId: message.conversationId,
|
||||||
sentAt: message.sent_at,
|
sentAt: message.sent_at,
|
||||||
snippet: message.snippet || '',
|
snippet: message.snippet || '',
|
||||||
bodyRanges: bodyRanges.map(
|
bodyRanges: bodyRanges.map((range): HydratedBodyRangeType => {
|
||||||
(bodyRange: BodyRangeType): HydratedBodyRangeType => {
|
// Hydrate user information on mention
|
||||||
const conversation = conversationSelector(bodyRange.mentionUuid);
|
if (BodyRange.isMention(range)) {
|
||||||
|
const conversation = conversationSelector(range.mentionUuid);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...bodyRange,
|
...range,
|
||||||
conversationID: conversation.id,
|
conversationID: conversation.id,
|
||||||
replacementText: conversation.title,
|
replacementText: conversation.title,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
),
|
|
||||||
|
if (BodyRange.isFormatting(range)) {
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw missingCaseError(range);
|
||||||
|
}),
|
||||||
body: message.body || '',
|
body: message.body || '',
|
||||||
|
|
||||||
isSelected: Boolean(
|
isSelected: Boolean(
|
||||||
|
|
|
@ -42,6 +42,7 @@ import {
|
||||||
reduceStorySendStatus,
|
reduceStorySendStatus,
|
||||||
resolveStorySendStatus,
|
resolveStorySendStatus,
|
||||||
} from '../../util/resolveStorySendStatus';
|
} from '../../util/resolveStorySendStatus';
|
||||||
|
import { BodyRange } from '../../types/BodyRange';
|
||||||
|
|
||||||
export const getStoriesState = (state: StateType): StoriesStateType =>
|
export const getStoriesState = (state: StateType): StoriesStateType =>
|
||||||
state.stories;
|
state.stories;
|
||||||
|
@ -183,6 +184,7 @@ export function getStoryView(
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attachment,
|
attachment,
|
||||||
|
bodyRanges,
|
||||||
expirationStartTimestamp,
|
expirationStartTimestamp,
|
||||||
expireTimer,
|
expireTimer,
|
||||||
readAt,
|
readAt,
|
||||||
|
@ -222,6 +224,7 @@ export function getStoryView(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
attachment,
|
attachment,
|
||||||
|
bodyRanges: bodyRanges?.filter(BodyRange.isFormatting),
|
||||||
canReply: canReply(story, ourConversationId, conversationSelector),
|
canReply: canReply(story, ourConversationId, conversationSelector),
|
||||||
isHidden: Boolean(sender.hideStory),
|
isHidden: Boolean(sender.hideStory),
|
||||||
isUnread: story.readStatus === ReadStatus.Unread,
|
isUnread: story.readStatus === ReadStatus.Unread,
|
||||||
|
@ -305,6 +308,7 @@ export const getStoryReplies = createSelector(
|
||||||
author: getAvatarData(conversation),
|
author: getAvatarData(conversation),
|
||||||
...pick(reply, ['body', 'deletedForEveryone', 'id', 'timestamp']),
|
...pick(reply, ['body', 'deletedForEveryone', 'id', 'timestamp']),
|
||||||
bodyRanges: bodyRanges?.map(bodyRange => {
|
bodyRanges: bodyRanges?.map(bodyRange => {
|
||||||
|
if (BodyRange.isMention(bodyRange)) {
|
||||||
const mentionConvo = conversationSelector(bodyRange.mentionUuid);
|
const mentionConvo = conversationSelector(bodyRange.mentionUuid);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -312,6 +316,9 @@ export const getStoryReplies = createSelector(
|
||||||
conversationID: mentionConvo.id,
|
conversationID: mentionConvo.id,
|
||||||
replacementText: mentionConvo.title,
|
replacementText: mentionConvo.title,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return bodyRange;
|
||||||
}),
|
}),
|
||||||
reactionEmoji: reply.storyReaction?.emoji,
|
reactionEmoji: reply.storyReaction?.emoji,
|
||||||
contactNameColor: contactNameColorSelector(
|
contactNameColor: contactNameColorSelector(
|
||||||
|
|
|
@ -33,9 +33,10 @@ import {
|
||||||
import { useGlobalModalActions } from '../ducks/globalModals';
|
import { useGlobalModalActions } from '../ducks/globalModals';
|
||||||
import { useLinkPreviewActions } from '../ducks/linkPreviews';
|
import { useLinkPreviewActions } from '../ducks/linkPreviews';
|
||||||
import { processBodyRanges } from '../selectors/message';
|
import { processBodyRanges } from '../selectors/message';
|
||||||
import { getTextWithMentions } from '../../util/getTextWithMentions';
|
|
||||||
import { SmartCompositionTextArea } from './CompositionTextArea';
|
import { SmartCompositionTextArea } from './CompositionTextArea';
|
||||||
import { useToastActions } from '../ducks/toast';
|
import { useToastActions } from '../ducks/toast';
|
||||||
|
import type { HydratedBodyRangeMention } from '../../types/BodyRange';
|
||||||
|
import { applyRangesForText, BodyRange } from '../../types/BodyRange';
|
||||||
|
|
||||||
function renderMentions(
|
function renderMentions(
|
||||||
message: ForwardMessagePropsType,
|
message: ForwardMessagePropsType,
|
||||||
|
@ -52,7 +53,13 @@ function renderMentions(
|
||||||
});
|
});
|
||||||
|
|
||||||
if (bodyRanges && bodyRanges.length) {
|
if (bodyRanges && bodyRanges.length) {
|
||||||
return getTextWithMentions(bodyRanges, text);
|
return applyRangesForText({
|
||||||
|
mentions: bodyRanges.filter<HydratedBodyRangeMention>(
|
||||||
|
BodyRange.isMention
|
||||||
|
),
|
||||||
|
spoilers: [],
|
||||||
|
text,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
|
|
|
@ -42,6 +42,7 @@ export function SmartMessageDetail(): JSX.Element | null {
|
||||||
doubleCheckMissingQuoteReference,
|
doubleCheckMissingQuoteReference,
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
markAttachmentAsCorrupted,
|
markAttachmentAsCorrupted,
|
||||||
|
messageExpanded,
|
||||||
openGiftBadge,
|
openGiftBadge,
|
||||||
popPanelForConversation,
|
popPanelForConversation,
|
||||||
pushPanelForConversation,
|
pushPanelForConversation,
|
||||||
|
@ -49,6 +50,7 @@ export function SmartMessageDetail(): JSX.Element | null {
|
||||||
showConversation,
|
showConversation,
|
||||||
showExpiredIncomingTapToViewToast,
|
showExpiredIncomingTapToViewToast,
|
||||||
showExpiredOutgoingTapToViewToast,
|
showExpiredOutgoingTapToViewToast,
|
||||||
|
showSpoiler,
|
||||||
startConversation,
|
startConversation,
|
||||||
} = useConversationsActions();
|
} = useConversationsActions();
|
||||||
const { showContactModal, toggleSafetyNumberModal } = useGlobalModalActions();
|
const { showContactModal, toggleSafetyNumberModal } = useGlobalModalActions();
|
||||||
|
@ -87,6 +89,7 @@ export function SmartMessageDetail(): JSX.Element | null {
|
||||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||||
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
|
markAttachmentAsCorrupted={markAttachmentAsCorrupted}
|
||||||
message={message}
|
message={message}
|
||||||
|
messageExpanded={messageExpanded}
|
||||||
openGiftBadge={openGiftBadge}
|
openGiftBadge={openGiftBadge}
|
||||||
pushPanelForConversation={pushPanelForConversation}
|
pushPanelForConversation={pushPanelForConversation}
|
||||||
receivedAt={receivedAt}
|
receivedAt={receivedAt}
|
||||||
|
@ -99,6 +102,7 @@ export function SmartMessageDetail(): JSX.Element | null {
|
||||||
showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast}
|
showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast}
|
||||||
showLightbox={showLightbox}
|
showLightbox={showLightbox}
|
||||||
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
|
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
|
||||||
|
showSpoiler={showSpoiler}
|
||||||
startConversation={startConversation}
|
startConversation={startConversation}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
toggleSafetyNumberModal={toggleSafetyNumberModal}
|
toggleSafetyNumberModal={toggleSafetyNumberModal}
|
||||||
|
|
|
@ -128,6 +128,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
||||||
showConversation,
|
showConversation,
|
||||||
showExpiredIncomingTapToViewToast,
|
showExpiredIncomingTapToViewToast,
|
||||||
showExpiredOutgoingTapToViewToast,
|
showExpiredOutgoingTapToViewToast,
|
||||||
|
showSpoiler,
|
||||||
startConversation,
|
startConversation,
|
||||||
} = useConversationsActions();
|
} = useConversationsActions();
|
||||||
|
|
||||||
|
@ -198,6 +199,7 @@ export function SmartTimelineItem(props: ExternalProps): JSX.Element {
|
||||||
showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast}
|
showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast}
|
||||||
showLightbox={showLightbox}
|
showLightbox={showLightbox}
|
||||||
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
|
showLightboxForViewOnceMedia={showLightboxForViewOnceMedia}
|
||||||
|
showSpoiler={showSpoiler}
|
||||||
startCallingLobby={startCallingLobby}
|
startCallingLobby={startCallingLobby}
|
||||||
startConversation={startConversation}
|
startConversation={startConversation}
|
||||||
toggleForwardMessagesModal={toggleForwardMessagesModal}
|
toggleForwardMessagesModal={toggleForwardMessagesModal}
|
||||||
|
|
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: {
|
attachment: {
|
||||||
contentType: 'image/gif',
|
contentType: 'image/gif',
|
||||||
},
|
},
|
||||||
expectedText: 'GIF',
|
expectedResult: {
|
||||||
expectedEmoji: '🎡',
|
text: 'GIF',
|
||||||
|
emoji: '🎡',
|
||||||
|
bodyRanges: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'photo',
|
title: 'photo',
|
||||||
attachment: {
|
attachment: {
|
||||||
contentType: 'image/png',
|
contentType: 'image/png',
|
||||||
},
|
},
|
||||||
expectedText: 'Photo',
|
expectedResult: {
|
||||||
expectedEmoji: '📷',
|
text: 'Photo',
|
||||||
|
emoji: '📷',
|
||||||
|
bodyRanges: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'video',
|
title: 'video',
|
||||||
attachment: {
|
attachment: {
|
||||||
contentType: 'video/mp4',
|
contentType: 'video/mp4',
|
||||||
},
|
},
|
||||||
expectedText: 'Video',
|
expectedResult: {
|
||||||
expectedEmoji: '🎥',
|
text: 'Video',
|
||||||
|
emoji: '🎥',
|
||||||
|
bodyRanges: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'voice message',
|
title: 'voice message',
|
||||||
|
@ -484,8 +493,11 @@ describe('Message', () => {
|
||||||
contentType: 'audio/ogg',
|
contentType: 'audio/ogg',
|
||||||
flags: Proto.AttachmentPointer.Flags.VOICE_MESSAGE,
|
flags: Proto.AttachmentPointer.Flags.VOICE_MESSAGE,
|
||||||
},
|
},
|
||||||
expectedText: 'Voice Message',
|
expectedResult: {
|
||||||
expectedEmoji: '🎤',
|
text: 'Voice Message',
|
||||||
|
emoji: '🎤',
|
||||||
|
bodyRanges: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'audio message',
|
title: 'audio message',
|
||||||
|
@ -493,28 +505,36 @@ describe('Message', () => {
|
||||||
contentType: 'audio/ogg',
|
contentType: 'audio/ogg',
|
||||||
fileName: 'audio.ogg',
|
fileName: 'audio.ogg',
|
||||||
},
|
},
|
||||||
expectedText: 'Audio Message',
|
expectedResult: {
|
||||||
expectedEmoji: '🔈',
|
text: 'Audio Message',
|
||||||
|
emoji: '🔈',
|
||||||
|
bodyRanges: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'plain text',
|
title: 'plain text',
|
||||||
attachment: {
|
attachment: {
|
||||||
contentType: 'text/plain',
|
contentType: 'text/plain',
|
||||||
},
|
},
|
||||||
expectedText: 'File',
|
expectedResult: {
|
||||||
expectedEmoji: '📎',
|
text: 'File',
|
||||||
|
emoji: '📎',
|
||||||
|
bodyRanges: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'unspecified-type',
|
title: 'unspecified-type',
|
||||||
attachment: {
|
attachment: {
|
||||||
contentType: null,
|
contentType: null,
|
||||||
},
|
},
|
||||||
expectedText: 'File',
|
expectedResult: {
|
||||||
expectedEmoji: '📎',
|
text: 'File',
|
||||||
|
emoji: '📎',
|
||||||
|
bodyRanges: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
attachmentTestCases.forEach(
|
attachmentTestCases.forEach(({ title, attachment, expectedResult }) => {
|
||||||
({ title, attachment, expectedText, expectedEmoji }) => {
|
|
||||||
it(`handles single ${title} attachments`, () => {
|
it(`handles single ${title} attachments`, () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
createMessage({
|
createMessage({
|
||||||
|
@ -522,7 +542,7 @@ describe('Message', () => {
|
||||||
source,
|
source,
|
||||||
attachments: [attachment],
|
attachments: [attachment],
|
||||||
}).getNotificationData(),
|
}).getNotificationData(),
|
||||||
{ text: expectedText, emoji: expectedEmoji }
|
expectedResult
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -538,7 +558,7 @@ describe('Message', () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).getNotificationData(),
|
}).getNotificationData(),
|
||||||
{ text: expectedText, emoji: expectedEmoji }
|
expectedResult
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -550,11 +570,10 @@ describe('Message', () => {
|
||||||
attachments: [attachment],
|
attachments: [attachment],
|
||||||
body: 'hello world',
|
body: 'hello world',
|
||||||
}).getNotificationData(),
|
}).getNotificationData(),
|
||||||
{ text: 'hello world', emoji: expectedEmoji }
|
{ ...expectedResult, text: 'hello world' }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
it('handles a "plain" message', () => {
|
it('handles a "plain" message', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
|
@ -563,7 +582,7 @@ describe('Message', () => {
|
||||||
source,
|
source,
|
||||||
body: 'hello world',
|
body: 'hello world',
|
||||||
}).getNotificationData(),
|
}).getNotificationData(),
|
||||||
{ text: 'hello world' }
|
{ text: 'hello world', bodyRanges: [] }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1527,6 +1527,7 @@ describe('both/state/ducks/conversations', () => {
|
||||||
...getDefaultMessage(messageId),
|
...getDefaultMessage(messageId),
|
||||||
body: 'changed',
|
body: 'changed',
|
||||||
displayLimit: undefined,
|
displayLimit: undefined,
|
||||||
|
isSpoilerExpanded: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('updates message data', () => {
|
it('updates message data', () => {
|
||||||
|
|
|
@ -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 = {
|
const message: ProcessedDataMessage = {
|
||||||
attachments,
|
attachments,
|
||||||
|
// We need to remove all of the extra stuff on these objects so serialize properly
|
||||||
|
bodyRanges: msg.bodyRanges?.map(item => ({ ...item })),
|
||||||
preview,
|
preview,
|
||||||
canReplyToStory: Boolean(msg.allowsReplies),
|
canReplyToStory: Boolean(msg.allowsReplies),
|
||||||
expireTimer: DurationInSeconds.DAY,
|
expireTimer: DurationInSeconds.DAY,
|
||||||
|
|
|
@ -57,7 +57,8 @@ import {
|
||||||
HTTPError,
|
HTTPError,
|
||||||
NoSenderKeyError,
|
NoSenderKeyError,
|
||||||
} from './Errors';
|
} from './Errors';
|
||||||
import type { BodyRangesType, StoryContextType } from '../types/Util';
|
import { BodyRange } from '../types/BodyRange';
|
||||||
|
import type { StoryContextType } from '../types/Util';
|
||||||
import type {
|
import type {
|
||||||
LinkPreviewImage,
|
LinkPreviewImage,
|
||||||
LinkPreviewMetadata,
|
LinkPreviewMetadata,
|
||||||
|
@ -76,6 +77,7 @@ import {
|
||||||
numberToAddressType,
|
numberToAddressType,
|
||||||
} from '../types/EmbeddedContact';
|
} from '../types/EmbeddedContact';
|
||||||
import type { StickerWithHydratedData } from '../types/Stickers';
|
import type { StickerWithHydratedData } from '../types/Stickers';
|
||||||
|
import { missingCaseError } from '../util/missingCaseError';
|
||||||
|
|
||||||
export type SendMetadataType = {
|
export type SendMetadataType = {
|
||||||
[identifier: string]: {
|
[identifier: string]: {
|
||||||
|
@ -192,7 +194,7 @@ export type MessageOptionsType = {
|
||||||
reaction?: ReactionType;
|
reaction?: ReactionType;
|
||||||
deletedForEveryoneTimestamp?: number;
|
deletedForEveryoneTimestamp?: number;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
mentions?: BodyRangesType;
|
mentions?: ReadonlyArray<BodyRange<BodyRange.Mention>>;
|
||||||
groupCallUpdate?: GroupCallUpdateType;
|
groupCallUpdate?: GroupCallUpdateType;
|
||||||
storyContext?: StoryContextType;
|
storyContext?: StoryContextType;
|
||||||
};
|
};
|
||||||
|
@ -205,7 +207,7 @@ export type GroupSendOptionsType = {
|
||||||
groupCallUpdate?: GroupCallUpdateType;
|
groupCallUpdate?: GroupCallUpdateType;
|
||||||
groupV1?: GroupV1InfoType;
|
groupV1?: GroupV1InfoType;
|
||||||
groupV2?: GroupV2InfoType;
|
groupV2?: GroupV2InfoType;
|
||||||
mentions?: BodyRangesType;
|
mentions?: ReadonlyArray<BodyRange<BodyRange.Mention>>;
|
||||||
messageText?: string;
|
messageText?: string;
|
||||||
preview?: ReadonlyArray<LinkPreviewType>;
|
preview?: ReadonlyArray<LinkPreviewType>;
|
||||||
profileKey?: Uint8Array;
|
profileKey?: Uint8Array;
|
||||||
|
@ -256,7 +258,7 @@ class Message {
|
||||||
|
|
||||||
deletedForEveryoneTimestamp?: number;
|
deletedForEveryoneTimestamp?: number;
|
||||||
|
|
||||||
mentions?: BodyRangesType;
|
mentions?: ReadonlyArray<BodyRange<BodyRange.Mention>>;
|
||||||
|
|
||||||
groupCallUpdate?: GroupCallUpdateType;
|
groupCallUpdate?: GroupCallUpdateType;
|
||||||
|
|
||||||
|
@ -480,7 +482,7 @@ class Message {
|
||||||
|
|
||||||
if (this.quote) {
|
if (this.quote) {
|
||||||
const { QuotedAttachment } = Proto.DataMessage.Quote;
|
const { QuotedAttachment } = Proto.DataMessage.Quote;
|
||||||
const { BodyRange, Quote } = Proto.DataMessage;
|
const { BodyRange: ProtoBodyRange, Quote } = Proto.DataMessage;
|
||||||
|
|
||||||
proto.quote = new Quote();
|
proto.quote = new Quote();
|
||||||
const { quote } = proto;
|
const { quote } = proto;
|
||||||
|
@ -510,13 +512,17 @@ class Message {
|
||||||
return quotedAttachment;
|
return quotedAttachment;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const bodyRanges: BodyRangesType = this.quote.bodyRanges || [];
|
const bodyRanges = this.quote.bodyRanges || [];
|
||||||
quote.bodyRanges = bodyRanges.map(range => {
|
quote.bodyRanges = bodyRanges.map(range => {
|
||||||
const bodyRange = new BodyRange();
|
const bodyRange = new ProtoBodyRange();
|
||||||
bodyRange.start = range.start;
|
bodyRange.start = range.start;
|
||||||
bodyRange.length = range.length;
|
bodyRange.length = range.length;
|
||||||
if (range.mentionUuid !== undefined) {
|
if (BodyRange.isMention(range)) {
|
||||||
bodyRange.mentionUuid = range.mentionUuid;
|
bodyRange.mentionUuid = range.mentionUuid;
|
||||||
|
} else if (BodyRange.isFormatting(range)) {
|
||||||
|
bodyRange.style = range.style;
|
||||||
|
} else {
|
||||||
|
throw missingCaseError(range);
|
||||||
}
|
}
|
||||||
return bodyRange;
|
return bodyRange;
|
||||||
});
|
});
|
||||||
|
|
|
@ -175,7 +175,8 @@ export function processQuote(
|
||||||
thumbnail: processAttachment(attachment.thumbnail),
|
thumbnail: processAttachment(attachment.thumbnail),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
bodyRanges: quote.bodyRanges ?? [],
|
// We need to remove all of the extra stuff on these objects so serialize properly
|
||||||
|
bodyRanges: quote.bodyRanges?.map(item => ({ ...item })) ?? [],
|
||||||
type: quote.type || Proto.DataMessage.Quote.Type.NORMAL,
|
type: quote.type || Proto.DataMessage.Quote.Type.NORMAL,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -348,7 +349,8 @@ export function processDataMessage(
|
||||||
isViewOnce: Boolean(message.isViewOnce),
|
isViewOnce: Boolean(message.isViewOnce),
|
||||||
reaction: processReaction(message.reaction),
|
reaction: processReaction(message.reaction),
|
||||||
delete: processDelete(message.delete),
|
delete: processDelete(message.delete),
|
||||||
bodyRanges: message.bodyRanges ?? [],
|
// We need to remove all of the extra stuff on these objects so serialize properly
|
||||||
|
bodyRanges: message.bodyRanges?.map(item => ({ ...item })) ?? [],
|
||||||
groupCallUpdate: dropNull(message.groupCallUpdate),
|
groupCallUpdate: dropNull(message.groupCallUpdate),
|
||||||
storyContext: dropNull(message.storyContext),
|
storyContext: dropNull(message.storyContext),
|
||||||
giftBadge: processGiftBadge(message.giftBadge),
|
giftBadge: processGiftBadge(message.giftBadge),
|
||||||
|
|
559
ts/types/BodyRange.ts
Normal file
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
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { AttachmentType } from './Attachment';
|
import type { AttachmentType } from './Attachment';
|
||||||
import type { HydratedBodyRangesType, LocalizerType } from './Util';
|
import type { HydratedBodyRangesType } from './BodyRange';
|
||||||
|
import type { LocalizerType } from './Util';
|
||||||
import type { ContactNameColorType } from './Colors';
|
import type { ContactNameColorType } from './Colors';
|
||||||
import type { ConversationType } from '../state/ducks/conversations';
|
import type { ConversationType } from '../state/ducks/conversations';
|
||||||
import type { ReadStatus } from '../messages/MessageReadStatus';
|
import type { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
|
@ -71,6 +72,7 @@ export type StorySendStateType = {
|
||||||
|
|
||||||
export type StoryViewType = {
|
export type StoryViewType = {
|
||||||
attachment?: AttachmentType;
|
attachment?: AttachmentType;
|
||||||
|
bodyRanges?: HydratedBodyRangesType;
|
||||||
canReply?: boolean;
|
canReply?: boolean;
|
||||||
isHidden?: boolean;
|
isHidden?: boolean;
|
||||||
isUnread?: boolean;
|
isUnread?: boolean;
|
||||||
|
|
|
@ -4,32 +4,6 @@
|
||||||
import type { IntlShape } from 'react-intl';
|
import type { IntlShape } from 'react-intl';
|
||||||
import type { UUIDStringType } from './UUID';
|
import type { UUIDStringType } from './UUID';
|
||||||
|
|
||||||
// Cold storage of body ranges
|
|
||||||
|
|
||||||
export type BodyRangeType = {
|
|
||||||
start: number;
|
|
||||||
length: number;
|
|
||||||
mentionUuid: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BodyRangesType = ReadonlyArray<BodyRangeType>;
|
|
||||||
|
|
||||||
// Used exclusive in CompositionArea and related conversation_view.tsx calls.
|
|
||||||
|
|
||||||
export type DraftBodyRangeType = BodyRangeType & {
|
|
||||||
replacementText: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DraftBodyRangesType = ReadonlyArray<DraftBodyRangeType>;
|
|
||||||
|
|
||||||
// Fully hydrated body range to be used in UI components.
|
|
||||||
|
|
||||||
export type HydratedBodyRangeType = DraftBodyRangeType & {
|
|
||||||
conversationID: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type HydratedBodyRangesType = ReadonlyArray<HydratedBodyRangeType>;
|
|
||||||
|
|
||||||
export type StoryContextType = {
|
export type StoryContextType = {
|
||||||
authorUuid?: UUIDStringType;
|
authorUuid?: UUIDStringType;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
|
|
@ -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 { deleteForEveryone } from './deleteForEveryone';
|
||||||
import { downloadAttachment } from './downloadAttachment';
|
import { downloadAttachment } from './downloadAttachment';
|
||||||
import { getStringForProfileChange } from './getStringForProfileChange';
|
import { getStringForProfileChange } from './getStringForProfileChange';
|
||||||
import { getTextWithMentions } from './getTextWithMentions';
|
|
||||||
import { getUuidsForE164s } from './getUuidsForE164s';
|
import { getUuidsForE164s } from './getUuidsForE164s';
|
||||||
import { getUserAgent } from './getUserAgent';
|
import { getUserAgent } from './getUserAgent';
|
||||||
import {
|
import {
|
||||||
|
@ -53,7 +52,6 @@ export {
|
||||||
flushMessageCounter,
|
flushMessageCounter,
|
||||||
fromWebSafeBase64,
|
fromWebSafeBase64,
|
||||||
getStringForProfileChange,
|
getStringForProfileChange,
|
||||||
getTextWithMentions,
|
|
||||||
getUserAgent,
|
getUserAgent,
|
||||||
incrementMessageCounter,
|
incrementMessageCounter,
|
||||||
initializeMessageCounter,
|
initializeMessageCounter,
|
||||||
|
|
|
@ -16,7 +16,7 @@ import { isNotNil } from './isNotNil';
|
||||||
import { resetLinkPreview } from '../services/LinkPreview';
|
import { resetLinkPreview } from '../services/LinkPreview';
|
||||||
import { getRecipientsByConversation } from './getRecipientsByConversation';
|
import { getRecipientsByConversation } from './getRecipientsByConversation';
|
||||||
import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage';
|
import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage';
|
||||||
import type { BodyRangesType } from '../types/Util';
|
import type { DraftBodyRangeMention } from '../types/BodyRange';
|
||||||
import type { StickerWithHydratedData } from '../types/Stickers';
|
import type { StickerWithHydratedData } from '../types/Stickers';
|
||||||
import { drop } from './drop';
|
import { drop } from './drop';
|
||||||
import { toLogFormat } from '../types/errors';
|
import { toLogFormat } from '../types/errors';
|
||||||
|
@ -168,7 +168,7 @@ export async function maybeForwardMessages(
|
||||||
attachments: Array<AttachmentType>;
|
attachments: Array<AttachmentType>;
|
||||||
body: string | undefined;
|
body: string | undefined;
|
||||||
contact?: Array<ContactWithHydratedAvatar>;
|
contact?: Array<ContactWithHydratedAvatar>;
|
||||||
mentions?: BodyRangesType;
|
mentions?: Array<DraftBodyRangeMention>;
|
||||||
preview?: Array<LinkPreviewType>;
|
preview?: Array<LinkPreviewType>;
|
||||||
quote?: QuotedMessageType;
|
quote?: QuotedMessageType;
|
||||||
sticker?: StickerWithHydratedData;
|
sticker?: StickerWithHydratedData;
|
||||||
|
|
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"
|
version "0.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
|
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
|
||||||
|
|
||||||
typescript@5.0.2:
|
typescript@4.9.5, typescript@^4.9.5:
|
||||||
version "5.0.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.2.tgz#891e1a90c5189d8506af64b9ef929fca99ba1ee5"
|
|
||||||
integrity sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==
|
|
||||||
|
|
||||||
typescript@^4.9.5:
|
|
||||||
version "4.9.5"
|
version "4.9.5"
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
|
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
|
||||||
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
|
integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
|
||||||
|
|
Loading…
Reference in a new issue