Allow copy/paste of formatting and mentions

This commit is contained in:
Scott Nonnenberg 2023-05-09 17:40:19 -07:00 committed by GitHub
parent 320ac044a8
commit b4caf67bf9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
55 changed files with 1003 additions and 446 deletions

View file

@ -3355,8 +3355,8 @@
"messageformat": "Spell check text entered in message composition box",
"description": "Description of the spell check setting"
},
"icu:textFormattingDescripton": {
"messageformat": "Enable text formatting popover when text is selected",
"icu:textFormattingDescription": {
"messageformat": "Show text formatting popover when text is selected",
"description": "Description of the text-formatting popover menu setting"
},
"spellCheckWillBeEnabled": {
@ -5542,6 +5542,26 @@
"messageformat": "Mark selected text as a spoiler",
"description": "Description of command to bold text in composer"
},
"icu:FormatMenu--guide--bold": {
"messageformat": "Bold",
"description": "Shown when you hover over the bold button in the popup formatting menu"
},
"icu:FormatMenu--guide--italic": {
"messageformat": "Italic",
"description": "Shown when you hover over the bold button in the popup formatting menu"
},
"icu:FormatMenu--guide--strikethrough": {
"messageformat": "Strikethrough",
"description": "Shown when you hover over the bold button in the popup formatting menu"
},
"icu:FormatMenu--guide--monospace": {
"messageformat": "Monospace",
"description": "Shown when you hover over the bold button in the popup formatting menu"
},
"icu:FormatMenu--guide--spoiler": {
"messageformat": "Spoiler",
"description": "Shown when you hover over the bold button in the popup formatting menu"
},
"Keyboard--scroll-to-top": {
"message": "Scroll to top of list",
"description": "(deleted 03/29/2023) Shown in the shortcuts guide"

View file

@ -93,6 +93,10 @@
line-height: 16px;
letter-spacing: 0;
}
@mixin font-subtitle-bold {
@include font-subtitle;
font-weight: 600;
}
@mixin font-caption {
@include font-family;

View file

@ -5,6 +5,7 @@ $inter: Inter, 'Helvetica Neue', 'Source Sans Pro', 'Source Han Sans SC',
'Source Han Sans CN', 'Hiragino Sans GB', 'Hiragino Kaku Gothic',
'Microsoft Yahei UI', Helvetica, Arial, sans-serif;
// Note: This font-family is checked for in matchMonospace, to support paste scenarios
$monospace: 'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', Menlo,
Consolas, monospace;

View file

@ -119,6 +119,7 @@
}
}
// Note: This is referenced in ModalHost to ensure 'external' clicks on it still work
&__format-menu {
padding-block: 6px;
padding-inline: 12px;
@ -128,13 +129,16 @@
display: flex;
flex-direction: row;
opacity: 0;
transition: opacity ease 200ms;
@include popper-shadow();
@include light-theme() {
background: $color-white;
}
@include dark-theme() {
background: $color-gray-80;
background: $color-gray-65;
}
&__item {
@ -161,6 +165,35 @@
}
}
&__popover {
@include font-subtitle-bold;
padding-block: 5px;
padding-inline: 8px;
text-align: center;
border-radius: 4px;
margin-bottom: 8px;
@include light-theme {
background-color: $color-black;
color: $color-gray-05;
}
@include dark-theme {
background-color: $color-gray-65;
color: $color-gray-05;
}
&__shortcut {
@include font-caption-bold;
@include light-theme {
color: $color-gray-15;
}
@include dark-theme {
color: $color-gray-25;
}
}
}
&__icon {
height: 20px;
width: 20px;
@ -196,7 +229,7 @@
}
}
&--strikethrough {
&--strike {
@include dark-theme {
@include color-svg(
'../images/icons/v3/text_format/textformat-strikethrough.svg',
@ -252,7 +285,7 @@
&--active {
@include dark-theme {
background-color: $color-ultramarine;
background-color: $color-ultramarine-light;
}
@include light-theme {
background-color: $color-ultramarine;
@ -263,13 +296,14 @@
background-color: $color-ultramarine;
}
.dark-theme.mouse-mode #{$parent}:hover & {
background-color: $color-ultramarine;
background-color: $color-ultramarine-light;
}
}
}
}
}
// Note: This is referenced in ModalHost to ensure 'external' clicks on it still work
&__suggestions {
padding: 0;
margin-bottom: 6px;
@ -435,6 +469,7 @@ button.CompositionInput__link-preview__close-button {
}
}
// Note: These are referenced in formatting/matchers.ts, to detect these styles on paste
.quill {
&--monospace {
font-family: $monospace;

View file

@ -12,6 +12,7 @@
}
// Note: only used in the left pane for search results, not in message bubbles
// Note: This is referenced in formatting/matchers.ts, to detect these styles on paste
&--keywordHighlight {
// Boldness of this is handled by <strong> element
@ -26,9 +27,11 @@
// Note: Spoiler must be last to override any other formatting applied to the section
&--spoiler {
user-select: none;
cursor: pointer;
// Prepare for our inner copy target
position: relative;
// Lighten things up a bit
opacity: 50%;
border-radius: 4px;
@ -46,6 +49,23 @@
}
}
&--spoiler--copy-target {
// We don't want this thing to affect the layout of the message
position: absolute;
top: 0;
// We can use left here; this is not visible to the user
/* stylelint-disable liberty/use-logical-spec */
left: 0;
height: 1px;
width: 1px;
// Hide text
color: transparent;
overflow: hidden;
}
// Note: This is referenced in formatting/matchers.ts, to detect these styles on paste
&--spoiler--noninteractive {
cursor: inherit;
box-shadow: none;
@ -55,6 +75,9 @@
&--spoiler-StoryViewer {
background-color: $color-white;
}
&--spoiler-MediaEditor {
background-color: $color-gray-15;
}
// The left pane
&--spoiler-ConversationList,

View file

@ -25,14 +25,16 @@ export default {
defaultValue: (props: SmartCompositionTextAreaProps) => (
<CompositionTextArea
{...props}
getPreferredBadge={() => undefined}
i18n={i18n}
isFormattingEnabled={false}
isFormattingSpoilersEnabled={false}
isFormattingEnabled
isFormattingFlagEnabled
isFormattingSpoilersFlagEnabled
onPickEmoji={action('onPickEmoji')}
onChange={action('onChange')}
onTextTooLong={action('onTextTooLong')}
onSetSkinTone={action('onSetSkinTone')}
getPreferredBadge={() => undefined}
platform="darwin"
/>
),
},

View file

@ -7,12 +7,17 @@ import { Button } from './Button';
import { Modal } from './Modal';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
import type { HydratedBodyRangesType } from '../types/BodyRange';
export type Props = {
i18n: LocalizerType;
onClose: () => void;
onSubmit: (text: string) => void;
onSubmit: (
text: string,
bodyRanges: HydratedBodyRangesType | undefined
) => void;
draftText: string;
draftBodyRanges: HydratedBodyRangesType | undefined;
theme: ThemeType;
RenderCompositionTextArea: (
props: SmartCompositionTextAreaProps
@ -24,10 +29,14 @@ export function AddCaptionModal({
onClose,
onSubmit,
draftText,
draftBodyRanges,
RenderCompositionTextArea,
theme,
}: Props): JSX.Element {
const [messageText, setMessageText] = React.useState('');
const [bodyRanges, setBodyRanges] = React.useState<
HydratedBodyRangesType | undefined
>();
const [isScrolledTop, setIsScrolledTop] = React.useState(true);
const [isScrolledBottom, setIsScrolledBottom] = React.useState(true);
@ -51,8 +60,8 @@ export function AddCaptionModal({
}, [updateScrollState]);
const handleSubmit = React.useCallback(() => {
onSubmit(messageText);
}, [messageText, onSubmit]);
onSubmit(messageText, bodyRanges);
}, [bodyRanges, messageText, onSubmit]);
return (
<Modal
@ -75,9 +84,13 @@ export function AddCaptionModal({
maxLength={1500}
whenToShowRemainingCount={1450}
placeholder={i18n('icu:AddCaptionModal__placeholder')}
onChange={setMessageText}
onChange={(updatedMessageText, updatedBodyRanges) => {
setMessageText(updatedMessageText);
setBodyRanges(updatedBodyRanges);
}}
scrollerRef={scrollerRef}
draftText={draftText}
bodyRanges={draftBodyRanges}
onSubmit={noop}
onScroll={updateScrollState}
theme={theme}

View file

@ -39,9 +39,13 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
sendCounter: 0,
i18n,
isDisabled: false,
isFormattingSpoilersEnabled:
overrideProps.isFormattingSpoilersEnabled === false
? overrideProps.isFormattingSpoilersEnabled
isFormattingFlagEnabled:
overrideProps.isFormattingFlagEnabled === false
? overrideProps.isFormattingFlagEnabled
: true,
isFormattingSpoilersFlagEnabled:
overrideProps.isFormattingSpoilersFlagEnabled === false
? overrideProps.isFormattingSpoilersFlagEnabled
: true,
isFormattingEnabled:
overrideProps.isFormattingEnabled === false
@ -50,6 +54,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
messageCompositionId: '456',
sendEditedMessage: action('sendEditedMessage'),
sendMultiMediaMessage: action('sendMultiMediaMessage'),
platform: 'darwin',
processAttachments: action('processAttachments'),
removeAttachment: action('removeAttachment'),
theme: React.useContext(StorybookThemeContext),
@ -290,12 +295,18 @@ QuoteWithPayment.story = {
name: 'Quote with payment',
};
export function NoFormatting(): JSX.Element {
export function NoFormattingMenu(): JSX.Element {
return <CompositionArea {...useProps({ isFormattingEnabled: false })} />;
}
export function NoSpoilerFormatting(): JSX.Element {
export function NoFormattingFlag(): JSX.Element {
return <CompositionArea {...useProps({ isFormattingFlagEnabled: false })} />;
}
export function NoSpoilerFormattingFlag(): JSX.Element {
return (
<CompositionArea {...useProps({ isFormattingSpoilersEnabled: false })} />
<CompositionArea
{...useProps({ isFormattingSpoilersFlagEnabled: false })}
/>
);
}

View file

@ -2,7 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { get } from 'lodash';
import classNames from 'classnames';
import type { ReadonlyDeep } from 'type-fest';
@ -99,7 +98,8 @@ export type OwnProps = Readonly<{
isDisabled: boolean;
isFetchingUUID?: boolean;
isFormattingEnabled: boolean;
isFormattingSpoilersEnabled: boolean;
isFormattingFlagEnabled: boolean;
isFormattingSpoilersFlagEnabled: boolean;
isGroupV1AndDisabled?: boolean;
isMissingMandatoryProfileSharing?: boolean;
isSignalConversation?: boolean;
@ -112,6 +112,7 @@ export type OwnProps = Readonly<{
messageRequestsEnabled?: boolean;
onClearAttachments(conversationId: string): unknown;
onCloseLinkPreview(conversationId: string): unknown;
platform: string;
showToast: ShowToastAction;
processAttachments: (options: {
conversationId: string;
@ -226,6 +227,7 @@ export function CompositionArea({
messageCompositionId,
showToast,
pushPanelForConversation,
platform,
processAttachments,
removeAttachment,
sendEditedMessage,
@ -259,8 +261,9 @@ export function CompositionArea({
draftText,
getPreferredBadge,
getQuotedMessage,
isFormattingSpoilersEnabled,
isFormattingEnabled,
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
onEditorStateChange,
onTextTooLong,
sendCounter,
@ -616,8 +619,8 @@ export function CompositionArea({
const key = KeyboardLayout.lookup(e);
// When using the ctrl key, `key` is `'K'`. When using the cmd key, `key` is `'k'`
const targetKey = key === 'k' || key === 'K';
const commandKey = get(window, 'platform') === 'darwin' && metaKey;
const controlKey = get(window, 'platform') !== 'darwin' && ctrlKey;
const commandKey = platform === 'darwin' && metaKey;
const controlKey = platform !== 'darwin' && ctrlKey;
const commandOrCtrl = commandKey || controlKey;
// cmd/ctrl-shift-k
@ -632,7 +635,7 @@ export function CompositionArea({
return () => {
document.removeEventListener('keydown', handler);
};
}, [setLarge]);
}, [platform, setLarge]);
const handleRecordingBeforeSend = useCallback(() => {
emojiButtonRef.current?.close();
@ -914,8 +917,9 @@ export function CompositionArea({
getQuotedMessage={getQuotedMessage}
i18n={i18n}
inputApi={inputApiRef}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
isFormattingEnabled={isFormattingEnabled}
isFormattingFlagEnabled={isFormattingFlagEnabled}
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
large={large}
linkPreviewLoading={linkPreviewLoading}
linkPreviewResult={linkPreviewResult}
@ -925,6 +929,7 @@ export function CompositionArea({
onPickEmoji={onPickEmoji}
onSubmit={handleSubmit}
onTextTooLong={onTextTooLong}
platform={platform}
sendCounter={sendCounter}
skinTone={skinTone}
sortedGroupMembers={sortedGroupMembers}

View file

@ -28,9 +28,13 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
clearQuotedMessage: action('clearQuotedMessage'),
getPreferredBadge: () => undefined,
getQuotedMessage: action('getQuotedMessage'),
isFormattingSpoilersEnabled:
overrideProps.isFormattingSpoilersEnabled === false
? overrideProps.isFormattingSpoilersEnabled
isFormattingFlagEnabled:
overrideProps.isFormattingFlagEnabled === false
? overrideProps.isFormattingFlagEnabled
: true,
isFormattingSpoilersFlagEnabled:
overrideProps.isFormattingSpoilersFlagEnabled === false
? overrideProps.isFormattingSpoilersFlagEnabled
: true,
isFormattingEnabled:
overrideProps.isFormattingEnabled === false
@ -42,6 +46,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
onPickEmoji: action('onPickEmoji'),
onSubmit: action('onSubmit'),
onTextTooLong: action('onTextTooLong'),
platform: 'darwin',
sendCounter: 0,
sortedGroupMembers: overrideProps.sortedGroupMembers || [],
skinTone: select(
@ -142,12 +147,18 @@ export function Mentions(): JSX.Element {
return <CompositionInput {...props} />;
}
export function NoFormatting(): JSX.Element {
export function NoFormattingMenu(): JSX.Element {
return <CompositionInput {...useProps({ isFormattingEnabled: false })} />;
}
export function NoSpoilerFormatting(): JSX.Element {
export function NoFormattingFlag(): JSX.Element {
return <CompositionInput {...useProps({ isFormattingFlagEnabled: false })} />;
}
export function NoSpoilerFormattingFlag(): JSX.Element {
return (
<CompositionInput {...useProps({ isFormattingSpoilersEnabled: false })} />
<CompositionInput
{...useProps({ isFormattingSpoilersFlagEnabled: false })}
/>
);
}

View file

@ -22,7 +22,7 @@ import type {
HydratedBodyRangesType,
RangeNode,
} from '../types/BodyRange';
import { collapseRangeTree, insertRange } from '../types/BodyRange';
import { BodyRange, collapseRangeTree, insertRange } from '../types/BodyRange';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
@ -31,7 +31,6 @@ import { MentionBlot } from '../quill/mentions/blot';
import {
matchEmojiImage,
matchEmojiBlot,
matchReactEmoji,
matchEmojiText,
} from '../quill/emoji/matchers';
import { matchMention } from '../quill/mentions/matchers';
@ -53,6 +52,14 @@ import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import type { DraftEditMessageType } from '../model-types.d';
import { usePrevious } from '../hooks/usePrevious';
import {
matchBold,
matchItalic,
matchMonospace,
matchSpoiler,
matchStrikethrough,
} from '../quill/formatting/matchers';
import { missingCaseError } from '../util/missingCaseError';
Quill.register('formats/emoji', EmojiBlot);
Quill.register('formats/mention', MentionBlot);
@ -91,7 +98,8 @@ export type Props = Readonly<{
large?: boolean;
inputApi?: React.MutableRefObject<InputApi | undefined>;
isFormattingEnabled: boolean;
isFormattingSpoilersEnabled: boolean;
isFormattingFlagEnabled: boolean;
isFormattingSpoilersFlagEnabled: boolean;
sendCounter: number;
skinTone?: EmojiPickDataType['skinTone'];
draftText?: string;
@ -117,6 +125,7 @@ export type Props = Readonly<{
timestamp: number
): unknown;
onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
platform: string;
getQuotedMessage?(): unknown;
clearQuotedMessage?(): unknown;
linkPreviewLoading?: boolean;
@ -141,7 +150,8 @@ export function CompositionInput(props: Props): React.ReactElement {
i18n,
inputApi,
isFormattingEnabled,
isFormattingSpoilersEnabled,
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
large,
linkPreviewLoading,
linkPreviewResult,
@ -151,6 +161,7 @@ export function CompositionInput(props: Props): React.ReactElement {
onScroll,
onSubmit,
placeholder,
platform,
skinTone,
sendCounter,
sortedGroupMembers,
@ -220,7 +231,29 @@ export function CompositionInput(props: Props): React.ReactElement {
return { text: '', bodyRanges: [] };
}
return getTextAndRangesFromOps(ops);
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
return {
text,
bodyRanges: bodyRanges.filter(range => {
if (BodyRange.isMention(range)) {
return true;
}
if (BodyRange.isFormatting(range)) {
if (!isFormattingFlagEnabled) {
return false;
}
if (
range.style === BodyRange.Style.SPOILER &&
!isFormattingSpoilersFlagEnabled
) {
return false;
}
return true;
}
throw missingCaseError(range);
}),
};
};
const focus = () => {
@ -352,32 +385,46 @@ export function CompositionInput(props: Props): React.ReactElement {
isFormattingEnabled,
isFormattingEnabled
);
const previousFormattingSpoilersEnabled = usePrevious(
isFormattingSpoilersEnabled,
isFormattingSpoilersEnabled
const previousFormattingFlagEnabled = usePrevious(
isFormattingFlagEnabled,
isFormattingFlagEnabled
);
const previousFormattingSpoilersFlagEnabled = usePrevious(
isFormattingSpoilersFlagEnabled,
isFormattingSpoilersFlagEnabled
);
React.useEffect(() => {
const formattingChanged =
typeof previousFormattingEnabled === 'boolean' &&
previousFormattingEnabled !== isFormattingEnabled;
const spoilersChanged =
typeof previousFormattingSpoilersEnabled === 'boolean' &&
previousFormattingSpoilersEnabled !== isFormattingSpoilersEnabled;
const flagChanged =
typeof previousFormattingFlagEnabled === 'boolean' &&
previousFormattingFlagEnabled !== isFormattingFlagEnabled;
const spoilersFlagChanged =
typeof previousFormattingSpoilersFlagEnabled === 'boolean' &&
previousFormattingSpoilersFlagEnabled !== isFormattingSpoilersFlagEnabled;
const quill = quillRef.current;
const changed = formattingChanged || spoilersChanged;
const changed = formattingChanged || flagChanged || spoilersFlagChanged;
if (quill && changed) {
quill.getModule('formattingMenu').updateOptions({
isEnabled: isFormattingEnabled,
isSpoilersEnabled: isFormattingSpoilersEnabled,
isMenuEnabled: isFormattingEnabled,
isEnabled: isFormattingFlagEnabled,
isSpoilersEnabled: isFormattingSpoilersFlagEnabled,
});
quill.options.formats = getQuillFormats({
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
});
}
}, [
isFormattingEnabled,
isFormattingSpoilersEnabled,
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
previousFormattingEnabled,
previousFormattingSpoilersEnabled,
previousFormattingFlagEnabled,
previousFormattingSpoilersFlagEnabled,
quillRef,
]);
@ -643,7 +690,11 @@ export function CompositionInput(props: Props): React.ReactElement {
matchers: [
['IMG', matchEmojiImage],
['IMG', matchEmojiBlot],
['SPAN', matchReactEmoji],
['STRONG', matchBold],
['EM', matchItalic],
['SPAN', matchMonospace],
['S', matchStrikethrough],
['SPAN', matchSpoiler],
[Node.TEXT_NODE, matchEmojiText],
['SPAN', matchMention(memberRepositoryRef)],
],
@ -677,8 +728,10 @@ export function CompositionInput(props: Props): React.ReactElement {
},
formattingMenu: {
i18n,
isEnabled: isFormattingEnabled,
isSpoilersEnabled: isFormattingSpoilersEnabled,
isMenuEnabled: isFormattingEnabled,
isEnabled: isFormattingFlagEnabled,
isSpoilersEnabled: isFormattingSpoilersFlagEnabled,
platform,
setFormattingChooserElement,
},
mentionCompletion: {
@ -692,25 +745,10 @@ export function CompositionInput(props: Props): React.ReactElement {
theme,
},
}}
formats={[
// For image replacement (local-only)
'emoji',
// @mentions
'mention',
...(isFormattingEnabled
? [
// Custom
...(isFormattingSpoilersEnabled
? [QuillFormattingStyle.spoiler]
: []),
QuillFormattingStyle.monospace,
// Built-in
QuillFormattingStyle.bold,
QuillFormattingStyle.italic,
QuillFormattingStyle.strike,
]
: []),
]}
formats={getQuillFormats({
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
})}
placeholder={placeholder || i18n('icu:sendMessage')}
readOnly={disabled}
ref={element => {
@ -838,3 +876,31 @@ export function CompositionInput(props: Props): React.ReactElement {
</Manager>
);
}
function getQuillFormats({
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
}: {
isFormattingFlagEnabled: boolean;
isFormattingSpoilersFlagEnabled: boolean;
}): Array<string> {
return [
// For image replacement (local-only)
'emoji',
// @mentions
'mention',
...(isFormattingFlagEnabled
? [
// Custom
...(isFormattingSpoilersFlagEnabled
? [QuillFormattingStyle.spoiler]
: []),
QuillFormattingStyle.monospace,
// Built-in
QuillFormattingStyle.bold,
QuillFormattingStyle.italic,
QuillFormattingStyle.strike,
]
: []),
];
}

View file

@ -22,7 +22,8 @@ export type CompositionTextAreaProps = {
bodyRanges?: HydratedBodyRangesType;
i18n: LocalizerType;
isFormattingEnabled: boolean;
isFormattingSpoilersEnabled: boolean;
isFormattingFlagEnabled: boolean;
isFormattingSpoilersFlagEnabled: boolean;
maxLength?: number;
placeholder?: string;
whenToShowRemainingCount?: number;
@ -41,6 +42,7 @@ export type CompositionTextAreaProps = {
timestamp: number
) => void;
onTextTooLong: () => void;
platform: string;
getPreferredBadge: PreferredBadgeSelectorType;
draftText: string;
theme: ThemeType;
@ -59,7 +61,8 @@ export function CompositionTextArea({
getPreferredBadge,
i18n,
isFormattingEnabled,
isFormattingSpoilersEnabled,
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
maxLength,
onChange,
onPickEmoji,
@ -68,6 +71,7 @@ export function CompositionTextArea({
onSubmit,
onTextTooLong,
placeholder,
platform,
recentEmojis,
scrollerRef,
skinTone,
@ -140,7 +144,8 @@ export function CompositionTextArea({
getQuotedMessage={noop}
i18n={i18n}
isFormattingEnabled={isFormattingEnabled}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
isFormattingFlagEnabled={isFormattingFlagEnabled}
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
inputApi={inputApiRef}
large
moduleClassName="CompositionTextArea__input"
@ -150,6 +155,7 @@ export function CompositionTextArea({
onSubmit={onSubmit}
onTextTooLong={onTextTooLong}
placeholder={placeholder}
platform={platform}
scrollerRef={scrollerRef}
sendCounter={0}
theme={theme}

View file

@ -89,7 +89,7 @@ export function EditHistoryMessagesModal({
// These states aren't in redux; they are meant to last only as long as this dialog.
const [revealedSpoilersById, setRevealedSpoilersById] = useState<
Record<string, boolean | undefined>
Record<string, Record<number, boolean> | undefined>
>({});
const [displayLimitById, setDisplayLimitById] = useState<
Record<string, number | undefined>
@ -118,7 +118,7 @@ export function EditHistoryMessagesModal({
displayLimit={displayLimitById[syntheticId]}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
isSpoilerExpanded={revealedSpoilersById[syntheticId] || false}
isSpoilerExpanded={revealedSpoilersById[syntheticId] || {}}
key={messageAttributes.timestamp}
kickOffAttachmentDownload={kickOffAttachmentDownload}
messageExpanded={(messageId, displayLimit) => {
@ -130,10 +130,10 @@ export function EditHistoryMessagesModal({
}}
platform={platform}
showLightbox={closeAndShowLightbox}
showSpoiler={messageId => {
showSpoiler={(messageId, data) => {
const update = {
...revealedSpoilersById,
[messageId]: true,
[messageId]: data,
};
setRevealedSpoilersById(update);
}}

View file

@ -58,14 +58,16 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
RenderCompositionTextArea: props => (
<CompositionTextArea
{...props}
getPreferredBadge={() => undefined}
i18n={i18n}
isFormattingSpoilersEnabled
isFormattingEnabled
isFormattingFlagEnabled
isFormattingSpoilersFlagEnabled
onPickEmoji={action('onPickEmoji')}
skinTone={0}
onSetSkinTone={action('onSetSkinTone')}
onTextTooLong={action('onTextTooLong')}
getPreferredBadge={() => undefined}
platform="darwin"
skinTone={0}
/>
),
showToast: action('showToast'),

View file

@ -13,6 +13,8 @@ import type { AvatarColorType } from '../types/Colors';
import type { BadgeType } from '../badges/types';
import { handleOutsideClick } from '../util/handleOutsideClick';
const EMPTY_OBJECT = Object.freeze(Object.create(null));
export type PropsType = {
areStoriesEnabled: boolean;
avatarPath?: string;
@ -186,7 +188,7 @@ export function MainHeader({
showArchivedConversations();
setShowAvatarPopup(false);
}}
style={{}}
style={EMPTY_OBJECT}
/>
</div>,
portalElement

View file

@ -63,13 +63,15 @@ export function WithCaption(): JSX.Element {
renderCompositionTextArea={props => (
<CompositionTextArea
{...props}
getPreferredBadge={() => undefined}
i18n={i18n}
isFormattingSpoilersEnabled
isFormattingEnabled
isFormattingFlagEnabled
isFormattingSpoilersFlagEnabled
onPickEmoji={action('onPickEmoji')}
onSetSkinTone={action('onSetSkinTone')}
onTextTooLong={action('onTextTooLong')}
getPreferredBadge={() => undefined}
platform="darwin"
/>
)}
/>

View file

@ -41,10 +41,11 @@ import {
} from '../mediaEditor/util/getTextStyleAttributes';
import { AddCaptionModal } from './AddCaptionModal';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
import { Emojify } from './conversation/Emojify';
import { AddNewLines } from './conversation/AddNewLines';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
import { Spinner } from './Spinner';
import type { HydratedBodyRangesType } from '../types/BodyRange';
import { MessageBody } from './conversation/MessageBody';
import { RenderLocation } from './conversation/MessageTextRenderer';
import { arrow } from '../util/keyboard';
export type MediaEditorResultType = Readonly<{
@ -52,6 +53,7 @@ export type MediaEditorResultType = Readonly<{
contentType: MIMEType;
blurHash: string;
caption?: string;
captionBodyRanges?: HydratedBodyRangesType;
}>;
export type PropsType = {
@ -137,6 +139,9 @@ export function MediaEditor({
useState<boolean>(false);
const [caption, setCaption] = useState('');
const [captionBodyRanges, setCaptionBodyRanges] = useState<
HydratedBodyRangesType | undefined
>();
const [showAddCaptionModal, setShowAddCaptionModal] = useState(false);
@ -948,11 +953,12 @@ export function MediaEditor({
>
{caption !== '' ? (
<span>
<AddNewLines
<MessageBody
renderLocation={RenderLocation.MediaEditor}
bodyRanges={captionBodyRanges}
i18n={i18n}
isSpoilerExpanded={{}}
text={caption}
renderNonNewLine={({ key, text }) => (
<Emojify key={key} text={text} />
)}
/>
</span>
) : (
@ -964,8 +970,10 @@ export function MediaEditor({
<AddCaptionModal
i18n={i18n}
draftText={caption}
onSubmit={messageText => {
draftBodyRanges={captionBodyRanges}
onSubmit={(messageText, bodyRanges) => {
setCaption(messageText.trim());
setCaptionBodyRanges(bodyRanges);
setShowAddCaptionModal(false);
}}
onClose={() => setShowAddCaptionModal(false)}
@ -1230,6 +1238,7 @@ export function MediaEditor({
contentType: IMAGE_PNG,
data,
caption: caption !== '' ? caption : undefined,
captionBodyRanges,
blurHash,
});
}}

View file

@ -133,6 +133,7 @@ export const ModalHost = React.memo(function ModalHostInner({
const exemptParent = target.closest(
'.TitleBarContainer__title, ' +
'.module-composition-input__suggestions, ' +
'.module-composition-input__format-menu, ' +
'.module-calling__modal-container'
);
if (exemptParent) {

View file

@ -594,7 +594,7 @@ export function Preferences({
{isFormattingFlagEnabled && (
<Checkbox
checked={hasTextFormatting}
label={i18n('icu:textFormattingDescripton')}
label={i18n('icu:textFormattingDescription')}
moduleClassName="Preferences__checkbox"
name="textFormatting"
onChange={onTextFormattingChange}

View file

@ -24,6 +24,7 @@ import { SendStoryModal } from './SendStoryModal';
import { MediaEditor } from './MediaEditor';
import { TextStoryCreator } from './TextStoryCreator';
import type { SmartCompositionTextAreaProps } from '../state/smart/CompositionTextArea';
import type { DraftBodyRanges } from '../types/BodyRange';
export type PropsType = {
debouncedMaybeGrabLinkPreview: (
@ -38,7 +39,8 @@ export type PropsType = {
onSend: (
listIds: Array<UUIDStringType>,
conversationIds: Array<string>,
attachment: AttachmentType
attachment: AttachmentType,
bodyRanges: DraftBodyRanges | undefined
) => unknown;
imageToBlurHash: typeof imageToBlurHash;
processAttachment: (
@ -123,6 +125,7 @@ export function StoryCreator({
>();
const [isReadyToSend, setIsReadyToSend] = useState(false);
const [attachmentUrl, setAttachmentUrl] = useState<string | undefined>();
const [bodyRanges, setBodyRanges] = useState<DraftBodyRanges | undefined>();
useEffect(() => {
let url: string | undefined;
@ -192,7 +195,7 @@ export function StoryCreator({
onRepliesNReactionsChanged={onRepliesNReactionsChanged}
onSelectedStoryList={onSelectedStoryList}
onSend={(listIds, groupIds) => {
onSend(listIds, groupIds, draftAttachment);
onSend(listIds, groupIds, draftAttachment, bodyRanges);
setDraftAttachment(undefined);
}}
onViewersUpdated={onViewersUpdated}
@ -219,7 +222,13 @@ export function StoryCreator({
supportsCaption
renderCompositionTextArea={renderCompositionTextArea}
imageToBlurHash={imageToBlurHash}
onDone={({ contentType, data, blurHash, caption }) => {
onDone={({
contentType,
data,
blurHash,
caption,
captionBodyRanges,
}) => {
setDraftAttachment({
...draftAttachment,
contentType,
@ -228,6 +237,7 @@ export function StoryCreator({
blurHash,
caption,
});
setBodyRanges(captionBodyRanges);
setIsReadyToSend(true);
}}
recentStickers={recentStickers}

View file

@ -86,7 +86,8 @@ export type PropsType = {
hasViewReceiptSetting: boolean;
i18n: LocalizerType;
isFormattingEnabled: boolean;
isFormattingSpoilersEnabled: boolean;
isFormattingFlagEnabled: boolean;
isFormattingSpoilersFlagEnabled: boolean;
isInternalUser?: boolean;
isSignalConversation?: boolean;
isWindowActive: boolean;
@ -148,7 +149,8 @@ export function StoryViewer({
hasViewReceiptSetting,
i18n,
isFormattingEnabled,
isFormattingSpoilersEnabled,
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
isInternalUser,
isSignalConversation,
isWindowActive,
@ -242,7 +244,9 @@ export function StoryViewer({
// Caption related hooks
const [hasExpandedCaption, setHasExpandedCaption] = useState<boolean>(false);
const [isSpoilerExpanded, setIsSpoilerExpanded] = useState<boolean>(false);
const [isSpoilerExpanded, setIsSpoilerExpanded] = useState<
Record<number, boolean>
>({});
const caption = useMemo(() => {
if (!attachment?.caption) {
@ -259,7 +263,7 @@ export function StoryViewer({
// Reset expansion if messageId changes
useEffect(() => {
setHasExpandedCaption(false);
setIsSpoilerExpanded(false);
setIsSpoilerExpanded({});
}, [messageId]);
// messageId is set as a dependency so that we can reset the story duration
@ -343,7 +347,7 @@ export function StoryViewer({
setConfirmDeleteStory(undefined);
setHasConfirmHideStory(false);
setHasExpandedCaption(false);
setIsSpoilerExpanded(false);
setIsSpoilerExpanded({});
setIsShowingContextMenu(false);
setPauseStory(false);
@ -692,7 +696,7 @@ export function StoryViewer({
bodyRanges={bodyRanges}
i18n={i18n}
isSpoilerExpanded={isSpoilerExpanded}
onExpandSpoiler={() => setIsSpoilerExpanded(true)}
onExpandSpoiler={data => setIsSpoilerExpanded(data)}
renderLocation={RenderLocation.StoryViewer}
text={caption.text}
/>
@ -941,7 +945,8 @@ export function StoryViewer({
i18n={i18n}
platform={platform}
isFormattingEnabled={isFormattingEnabled}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
isFormattingFlagEnabled={isFormattingFlagEnabled}
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
isInternalUser={isInternalUser}
group={group}
onClose={() => setCurrentViewTarget(null)}

View file

@ -91,7 +91,8 @@ export type PropsType = {
i18n: LocalizerType;
platform: string;
isFormattingEnabled: boolean;
isFormattingSpoilersEnabled: boolean;
isFormattingFlagEnabled: boolean;
isFormattingSpoilersFlagEnabled: boolean;
isInternalUser?: boolean;
onChangeViewTarget: (target: StoryViewTargetType) => unknown;
onClose: () => unknown;
@ -127,7 +128,8 @@ export function StoryViewsNRepliesModal({
i18n,
platform,
isFormattingEnabled,
isFormattingSpoilersEnabled,
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
isInternalUser,
onChangeViewTarget,
onClose,
@ -155,7 +157,7 @@ export function StoryViewsNRepliesModal({
// These states aren't in redux; they are meant to last only as long as this dialog.
const [revealedSpoilersById, setRevealedSpoilersById] = useState<
Record<string, boolean | undefined>
Record<string, Record<number, boolean> | undefined>
>({});
const [displayLimitById, setDisplayLimitById] = useState<
Record<string, number | undefined>
@ -239,7 +241,8 @@ export function StoryViewsNRepliesModal({
i18n={i18n}
inputApi={inputApiRef}
isFormattingEnabled={isFormattingEnabled}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
isFormattingFlagEnabled={isFormattingFlagEnabled}
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
moduleClassName="StoryViewsNRepliesModal__input"
onCloseLinkPreview={noop}
onEditorStateChange={({ messageText }) => {
@ -259,6 +262,7 @@ export function StoryViewsNRepliesModal({
firstName: authorTitle,
})
}
platform={platform}
sendCounter={0}
sortedGroupMembers={sortedGroupMembers}
theme={ThemeType.dark}
@ -310,7 +314,7 @@ export function StoryViewsNRepliesModal({
platform={platform}
id={reply.id}
isInternalUser={isInternalUser}
isSpoilerExpanded={revealedSpoilersById[reply.id] || false}
isSpoilerExpanded={revealedSpoilersById[reply.id] || {}}
messageExpanded={(messageId, displayLimit) => {
const update = {
...displayLimitById,
@ -322,10 +326,10 @@ export function StoryViewsNRepliesModal({
shouldCollapseAbove={shouldCollapse(reply, replies[index - 1])}
shouldCollapseBelow={shouldCollapse(reply, replies[index + 1])}
showContactModal={showContactModal}
showSpoiler={messageId => {
showSpoiler={(messageId, data) => {
const update = {
...revealedSpoilersById,
[messageId]: true,
[messageId]: data,
};
setRevealedSpoilersById(update);
}}
@ -504,14 +508,14 @@ type ReplyOrReactionMessageProps = {
platform: string;
id: string;
isInternalUser?: boolean;
isSpoilerExpanded: boolean;
isSpoilerExpanded: Record<number, boolean>;
onContextMenu?: (ev: React.MouseEvent) => void;
reply: ReplyType;
shouldCollapseAbove: boolean;
shouldCollapseBelow: boolean;
showContactModal: (contactId: string, conversationId?: string) => void;
messageExpanded: (messageId: string, displayLimit: number) => void;
showSpoiler: (messageId: string) => void;
showSpoiler: (messageId: string, data: Record<number, boolean>) => void;
};
function ReplyOrReactionMessage({

View file

@ -21,7 +21,7 @@ type EventWrapperPropsType = {
// disabled button. This uses native browser events to avoid that.
//
// See <https://lecstor.com/react-disabled-button-onmouseleave/>.
const TooltipEventWrapper = React.forwardRef<
export const TooltipEventWrapper = React.forwardRef<
HTMLSpanElement,
EventWrapperPropsType
>(function TooltipEvent({ onHoverChanged, children }, ref): JSX.Element {

View file

@ -218,7 +218,7 @@ export type PropsData = {
isTargetedCounter?: number;
isSelected: boolean;
isSelectMode: boolean;
isSpoilerExpanded?: boolean;
isSpoilerExpanded?: Record<number, boolean>;
direction: DirectionType;
timestamp: number;
status?: MessageStatusType;
@ -324,7 +324,7 @@ export type PropsActions = {
pushPanelForConversation: PushPanelForConversationActionType;
retryMessageSend: (messageId: string) => unknown;
showContactModal: (contactId: string, conversationId?: string) => void;
showSpoiler: (messageId: string) => void;
showSpoiler: (messageId: string, data: Record<number, boolean>) => void;
kickOffAttachmentDownload: (options: {
attachment: AttachmentType;
@ -1803,7 +1803,7 @@ export class Message extends React.PureComponent<Props, State> {
displayLimit={displayLimit}
i18n={i18n}
id={id}
isSpoilerExpanded={isSpoilerExpanded || false}
isSpoilerExpanded={isSpoilerExpanded || {}}
kickOffBodyDownload={() => {
if (!textAttachment) {
return;
@ -1816,7 +1816,7 @@ export class Message extends React.PureComponent<Props, State> {
messageExpanded={messageExpanded}
showConversation={showConversation}
renderLocation={RenderLocation.Timeline}
onExpandSpoiler={() => showSpoiler(id)}
onExpandSpoiler={data => showSpoiler(id, data)}
text={contents || ''}
textAttachment={textAttachment}
/>

View file

@ -23,7 +23,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
disableLinks: overrideProps.disableLinks || false,
direction: 'incoming',
i18n,
isSpoilerExpanded: overrideProps.isSpoilerExpanded || false,
isSpoilerExpanded: overrideProps.isSpoilerExpanded || {},
onExpandSpoiler: overrideProps.onExpandSpoiler || action('onExpandSpoiler'),
renderLocation: RenderLocation.Timeline,
showConversation:
@ -216,7 +216,7 @@ ComplexMessageBody.story = {
};
export function FormattingBasic(): JSX.Element {
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState({});
const props = createProps({
bodyRanges: [
@ -258,7 +258,7 @@ export function FormattingBasic(): JSX.Element {
},
],
isSpoilerExpanded,
onExpandSpoiler: () => setIsSpoilerExpanded(true),
onExpandSpoiler: data => setIsSpoilerExpanded(data),
text: '… Its in words that the magic is Abracadabra, Open Sesame, and the rest but the magic words in one story arent magical in the next. The real magic is to understand which words work, and when, and for what; the trick is to learn the trick. … And those words are made from the letters of our alphabet: a couple-dozen squiggles we can draw with the pen. This is the key! And the treasure, too, if we can only get our hands on it! Its as if as if the key to the treasure is the treasure!',
});
@ -272,7 +272,7 @@ export function FormattingBasic(): JSX.Element {
}
export function FormattingSpoiler(): JSX.Element {
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState({});
const props = createProps({
bodyRanges: [
@ -312,7 +312,7 @@ export function FormattingSpoiler(): JSX.Element {
},
],
isSpoilerExpanded,
onExpandSpoiler: () => setIsSpoilerExpanded(true),
onExpandSpoiler: data => setIsSpoilerExpanded(data),
text: "This is a very secret https://somewhere.com 💡 thing, \uFFFC and \uFFFC, that you shouldn't be able to read. Stay away!",
});
@ -322,9 +322,9 @@ export function FormattingSpoiler(): JSX.Element {
<hr />
<MessageBody {...props} disableLinks />
<hr />
<MessageBody {...props} isSpoilerExpanded={false} />
<MessageBody {...props} isSpoilerExpanded={{}} />
<hr />
<MessageBody {...props} disableLinks isSpoilerExpanded={false} />
<MessageBody {...props} disableLinks isSpoilerExpanded={{}} />
</>
);
}
@ -406,7 +406,7 @@ export function FormattingNesting(): JSX.Element {
}
export function FormattingComplex(): JSX.Element {
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState({});
const text =
'Computational processes \uFFFC are abstract beings that inhabit computers. ' +
'As they evolve, processes manipulate other abstract things called data. ' +
@ -461,7 +461,7 @@ export function FormattingComplex(): JSX.Element {
},
],
isSpoilerExpanded,
onExpandSpoiler: () => setIsSpoilerExpanded(true),
onExpandSpoiler: data => setIsSpoilerExpanded(data),
text,
});

View file

@ -24,9 +24,9 @@ export type Props = {
// If set, interactive elements will be left as plain text: links, mentions, spoilers
disableLinks?: boolean;
i18n: LocalizerType;
isSpoilerExpanded: boolean;
isSpoilerExpanded: Record<string, boolean>;
kickOffBodyDownload?: () => void;
onExpandSpoiler?: () => unknown;
onExpandSpoiler?: (data: Record<number, boolean>) => unknown;
onIncreaseTextLength?: () => unknown;
prefix?: string;
renderLocation: RenderLocation;

View file

@ -25,7 +25,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
displayLimit: overrideProps.displayLimit,
i18n,
id: 'some-id',
isSpoilerExpanded: overrideProps.isSpoilerExpanded === true,
isSpoilerExpanded: overrideProps.isSpoilerExpanded || {},
messageExpanded: action('messageExpanded'),
onExpandSpoiler: overrideProps.onExpandSpoiler || action('onExpandSpoiler'),
renderLocation: RenderLocation.Timeline,
@ -39,8 +39,8 @@ function MessageBodyReadMoreTest({
text: messageBodyText,
}: {
bodyRanges?: HydratedBodyRangesType;
isSpoilerExpanded?: boolean;
onExpandSpoiler?: () => void;
isSpoilerExpanded?: Record<number, boolean>;
onExpandSpoiler?: (data: Record<number, boolean>) => void;
text: string;
}): JSX.Element {
const [displayLimit, setDisplayLimit] = useState<number | undefined>();
@ -132,7 +132,7 @@ export function LongTextWithFormatting(): JSX.Element {
}
export function LongTextMostlySpoiler(): JSX.Element {
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState(false);
const [isSpoilerExpanded, setIsSpoilerExpanded] = React.useState({});
const bodyRanges = [
{
start: 7,
@ -148,7 +148,7 @@ export function LongTextMostlySpoiler(): JSX.Element {
bodyRanges={bodyRanges}
text={text}
isSpoilerExpanded={isSpoilerExpanded}
onExpandSpoiler={() => setIsSpoilerExpanded(true)}
onExpandSpoiler={data => setIsSpoilerExpanded(data)}
/>
);
}

View file

@ -42,7 +42,7 @@ const defaultMessage: MessageDataPropsType = {
isMessageRequestAccepted: true,
isSelected: false,
isSelectMode: false,
isSpoilerExpanded: false,
isSpoilerExpanded: {},
previews: [],
readStatus: ReadStatus.Read,
status: 'sent',

View file

@ -5,16 +5,18 @@ import React from 'react';
import type { ReactElement } from 'react';
import classNames from 'classnames';
import emojiRegex from 'emoji-regex';
import { sortBy } from 'lodash';
import { linkify, SUPPORTED_PROTOCOLS } from './Linkify';
import type {
BodyRange,
BodyRangesForDisplayType,
DisplayNode,
HydratedBodyRangeMention,
RangeNode,
} from '../../types/BodyRange';
import {
SPOILER_REPLACEMENT,
BodyRange,
insertRange,
collapseRangeTree,
groupContiguousSpoilers,
@ -30,6 +32,7 @@ const EMOJI_REGEXP = emojiRegex();
export enum RenderLocation {
ConversationList = 'ConversationList',
Quote = 'Quote',
MediaEditor = 'MediaEditor',
SearchResult = 'SearchResult',
StoryViewer = 'StoryViewer',
Timeline = 'Timeline',
@ -41,9 +44,9 @@ type Props = {
disableLinks: boolean;
emojiSizeClass: SizeClassType | undefined;
i18n: LocalizerType;
isSpoilerExpanded: boolean;
isSpoilerExpanded: Record<number, boolean>;
messageText: string;
onExpandSpoiler?: () => void;
onExpandSpoiler?: (data: Record<number, boolean>) => void;
onMentionTrigger: (conversationId: string) => void;
renderLocation: RenderLocation;
// Sometimes we're passed a string with a suffix (like '...'); we won't process that
@ -63,19 +66,32 @@ export function MessageTextRenderer({
renderLocation,
textLength,
}: Props): JSX.Element {
const links = disableLinks ? [] : extractLinks(messageText);
const tree = bodyRanges.reduce<ReadonlyArray<RangeNode>>(
(acc, range) => {
// Drop bodyRanges that don't apply. Read More means truncated strings.
if (range.start < textLength) {
return insertRange(range, acc);
}
return acc;
},
links.map(b => ({ ...b, ranges: [] }))
);
const nodes = collapseRangeTree({ tree, text: messageText });
const finalNodes = groupContiguousSpoilers(nodes);
const finalNodes = React.useMemo(() => {
const links = disableLinks ? [] : extractLinks(messageText);
// We need mentions to come last; they can't have children for proper rendering
const sortedRanges = sortBy(bodyRanges, range =>
BodyRange.isMention(range) ? 1 : 0
);
// Create range tree, dropping bodyRanges that don't apply. Read More means truncated
// strings.
const tree = sortedRanges.reduce<ReadonlyArray<RangeNode>>(
(acc, range) => {
if (range.start < textLength) {
return insertRange(range, acc);
}
return acc;
},
links.map(b => ({ ...b, ranges: [] }))
);
// Turn tree into flat list for proper spoiler rendering
const nodes = collapseRangeTree({ tree, text: messageText });
// Group all contigusous spoilers to create one parent spoiler element in the DOM
return groupContiguousSpoilers(nodes);
}, [bodyRanges, disableLinks, messageText, textLength]);
return (
<>
@ -114,16 +130,18 @@ function renderNode({
emojiSizeClass: SizeClassType | undefined;
i18n: LocalizerType;
isInvisible: boolean;
isSpoilerExpanded: boolean;
isSpoilerExpanded: Record<number, boolean>;
node: DisplayNode;
onExpandSpoiler?: () => void;
onExpandSpoiler?: (data: Record<number, boolean>) => void;
onMentionTrigger: ((conversationId: string) => void) | undefined;
renderLocation: RenderLocation;
}): ReactElement {
const key = node.start;
if (node.isSpoiler && node.spoilerChildren?.length) {
const isSpoilerHidden = Boolean(node.isSpoiler && !isSpoilerExpanded);
const isSpoilerHidden = Boolean(
node.isSpoiler && !isSpoilerExpanded[node.spoilerIndex || 0]
);
const content = node.spoilerChildren?.map(spoilerNode =>
renderNode({
direction,
@ -174,7 +192,10 @@ function renderNode({
if (onExpandSpoiler) {
event.preventDefault();
event.stopPropagation();
onExpandSpoiler();
onExpandSpoiler({
...isSpoilerExpanded,
[node.spoilerIndex || 0]: true,
});
}
}
}
@ -187,10 +208,19 @@ function renderNode({
}
event.preventDefault();
event.stopPropagation();
onExpandSpoiler?.();
onExpandSpoiler?.({
...isSpoilerExpanded,
[node.spoilerIndex || 0]: true,
});
}
}
>
<span
aria-hidden
className="MessageTextRenderer__formatting--spoiler--copy-target"
>
{SPOILER_REPLACEMENT}
</span>
<span aria-hidden>{content}</span>
</span>
);

View file

@ -114,7 +114,7 @@ const defaultMessageProps: TimelineMessagesProps = {
isMessageRequestAccepted: true,
isSelected: false,
isSelectMode: false,
isSpoilerExpanded: false,
isSpoilerExpanded: {},
toggleSelectMessage: action('toggleSelectMessage'),
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),

View file

@ -27,6 +27,8 @@ import { PaymentEventKind } from '../../types/Payment';
import { getPaymentEventNotificationText } from '../../messages/helpers';
import { RenderLocation } from './MessageTextRenderer';
const EMPTY_OBJECT = Object.freeze(Object.create(null));
export type Props = {
authorTitle: string;
conversationColor: ConversationColorType;
@ -359,7 +361,7 @@ export function Quote(props: Props): JSX.Element | null {
disableLinks
disableJumbomoji
i18n={i18n}
isSpoilerExpanded={false}
isSpoilerExpanded={EMPTY_OBJECT}
renderLocation={RenderLocation.Quote}
text={text}
/>

View file

@ -65,7 +65,7 @@ function mockMessageTimelineItem(
isMessageRequestAccepted: true,
isSelected: false,
isSelectMode: false,
isSpoilerExpanded: false,
isSpoilerExpanded: {},
previews: [],
readStatus: ReadStatus.Read,
canRetryDeleteForEveryone: true,

View file

@ -300,9 +300,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isSelectMode: isBoolean(overrideProps.isSelectMode)
? overrideProps.isSelectMode
: false,
isSpoilerExpanded: isBoolean(overrideProps.isSpoilerExpanded)
? overrideProps.isSpoilerExpanded
: false,
isSpoilerExpanded: overrideProps.isSpoilerExpanded || {},
isTapToView: overrideProps.isTapToView,
isTapToViewError: overrideProps.isTapToViewError,
isTapToViewExpired: overrideProps.isTapToViewExpired,

View file

@ -21,6 +21,7 @@ import type { BadgeType } from '../../badges/types';
import { isSignalConversation } from '../../util/isSignalConversation';
import { RenderLocation } from '../conversation/MessageTextRenderer';
const EMPTY_OBJECT = Object.freeze(Object.create(null));
const MESSAGE_STATUS_ICON_CLASS_NAME = `${MESSAGE_TEXT_CLASS_NAME}__status-icon`;
export const MessageStatuses = [
@ -149,7 +150,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
disableJumbomoji
disableLinks
i18n={i18n}
isSpoilerExpanded={false}
isSpoilerExpanded={{}}
prefix={draftPreview.prefix}
renderLocation={RenderLocation.ConversationList}
text={draftPreview.text}
@ -170,7 +171,7 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
disableJumbomoji
disableLinks
i18n={i18n}
isSpoilerExpanded={false}
isSpoilerExpanded={EMPTY_OBJECT}
prefix={lastMessage.prefix}
renderLocation={RenderLocation.ConversationList}
text={lastMessage.text}

View file

@ -3,6 +3,7 @@
import type { FunctionComponent, ReactNode } from 'react';
import React, { useCallback } from 'react';
import { noop } from 'lodash';
import { ContactName } from '../conversation/ContactName';
@ -21,6 +22,8 @@ import {
RenderLocation,
} from '../conversation/MessageTextRenderer';
const EMPTY_OBJECT = Object.freeze(Object.create(null));
export type PropsDataType = {
isSelected?: boolean;
isSearchingInConversation?: boolean;
@ -166,8 +169,8 @@ export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
disableLinks
emojiSizeClass={undefined}
i18n={i18n}
isSpoilerExpanded={false}
onMentionTrigger={() => null}
isSpoilerExpanded={EMPTY_OBJECT}
onMentionTrigger={noop}
renderLocation={RenderLocation.SearchResult}
textLength={cleanedSnippet.length}
/>

View file

@ -125,6 +125,7 @@ export async function sendStory(
}
const attachments = originalMessage.get('attachments') || [];
const bodyRanges = originalMessage.get('bodyRanges')?.slice();
const [attachment] = attachments;
if (!attachment) {
@ -180,6 +181,7 @@ export async function sendStory(
// attributes inside it.
originalStoryMessage = await messaging.getStoryMessage({
allowsReplies: true,
bodyRanges,
fileAttachment,
groupV2,
textAttachment,
@ -317,6 +319,7 @@ export async function sendStory(
);
const storyMessage = new Proto.StoryMessage();
storyMessage.bodyRanges = originalStoryMessage.bodyRanges;
storyMessage.profileKey = originalStoryMessage.profileKey;
storyMessage.fileAttachment = originalStoryMessage.fileAttachment;
storyMessage.textAttachment = originalStoryMessage.textAttachment;

View file

@ -4164,6 +4164,7 @@ export class ConversationModel extends window.Backbone
draftTimestamp: null,
quotedMessageId: undefined,
lastMessageAuthor: message.getAuthorText(),
lastMessageBodyRanges: message.get('bodyRanges'),
lastMessage: message.getNotificationText(),
lastMessageStatus: 'sending' as const,
};

View file

@ -204,6 +204,15 @@ export class EmojiCompletion {
return PASS_THROUGH;
}
getAttributesForInsert(index: number): Record<string, unknown> {
const character = index > 0 ? index - 1 : 0;
const contents = this.quill.getContents(character, 1);
return contents.ops.reduce(
(acc, op) => ({ acc, ...op.attributes }),
{} as Record<string, unknown>
);
}
completeEmoji(): void {
const range = this.quill.getSelection();
@ -241,7 +250,9 @@ export class EmojiCompletion {
const delta = new Delta().retain(index).delete(range).insert({ emoji });
if (withTrailingSpace) {
this.quill.updateContents(delta.insert(' '), 'user');
// The extra space we add won't be formatted unless we manually provide attributes
const attributes = this.getAttributesForInsert(range - 1);
this.quill.updateContents(delta.insert(' ', attributes), 'user');
this.quill.setSelection(index + 2, 0, 'user');
} else {
this.quill.updateContents(delta, 'user');

View file

@ -4,12 +4,16 @@
import Delta from 'quill-delta';
import { insertEmojiOps } from '../util';
export const matchEmojiImage = (node: Element): Delta => {
if (node.classList.contains('emoji')) {
const emoji = node.getAttribute('title');
export const matchEmojiImage = (node: Element, delta: Delta): Delta => {
if (
(node.classList.contains('emoji') ||
node.classList.contains('module-emoji__image--16px')) &&
!node.classList.contains('emoji--invisible')
) {
const emoji = node.getAttribute('aria-label');
return new Delta().insert({ emoji });
}
return new Delta();
return delta;
};
export const matchEmojiBlot = (node: HTMLElement, delta: Delta): Delta => {
@ -20,14 +24,6 @@ export const matchEmojiBlot = (node: HTMLElement, delta: Delta): Delta => {
return delta;
};
export const matchReactEmoji = (node: HTMLElement, delta: Delta): Delta => {
if (node.classList.contains('module-emoji')) {
const emoji = node.innerText.trim();
return new Delta().insert({ emoji });
}
return delta;
};
export const matchEmojiText = (node: Text): Delta => {
const nodeAsInsert = { insert: node.data };

View 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;
};

View file

@ -2,21 +2,36 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type Quill from 'quill';
import type { KeyboardContext } from 'quill';
import React from 'react';
import classNames from 'classnames';
import { Popper } from 'react-popper';
import { createPortal } from 'react-dom';
import type { VirtualElement } from '@popperjs/core';
import { pick } from 'lodash';
import * as log from '../../logging/log';
import * as Errors from '../../types/errors';
import type { LocalizerType } from '../../types/Util';
import { handleOutsideClick } from '../../util/handleOutsideClick';
import { SECOND } from '../../util/durations/constants';
const BUTTON_HOVER_TIMEOUT = 2 * SECOND;
// Note: Keyboard shortcuts are defined in the constructor below, and when using
// <FormattingButton /> below. They're also referenced in ShortcutGuide.tsx.
const BOLD_CHAR = 'B';
const ITALIC_CHAR = 'I';
const MONOSPACE_CHAR = 'E';
const SPOILER_CHAR = 'B';
const STRIKETHROUGH_CHAR = 'X';
type FormattingPickerOptions = {
i18n: LocalizerType;
isMenuEnabled: boolean;
isEnabled: boolean;
isSpoilersEnabled: boolean;
platform: string;
setFormattingChooserElement: (element: JSX.Element | null) => void;
};
@ -28,9 +43,50 @@ export enum QuillFormattingStyle {
spoiler = 'spoiler',
}
export class FormattingMenu {
lastSelection: { start: number; end: number } | undefined;
function findMaximumRect(rects: DOMRectList):
| {
x: number;
y: number;
height: number;
width: number;
}
| undefined {
const first = rects[0];
if (!first) {
return undefined;
}
let result = pick(first, ['top', 'left', 'right', 'bottom']);
for (let i = 1, max = rects.length; i < max; i += 1) {
const rect = rects[i];
result = {
top: Math.min(rect.top, result.top),
left: Math.min(rect.left, result.left),
bottom: Math.max(rect.bottom, result.bottom),
right: Math.max(rect.right, result.right),
};
}
return {
x: result.left,
y: result.top,
height: result.bottom - result.top,
width: result.right - result.left,
};
}
function getMetaKey(platform: string, i18n: LocalizerType) {
const isMacOS = platform === 'darwin';
if (isMacOS) {
return '⌘';
}
return i18n('icu:Keyboard--Key--ctrl');
}
export class FormattingMenu {
options: FormattingPickerOptions;
outsideClickDestructor?: () => void;
@ -51,19 +107,21 @@ export class FormattingMenu {
// We override these keybindings, which means that we need to move their priority
// above the built-in shortcuts, which don't exactly do what we want.
const boldChar = 'B';
const boldCharCode = boldChar.charCodeAt(0);
this.quill.keyboard.addBinding({ key: boldChar, shortKey: true }, () =>
this.toggleForStyle(QuillFormattingStyle.bold)
const boldCharCode = BOLD_CHAR.charCodeAt(0);
this.quill.keyboard.addBinding(
{ key: BOLD_CHAR, shortKey: true },
(_range, context) =>
this.toggleForStyle(QuillFormattingStyle.bold, context)
);
quill.keyboard.bindings[boldCharCode].unshift(
quill.keyboard.bindings[boldCharCode].pop()
);
const italicChar = 'I';
const italicCharCode = italicChar.charCodeAt(0);
this.quill.keyboard.addBinding({ key: italicChar, shortKey: true }, () =>
this.toggleForStyle(QuillFormattingStyle.italic)
const italicCharCode = ITALIC_CHAR.charCodeAt(0);
this.quill.keyboard.addBinding(
{ key: ITALIC_CHAR, shortKey: true },
(_range, context) =>
this.toggleForStyle(QuillFormattingStyle.italic, context)
);
quill.keyboard.bindings[italicCharCode].unshift(
quill.keyboard.bindings[italicCharCode].pop()
@ -71,16 +129,20 @@ export class FormattingMenu {
// No need for changing priority for these new keybindings
this.quill.keyboard.addBinding({ key: 'E', shortKey: true }, () =>
this.toggleForStyle(QuillFormattingStyle.monospace)
this.quill.keyboard.addBinding(
{ key: MONOSPACE_CHAR, shortKey: true },
(_range, context) =>
this.toggleForStyle(QuillFormattingStyle.monospace, context)
);
this.quill.keyboard.addBinding(
{ key: 'X', shortKey: true, shiftKey: true },
() => this.toggleForStyle(QuillFormattingStyle.strike)
{ key: STRIKETHROUGH_CHAR, shortKey: true, shiftKey: true },
(_range, context) =>
this.toggleForStyle(QuillFormattingStyle.strike, context)
);
this.quill.keyboard.addBinding(
{ key: 'B', shortKey: true, shiftKey: true },
() => this.toggleForStyle(QuillFormattingStyle.spoiler)
{ key: SPOILER_CHAR, shortKey: true, shiftKey: true },
(_range, context) =>
this.toggleForStyle(QuillFormattingStyle.spoiler, context)
);
}
@ -94,8 +156,7 @@ export class FormattingMenu {
}
onEditorChange(): void {
if (!this.options.isEnabled) {
this.lastSelection = undefined;
if (!this.options.isMenuEnabled) {
this.referenceElement = undefined;
this.render();
@ -104,38 +165,19 @@ export class FormattingMenu {
const isFocused = this.quill.hasFocus();
if (!isFocused) {
this.lastSelection = undefined;
this.referenceElement = undefined;
this.render();
return;
}
const previousSelection = this.lastSelection;
const quillSelection = this.quill.getSelection();
this.lastSelection =
quillSelection && quillSelection.length > 0
? {
start: quillSelection.index,
end: quillSelection.index + quillSelection.length,
}
: undefined;
if (!this.lastSelection) {
if (!quillSelection || quillSelection.length === 0) {
this.referenceElement = undefined;
} else {
const noOverlapWithNewSelection =
previousSelection &&
(this.lastSelection.end < previousSelection.start ||
this.lastSelection.start > previousSelection.end);
const newSelectionStartsEarlier =
previousSelection && this.lastSelection.start < previousSelection.start;
if (noOverlapWithNewSelection || newSelectionStartsEarlier) {
this.referenceElement = undefined;
}
// a virtual reference to the text we are trying to format
this.referenceElement = this.referenceElement || {
this.referenceElement = {
getBoundingClientRect() {
const selection = window.getSelection();
@ -148,26 +190,37 @@ export class FormattingMenu {
const editorElement = activeElement?.closest(
'.module-composition-input__input'
);
const editorRect = editorElement?.getClientRects()[0];
if (!editorRect) {
log.warn('No editor rect when showing formatting menu');
return new DOMRect();
}
const rect = range.getClientRects()[0];
const rect = findMaximumRect(range.getClientRects());
if (!rect) {
log.warn('No maximum rect when showing formatting menu');
return new DOMRect();
}
// If we've scrolled down and the top of the composer text is invisible, above
// where the editor ends, we fix the popover so it stays connected to the
// visible editor. Important for the 'Cmd-A' scenario when scrolled down.
const updatedY = Math.max(
(editorElement?.getClientRects()[0]?.y || 0) - 10,
(rect?.y || 0) - 10
(editorRect.y || 0) - 10,
(rect.y || 0) - 10
);
const updatedHeight = rect.height + (rect.y - updatedY);
return DOMRect.fromRect({
x: rect.x,
y: updatedY,
height: rect.height,
height: updatedHeight,
width: rect.width,
});
}
log.warn('No selection range when formatting text');
return new DOMRect(); // don't crash just because we couldn't get a rectangle
log.warn('No selection range when showing formatting menu');
return new DOMRect();
},
};
}
@ -184,9 +237,21 @@ export class FormattingMenu {
return contents.ops.every(op => op.attributes?.[style]);
}
toggleForStyle(style: QuillFormattingStyle): void {
toggleForStyle(style: QuillFormattingStyle, context?: KeyboardContext): void {
if (!this.options.isEnabled) {
return;
}
if (
!this.options.isSpoilersEnabled &&
style === QuillFormattingStyle.spoiler
) {
return;
}
try {
const isEnabled = this.isStyleEnabledInSelection(style);
const isEnabled = context
? Boolean(context.format[style])
: this.isStyleEnabledInSelection(style);
if (isEnabled === undefined) {
return;
}
@ -197,7 +262,7 @@ export class FormattingMenu {
}
render(): void {
if (!this.lastSelection) {
if (!this.referenceElement) {
this.outsideClickDestructor?.();
this.outsideClickDestructor = undefined;
@ -206,123 +271,103 @@ export class FormattingMenu {
return;
}
const { i18n, isSpoilersEnabled } = this.options;
const { i18n, isSpoilersEnabled, platform } = this.options;
const metaKey = getMetaKey(platform, i18n);
const shiftKey = i18n('icu:Keyboard--Key--shift');
// showing the popup format menu
const isStyleEnabledInSelection = this.isStyleEnabledInSelection.bind(this);
const toggleForStyle = this.toggleForStyle.bind(this);
const element = createPortal(
<Popper placement="top-start" referenceElement={this.referenceElement}>
{({ ref, style }) => (
<div
ref={ref}
className="module-composition-input__format-menu"
style={style}
role="menu"
tabIndex={0}
>
<button
type="button"
className="module-composition-input__format-menu__item"
aria-label={i18n('icu:Keyboard--composer--bold')}
onClick={event => {
event.preventDefault();
event.stopPropagation();
this.toggleForStyle(QuillFormattingStyle.bold);
}}
<Popper
placement="top"
referenceElement={this.referenceElement}
modifiers={[
{
name: 'fadeIn',
enabled: true,
phase: 'write',
fn({ state }) {
// eslint-disable-next-line no-param-reassign
state.elements.popper.style.opacity = '1';
},
},
]}
>
{({ ref, style }) => {
const [hasLongHovered, setHasLongHovered] =
React.useState<boolean>(false);
const onLongHover = React.useCallback(
(value: boolean) => {
setHasLongHovered(value);
},
[setHasLongHovered]
);
return (
<div
ref={ref}
className="module-composition-input__format-menu"
style={style}
role="menu"
tabIndex={0}
onMouseLeave={() => setHasLongHovered(false)}
>
<div
className={classNames(
'module-composition-input__format-menu__item__icon',
'module-composition-input__format-menu__item__icon--bold',
this.isStyleEnabledInSelection(QuillFormattingStyle.bold)
? 'module-composition-input__format-menu__item__icon--active'
: null
)}
<FormattingButton
hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection}
label={i18n('icu:Keyboard--composer--bold')}
onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${BOLD_CHAR}`}
popupGuideText={i18n('icu:FormatMenu--guide--bold')}
style={QuillFormattingStyle.bold}
toggleForStyle={toggleForStyle}
/>
</button>
<button
type="button"
className="module-composition-input__format-menu__item"
aria-label={i18n('icu:Keyboard--composer--italic')}
onClick={event => {
event.preventDefault();
event.stopPropagation();
this.toggleForStyle(QuillFormattingStyle.italic);
}}
>
<div
className={classNames(
'module-composition-input__format-menu__item__icon',
'module-composition-input__format-menu__item__icon--italic',
this.isStyleEnabledInSelection(QuillFormattingStyle.italic)
? 'module-composition-input__format-menu__item__icon--active'
: null
)}
<FormattingButton
hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection}
label={i18n('icu:Keyboard--composer--italic')}
onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${ITALIC_CHAR}`}
popupGuideText={i18n('icu:FormatMenu--guide--italic')}
style={QuillFormattingStyle.italic}
toggleForStyle={toggleForStyle}
/>
</button>
<button
type="button"
className="module-composition-input__format-menu__item"
aria-label={i18n('icu:Keyboard--composer--strikethrough')}
onClick={event => {
event.preventDefault();
event.stopPropagation();
this.toggleForStyle(QuillFormattingStyle.strike);
}}
>
<div
className={classNames(
'module-composition-input__format-menu__item__icon',
'module-composition-input__format-menu__item__icon--strikethrough',
this.isStyleEnabledInSelection(QuillFormattingStyle.strike)
? 'module-composition-input__format-menu__item__icon--active'
: null
)}
<FormattingButton
hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection}
label={i18n('icu:Keyboard--composer--strikethrough')}
onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${shiftKey} + ${STRIKETHROUGH_CHAR}`}
popupGuideText={i18n('icu:FormatMenu--guide--strikethrough')}
style={QuillFormattingStyle.strike}
toggleForStyle={toggleForStyle}
/>
</button>
<button
type="button"
className="module-composition-input__format-menu__item"
aria-label={i18n('icu:Keyboard--composer--monospace')}
onClick={event => {
event.preventDefault();
event.stopPropagation();
this.toggleForStyle(QuillFormattingStyle.monospace);
}}
>
<div
className={classNames(
'module-composition-input__format-menu__item__icon',
'module-composition-input__format-menu__item__icon--monospace',
this.isStyleEnabledInSelection(QuillFormattingStyle.monospace)
? 'module-composition-input__format-menu__item__icon--active'
: null
)}
<FormattingButton
hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection}
label={i18n('icu:Keyboard--composer--monospace')}
onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${MONOSPACE_CHAR}`}
popupGuideText={i18n('icu:FormatMenu--guide--monospace')}
style={QuillFormattingStyle.monospace}
toggleForStyle={toggleForStyle}
/>
</button>
{isSpoilersEnabled ? (
<button
type="button"
className="module-composition-input__format-menu__item"
aria-label={i18n('icu:Keyboard--composer--spoiler')}
onClick={event => {
event.preventDefault();
event.stopPropagation();
this.toggleForStyle(QuillFormattingStyle.spoiler);
}}
>
<div
className={classNames(
'module-composition-input__format-menu__item__icon',
'module-composition-input__format-menu__item__icon--spoiler',
this.isStyleEnabledInSelection(QuillFormattingStyle.spoiler)
? 'module-composition-input__format-menu__item__icon--active'
: null
)}
{isSpoilersEnabled ? (
<FormattingButton
hasLongHovered={hasLongHovered}
isStyleEnabledInSelection={isStyleEnabledInSelection}
onLongHover={onLongHover}
popupGuideShortcut={`${metaKey} + ${shiftKey} + ${SPOILER_CHAR}`}
popupGuideText={i18n('icu:FormatMenu--guide--spoiler')}
label={i18n('icu:Keyboard--composer--spoiler')}
style={QuillFormattingStyle.spoiler}
toggleForStyle={toggleForStyle}
/>
</button>
) : null}
</div>
)}
) : null}
</div>
);
}}
</Popper>,
this.root
);
@ -342,3 +387,92 @@ export class FormattingMenu {
this.options.setFormattingChooserElement(element);
}
}
function FormattingButton({
hasLongHovered,
isStyleEnabledInSelection,
label,
onLongHover,
popupGuideText,
popupGuideShortcut,
style,
toggleForStyle,
}: {
hasLongHovered: boolean;
isStyleEnabledInSelection: (
style: QuillFormattingStyle
) => boolean | undefined;
label: string;
onLongHover: (value: boolean) => unknown;
popupGuideText: string;
popupGuideShortcut: string;
style: QuillFormattingStyle;
toggleForStyle: (style: QuillFormattingStyle) => unknown;
}): JSX.Element {
const buttonRef = React.useRef<HTMLButtonElement | null>(null);
const timerRef = React.useRef<NodeJS.Timeout | undefined>();
const [isHovered, setIsHovered] = React.useState<boolean>(false);
return (
<>
{hasLongHovered && isHovered && buttonRef.current ? (
<Popper placement="top" referenceElement={buttonRef.current}>
{({ ref, style: popperStyles }) => (
<div
className="module-composition-input__format-menu__item__popover"
ref={ref}
style={popperStyles}
>
{popupGuideText}
<div className="module-composition-input__format-menu__item__popover__shortcut">
{popupGuideShortcut}
</div>
</div>
)}
</Popper>
) : null}
<button
ref={buttonRef}
type="button"
className="module-composition-input__format-menu__item"
aria-label={label}
onClick={event => {
event.preventDefault();
event.stopPropagation();
onLongHover(false);
toggleForStyle(style);
}}
onMouseEnter={() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
}
timerRef.current = setTimeout(() => {
onLongHover(true);
}, BUTTON_HOVER_TIMEOUT);
setIsHovered(true);
}}
onMouseLeave={() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = undefined;
}
setIsHovered(false);
}}
>
<div
className={classNames(
'module-composition-input__format-menu__item__icon',
`module-composition-input__format-menu__item__icon--${style}`,
isStyleEnabledInSelection(style)
? 'module-composition-input__format-menu__item__icon--active'
: null
)}
/>
</button>
</>
);
}

View file

@ -184,16 +184,31 @@ export class MentionCompletion {
}
}
getAttributesForInsert(index: number): Record<string, unknown> {
const character = index > 0 ? index - 1 : 0;
const contents = this.quill.getContents(character, 1);
return contents.ops.reduce(
(acc, op) => ({ acc, ...op.attributes }),
{} as Record<string, unknown>
);
}
insertMention(
mention: ConversationType,
index: number,
range: number,
withTrailingSpace = false
): void {
const delta = new Delta().retain(index).delete(range).insert({ mention });
// The mention + space we add won't be formatted unless we manually provide attributes
const attributes = this.getAttributesForInsert(range - 1);
const delta = new Delta()
.retain(index)
.delete(range)
.insert({ mention }, attributes);
if (withTrailingSpace) {
this.quill.updateContents(delta.insert(' '), 'user');
this.quill.updateContents(delta.insert(' ', attributes), 'user');
this.quill.setSelection(index + 2, 0, 'user');
} else {
this.quill.updateContents(delta, 'user');

View file

@ -13,7 +13,10 @@ export const matchMention =
if (memberRepository) {
const { title } = node.dataset;
if (node.classList.contains('MessageBody__at-mention')) {
if (
node.classList.contains('MessageBody__at-mention') &&
!node.classList.contains('MessageBody__at-mention--invisible')
) {
const { id } = node.dataset;
const conversation = memberRepository.getMemberById(id);

View file

@ -4,24 +4,6 @@
import type Quill from 'quill';
import Delta from 'quill-delta';
import { getTextFromOps } from '../util';
const getSelectionHTML = () => {
const selection = window.getSelection();
if (selection == null) {
return '';
}
const range = selection.getRangeAt(0);
const contents = range.cloneContents();
const div = document.createElement('div');
div.appendChild(contents);
return div.innerHTML;
};
const replaceAngleBrackets = (text: string) => {
const entities: Array<[RegExp, string]> = [
[/&/g, '&amp;'],
@ -41,47 +23,14 @@ export class SignalClipboard {
constructor(quill: Quill) {
this.quill = quill;
this.quill.root.addEventListener('copy', e => this.onCaptureCopy(e, false));
this.quill.root.addEventListener('cut', e => this.onCaptureCopy(e, true));
this.quill.root.addEventListener('paste', e => this.onCapturePaste(e));
const clipboard = this.quill.getModule('clipboard');
// We don't want any of the default matchers!
clipboard.matchers = clipboard.matchers.slice(11);
}
onCaptureCopy(event: ClipboardEvent, isCut = false): void {
event.preventDefault();
if (event.clipboardData == null) {
return;
}
const range = this.quill.getSelection();
if (range == null) {
return;
}
const contents = this.quill.getContents(range.index, range.length);
if (contents == null) {
return;
}
const { ops } = contents;
if (!ops || !ops.length) {
return;
}
const text = getTextFromOps(ops);
const html = getSelectionHTML();
event.clipboardData.setData('text/plain', text);
event.clipboardData.setData('text/signal', html);
if (isCut) {
this.quill.deleteText(range.index, range.length, 'user');
}
}
// TODO: do we need this anymore, given that we aren't using signal/html?
onCapturePaste(event: ClipboardEvent): void {
if (event.clipboardData == null) {
return;
@ -97,7 +46,7 @@ export class SignalClipboard {
}
const text = event.clipboardData.getData('text/plain');
const html = event.clipboardData.getData('text/signal');
const html = event.clipboardData.getData('text/html');
const clipboardDelta = html
? clipboard.convert(html)

9
ts/quill/types.d.ts vendored
View file

@ -50,6 +50,7 @@ declare module 'quill' {
interface ClipboardStatic {
convert(html: string): UpdatedDelta;
matchers: Array<unknown>;
}
interface SelectionStatic {
@ -80,13 +81,17 @@ declare module 'quill' {
getModule(module: string): unknown;
selection: SelectionStatic;
options: Record<string, unknown>;
}
export type KeyboardContext = {
format: Record<string, unknown>;
};
interface KeyboardStatic {
addBinding(
key: UpdatedKey,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
callback: (range: RangeStatic, context: any) => void
callback: (range: RangeStatic, context: KeyboardContext) => void
): void;
// in-code reference missing in @types
bindings: Record<string | number, Array<unknown>>;

View file

@ -185,7 +185,7 @@ export type MessageType = MessageAttributesType & {
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type MessageWithUIFieldsType = MessageAttributesType & {
displayLimit?: number;
isSpoilerExpanded?: boolean;
isSpoilerExpanded?: Record<number, boolean>;
};
export const ConversationTypes = ['direct', 'group'] as const;
@ -737,6 +737,7 @@ export type ShowSpoilerActionType = ReadonlyDeep<{
type: typeof SHOW_SPOILER;
payload: {
id: string;
data: Record<number, boolean>;
};
}>;
@ -2740,11 +2741,15 @@ function messageExpanded(
},
};
}
function showSpoiler(id: string): ShowSpoilerActionType {
function showSpoiler(
id: string,
data: Record<number, boolean>
): ShowSpoilerActionType {
return {
type: SHOW_SPOILER,
payload: {
id,
data,
},
};
}
@ -4981,7 +4986,7 @@ export function reducer(
};
}
if (action.type === SHOW_SPOILER) {
const { id } = action.payload;
const { id, data } = action.payload;
const existingMessage = state.messagesLookup[id];
if (!existingMessage) {
@ -4990,7 +4995,7 @@ export function reducer(
const updatedMessage = {
...existingMessage,
isSpoilerExpanded: true,
isSpoilerExpanded: data,
};
return {

View file

@ -617,7 +617,8 @@ function replyToStory(
function sendStoryMessage(
listIds: Array<UUIDStringType>,
conversationIds: Array<string>,
attachment: AttachmentType
attachment: AttachmentType,
bodyRanges: DraftBodyRanges | undefined
): ThunkAction<
void,
RootStateType,
@ -661,7 +662,12 @@ function sendStoryMessage(
}
try {
await doSendStoryMessage(listIds, conversationIds, attachment);
await doSendStoryMessage(
listIds,
conversationIds,
attachment,
bodyRanges
);
// Note: Only when we've successfully queued the message do we dismiss the story
// composer view.

View file

@ -15,7 +15,12 @@ import { imageToBlurHash } from '../../util/imageToBlurHash';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { selectRecentEmojis } from '../selectors/emojis';
import { getIntl, getTheme, getUserConversationId } from '../selectors/user';
import {
getIntl,
getPlatform,
getTheme,
getUserConversationId,
} from '../selectors/user';
import { getEmojiSkinTone, getTextFormattingEnabled } from '../selectors/items';
import {
getConversationSelector,
@ -52,6 +57,7 @@ export type CompositionAreaPropsType = ExternalProps & ComponentPropsType;
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props;
const platform = getPlatform(state);
const conversationSelector = getConversationSelector(state);
const conversation = conversationSelector(id);
@ -112,11 +118,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const selectedMessageIds = getSelectedMessageIds(state);
const isFormattingEnabled =
getIsFormattingFlagEnabled(state) && getTextFormattingEnabled(state);
const isFormattingSpoilersEnabled =
getIsFormattingSpoilersFlagEnabled(state) &&
getTextFormattingEnabled(state);
const isFormattingEnabled = getTextFormattingEnabled(state);
const isFormattingFlagEnabled = getIsFormattingFlagEnabled(state);
const isFormattingSpoilersFlagEnabled =
getIsFormattingSpoilersFlagEnabled(state);
return {
// Base
@ -126,9 +131,11 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
getPreferredBadge: getPreferredBadgeSelector(state),
i18n: getIntl(state),
isDisabled,
isFormattingSpoilersEnabled,
isFormattingEnabled,
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
messageCompositionId,
platform,
sendCounter,
theme: getTheme(state),

View file

@ -5,9 +5,7 @@ import React from 'react';
import { useSelector } from 'react-redux';
import type { CompositionTextAreaProps } from '../../components/CompositionTextArea';
import { CompositionTextArea } from '../../components/CompositionTextArea';
import type { LocalizerType } from '../../types/I18N';
import type { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
import { getIntl, getPlatform } from '../selectors/user';
import { useActions as useEmojiActions } from '../ducks/emojis';
import { useActions as useItemsActions } from '../ducks/items';
import { getPreferredBadgeSelector } from '../selectors/badges';
@ -35,30 +33,32 @@ export type SmartCompositionTextAreaProps = Pick<
export function SmartCompositionTextArea(
props: SmartCompositionTextAreaProps
): JSX.Element {
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const i18n = useSelector(getIntl);
const platform = useSelector(getPlatform);
const { onUseEmoji: onPickEmoji } = useEmojiActions();
const { onSetSkinTone } = useItemsActions();
const { onTextTooLong } = useComposerActions();
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
const isFormattingOptionEnabled = useSelector(getTextFormattingEnabled);
const isFormattingEnabled =
useSelector(getIsFormattingFlagEnabled) && isFormattingOptionEnabled;
const isFormattingSpoilersEnabled =
useSelector(getIsFormattingSpoilersFlagEnabled) &&
isFormattingOptionEnabled;
const isFormattingEnabled = useSelector(getTextFormattingEnabled);
const isFormattingFlagEnabled = useSelector(getIsFormattingFlagEnabled);
const isFormattingSpoilersFlagEnabled = useSelector(
getIsFormattingSpoilersFlagEnabled
);
return (
<CompositionTextArea
{...props}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
isFormattingEnabled={isFormattingEnabled}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
isFormattingFlagEnabled={isFormattingFlagEnabled}
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
onPickEmoji={onPickEmoji}
onSetSkinTone={onSetSkinTone}
getPreferredBadge={getPreferredBadge}
onTextTooLong={onTextTooLong}
platform={platform}
/>
);
}

View file

@ -94,12 +94,11 @@ export function SmartStoryViewer(): JSX.Element | null {
getHasStoryViewReceiptSetting
);
const isFormattingOptionEnabled = useSelector(getTextFormattingEnabled);
const isFormattingEnabled =
useSelector(getIsFormattingFlagEnabled) && isFormattingOptionEnabled;
const isFormattingSpoilersEnabled =
useSelector(getIsFormattingSpoilersFlagEnabled) &&
isFormattingOptionEnabled;
const isFormattingEnabled = useSelector(getTextFormattingEnabled);
const isFormattingFlagEnabled = useSelector(getIsFormattingFlagEnabled);
const isFormattingSpoilersFlagEnabled = useSelector(
getIsFormattingSpoilersFlagEnabled
);
const { pauseVoiceNotePlayer } = useAudioPlayerActions();
@ -127,7 +126,8 @@ export function SmartStoryViewer(): JSX.Element | null {
platform={platform}
isInternalUser={internalUser}
isFormattingEnabled={isFormattingEnabled}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
isFormattingFlagEnabled={isFormattingFlagEnabled}
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
isSignalConversation={isSignalConversation({
id: conversationStory.conversationId,
})}

View file

@ -101,6 +101,64 @@ describe('getTextAndRangesFromOps', () => {
});
});
describe('given formatting', () => {
it('handles trimming at the end of the message', () => {
const ops = [
{
insert: 'Text with trailing ',
attributes: { bold: true },
},
{
insert: 'whitespace ',
attributes: { bold: true, italic: true },
},
];
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
assert.equal(text, 'Text with trailing whitespace');
assert.equal(bodyRanges.length, 2);
assert.deepEqual(bodyRanges, [
{
start: 0,
length: 29,
style: BodyRange.Style.BOLD,
},
{
start: 19,
length: 10,
style: BodyRange.Style.ITALIC,
},
]);
});
it('handles trimming at beginning of the message', () => {
const ops = [
{
insert: ' Text with leading ',
attributes: { bold: true },
},
{
insert: 'whitespace!!',
attributes: { bold: true, italic: true },
},
];
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
assert.equal(text, 'Text with leading whitespace!!');
assert.equal(bodyRanges.length, 2);
assert.deepEqual(bodyRanges, [
{
start: 0,
length: 30,
style: BodyRange.Style.BOLD,
},
{
start: 18,
length: 12,
style: BodyRange.Style.ITALIC,
},
]);
});
});
describe('given text, emoji, and mentions', () => {
it('returns the trimmed text with placeholders and mentions', () => {
const ops = [

View file

@ -696,21 +696,27 @@ export default class MessageSender {
async getStoryMessage({
allowsReplies,
bodyRanges,
fileAttachment,
groupV2,
profileKey,
textAttachment,
}: {
allowsReplies?: boolean;
bodyRanges?: Array<RawBodyRange>;
fileAttachment?: UploadedAttachmentType;
groupV2?: GroupV2InfoType;
profileKey: Uint8Array;
textAttachment?: OutgoingTextAttachmentType;
}): Promise<Proto.StoryMessage> {
const storyMessage = new Proto.StoryMessage();
storyMessage.profileKey = profileKey;
if (fileAttachment) {
if (bodyRanges) {
storyMessage.bodyRanges = bodyRanges;
}
try {
storyMessage.fileAttachment = fileAttachment;
} catch (error) {

View file

@ -326,6 +326,7 @@ export type DisplayNode = {
isKeywordHighlight?: boolean;
// Only for spoilers, only to represent contiguous groupings
spoilerIndex?: number;
spoilerChildren?: ReadonlyArray<DisplayNode>;
};
type PartialDisplayNode = Omit<
@ -450,15 +451,18 @@ export function groupContiguousSpoilers(
const result: Array<DisplayNode> = [];
let spoilerContainer: DisplayNode | undefined;
let spoilerIndex = 0;
nodes.forEach(node => {
if (node.isSpoiler) {
if (!spoilerContainer) {
spoilerContainer = {
...node,
spoilerIndex,
isSpoiler: true,
spoilerChildren: [],
};
spoilerIndex += 1;
result.push(spoilerContainer);
}
if (spoilerContainer) {
@ -567,7 +571,7 @@ export function processBodyRangesForSearchResult({
};
}
const SPOILER_REPLACEMENT = '■■■■';
export const SPOILER_REPLACEMENT = '■■■■';
export function applyRangesForText({
text,

View file

@ -693,9 +693,8 @@
"rule": "thenify-multiArgs",
"path": "node_modules/default-browser-id/node_modules/pify/index.js",
"line": "\t\t\t\t} else if (opts.multiArgs) {",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-04-20T16:43:40.643Z",
"reasonDetail": "<optional>"
"reasonCategory": "usageTrusted",
"updated": "2023-04-20T16:43:40.643Z"
},
{
"rule": "DOM-outerHTML",
@ -2370,9 +2369,8 @@
"rule": "React-useRef",
"path": "ts/components/conversation/InlineNotificationWrapper.tsx",
"line": " const focusRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-04-12T15:51:28.066Z",
"reasonDetail": "<optional>"
"reasonCategory": "usageTrusted",
"updated": "2023-04-12T15:51:28.066Z"
},
{
"rule": "React-createRef",
@ -2402,17 +2400,15 @@
"rule": "React-useRef",
"path": "ts/components/conversation/MessageDetail.tsx",
"line": " const focusRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-04-12T15:51:28.066Z",
"reasonDetail": "<optional>"
"reasonCategory": "usageTrusted",
"updated": "2023-04-12T15:51:28.066Z"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/MessageDetail.tsx",
"line": " const messageContainerRef = useRef<HTMLDivElement>(null);",
"reasonCategory": "falseMatch|testCode|exampleCode|otherUtilityCode|regexMatchedSafeCode|notExercisedByOurApp|ruleNeeded|usageTrusted",
"updated": "2023-04-12T15:51:28.066Z",
"reasonDetail": "<optional>"
"reasonCategory": "usageTrusted",
"updated": "2023-04-12T15:51:28.066Z"
},
{
"rule": "React-useRef",
@ -2546,12 +2542,20 @@
"updated": "2021-10-22T00:52:39.251Z"
},
{
"rule": "DOM-innerHTML",
"path": "ts/quill/signal-clipboard/index.ts",
"line": " return div.innerHTML;",
"rule": "React-useRef",
"path": "ts/quill/formatting/menu.tsx",
"line": " const buttonRef = React.useRef<HTMLButtonElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2020-11-06T17:43:07.381Z",
"reasonDetail": "used for figuring out clipboard contents"
"updated": "2023-04-22T00:07:56.294Z",
"reasonDetail": "Popper needs to reference the button"
},
{
"rule": "React-useRef",
"path": "ts/quill/formatting/menu.tsx",
"line": " const timerRef = React.useRef<NodeJS.Timeout | undefined>();",
"reasonCategory": "usageTrusted",
"updated": "2023-04-22T00:07:56.294Z",
"reasonDetail": "We need a persistent timer to track long-hovers"
},
{
"rule": "React-useRef",

View file

@ -28,11 +28,13 @@ import { isNotNil } from './isNotNil';
import { collect } from './iterables';
import { DurationInSeconds } from './durations';
import { sanitizeLinkPreview } from '../services/LinkPreview';
import type { DraftBodyRanges } from '../types/BodyRange';
export async function sendStoryMessage(
listIds: Array<string>,
conversationIds: Array<string>,
attachment: AttachmentType
attachment: AttachmentType,
bodyRanges: DraftBodyRanges | undefined
): Promise<void> {
if (getStoriesBlocked()) {
log.warn('stories.sendStoryMessage: stories disabled, returning early');
@ -171,6 +173,7 @@ export async function sendStoryMessage(
// on the receiver side.
return window.Signal.Migrations.upgradeMessageSchema({
attachments,
bodyRanges,
conversationId: ourConversation.id,
expireTimer: DurationInSeconds.DAY,
expirationStartTimestamp: Date.now(),
@ -277,6 +280,7 @@ export async function sendStoryMessage(
const messageAttributes =
await window.Signal.Migrations.upgradeMessageSchema({
attachments,
bodyRanges,
canReplyToStory: true,
conversationId: group.id,
expireTimer: DurationInSeconds.DAY,