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