Allow copy/paste of formatting and mentions
This commit is contained in:
parent
320ac044a8
commit
b4caf67bf9
55 changed files with 1003 additions and 446 deletions
|
@ -3355,8 +3355,8 @@
|
|||
"messageformat": "Spell check text entered in message composition box",
|
||||
"description": "Description of the spell check setting"
|
||||
},
|
||||
"icu:textFormattingDescripton": {
|
||||
"messageformat": "Enable text formatting popover when text is selected",
|
||||
"icu:textFormattingDescription": {
|
||||
"messageformat": "Show text formatting popover when text is selected",
|
||||
"description": "Description of the text-formatting popover menu setting"
|
||||
},
|
||||
"spellCheckWillBeEnabled": {
|
||||
|
@ -5542,6 +5542,26 @@
|
|||
"messageformat": "Mark selected text as a spoiler",
|
||||
"description": "Description of command to bold text in composer"
|
||||
},
|
||||
"icu:FormatMenu--guide--bold": {
|
||||
"messageformat": "Bold",
|
||||
"description": "Shown when you hover over the bold button in the popup formatting menu"
|
||||
},
|
||||
"icu:FormatMenu--guide--italic": {
|
||||
"messageformat": "Italic",
|
||||
"description": "Shown when you hover over the bold button in the popup formatting menu"
|
||||
},
|
||||
"icu:FormatMenu--guide--strikethrough": {
|
||||
"messageformat": "Strikethrough",
|
||||
"description": "Shown when you hover over the bold button in the popup formatting menu"
|
||||
},
|
||||
"icu:FormatMenu--guide--monospace": {
|
||||
"messageformat": "Monospace",
|
||||
"description": "Shown when you hover over the bold button in the popup formatting menu"
|
||||
},
|
||||
"icu:FormatMenu--guide--spoiler": {
|
||||
"messageformat": "Spoiler",
|
||||
"description": "Shown when you hover over the bold button in the popup formatting menu"
|
||||
},
|
||||
"Keyboard--scroll-to-top": {
|
||||
"message": "Scroll to top of list",
|
||||
"description": "(deleted 03/29/2023) Shown in the shortcuts guide"
|
||||
|
|
|
@ -93,6 +93,10 @@
|
|||
line-height: 16px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@mixin font-subtitle-bold {
|
||||
@include font-subtitle;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@mixin font-caption {
|
||||
@include font-family;
|
||||
|
|
|
@ -5,6 +5,7 @@ $inter: Inter, 'Helvetica Neue', 'Source Sans Pro', 'Source Han Sans SC',
|
|||
'Source Han Sans CN', 'Hiragino Sans GB', 'Hiragino Kaku Gothic',
|
||||
'Microsoft Yahei UI', Helvetica, Arial, sans-serif;
|
||||
|
||||
// Note: This font-family is checked for in matchMonospace, to support paste scenarios
|
||||
$monospace: 'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', Menlo,
|
||||
Consolas, monospace;
|
||||
|
||||
|
|
|
@ -119,6 +119,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Note: This is referenced in ModalHost to ensure 'external' clicks on it still work
|
||||
&__format-menu {
|
||||
padding-block: 6px;
|
||||
padding-inline: 12px;
|
||||
|
@ -128,13 +129,16 @@
|
|||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
opacity: 0;
|
||||
transition: opacity ease 200ms;
|
||||
|
||||
@include popper-shadow();
|
||||
|
||||
@include light-theme() {
|
||||
background: $color-white;
|
||||
}
|
||||
@include dark-theme() {
|
||||
background: $color-gray-80;
|
||||
background: $color-gray-65;
|
||||
}
|
||||
|
||||
&__item {
|
||||
|
@ -161,6 +165,35 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__popover {
|
||||
@include font-subtitle-bold;
|
||||
padding-block: 5px;
|
||||
padding-inline: 8px;
|
||||
text-align: center;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
@include light-theme {
|
||||
background-color: $color-black;
|
||||
color: $color-gray-05;
|
||||
}
|
||||
@include dark-theme {
|
||||
background-color: $color-gray-65;
|
||||
color: $color-gray-05;
|
||||
}
|
||||
|
||||
&__shortcut {
|
||||
@include font-caption-bold;
|
||||
|
||||
@include light-theme {
|
||||
color: $color-gray-15;
|
||||
}
|
||||
@include dark-theme {
|
||||
color: $color-gray-25;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
|
@ -196,7 +229,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
&--strikethrough {
|
||||
&--strike {
|
||||
@include dark-theme {
|
||||
@include color-svg(
|
||||
'../images/icons/v3/text_format/textformat-strikethrough.svg',
|
||||
|
@ -252,7 +285,7 @@
|
|||
|
||||
&--active {
|
||||
@include dark-theme {
|
||||
background-color: $color-ultramarine;
|
||||
background-color: $color-ultramarine-light;
|
||||
}
|
||||
@include light-theme {
|
||||
background-color: $color-ultramarine;
|
||||
|
@ -263,13 +296,14 @@
|
|||
background-color: $color-ultramarine;
|
||||
}
|
||||
.dark-theme.mouse-mode #{$parent}:hover & {
|
||||
background-color: $color-ultramarine;
|
||||
background-color: $color-ultramarine-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: This is referenced in ModalHost to ensure 'external' clicks on it still work
|
||||
&__suggestions {
|
||||
padding: 0;
|
||||
margin-bottom: 6px;
|
||||
|
@ -435,6 +469,7 @@ button.CompositionInput__link-preview__close-button {
|
|||
}
|
||||
}
|
||||
|
||||
// Note: These are referenced in formatting/matchers.ts, to detect these styles on paste
|
||||
.quill {
|
||||
&--monospace {
|
||||
font-family: $monospace;
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
}
|
||||
|
||||
// Note: only used in the left pane for search results, not in message bubbles
|
||||
// Note: This is referenced in formatting/matchers.ts, to detect these styles on paste
|
||||
&--keywordHighlight {
|
||||
// Boldness of this is handled by <strong> element
|
||||
|
||||
|
@ -26,9 +27,11 @@
|
|||
|
||||
// Note: Spoiler must be last to override any other formatting applied to the section
|
||||
&--spoiler {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
// Prepare for our inner copy target
|
||||
position: relative;
|
||||
|
||||
// Lighten things up a bit
|
||||
opacity: 50%;
|
||||
border-radius: 4px;
|
||||
|
@ -46,6 +49,23 @@
|
|||
}
|
||||
}
|
||||
|
||||
&--spoiler--copy-target {
|
||||
// We don't want this thing to affect the layout of the message
|
||||
position: absolute;
|
||||
top: 0;
|
||||
// We can use left here; this is not visible to the user
|
||||
/* stylelint-disable liberty/use-logical-spec */
|
||||
left: 0;
|
||||
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
|
||||
// Hide text
|
||||
color: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Note: This is referenced in formatting/matchers.ts, to detect these styles on paste
|
||||
&--spoiler--noninteractive {
|
||||
cursor: inherit;
|
||||
box-shadow: none;
|
||||
|
@ -55,6 +75,9 @@
|
|||
&--spoiler-StoryViewer {
|
||||
background-color: $color-white;
|
||||
}
|
||||
&--spoiler-MediaEditor {
|
||||
background-color: $color-gray-15;
|
||||
}
|
||||
|
||||
// The left pane
|
||||
&--spoiler-ConversationList,
|
||||
|
|
|
@ -25,14 +25,16 @@ export default {
|
|||
defaultValue: (props: SmartCompositionTextAreaProps) => (
|
||||
<CompositionTextArea
|
||||
{...props}
|
||||
getPreferredBadge={() => undefined}
|
||||
i18n={i18n}
|
||||
isFormattingEnabled={false}
|
||||
isFormattingSpoilersEnabled={false}
|
||||
isFormattingEnabled
|
||||
isFormattingFlagEnabled
|
||||
isFormattingSpoilersFlagEnabled
|
||||
onPickEmoji={action('onPickEmoji')}
|
||||
onChange={action('onChange')}
|
||||
onTextTooLong={action('onTextTooLong')}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
getPreferredBadge={() => undefined}
|
||||
platform="darwin"
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
|
|
@ -7,12 +7,17 @@ import { Button } from './Button';
|
|||
import { Modal } from './Modal';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
|
||||
import type { HydratedBodyRangesType } from '../types/BodyRange';
|
||||
|
||||
export type Props = {
|
||||
i18n: LocalizerType;
|
||||
onClose: () => void;
|
||||
onSubmit: (text: string) => void;
|
||||
onSubmit: (
|
||||
text: string,
|
||||
bodyRanges: HydratedBodyRangesType | undefined
|
||||
) => void;
|
||||
draftText: string;
|
||||
draftBodyRanges: HydratedBodyRangesType | undefined;
|
||||
theme: ThemeType;
|
||||
RenderCompositionTextArea: (
|
||||
props: SmartCompositionTextAreaProps
|
||||
|
@ -24,10 +29,14 @@ export function AddCaptionModal({
|
|||
onClose,
|
||||
onSubmit,
|
||||
draftText,
|
||||
draftBodyRanges,
|
||||
RenderCompositionTextArea,
|
||||
theme,
|
||||
}: Props): JSX.Element {
|
||||
const [messageText, setMessageText] = React.useState('');
|
||||
const [bodyRanges, setBodyRanges] = React.useState<
|
||||
HydratedBodyRangesType | undefined
|
||||
>();
|
||||
|
||||
const [isScrolledTop, setIsScrolledTop] = React.useState(true);
|
||||
const [isScrolledBottom, setIsScrolledBottom] = React.useState(true);
|
||||
|
@ -51,8 +60,8 @@ export function AddCaptionModal({
|
|||
}, [updateScrollState]);
|
||||
|
||||
const handleSubmit = React.useCallback(() => {
|
||||
onSubmit(messageText);
|
||||
}, [messageText, onSubmit]);
|
||||
onSubmit(messageText, bodyRanges);
|
||||
}, [bodyRanges, messageText, onSubmit]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
|
@ -75,9 +84,13 @@ export function AddCaptionModal({
|
|||
maxLength={1500}
|
||||
whenToShowRemainingCount={1450}
|
||||
placeholder={i18n('icu:AddCaptionModal__placeholder')}
|
||||
onChange={setMessageText}
|
||||
onChange={(updatedMessageText, updatedBodyRanges) => {
|
||||
setMessageText(updatedMessageText);
|
||||
setBodyRanges(updatedBodyRanges);
|
||||
}}
|
||||
scrollerRef={scrollerRef}
|
||||
draftText={draftText}
|
||||
bodyRanges={draftBodyRanges}
|
||||
onSubmit={noop}
|
||||
onScroll={updateScrollState}
|
||||
theme={theme}
|
||||
|
|
|
@ -39,9 +39,13 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
sendCounter: 0,
|
||||
i18n,
|
||||
isDisabled: false,
|
||||
isFormattingSpoilersEnabled:
|
||||
overrideProps.isFormattingSpoilersEnabled === false
|
||||
? overrideProps.isFormattingSpoilersEnabled
|
||||
isFormattingFlagEnabled:
|
||||
overrideProps.isFormattingFlagEnabled === false
|
||||
? overrideProps.isFormattingFlagEnabled
|
||||
: true,
|
||||
isFormattingSpoilersFlagEnabled:
|
||||
overrideProps.isFormattingSpoilersFlagEnabled === false
|
||||
? overrideProps.isFormattingSpoilersFlagEnabled
|
||||
: true,
|
||||
isFormattingEnabled:
|
||||
overrideProps.isFormattingEnabled === false
|
||||
|
@ -50,6 +54,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
messageCompositionId: '456',
|
||||
sendEditedMessage: action('sendEditedMessage'),
|
||||
sendMultiMediaMessage: action('sendMultiMediaMessage'),
|
||||
platform: 'darwin',
|
||||
processAttachments: action('processAttachments'),
|
||||
removeAttachment: action('removeAttachment'),
|
||||
theme: React.useContext(StorybookThemeContext),
|
||||
|
@ -290,12 +295,18 @@ QuoteWithPayment.story = {
|
|||
name: 'Quote with payment',
|
||||
};
|
||||
|
||||
export function NoFormatting(): JSX.Element {
|
||||
export function NoFormattingMenu(): JSX.Element {
|
||||
return <CompositionArea {...useProps({ isFormattingEnabled: false })} />;
|
||||
}
|
||||
|
||||
export function NoSpoilerFormatting(): JSX.Element {
|
||||
export function NoFormattingFlag(): JSX.Element {
|
||||
return <CompositionArea {...useProps({ isFormattingFlagEnabled: false })} />;
|
||||
}
|
||||
|
||||
export function NoSpoilerFormattingFlag(): JSX.Element {
|
||||
return (
|
||||
<CompositionArea {...useProps({ isFormattingSpoilersEnabled: false })} />
|
||||
<CompositionArea
|
||||
{...useProps({ isFormattingSpoilersFlagEnabled: false })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { get } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import type { ReadonlyDeep } from 'type-fest';
|
||||
|
||||
|
@ -99,7 +98,8 @@ export type OwnProps = Readonly<{
|
|||
isDisabled: boolean;
|
||||
isFetchingUUID?: boolean;
|
||||
isFormattingEnabled: boolean;
|
||||
isFormattingSpoilersEnabled: boolean;
|
||||
isFormattingFlagEnabled: boolean;
|
||||
isFormattingSpoilersFlagEnabled: boolean;
|
||||
isGroupV1AndDisabled?: boolean;
|
||||
isMissingMandatoryProfileSharing?: boolean;
|
||||
isSignalConversation?: boolean;
|
||||
|
@ -112,6 +112,7 @@ export type OwnProps = Readonly<{
|
|||
messageRequestsEnabled?: boolean;
|
||||
onClearAttachments(conversationId: string): unknown;
|
||||
onCloseLinkPreview(conversationId: string): unknown;
|
||||
platform: string;
|
||||
showToast: ShowToastAction;
|
||||
processAttachments: (options: {
|
||||
conversationId: string;
|
||||
|
@ -226,6 +227,7 @@ export function CompositionArea({
|
|||
messageCompositionId,
|
||||
showToast,
|
||||
pushPanelForConversation,
|
||||
platform,
|
||||
processAttachments,
|
||||
removeAttachment,
|
||||
sendEditedMessage,
|
||||
|
@ -259,8 +261,9 @@ export function CompositionArea({
|
|||
draftText,
|
||||
getPreferredBadge,
|
||||
getQuotedMessage,
|
||||
isFormattingSpoilersEnabled,
|
||||
isFormattingEnabled,
|
||||
isFormattingFlagEnabled,
|
||||
isFormattingSpoilersFlagEnabled,
|
||||
onEditorStateChange,
|
||||
onTextTooLong,
|
||||
sendCounter,
|
||||
|
@ -616,8 +619,8 @@ export function CompositionArea({
|
|||
const key = KeyboardLayout.lookup(e);
|
||||
// When using the ctrl key, `key` is `'K'`. When using the cmd key, `key` is `'k'`
|
||||
const targetKey = key === 'k' || key === 'K';
|
||||
const commandKey = get(window, 'platform') === 'darwin' && metaKey;
|
||||
const controlKey = get(window, 'platform') !== 'darwin' && ctrlKey;
|
||||
const commandKey = platform === 'darwin' && metaKey;
|
||||
const controlKey = platform !== 'darwin' && ctrlKey;
|
||||
const commandOrCtrl = commandKey || controlKey;
|
||||
|
||||
// cmd/ctrl-shift-k
|
||||
|
@ -632,7 +635,7 @@ export function CompositionArea({
|
|||
return () => {
|
||||
document.removeEventListener('keydown', handler);
|
||||
};
|
||||
}, [setLarge]);
|
||||
}, [platform, setLarge]);
|
||||
|
||||
const handleRecordingBeforeSend = useCallback(() => {
|
||||
emojiButtonRef.current?.close();
|
||||
|
@ -914,8 +917,9 @@ export function CompositionArea({
|
|||
getQuotedMessage={getQuotedMessage}
|
||||
i18n={i18n}
|
||||
inputApi={inputApiRef}
|
||||
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
|
||||
isFormattingEnabled={isFormattingEnabled}
|
||||
isFormattingFlagEnabled={isFormattingFlagEnabled}
|
||||
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
|
||||
large={large}
|
||||
linkPreviewLoading={linkPreviewLoading}
|
||||
linkPreviewResult={linkPreviewResult}
|
||||
|
@ -925,6 +929,7 @@ export function CompositionArea({
|
|||
onPickEmoji={onPickEmoji}
|
||||
onSubmit={handleSubmit}
|
||||
onTextTooLong={onTextTooLong}
|
||||
platform={platform}
|
||||
sendCounter={sendCounter}
|
||||
skinTone={skinTone}
|
||||
sortedGroupMembers={sortedGroupMembers}
|
||||
|
|
|
@ -28,9 +28,13 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
clearQuotedMessage: action('clearQuotedMessage'),
|
||||
getPreferredBadge: () => undefined,
|
||||
getQuotedMessage: action('getQuotedMessage'),
|
||||
isFormattingSpoilersEnabled:
|
||||
overrideProps.isFormattingSpoilersEnabled === false
|
||||
? overrideProps.isFormattingSpoilersEnabled
|
||||
isFormattingFlagEnabled:
|
||||
overrideProps.isFormattingFlagEnabled === false
|
||||
? overrideProps.isFormattingFlagEnabled
|
||||
: true,
|
||||
isFormattingSpoilersFlagEnabled:
|
||||
overrideProps.isFormattingSpoilersFlagEnabled === false
|
||||
? overrideProps.isFormattingSpoilersFlagEnabled
|
||||
: true,
|
||||
isFormattingEnabled:
|
||||
overrideProps.isFormattingEnabled === false
|
||||
|
@ -42,6 +46,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
onPickEmoji: action('onPickEmoji'),
|
||||
onSubmit: action('onSubmit'),
|
||||
onTextTooLong: action('onTextTooLong'),
|
||||
platform: 'darwin',
|
||||
sendCounter: 0,
|
||||
sortedGroupMembers: overrideProps.sortedGroupMembers || [],
|
||||
skinTone: select(
|
||||
|
@ -142,12 +147,18 @@ export function Mentions(): JSX.Element {
|
|||
return <CompositionInput {...props} />;
|
||||
}
|
||||
|
||||
export function NoFormatting(): JSX.Element {
|
||||
export function NoFormattingMenu(): JSX.Element {
|
||||
return <CompositionInput {...useProps({ isFormattingEnabled: false })} />;
|
||||
}
|
||||
|
||||
export function NoSpoilerFormatting(): JSX.Element {
|
||||
export function NoFormattingFlag(): JSX.Element {
|
||||
return <CompositionInput {...useProps({ isFormattingFlagEnabled: false })} />;
|
||||
}
|
||||
|
||||
export function NoSpoilerFormattingFlag(): JSX.Element {
|
||||
return (
|
||||
<CompositionInput {...useProps({ isFormattingSpoilersEnabled: false })} />
|
||||
<CompositionInput
|
||||
{...useProps({ isFormattingSpoilersFlagEnabled: false })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ import type {
|
|||
HydratedBodyRangesType,
|
||||
RangeNode,
|
||||
} from '../types/BodyRange';
|
||||
import { collapseRangeTree, insertRange } from '../types/BodyRange';
|
||||
import { BodyRange, collapseRangeTree, insertRange } from '../types/BodyRange';
|
||||
import type { LocalizerType, ThemeType } from '../types/Util';
|
||||
import type { ConversationType } from '../state/ducks/conversations';
|
||||
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
|
||||
|
@ -31,7 +31,6 @@ import { MentionBlot } from '../quill/mentions/blot';
|
|||
import {
|
||||
matchEmojiImage,
|
||||
matchEmojiBlot,
|
||||
matchReactEmoji,
|
||||
matchEmojiText,
|
||||
} from '../quill/emoji/matchers';
|
||||
import { matchMention } from '../quill/mentions/matchers';
|
||||
|
@ -53,6 +52,14 @@ import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
|||
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
|
||||
import type { DraftEditMessageType } from '../model-types.d';
|
||||
import { usePrevious } from '../hooks/usePrevious';
|
||||
import {
|
||||
matchBold,
|
||||
matchItalic,
|
||||
matchMonospace,
|
||||
matchSpoiler,
|
||||
matchStrikethrough,
|
||||
} from '../quill/formatting/matchers';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
|
||||
Quill.register('formats/emoji', EmojiBlot);
|
||||
Quill.register('formats/mention', MentionBlot);
|
||||
|
@ -91,7 +98,8 @@ export type Props = Readonly<{
|
|||
large?: boolean;
|
||||
inputApi?: React.MutableRefObject<InputApi | undefined>;
|
||||
isFormattingEnabled: boolean;
|
||||
isFormattingSpoilersEnabled: boolean;
|
||||
isFormattingFlagEnabled: boolean;
|
||||
isFormattingSpoilersFlagEnabled: boolean;
|
||||
sendCounter: number;
|
||||
skinTone?: EmojiPickDataType['skinTone'];
|
||||
draftText?: string;
|
||||
|
@ -117,6 +125,7 @@ export type Props = Readonly<{
|
|||
timestamp: number
|
||||
): unknown;
|
||||
onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
|
||||
platform: string;
|
||||
getQuotedMessage?(): unknown;
|
||||
clearQuotedMessage?(): unknown;
|
||||
linkPreviewLoading?: boolean;
|
||||
|
@ -141,7 +150,8 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
i18n,
|
||||
inputApi,
|
||||
isFormattingEnabled,
|
||||
isFormattingSpoilersEnabled,
|
||||
isFormattingFlagEnabled,
|
||||
isFormattingSpoilersFlagEnabled,
|
||||
large,
|
||||
linkPreviewLoading,
|
||||
linkPreviewResult,
|
||||
|
@ -151,6 +161,7 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
onScroll,
|
||||
onSubmit,
|
||||
placeholder,
|
||||
platform,
|
||||
skinTone,
|
||||
sendCounter,
|
||||
sortedGroupMembers,
|
||||
|
@ -220,7 +231,29 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
return { text: '', bodyRanges: [] };
|
||||
}
|
||||
|
||||
return getTextAndRangesFromOps(ops);
|
||||
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
|
||||
|
||||
return {
|
||||
text,
|
||||
bodyRanges: bodyRanges.filter(range => {
|
||||
if (BodyRange.isMention(range)) {
|
||||
return true;
|
||||
}
|
||||
if (BodyRange.isFormatting(range)) {
|
||||
if (!isFormattingFlagEnabled) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
range.style === BodyRange.Style.SPOILER &&
|
||||
!isFormattingSpoilersFlagEnabled
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
throw missingCaseError(range);
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const focus = () => {
|
||||
|
@ -352,32 +385,46 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
isFormattingEnabled,
|
||||
isFormattingEnabled
|
||||
);
|
||||
const previousFormattingSpoilersEnabled = usePrevious(
|
||||
isFormattingSpoilersEnabled,
|
||||
isFormattingSpoilersEnabled
|
||||
const previousFormattingFlagEnabled = usePrevious(
|
||||
isFormattingFlagEnabled,
|
||||
isFormattingFlagEnabled
|
||||
);
|
||||
const previousFormattingSpoilersFlagEnabled = usePrevious(
|
||||
isFormattingSpoilersFlagEnabled,
|
||||
isFormattingSpoilersFlagEnabled
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const formattingChanged =
|
||||
typeof previousFormattingEnabled === 'boolean' &&
|
||||
previousFormattingEnabled !== isFormattingEnabled;
|
||||
const spoilersChanged =
|
||||
typeof previousFormattingSpoilersEnabled === 'boolean' &&
|
||||
previousFormattingSpoilersEnabled !== isFormattingSpoilersEnabled;
|
||||
const flagChanged =
|
||||
typeof previousFormattingFlagEnabled === 'boolean' &&
|
||||
previousFormattingFlagEnabled !== isFormattingFlagEnabled;
|
||||
const spoilersFlagChanged =
|
||||
typeof previousFormattingSpoilersFlagEnabled === 'boolean' &&
|
||||
previousFormattingSpoilersFlagEnabled !== isFormattingSpoilersFlagEnabled;
|
||||
|
||||
const quill = quillRef.current;
|
||||
const changed = formattingChanged || spoilersChanged;
|
||||
const changed = formattingChanged || flagChanged || spoilersFlagChanged;
|
||||
if (quill && changed) {
|
||||
quill.getModule('formattingMenu').updateOptions({
|
||||
isEnabled: isFormattingEnabled,
|
||||
isSpoilersEnabled: isFormattingSpoilersEnabled,
|
||||
isMenuEnabled: isFormattingEnabled,
|
||||
isEnabled: isFormattingFlagEnabled,
|
||||
isSpoilersEnabled: isFormattingSpoilersFlagEnabled,
|
||||
});
|
||||
quill.options.formats = getQuillFormats({
|
||||
isFormattingFlagEnabled,
|
||||
isFormattingSpoilersFlagEnabled,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
isFormattingEnabled,
|
||||
isFormattingSpoilersEnabled,
|
||||
isFormattingFlagEnabled,
|
||||
isFormattingSpoilersFlagEnabled,
|
||||
previousFormattingEnabled,
|
||||
previousFormattingSpoilersEnabled,
|
||||
previousFormattingFlagEnabled,
|
||||
previousFormattingSpoilersFlagEnabled,
|
||||
quillRef,
|
||||
]);
|
||||
|
||||
|
@ -643,7 +690,11 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
matchers: [
|
||||
['IMG', matchEmojiImage],
|
||||
['IMG', matchEmojiBlot],
|
||||
['SPAN', matchReactEmoji],
|
||||
['STRONG', matchBold],
|
||||
['EM', matchItalic],
|
||||
['SPAN', matchMonospace],
|
||||
['S', matchStrikethrough],
|
||||
['SPAN', matchSpoiler],
|
||||
[Node.TEXT_NODE, matchEmojiText],
|
||||
['SPAN', matchMention(memberRepositoryRef)],
|
||||
],
|
||||
|
@ -677,8 +728,10 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
},
|
||||
formattingMenu: {
|
||||
i18n,
|
||||
isEnabled: isFormattingEnabled,
|
||||
isSpoilersEnabled: isFormattingSpoilersEnabled,
|
||||
isMenuEnabled: isFormattingEnabled,
|
||||
isEnabled: isFormattingFlagEnabled,
|
||||
isSpoilersEnabled: isFormattingSpoilersFlagEnabled,
|
||||
platform,
|
||||
setFormattingChooserElement,
|
||||
},
|
||||
mentionCompletion: {
|
||||
|
@ -692,25 +745,10 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
theme,
|
||||
},
|
||||
}}
|
||||
formats={[
|
||||
// For image replacement (local-only)
|
||||
'emoji',
|
||||
// @mentions
|
||||
'mention',
|
||||
...(isFormattingEnabled
|
||||
? [
|
||||
// Custom
|
||||
...(isFormattingSpoilersEnabled
|
||||
? [QuillFormattingStyle.spoiler]
|
||||
: []),
|
||||
QuillFormattingStyle.monospace,
|
||||
// Built-in
|
||||
QuillFormattingStyle.bold,
|
||||
QuillFormattingStyle.italic,
|
||||
QuillFormattingStyle.strike,
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
formats={getQuillFormats({
|
||||
isFormattingFlagEnabled,
|
||||
isFormattingSpoilersFlagEnabled,
|
||||
})}
|
||||
placeholder={placeholder || i18n('icu:sendMessage')}
|
||||
readOnly={disabled}
|
||||
ref={element => {
|
||||
|
@ -838,3 +876,31 @@ export function CompositionInput(props: Props): React.ReactElement {
|
|||
</Manager>
|
||||
);
|
||||
}
|
||||
|
||||
function getQuillFormats({
|
||||
isFormattingFlagEnabled,
|
||||
isFormattingSpoilersFlagEnabled,
|
||||
}: {
|
||||
isFormattingFlagEnabled: boolean;
|
||||
isFormattingSpoilersFlagEnabled: boolean;
|
||||
}): Array<string> {
|
||||
return [
|
||||
// For image replacement (local-only)
|
||||
'emoji',
|
||||
// @mentions
|
||||
'mention',
|
||||
...(isFormattingFlagEnabled
|
||||
? [
|
||||
// Custom
|
||||
...(isFormattingSpoilersFlagEnabled
|
||||
? [QuillFormattingStyle.spoiler]
|
||||
: []),
|
||||
QuillFormattingStyle.monospace,
|
||||
// Built-in
|
||||
QuillFormattingStyle.bold,
|
||||
QuillFormattingStyle.italic,
|
||||
QuillFormattingStyle.strike,
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
|
|
@ -22,7 +22,8 @@ export type CompositionTextAreaProps = {
|
|||
bodyRanges?: HydratedBodyRangesType;
|
||||
i18n: LocalizerType;
|
||||
isFormattingEnabled: boolean;
|
||||
isFormattingSpoilersEnabled: boolean;
|
||||
isFormattingFlagEnabled: boolean;
|
||||
isFormattingSpoilersFlagEnabled: boolean;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
whenToShowRemainingCount?: number;
|
||||
|
@ -41,6 +42,7 @@ export type CompositionTextAreaProps = {
|
|||
timestamp: number
|
||||
) => void;
|
||||
onTextTooLong: () => void;
|
||||
platform: string;
|
||||
getPreferredBadge: PreferredBadgeSelectorType;
|
||||
draftText: string;
|
||||
theme: ThemeType;
|
||||
|
@ -59,7 +61,8 @@ export function CompositionTextArea({
|
|||
getPreferredBadge,
|
||||
i18n,
|
||||
isFormattingEnabled,
|
||||
isFormattingSpoilersEnabled,
|
||||
isFormattingFlagEnabled,
|
||||
isFormattingSpoilersFlagEnabled,
|
||||
maxLength,
|
||||
onChange,
|
||||
onPickEmoji,
|
||||
|
@ -68,6 +71,7 @@ export function CompositionTextArea({
|
|||
onSubmit,
|
||||
onTextTooLong,
|
||||
placeholder,
|
||||
platform,
|
||||
recentEmojis,
|
||||
scrollerRef,
|
||||
skinTone,
|
||||
|
@ -140,7 +144,8 @@ export function CompositionTextArea({
|
|||
getQuotedMessage={noop}
|
||||
i18n={i18n}
|
||||
isFormattingEnabled={isFormattingEnabled}
|
||||
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
|
||||
isFormattingFlagEnabled={isFormattingFlagEnabled}
|
||||
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
|
||||
inputApi={inputApiRef}
|
||||
large
|
||||
moduleClassName="CompositionTextArea__input"
|
||||
|
@ -150,6 +155,7 @@ export function CompositionTextArea({
|
|||
onSubmit={onSubmit}
|
||||
onTextTooLong={onTextTooLong}
|
||||
placeholder={placeholder}
|
||||
platform={platform}
|
||||
scrollerRef={scrollerRef}
|
||||
sendCounter={0}
|
||||
theme={theme}
|
||||
|
|
|
@ -89,7 +89,7 @@ export function EditHistoryMessagesModal({
|
|||
|
||||
// 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>
|
||||
Record<string, Record<number, boolean> | undefined>
|
||||
>({});
|
||||
const [displayLimitById, setDisplayLimitById] = useState<
|
||||
Record<string, number | undefined>
|
||||
|
@ -118,7 +118,7 @@ export function EditHistoryMessagesModal({
|
|||
displayLimit={displayLimitById[syntheticId]}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
isSpoilerExpanded={revealedSpoilersById[syntheticId] || false}
|
||||
isSpoilerExpanded={revealedSpoilersById[syntheticId] || {}}
|
||||
key={messageAttributes.timestamp}
|
||||
kickOffAttachmentDownload={kickOffAttachmentDownload}
|
||||
messageExpanded={(messageId, displayLimit) => {
|
||||
|
@ -130,10 +130,10 @@ export function EditHistoryMessagesModal({
|
|||
}}
|
||||
platform={platform}
|
||||
showLightbox={closeAndShowLightbox}
|
||||
showSpoiler={messageId => {
|
||||
showSpoiler={(messageId, data) => {
|
||||
const update = {
|
||||
...revealedSpoilersById,
|
||||
[messageId]: true,
|
||||
[messageId]: data,
|
||||
};
|
||||
setRevealedSpoilersById(update);
|
||||
}}
|
||||
|
|
|
@ -58,14 +58,16 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
RenderCompositionTextArea: props => (
|
||||
<CompositionTextArea
|
||||
{...props}
|
||||
getPreferredBadge={() => undefined}
|
||||
i18n={i18n}
|
||||
isFormattingSpoilersEnabled
|
||||
isFormattingEnabled
|
||||
isFormattingFlagEnabled
|
||||
isFormattingSpoilersFlagEnabled
|
||||
onPickEmoji={action('onPickEmoji')}
|
||||
skinTone={0}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
onTextTooLong={action('onTextTooLong')}
|
||||
getPreferredBadge={() => undefined}
|
||||
platform="darwin"
|
||||
skinTone={0}
|
||||
/>
|
||||
),
|
||||
showToast: action('showToast'),
|
||||
|
|
|
@ -13,6 +13,8 @@ import type { AvatarColorType } from '../types/Colors';
|
|||
import type { BadgeType } from '../badges/types';
|
||||
import { handleOutsideClick } from '../util/handleOutsideClick';
|
||||
|
||||
const EMPTY_OBJECT = Object.freeze(Object.create(null));
|
||||
|
||||
export type PropsType = {
|
||||
areStoriesEnabled: boolean;
|
||||
avatarPath?: string;
|
||||
|
@ -186,7 +188,7 @@ export function MainHeader({
|
|||
showArchivedConversations();
|
||||
setShowAvatarPopup(false);
|
||||
}}
|
||||
style={{}}
|
||||
style={EMPTY_OBJECT}
|
||||
/>
|
||||
</div>,
|
||||
portalElement
|
||||
|
|
|
@ -63,13 +63,15 @@ export function WithCaption(): JSX.Element {
|
|||
renderCompositionTextArea={props => (
|
||||
<CompositionTextArea
|
||||
{...props}
|
||||
getPreferredBadge={() => undefined}
|
||||
i18n={i18n}
|
||||
isFormattingSpoilersEnabled
|
||||
isFormattingEnabled
|
||||
isFormattingFlagEnabled
|
||||
isFormattingSpoilersFlagEnabled
|
||||
onPickEmoji={action('onPickEmoji')}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
onTextTooLong={action('onTextTooLong')}
|
||||
getPreferredBadge={() => undefined}
|
||||
platform="darwin"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -41,10 +41,11 @@ import {
|
|||
} from '../mediaEditor/util/getTextStyleAttributes';
|
||||
import { AddCaptionModal } from './AddCaptionModal';
|
||||
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
|
||||
import { Emojify } from './conversation/Emojify';
|
||||
import { AddNewLines } from './conversation/AddNewLines';
|
||||
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
|
||||
import { Spinner } from './Spinner';
|
||||
import type { HydratedBodyRangesType } from '../types/BodyRange';
|
||||
import { MessageBody } from './conversation/MessageBody';
|
||||
import { RenderLocation } from './conversation/MessageTextRenderer';
|
||||
import { arrow } from '../util/keyboard';
|
||||
|
||||
export type MediaEditorResultType = Readonly<{
|
||||
|
@ -52,6 +53,7 @@ export type MediaEditorResultType = Readonly<{
|
|||
contentType: MIMEType;
|
||||
blurHash: string;
|
||||
caption?: string;
|
||||
captionBodyRanges?: HydratedBodyRangesType;
|
||||
}>;
|
||||
|
||||
export type PropsType = {
|
||||
|
@ -137,6 +139,9 @@ export function MediaEditor({
|
|||
useState<boolean>(false);
|
||||
|
||||
const [caption, setCaption] = useState('');
|
||||
const [captionBodyRanges, setCaptionBodyRanges] = useState<
|
||||
HydratedBodyRangesType | undefined
|
||||
>();
|
||||
|
||||
const [showAddCaptionModal, setShowAddCaptionModal] = useState(false);
|
||||
|
||||
|
@ -948,11 +953,12 @@ export function MediaEditor({
|
|||
>
|
||||
{caption !== '' ? (
|
||||
<span>
|
||||
<AddNewLines
|
||||
<MessageBody
|
||||
renderLocation={RenderLocation.MediaEditor}
|
||||
bodyRanges={captionBodyRanges}
|
||||
i18n={i18n}
|
||||
isSpoilerExpanded={{}}
|
||||
text={caption}
|
||||
renderNonNewLine={({ key, text }) => (
|
||||
<Emojify key={key} text={text} />
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
|
@ -964,8 +970,10 @@ export function MediaEditor({
|
|||
<AddCaptionModal
|
||||
i18n={i18n}
|
||||
draftText={caption}
|
||||
onSubmit={messageText => {
|
||||
draftBodyRanges={captionBodyRanges}
|
||||
onSubmit={(messageText, bodyRanges) => {
|
||||
setCaption(messageText.trim());
|
||||
setCaptionBodyRanges(bodyRanges);
|
||||
setShowAddCaptionModal(false);
|
||||
}}
|
||||
onClose={() => setShowAddCaptionModal(false)}
|
||||
|
@ -1230,6 +1238,7 @@ export function MediaEditor({
|
|||
contentType: IMAGE_PNG,
|
||||
data,
|
||||
caption: caption !== '' ? caption : undefined,
|
||||
captionBodyRanges,
|
||||
blurHash,
|
||||
});
|
||||
}}
|
||||
|
|
|
@ -133,6 +133,7 @@ export const ModalHost = React.memo(function ModalHostInner({
|
|||
const exemptParent = target.closest(
|
||||
'.TitleBarContainer__title, ' +
|
||||
'.module-composition-input__suggestions, ' +
|
||||
'.module-composition-input__format-menu, ' +
|
||||
'.module-calling__modal-container'
|
||||
);
|
||||
if (exemptParent) {
|
||||
|
|
|
@ -594,7 +594,7 @@ export function Preferences({
|
|||
{isFormattingFlagEnabled && (
|
||||
<Checkbox
|
||||
checked={hasTextFormatting}
|
||||
label={i18n('icu:textFormattingDescripton')}
|
||||
label={i18n('icu:textFormattingDescription')}
|
||||
moduleClassName="Preferences__checkbox"
|
||||
name="textFormatting"
|
||||
onChange={onTextFormattingChange}
|
||||
|
|
|
@ -24,6 +24,7 @@ import { SendStoryModal } from './SendStoryModal';
|
|||
import { MediaEditor } from './MediaEditor';
|
||||
import { TextStoryCreator } from './TextStoryCreator';
|
||||
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
|
||||
import type { DraftBodyRanges } from '../types/BodyRange';
|
||||
|
||||
export type PropsType = {
|
||||
debouncedMaybeGrabLinkPreview: (
|
||||
|
@ -38,7 +39,8 @@ export type PropsType = {
|
|||
onSend: (
|
||||
listIds: Array<UUIDStringType>,
|
||||
conversationIds: Array<string>,
|
||||
attachment: AttachmentType
|
||||
attachment: AttachmentType,
|
||||
bodyRanges: DraftBodyRanges | undefined
|
||||
) => unknown;
|
||||
imageToBlurHash: typeof imageToBlurHash;
|
||||
processAttachment: (
|
||||
|
@ -123,6 +125,7 @@ export function StoryCreator({
|
|||
>();
|
||||
const [isReadyToSend, setIsReadyToSend] = useState(false);
|
||||
const [attachmentUrl, setAttachmentUrl] = useState<string | undefined>();
|
||||
const [bodyRanges, setBodyRanges] = useState<DraftBodyRanges | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
let url: string | undefined;
|
||||
|
@ -192,7 +195,7 @@ export function StoryCreator({
|
|||
onRepliesNReactionsChanged={onRepliesNReactionsChanged}
|
||||
onSelectedStoryList={onSelectedStoryList}
|
||||
onSend={(listIds, groupIds) => {
|
||||
onSend(listIds, groupIds, draftAttachment);
|
||||
onSend(listIds, groupIds, draftAttachment, bodyRanges);
|
||||
setDraftAttachment(undefined);
|
||||
}}
|
||||
onViewersUpdated={onViewersUpdated}
|
||||
|
@ -219,7 +222,13 @@ export function StoryCreator({
|
|||
supportsCaption
|
||||
renderCompositionTextArea={renderCompositionTextArea}
|
||||
imageToBlurHash={imageToBlurHash}
|
||||
onDone={({ contentType, data, blurHash, caption }) => {
|
||||
onDone={({
|
||||
contentType,
|
||||
data,
|
||||
blurHash,
|
||||
caption,
|
||||
captionBodyRanges,
|
||||
}) => {
|
||||
setDraftAttachment({
|
||||
...draftAttachment,
|
||||
contentType,
|
||||
|
@ -228,6 +237,7 @@ export function StoryCreator({
|
|||
blurHash,
|
||||
caption,
|
||||
});
|
||||
setBodyRanges(captionBodyRanges);
|
||||
setIsReadyToSend(true);
|
||||
}}
|
||||
recentStickers={recentStickers}
|
||||
|
|
|
@ -86,7 +86,8 @@ export type PropsType = {
|
|||
hasViewReceiptSetting: boolean;
|
||||
i18n: LocalizerType;
|
||||
isFormattingEnabled: boolean;
|
||||
isFormattingSpoilersEnabled: boolean;
|
||||
isFormattingFlagEnabled: boolean;
|
||||
isFormattingSpoilersFlagEnabled: boolean;
|
||||
isInternalUser?: boolean;
|
||||
isSignalConversation?: boolean;
|
||||
isWindowActive: boolean;
|
||||
|
@ -148,7 +149,8 @@ export function StoryViewer({
|
|||
hasViewReceiptSetting,
|
||||
i18n,
|
||||
isFormattingEnabled,
|
||||
isFormattingSpoilersEnabled,
|
||||
isFormattingFlagEnabled,
|
||||
isFormattingSpoilersFlagEnabled,
|
||||
isInternalUser,
|
||||
isSignalConversation,
|
||||
isWindowActive,
|
||||
|
@ -242,7 +244,9 @@ export function StoryViewer({
|
|||
|
||||
// Caption related hooks
|
||||
const [hasExpandedCaption, setHasExpandedCaption] = useState<boolean>(false);
|
||||
const [isSpoilerExpanded, setIsSpoilerExpanded] = useState<boolean>(false);
|
||||
const [isSpoilerExpanded, setIsSpoilerExpanded] = useState<
|
||||
Record<number, boolean>
|
||||
>({});
|
||||
|
||||
const caption = useMemo(() => {
|
||||
if (!attachment?.caption) {
|
||||
|
@ -259,7 +263,7 @@ export function StoryViewer({
|
|||
// Reset expansion if messageId changes
|
||||
useEffect(() => {
|
||||
setHasExpandedCaption(false);
|
||||
setIsSpoilerExpanded(false);
|
||||
setIsSpoilerExpanded({});
|
||||
}, [messageId]);
|
||||
|
||||
// messageId is set as a dependency so that we can reset the story duration
|
||||
|
@ -343,7 +347,7 @@ export function StoryViewer({
|
|||
setConfirmDeleteStory(undefined);
|
||||
setHasConfirmHideStory(false);
|
||||
setHasExpandedCaption(false);
|
||||
setIsSpoilerExpanded(false);
|
||||
setIsSpoilerExpanded({});
|
||||
setIsShowingContextMenu(false);
|
||||
setPauseStory(false);
|
||||
|
||||
|
@ -692,7 +696,7 @@ export function StoryViewer({
|
|||
bodyRanges={bodyRanges}
|
||||
i18n={i18n}
|
||||
isSpoilerExpanded={isSpoilerExpanded}
|
||||
onExpandSpoiler={() => setIsSpoilerExpanded(true)}
|
||||
onExpandSpoiler={data => setIsSpoilerExpanded(data)}
|
||||
renderLocation={RenderLocation.StoryViewer}
|
||||
text={caption.text}
|
||||
/>
|
||||
|
@ -941,7 +945,8 @@ export function StoryViewer({
|
|||
i18n={i18n}
|
||||
platform={platform}
|
||||
isFormattingEnabled={isFormattingEnabled}
|
||||
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
|
||||
isFormattingFlagEnabled={isFormattingFlagEnabled}
|
||||
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
|
||||
isInternalUser={isInternalUser}
|
||||
group={group}
|
||||
onClose={() => setCurrentViewTarget(null)}
|
||||
|
|
|
@ -91,7 +91,8 @@ export type PropsType = {
|
|||
i18n: LocalizerType;
|
||||
platform: string;
|
||||
isFormattingEnabled: boolean;
|
||||
isFormattingSpoilersEnabled: boolean;
|
||||
isFormattingFlagEnabled: boolean;
|
||||
isFormattingSpoilersFlagEnabled: boolean;
|
||||
isInternalUser?: boolean;
|
||||
onChangeViewTarget: (target: StoryViewTargetType) => unknown;
|
||||
onClose: () => unknown;
|
||||
|
@ -127,7 +128,8 @@ export function StoryViewsNRepliesModal({
|
|||
i18n,
|
||||
platform,
|
||||
isFormattingEnabled,
|
||||
isFormattingSpoilersEnabled,
|
||||
isFormattingFlagEnabled,
|
||||
isFormattingSpoilersFlagEnabled,
|
||||
isInternalUser,
|
||||
onChangeViewTarget,
|
||||
onClose,
|
||||
|
@ -155,7 +157,7 @@ export function StoryViewsNRepliesModal({
|
|||
|
||||
// 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>
|
||||
Record<string, Record<number, boolean> | undefined>
|
||||
>({});
|
||||
const [displayLimitById, setDisplayLimitById] = useState<
|
||||
Record<string, number | undefined>
|
||||
|
@ -239,7 +241,8 @@ export function StoryViewsNRepliesModal({
|
|||
i18n={i18n}
|
||||
inputApi={inputApiRef}
|
||||
isFormattingEnabled={isFormattingEnabled}
|
||||
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
|
||||
isFormattingFlagEnabled={isFormattingFlagEnabled}
|
||||
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
|
||||
moduleClassName="StoryViewsNRepliesModal__input"
|
||||
onCloseLinkPreview={noop}
|
||||
onEditorStateChange={({ messageText }) => {
|
||||
|
@ -259,6 +262,7 @@ export function StoryViewsNRepliesModal({
|
|||
firstName: authorTitle,
|
||||
})
|
||||
}
|
||||
platform={platform}
|
||||
sendCounter={0}
|
||||
sortedGroupMembers={sortedGroupMembers}
|
||||
theme={ThemeType.dark}
|
||||
|
@ -310,7 +314,7 @@ export function StoryViewsNRepliesModal({
|
|||
platform={platform}
|
||||
id={reply.id}
|
||||
isInternalUser={isInternalUser}
|
||||
isSpoilerExpanded={revealedSpoilersById[reply.id] || false}
|
||||
isSpoilerExpanded={revealedSpoilersById[reply.id] || {}}
|
||||
messageExpanded={(messageId, displayLimit) => {
|
||||
const update = {
|
||||
...displayLimitById,
|
||||
|
@ -322,10 +326,10 @@ export function StoryViewsNRepliesModal({
|
|||
shouldCollapseAbove={shouldCollapse(reply, replies[index - 1])}
|
||||
shouldCollapseBelow={shouldCollapse(reply, replies[index + 1])}
|
||||
showContactModal={showContactModal}
|
||||
showSpoiler={messageId => {
|
||||
showSpoiler={(messageId, data) => {
|
||||
const update = {
|
||||
...revealedSpoilersById,
|
||||
[messageId]: true,
|
||||
[messageId]: data,
|
||||
};
|
||||
setRevealedSpoilersById(update);
|
||||
}}
|
||||
|
@ -504,14 +508,14 @@ type ReplyOrReactionMessageProps = {
|
|||
platform: string;
|
||||
id: string;
|
||||
isInternalUser?: boolean;
|
||||
isSpoilerExpanded: boolean;
|
||||
isSpoilerExpanded: Record<number, boolean>;
|
||||
onContextMenu?: (ev: React.MouseEvent) => void;
|
||||
reply: ReplyType;
|
||||
shouldCollapseAbove: boolean;
|
||||
shouldCollapseBelow: boolean;
|
||||
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||
messageExpanded: (messageId: string, displayLimit: number) => void;
|
||||
showSpoiler: (messageId: string) => void;
|
||||
showSpoiler: (messageId: string, data: Record<number, boolean>) => void;
|
||||
};
|
||||
|
||||
function ReplyOrReactionMessage({
|
||||
|
|
|
@ -21,7 +21,7 @@ type EventWrapperPropsType = {
|
|||
// disabled button. This uses native browser events to avoid that.
|
||||
//
|
||||
// See <https://lecstor.com/react-disabled-button-onmouseleave/>.
|
||||
const TooltipEventWrapper = React.forwardRef<
|
||||
export const TooltipEventWrapper = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
EventWrapperPropsType
|
||||
>(function TooltipEvent({ onHoverChanged, children }, ref): JSX.Element {
|
||||
|
|
|
@ -218,7 +218,7 @@ export type PropsData = {
|
|||
isTargetedCounter?: number;
|
||||
isSelected: boolean;
|
||||
isSelectMode: boolean;
|
||||
isSpoilerExpanded?: boolean;
|
||||
isSpoilerExpanded?: Record<number, boolean>;
|
||||
direction: DirectionType;
|
||||
timestamp: number;
|
||||
status?: MessageStatusType;
|
||||
|
@ -324,7 +324,7 @@ export type PropsActions = {
|
|||
pushPanelForConversation: PushPanelForConversationActionType;
|
||||
retryMessageSend: (messageId: string) => unknown;
|
||||
showContactModal: (contactId: string, conversationId?: string) => void;
|
||||
showSpoiler: (messageId: string) => void;
|
||||
showSpoiler: (messageId: string, data: Record<number, boolean>) => void;
|
||||
|
||||
kickOffAttachmentDownload: (options: {
|
||||
attachment: AttachmentType;
|
||||
|
@ -1803,7 +1803,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
displayLimit={displayLimit}
|
||||
i18n={i18n}
|
||||
id={id}
|
||||
isSpoilerExpanded={isSpoilerExpanded || false}
|
||||
isSpoilerExpanded={isSpoilerExpanded || {}}
|
||||
kickOffBodyDownload={() => {
|
||||
if (!textAttachment) {
|
||||
return;
|
||||
|
@ -1816,7 +1816,7 @@ export class Message extends React.PureComponent<Props, State> {
|
|||
messageExpanded={messageExpanded}
|
||||
showConversation={showConversation}
|
||||
renderLocation={RenderLocation.Timeline}
|
||||
onExpandSpoiler={() => showSpoiler(id)}
|
||||
onExpandSpoiler={data => showSpoiler(id, data)}
|
||||
text={contents || ''}
|
||||
textAttachment={textAttachment}
|
||||
/>
|
||||
|
|
|
@ -23,7 +23,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
disableLinks: overrideProps.disableLinks || false,
|
||||
direction: 'incoming',
|
||||
i18n,
|
||||
isSpoilerExpanded: overrideProps.isSpoilerExpanded || false,
|
||||
isSpoilerExpanded: overrideProps.isSpoilerExpanded || {},
|
||||
onExpandSpoiler: overrideProps.onExpandSpoiler || action('onExpandSpoiler'),
|
||||
renderLocation: RenderLocation.Timeline,
|
||||
showConversation:
|
||||
|
@ -216,7 +216,7 @@ ComplexMessageBody.story = {
|
|||
};
|
||||
|
||||
export function FormattingBasic(): JSX.Element {
|
||||
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
|
||||
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState({});
|
||||
|
||||
const props = createProps({
|
||||
bodyRanges: [
|
||||
|
@ -258,7 +258,7 @@ export function FormattingBasic(): JSX.Element {
|
|||
},
|
||||
],
|
||||
isSpoilerExpanded,
|
||||
onExpandSpoiler: () => setIsSpoilerExpanded(true),
|
||||
onExpandSpoiler: data => setIsSpoilerExpanded(data),
|
||||
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!',
|
||||
});
|
||||
|
||||
|
@ -272,7 +272,7 @@ export function FormattingBasic(): JSX.Element {
|
|||
}
|
||||
|
||||
export function FormattingSpoiler(): JSX.Element {
|
||||
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
|
||||
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState({});
|
||||
|
||||
const props = createProps({
|
||||
bodyRanges: [
|
||||
|
@ -312,7 +312,7 @@ export function FormattingSpoiler(): JSX.Element {
|
|||
},
|
||||
],
|
||||
isSpoilerExpanded,
|
||||
onExpandSpoiler: () => setIsSpoilerExpanded(true),
|
||||
onExpandSpoiler: data => setIsSpoilerExpanded(data),
|
||||
text: "This is a very secret https://somewhere.com 💡 thing, \uFFFC and \uFFFC, that you shouldn't be able to read. Stay away!",
|
||||
});
|
||||
|
||||
|
@ -322,9 +322,9 @@ export function FormattingSpoiler(): JSX.Element {
|
|||
<hr />
|
||||
<MessageBody {...props} disableLinks />
|
||||
<hr />
|
||||
<MessageBody {...props} isSpoilerExpanded={false} />
|
||||
<MessageBody {...props} isSpoilerExpanded={{}} />
|
||||
<hr />
|
||||
<MessageBody {...props} disableLinks isSpoilerExpanded={false} />
|
||||
<MessageBody {...props} disableLinks isSpoilerExpanded={{}} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -406,7 +406,7 @@ export function FormattingNesting(): JSX.Element {
|
|||
}
|
||||
|
||||
export function FormattingComplex(): JSX.Element {
|
||||
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
|
||||
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState({});
|
||||
const text =
|
||||
'Computational processes \uFFFC are abstract beings that inhabit computers. ' +
|
||||
'As they evolve, processes manipulate other abstract things called data. ' +
|
||||
|
@ -461,7 +461,7 @@ export function FormattingComplex(): JSX.Element {
|
|||
},
|
||||
],
|
||||
isSpoilerExpanded,
|
||||
onExpandSpoiler: () => setIsSpoilerExpanded(true),
|
||||
onExpandSpoiler: data => setIsSpoilerExpanded(data),
|
||||
text,
|
||||
});
|
||||
|
||||
|
|
|
@ -24,9 +24,9 @@ export type Props = {
|
|||
// If set, interactive elements will be left as plain text: links, mentions, spoilers
|
||||
disableLinks?: boolean;
|
||||
i18n: LocalizerType;
|
||||
isSpoilerExpanded: boolean;
|
||||
isSpoilerExpanded: Record<string, boolean>;
|
||||
kickOffBodyDownload?: () => void;
|
||||
onExpandSpoiler?: () => unknown;
|
||||
onExpandSpoiler?: (data: Record<number, boolean>) => unknown;
|
||||
onIncreaseTextLength?: () => unknown;
|
||||
prefix?: string;
|
||||
renderLocation: RenderLocation;
|
||||
|
|
|
@ -25,7 +25,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
displayLimit: overrideProps.displayLimit,
|
||||
i18n,
|
||||
id: 'some-id',
|
||||
isSpoilerExpanded: overrideProps.isSpoilerExpanded === true,
|
||||
isSpoilerExpanded: overrideProps.isSpoilerExpanded || {},
|
||||
messageExpanded: action('messageExpanded'),
|
||||
onExpandSpoiler: overrideProps.onExpandSpoiler || action('onExpandSpoiler'),
|
||||
renderLocation: RenderLocation.Timeline,
|
||||
|
@ -39,8 +39,8 @@ function MessageBodyReadMoreTest({
|
|||
text: messageBodyText,
|
||||
}: {
|
||||
bodyRanges?: HydratedBodyRangesType;
|
||||
isSpoilerExpanded?: boolean;
|
||||
onExpandSpoiler?: () => void;
|
||||
isSpoilerExpanded?: Record<number, boolean>;
|
||||
onExpandSpoiler?: (data: Record<number, boolean>) => void;
|
||||
text: string;
|
||||
}): JSX.Element {
|
||||
const [displayLimit, setDisplayLimit] = useState<number | undefined>();
|
||||
|
@ -132,7 +132,7 @@ export function LongTextWithFormatting(): JSX.Element {
|
|||
}
|
||||
|
||||
export function LongTextMostlySpoiler(): JSX.Element {
|
||||
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
|
||||
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState({});
|
||||
const bodyRanges = [
|
||||
{
|
||||
start: 7,
|
||||
|
@ -148,7 +148,7 @@ export function LongTextMostlySpoiler(): JSX.Element {
|
|||
bodyRanges={bodyRanges}
|
||||
text={text}
|
||||
isSpoilerExpanded={isSpoilerExpanded}
|
||||
onExpandSpoiler={() => setIsSpoilerExpanded(true)}
|
||||
onExpandSpoiler={data => setIsSpoilerExpanded(data)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@ const defaultMessage: MessageDataPropsType = {
|
|||
isMessageRequestAccepted: true,
|
||||
isSelected: false,
|
||||
isSelectMode: false,
|
||||
isSpoilerExpanded: false,
|
||||
isSpoilerExpanded: {},
|
||||
previews: [],
|
||||
readStatus: ReadStatus.Read,
|
||||
status: 'sent',
|
||||
|
|
|
@ -5,16 +5,18 @@ import React from 'react';
|
|||
import type { ReactElement } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import emojiRegex from 'emoji-regex';
|
||||
import { sortBy } from 'lodash';
|
||||
|
||||
import { linkify, SUPPORTED_PROTOCOLS } from './Linkify';
|
||||
import type {
|
||||
BodyRange,
|
||||
BodyRangesForDisplayType,
|
||||
DisplayNode,
|
||||
HydratedBodyRangeMention,
|
||||
RangeNode,
|
||||
} from '../../types/BodyRange';
|
||||
import {
|
||||
SPOILER_REPLACEMENT,
|
||||
BodyRange,
|
||||
insertRange,
|
||||
collapseRangeTree,
|
||||
groupContiguousSpoilers,
|
||||
|
@ -30,6 +32,7 @@ const EMOJI_REGEXP = emojiRegex();
|
|||
export enum RenderLocation {
|
||||
ConversationList = 'ConversationList',
|
||||
Quote = 'Quote',
|
||||
MediaEditor = 'MediaEditor',
|
||||
SearchResult = 'SearchResult',
|
||||
StoryViewer = 'StoryViewer',
|
||||
Timeline = 'Timeline',
|
||||
|
@ -41,9 +44,9 @@ type Props = {
|
|||
disableLinks: boolean;
|
||||
emojiSizeClass: SizeClassType | undefined;
|
||||
i18n: LocalizerType;
|
||||
isSpoilerExpanded: boolean;
|
||||
isSpoilerExpanded: Record<number, boolean>;
|
||||
messageText: string;
|
||||
onExpandSpoiler?: () => void;
|
||||
onExpandSpoiler?: (data: Record<number, boolean>) => void;
|
||||
onMentionTrigger: (conversationId: string) => void;
|
||||
renderLocation: RenderLocation;
|
||||
// Sometimes we're passed a string with a suffix (like '...'); we won't process that
|
||||
|
@ -63,19 +66,32 @@ export function MessageTextRenderer({
|
|||
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);
|
||||
const finalNodes = React.useMemo(() => {
|
||||
const links = disableLinks ? [] : extractLinks(messageText);
|
||||
|
||||
// We need mentions to come last; they can't have children for proper rendering
|
||||
const sortedRanges = sortBy(bodyRanges, range =>
|
||||
BodyRange.isMention(range) ? 1 : 0
|
||||
);
|
||||
|
||||
// Create range tree, dropping bodyRanges that don't apply. Read More means truncated
|
||||
// strings.
|
||||
const tree = sortedRanges.reduce<ReadonlyArray<RangeNode>>(
|
||||
(acc, range) => {
|
||||
if (range.start < textLength) {
|
||||
return insertRange(range, acc);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
links.map(b => ({ ...b, ranges: [] }))
|
||||
);
|
||||
|
||||
// Turn tree into flat list for proper spoiler rendering
|
||||
const nodes = collapseRangeTree({ tree, text: messageText });
|
||||
|
||||
// Group all contigusous spoilers to create one parent spoiler element in the DOM
|
||||
return groupContiguousSpoilers(nodes);
|
||||
}, [bodyRanges, disableLinks, messageText, textLength]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -114,16 +130,18 @@ function renderNode({
|
|||
emojiSizeClass: SizeClassType | undefined;
|
||||
i18n: LocalizerType;
|
||||
isInvisible: boolean;
|
||||
isSpoilerExpanded: boolean;
|
||||
isSpoilerExpanded: Record<number, boolean>;
|
||||
node: DisplayNode;
|
||||
onExpandSpoiler?: () => void;
|
||||
onExpandSpoiler?: (data: Record<number, boolean>) => 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 isSpoilerHidden = Boolean(
|
||||
node.isSpoiler && !isSpoilerExpanded[node.spoilerIndex || 0]
|
||||
);
|
||||
const content = node.spoilerChildren?.map(spoilerNode =>
|
||||
renderNode({
|
||||
direction,
|
||||
|
@ -174,7 +192,10 @@ function renderNode({
|
|||
if (onExpandSpoiler) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onExpandSpoiler();
|
||||
onExpandSpoiler({
|
||||
...isSpoilerExpanded,
|
||||
[node.spoilerIndex || 0]: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -187,10 +208,19 @@ function renderNode({
|
|||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onExpandSpoiler?.();
|
||||
onExpandSpoiler?.({
|
||||
...isSpoilerExpanded,
|
||||
[node.spoilerIndex || 0]: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="MessageTextRenderer__formatting--spoiler--copy-target"
|
||||
>
|
||||
{SPOILER_REPLACEMENT}
|
||||
</span>
|
||||
<span aria-hidden>{content}</span>
|
||||
</span>
|
||||
);
|
||||
|
|
|
@ -114,7 +114,7 @@ const defaultMessageProps: TimelineMessagesProps = {
|
|||
isMessageRequestAccepted: true,
|
||||
isSelected: false,
|
||||
isSelectMode: false,
|
||||
isSpoilerExpanded: false,
|
||||
isSpoilerExpanded: {},
|
||||
toggleSelectMessage: action('toggleSelectMessage'),
|
||||
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
|
||||
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
|
||||
|
|
|
@ -27,6 +27,8 @@ import { PaymentEventKind } from '../../types/Payment';
|
|||
import { getPaymentEventNotificationText } from '../../messages/helpers';
|
||||
import { RenderLocation } from './MessageTextRenderer';
|
||||
|
||||
const EMPTY_OBJECT = Object.freeze(Object.create(null));
|
||||
|
||||
export type Props = {
|
||||
authorTitle: string;
|
||||
conversationColor: ConversationColorType;
|
||||
|
@ -359,7 +361,7 @@ export function Quote(props: Props): JSX.Element | null {
|
|||
disableLinks
|
||||
disableJumbomoji
|
||||
i18n={i18n}
|
||||
isSpoilerExpanded={false}
|
||||
isSpoilerExpanded={EMPTY_OBJECT}
|
||||
renderLocation={RenderLocation.Quote}
|
||||
text={text}
|
||||
/>
|
||||
|
|
|
@ -65,7 +65,7 @@ function mockMessageTimelineItem(
|
|||
isMessageRequestAccepted: true,
|
||||
isSelected: false,
|
||||
isSelectMode: false,
|
||||
isSpoilerExpanded: false,
|
||||
isSpoilerExpanded: {},
|
||||
previews: [],
|
||||
readStatus: ReadStatus.Read,
|
||||
canRetryDeleteForEveryone: true,
|
||||
|
|
|
@ -300,9 +300,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
|||
isSelectMode: isBoolean(overrideProps.isSelectMode)
|
||||
? overrideProps.isSelectMode
|
||||
: false,
|
||||
isSpoilerExpanded: isBoolean(overrideProps.isSpoilerExpanded)
|
||||
? overrideProps.isSpoilerExpanded
|
||||
: false,
|
||||
isSpoilerExpanded: overrideProps.isSpoilerExpanded || {},
|
||||
isTapToView: overrideProps.isTapToView,
|
||||
isTapToViewError: overrideProps.isTapToViewError,
|
||||
isTapToViewExpired: overrideProps.isTapToViewExpired,
|
||||
|
|
|
@ -21,6 +21,7 @@ import type { BadgeType } from '../../badges/types';
|
|||
import { isSignalConversation } from '../../util/isSignalConversation';
|
||||
import { RenderLocation } from '../conversation/MessageTextRenderer';
|
||||
|
||||
const EMPTY_OBJECT = Object.freeze(Object.create(null));
|
||||
const MESSAGE_STATUS_ICON_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__status-icon`;
|
||||
|
||||
export const MessageStatuses = [
|
||||
|
@ -149,7 +150,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
|||
disableJumbomoji
|
||||
disableLinks
|
||||
i18n={i18n}
|
||||
isSpoilerExpanded={false}
|
||||
isSpoilerExpanded={{}}
|
||||
prefix={draftPreview.prefix}
|
||||
renderLocation={RenderLocation.ConversationList}
|
||||
text={draftPreview.text}
|
||||
|
@ -170,7 +171,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
|
|||
disableJumbomoji
|
||||
disableLinks
|
||||
i18n={i18n}
|
||||
isSpoilerExpanded={false}
|
||||
isSpoilerExpanded={EMPTY_OBJECT}
|
||||
prefix={lastMessage.prefix}
|
||||
renderLocation={RenderLocation.ConversationList}
|
||||
text={lastMessage.text}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
import type { FunctionComponent, ReactNode } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { noop } from 'lodash';
|
||||
|
||||
import { ContactName } from '../conversation/ContactName';
|
||||
|
||||
|
@ -21,6 +22,8 @@ import {
|
|||
RenderLocation,
|
||||
} from '../conversation/MessageTextRenderer';
|
||||
|
||||
const EMPTY_OBJECT = Object.freeze(Object.create(null));
|
||||
|
||||
export type PropsDataType = {
|
||||
isSelected?: boolean;
|
||||
isSearchingInConversation?: boolean;
|
||||
|
@ -166,8 +169,8 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
|
|||
disableLinks
|
||||
emojiSizeClass={undefined}
|
||||
i18n={i18n}
|
||||
isSpoilerExpanded={false}
|
||||
onMentionTrigger={() => null}
|
||||
isSpoilerExpanded={EMPTY_OBJECT}
|
||||
onMentionTrigger={noop}
|
||||
renderLocation={RenderLocation.SearchResult}
|
||||
textLength={cleanedSnippet.length}
|
||||
/>
|
||||
|
|
|
@ -125,6 +125,7 @@ export async function sendStory(
|
|||
}
|
||||
|
||||
const attachments = originalMessage.get('attachments') || [];
|
||||
const bodyRanges = originalMessage.get('bodyRanges')?.slice();
|
||||
const [attachment] = attachments;
|
||||
|
||||
if (!attachment) {
|
||||
|
@ -180,6 +181,7 @@ export async function sendStory(
|
|||
// attributes inside it.
|
||||
originalStoryMessage = await messaging.getStoryMessage({
|
||||
allowsReplies: true,
|
||||
bodyRanges,
|
||||
fileAttachment,
|
||||
groupV2,
|
||||
textAttachment,
|
||||
|
@ -317,6 +319,7 @@ export async function sendStory(
|
|||
);
|
||||
|
||||
const storyMessage = new Proto.StoryMessage();
|
||||
storyMessage.bodyRanges = originalStoryMessage.bodyRanges;
|
||||
storyMessage.profileKey = originalStoryMessage.profileKey;
|
||||
storyMessage.fileAttachment = originalStoryMessage.fileAttachment;
|
||||
storyMessage.textAttachment = originalStoryMessage.textAttachment;
|
||||
|
|
|
@ -4164,6 +4164,7 @@ export class ConversationModel extends window.Backbone
|
|||
draftTimestamp: null,
|
||||
quotedMessageId: undefined,
|
||||
lastMessageAuthor: message.getAuthorText(),
|
||||
lastMessageBodyRanges: message.get('bodyRanges'),
|
||||
lastMessage: message.getNotificationText(),
|
||||
lastMessageStatus: 'sending' as const,
|
||||
};
|
||||
|
|
|
@ -204,6 +204,15 @@ export class EmojiCompletion {
|
|||
return PASS_THROUGH;
|
||||
}
|
||||
|
||||
getAttributesForInsert(index: number): Record<string, unknown> {
|
||||
const character = index > 0 ? index - 1 : 0;
|
||||
const contents = this.quill.getContents(character, 1);
|
||||
return contents.ops.reduce(
|
||||
(acc, op) => ({ acc, ...op.attributes }),
|
||||
{} as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
completeEmoji(): void {
|
||||
const range = this.quill.getSelection();
|
||||
|
||||
|
@ -241,7 +250,9 @@ export class EmojiCompletion {
|
|||
const delta = new Delta().retain(index).delete(range).insert({ emoji });
|
||||
|
||||
if (withTrailingSpace) {
|
||||
this.quill.updateContents(delta.insert(' '), 'user');
|
||||
// The extra space we add won't be formatted unless we manually provide attributes
|
||||
const attributes = this.getAttributesForInsert(range - 1);
|
||||
this.quill.updateContents(delta.insert(' ', attributes), 'user');
|
||||
this.quill.setSelection(index + 2, 0, 'user');
|
||||
} else {
|
||||
this.quill.updateContents(delta, 'user');
|
||||
|
|
|
@ -4,12 +4,16 @@
|
|||
import Delta from 'quill-delta';
|
||||
import { insertEmojiOps } from '../util';
|
||||
|
||||
export const matchEmojiImage = (node: Element): Delta => {
|
||||
if (node.classList.contains('emoji')) {
|
||||
const emoji = node.getAttribute('title');
|
||||
export const matchEmojiImage = (node: Element, delta: Delta): Delta => {
|
||||
if (
|
||||
(node.classList.contains('emoji') ||
|
||||
node.classList.contains('module-emoji__image--16px')) &&
|
||||
!node.classList.contains('emoji--invisible')
|
||||
) {
|
||||
const emoji = node.getAttribute('aria-label');
|
||||
return new Delta().insert({ emoji });
|
||||
}
|
||||
return new Delta();
|
||||
return delta;
|
||||
};
|
||||
|
||||
export const matchEmojiBlot = (node: HTMLElement, delta: Delta): Delta => {
|
||||
|
@ -20,14 +24,6 @@ export const matchEmojiBlot = (node: HTMLElement, delta: Delta): Delta => {
|
|||
return delta;
|
||||
};
|
||||
|
||||
export const matchReactEmoji = (node: HTMLElement, delta: Delta): Delta => {
|
||||
if (node.classList.contains('module-emoji')) {
|
||||
const emoji = node.innerText.trim();
|
||||
return new Delta().insert({ emoji });
|
||||
}
|
||||
return delta;
|
||||
};
|
||||
|
||||
export const matchEmojiText = (node: Text): Delta => {
|
||||
const nodeAsInsert = { insert: node.data };
|
||||
|
||||
|
|
80
ts/quill/formatting/matchers.ts
Normal file
80
ts/quill/formatting/matchers.ts
Normal file
|
@ -0,0 +1,80 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import Delta from 'quill-delta';
|
||||
import { QuillFormattingStyle } from './menu';
|
||||
|
||||
function applyStyleToOps(delta: Delta, style: QuillFormattingStyle): Delta {
|
||||
return new Delta(
|
||||
delta.map(op => ({
|
||||
...op,
|
||||
attributes: {
|
||||
...op.attributes,
|
||||
[style]: true,
|
||||
},
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export const matchBold = (_node: HTMLElement, delta: Delta): Delta => {
|
||||
if (delta.length() > 0) {
|
||||
return applyStyleToOps(delta, QuillFormattingStyle.bold);
|
||||
}
|
||||
|
||||
return delta;
|
||||
};
|
||||
|
||||
export const matchItalic = (_node: HTMLElement, delta: Delta): Delta => {
|
||||
if (delta.length() > 0) {
|
||||
return applyStyleToOps(delta, QuillFormattingStyle.italic);
|
||||
}
|
||||
|
||||
return delta;
|
||||
};
|
||||
|
||||
export const matchStrikethrough = (_node: HTMLElement, delta: Delta): Delta => {
|
||||
if (delta.length() > 0) {
|
||||
return applyStyleToOps(delta, QuillFormattingStyle.strike);
|
||||
}
|
||||
|
||||
return delta;
|
||||
};
|
||||
|
||||
export const matchMonospace = (node: HTMLElement, delta: Delta): Delta => {
|
||||
const classes = [
|
||||
'MessageTextRenderer__formatting--monospace',
|
||||
'quill--monospace',
|
||||
];
|
||||
// Note: This is defined as $monospace in _variables.scss
|
||||
const fontFamily =
|
||||
'font-family: "SF Mono", SFMono-Regular, ui-monospace, "DejaVu Sans Mono", Menlo, Consolas, monospace;';
|
||||
|
||||
if (
|
||||
delta.length() > 0 &&
|
||||
(node.classList.contains(classes[0]) ||
|
||||
node.classList.contains(classes[1]) ||
|
||||
node.attributes.getNamedItem('style')?.value?.includes(fontFamily))
|
||||
) {
|
||||
return applyStyleToOps(delta, QuillFormattingStyle.monospace);
|
||||
}
|
||||
|
||||
return delta;
|
||||
};
|
||||
|
||||
export const matchSpoiler = (node: HTMLElement, delta: Delta): Delta => {
|
||||
const classes = [
|
||||
'quill--spoiler',
|
||||
'MessageTextRenderer__formatting--spoiler--revealed',
|
||||
// Note: we don't match on hidden spoilers in message body; we use copy-target text
|
||||
];
|
||||
|
||||
if (
|
||||
delta.length() > 0 &&
|
||||
(node.classList.contains(classes[0]) ||
|
||||
node.classList.contains(classes[1]) ||
|
||||
node.classList.contains(classes[2]))
|
||||
) {
|
||||
return applyStyleToOps(delta, QuillFormattingStyle.spoiler);
|
||||
}
|
||||
return delta;
|
||||
};
|
|
@ -2,21 +2,36 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type Quill from 'quill';
|
||||
import type { KeyboardContext } from 'quill';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Popper } from 'react-popper';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { VirtualElement } from '@popperjs/core';
|
||||
import { pick } from 'lodash';
|
||||
|
||||
import * as log from '../../logging/log';
|
||||
import * as Errors from '../../types/errors';
|
||||
import type { LocalizerType } from '../../types/Util';
|
||||
import { handleOutsideClick } from '../../util/handleOutsideClick';
|
||||
import { SECOND } from '../../util/durations/constants';
|
||||
|
||||
const BUTTON_HOVER_TIMEOUT = 2 * SECOND;
|
||||
|
||||
// Note: Keyboard shortcuts are defined in the constructor below, and when using
|
||||
// <FormattingButton /> below. They're also referenced in ShortcutGuide.tsx.
|
||||
const BOLD_CHAR = 'B';
|
||||
const ITALIC_CHAR = 'I';
|
||||
const MONOSPACE_CHAR = 'E';
|
||||
const SPOILER_CHAR = 'B';
|
||||
const STRIKETHROUGH_CHAR = 'X';
|
||||
|
||||
type FormattingPickerOptions = {
|
||||
i18n: LocalizerType;
|
||||
isMenuEnabled: boolean;
|
||||
isEnabled: boolean;
|
||||
isSpoilersEnabled: boolean;
|
||||
platform: string;
|
||||
setFormattingChooserElement: (element: JSX.Element | null) => void;
|
||||
};
|
||||
|
||||
|
@ -28,9 +43,50 @@ export enum QuillFormattingStyle {
|
|||
spoiler = 'spoiler',
|
||||
}
|
||||
|
||||
export class FormattingMenu {
|
||||
lastSelection: { start: number; end: number } | undefined;
|
||||
function findMaximumRect(rects: DOMRectList):
|
||||
| {
|
||||
x: number;
|
||||
y: number;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
| undefined {
|
||||
const first = rects[0];
|
||||
if (!first) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let result = pick(first, ['top', 'left', 'right', 'bottom']);
|
||||
|
||||
for (let i = 1, max = rects.length; i < max; i += 1) {
|
||||
const rect = rects[i];
|
||||
|
||||
result = {
|
||||
top: Math.min(rect.top, result.top),
|
||||
left: Math.min(rect.left, result.left),
|
||||
bottom: Math.max(rect.bottom, result.bottom),
|
||||
right: Math.max(rect.right, result.right),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
x: result.left,
|
||||
y: result.top,
|
||||
height: result.bottom - result.top,
|
||||
width: result.right - result.left,
|
||||
};
|
||||
}
|
||||
|
||||
function getMetaKey(platform: string, i18n: LocalizerType) {
|
||||
const isMacOS = platform === 'darwin';
|
||||
|
||||
if (isMacOS) {
|
||||
return '⌘';
|
||||
}
|
||||
return i18n('icu:Keyboard--Key--ctrl');
|
||||
}
|
||||
|
||||
export class FormattingMenu {
|
||||
options: FormattingPickerOptions;
|
||||
|
||||
outsideClickDestructor?: () => void;
|
||||
|
@ -51,19 +107,21 @@ export class FormattingMenu {
|
|||
// We override these keybindings, which means that we need to move their priority
|
||||
// above the built-in shortcuts, which don't exactly do what we want.
|
||||
|
||||
const boldChar = 'B';
|
||||
const boldCharCode = boldChar.charCodeAt(0);
|
||||
this.quill.keyboard.addBinding({ key: boldChar, shortKey: true }, () =>
|
||||
this.toggleForStyle(QuillFormattingStyle.bold)
|
||||
const boldCharCode = BOLD_CHAR.charCodeAt(0);
|
||||
this.quill.keyboard.addBinding(
|
||||
{ key: BOLD_CHAR, shortKey: true },
|
||||
(_range, context) =>
|
||||
this.toggleForStyle(QuillFormattingStyle.bold, context)
|
||||
);
|
||||
quill.keyboard.bindings[boldCharCode].unshift(
|
||||
quill.keyboard.bindings[boldCharCode].pop()
|
||||
);
|
||||
|
||||
const italicChar = 'I';
|
||||
const italicCharCode = italicChar.charCodeAt(0);
|
||||
this.quill.keyboard.addBinding({ key: italicChar, shortKey: true }, () =>
|
||||
this.toggleForStyle(QuillFormattingStyle.italic)
|
||||
const italicCharCode = ITALIC_CHAR.charCodeAt(0);
|
||||
this.quill.keyboard.addBinding(
|
||||
{ key: ITALIC_CHAR, shortKey: true },
|
||||
(_range, context) =>
|
||||
this.toggleForStyle(QuillFormattingStyle.italic, context)
|
||||
);
|
||||
quill.keyboard.bindings[italicCharCode].unshift(
|
||||
quill.keyboard.bindings[italicCharCode].pop()
|
||||
|
@ -71,16 +129,20 @@ export class FormattingMenu {
|
|||
|
||||
// No need for changing priority for these new keybindings
|
||||
|
||||
this.quill.keyboard.addBinding({ key: 'E', shortKey: true }, () =>
|
||||
this.toggleForStyle(QuillFormattingStyle.monospace)
|
||||
this.quill.keyboard.addBinding(
|
||||
{ key: MONOSPACE_CHAR, shortKey: true },
|
||||
(_range, context) =>
|
||||
this.toggleForStyle(QuillFormattingStyle.monospace, context)
|
||||
);
|
||||
this.quill.keyboard.addBinding(
|
||||
{ key: 'X', shortKey: true, shiftKey: true },
|
||||
() => this.toggleForStyle(QuillFormattingStyle.strike)
|
||||
{ key: STRIKETHROUGH_CHAR, shortKey: true, shiftKey: true },
|
||||
(_range, context) =>
|
||||
this.toggleForStyle(QuillFormattingStyle.strike, context)
|
||||
);
|
||||
this.quill.keyboard.addBinding(
|
||||
{ key: 'B', shortKey: true, shiftKey: true },
|
||||
() => this.toggleForStyle(QuillFormattingStyle.spoiler)
|
||||
{ key: SPOILER_CHAR, shortKey: true, shiftKey: true },
|
||||
(_range, context) =>
|
||||
this.toggleForStyle(QuillFormattingStyle.spoiler, context)
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -94,8 +156,7 @@ export class FormattingMenu {
|
|||
}
|
||||
|
||||
onEditorChange(): void {
|
||||
if (!this.options.isEnabled) {
|
||||
this.lastSelection = undefined;
|
||||
if (!this.options.isMenuEnabled) {
|
||||
this.referenceElement = undefined;
|
||||
this.render();
|
||||
|
||||
|
@ -104,38 +165,19 @@ export class FormattingMenu {
|
|||
|
||||
const isFocused = this.quill.hasFocus();
|
||||
if (!isFocused) {
|
||||
this.lastSelection = undefined;
|
||||
this.referenceElement = undefined;
|
||||
this.render();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSelection = this.lastSelection;
|
||||
const quillSelection = this.quill.getSelection();
|
||||
this.lastSelection =
|
||||
quillSelection && quillSelection.length > 0
|
||||
? {
|
||||
start: quillSelection.index,
|
||||
end: quillSelection.index + quillSelection.length,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
if (!this.lastSelection) {
|
||||
if (!quillSelection || quillSelection.length === 0) {
|
||||
this.referenceElement = undefined;
|
||||
} else {
|
||||
const noOverlapWithNewSelection =
|
||||
previousSelection &&
|
||||
(this.lastSelection.end < previousSelection.start ||
|
||||
this.lastSelection.start > previousSelection.end);
|
||||
const newSelectionStartsEarlier =
|
||||
previousSelection && this.lastSelection.start < previousSelection.start;
|
||||
|
||||
if (noOverlapWithNewSelection || newSelectionStartsEarlier) {
|
||||
this.referenceElement = undefined;
|
||||
}
|
||||
// a virtual reference to the text we are trying to format
|
||||
this.referenceElement = this.referenceElement || {
|
||||
this.referenceElement = {
|
||||
getBoundingClientRect() {
|
||||
const selection = window.getSelection();
|
||||
|
||||
|
@ -148,26 +190,37 @@ export class FormattingMenu {
|
|||
const editorElement = activeElement?.closest(
|
||||
'.module-composition-input__input'
|
||||
);
|
||||
const editorRect = editorElement?.getClientRects()[0];
|
||||
if (!editorRect) {
|
||||
log.warn('No editor rect when showing formatting menu');
|
||||
return new DOMRect();
|
||||
}
|
||||
|
||||
const rect = range.getClientRects()[0];
|
||||
const rect = findMaximumRect(range.getClientRects());
|
||||
if (!rect) {
|
||||
log.warn('No maximum rect when showing formatting menu');
|
||||
return new DOMRect();
|
||||
}
|
||||
|
||||
// If we've scrolled down and the top of the composer text is invisible, above
|
||||
// where the editor ends, we fix the popover so it stays connected to the
|
||||
// visible editor. Important for the 'Cmd-A' scenario when scrolled down.
|
||||
const updatedY = Math.max(
|
||||
(editorElement?.getClientRects()[0]?.y || 0) - 10,
|
||||
(rect?.y || 0) - 10
|
||||
(editorRect.y || 0) - 10,
|
||||
(rect.y || 0) - 10
|
||||
);
|
||||
const updatedHeight = rect.height + (rect.y - updatedY);
|
||||
|
||||
return DOMRect.fromRect({
|
||||
x: rect.x,
|
||||
y: updatedY,
|
||||
height: rect.height,
|
||||
height: updatedHeight,
|
||||
width: rect.width,
|
||||
});
|
||||
}
|
||||
log.warn('No selection range when formatting text');
|
||||
return new DOMRect(); // don't crash just because we couldn't get a rectangle
|
||||
|
||||
log.warn('No selection range when showing formatting menu');
|
||||
return new DOMRect();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -184,9 +237,21 @@ export class FormattingMenu {
|
|||
return contents.ops.every(op => op.attributes?.[style]);
|
||||
}
|
||||
|
||||
toggleForStyle(style: QuillFormattingStyle): void {
|
||||
toggleForStyle(style: QuillFormattingStyle, context?: KeyboardContext): void {
|
||||
if (!this.options.isEnabled) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!this.options.isSpoilersEnabled &&
|
||||
style === QuillFormattingStyle.spoiler
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const isEnabled = this.isStyleEnabledInSelection(style);
|
||||
const isEnabled = context
|
||||
? Boolean(context.format[style])
|
||||
: this.isStyleEnabledInSelection(style);
|
||||
if (isEnabled === undefined) {
|
||||
return;
|
||||
}
|
||||
|
@ -197,7 +262,7 @@ export class FormattingMenu {
|
|||
}
|
||||
|
||||
render(): void {
|
||||
if (!this.lastSelection) {
|
||||
if (!this.referenceElement) {
|
||||
this.outsideClickDestructor?.();
|
||||
this.outsideClickDestructor = undefined;
|
||||
|
||||
|
@ -206,123 +271,103 @@ export class FormattingMenu {
|
|||
return;
|
||||
}
|
||||
|
||||
const { i18n, isSpoilersEnabled } = this.options;
|
||||
const { i18n, isSpoilersEnabled, platform } = this.options;
|
||||
const metaKey = getMetaKey(platform, i18n);
|
||||
const shiftKey = i18n('icu:Keyboard--Key--shift');
|
||||
|
||||
// showing the popup format menu
|
||||
const isStyleEnabledInSelection = this.isStyleEnabledInSelection.bind(this);
|
||||
const toggleForStyle = this.toggleForStyle.bind(this);
|
||||
const element = createPortal(
|
||||
<Popper placement="top-start" referenceElement={this.referenceElement}>
|
||||
{({ ref, style }) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className="module-composition-input__format-menu"
|
||||
style={style}
|
||||
role="menu"
|
||||
tabIndex={0}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="module-composition-input__format-menu__item"
|
||||
aria-label={i18n('icu:Keyboard--composer--bold')}
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.toggleForStyle(QuillFormattingStyle.bold);
|
||||
}}
|
||||
<Popper
|
||||
placement="top"
|
||||
referenceElement={this.referenceElement}
|
||||
modifiers={[
|
||||
{
|
||||
name: 'fadeIn',
|
||||
enabled: true,
|
||||
phase: 'write',
|
||||
fn({ state }) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
state.elements.popper.style.opacity = '1';
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{({ ref, style }) => {
|
||||
const [hasLongHovered, setHasLongHovered] =
|
||||
React.useState<boolean>(false);
|
||||
const onLongHover = React.useCallback(
|
||||
(value: boolean) => {
|
||||
setHasLongHovered(value);
|
||||
},
|
||||
[setHasLongHovered]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="module-composition-input__format-menu"
|
||||
style={style}
|
||||
role="menu"
|
||||
tabIndex={0}
|
||||
onMouseLeave={() => setHasLongHovered(false)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-composition-input__format-menu__item__icon',
|
||||
'module-composition-input__format-menu__item__icon--bold',
|
||||
this.isStyleEnabledInSelection(QuillFormattingStyle.bold)
|
||||
? 'module-composition-input__format-menu__item__icon--active'
|
||||
: null
|
||||
)}
|
||||
<FormattingButton
|
||||
hasLongHovered={hasLongHovered}
|
||||
isStyleEnabledInSelection={isStyleEnabledInSelection}
|
||||
label={i18n('icu:Keyboard--composer--bold')}
|
||||
onLongHover={onLongHover}
|
||||
popupGuideShortcut={`${metaKey} + ${BOLD_CHAR}`}
|
||||
popupGuideText={i18n('icu:FormatMenu--guide--bold')}
|
||||
style={QuillFormattingStyle.bold}
|
||||
toggleForStyle={toggleForStyle}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="module-composition-input__format-menu__item"
|
||||
aria-label={i18n('icu:Keyboard--composer--italic')}
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.toggleForStyle(QuillFormattingStyle.italic);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-composition-input__format-menu__item__icon',
|
||||
'module-composition-input__format-menu__item__icon--italic',
|
||||
this.isStyleEnabledInSelection(QuillFormattingStyle.italic)
|
||||
? 'module-composition-input__format-menu__item__icon--active'
|
||||
: null
|
||||
)}
|
||||
<FormattingButton
|
||||
hasLongHovered={hasLongHovered}
|
||||
isStyleEnabledInSelection={isStyleEnabledInSelection}
|
||||
label={i18n('icu:Keyboard--composer--italic')}
|
||||
onLongHover={onLongHover}
|
||||
popupGuideShortcut={`${metaKey} + ${ITALIC_CHAR}`}
|
||||
popupGuideText={i18n('icu:FormatMenu--guide--italic')}
|
||||
style={QuillFormattingStyle.italic}
|
||||
toggleForStyle={toggleForStyle}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="module-composition-input__format-menu__item"
|
||||
aria-label={i18n('icu:Keyboard--composer--strikethrough')}
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.toggleForStyle(QuillFormattingStyle.strike);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-composition-input__format-menu__item__icon',
|
||||
'module-composition-input__format-menu__item__icon--strikethrough',
|
||||
this.isStyleEnabledInSelection(QuillFormattingStyle.strike)
|
||||
? 'module-composition-input__format-menu__item__icon--active'
|
||||
: null
|
||||
)}
|
||||
<FormattingButton
|
||||
hasLongHovered={hasLongHovered}
|
||||
isStyleEnabledInSelection={isStyleEnabledInSelection}
|
||||
label={i18n('icu:Keyboard--composer--strikethrough')}
|
||||
onLongHover={onLongHover}
|
||||
popupGuideShortcut={`${metaKey} + ${shiftKey} + ${STRIKETHROUGH_CHAR}`}
|
||||
popupGuideText={i18n('icu:FormatMenu--guide--strikethrough')}
|
||||
style={QuillFormattingStyle.strike}
|
||||
toggleForStyle={toggleForStyle}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="module-composition-input__format-menu__item"
|
||||
aria-label={i18n('icu:Keyboard--composer--monospace')}
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.toggleForStyle(QuillFormattingStyle.monospace);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-composition-input__format-menu__item__icon',
|
||||
'module-composition-input__format-menu__item__icon--monospace',
|
||||
this.isStyleEnabledInSelection(QuillFormattingStyle.monospace)
|
||||
? 'module-composition-input__format-menu__item__icon--active'
|
||||
: null
|
||||
)}
|
||||
<FormattingButton
|
||||
hasLongHovered={hasLongHovered}
|
||||
isStyleEnabledInSelection={isStyleEnabledInSelection}
|
||||
label={i18n('icu:Keyboard--composer--monospace')}
|
||||
onLongHover={onLongHover}
|
||||
popupGuideShortcut={`${metaKey} + ${MONOSPACE_CHAR}`}
|
||||
popupGuideText={i18n('icu:FormatMenu--guide--monospace')}
|
||||
style={QuillFormattingStyle.monospace}
|
||||
toggleForStyle={toggleForStyle}
|
||||
/>
|
||||
</button>
|
||||
{isSpoilersEnabled ? (
|
||||
<button
|
||||
type="button"
|
||||
className="module-composition-input__format-menu__item"
|
||||
aria-label={i18n('icu:Keyboard--composer--spoiler')}
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.toggleForStyle(QuillFormattingStyle.spoiler);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-composition-input__format-menu__item__icon',
|
||||
'module-composition-input__format-menu__item__icon--spoiler',
|
||||
this.isStyleEnabledInSelection(QuillFormattingStyle.spoiler)
|
||||
? 'module-composition-input__format-menu__item__icon--active'
|
||||
: null
|
||||
)}
|
||||
{isSpoilersEnabled ? (
|
||||
<FormattingButton
|
||||
hasLongHovered={hasLongHovered}
|
||||
isStyleEnabledInSelection={isStyleEnabledInSelection}
|
||||
onLongHover={onLongHover}
|
||||
popupGuideShortcut={`${metaKey} + ${shiftKey} + ${SPOILER_CHAR}`}
|
||||
popupGuideText={i18n('icu:FormatMenu--guide--spoiler')}
|
||||
label={i18n('icu:Keyboard--composer--spoiler')}
|
||||
style={QuillFormattingStyle.spoiler}
|
||||
toggleForStyle={toggleForStyle}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>,
|
||||
this.root
|
||||
);
|
||||
|
@ -342,3 +387,92 @@ export class FormattingMenu {
|
|||
this.options.setFormattingChooserElement(element);
|
||||
}
|
||||
}
|
||||
|
||||
function FormattingButton({
|
||||
hasLongHovered,
|
||||
isStyleEnabledInSelection,
|
||||
label,
|
||||
onLongHover,
|
||||
popupGuideText,
|
||||
popupGuideShortcut,
|
||||
style,
|
||||
toggleForStyle,
|
||||
}: {
|
||||
hasLongHovered: boolean;
|
||||
isStyleEnabledInSelection: (
|
||||
style: QuillFormattingStyle
|
||||
) => boolean | undefined;
|
||||
label: string;
|
||||
onLongHover: (value: boolean) => unknown;
|
||||
popupGuideText: string;
|
||||
popupGuideShortcut: string;
|
||||
style: QuillFormattingStyle;
|
||||
toggleForStyle: (style: QuillFormattingStyle) => unknown;
|
||||
}): JSX.Element {
|
||||
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const timerRef = React.useRef<NodeJS.Timeout | undefined>();
|
||||
const [isHovered, setIsHovered] = React.useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasLongHovered && isHovered && buttonRef.current ? (
|
||||
<Popper placement="top" referenceElement={buttonRef.current}>
|
||||
{({ ref, style: popperStyles }) => (
|
||||
<div
|
||||
className="module-composition-input__format-menu__item__popover"
|
||||
ref={ref}
|
||||
style={popperStyles}
|
||||
>
|
||||
{popupGuideText}
|
||||
<div className="module-composition-input__format-menu__item__popover__shortcut">
|
||||
{popupGuideShortcut}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Popper>
|
||||
) : null}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="module-composition-input__format-menu__item"
|
||||
aria-label={label}
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onLongHover(false);
|
||||
toggleForStyle(style);
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = undefined;
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
onLongHover(true);
|
||||
}, BUTTON_HOVER_TIMEOUT);
|
||||
|
||||
setIsHovered(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = undefined;
|
||||
}
|
||||
|
||||
setIsHovered(false);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-composition-input__format-menu__item__icon',
|
||||
`module-composition-input__format-menu__item__icon--${style}`,
|
||||
isStyleEnabledInSelection(style)
|
||||
? 'module-composition-input__format-menu__item__icon--active'
|
||||
: null
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -184,16 +184,31 @@ export class MentionCompletion {
|
|||
}
|
||||
}
|
||||
|
||||
getAttributesForInsert(index: number): Record<string, unknown> {
|
||||
const character = index > 0 ? index - 1 : 0;
|
||||
const contents = this.quill.getContents(character, 1);
|
||||
return contents.ops.reduce(
|
||||
(acc, op) => ({ acc, ...op.attributes }),
|
||||
{} as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
insertMention(
|
||||
mention: ConversationType,
|
||||
index: number,
|
||||
range: number,
|
||||
withTrailingSpace = false
|
||||
): void {
|
||||
const delta = new Delta().retain(index).delete(range).insert({ mention });
|
||||
// The mention + space we add won't be formatted unless we manually provide attributes
|
||||
const attributes = this.getAttributesForInsert(range - 1);
|
||||
|
||||
const delta = new Delta()
|
||||
.retain(index)
|
||||
.delete(range)
|
||||
.insert({ mention }, attributes);
|
||||
|
||||
if (withTrailingSpace) {
|
||||
this.quill.updateContents(delta.insert(' '), 'user');
|
||||
this.quill.updateContents(delta.insert(' ', attributes), 'user');
|
||||
this.quill.setSelection(index + 2, 0, 'user');
|
||||
} else {
|
||||
this.quill.updateContents(delta, 'user');
|
||||
|
|
|
@ -13,7 +13,10 @@ export const matchMention =
|
|||
if (memberRepository) {
|
||||
const { title } = node.dataset;
|
||||
|
||||
if (node.classList.contains('MessageBody__at-mention')) {
|
||||
if (
|
||||
node.classList.contains('MessageBody__at-mention') &&
|
||||
!node.classList.contains('MessageBody__at-mention--invisible')
|
||||
) {
|
||||
const { id } = node.dataset;
|
||||
const conversation = memberRepository.getMemberById(id);
|
||||
|
||||
|
|
|
@ -4,24 +4,6 @@
|
|||
import type Quill from 'quill';
|
||||
import Delta from 'quill-delta';
|
||||
|
||||
import { getTextFromOps } from '../util';
|
||||
|
||||
const getSelectionHTML = () => {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (selection == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const contents = range.cloneContents();
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.appendChild(contents);
|
||||
|
||||
return div.innerHTML;
|
||||
};
|
||||
|
||||
const replaceAngleBrackets = (text: string) => {
|
||||
const entities: Array<[RegExp, string]> = [
|
||||
[/&/g, '&'],
|
||||
|
@ -41,47 +23,14 @@ export class SignalClipboard {
|
|||
constructor(quill: Quill) {
|
||||
this.quill = quill;
|
||||
|
||||
this.quill.root.addEventListener('copy', e => this.onCaptureCopy(e, false));
|
||||
this.quill.root.addEventListener('cut', e => this.onCaptureCopy(e, true));
|
||||
this.quill.root.addEventListener('paste', e => this.onCapturePaste(e));
|
||||
|
||||
const clipboard = this.quill.getModule('clipboard');
|
||||
// We don't want any of the default matchers!
|
||||
clipboard.matchers = clipboard.matchers.slice(11);
|
||||
}
|
||||
|
||||
onCaptureCopy(event: ClipboardEvent, isCut = false): void {
|
||||
event.preventDefault();
|
||||
|
||||
if (event.clipboardData == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = this.quill.getSelection();
|
||||
|
||||
if (range == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contents = this.quill.getContents(range.index, range.length);
|
||||
|
||||
if (contents == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ops } = contents;
|
||||
|
||||
if (!ops || !ops.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = getTextFromOps(ops);
|
||||
const html = getSelectionHTML();
|
||||
|
||||
event.clipboardData.setData('text/plain', text);
|
||||
event.clipboardData.setData('text/signal', html);
|
||||
|
||||
if (isCut) {
|
||||
this.quill.deleteText(range.index, range.length, 'user');
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: do we need this anymore, given that we aren't using signal/html?
|
||||
onCapturePaste(event: ClipboardEvent): void {
|
||||
if (event.clipboardData == null) {
|
||||
return;
|
||||
|
@ -97,7 +46,7 @@ export class SignalClipboard {
|
|||
}
|
||||
|
||||
const text = event.clipboardData.getData('text/plain');
|
||||
const html = event.clipboardData.getData('text/signal');
|
||||
const html = event.clipboardData.getData('text/html');
|
||||
|
||||
const clipboardDelta = html
|
||||
? clipboard.convert(html)
|
||||
|
|
9
ts/quill/types.d.ts
vendored
9
ts/quill/types.d.ts
vendored
|
@ -50,6 +50,7 @@ declare module 'quill' {
|
|||
|
||||
interface ClipboardStatic {
|
||||
convert(html: string): UpdatedDelta;
|
||||
matchers: Array<unknown>;
|
||||
}
|
||||
|
||||
interface SelectionStatic {
|
||||
|
@ -80,13 +81,17 @@ declare module 'quill' {
|
|||
getModule(module: string): unknown;
|
||||
|
||||
selection: SelectionStatic;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type KeyboardContext = {
|
||||
format: Record<string, unknown>;
|
||||
};
|
||||
|
||||
interface KeyboardStatic {
|
||||
addBinding(
|
||||
key: UpdatedKey,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
callback: (range: RangeStatic, context: any) => void
|
||||
callback: (range: RangeStatic, context: KeyboardContext) => void
|
||||
): void;
|
||||
// in-code reference missing in @types
|
||||
bindings: Record<string | number, Array<unknown>>;
|
||||
|
|
|
@ -185,7 +185,7 @@ export type MessageType = MessageAttributesType & {
|
|||
// eslint-disable-next-line local-rules/type-alias-readonlydeep
|
||||
export type MessageWithUIFieldsType = MessageAttributesType & {
|
||||
displayLimit?: number;
|
||||
isSpoilerExpanded?: boolean;
|
||||
isSpoilerExpanded?: Record<number, boolean>;
|
||||
};
|
||||
|
||||
export const ConversationTypes = ['direct', 'group'] as const;
|
||||
|
@ -737,6 +737,7 @@ export type ShowSpoilerActionType = ReadonlyDeep<{
|
|||
type: typeof SHOW_SPOILER;
|
||||
payload: {
|
||||
id: string;
|
||||
data: Record<number, boolean>;
|
||||
};
|
||||
}>;
|
||||
|
||||
|
@ -2740,11 +2741,15 @@ function messageExpanded(
|
|||
},
|
||||
};
|
||||
}
|
||||
function showSpoiler(id: string): ShowSpoilerActionType {
|
||||
function showSpoiler(
|
||||
id: string,
|
||||
data: Record<number, boolean>
|
||||
): ShowSpoilerActionType {
|
||||
return {
|
||||
type: SHOW_SPOILER,
|
||||
payload: {
|
||||
id,
|
||||
data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -4981,7 +4986,7 @@ export function reducer(
|
|||
};
|
||||
}
|
||||
if (action.type === SHOW_SPOILER) {
|
||||
const { id } = action.payload;
|
||||
const { id, data } = action.payload;
|
||||
|
||||
const existingMessage = state.messagesLookup[id];
|
||||
if (!existingMessage) {
|
||||
|
@ -4990,7 +4995,7 @@ export function reducer(
|
|||
|
||||
const updatedMessage = {
|
||||
...existingMessage,
|
||||
isSpoilerExpanded: true,
|
||||
isSpoilerExpanded: data,
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
@ -617,7 +617,8 @@ function replyToStory(
|
|||
function sendStoryMessage(
|
||||
listIds: Array<UUIDStringType>,
|
||||
conversationIds: Array<string>,
|
||||
attachment: AttachmentType
|
||||
attachment: AttachmentType,
|
||||
bodyRanges: DraftBodyRanges | undefined
|
||||
): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
|
@ -661,7 +662,12 @@ function sendStoryMessage(
|
|||
}
|
||||
|
||||
try {
|
||||
await doSendStoryMessage(listIds, conversationIds, attachment);
|
||||
await doSendStoryMessage(
|
||||
listIds,
|
||||
conversationIds,
|
||||
attachment,
|
||||
bodyRanges
|
||||
);
|
||||
|
||||
// Note: Only when we've successfully queued the message do we dismiss the story
|
||||
// composer view.
|
||||
|
|
|
@ -15,7 +15,12 @@ import { imageToBlurHash } from '../../util/imageToBlurHash';
|
|||
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||
import { selectRecentEmojis } from '../selectors/emojis';
|
||||
import { getIntl, getTheme, getUserConversationId } from '../selectors/user';
|
||||
import {
|
||||
getIntl,
|
||||
getPlatform,
|
||||
getTheme,
|
||||
getUserConversationId,
|
||||
} from '../selectors/user';
|
||||
import { getEmojiSkinTone, getTextFormattingEnabled } from '../selectors/items';
|
||||
import {
|
||||
getConversationSelector,
|
||||
|
@ -52,6 +57,7 @@ export type CompositionAreaPropsType = ExternalProps & ComponentPropsType;
|
|||
|
||||
const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
||||
const { id } = props;
|
||||
const platform = getPlatform(state);
|
||||
|
||||
const conversationSelector = getConversationSelector(state);
|
||||
const conversation = conversationSelector(id);
|
||||
|
@ -112,11 +118,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
|
||||
const selectedMessageIds = getSelectedMessageIds(state);
|
||||
|
||||
const isFormattingEnabled =
|
||||
getIsFormattingFlagEnabled(state) && getTextFormattingEnabled(state);
|
||||
const isFormattingSpoilersEnabled =
|
||||
getIsFormattingSpoilersFlagEnabled(state) &&
|
||||
getTextFormattingEnabled(state);
|
||||
const isFormattingEnabled = getTextFormattingEnabled(state);
|
||||
const isFormattingFlagEnabled = getIsFormattingFlagEnabled(state);
|
||||
const isFormattingSpoilersFlagEnabled =
|
||||
getIsFormattingSpoilersFlagEnabled(state);
|
||||
|
||||
return {
|
||||
// Base
|
||||
|
@ -126,9 +131,11 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
|
|||
getPreferredBadge: getPreferredBadgeSelector(state),
|
||||
i18n: getIntl(state),
|
||||
isDisabled,
|
||||
isFormattingSpoilersEnabled,
|
||||
isFormattingEnabled,
|
||||
isFormattingFlagEnabled,
|
||||
isFormattingSpoilersFlagEnabled,
|
||||
messageCompositionId,
|
||||
platform,
|
||||
sendCounter,
|
||||
theme: getTheme(state),
|
||||
|
||||
|
|
|
@ -5,9 +5,7 @@ import React from 'react';
|
|||
import { useSelector } from 'react-redux';
|
||||
import type { CompositionTextAreaProps } from '../../components/CompositionTextArea';
|
||||
import { CompositionTextArea } from '../../components/CompositionTextArea';
|
||||
import type { LocalizerType } from '../../types/I18N';
|
||||
import type { StateType } from '../reducer';
|
||||
import { getIntl } from '../selectors/user';
|
||||
import { getIntl, getPlatform } from '../selectors/user';
|
||||
import { useActions as useEmojiActions } from '../ducks/emojis';
|
||||
import { useActions as useItemsActions } from '../ducks/items';
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||
|
@ -35,30 +33,32 @@ export type SmartCompositionTextAreaProps = Pick<
|
|||
export function SmartCompositionTextArea(
|
||||
props: SmartCompositionTextAreaProps
|
||||
): JSX.Element {
|
||||
const i18n = useSelector<StateType, LocalizerType>(getIntl);
|
||||
const i18n = useSelector(getIntl);
|
||||
const platform = useSelector(getPlatform);
|
||||
|
||||
const { onUseEmoji: onPickEmoji } = useEmojiActions();
|
||||
const { onSetSkinTone } = useItemsActions();
|
||||
const { onTextTooLong } = useComposerActions();
|
||||
|
||||
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
|
||||
const isFormattingOptionEnabled = useSelector(getTextFormattingEnabled);
|
||||
const isFormattingEnabled =
|
||||
useSelector(getIsFormattingFlagEnabled) && isFormattingOptionEnabled;
|
||||
const isFormattingSpoilersEnabled =
|
||||
useSelector(getIsFormattingSpoilersFlagEnabled) &&
|
||||
isFormattingOptionEnabled;
|
||||
const isFormattingEnabled = useSelector(getTextFormattingEnabled);
|
||||
const isFormattingFlagEnabled = useSelector(getIsFormattingFlagEnabled);
|
||||
const isFormattingSpoilersFlagEnabled = useSelector(
|
||||
getIsFormattingSpoilersFlagEnabled
|
||||
);
|
||||
|
||||
return (
|
||||
<CompositionTextArea
|
||||
{...props}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
i18n={i18n}
|
||||
isFormattingEnabled={isFormattingEnabled}
|
||||
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
|
||||
isFormattingFlagEnabled={isFormattingFlagEnabled}
|
||||
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
|
||||
onPickEmoji={onPickEmoji}
|
||||
onSetSkinTone={onSetSkinTone}
|
||||
getPreferredBadge={getPreferredBadge}
|
||||
onTextTooLong={onTextTooLong}
|
||||
platform={platform}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -94,12 +94,11 @@ export function SmartStoryViewer(): JSX.Element | null {
|
|||
getHasStoryViewReceiptSetting
|
||||
);
|
||||
|
||||
const isFormattingOptionEnabled = useSelector(getTextFormattingEnabled);
|
||||
const isFormattingEnabled =
|
||||
useSelector(getIsFormattingFlagEnabled) && isFormattingOptionEnabled;
|
||||
const isFormattingSpoilersEnabled =
|
||||
useSelector(getIsFormattingSpoilersFlagEnabled) &&
|
||||
isFormattingOptionEnabled;
|
||||
const isFormattingEnabled = useSelector(getTextFormattingEnabled);
|
||||
const isFormattingFlagEnabled = useSelector(getIsFormattingFlagEnabled);
|
||||
const isFormattingSpoilersFlagEnabled = useSelector(
|
||||
getIsFormattingSpoilersFlagEnabled
|
||||
);
|
||||
|
||||
const { pauseVoiceNotePlayer } = useAudioPlayerActions();
|
||||
|
||||
|
@ -127,7 +126,8 @@ export function SmartStoryViewer(): JSX.Element | null {
|
|||
platform={platform}
|
||||
isInternalUser={internalUser}
|
||||
isFormattingEnabled={isFormattingEnabled}
|
||||
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
|
||||
isFormattingFlagEnabled={isFormattingFlagEnabled}
|
||||
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
|
||||
isSignalConversation={isSignalConversation({
|
||||
id: conversationStory.conversationId,
|
||||
})}
|
||||
|
|
|
@ -101,6 +101,64 @@ describe('getTextAndRangesFromOps', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('given formatting', () => {
|
||||
it('handles trimming at the end of the message', () => {
|
||||
const ops = [
|
||||
{
|
||||
insert: 'Text with trailing ',
|
||||
attributes: { bold: true },
|
||||
},
|
||||
{
|
||||
insert: 'whitespace ',
|
||||
attributes: { bold: true, italic: true },
|
||||
},
|
||||
];
|
||||
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
|
||||
assert.equal(text, 'Text with trailing whitespace');
|
||||
assert.equal(bodyRanges.length, 2);
|
||||
assert.deepEqual(bodyRanges, [
|
||||
{
|
||||
start: 0,
|
||||
length: 29,
|
||||
style: BodyRange.Style.BOLD,
|
||||
},
|
||||
{
|
||||
start: 19,
|
||||
length: 10,
|
||||
style: BodyRange.Style.ITALIC,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles trimming at beginning of the message', () => {
|
||||
const ops = [
|
||||
{
|
||||
insert: ' Text with leading ',
|
||||
attributes: { bold: true },
|
||||
},
|
||||
{
|
||||
insert: 'whitespace!!',
|
||||
attributes: { bold: true, italic: true },
|
||||
},
|
||||
];
|
||||
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
|
||||
assert.equal(text, 'Text with leading whitespace!!');
|
||||
assert.equal(bodyRanges.length, 2);
|
||||
assert.deepEqual(bodyRanges, [
|
||||
{
|
||||
start: 0,
|
||||
length: 30,
|
||||
style: BodyRange.Style.BOLD,
|
||||
},
|
||||
{
|
||||
start: 18,
|
||||
length: 12,
|
||||
style: BodyRange.Style.ITALIC,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('given text, emoji, and mentions', () => {
|
||||
it('returns the trimmed text with placeholders and mentions', () => {
|
||||
const ops = [
|
||||
|
|
|
@ -696,21 +696,27 @@ export default class MessageSender {
|
|||
|
||||
async getStoryMessage({
|
||||
allowsReplies,
|
||||
bodyRanges,
|
||||
fileAttachment,
|
||||
groupV2,
|
||||
profileKey,
|
||||
textAttachment,
|
||||
}: {
|
||||
allowsReplies?: boolean;
|
||||
bodyRanges?: Array<RawBodyRange>;
|
||||
fileAttachment?: UploadedAttachmentType;
|
||||
groupV2?: GroupV2InfoType;
|
||||
profileKey: Uint8Array;
|
||||
textAttachment?: OutgoingTextAttachmentType;
|
||||
}): Promise<Proto.StoryMessage> {
|
||||
const storyMessage = new Proto.StoryMessage();
|
||||
|
||||
storyMessage.profileKey = profileKey;
|
||||
|
||||
if (fileAttachment) {
|
||||
if (bodyRanges) {
|
||||
storyMessage.bodyRanges = bodyRanges;
|
||||
}
|
||||
try {
|
||||
storyMessage.fileAttachment = fileAttachment;
|
||||
} catch (error) {
|
||||
|
|
|
@ -326,6 +326,7 @@ export type DisplayNode = {
|
|||
isKeywordHighlight?: boolean;
|
||||
|
||||
// Only for spoilers, only to represent contiguous groupings
|
||||
spoilerIndex?: number;
|
||||
spoilerChildren?: ReadonlyArray<DisplayNode>;
|
||||
};
|
||||
type PartialDisplayNode = Omit<
|
||||
|
@ -450,15 +451,18 @@ export function groupContiguousSpoilers(
|
|||
const result: Array<DisplayNode> = [];
|
||||
|
||||
let spoilerContainer: DisplayNode | undefined;
|
||||
let spoilerIndex = 0;
|
||||
|
||||
nodes.forEach(node => {
|
||||
if (node.isSpoiler) {
|
||||
if (!spoilerContainer) {
|
||||
spoilerContainer = {
|
||||
...node,
|
||||
spoilerIndex,
|
||||
isSpoiler: true,
|
||||
spoilerChildren: [],
|
||||
};
|
||||
spoilerIndex += 1;
|
||||
result.push(spoilerContainer);
|
||||
}
|
||||
if (spoilerContainer) {
|
||||
|
@ -567,7 +571,7 @@ export function processBodyRangesForSearchResult({
|
|||
};
|
||||
}
|
||||
|
||||
const SPOILER_REPLACEMENT = '■■■■';
|
||||
export const SPOILER_REPLACEMENT = '■■■■';
|
||||
|
||||
export function applyRangesForText({
|
||||
text,
|
||||
|
|
|
@ -693,9 +693,8 @@
|
|||
"rule": "thenify-multiArgs",
|
||||
"path": "node_modules/default-browser-id/node_modules/pify/index.js",
|
||||
"line": "\t\t\t\t} else if (opts.multiArgs) {",
|
||||
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
|
||||
"updated": "2023-04-20T16:43:40.643Z",
|
||||
"reasonDetail": "<optional>"
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2023-04-20T16:43:40.643Z"
|
||||
},
|
||||
{
|
||||
"rule": "DOM-outerHTML",
|
||||
|
@ -2370,9 +2369,8 @@
|
|||
"rule": "React-useRef",
|
||||
"path": "ts/components/conversation/InlineNotificationWrapper.tsx",
|
||||
"line": " const focusRef = useRef<HTMLDivElement>(null);",
|
||||
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
|
||||
"updated": "2023-04-12T15:51:28.066Z",
|
||||
"reasonDetail": "<optional>"
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2023-04-12T15:51:28.066Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-createRef",
|
||||
|
@ -2402,17 +2400,15 @@
|
|||
"rule": "React-useRef",
|
||||
"path": "ts/components/conversation/MessageDetail.tsx",
|
||||
"line": " const focusRef = useRef<HTMLDivElement>(null);",
|
||||
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
|
||||
"updated": "2023-04-12T15:51:28.066Z",
|
||||
"reasonDetail": "<optional>"
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2023-04-12T15:51:28.066Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/components/conversation/MessageDetail.tsx",
|
||||
"line": " const messageContainerRef = useRef<HTMLDivElement>(null);",
|
||||
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
|
||||
"updated": "2023-04-12T15:51:28.066Z",
|
||||
"reasonDetail": "<optional>"
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2023-04-12T15:51:28.066Z"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
|
@ -2546,12 +2542,20 @@
|
|||
"updated": "2021-10-22T00:52:39.251Z"
|
||||
},
|
||||
{
|
||||
"rule": "DOM-innerHTML",
|
||||
"path": "ts/quill/signal-clipboard/index.ts",
|
||||
"line": " return div.innerHTML;",
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/quill/formatting/menu.tsx",
|
||||
"line": " const buttonRef = React.useRef<HTMLButtonElement | null>(null);",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2020-11-06T17:43:07.381Z",
|
||||
"reasonDetail": "used for figuring out clipboard contents"
|
||||
"updated": "2023-04-22T00:07:56.294Z",
|
||||
"reasonDetail": "Popper needs to reference the button"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
"path": "ts/quill/formatting/menu.tsx",
|
||||
"line": " const timerRef = React.useRef<NodeJS.Timeout | undefined>();",
|
||||
"reasonCategory": "usageTrusted",
|
||||
"updated": "2023-04-22T00:07:56.294Z",
|
||||
"reasonDetail": "We need a persistent timer to track long-hovers"
|
||||
},
|
||||
{
|
||||
"rule": "React-useRef",
|
||||
|
|
|
@ -28,11 +28,13 @@ import { isNotNil } from './isNotNil';
|
|||
import { collect } from './iterables';
|
||||
import { DurationInSeconds } from './durations';
|
||||
import { sanitizeLinkPreview } from '../services/LinkPreview';
|
||||
import type { DraftBodyRanges } from '../types/BodyRange';
|
||||
|
||||
export async function sendStoryMessage(
|
||||
listIds: Array<string>,
|
||||
conversationIds: Array<string>,
|
||||
attachment: AttachmentType
|
||||
attachment: AttachmentType,
|
||||
bodyRanges: DraftBodyRanges | undefined
|
||||
): Promise<void> {
|
||||
if (getStoriesBlocked()) {
|
||||
log.warn('stories.sendStoryMessage: stories disabled, returning early');
|
||||
|
@ -171,6 +173,7 @@ export async function sendStoryMessage(
|
|||
// on the receiver side.
|
||||
return window.Signal.Migrations.upgradeMessageSchema({
|
||||
attachments,
|
||||
bodyRanges,
|
||||
conversationId: ourConversation.id,
|
||||
expireTimer: DurationInSeconds.DAY,
|
||||
expirationStartTimestamp: Date.now(),
|
||||
|
@ -277,6 +280,7 @@ export async function sendStoryMessage(
|
|||
const messageAttributes =
|
||||
await window.Signal.Migrations.upgradeMessageSchema({
|
||||
attachments,
|
||||
bodyRanges,
|
||||
canReplyToStory: true,
|
||||
conversationId: group.id,
|
||||
expireTimer: DurationInSeconds.DAY,
|
||||
|
|
Loading…
Reference in a new issue