Support for sending formatting messages

This commit is contained in:
Scott Nonnenberg 2023-04-14 11:16:28 -07:00 committed by GitHub
parent 42e13aedcd
commit 9bfbee464b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 1762 additions and 371 deletions

View file

@ -3324,6 +3324,10 @@
"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",
"description": "Description of the text-formatting popover menu setting"
},
"spellCheckWillBeEnabled": {
"message": "Spell check will be enabled the next time Signal starts.",
"description": "(deleted 03/29/2023) Shown when the user enables spellcheck to indicate that they must restart Signal."
@ -5467,6 +5471,26 @@
"messageformat": "Composer",
"description": "Header of the keyboard shortcuts guide - composer section"
},
"icu:Keyboard--composer--bold": {
"messageformat": "Mark selected text as bold",
"description": "Description of command to bold text in composer"
},
"icu:Keyboard--composer--italic": {
"messageformat": "Mark selected text as italic",
"description": "Description of command to bold text in composer"
},
"icu:Keyboard--composer--strikethrough": {
"messageformat": "Mark selected text as strikethrough",
"description": "Description of command to bold text in composer"
},
"icu:Keyboard--composer--monospace": {
"messageformat": "Mark selected text as monospace",
"description": "Description of command to bold text in composer"
},
"icu:Keyboard--composer--spoiler": {
"messageformat": "Mark selected text as a spoiler",
"description": "Description of command to bold text in composer"
},
"Keyboard--scroll-to-top": {
"message": "Scroll to top of list",
"description": "(deleted 03/29/2023) Shown in the shortcuts guide"
@ -5619,6 +5643,14 @@
"messageformat": "Chat marked unread",
"description": "A toast that shows up when user marks a conversation as unread"
},
"icu:SendFormatting--dialog--title": {
"messageformat": "Sending formatted text",
"description": "Title of the modal shown before sending your first formatting message"
},
"icu:SendFormatting--dialog--body": {
"messageformat": "Some people may be using a version of Signal that doesnt support formatted text. They will not be able to see the formatting changes youve made to your message.",
"description": "Body text of the modal shown before sending your first formatting message"
},
"icu:AuthArtCreator--dialog--message": {
"messageformat": "Would you like to open Signal Sticker Pack Creator?",
"description": "A body of the dialog that is presented when user tries to open Signal Sticker Pack Creator from a link"

3
images/icons/v3/bold.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.9375 2.60417C5.24714 2.60417 4.6875 3.16382 4.6875 3.85417V16.1458C4.6875 16.8362 5.24714 17.3958 5.9375 17.3958H11.0154C12.2611 17.3958 13.4958 17.0761 14.4302 16.374C15.3789 15.6612 15.9896 14.5736 15.9896 13.1215C15.9896 12.0686 15.6012 11.1986 14.937 10.595C14.3718 10.0813 13.6292 9.78038 12.8079 9.70488V9.61211C13.5016 9.42673 14.1231 9.10681 14.5945 8.64455C15.1478 8.10208 15.4764 7.37962 15.4764 6.51042C15.4764 5.0956 14.8614 4.09409 13.9229 3.46372C13.0061 2.84796 11.8109 2.60417 10.6438 2.60417H5.9375ZM7.70836 8.76855V5.08596H10.1964C10.8919 5.08596 11.4573 5.27424 11.8396 5.58044C12.2128 5.87938 12.4372 6.30938 12.4372 6.86936C12.4372 7.51106 12.2083 7.96586 11.8375 8.2689C11.4554 8.58117 10.8773 8.76855 10.1258 8.76855H7.70836ZM7.70836 14.9231V11.0188H10.4252C11.244 11.0188 11.8646 11.2134 12.2731 11.5501C12.6693 11.8767 12.9177 12.379 12.9177 13.119C12.9177 13.7749 12.6692 14.1962 12.2632 14.4722C11.8323 14.7652 11.1794 14.9231 10.3389 14.9231H7.70836Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.8125 16.6667C12.8125 17.0694 12.486 17.3958 12.0833 17.3958H6.14582C5.74312 17.3958 5.41666 17.0694 5.41666 16.6667C5.41666 16.264 5.74312 15.9375 6.14582 15.9375H8.23962L10.2806 4.06251H8.22916C7.82645 4.06251 7.49999 3.73605 7.49999 3.33334C7.49999 2.93063 7.82645 2.60417 8.22916 2.60417H14.1667C14.5694 2.60417 14.8958 2.93063 14.8958 3.33334C14.8958 3.73605 14.5694 4.06251 14.1667 4.06251H12.0729L10.0318 15.9375H12.0833C12.486 15.9375 12.8125 16.264 12.8125 16.6667Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 606 B

View file

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.30103 5.01118L6.45732 7.98254C6.45799 7.99529 6.45832 8.00806 6.45832 8.02084V16.7708C6.45832 17.2311 6.08523 17.6042 5.62499 17.6042C5.16475 17.6042 4.79166 17.2311 4.79166 16.7708V3.54167C4.79166 3.0239 5.21139 2.60417 5.72916 2.60417H6.31321C6.70564 2.60417 7.05654 2.84859 7.19258 3.21669L9.99999 10.8132L12.8074 3.21669C12.9434 2.84859 13.2943 2.60417 13.6868 2.60417H14.2708C14.7886 2.60417 15.2083 3.0239 15.2083 3.54167V16.7708C15.2083 17.2311 14.8352 17.6042 14.375 17.6042C13.9148 17.6042 13.5417 17.2311 13.5417 16.7708V8.02084C13.5417 8.00806 13.542 7.99529 13.5427 7.98254L13.699 5.01118L10.6839 13.1694C10.5781 13.4557 10.3052 13.6458 9.99999 13.6458C9.69477 13.6458 9.42184 13.4557 9.31604 13.1694L6.30103 5.01118Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 861 B

View file

@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.2969 2.35382C16.6078 2.60983 16.6522 3.06936 16.3962 3.38022L4.72955 17.5469C4.47355 17.8578 4.01401 17.9022 3.70315 17.6462C3.39229 17.3902 3.34782 16.9307 3.60382 16.6198L15.2705 2.45315C15.5265 2.14229 15.986 2.09782 16.2969 2.35382Z" fill="black"/>
<path d="M11.1498 2.35382C11.4607 2.60983 11.5052 3.06936 11.2492 3.38022L4.72955 11.2969C4.47355 11.6078 4.01401 11.6522 3.70315 11.3962C3.39229 11.1402 3.34782 10.6807 3.60382 10.3698L10.1234 2.45315C10.3794 2.14229 10.839 2.09782 11.1498 2.35382Z" fill="black"/>
<path d="M16.3962 9.63022C16.6522 9.31936 16.6077 8.85983 16.2968 8.60382C15.986 8.34782 15.5264 8.39229 15.2704 8.70315L8.75081 16.6198C8.49481 16.9307 8.53928 17.3902 8.85014 17.6462C9.16101 17.9022 9.62054 17.8578 9.87655 17.5469L16.3962 9.63022Z" fill="black"/>
<path d="M6.00277 2.35382C6.31364 2.60983 6.35811 3.06936 6.1021 3.38022L4.72955 5.04689C4.47355 5.35775 4.01401 5.40222 3.70315 5.14622C3.39229 4.89022 3.34782 4.43068 3.60382 4.11982L4.97637 2.45315C5.23237 2.14229 5.69191 2.09782 6.00277 2.35382Z" fill="black"/>
<path d="M16.3962 15.8802C16.6522 15.5694 16.6077 15.1098 16.2969 14.8538C15.986 14.5978 15.5265 14.6423 15.2705 14.9532L13.8979 16.6198C13.6419 16.9307 13.6864 17.3902 13.9972 17.6462C14.3081 17.9022 14.7676 17.8578 15.0236 17.5469L16.3962 15.8802Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.4157 3.48725C7.31167 2.77375 8.5529 2.39583 10 2.39583C12.5734 2.39583 14.4825 3.60862 14.8592 5.4163C14.9719 5.95753 14.5447 6.4123 14.0467 6.4123C13.6292 6.4123 13.3013 6.11051 13.2277 5.73712C13.0547 4.85929 11.8973 3.85416 10 3.85416C8.90675 3.85416 8.09445 4.16558 7.56874 4.60895C7.04984 5.04657 6.77087 5.64249 6.77087 6.32267C6.77087 6.93203 6.95887 7.39512 7.40552 7.79937C7.48815 7.87416 7.58091 7.94804 7.68472 8.02083H5.34377C5.11991 7.55953 5.00003 7.03195 5.00003 6.42926C5.00003 5.24676 5.50671 4.21114 6.4157 3.48725Z" fill="black"/>
<path d="M12.8991 11.9792H15.0856C15.2317 12.3543 15.3125 12.7762 15.3125 13.251C15.3125 14.8478 14.5662 15.9773 13.483 16.6752C12.4373 17.3489 11.1119 17.6042 9.89344 17.6042C8.5524 17.6042 7.41491 17.3322 6.52818 16.7843C5.62928 16.229 5.03151 15.4158 4.74922 14.4295C4.59327 13.8846 5.01235 13.3745 5.54613 13.3745C5.93273 13.3745 6.25185 13.6373 6.34809 13.9864C6.69116 15.231 8.00511 16.1458 9.89344 16.1458C10.899 16.1458 11.834 15.8603 12.4984 15.3691C13.1465 14.89 13.5417 14.218 13.5417 13.3576C13.5417 12.9561 13.4463 12.6591 13.2967 12.4208C13.199 12.2651 13.0679 12.1186 12.8991 11.9792Z" fill="black"/>
<path d="M16.25 10.7292C16.6527 10.7292 16.9792 10.4027 16.9792 9.99999C16.9792 9.59729 16.6527 9.27083 16.25 9.27083H3.75001C3.3473 9.27083 3.02084 9.59729 3.02084 9.99999C3.02084 10.4027 3.3473 10.7292 3.75001 10.7292H16.25Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -120,6 +120,144 @@
}
}
&__format-menu {
padding: 6px 12px;
border-radius: 8px;
z-index: $z-index-above-popup;
display: flex;
flex-direction: row;
@include popper-shadow();
@include light-theme() {
background: $color-white;
}
@include dark-theme() {
background: $color-gray-80;
}
&__item {
$parent: &;
@include button-reset;
height: 24px;
width: 24px;
border-radius: 4px;
margin-right: 8px;
&:last-child {
margin-right: 0;
}
@include mouse-mode {
&:hover {
background-color: $color-gray-05;
}
}
@include dark-mouse-mode {
&:hover {
background-color: $color-gray-60;
}
}
&__icon {
height: 20px;
width: 20px;
margin: 2px;
&--bold {
@include dark-theme {
@include color-svg('../images/icons/v3/bold.svg', $color-gray-25);
}
@include light-theme {
@include color-svg('../images/icons/v3/bold.svg', $color-gray-60);
}
}
&--italic {
@include dark-theme {
@include color-svg('../images/icons/v3/italic.svg', $color-gray-25);
}
@include light-theme {
@include color-svg('../images/icons/v3/italic.svg', $color-gray-60);
}
}
&--strikethrough {
@include dark-theme {
@include color-svg(
'../images/icons/v3/strikethrough.svg',
$color-gray-25
);
}
@include light-theme {
@include color-svg(
'../images/icons/v3/strikethrough.svg',
$color-gray-60
);
}
}
&--monospace {
@include dark-theme {
@include color-svg(
'../images/icons/v3/monospace.svg',
$color-gray-25
);
}
@include light-theme {
@include color-svg(
'../images/icons/v3/monospace.svg',
$color-gray-60
);
}
}
&--spoiler {
@include dark-theme {
@include color-svg(
'../images/icons/v3/spoiler.svg',
$color-gray-25
);
}
@include light-theme {
@include color-svg(
'../images/icons/v3/spoiler.svg',
$color-gray-60
);
}
}
// Here we look at hover for the parent so the 2px border in between is active
// We can't use the mixins because .mouse-mode would wend up after the >
.mouse-mode #{$parent}:hover & {
background-color: $color-gray-90;
}
.dark-theme.mouse-mode #{$parent}:hover & {
background-color: $color-gray-15;
}
&--active {
@include dark-theme {
background-color: $color-ultramarine;
}
@include light-theme {
background-color: $color-ultramarine;
}
// Override above hover behaviors
.mouse-mode #{$parent}:hover & {
background-color: $color-ultramarine;
}
.dark-theme.mouse-mode #{$parent}:hover & {
background-color: $color-ultramarine;
}
}
}
}
}
&__suggestions {
padding: 0;
margin-bottom: 6px;
@ -254,3 +392,19 @@ button.CompositionInput__link-preview__close-button {
}
}
}
.quill {
&--monospace {
font-family: monospace;
}
&--spoiler {
@include light-theme {
// vs color/$color-gray-90, background/$color-gray-05
background-color: $color-gray-25;
}
@include dark-theme {
// vs color/$color-gray-05, background/$color-gray-95
background-color: $color-gray-45;
}
}
}

View file

@ -3,32 +3,17 @@
.MessageTextRenderer {
&__formatting {
&--bold {
font-weight: 600;
}
&--italic {
font-style: italic;
}
// bold is handled by <strong> element
// italic is handled by <em> element
// strikethrough is handled by <s> element
&--monospace {
font-family: monospace;
}
&--strikethrough {
text-decoration: line-through;
bdi {
text-decoration: line-through;
}
}
&--none {
text-decoration: none;
font-weight: 400;
bdi {
text-decoration: none;
}
}
// Note: only used in the left pane for search results, not in message bubbles
&--keywordHighlight {
font-weight: 600;
// Boldness of this is handled by <strong> element
// To differentiate it from bold formatting, we increase the color contrast
@include light-theme {
@ -44,6 +29,10 @@
user-select: none;
cursor: pointer;
// Lighten things up a bit
opacity: 50%;
border-radius: 4px;
// make child text invisible
color: transparent;

View file

@ -16,29 +16,31 @@ export type ConfigKeyType =
| 'desktop.announcementGroup'
| 'desktop.calling.audioLevelForSpeaking'
| 'desktop.cdsi.returnAcisWithoutUaks'
| 'desktop.contactManagement'
| 'desktop.contactManagement.beta'
| 'desktop.clientExpiration'
| 'desktop.groupCallOutboundRing2'
| 'desktop.contactManagement.beta'
| 'desktop.contactManagement'
| 'desktop.groupCallOutboundRing2.beta'
| 'desktop.groupCallOutboundRing2'
| 'desktop.internalUser'
| 'desktop.mandatoryProfileSharing'
| 'desktop.mediaQuality.levels'
| 'desktop.messageCleanup'
| 'desktop.messageRequests'
| 'desktop.pnp'
| 'desktop.safetyNumberUUID'
| 'desktop.safetyNumberUUID.timestamp'
| 'desktop.retryReceiptLifespan'
| 'desktop.retryRespondMaxAge'
| 'desktop.safetyNumberUUID.timestamp'
| 'desktop.safetyNumberUUID'
| 'desktop.senderKey.retry'
| 'desktop.senderKey.send'
| 'desktop.senderKeyMaxAge'
| 'desktop.sendSenderKey3'
| 'desktop.showUserBadges.beta'
| 'desktop.showUserBadges2'
| 'desktop.stories2'
| 'desktop.stories2.beta'
| 'desktop.stories2'
| 'desktop.textFormatting.spoilerSend'
| 'desktop.textFormatting'
| 'desktop.usernames'
| 'global.attachments.maxBytes'
| 'global.calling.maxGroupCallRingSize'

View file

@ -26,6 +26,8 @@ export default {
<CompositionTextArea
{...props}
i18n={i18n}
isFormattingEnabled={false}
isFormattingSpoilersEnabled={false}
onPickEmoji={action('onPickEmoji')}
onChange={action('onChange')}
onTextTooLong={action('onTextTooLong')}

View file

@ -38,6 +38,14 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
sendCounter: 0,
i18n,
isDisabled: false,
isFormattingSpoilersEnabled:
overrideProps.isFormattingSpoilersEnabled === false
? overrideProps.isFormattingSpoilersEnabled
: true,
isFormattingEnabled:
overrideProps.isFormattingEnabled === false
? overrideProps.isFormattingEnabled
: true,
messageCompositionId: '456',
sendMultiMediaMessage: action('sendMultiMediaMessage'),
processAttachments: action('processAttachments'),
@ -279,3 +287,13 @@ export function QuoteWithPayment(): JSX.Element {
QuoteWithPayment.story = {
name: 'Quote with payment',
};
export function NoFormatting(): JSX.Element {
return <CompositionArea {...useProps({ isFormattingEnabled: false })} />;
}
export function NoSpoilerFormatting(): JSX.Element {
return (
<CompositionArea {...useProps({ isFormattingSpoilersEnabled: false })} />
);
}

View file

@ -4,7 +4,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { get } from 'lodash';
import classNames from 'classnames';
import type { DraftBodyRangeMention } from '../types/BodyRange';
import type { DraftBodyRanges } from '../types/BodyRange';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { ErrorDialogAudioRecorderType } from '../types/AudioRecorder';
import { RecordingState } from '../types/AudioRecorder';
@ -93,6 +93,8 @@ export type OwnProps = Readonly<{
imageToBlurHash: typeof imageToBlurHash;
isDisabled: boolean;
isFetchingUUID?: boolean;
isFormattingEnabled: boolean;
isFormattingSpoilersEnabled: boolean;
isGroupV1AndDisabled?: boolean;
isMissingMandatoryProfileSharing?: boolean;
isSignalConversation?: boolean;
@ -119,7 +121,7 @@ export type OwnProps = Readonly<{
conversationId: string,
options: {
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>;
bodyRanges?: DraftBodyRanges;
message?: string;
timestamp?: number;
voiceNoteAttachment?: InMemoryAttachmentDraftType;
@ -232,6 +234,8 @@ export function CompositionArea({
draftText,
getPreferredBadge,
getQuotedMessage,
isFormattingSpoilersEnabled,
isFormattingEnabled,
onEditorStateChange,
onTextTooLong,
sendCounter,
@ -305,15 +309,11 @@ export function CompositionArea({
}, [inputApiRef, setLarge]);
const handleSubmit = useCallback(
(
message: string,
mentions: ReadonlyArray<DraftBodyRangeMention>,
timestamp: number
) => {
(message: string, bodyRanges: DraftBodyRanges, timestamp: number) => {
emojiButtonRef.current?.close();
sendMultiMediaMessage(conversationId, {
draftAttachments,
draftBodyRanges: mentions,
bodyRanges,
message,
timestamp,
});
@ -511,14 +511,14 @@ export function CompositionArea({
const handler = (e: KeyboardEvent) => {
const { shiftKey, ctrlKey, metaKey } = e;
const key = KeyboardLayout.lookup(e);
// When using the ctrl key, `key` is `'X'`. When using the cmd key, `key` is `'x'`
const xKey = key === 'x' || key === 'X';
// 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 commandOrCtrl = commandKey || controlKey;
// cmd/ctrl-shift-x
if (xKey && shiftKey && commandOrCtrl) {
// cmd/ctrl-shift-k
if (targetKey && shiftKey && commandOrCtrl) {
e.preventDefault();
setLarge(x => !x);
}
@ -797,6 +797,8 @@ export function CompositionArea({
getQuotedMessage={getQuotedMessage}
i18n={i18n}
inputApi={inputApiRef}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
isFormattingEnabled={isFormattingEnabled}
large={large}
linkPreviewLoading={linkPreviewLoading}
linkPreviewResult={linkPreviewResult}

View file

@ -23,16 +23,24 @@ export default {
const useProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n,
disabled: boolean('disabled', overrideProps.disabled || false),
onSubmit: action('onSubmit'),
onEditorStateChange: action('onEditorStateChange'),
onTextTooLong: action('onTextTooLong'),
draftText: overrideProps.draftText || undefined,
draftBodyRanges: overrideProps.draftBodyRanges || [],
clearQuotedMessage: action('clearQuotedMessage'),
getPreferredBadge: () => undefined,
getQuotedMessage: action('getQuotedMessage'),
onPickEmoji: action('onPickEmoji'),
isFormattingSpoilersEnabled:
overrideProps.isFormattingSpoilersEnabled === false
? overrideProps.isFormattingSpoilersEnabled
: true,
isFormattingEnabled:
overrideProps.isFormattingEnabled === false
? overrideProps.isFormattingEnabled
: true,
large: boolean('large', overrideProps.large || false),
onEditorStateChange: action('onEditorStateChange'),
onPickEmoji: action('onPickEmoji'),
onSubmit: action('onSubmit'),
onTextTooLong: action('onTextTooLong'),
sendCounter: 0,
sortedGroupMembers: overrideProps.sortedGroupMembers || [],
skinTone: select(
@ -124,6 +132,7 @@ export function Mentions(): JSX.Element {
start: 5,
length: 1,
mentionUuid: '0',
conversationID: 'k',
replacementText: 'Kate Beaton',
},
],
@ -131,3 +140,13 @@ export function Mentions(): JSX.Element {
return <CompositionInput {...props} />;
}
export function NoFormatting(): JSX.Element {
return <CompositionInput {...useProps({ isFormattingEnabled: false })} />;
}
export function NoSpoilerFormatting(): JSX.Element {
return (
<CompositionInput {...useProps({ isFormattingSpoilersEnabled: false })} />
);
}

View file

@ -11,10 +11,18 @@ import type { DeltaStatic, KeyboardStatic, RangeStatic } from 'quill';
import Quill from 'quill';
import { MentionCompletion } from '../quill/mentions/completion';
import { FormattingMenu, QuillFormattingStyle } from '../quill/formatting/menu';
import { MonospaceBlot } from '../quill/formatting/monospaceBlot';
import { SpoilerBlot } from '../quill/formatting/spoilerBlot';
import { EmojiBlot, EmojiCompletion } from '../quill/emoji';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import { convertShortName } from './emoji/lib';
import type { DraftBodyRangeMention } from '../types/BodyRange';
import type {
DraftBodyRanges,
HydratedBodyRangesType,
RangeNode,
} from '../types/BodyRange';
import { 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';
@ -30,11 +38,11 @@ import { matchMention } from '../quill/mentions/matchers';
import { MemberRepository } from '../quill/memberRepository';
import {
getDeltaToRemoveStaleMentions,
getTextAndMentionsFromOps,
getTextAndRangesFromOps,
isMentionBlot,
getDeltaToRestartMention,
insertMentionOps,
insertEmojiOps,
insertFormattingAndMentionsOps,
} from '../quill/util';
import { SignalClipboard } from '../quill/signal-clipboard';
import { DirectionalBlot } from '../quill/block/blot';
@ -43,12 +51,16 @@ import * as log from '../logging/log';
import { useRefMerger } from '../hooks/useRefMerger';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { StagedLinkPreview } from './conversation/StagedLinkPreview';
import { usePrevious } from '../hooks/usePrevious';
Quill.register('formats/emoji', EmojiBlot);
Quill.register('formats/mention', MentionBlot);
Quill.register('formats/block', DirectionalBlot);
Quill.register('formats/monospace', MonospaceBlot);
Quill.register('formats/spoiler', SpoilerBlot);
Quill.register('modules/emojiCompletion', EmojiCompletion);
Quill.register('modules/mentionCompletion', MentionCompletion);
Quill.register('modules/formattingMenu', FormattingMenu);
Quill.register('modules/signalClipboard', SignalClipboard);
type HistoryStatic = {
@ -61,7 +73,7 @@ export type InputApi = {
insertEmoji: (e: EmojiPickDataType) => void;
setContents: (
text: string,
draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>,
draftBodyRanges?: HydratedBodyRangesType,
cursorToEnd?: boolean
) => void;
reset: () => void;
@ -76,10 +88,12 @@ export type Props = Readonly<{
getPreferredBadge: PreferredBadgeSelectorType;
large?: boolean;
inputApi?: React.MutableRefObject<InputApi | undefined>;
isFormattingEnabled: boolean;
isFormattingSpoilersEnabled: boolean;
sendCounter: number;
skinTone?: EmojiPickDataType['skinTone'];
draftText?: string;
draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>;
draftBodyRanges?: HydratedBodyRangesType;
moduleClassName?: string;
theme: ThemeType;
placeholder?: string;
@ -87,7 +101,7 @@ export type Props = Readonly<{
scrollerRef?: React.RefObject<HTMLDivElement>;
onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?(options: {
bodyRanges: ReadonlyArray<DraftBodyRangeMention>;
bodyRanges: DraftBodyRanges;
caretLocation?: number;
conversationId: string | undefined;
messageText: string;
@ -97,7 +111,7 @@ export type Props = Readonly<{
onPickEmoji(o: EmojiPickDataType): unknown;
onSubmit(
message: string,
mentions: ReadonlyArray<DraftBodyRangeMention>,
bodyRanges: DraftBodyRanges,
timestamp: number
): unknown;
onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
@ -123,6 +137,8 @@ export function CompositionInput(props: Props): React.ReactElement {
getQuotedMessage,
i18n,
inputApi,
isFormattingEnabled,
isFormattingSpoilersEnabled,
large,
linkPreviewLoading,
linkPreviewResult,
@ -142,6 +158,8 @@ export function CompositionInput(props: Props): React.ReactElement {
const [emojiCompletionElement, setEmojiCompletionElement] =
React.useState<JSX.Element>();
const [formattingChooserElement, setFormattingChooserElement] =
React.useState<JSX.Element>();
const [lastSelectionRange, setLastSelectionRange] =
React.useState<RangeStatic | null>(null);
const [mentionCompletionElement, setMentionCompletionElement] =
@ -161,38 +179,45 @@ export function CompositionInput(props: Props): React.ReactElement {
const generateDelta = (
text: string,
mentions: ReadonlyArray<DraftBodyRangeMention>
bodyRanges: HydratedBodyRangesType
): Delta => {
const initialOps = [{ insert: text }];
const opsWithMentions = insertMentionOps(initialOps, mentions);
const opsWithEmojis = insertEmojiOps(opsWithMentions);
const textLength = text.length;
const tree = bodyRanges.reduce<ReadonlyArray<RangeNode>>((acc, range) => {
if (range.start < textLength) {
return insertRange(range, acc);
}
return acc;
}, []);
const nodes = collapseRangeTree({ tree, text });
const opsWithFormattingAndMentions = insertFormattingAndMentionsOps(nodes);
const opsWithEmojis = insertEmojiOps(opsWithFormattingAndMentions);
return new Delta(opsWithEmojis);
};
const getTextAndMentions = (): [
string,
ReadonlyArray<DraftBodyRangeMention>
] => {
const getTextAndRanges = (): {
text: string;
bodyRanges: DraftBodyRanges;
} => {
const quill = quillRef.current;
if (quill === undefined) {
return ['', []];
return { text: '', bodyRanges: [] };
}
const contents = quill.getContents();
if (contents === undefined) {
return ['', []];
return { text: '', bodyRanges: [] };
}
const { ops } = contents;
if (ops === undefined) {
return ['', []];
return { text: '', bodyRanges: [] };
}
return getTextAndMentionsFromOps(ops);
return getTextAndRangesFromOps(ops);
};
const focus = () => {
@ -251,7 +276,7 @@ export function CompositionInput(props: Props): React.ReactElement {
const setContents = (
text: string,
mentions?: ReadonlyArray<DraftBodyRangeMention>,
bodyRanges?: HydratedBodyRangesType,
cursorToEnd?: boolean
) => {
const quill = quillRef.current;
@ -260,7 +285,7 @@ export function CompositionInput(props: Props): React.ReactElement {
return;
}
const delta = generateDelta(text || '', mentions || []);
const delta = generateDelta(text || '', bodyRanges || []);
canSendRef.current = true;
// We need to cast here because we use @types/quill@1.3.10 which has types
@ -288,13 +313,13 @@ export function CompositionInput(props: Props): React.ReactElement {
return;
}
const [text, mentions] = getTextAndMentions();
const { text, bodyRanges } = getTextAndRanges();
log.info(
`CompositionInput: Submitting message ${timestamp} with ${mentions.length} mentions`
`CompositionInput: Submitting message ${timestamp} with ${bodyRanges.length} ranges`
);
canSendRef.current = false;
onSubmit(text, mentions, timestamp);
onSubmit(text, bodyRanges, timestamp);
};
if (inputApi) {
@ -320,6 +345,39 @@ export function CompositionInput(props: Props): React.ReactElement {
return false;
};
const previousFormattingEnabled = usePrevious(
isFormattingEnabled,
isFormattingEnabled
);
const previousFormattingSpoilersEnabled = usePrevious(
isFormattingSpoilersEnabled,
isFormattingSpoilersEnabled
);
React.useEffect(() => {
const formattingChanged =
typeof previousFormattingEnabled === 'boolean' &&
previousFormattingEnabled !== isFormattingEnabled;
const spoilersChanged =
typeof previousFormattingSpoilersEnabled === 'boolean' &&
previousFormattingSpoilersEnabled !== isFormattingSpoilersEnabled;
const quill = quillRef.current;
const changed = formattingChanged || spoilersChanged;
if (quill && changed) {
quill.getModule('formattingMenu').updateOptions({
isEnabled: isFormattingEnabled,
isSpoilersEnabled: isFormattingSpoilersEnabled,
});
}
}, [
isFormattingEnabled,
isFormattingSpoilersEnabled,
previousFormattingEnabled,
previousFormattingSpoilersEnabled,
quillRef,
]);
const onEnter = (): boolean => {
const quill = quillRef.current;
const emojiCompletion = emojiCompletionRef.current;
@ -439,7 +497,7 @@ export function CompositionInput(props: Props): React.ReactElement {
const onChange = (): void => {
const quill = quillRef.current;
const [text, mentions] = getTextAndMentions();
const { text, bodyRanges } = getTextAndRanges();
if (quill !== undefined) {
const historyModule: HistoryStatic = quill.getModule('history');
@ -462,7 +520,7 @@ export function CompositionInput(props: Props): React.ReactElement {
const selection = quill.getSelection();
onEditorStateChange({
bodyRanges: mentions,
bodyRanges,
caretLocation: selection ? selection.index : undefined,
conversationId,
messageText: text,
@ -614,6 +672,12 @@ export function CompositionInput(props: Props): React.ReactElement {
callbacksRef.current.onPickEmoji(emoji),
skinTone,
},
formattingMenu: {
i18n,
isEnabled: isFormattingEnabled,
isSpoilersEnabled: isFormattingSpoilersEnabled,
setFormattingChooserElement,
},
mentionCompletion: {
getPreferredBadge,
me: sortedGroupMembers
@ -625,7 +689,25 @@ export function CompositionInput(props: Props): React.ReactElement {
theme,
},
}}
formats={['emoji', 'mention']}
formats={[
// For image replacement (local-only)
'emoji',
// @mentions
'mention',
...(isFormattingEnabled
? [
// Custom
...(isFormattingSpoilersEnabled
? [QuillFormattingStyle.spoiler]
: []),
QuillFormattingStyle.monospace,
// Built-in
QuillFormattingStyle.bold,
QuillFormattingStyle.italic,
QuillFormattingStyle.strike,
]
: []),
]}
placeholder={placeholder || i18n('icu:sendMessage')}
readOnly={disabled}
ref={element => {
@ -698,6 +780,7 @@ export function CompositionInput(props: Props): React.ReactElement {
className={getClassName('__input')}
ref={ref}
data-testid="CompositionInput"
data-enabled={disabled ? 'false' : 'true'}
>
{conversationId && linkPreviewLoading && linkPreviewResult && (
<StagedLinkPreview
@ -727,6 +810,7 @@ export function CompositionInput(props: Props): React.ReactElement {
>
{reactQuill}
{emojiCompletionElement}
{formattingChooserElement}
{mentionCompletionElement}
</div>
</div>

View file

@ -9,14 +9,20 @@ import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled';
import type { InputApi } from './CompositionInput';
import { CompositionInput } from './CompositionInput';
import { EmojiButton } from './emoji/EmojiButton';
import type { DraftBodyRangeMention } from '../types/BodyRange';
import type {
DraftBodyRanges,
HydratedBodyRangesType,
} from '../types/BodyRange';
import type { ThemeType } from '../types/Util';
import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import * as grapheme from '../util/grapheme';
export type CompositionTextAreaProps = {
bodyRanges?: HydratedBodyRangesType;
i18n: LocalizerType;
isFormattingEnabled: boolean;
isFormattingSpoilersEnabled: boolean;
maxLength?: number;
placeholder?: string;
whenToShowRemainingCount?: number;
@ -25,13 +31,13 @@ export type CompositionTextAreaProps = {
onPickEmoji: (e: EmojiPickDataType) => void;
onChange: (
messageText: string,
draftBodyRanges: ReadonlyArray<DraftBodyRangeMention>,
draftBodyRanges: HydratedBodyRangesType,
caretLocation?: number | undefined
) => void;
onSetSkinTone: (tone: number) => void;
onSubmit: (
message: string,
draftBodyRanges: ReadonlyArray<DraftBodyRangeMention>,
draftBodyRanges: DraftBodyRanges,
timestamp: number
) => void;
onTextTooLong: () => void;
@ -48,22 +54,25 @@ export type CompositionTextAreaProps = {
* basically a rectangle input with an emoji selector floating at the top-right
*/
export function CompositionTextArea({
bodyRanges,
draftText,
getPreferredBadge,
i18n,
placeholder,
isFormattingEnabled,
isFormattingSpoilersEnabled,
maxLength,
whenToShowRemainingCount = Infinity,
scrollerRef,
onScroll,
onPickEmoji,
onChange,
onPickEmoji,
onScroll,
onSetSkinTone,
onSubmit,
onTextTooLong,
getPreferredBadge,
draftText,
theme,
placeholder,
recentEmojis,
scrollerRef,
skinTone,
theme,
whenToShowRemainingCount = Infinity,
}: CompositionTextAreaProps): JSX.Element {
const inputApiRef = React.useRef<InputApi | undefined>();
const [characterCount, setCharacterCount] = React.useState(
@ -87,7 +96,11 @@ export function CompositionTextArea({
}, [inputApiRef]);
const handleChange = React.useCallback(
({ bodyRanges, caretLocation, messageText: newValue }) => {
({
bodyRanges: updatedBodyRanges,
caretLocation,
messageText: newValue,
}) => {
const inputEl = inputApiRef.current;
if (!inputEl) {
return;
@ -108,11 +121,11 @@ export function CompositionTextArea({
// was modifying text in the middle of the editor
// a better solution would be to prevent the change to begin with, but
// quill makes this VERY difficult
inputEl.setContents(newValueSized, bodyRanges, true);
inputEl.setContents(newValueSized, updatedBodyRanges, true);
}
}
setCharacterCount(newCharacterCount);
onChange(newValue, bodyRanges, caretLocation);
onChange(newValue, updatedBodyRanges, caretLocation);
},
[maxLength, onChange]
);
@ -121,10 +134,13 @@ export function CompositionTextArea({
<div className="CompositionTextArea">
<CompositionInput
clearQuotedMessage={shouldNeverBeCalled}
draftBodyRanges={bodyRanges}
draftText={draftText}
getPreferredBadge={getPreferredBadge}
getQuotedMessage={noop}
i18n={i18n}
isFormattingEnabled={isFormattingEnabled}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
inputApi={inputApiRef}
large
moduleClassName="CompositionTextArea__input"

View file

@ -0,0 +1,38 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { ConfirmationDialog } from './ConfirmationDialog';
type PropsType = {
i18n: LocalizerType;
onSendAnyway: () => void;
onCancel: () => void;
};
export function FormattingWarningModal({
i18n,
onSendAnyway,
onCancel,
}: PropsType): JSX.Element | null {
return (
<ConfirmationDialog
actions={[
{
action: onSendAnyway,
autoClose: true,
style: 'affirmative',
text: i18n('icu:sendAnyway'),
},
]}
dialogName="FormattingWarningModal"
i18n={i18n}
onCancel={onCancel}
onClose={onCancel}
title={i18n('icu:SendFormatting--dialog--title')}
>
{i18n('icu:SendFormatting--dialog--body')}
</ConfirmationDialog>
);
}

View file

@ -59,6 +59,8 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
<CompositionTextArea
{...props}
i18n={i18n}
isFormattingSpoilersEnabled
isFormattingEnabled
onPickEmoji={action('onPickEmoji')}
skinTone={0}
onSetSkinTone={action('onSetSkinTone')}

View file

@ -43,6 +43,8 @@ import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { LinkPreviewSourceType } from '../types/LinkPreview';
import { ToastType } from '../types/Toast';
import type { ShowToastAction } from '../state/ducks/toast';
import type { HydratedBodyRangesType } from '../types/BodyRange';
import { BodyRange } from '../types/BodyRange';
export type DataPropsType = {
candidateConversations: ReadonlyArray<ConversationType>;
@ -135,7 +137,14 @@ export function ForwardMessagesModal({
const previews = lonelyLinkPreview ? [lonelyLinkPreview] : [];
doForwardMessages(conversationIds, [{ ...lonelyDraft, previews }]);
} else {
doForwardMessages(conversationIds, drafts);
doForwardMessages(
conversationIds,
drafts.map(draft => ({
...draft,
// We don't keep @mention bodyRanges in multi-forward scenarios
bodyRanges: draft.bodyRanges?.filter(BodyRange.isFormatting),
}))
);
}
}, [
drafts,
@ -304,8 +313,8 @@ export function ForwardMessagesModal({
<ForwardMessageEditor
draft={lonelyDraft}
linkPreview={lonelyLinkPreview}
onChange={messageBody => {
onChange([{ ...lonelyDraft, messageBody }]);
onChange={(messageBody, bodyRanges) => {
onChange([{ ...lonelyDraft, messageBody, bodyRanges }]);
}}
removeLinkPreview={removeLinkPreview}
theme={theme}
@ -420,7 +429,11 @@ type ForwardMessageEditorProps = Readonly<{
RenderCompositionTextArea: (
props: SmartCompositionTextAreaProps
) => JSX.Element;
onChange: (messageText: string, caretLocation?: number) => unknown;
onChange: (
messageText: string,
bodyRanges: HydratedBodyRangesType,
caretLocation?: number
) => unknown;
onSubmit: () => unknown;
theme: ThemeType;
i18n: LocalizerType;
@ -470,10 +483,9 @@ function ForwardMessageEditor({
) : null}
<RenderCompositionTextArea
bodyRanges={draft.bodyRanges}
draftText={draft.messageBody ?? ''}
onChange={(messageText, _bodyRanges, caretLocation) => {
onChange(messageText, caretLocation);
}}
onChange={onChange}
onSubmit={onSubmit}
theme={theme}
/>

View file

@ -7,15 +7,18 @@ import type {
ContactModalStateType,
DeleteMessagesPropsType,
EditHistoryMessagesType,
FormattingWarningDataType,
ForwardMessagesPropsType,
SafetyNumberChangedBlockingDataType,
UserNotFoundModalStateType,
} from '../state/ducks/globalModals';
import type { LocalizerType, ThemeType } from '../types/Util';
import type { ExplodePromiseResultType } from '../util/explodePromise';
import { missingCaseError } from '../util/missingCaseError';
import { ButtonVariant } from './Button';
import { ConfirmationDialog } from './ConfirmationDialog';
import { FormattingWarningModal } from './FormattingWarningModal';
import { SignalConnectionsModal } from './SignalConnectionsModal';
import { WhatsNewModal } from './WhatsNewModal';
@ -42,6 +45,11 @@ export type PropsType = {
// DeleteMessageModal
deleteMessagesProps: DeleteMessagesPropsType | undefined;
renderDeleteMessagesModal: () => JSX.Element;
// FormattingWarningModal
showFormattingWarningModal: (
explodedPromise: ExplodePromiseResultType<boolean> | undefined
) => void;
formattingWarningData: FormattingWarningDataType | undefined;
// ForwardMessageModal
forwardMessagesProps: ForwardMessagesPropsType | undefined;
renderForwardMessagesModal: () => JSX.Element;
@ -99,6 +107,9 @@ export function GlobalModalContainer({
// DeleteMessageModal
deleteMessagesProps,
renderDeleteMessagesModal,
// FormattingWarningModal
showFormattingWarningModal,
formattingWarningData,
// ForwardMessageModal
forwardMessagesProps,
renderForwardMessagesModal,
@ -169,6 +180,23 @@ export function GlobalModalContainer({
return renderDeleteMessagesModal();
}
if (formattingWarningData) {
const { resolve } = formattingWarningData.explodedPromise;
return (
<FormattingWarningModal
i18n={i18n}
onSendAnyway={() => {
showFormattingWarningModal(undefined);
resolve(true);
}}
onCancel={() => {
showFormattingWarningModal(undefined);
resolve(false);
}}
/>
);
}
if (forwardMessagesProps) {
return renderForwardMessagesModal();
}

View file

@ -64,6 +64,8 @@ export function WithCaption(): JSX.Element {
<CompositionTextArea
{...props}
i18n={i18n}
isFormattingSpoilersEnabled
isFormattingEnabled
onPickEmoji={action('onPickEmoji')}
onSetSkinTone={action('onSetSkinTone')}
onTextTooLong={action('onTextTooLong')}

View file

@ -89,11 +89,13 @@ const getDefaultArgs = (): PropsDataType => ({
hasRelayCalls: false,
hasSpellCheck: true,
hasStoriesDisabled: false,
hasTextFormatting: true,
hasTypingIndicators: true,
initialSpellCheckSetting: true,
isAudioNotificationsSupported: true,
isAutoDownloadUpdatesSupported: true,
isAutoLaunchSupported: true,
isFormattingFlagEnabled: true,
isHideMenuBarSupported: true,
isNotificationAttentionSupported: true,
isPhoneNumberSharingSupported: true,
@ -161,6 +163,7 @@ export default {
onSelectedSpeakerChange: { action: true },
onSentMediaQualityChange: { action: true },
onSpellCheckChange: { action: true },
onTextFormattingChange: { action: true },
onThemeChange: { action: true },
onUniversalExpireTimerChange: { action: true },
onWhoCanSeeMeChange: { action: true },
@ -217,3 +220,8 @@ PNPDiscoverabilityDisabled.args = {
PNPDiscoverabilityDisabled.story = {
name: 'PNP Discoverability Disabled',
};
export const FormattingDisabled = Template.bind({});
FormattingDisabled.args = {
isFormattingFlagEnabled: false,
};

View file

@ -80,6 +80,7 @@ export type PropsDataType = {
hasRelayCalls?: boolean;
hasSpellCheck: boolean;
hasStoriesDisabled: boolean;
hasTextFormatting: boolean;
hasTypingIndicators: boolean;
lastSyncTime?: number;
notificationContent: NotificationSettingType;
@ -98,6 +99,9 @@ export type PropsDataType = {
initialSpellCheckSetting: boolean;
shouldShowStoriesSettings: boolean;
// Feature flags
isFormattingFlagEnabled: boolean;
// Limited support features
isAudioNotificationsSupported: boolean;
isAutoDownloadUpdatesSupported: boolean;
@ -162,6 +166,7 @@ type PropsFunctionType = {
onSelectedSpeakerChange: SelectChangeHandlerType<AudioDevice | undefined>;
onSentMediaQualityChange: SelectChangeHandlerType<SentMediaQualityType>;
onSpellCheckChange: CheckboxChangeHandlerType;
onTextFormattingChange: CheckboxChangeHandlerType;
onThemeChange: SelectChangeHandlerType<ThemeType>;
onUniversalExpireTimerChange: SelectChangeHandlerType<number>;
onWhoCanSeeMeChange: SelectChangeHandlerType<PhoneNumberSharingMode>;
@ -245,12 +250,14 @@ export function Preferences({
hasRelayCalls,
hasSpellCheck,
hasStoriesDisabled,
hasTextFormatting,
hasTypingIndicators,
i18n,
initialSpellCheckSetting,
isAudioNotificationsSupported,
isAutoDownloadUpdatesSupported,
isAutoLaunchSupported,
isFormattingFlagEnabled,
isHideMenuBarSupported,
isPhoneNumberSharingSupported,
isNotificationAttentionSupported,
@ -284,6 +291,7 @@ export function Preferences({
onSelectedSpeakerChange,
onSentMediaQualityChange,
onSpellCheckChange,
onTextFormattingChange,
onThemeChange,
onUniversalExpireTimerChange,
onWhoCanSeeMeChange,
@ -550,6 +558,15 @@ export function Preferences({
name="spellcheck"
onChange={onSpellCheckChange}
/>
{isFormattingFlagEnabled && (
<Checkbox
checked={hasTextFormatting}
label={i18n('icu:textFormattingDescripton')}
moduleClassName="Preferences__checkbox"
name="textFormatting"
onChange={onTextFormattingChange}
/>
)}
<Checkbox
checked={hasLinkPreviews}
description={i18n('icu:Preferences__link-previews--description')}

View file

@ -3,7 +3,6 @@
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { boolean, select } from '@storybook/addon-knobs';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
@ -19,18 +18,16 @@ export default {
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
i18n,
close: action('close'),
hasInstalledStickers: boolean(
'hasInstalledStickers',
overrideProps.hasInstalledStickers || false
),
platform: select(
'platform',
{
macOS: 'darwin',
other: 'other',
},
overrideProps.platform || 'other'
),
isFormattingFlagEnabled:
overrideProps.isFormattingFlagEnabled === false
? overrideProps.isFormattingFlagEnabled
: true,
isFormattingSpoilersFlagEnabled:
overrideProps.isFormattingSpoilersFlagEnabled === false
? overrideProps.isFormattingSpoilersFlagEnabled
: true,
hasInstalledStickers: overrideProps.hasInstalledStickers === true || false,
platform: overrideProps.platform || 'other',
});
export function Default(): JSX.Element {
@ -47,3 +44,13 @@ export function HasStickers(): JSX.Element {
const props = createProps({ hasInstalledStickers: true });
return <ShortcutGuide {...props} />;
}
export function NoFormatting(): JSX.Element {
const props = createProps({ isFormattingFlagEnabled: false });
return <ShortcutGuide {...props} />;
}
export function NoSpoilerFormatting(): JSX.Element {
const props = createProps({ isFormattingSpoilersFlagEnabled: false });
return <ShortcutGuide {...props} />;
}

View file

@ -8,6 +8,8 @@ import type { LocalizerType } from '../types/Util';
export type Props = {
hasInstalledStickers: boolean;
isFormattingFlagEnabled: boolean;
isFormattingSpoilersFlagEnabled: boolean;
platform: string;
readonly close: () => unknown;
readonly i18n: LocalizerType;
@ -26,12 +28,15 @@ type KeyType =
| ','
| '.'
| 'A'
| 'B'
| 'C'
| 'D'
| 'E'
| 'F'
| 'G'
| 'I'
| 'J'
| 'K'
| 'L'
| 'M'
| 'N'
@ -206,8 +211,12 @@ function getMessageShortcuts(i18n: LocalizerType): Array<ShortcutType> {
];
}
function getComposerShortcuts(i18n: LocalizerType): Array<ShortcutType> {
return [
function getComposerShortcuts(
i18n: LocalizerType,
isFormattingFlagEnabled: boolean,
isFormattingSpoilersFlagEnabled: boolean
): Array<ShortcutType> {
const shortcuts: Array<ShortcutType> = [
{
id: 'Keyboard--add-newline',
description: i18n('icu:Keyboard--add-newline'),
@ -216,7 +225,7 @@ function getComposerShortcuts(i18n: LocalizerType): Array<ShortcutType> {
{
id: 'Keyboard--expand-composer',
description: i18n('icu:Keyboard--expand-composer'),
keys: [['commandOrCtrl', 'shift', 'X']],
keys: [['commandOrCtrl', 'shift', 'K']],
},
{
id: 'Keyboard--send-in-expanded-composer',
@ -239,6 +248,39 @@ function getComposerShortcuts(i18n: LocalizerType): Array<ShortcutType> {
keys: [['commandOrCtrl', 'shift', 'P']],
},
];
if (isFormattingFlagEnabled) {
shortcuts.push({
id: 'Keyboard--composer--bold',
description: i18n('icu:Keyboard--composer--bold'),
keys: [['commandOrCtrl', 'B']],
});
shortcuts.push({
id: 'Keyboard--composer--italic',
description: i18n('icu:Keyboard--composer--italic'),
keys: [['commandOrCtrl', 'I']],
});
shortcuts.push({
id: 'Keyboard--composer--strikethrough',
description: i18n('icu:Keyboard--composer--strikethrough'),
keys: [['commandOrCtrl', 'shift', 'X']],
});
shortcuts.push({
id: 'Keyboard--composer--monospace',
description: i18n('icu:Keyboard--composer--monospace'),
keys: [['commandOrCtrl', 'E']],
});
if (isFormattingSpoilersFlagEnabled) {
shortcuts.push({
id: 'Keyboard--composer--spoiler',
description: i18n('icu:Keyboard--composer--spoiler'),
keys: [['commandOrCtrl', 'shift', 'B']],
});
}
}
return shortcuts;
}
function getCallingShortcuts(i18n: LocalizerType): Array<ShortcutType> {
@ -287,7 +329,14 @@ function getCallingShortcuts(i18n: LocalizerType): Array<ShortcutType> {
}
export function ShortcutGuide(props: Props): JSX.Element {
const { i18n, close, hasInstalledStickers, platform } = props;
const {
i18n,
close,
hasInstalledStickers,
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
platform,
} = props;
const isMacOS = platform === 'darwin';
// Restore focus on teardown
@ -345,7 +394,11 @@ export function ShortcutGuide(props: Props): JSX.Element {
{i18n('icu:Keyboard--composer-header')}
</div>
<div className="module-shortcut-guide__section-list">
{getComposerShortcuts(i18n).map((shortcut, index) =>
{getComposerShortcuts(
i18n,
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled
).map((shortcut, index) =>
renderShortcut(shortcut, index, isMacOS, i18n)
)}
</div>

View file

@ -8,6 +8,8 @@ import { ShortcutGuide } from './ShortcutGuide';
export type PropsType = {
hasInstalledStickers: boolean;
isFormattingFlagEnabled: boolean;
isFormattingSpoilersFlagEnabled: boolean;
platform: string;
readonly closeShortcutGuideModal: () => unknown;
readonly i18n: LocalizerType;
@ -16,8 +18,14 @@ export type PropsType = {
export const ShortcutGuideModal = React.memo(function ShortcutGuideModalInner(
props: PropsType
) {
const { i18n, closeShortcutGuideModal, hasInstalledStickers, platform } =
props;
const {
i18n,
closeShortcutGuideModal,
hasInstalledStickers,
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
platform,
} = props;
const [root, setRoot] = React.useState<HTMLElement | null>(null);
React.useEffect(() => {
@ -37,6 +45,8 @@ export const ShortcutGuideModal = React.memo(function ShortcutGuideModalInner(
<ShortcutGuide
close={closeShortcutGuideModal}
hasInstalledStickers={hasInstalledStickers}
isFormattingFlagEnabled={isFormattingFlagEnabled}
isFormattingSpoilersFlagEnabled={isFormattingSpoilersFlagEnabled}
i18n={i18n}
platform={platform}
/>

View file

@ -10,7 +10,7 @@ import React, {
useState,
} from 'react';
import classNames from 'classnames';
import type { DraftBodyRangeMention } from '../types/BodyRange';
import type { DraftBodyRanges } from '../types/BodyRange';
import type { LocalizerType } from '../types/Util';
import type { ContextMenuOptionType } from './ContextMenu';
import type {
@ -84,6 +84,8 @@ export type PropsType = {
hasAllStoriesUnmuted: boolean;
hasViewReceiptSetting: boolean;
i18n: LocalizerType;
isFormattingEnabled: boolean;
isFormattingSpoilersEnabled: boolean;
isInternalUser?: boolean;
isSignalConversation?: boolean;
isWindowActive: boolean;
@ -97,7 +99,7 @@ export type PropsType = {
onReactToStory: (emoji: string, story: StoryViewType) => unknown;
onReplyToStory: (
message: string,
mentions: ReadonlyArray<DraftBodyRangeMention>,
bodyRanges: DraftBodyRanges,
timestamp: number,
story: StoryViewType
) => unknown;
@ -144,6 +146,8 @@ export function StoryViewer({
hasAllStoriesUnmuted,
hasViewReceiptSetting,
i18n,
isFormattingEnabled,
isFormattingSpoilersEnabled,
isInternalUser,
isSignalConversation,
isWindowActive,
@ -933,6 +937,8 @@ export function StoryViewer({
hasViewsCapability={isSent}
i18n={i18n}
platform={platform}
isFormattingEnabled={isFormattingEnabled}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
isInternalUser={isInternalUser}
group={group}
onClose={() => setCurrentViewTarget(null)}
@ -944,12 +950,12 @@ export function StoryViewer({
}
setReactionEmoji(emoji);
}}
onReply={(message, mentions, replyTimestamp) => {
onReply={(message, replyBodyRanges, replyTimestamp) => {
if (!isGroupStory) {
setCurrentViewTarget(null);
showToast({ toastType: ToastType.StoryReply });
}
onReplyToStory(message, mentions, replyTimestamp, story);
onReplyToStory(message, replyBodyRanges, replyTimestamp, story);
}}
onSetSkinTone={onSetSkinTone}
onTextTooLong={onTextTooLong}

View file

@ -11,7 +11,7 @@ import React, {
import classNames from 'classnames';
import { noop } from 'lodash';
import type { DraftBodyRangeMention } from '../types/BodyRange';
import type { DraftBodyRanges } from '../types/BodyRange';
import type { LocalizerType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
@ -89,13 +89,15 @@ export type PropsType = {
hasViewsCapability: boolean;
i18n: LocalizerType;
platform: string;
isFormattingEnabled: boolean;
isFormattingSpoilersEnabled: boolean;
isInternalUser?: boolean;
onChangeViewTarget: (target: StoryViewTargetType) => unknown;
onClose: () => unknown;
onReact: (emoji: string) => unknown;
onReply: (
message: string,
mentions: ReadonlyArray<DraftBodyRangeMention>,
bodyRanges: DraftBodyRanges,
timestamp: number
) => unknown;
onSetSkinTone: (tone: number) => unknown;
@ -123,6 +125,8 @@ export function StoryViewsNRepliesModal({
hasViewsCapability,
i18n,
platform,
isFormattingEnabled,
isFormattingSpoilersEnabled,
isInternalUser,
onChangeViewTarget,
onClose,
@ -233,6 +237,8 @@ export function StoryViewsNRepliesModal({
getPreferredBadge={getPreferredBadge}
i18n={i18n}
inputApi={inputApiRef}
isFormattingEnabled={isFormattingEnabled}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
moduleClassName="StoryViewsNRepliesModal__input"
onEditorStateChange={({ messageText }) => {
setMessageBodyText(messageText);

View file

@ -196,7 +196,7 @@ function renderNode({
);
}
const content = renderMentions({
let content = renderMentions({
direction,
disableLinks,
emojiSizeClass,
@ -206,13 +206,19 @@ function renderNode({
text: node.text,
});
// We use separate elements for these because we want screenreaders to understand them
if (node.isBold || node.isKeywordHighlight) {
content = <strong>{content}</strong>;
}
if (node.isItalic) {
content = <em>{content}</em>;
}
if (node.isStrikethrough) {
content = <s>{content}</s>;
}
const formattingClasses = classNames(
node.isBold ? 'MessageTextRenderer__formatting--bold' : null,
node.isItalic ? 'MessageTextRenderer__formatting--italic' : null,
node.isMonospace ? 'MessageTextRenderer__formatting--monospace' : null,
node.isStrikethrough
? 'MessageTextRenderer__formatting--strikethrough'
: null,
node.isKeywordHighlight
? 'MessageTextRenderer__formatting--keywordHighlight'
: null,

View file

@ -22,7 +22,7 @@ import type {
ReactionType,
} from '../../textsecure/SendMessage';
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import { BodyRange } from '../../types/BodyRange';
import type { RawBodyRange } from '../../types/BodyRange';
import type { StoryContextType } from '../../types/Util';
import type { LoggerType } from '../../types/Logging';
import type { StickerWithHydratedData } from '../../types/Stickers';
@ -150,7 +150,7 @@ export async function sendNormalMessage(
contact,
deletedForEveryoneTimestamp,
expireTimer,
mentions,
bodyRanges,
messageTimestamp,
preview,
quote,
@ -208,6 +208,7 @@ export async function sendNormalMessage(
const dataMessage = await messaging.getDataMessage({
attachments,
body,
bodyRanges,
contact,
deletedForEveryoneTimestamp,
expireTimer,
@ -252,6 +253,7 @@ export async function sendNormalMessage(
contentHint: ContentHint.RESENDABLE,
groupSendOptions: {
attachments,
bodyRanges,
contact,
deletedForEveryoneTimestamp,
expireTimer,
@ -267,7 +269,6 @@ export async function sendNormalMessage(
storyContext,
reaction,
timestamp: messageTimestamp,
mentions,
},
messageId,
sendOptions,
@ -307,6 +308,7 @@ export async function sendNormalMessage(
log.info('sending direct message');
innerPromise = messaging.sendMessageToIdentifier({
attachments,
bodyRanges,
contact,
contentHint: ContentHint.RESENDABLE,
deletedForEveryoneTimestamp,
@ -472,7 +474,7 @@ async function getMessageSendData({
contact?: Array<ContactWithHydratedAvatar>;
deletedForEveryoneTimestamp: undefined | number;
expireTimer: undefined | DurationInSeconds;
mentions: undefined | ReadonlyArray<BodyRange<BodyRange.Mention>>;
bodyRanges: undefined | ReadonlyArray<RawBodyRange>;
messageTimestamp: number;
preview: Array<LinkPreviewType>;
quote: QuotedMessageType | null;
@ -539,7 +541,8 @@ async function getMessageSendData({
contact,
deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'),
expireTimer: message.get('expireTimer'),
mentions: message.get('bodyRanges')?.filter(BodyRange.isMention),
// TODO: we want filtration here if feature flag doesn't allow format/spoiler sends
bodyRanges: message.get('bodyRanges'),
messageTimestamp,
preview,
quote,

View file

@ -62,6 +62,7 @@ export class SettingsChannel extends EventEmitter {
this.installCallback('isPrimary');
this.installCallback('syncRequest');
this.installCallback('isPhoneNumberSharingEnabled');
this.installCallback('isFormattingFlagEnabled');
this.installCallback('shouldShowStoriesSettings');
// Getters only. These are set by the primary device
@ -87,6 +88,7 @@ export class SettingsChannel extends EventEmitter {
this.installSetting('spellCheck', {
isEphemeral: true,
});
this.installSetting('textFormatting');
this.installSetting('autoDownloadUpdate');
this.installSetting('autoLaunch');

4
ts/model-types.d.ts vendored
View file

@ -6,7 +6,7 @@
import * as Backbone from 'backbone';
import type { GroupV2ChangeType } from './groups';
import type { DraftBodyRangeMention, RawBodyRange } from './types/BodyRange';
import type { DraftBodyRanges, RawBodyRange } from './types/BodyRange';
import type { CallHistoryDetailsFromDiskType } from './types/Calling';
import type { CustomColorType, ConversationColorType } from './types/Colors';
import type { DeviceType } from './textsecure/Types.d';
@ -298,7 +298,7 @@ export type ConversationAttributesType = {
firstUnregisteredAt?: number;
draftChanged?: boolean;
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>;
draftBodyRanges?: DraftBodyRanges;
draftTimestamp?: number | null;
hideStory?: boolean;
inbox_position?: number;

View file

@ -84,7 +84,7 @@ import {
deriveAccessKey,
} from '../Crypto';
import * as Bytes from '../Bytes';
import type { DraftBodyRangeMention } from '../types/BodyRange';
import type { DraftBodyRanges } from '../types/BodyRange';
import { BodyRange, hydrateRanges } from '../types/BodyRange';
import { migrateColor } from '../util/migrateColor';
import { isNotNil } from '../util/isNotNil';
@ -3870,7 +3870,7 @@ export class ConversationModel extends window.Backbone
}
private getDraftBodyRanges = memoizeByThis(
(): ReadonlyArray<DraftBodyRangeMention> | undefined => {
(): DraftBodyRanges | undefined => {
return this.get('draftBodyRanges');
}
);
@ -4133,7 +4133,7 @@ export class ConversationModel extends window.Backbone
attachments,
body,
contact,
mentions,
bodyRanges,
preview,
quote,
sticker,
@ -4141,7 +4141,7 @@ export class ConversationModel extends window.Backbone
attachments: Array<AttachmentType>;
body: string | undefined;
contact?: Array<ContactWithHydratedAvatar>;
mentions?: ReadonlyArray<BodyRange<BodyRange.Mention>>;
bodyRanges?: DraftBodyRanges;
preview?: Array<LinkPreviewType>;
quote?: QuotedMessageType;
sticker?: StickerWithHydratedData;
@ -4239,7 +4239,7 @@ export class ConversationModel extends window.Backbone
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.NotApplicable,
sticker,
bodyRanges: mentions,
bodyRanges,
sendHQImages,
sendStateByConversationId: zipObject(
recipientConversationIds,

View file

@ -0,0 +1,323 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type Quill 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 * as log from '../../logging/log';
import * as Errors from '../../types/errors';
import type { LocalizerType } from '../../types/Util';
import { handleOutsideClick } from '../../util/handleOutsideClick';
type FormattingPickerOptions = {
i18n: LocalizerType;
isEnabled: boolean;
isSpoilersEnabled: boolean;
setFormattingChooserElement: (element: JSX.Element | null) => void;
};
export enum QuillFormattingStyle {
bold = 'bold',
italic = 'italic',
monospace = 'monospace',
strike = 'strike',
spoiler = 'spoiler',
}
export class FormattingMenu {
lastSelection: { start: number; end: number } | undefined;
options: FormattingPickerOptions;
outsideClickDestructor?: () => void;
quill: Quill;
referenceElement: VirtualElement | undefined;
root: HTMLDivElement;
constructor(quill: Quill, options: FormattingPickerOptions) {
this.quill = quill;
this.options = options;
this.root = document.body.appendChild(document.createElement('div'));
this.quill.on('editor-change', this.onEditorChange.bind(this));
// Note: Bold and Italic are built-in
this.quill.keyboard.addBinding({ key: 'E', shortKey: true }, () =>
this.toggleForStyle(QuillFormattingStyle.monospace)
);
this.quill.keyboard.addBinding(
{ key: 'X', shortKey: true, shiftKey: true },
() => this.toggleForStyle(QuillFormattingStyle.strike)
);
this.quill.keyboard.addBinding(
{ key: 'B', shortKey: true, shiftKey: true },
() => this.toggleForStyle(QuillFormattingStyle.spoiler)
);
}
destroy(): void {
this.root.remove();
}
updateOptions(options: Partial<FormattingPickerOptions>): void {
this.options = { ...this.options, ...options };
this.onEditorChange();
}
onEditorChange(): void {
if (!this.options.isEnabled) {
this.lastSelection = undefined;
this.referenceElement = undefined;
this.render();
return;
}
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) {
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 || {
getBoundingClientRect() {
const selection = window.getSelection();
// there's a selection and at least one range
if (selection != null && selection.rangeCount !== 0) {
// grab the first range, the one the user is actually on right now
const range = selection.getRangeAt(0);
const { activeElement } = document;
const editorElement = activeElement?.closest(
'.module-composition-input__input'
);
const rect = range.getClientRects()[0];
// 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,
rect.y
);
return DOMRect.fromRect({
x: rect.x,
y: updatedY,
height: rect.height,
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
},
};
}
this.render();
}
isStyleEnabledInSelection(style: QuillFormattingStyle): boolean | undefined {
const selection = this.quill.getSelection();
if (!selection || !selection.length) {
return;
}
const contents = this.quill.getContents(selection.index, selection.length);
return contents.ops.every(op => op.attributes?.[style]);
}
toggleForStyle(style: QuillFormattingStyle): void {
try {
const isEnabled = this.isStyleEnabledInSelection(style);
if (isEnabled === undefined) {
return;
}
this.quill.format(style, !isEnabled);
} catch (error) {
log.error('toggleForStyle error:', Errors.toLogFormat(error));
}
}
render(): void {
if (!this.lastSelection) {
this.outsideClickDestructor?.();
this.outsideClickDestructor = undefined;
this.options.setFormattingChooserElement(null);
return;
}
const { i18n, isSpoilersEnabled } = this.options;
// showing the popup format menu
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);
}}
>
<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>
<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
)}
/>
</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
)}
/>
</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
)}
/>
</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
)}
/>
</button>
) : null}
</div>
)}
</Popper>,
this.root
);
// Just to make sure that we don't propagate outside clicks until this is closed.
this.outsideClickDestructor?.();
this.outsideClickDestructor = handleOutsideClick(
() => {
return true;
},
{
name: 'quill.emoji.completion',
containerElements: [this.root],
}
);
this.options.setFormattingChooserElement(element);
}
}

View file

@ -0,0 +1,30 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type Parchment from 'parchment';
import Quill from 'quill';
const Inline: typeof Parchment.Inline = Quill.import('blots/inline');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyRecord = Record<string, any>;
export class MonospaceBlot extends Inline {
static override formats(): boolean {
return true;
}
override optimize(context: AnyRecord): void {
super.optimize(context);
if (!this.domNode.classList.contains(this.statics.className)) {
this.domNode.classList.add(this.statics.className);
}
}
}
MonospaceBlot.blotName = 'monospace';
MonospaceBlot.className = 'quill--monospace';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore See this workaround: https://github.com/quilljs/quill/issues/2312#issuecomment-1097922620
Inline.order.splice(Inline.order.indexOf('bold'), 0, MonospaceBlot.blotName);

View file

@ -0,0 +1,30 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type Parchment from 'parchment';
import Quill from 'quill';
const Inline: typeof Parchment.Inline = Quill.import('blots/inline');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyRecord = Record<string, any>;
export class SpoilerBlot extends Inline {
static override formats(): boolean {
return true;
}
override optimize(context: AnyRecord): void {
super.optimize(context);
if (!this.domNode.classList.contains(this.statics.className)) {
this.domNode.classList.add(this.statics.className);
}
}
}
SpoilerBlot.blotName = 'spoiler';
SpoilerBlot.className = 'quill--spoiler';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore See this workaround: https://github.com/quilljs/quill/issues/2312#issuecomment-1097922620
Inline.order.splice(Inline.order.indexOf('bold'), 0, SpoilerBlot.blotName);

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

@ -4,6 +4,7 @@
import type UpdatedDelta from 'quill-delta';
import type { MentionCompletion } from './mentions/completion';
import type { EmojiCompletion } from './emoji/completion';
import type { FormattingMenu } from './formatting/menu';
declare module 'react-quill' {
// `react-quill` uses a different but compatible version of Delta
@ -21,6 +22,7 @@ declare module 'quill' {
interface UpdatedKey {
key: string | number;
shiftKey?: boolean;
shortKey?: boolean;
}
export type UpdatedTextChangeHandler = (
@ -29,6 +31,10 @@ declare module 'quill' {
source: Sources
) => void;
export type UpdatedEditorChangeHandler = (
eventName: 'text-change' | 'selection-change'
) => void;
interface LeafBlot {
text?: string;
// Quill doesn't make it easy to type this result.
@ -61,11 +67,16 @@ declare module 'quill' {
eventName: 'text-change',
handler: UpdatedTextChangeHandler
): EventEmitter;
on(
eventName: 'editor-change',
handler: UpdatedEditorChangeHandler
): EventEmitter;
getModule(module: 'history'): HistoryStatic;
getModule(module: 'clipboard'): ClipboardStatic;
getModule(module: 'mentionCompletion'): MentionCompletion;
getModule(module: 'emojiCompletion'): EmojiCompletion;
getModule(module: 'formattingMenu'): FormattingMenu;
getModule(module: 'history'): HistoryStatic;
getModule(module: 'mentionCompletion'): MentionCompletion;
getModule(module: string): unknown;
selection: SelectionStatic;

View file

@ -6,18 +6,30 @@ import Delta from 'quill-delta';
import type { LeafBlot, DeltaOperation } from 'quill';
import type Op from 'quill-delta/dist/Op';
import type { DraftBodyRangeMention } from '../types/BodyRange';
import type {
DisplayNode,
DraftBodyRange,
DraftBodyRanges,
} from '../types/BodyRange';
import { BodyRange } from '../types/BodyRange';
import type { MentionBlot } from './mentions/blot';
import { QuillFormattingStyle } from './formatting/menu';
export type MentionBlotValue = {
uuid: string;
title: string;
};
export type FormattingBlotValue = {
style: BodyRange.Style;
};
export const isMentionBlot = (blot: LeafBlot): blot is MentionBlot =>
blot.value() && blot.value().mention;
export const isFormatting = (blot: LeafBlot): blot is MentionBlot =>
blot.value() && blot.value().style;
export type RetainOp = Op & { retain: number };
export type InsertOp<K extends string, T> = Op & { insert: { [V in K]: T } };
@ -60,13 +72,102 @@ export const getTextFromOps = (ops: Array<DeltaOperation>): string =>
}, '')
.trim();
export const getTextAndMentionsFromOps = (
const { BOLD, ITALIC, MONOSPACE, SPOILER, STRIKETHROUGH, NONE } =
BodyRange.Style;
function extractFormatRange({
bodyRanges,
index,
previousData,
hasStyle,
style,
}: {
bodyRanges: Array<DraftBodyRange>;
index: number;
previousData: { start: number } | undefined;
hasStyle: boolean;
style: BodyRange.Style;
}) {
if (hasStyle && !previousData) {
return { start: index };
}
if (!hasStyle && previousData) {
const { start } = previousData;
bodyRanges.push({
length: index - start,
start,
style,
});
return undefined;
}
return previousData;
}
function extractAllFormats(
bodyRanges: Array<DraftBodyRange>,
formats: Record<BodyRange.Style, { start: number } | undefined>,
index: number,
op?: Op
): Record<BodyRange.Style, { start: number } | undefined> {
const result = { ...formats };
const params = {
bodyRanges,
index,
};
result[BOLD] = extractFormatRange({
...params,
style: BOLD,
previousData: result[BOLD],
hasStyle: op?.attributes?.[QuillFormattingStyle.bold],
});
result[ITALIC] = extractFormatRange({
...params,
style: ITALIC,
previousData: result[ITALIC],
hasStyle: op?.attributes?.[QuillFormattingStyle.italic],
});
result[MONOSPACE] = extractFormatRange({
...params,
style: MONOSPACE,
previousData: result[MONOSPACE],
hasStyle: op?.attributes?.[QuillFormattingStyle.monospace],
});
result[SPOILER] = extractFormatRange({
...params,
style: SPOILER,
previousData: result[SPOILER],
hasStyle: op?.attributes?.[QuillFormattingStyle.spoiler],
});
result[STRIKETHROUGH] = extractFormatRange({
...params,
style: STRIKETHROUGH,
previousData: result[STRIKETHROUGH],
hasStyle: op?.attributes?.[QuillFormattingStyle.strike],
});
return result;
}
export const getTextAndRangesFromOps = (
ops: Array<Op>
): [string, ReadonlyArray<DraftBodyRangeMention>] => {
const mentions: Array<DraftBodyRangeMention> = [];
): { text: string; bodyRanges: DraftBodyRanges } => {
const bodyRanges: Array<DraftBodyRange> = [];
let formats: Record<BodyRange.Style, { start: number } | undefined> = {
[BOLD]: undefined,
[ITALIC]: undefined,
[MONOSPACE]: undefined,
[SPOILER]: undefined,
[STRIKETHROUGH]: undefined,
[NONE]: undefined,
};
const text = ops
.reduce((acc, op, index) => {
// Start or finish format sections as needed
formats = extractAllFormats(bodyRanges, formats, acc.length, op);
if (typeof op.insert === 'string') {
const toAdd = index === 0 ? op.insert.trimStart() : op.insert;
return acc + toAdd;
@ -77,7 +178,7 @@ export const getTextAndMentionsFromOps = (
}
if (isInsertMentionOp(op)) {
mentions.push({
bodyRanges.push({
length: 1, // The length of `\uFFFC`
mentionUuid: op.insert.mention.uuid,
replacementText: op.insert.mention.title,
@ -91,7 +192,10 @@ export const getTextAndMentionsFromOps = (
}, '')
.trimEnd(); // Trimming the start of this string will mess up mention indices
return [text, mentions];
// Close off any pending formats
extractAllFormats(bodyRanges, formats, text.length);
return { text, bodyRanges };
};
export const getBlotTextPartitions = (
@ -167,13 +271,35 @@ export const getDeltaToRemoveStaleMentions = (
return new Delta(newOps);
};
export const insertFormattingAndMentionsOps = (
nodes: ReadonlyArray<DisplayNode>
): ReadonlyArray<Op> => {
let ops: Array<Op> = [];
nodes.forEach(node => {
const startingOp: Op = {
insert: node.text,
attributes: {
[QuillFormattingStyle.bold]: node.isBold,
[QuillFormattingStyle.italic]: node.isItalic,
[QuillFormattingStyle.monospace]: node.isMonospace,
[QuillFormattingStyle.spoiler]: node.isSpoiler,
[QuillFormattingStyle.strike]: node.isStrikethrough,
},
};
ops = ops.concat(insertMentionOps([startingOp], node.mentions));
});
return ops;
};
export const insertMentionOps = (
incomingOps: Array<Op>,
bodyRanges: ReadonlyArray<DraftBodyRangeMention>
bodyRanges: DraftBodyRanges
): Array<Op> => {
const ops = [...incomingOps];
const sortableBodyRanges: Array<DraftBodyRangeMention> = bodyRanges.slice();
const sortableBodyRanges: Array<DraftBodyRange> = bodyRanges.slice();
// Working backwards through bodyRanges (to avoid offsetting later mentions),
// Shift off the op with the text to the left of the last mention,
@ -191,7 +317,7 @@ export const insertMentionOps = (
const op = ops.shift();
if (op) {
const { insert } = op;
const { insert, attributes } = op;
if (typeof insert === 'string') {
const left = insert.slice(0, start);
@ -202,9 +328,9 @@ export const insertMentionOps = (
title: replacementText,
};
ops.unshift({ insert: right });
ops.unshift({ insert: { mention } });
ops.unshift({ insert: left });
ops.unshift({ insert: right, attributes });
ops.unshift({ insert: { mention }, attributes });
ops.unshift({ insert: left, attributes });
} else {
ops.unshift(op);
}
@ -214,10 +340,11 @@ export const insertMentionOps = (
return ops;
};
export const insertEmojiOps = (incomingOps: Array<Op>): Array<Op> => {
export const insertEmojiOps = (incomingOps: ReadonlyArray<Op>): Array<Op> => {
return incomingOps.reduce((ops, op) => {
if (typeof op.insert === 'string') {
const text = op.insert;
const { attributes } = op;
const re = emojiRegex();
let index = 0;
let match: RegExpExecArray | null;
@ -225,12 +352,12 @@ export const insertEmojiOps = (incomingOps: Array<Op>): Array<Op> => {
// eslint-disable-next-line no-cond-assign
while ((match = re.exec(text))) {
const [emoji] = match;
ops.push({ insert: text.slice(index, match.index) });
ops.push({ insert: { emoji } });
ops.push({ insert: text.slice(index, match.index), attributes });
ops.push({ insert: { emoji }, attributes });
index = match.index + emoji.length;
}
ops.push({ insert: text.slice(index, text.length) });
ops.push({ insert: text.slice(index, text.length), attributes });
} else {
ops.push(op);
}

View file

@ -3,7 +3,7 @@
import path from 'path';
import { debounce } from 'lodash';
import { debounce, isEqual } from 'lodash';
import type { ThunkAction } from 'redux-thunk';
import type { ReadonlyDeep } from 'type-fest';
@ -17,7 +17,7 @@ import type {
InMemoryAttachmentDraftType,
} from '../../types/Attachment';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import type { DraftBodyRangeMention } from '../../types/BodyRange';
import type { DraftBodyRanges } from '../../types/BodyRange';
import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import type { MessageAttributesType } from '../../model-types.d';
import type { NoopActionType } from './noop';
@ -87,6 +87,7 @@ import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper';
import { drop } from '../../util/drop';
import { strictAssert } from '../../util/assert';
import { makeQuote } from '../../util/makeQuote';
import { maybeBlockSendForFormattingModal } from '../../util/maybeBlockSendForFormattingModal';
// State
// eslint-disable-next-line local-rules/type-alias-readonlydeep
@ -380,7 +381,7 @@ function sendMultiMediaMessage(
conversationId: string,
options: {
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>;
bodyRanges?: DraftBodyRanges;
message?: string;
timestamp?: number;
voiceNoteAttachment?: InMemoryAttachmentDraftType;
@ -404,7 +405,7 @@ function sendMultiMediaMessage(
const {
draftAttachments,
draftBodyRanges,
bodyRanges,
message = '',
timestamp = Date.now(),
voiceNoteAttachment,
@ -430,7 +431,28 @@ function sendMultiMediaMessage(
}
} catch (error) {
dispatch(setComposerDisabledState(conversationId, false));
log.error('sendMessage error:', Errors.toLogFormat(error));
log.error(
'sendMessage block until verified error:',
Errors.toLogFormat(error)
);
return;
}
try {
if (bodyRanges?.length && !window.storage.get('formattingWarningShown')) {
const sendAnyway = await maybeBlockSendForFormattingModal(bodyRanges);
if (!sendAnyway) {
dispatch(setComposerDisabledState(conversationId, false));
return;
}
drop(window.storage.put('formattingWarningShown', true));
}
} catch (error) {
dispatch(setComposerDisabledState(conversationId, false));
log.error(
'sendMessage block for formatting modal:',
Errors.toLogFormat(error)
);
return;
}
@ -493,7 +515,7 @@ function sendMultiMediaMessage(
attachments,
quote,
preview: getLinkPreviewForSend(message),
mentions: draftBodyRanges,
bodyRanges,
},
{
sendHQImages,
@ -810,7 +832,7 @@ function onEditorStateChange({
messageText,
sendCounter,
}: {
bodyRanges: ReadonlyArray<DraftBodyRangeMention>;
bodyRanges: DraftBodyRanges;
caretLocation?: number;
conversationId: string | undefined;
messageText: string;
@ -1163,7 +1185,7 @@ const debouncedSaveDraft = debounce(saveDraft);
function saveDraft(
conversationId: string,
messageText: string,
mentions: ReadonlyArray<DraftBodyRangeMention>
bodyRanges: DraftBodyRanges
) {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
@ -1183,7 +1205,10 @@ function saveDraft(
return;
}
if (messageText !== conversation.get('draft')) {
if (
messageText !== conversation.get('draft') ||
!isEqual(bodyRanges, conversation.get('draftBodyRanges'))
) {
log.info(`saveDraft(${conversation.idForLogging()})`);
const now = Date.now();
let activeAt = conversation.get('active_at');
@ -1197,7 +1222,7 @@ function saveDraft(
conversation.set({
active_at: activeAt,
draft: messageText,
draftBodyRanges: mentions,
draftBodyRanges: bodyRanges,
draftChanged: true,
timestamp,
});

View file

@ -56,7 +56,7 @@ import type {
MessageAttributesType,
} from '../../model-types.d';
import type {
DraftBodyRangeMention,
DraftBodyRanges,
HydratedBodyRangesType,
} from '../../types/BodyRange';
import { CallMode } from '../../types/Calling';
@ -288,7 +288,7 @@ export type ConversationType = ReadonlyDeep<
shouldShowDraft?: boolean;
// Full information for re-hydrating composition area
draftText?: string;
draftBodyRanges?: ReadonlyArray<DraftBodyRangeMention>;
draftBodyRanges?: DraftBodyRanges;
// Summary for the left pane
draftPreview?: DraftPreviewType;

View file

@ -59,6 +59,9 @@ export type SafetyNumberChangedBlockingDataType = ReadonlyDeep<{
promiseUuid: UUIDStringType;
source?: SafetyNumberChangeSource;
}>;
export type FormattingWarningDataType = ReadonlyDeep<{
explodedPromise: ExplodePromiseResultType<boolean>;
}>;
export type AuthorizeArtCreatorDataType =
ReadonlyDeep<AuthorizeArtCreatorOptionsType>;
@ -72,27 +75,28 @@ type MigrateToGV2PropsType = ReadonlyDeep<{
export type GlobalModalsStateType = ReadonlyDeep<{
addUserToAnotherGroupModalContactId?: string;
authArtCreatorData?: AuthorizeArtCreatorDataType;
contactModalState?: ContactModalStateType;
deleteMessagesProps?: DeleteMessagesPropsType;
editHistoryMessages?: EditHistoryMessagesType;
errorModalProps?: {
description?: string;
title?: string;
};
deleteMessagesProps?: DeleteMessagesPropsType;
formattingWarningData?: FormattingWarningDataType;
forwardMessagesProps?: ForwardMessagesPropsType;
gv2MigrationProps?: MigrateToGV2PropsType;
hasConfirmationModal: boolean;
isAuthorizingArtCreator?: boolean;
isProfileEditorVisible: boolean;
isSignalConnectionsVisible: boolean;
isShortcutGuideModalVisible: boolean;
isSignalConnectionsVisible: boolean;
isStoriesSettingsVisible: boolean;
isWhatsNewVisible: boolean;
profileEditorHasError: boolean;
safetyNumberChangedBlockingData?: SafetyNumberChangedBlockingDataType;
safetyNumberModalContactId?: string;
stickerPackPreviewId?: string;
isAuthorizingArtCreator?: boolean;
authArtCreatorData?: AuthorizeArtCreatorDataType;
userNotFoundModalState?: UserNotFoundModalStateType;
}>;
@ -126,6 +130,8 @@ const SHOW_STICKER_PACK_PREVIEW = 'globalModals/SHOW_STICKER_PACK_PREVIEW';
const CLOSE_STICKER_PACK_PREVIEW = 'globalModals/CLOSE_STICKER_PACK_PREVIEW';
const CLOSE_ERROR_MODAL = 'globalModals/CLOSE_ERROR_MODAL';
const SHOW_ERROR_MODAL = 'globalModals/SHOW_ERROR_MODAL';
const SHOW_FORMATTING_WARNING_MODAL =
'globalModals/SHOW_FORMATTING_WARNING_MODAL';
const CLOSE_SHORTCUT_GUIDE_MODAL = 'globalModals/CLOSE_SHORTCUT_GUIDE_MODAL';
const SHOW_SHORTCUT_GUIDE_MODAL = 'globalModals/SHOW_SHORTCUT_GUIDE_MODAL';
const SHOW_AUTH_ART_CREATOR = 'globalModals/SHOW_AUTH_ART_CREATOR';
@ -221,6 +227,13 @@ type ShowStoriesSettingsActionType = ReadonlyDeep<{
type: typeof SHOW_STORIES_SETTINGS;
}>;
type ShowFormattingWarningModalActionType = ReadonlyDeep<{
type: typeof SHOW_FORMATTING_WARNING_MODAL;
payload: {
explodedPromise: ExplodePromiseResultType<boolean> | undefined;
};
}>;
type HideStoriesSettingsActionType = ReadonlyDeep<{
type: typeof HIDE_STORIES_SETTINGS;
}>;
@ -323,6 +336,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| ShowContactModalActionType
| ShowEditHistoryModalActionType
| ShowErrorModalActionType
| ShowFormattingWarningModalActionType
| ShowSendAnywayDialogActionType
| ShowShortcutGuideModalActionType
| ShowStickerPackPreviewActionType
@ -331,13 +345,13 @@ export type GlobalModalsActionType = ReadonlyDeep<
| ShowWhatsNewModalActionType
| StartMigrationToGV2ActionType
| ToggleAddUserToAnotherGroupModalActionType
| ToggleConfirmationModalActionType
| ToggleDeleteMessagesModalActionType
| ToggleForwardMessagesModalActionType
| ToggleProfileEditorActionType
| ToggleProfileEditorErrorActionType
| ToggleSafetyNumberModalActionType
| ToggleSignalConnectionsModalActionType
| ToggleConfirmationModalActionType
>;
// Action Creators
@ -360,6 +374,7 @@ export const actions = {
showContactModal,
showEditHistoryModal,
showErrorModal,
showFormattingWarningModal,
showGV2MigrationDialog,
showShortcutGuideModal,
showStickerPackPreview,
@ -434,6 +449,12 @@ function showStoriesSettings(): ShowStoriesSettingsActionType {
return { type: SHOW_STORIES_SETTINGS };
}
function showFormattingWarningModal(
explodedPromise: ExplodePromiseResultType<boolean> | undefined
): ShowFormattingWarningModalActionType {
return { type: SHOW_FORMATTING_WARNING_MODAL, payload: { explodedPromise } };
}
function showGV2MigrationDialog(
conversationId: string
): ThunkAction<void, RootStateType, unknown, StartMigrationToGV2ActionType> {
@ -944,6 +965,21 @@ export function reducer(
};
}
if (action.type === SHOW_FORMATTING_WARNING_MODAL) {
const { explodedPromise } = action.payload;
if (!explodedPromise) {
return {
...state,
formattingWarningData: undefined,
};
}
return {
...state,
formattingWarningData: { explodedPromise },
};
}
if (action.type === SHOW_STICKER_PACK_PREVIEW) {
return {
...state,

View file

@ -7,7 +7,7 @@ import { isEqual, pick } from 'lodash';
import type { ReadonlyDeep } from 'type-fest';
import * as Errors from '../../types/errors';
import type { AttachmentType } from '../../types/Attachment';
import type { DraftBodyRangeMention } from '../../types/BodyRange';
import type { DraftBodyRanges } from '../../types/BodyRange';
import type { MessageAttributesType } from '../../model-types.d';
import type {
MessageChangedActionType,
@ -559,7 +559,7 @@ function reactToStory(
function replyToStory(
conversationId: string,
messageBody: string,
mentions: ReadonlyArray<DraftBodyRangeMention>,
bodyRanges: DraftBodyRanges,
timestamp: number,
story: StoryViewType
): ThunkAction<void, RootStateType, unknown, StoryChangedActionType> {
@ -575,7 +575,7 @@ function replyToStory(
{
body: messageBody,
attachments: [],
mentions,
bodyRanges,
},
{
storyId: story.messageId,

View file

@ -6,6 +6,11 @@ import { createSelector } from 'reselect';
import type { StateType } from '../reducer';
import type { ComposerStateType, QuotedMessageType } from '../ducks/composer';
import { getComposerStateForConversation } from '../ducks/composer';
import {
getRemoteConfig,
getTextFormattingEnabled,
isRemoteConfigFlagEnabled,
} from './items';
export const getComposerState = (state: StateType): ComposerStateType =>
state.composer;
@ -22,3 +27,28 @@ export const getQuotedMessageSelector = createSelector(
(conversationId: string): QuotedMessageType | undefined =>
composerStateForConversationIdSelector(conversationId).quotedMessage
);
export const getIsFormattingEnabled = createSelector(
getTextFormattingEnabled,
getRemoteConfig,
(isOptionEnabled, remoteConfig) => {
return (
isOptionEnabled &&
isRemoteConfigFlagEnabled(remoteConfig, 'desktop.textFormatting')
);
}
);
export const getIsFormattingSpoilersEnabled = createSelector(
getTextFormattingEnabled,
getRemoteConfig,
(isOptionEnabled, remoteConfig) => {
return (
isOptionEnabled &&
isRemoteConfigFlagEnabled(
remoteConfig,
'desktop.textFormatting.spoilerSend'
)
);
}
);

View file

@ -48,7 +48,7 @@ export const getUniversalExpireTimer = createSelector(
DurationInSeconds.fromSeconds(state[UNIVERSAL_EXPIRE_TIMER_ITEM] || 0)
);
const isRemoteConfigFlagEnabled = (
export const isRemoteConfigFlagEnabled = (
config: Readonly<ConfigMapType>,
key: ConfigKeyType
): boolean => Boolean(config[key]?.enabled);
@ -250,3 +250,8 @@ export const getAutoDownloadUpdate = createSelector(
(state: ItemsStateType): boolean =>
Boolean(state['auto-download-update'] ?? true)
);
export const getTextFormattingEnabled = createSelector(
getItems,
(state: ItemsStateType): boolean => Boolean(state.textFormatting ?? true)
);

View file

@ -32,11 +32,16 @@ import {
getRecentStickers,
} from '../selectors/stickers';
import { isSignalConversation } from '../../util/isSignalConversation';
import { getComposerStateForConversationIdSelector } from '../selectors/composer';
import {
getComposerStateForConversationIdSelector,
getIsFormattingEnabled,
getIsFormattingSpoilersEnabled,
} from '../selectors/composer';
import type { SmartCompositionRecordingProps } from './CompositionRecording';
import { SmartCompositionRecording } from './CompositionRecording';
import type { SmartCompositionRecordingDraftProps } from './CompositionRecordingDraft';
import { SmartCompositionRecordingDraft } from './CompositionRecordingDraft';
import { BodyRange } from '../../types/BodyRange';
type ExternalProps = {
id: string;
@ -93,6 +98,9 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const selectedMessageIds = getSelectedMessageIds(state);
const isFormattingEnabled = getIsFormattingEnabled(state);
const isFormattingSpoilersEnabled = getIsFormattingSpoilersEnabled(state);
return {
// Base
conversationId: id,
@ -100,6 +108,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
getPreferredBadge: getPreferredBadgeSelector(state),
i18n: getIntl(state),
isDisabled,
isFormattingSpoilersEnabled,
isFormattingEnabled,
messageCompositionId,
sendCounter,
theme: getTheme(state),
@ -154,7 +164,19 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
groupAdmins: getGroupAdminsSelector(state)(conversation.id),
draftText: dropNull(draftText),
draftBodyRanges,
draftBodyRanges: draftBodyRanges?.map(bodyRange => {
if (BodyRange.isMention(bodyRange)) {
const mentionConvo = conversationSelector(bodyRange.mentionUuid);
return {
...bodyRange,
conversationID: mentionConvo.id,
replacementText: mentionConvo.title,
};
}
return bodyRange;
}),
renderSmartCompositionRecording: (
recProps: SmartCompositionRecordingProps
) => {

View file

@ -12,9 +12,14 @@ import { useActions as useEmojiActions } from '../ducks/emojis';
import { useActions as useItemsActions } from '../ducks/items';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { useComposerActions } from '../ducks/composer';
import {
getIsFormattingEnabled,
getIsFormattingSpoilersEnabled,
} from '../selectors/composer';
export type SmartCompositionTextAreaProps = Pick<
CompositionTextAreaProps,
| 'bodyRanges'
| 'draftText'
| 'placeholder'
| 'onChange'
@ -36,11 +41,17 @@ export function SmartCompositionTextArea(
const { onTextTooLong } = useComposerActions();
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
const isFormattingEnabled = useSelector(getIsFormattingEnabled);
const isFormattingSpoilersEnabled = useSelector(
getIsFormattingSpoilersEnabled
);
return (
<CompositionTextArea
{...props}
i18n={i18n}
isFormattingEnabled={isFormattingEnabled}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
onPickEmoji={onPickEmoji}
onSetSkinTone={onSetSkinTone}
getPreferredBadge={getPreferredBadge}

View file

@ -3,16 +3,12 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import type {
ForwardMessagePropsType,
ForwardMessagesPropsType,
} from '../ducks/globalModals';
import type { ForwardMessagesPropsType } from '../ducks/globalModals';
import type { StateType } from '../reducer';
import * as log from '../../logging/log';
import { ForwardMessagesModal } from '../../components/ForwardMessagesModal';
import { LinkPreviewSourceType } from '../../types/LinkPreview';
import * as Errors from '../../types/errors';
import type { GetConversationByIdType } from '../selectors/conversations';
import {
getAllComposableConversations,
getConversationSelector,
@ -32,38 +28,9 @@ import {
} from '../../services/LinkPreview';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useLinkPreviewActions } from '../ducks/linkPreviews';
import { processBodyRanges } from '../selectors/message';
import { SmartCompositionTextArea } from './CompositionTextArea';
import { useToastActions } from '../ducks/toast';
import type { HydratedBodyRangeMention } from '../../types/BodyRange';
import { applyRangesForText, BodyRange } from '../../types/BodyRange';
function renderMentions(
message: ForwardMessagePropsType,
conversationSelector: GetConversationByIdType
): string | undefined {
const { text } = message;
if (!text) {
return text;
}
const bodyRanges = processBodyRanges(message, {
conversationSelector,
});
if (bodyRanges && bodyRanges.length) {
return applyRangesForText({
mentions: bodyRanges.filter<HydratedBodyRangeMention>(
BodyRange.isMention
),
spoilers: [],
text,
});
}
return text;
}
import { hydrateRanges } from '../../types/BodyRange';
export function SmartForwardMessagesModal(): JSX.Element | null {
const forwardMessagesProps = useSelector<
@ -87,11 +54,12 @@ export function SmartForwardMessagesModal(): JSX.Element | null {
return (
forwardMessagesProps?.messages.map((props): MessageForwardDraft => {
return {
originalMessageId: props.id,
attachments: props.attachments ?? [],
messageBody: renderMentions(props, getConversation),
isSticker: Boolean(props.isSticker),
bodyRanges: hydrateRanges(props.bodyRanges, getConversation),
hasContact: Boolean(props.contact),
isSticker: Boolean(props.isSticker),
messageBody: props.text,
originalMessageId: props.id,
previews: props.previews ?? [],
};
}) ?? []

View file

@ -68,6 +68,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
editHistoryMessages,
errorModalProps,
deleteMessagesProps,
formattingWarningData,
forwardMessagesProps,
isProfileEditorVisible,
isShortcutGuideModalVisible,
@ -85,12 +86,13 @@ export function SmartGlobalModalContainer(): JSX.Element {
);
const {
closeErrorModal,
hideWhatsNewModal,
hideUserNotFoundModal,
toggleSignalConnectionsModal,
cancelAuthorizeArtCreator,
closeErrorModal,
confirmAuthorizeArtCreator,
hideUserNotFoundModal,
hideWhatsNewModal,
showFormattingWarningModal,
toggleSignalConnectionsModal,
} = useGlobalModalActions();
const renderAddUserToAnotherGroup = useCallback(() => {
@ -135,6 +137,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
editHistoryMessages={editHistoryMessages}
errorModalProps={errorModalProps}
deleteMessagesProps={deleteMessagesProps}
formattingWarningData={formattingWarningData}
forwardMessagesProps={forwardMessagesProps}
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
hideUserNotFoundModal={hideUserNotFoundModal}
@ -159,6 +162,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
renderStoriesSettings={renderStoriesSettings}
safetyNumberChangedBlockingData={safetyNumberChangedBlockingData}
safetyNumberModalContactId={safetyNumberModalContactId}
showFormattingWarningModal={showFormattingWarningModal}
stickerPackPreviewId={stickerPackPreviewId}
theme={theme}
toggleSignalConnectionsModal={toggleSignalConnectionsModal}

View file

@ -14,6 +14,10 @@ import {
getKnownStickerPacks,
getReceivedStickerPacks,
} from '../selectors/stickers';
import {
getIsFormattingEnabled,
getIsFormattingSpoilersEnabled,
} from '../selectors/composer';
const mapStateToProps = (state: StateType) => {
const blessedPacks = getBlessedStickerPacks(state);
@ -21,6 +25,9 @@ const mapStateToProps = (state: StateType) => {
const knownPacks = getKnownStickerPacks(state);
const receivedPacks = getReceivedStickerPacks(state);
const isFormattingFlagEnabled = getIsFormattingEnabled(state);
const isFormattingSpoilersFlagEnabled = getIsFormattingSpoilersEnabled(state);
const hasInstalledStickers =
countStickers({
knownPacks,
@ -33,6 +40,8 @@ const mapStateToProps = (state: StateType) => {
return {
hasInstalledStickers,
isFormattingFlagEnabled,
isFormattingSpoilersFlagEnabled,
platform,
i18n: getIntl(state),
};

View file

@ -39,6 +39,10 @@ import { useAudioPlayerActions } from '../ducks/audioPlayer';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useStoriesActions } from '../ducks/stories';
import { useIsWindowActive } from '../../hooks/useIsWindowActive';
import {
getIsFormattingEnabled,
getIsFormattingSpoilersEnabled,
} from '../selectors/composer';
export function SmartStoryViewer(): JSX.Element | null {
const storiesActions = useStoriesActions();
@ -89,6 +93,11 @@ export function SmartStoryViewer(): JSX.Element | null {
getHasStoryViewReceiptSetting
);
const isFormattingEnabled = useSelector(getIsFormattingEnabled);
const isFormattingSpoilersEnabled = useSelector(
getIsFormattingSpoilersEnabled
);
const { pauseVoiceNotePlayer } = useAudioPlayerActions();
const storyInfo = getStoryById(
@ -114,7 +123,8 @@ export function SmartStoryViewer(): JSX.Element | null {
i18n={i18n}
platform={platform}
isInternalUser={internalUser}
saveAttachment={internalUser ? saveAttachment : asyncShouldNeverBeCalled}
isFormattingEnabled={isFormattingEnabled}
isFormattingSpoilersEnabled={isFormattingSpoilersEnabled}
isSignalConversation={isSignalConversation({
id: conversationStory.conversationId,
})}
@ -149,6 +159,7 @@ export function SmartStoryViewer(): JSX.Element | null {
renderEmojiPicker={renderEmojiPicker}
replyState={replyState}
retryMessageSend={retryMessageSend}
saveAttachment={internalUser ? saveAttachment : asyncShouldNeverBeCalled}
showContactModal={showContactModal}
showToast={showToast}
skinTone={skinTone}

View file

@ -124,11 +124,7 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
const deltaList = new Array<number>();
for (let runId = 0; runId < RUN_COUNT + DISCARD_COUNT; runId += 1) {
debug('finding composition input and clicking it');
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const input = composeArea.locator('[data-testid=CompositionInput]');
const input = await app.waitForEnabledComposer(250);
debug('entering message text');
await input.type(`my message ${runId}`);

View file

@ -78,10 +78,7 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
const deltaList = new Array<number>();
for (let runId = 0; runId < RUN_COUNT + DISCARD_COUNT; runId += 1) {
debug('finding composition input and clicking it');
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const input = composeArea.locator('[data-testid=CompositionInput]');
const input = await app.waitForEnabledComposer(250);
debug('entering message text');
await input.type(`my message ${runId}`);

View file

@ -1,7 +1,7 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ElectronApplication, Page } from 'playwright';
import type { ElectronApplication, Locator, Page } from 'playwright';
import { _electron as electron } from 'playwright';
import { EventEmitter } from 'events';
@ -10,6 +10,7 @@ import type {
IPCResponse as ChallengeResponseType,
} from '../challenge';
import type { ReceiptType } from '../types/Receipt';
import { sleep } from '../util/sleep';
export type AppLoadedInfoType = Readonly<{
loadTime: number;
@ -61,6 +62,22 @@ export class App extends EventEmitter {
this.privApp.on('close', () => this.emit('close'));
}
public async waitForEnabledComposer(sleepTimeout = 1000): Promise<Locator> {
const window = await this.getWindow();
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const composeContainer = composeArea.locator(
'[data-testid=CompositionInput][data-enabled=true]'
);
await composeContainer.waitFor();
// Let quill start up
await sleep(sleepTimeout);
return composeContainer.locator('.ql-editor');
}
public async waitForProvisionURL(): Promise<string> {
return this.waitForEvent('provisioning-url');
}

View file

@ -123,12 +123,7 @@ describe('pnp/merge', function needsName() {
debug('Send message to ACI');
{
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const compositionInput = composeArea.locator(
'[data-testid=CompositionInput]'
);
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('Hello ACI');
await compositionInput.press('Enter');
@ -159,12 +154,7 @@ describe('pnp/merge', function needsName() {
if (withNotification) {
debug('Send message to PNI');
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const compositionInput = composeArea.locator(
'[data-testid=CompositionInput]'
);
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('Hello PNI');
await compositionInput.press('Enter');
@ -273,12 +263,7 @@ describe('pnp/merge', function needsName() {
debug('Send message to merged contact');
{
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const compositionInput = composeArea.locator(
'[data-testid=CompositionInput]'
);
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('Hello merged');
await compositionInput.press('Enter');
@ -381,12 +366,7 @@ describe('pnp/merge', function needsName() {
debug('Send message to merged contact');
{
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const compositionInput = composeArea.locator(
'[data-testid=CompositionInput]'
);
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('Hello merged');
await compositionInput.press('Enter');

View file

@ -101,12 +101,7 @@ describe('pnp/PNI Change', function needsName() {
debug('Send message to contactA');
{
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const compositionInput = composeArea.locator(
'[data-testid=CompositionInput]'
);
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('message to contactA');
await compositionInput.press('Enter');
@ -206,12 +201,7 @@ describe('pnp/PNI Change', function needsName() {
debug('Send message to contactA');
{
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const compositionInput = composeArea.locator(
'[data-testid=CompositionInput]'
);
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('message to contactA');
await compositionInput.press('Enter');
@ -313,12 +303,7 @@ describe('pnp/PNI Change', function needsName() {
debug('Send message to contactA');
{
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const compositionInput = composeArea.locator(
'[data-testid=CompositionInput]'
);
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('message to contactA');
await compositionInput.press('Enter');
@ -375,12 +360,7 @@ describe('pnp/PNI Change', function needsName() {
debug('Send message to contactB');
{
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const compositionInput = composeArea.locator(
'[data-testid=CompositionInput]'
);
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('message to contactB');
await compositionInput.press('Enter');
@ -455,12 +435,7 @@ describe('pnp/PNI Change', function needsName() {
debug('Send message to contactA');
{
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const compositionInput = composeArea.locator(
'[data-testid=CompositionInput]'
);
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('message to contactA');
await compositionInput.press('Enter');
@ -548,12 +523,7 @@ describe('pnp/PNI Change', function needsName() {
debug('Send message to contactA');
{
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const compositionInput = composeArea.locator(
'[data-testid=CompositionInput]'
);
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('second message to contactA');
await compositionInput.press('Enter');

View file

@ -104,9 +104,6 @@ describe('pnp/PNI Signature', function needsName() {
const leftPane = window.locator('.left-pane-wrapper');
const conversationStack = window.locator('.conversation-stack');
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
debug('creating a stranger');
const stranger = await server.createPrimaryDevice({
@ -163,15 +160,13 @@ describe('pnp/PNI Signature', function needsName() {
assert.strictEqual(source, desktop, 'initial message has valid source');
checkPniSignature(content.pniSignatureMessage, 'initial message');
}
debug('Enter first message text');
const compositionInput = composeArea.locator(
'[data-testid=CompositionInput]'
);
await compositionInput.type('first');
await compositionInput.press('Enter');
{
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('first');
await compositionInput.press('Enter');
}
debug('Waiting for the first message with pni signature');
{
const { source, content, body, dataMessage } =
@ -193,12 +188,13 @@ describe('pnp/PNI Signature', function needsName() {
timestamp: receiptTimestamp,
});
}
debug('Enter second message text');
{
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('second');
await compositionInput.press('Enter');
await compositionInput.type('second');
await compositionInput.press('Enter');
}
debug('Waiting for the second message with pni signature');
{
const { source, content, body, dataMessage } =
@ -221,12 +217,13 @@ describe('pnp/PNI Signature', function needsName() {
timestamp: receiptTimestamp,
});
}
debug('Enter third message text');
{
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('third');
await compositionInput.press('Enter');
await compositionInput.type('third');
await compositionInput.press('Enter');
}
debug('Waiting for the third message without pni signature');
{
const { source, content, body } = await stranger.waitForMessage();
@ -261,9 +258,6 @@ describe('pnp/PNI Signature', function needsName() {
const window = await app.getWindow();
const leftPane = window.locator('.left-pane-wrapper');
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
debug('opening conversation with the pni contact');
await leftPane
@ -272,12 +266,12 @@ describe('pnp/PNI Signature', function needsName() {
.click();
debug('Enter a PNI message text');
const compositionInput = composeArea.locator(
'[data-testid=CompositionInput]'
);
{
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('Hello PNI');
await compositionInput.press('Enter');
await compositionInput.type('Hello PNI');
await compositionInput.press('Enter');
}
debug('Waiting for a PNI message');
{
@ -296,7 +290,11 @@ describe('pnp/PNI Signature', function needsName() {
const state = await phone.expectStorageState('state before merge');
debug('Enter a draft text without hitting enter');
await compositionInput.type('Draft text');
{
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('Draft text');
}
debug('Send back the response with profile key and pni signature');
@ -313,12 +311,14 @@ describe('pnp/PNI Signature', function needsName() {
.locator(`[data-testid="${pniContact.toContact().uuid}"]`)
.waitFor();
debug('Wait for composition input to clear');
await composeArea.locator('[data-testid=CompositionInput]').waitFor();
{
debug('Wait for composition input to clear');
const compositionInput = await app.waitForEnabledComposer();
debug('Enter an ACI message text');
await compositionInput.type('Hello ACI');
await compositionInput.press('Enter');
debug('Enter an ACI message text');
await compositionInput.type('Hello ACI');
await compositionInput.press('Enter');
}
debug('Waiting for a ACI message');
{

View file

@ -265,12 +265,7 @@ describe('pnp/username', function needsName() {
debug('sending a message');
{
const composeArea = window.locator(
'.composition-area-wrapper, .conversation .ConversationView'
);
const compositionInput = composeArea.locator(
'[data-testid=CompositionInput]'
);
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('Hello Carl');
await compositionInput.press('Enter');

View file

@ -5,9 +5,10 @@ import { assert } from 'chai';
import {
getDeltaToRemoveStaleMentions,
getTextAndMentionsFromOps,
getTextAndRangesFromOps,
getDeltaToRestartMention,
} from '../../quill/util';
import { BodyRange } from '../../types/BodyRange';
describe('getDeltaToRemoveStaleMentions', () => {
const memberUuids = ['abcdef', 'ghijkl'];
@ -83,20 +84,20 @@ describe('getDeltaToRemoveStaleMentions', () => {
});
});
describe('getTextAndMentionsFromOps', () => {
describe('getTextAndRangesFromOps', () => {
describe('given only text', () => {
it('returns only text trimmed', () => {
const ops = [{ insert: ' The ' }, { insert: ' text \n' }];
const [resultText, resultMentions] = getTextAndMentionsFromOps(ops);
assert.equal(resultText, 'The text');
assert.equal(resultMentions.length, 0);
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
assert.equal(text, 'The text');
assert.equal(bodyRanges.length, 0);
});
it('returns trimmed of trailing newlines', () => {
const ops = [{ insert: ' The\ntext\n\n\n' }];
const [resultText, resultMentions] = getTextAndMentionsFromOps(ops);
assert.equal(resultText, 'The\ntext');
assert.equal(resultMentions.length, 0);
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
assert.equal(text, 'The\ntext');
assert.equal(bodyRanges.length, 0);
});
});
@ -120,9 +121,9 @@ describe('getTextAndMentionsFromOps', () => {
},
},
];
const [resultText, resultMentions] = getTextAndMentionsFromOps(ops);
assert.equal(resultText, '😂 wow, funny, \uFFFC');
assert.deepEqual(resultMentions, [
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
assert.equal(text, '😂 wow, funny, \uFFFC');
assert.deepEqual(bodyRanges, [
{
length: 1,
mentionUuid: 'abcdef',
@ -145,9 +146,9 @@ describe('getTextAndMentionsFromOps', () => {
},
},
];
const [resultText, resultMentions] = getTextAndMentionsFromOps(ops);
assert.equal(resultText, '\uFFFC');
assert.deepEqual(resultMentions, [
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
assert.equal(text, '\uFFFC');
assert.deepEqual(bodyRanges, [
{
length: 1,
mentionUuid: 'abcdef',
@ -170,9 +171,9 @@ describe('getTextAndMentionsFromOps', () => {
},
{ insert: '\n test' },
];
const [resultText, resultMentions] = getTextAndMentionsFromOps(ops);
assert.equal(resultText, 'test \n\uFFFC\n test');
assert.deepEqual(resultMentions, [
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
assert.equal(text, 'test \n\uFFFC\n test');
assert.deepEqual(bodyRanges, [
{
length: 1,
mentionUuid: 'abcdef',
@ -182,6 +183,188 @@ describe('getTextAndMentionsFromOps', () => {
]);
});
});
describe('given formatting on text, with emoji and mentions', () => {
it('handles overlapping and contiguous format sections properly', () => {
const ops = [
{
insert: 'Hey, ',
attributes: {
spoiler: true,
},
},
{
insert: {
mention: {
uuid: 'a',
title: '@alice',
},
},
attributes: {
spoiler: true,
},
},
{
insert: ': this is ',
attributes: {
spoiler: true,
},
},
{
insert: 'bold',
attributes: {
bold: true,
spoiler: true,
},
},
{
insert: ' and',
attributes: {
bold: true,
italic: true,
spoiler: true,
},
},
{
insert: ' italic',
attributes: {
italic: true,
spoiler: true,
},
},
{
insert: ' and strikethrough',
attributes: {
strike: true,
},
},
{ insert: ' ' },
{
insert: 'and monospace',
attributes: {
monospace: true,
},
},
];
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
assert.equal(
text,
'Hey, \uFFFC: this is bold and italic and strikethrough and monospace'
);
assert.deepEqual(bodyRanges, [
{
start: 5,
length: 1,
mentionUuid: 'a',
replacementText: '@alice',
},
{
start: 16,
length: 8,
style: BodyRange.Style.BOLD,
},
{
start: 20,
length: 11,
style: BodyRange.Style.ITALIC,
},
{
start: 0,
length: 31,
style: BodyRange.Style.SPOILER,
},
{
start: 31,
length: 18,
style: BodyRange.Style.STRIKETHROUGH,
},
{
start: 50,
length: 13,
style: BodyRange.Style.MONOSPACE,
},
]);
});
it('handles lots of the same format', () => {
const ops = [
{
insert: 'Every',
attributes: {
bold: true,
},
},
{
insert: ' other ',
},
{
insert: 'word',
attributes: {
bold: true,
},
},
{
insert: ' is ',
},
{
insert: 'bold!',
attributes: {
bold: true,
},
},
];
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
assert.equal(text, 'Every other word is bold!');
assert.deepEqual(bodyRanges, [
{
start: 0,
length: 5,
style: BodyRange.Style.BOLD,
},
{
start: 12,
length: 4,
style: BodyRange.Style.BOLD,
},
{
start: 20,
length: 5,
style: BodyRange.Style.BOLD,
},
]);
});
it('handles formatting on mentions', () => {
const ops = [
{
insert: {
mention: {
uuid: 'a',
title: '@alice',
},
},
attributes: {
bold: true,
},
},
];
const { text, bodyRanges } = getTextAndRangesFromOps(ops);
assert.equal(text, '\uFFFC');
assert.deepEqual(bodyRanges, [
{
start: 0,
length: 1,
mentionUuid: 'a',
replacementText: '@alice',
},
{
start: 0,
length: 1,
style: BodyRange.Style.BOLD,
},
]);
});
});
});
describe('getDeltaToRestartMention', () => {

View file

@ -57,6 +57,7 @@ import {
HTTPError,
NoSenderKeyError,
} from './Errors';
import type { RawBodyRange } from '../types/BodyRange';
import { BodyRange } from '../types/BodyRange';
import type { StoryContextType } from '../types/Util';
import type {
@ -177,6 +178,7 @@ export type ContactWithHydratedAvatar = EmbeddedContactType & {
export type MessageOptionsType = {
attachments?: ReadonlyArray<AttachmentType> | null;
body?: string;
bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: Array<ContactWithHydratedAvatar>;
expireTimer?: DurationInSeconds;
flags?: number;
@ -194,12 +196,12 @@ export type MessageOptionsType = {
reaction?: ReactionType;
deletedForEveryoneTimestamp?: number;
timestamp: number;
mentions?: ReadonlyArray<BodyRange<BodyRange.Mention>>;
groupCallUpdate?: GroupCallUpdateType;
storyContext?: StoryContextType;
};
export type GroupSendOptionsType = {
attachments?: Array<AttachmentType>;
bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: Array<ContactWithHydratedAvatar>;
deletedForEveryoneTimestamp?: number;
expireTimer?: DurationInSeconds;
@ -207,7 +209,6 @@ export type GroupSendOptionsType = {
groupCallUpdate?: GroupCallUpdateType;
groupV1?: GroupV1InfoType;
groupV2?: GroupV2InfoType;
mentions?: ReadonlyArray<BodyRange<BodyRange.Mention>>;
messageText?: string;
preview?: ReadonlyArray<LinkPreviewType>;
profileKey?: Uint8Array;
@ -223,6 +224,8 @@ class Message {
body?: string;
bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: Array<ContactWithHydratedAvatar>;
expireTimer?: DurationInSeconds;
@ -258,8 +261,6 @@ class Message {
deletedForEveryoneTimestamp?: number;
mentions?: ReadonlyArray<BodyRange<BodyRange.Mention>>;
groupCallUpdate?: GroupCallUpdateType;
storyContext?: StoryContextType;
@ -267,6 +268,7 @@ class Message {
constructor(options: MessageOptionsType) {
this.attachments = options.attachments || [];
this.body = options.body;
this.bodyRanges = options.bodyRanges;
this.contact = options.contact;
this.expireTimer = options.expireTimer;
this.flags = options.flags;
@ -281,7 +283,6 @@ class Message {
this.reaction = options.reaction;
this.timestamp = options.timestamp;
this.deletedForEveryoneTimestamp = options.deletedForEveryoneTimestamp;
this.mentions = options.mentions;
this.groupCallUpdate = options.groupCallUpdate;
this.storyContext = options.storyContext;
@ -355,13 +356,21 @@ class Message {
if (this.body) {
proto.body = this.body;
const mentionCount = this.mentions ? this.mentions.length : 0;
const mentionCount = this.bodyRanges
? this.bodyRanges.filter(BodyRange.isMention).length
: 0;
const otherRangeCount = this.bodyRanges
? this.bodyRanges.length - mentionCount
: 0;
const placeholders = this.body.match(/\uFFFC/g);
const placeholderCount = placeholders ? placeholders.length : 0;
const storyInfo = this.storyContext
? `, story: ${this.storyContext.timestamp}`
: '';
log.info(
`Sending a message with ${mentionCount} mentions and ${placeholderCount} placeholders${
this.storyContext ? `, story: ${this.storyContext.timestamp}` : ''
}`
`Sending a message with ${mentionCount} mentions, ` +
`${placeholderCount} placeholders, ` +
`and ${otherRangeCount} other ranges${storyInfo}`
);
}
if (this.flags) {
@ -547,16 +556,28 @@ class Message {
targetSentTimestamp: Long.fromNumber(this.deletedForEveryoneTimestamp),
};
}
if (this.mentions) {
if (this.bodyRanges) {
proto.requiredProtocolVersion =
Proto.DataMessage.ProtocolVersion.MENTIONS;
proto.bodyRanges = this.mentions.map(
({ start, length, mentionUuid }) => ({
start,
length,
mentionUuid,
})
);
proto.bodyRanges = this.bodyRanges.map(bodyRange => {
const { start, length } = bodyRange;
if (BodyRange.isMention(bodyRange)) {
return {
start,
length,
mentionUuid: bodyRange.mentionUuid,
};
}
if (BodyRange.isFormatting(bodyRange)) {
return {
start,
length,
style: bodyRange.style,
};
}
throw missingCaseError(bodyRange);
});
}
if (this.groupCallUpdate) {
@ -1079,6 +1100,7 @@ export default class MessageSender {
): MessageOptionsType {
const {
attachments,
bodyRanges,
contact,
deletedForEveryoneTimestamp,
expireTimer,
@ -1086,7 +1108,6 @@ export default class MessageSender {
groupCallUpdate,
groupV1,
groupV2,
mentions,
messageText,
preview,
profileKey,
@ -1129,6 +1150,7 @@ export default class MessageSender {
return {
attachments,
bodyRanges,
body: messageText,
contact,
deletedForEveryoneTimestamp,
@ -1142,7 +1164,6 @@ export default class MessageSender {
type: Proto.GroupContext.Type.DELIVER,
}
: undefined,
mentions,
preview,
profileKey,
quote,
@ -1344,6 +1365,7 @@ export default class MessageSender {
// message to just one person.
async sendMessageToIdentifier({
attachments,
bodyRanges,
contact,
contentHint,
deletedForEveryoneTimestamp,
@ -1364,6 +1386,7 @@ export default class MessageSender {
includePniSignatureMessage,
}: Readonly<{
attachments: ReadonlyArray<AttachmentType> | undefined;
bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: Array<ContactWithHydratedAvatar>;
contentHint: number;
deletedForEveryoneTimestamp: number | undefined;
@ -1386,6 +1409,7 @@ export default class MessageSender {
return this.sendMessage({
messageOptions: {
attachments,
bodyRanges,
body: messageText,
contact,
deletedForEveryoneTimestamp,

View file

@ -89,6 +89,10 @@ export type DraftBodyRangeMention = BodyRange<
replacementText: string;
}
>;
export type DraftBodyRange =
| DraftBodyRangeMention
| BodyRange<BodyRange.Formatting>;
export type DraftBodyRanges = ReadonlyArray<DraftBodyRange>;
// Fully hydrated body range to be used in UI components.

View file

@ -54,13 +54,13 @@ export type StorageAccessType = {
'call-ringtone-notification': boolean;
'call-system-notification': boolean;
'hide-menu-bar': boolean;
'system-tray-setting': SystemTraySetting;
'incoming-call-notification': boolean;
'notification-draw-attention': boolean;
'notification-setting': NotificationSettingType;
'read-receipt-setting': boolean;
'sent-media-quality': SentMediaQualitySettingType;
'spell-check': boolean;
'system-tray-setting': SystemTraySetting;
'theme-setting': ThemeSettingType;
attachmentMigration_isComplete: boolean;
attachmentMigration_lastProcessedIndex: number;
@ -69,6 +69,7 @@ export type StorageAccessType = {
customColors: CustomColorsItemType;
device_name: string;
existingOnboardingStoryMessageIds: ReadonlyArray<string> | undefined;
formattingWarningShown: boolean;
hasRegisterSupportForUnauthenticatedDelivery: boolean;
hasSetMyStoriesPrivacy: boolean;
hasCompletedUsernameOnboarding: boolean;
@ -110,6 +111,7 @@ export type StorageAccessType = {
// Unlike `number_id` (which also includes device id) this field is only
// updated whenever we receive a new storage manifest
accountE164: string;
textFormatting: boolean;
typingIndicators: boolean;
sealedSenderIndicators: boolean;
storageFetchComplete: boolean;

View file

@ -41,6 +41,7 @@ import {
import { lookupConversationWithoutUuid } from './lookupConversationWithoutUuid';
import * as log from '../logging/log';
import { deleteAllMyStories } from './deleteAllMyStories';
import { isEnabled } from '../RemoteConfig';
type SentMediaQualityType = 'standard' | 'high';
type ThemeType = 'light' | 'dark' | 'system';
@ -66,6 +67,7 @@ export type IPCEventsValuesType = {
sentMediaQualitySetting: SentMediaQualityType;
spellCheck: boolean;
systemTraySetting: SystemTraySetting;
textFormatting: boolean;
themeSetting: ThemeType;
universalExpireTimer: DurationInSeconds;
zoomFactor: ZoomFactorType;
@ -104,6 +106,7 @@ export type IPCEventsCallbacksType = {
editCustomColor: (colorId: string, customColor: CustomColorType) => void;
getConversationsWithCustomColor: (x: string) => Array<ConversationType>;
installStickerPack: (packId: string, key: string) => Promise<void>;
isFormattingFlagEnabled: () => boolean;
isPhoneNumberSharingEnabled: () => boolean;
isPrimary: () => boolean;
removeCustomColor: (x: string) => void;
@ -397,6 +400,8 @@ export function createIPCEvents(
getSpellCheck: () => window.storage.get('spell-check', true),
setSpellCheck: value => window.storage.put('spell-check', value),
getTextFormatting: () => window.storage.get('textFormatting', true),
setTextFormatting: value => window.storage.put('textFormatting', value),
getAlwaysRelayCalls: () => window.storage.get('always-relay-calls'),
setAlwaysRelayCalls: value =>
@ -407,6 +412,7 @@ export function createIPCEvents(
return window.IPC.setAutoLaunch(value);
},
isFormattingFlagEnabled: () => isEnabled('desktop.textFormatting'),
isPhoneNumberSharingEnabled: () => isPhoneNumberSharingEnabled(),
isPrimary: () => window.textsecure.storage.user.getDeviceId() === 1,
shouldShowStoriesSettings: () => getStoriesAvailable(),

View file

@ -0,0 +1,18 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { DraftBodyRanges } from '../types/BodyRange';
import { BodyRange } from '../types/BodyRange';
import { explodePromise } from './explodePromise';
export async function maybeBlockSendForFormattingModal(
bodyRanges: DraftBodyRanges
): Promise<boolean> {
if (!bodyRanges.some(BodyRange.isFormatting)) {
return true;
}
const explodedPromise = explodePromise<boolean>();
window.reduxActions.globalModals.showFormattingWarningModal(explodedPromise);
return explodedPromise.promise;
}

View file

@ -16,18 +16,22 @@ import { isNotNil } from './isNotNil';
import { resetLinkPreview } from '../services/LinkPreview';
import { getRecipientsByConversation } from './getRecipientsByConversation';
import type { ContactWithHydratedAvatar } from '../textsecure/SendMessage';
import type { DraftBodyRangeMention } from '../types/BodyRange';
import type {
DraftBodyRanges,
HydratedBodyRangesType,
} from '../types/BodyRange';
import type { StickerWithHydratedData } from '../types/Stickers';
import { drop } from './drop';
import { toLogFormat } from '../types/errors';
export type MessageForwardDraft = Readonly<{
originalMessageId: string;
attachments?: ReadonlyArray<AttachmentType>;
previews: ReadonlyArray<LinkPreviewType>;
isSticker: boolean;
bodyRanges?: HydratedBodyRangesType;
hasContact: boolean;
isSticker: boolean;
messageBody?: string;
originalMessageId: string;
previews: ReadonlyArray<LinkPreviewType>;
}>;
export type ForwardMessageData = Readonly<{
@ -148,9 +152,9 @@ export async function maybeForwardMessages(
// send along with the message and do the send to each conversation.
const preparedMessages = await Promise.all(
messages.map(async message => {
const { originalMessage, draft } = message;
const { draft, originalMessage } = message;
const { sticker, contact } = originalMessage;
const { messageBody, previews, attachments } = draft;
const { attachments, bodyRanges, messageBody, previews } = draft;
const idForLogging = getMessageIdForLogging(originalMessage);
log.info(`maybeForwardMessage: Forwarding ${idForLogging}`);
@ -167,8 +171,8 @@ export async function maybeForwardMessages(
let enqueuedMessage: {
attachments: Array<AttachmentType>;
body: string | undefined;
bodyRanges?: DraftBodyRanges;
contact?: Array<ContactWithHydratedAvatar>;
mentions?: Array<DraftBodyRangeMention>;
preview?: Array<LinkPreviewType>;
quote?: QuotedMessageType;
sticker?: StickerWithHydratedData;
@ -215,6 +219,7 @@ export async function maybeForwardMessages(
enqueuedMessage = {
body: messageBody || undefined,
bodyRanges,
attachments: attachmentsToSend,
preview,
};

View file

@ -32,6 +32,7 @@ installSetting('typingIndicatorSetting', {
});
installCallback('deleteAllMyStories');
installCallback('isFormattingFlagEnabled');
installCallback('isPhoneNumberSharingEnabled');
installCallback('isPrimary');
installCallback('shouldShowStoriesSettings');
@ -54,6 +55,7 @@ installSetting('notificationSetting');
installSetting('spellCheck');
installSetting('systemTraySetting');
installSetting('sentMediaQualitySetting');
installSetting('textFormatting');
installSetting('themeSetting');
installSetting('universalExpireTimer');
installSetting('zoomFactor');

View file

@ -42,8 +42,9 @@ const settingNotificationDrawAttention = createSetting(
);
const settingNotificationSetting = createSetting('notificationSetting');
const settingRelayCalls = createSetting('alwaysRelayCalls');
const settingSpellCheck = createSetting('spellCheck');
const settingSentMediaQuality = createSetting('sentMediaQualitySetting');
const settingSpellCheck = createSetting('spellCheck');
const settingTextFormatting = createSetting('textFormatting');
const settingTheme = createSetting('themeSetting');
const settingSystemTraySetting = createSetting('systemTraySetting');
@ -78,6 +79,7 @@ const settingUniversalExpireTimer = createSetting('universalExpireTimer');
// Callbacks
const ipcGetAvailableIODevices = createCallback('getAvailableIODevices');
const ipcGetCustomColors = createCallback('getCustomColors');
const ipcIsFormattingFlagEnabled = createCallback('isFormattingFlagEnabled');
const ipcIsSyncNotSupported = createCallback('isPrimary');
const ipcMakeSyncRequest = createCallback('syncRequest');
const ipcPNP = createCallback('isPhoneNumberSharingEnabled');
@ -148,7 +150,9 @@ const renderPreferences = async () => {
hasRelayCalls,
hasSpellCheck,
hasStoriesDisabled,
hasTextFormatting,
hasTypingIndicators,
isFormattingFlagEnabled,
isPhoneNumberSharingSupported,
lastSyncTime,
notificationContent,
@ -187,6 +191,7 @@ const renderPreferences = async () => {
hasRelayCalls: settingRelayCalls.getValue(),
hasSpellCheck: settingSpellCheck.getValue(),
hasStoriesDisabled: settingHasStoriesDisabled.getValue(),
hasTextFormatting: settingTextFormatting.getValue(),
hasTypingIndicators: settingTypingIndicators.getValue(),
isPhoneNumberSharingSupported: ipcPNP(),
lastSyncTime: settingLastSyncTime.getValue(),
@ -206,6 +211,7 @@ const renderPreferences = async () => {
availableIODevices: ipcGetAvailableIODevices(),
customColors: ipcGetCustomColors(),
defaultConversationColor: ipcGetDefaultConversationColor(),
isFormattingFlagEnabled: ipcIsFormattingFlagEnabled(),
isSyncNotSupported: ipcIsSyncNotSupported(),
shouldShowStoriesSettings: ipcShouldShowStoriesSettings(),
});
@ -248,6 +254,7 @@ const renderPreferences = async () => {
hasRelayCalls,
hasSpellCheck,
hasStoriesDisabled,
hasTextFormatting,
hasTypingIndicators,
lastSyncTime,
notificationContent,
@ -294,6 +301,9 @@ const renderPreferences = async () => {
SignalContext.getVersion()
),
// Feature flags
isFormattingFlagEnabled,
// Change handlers
onAudioNotificationsChange: reRender(settingAudioNotification.setValue),
onAutoDownloadUpdateChange: reRender(settingAutoDownloadUpdate.setValue),
@ -353,6 +363,7 @@ const renderPreferences = async () => {
onSelectedSpeakerChange: reRender(settingAudioOutput.setValue),
onSentMediaQualityChange: reRender(settingSentMediaQuality.setValue),
onSpellCheckChange: reRender(settingSpellCheck.setValue),
onTextFormattingChange: reRender(settingTextFormatting.setValue),
onThemeChange: reRender(settingTheme.setValue),
onUniversalExpireTimerChange: (newValue: number): Promise<void> => {
return onUniversalExpireTimerChange(