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

@ -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(