Support for sending formatting messages
This commit is contained in:
parent
42e13aedcd
commit
9bfbee464b
65 changed files with 1762 additions and 371 deletions
|
@ -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'
|
||||
|
|
|
@ -26,6 +26,8 @@ export default {
|
|||
<CompositionTextArea
|
||||
{...props}
|
||||
i18n={i18n}
|
||||
isFormattingEnabled={false}
|
||||
isFormattingSpoilersEnabled={false}
|
||||
onPickEmoji={action('onPickEmoji')}
|
||||
onChange={action('onChange')}
|
||||
onTextTooLong={action('onTextTooLong')}
|
||||
|
|
|
@ -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 })} />
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 })} />
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
38
ts/components/FormattingWarningModal.tsx
Normal file
38
ts/components/FormattingWarningModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -59,6 +59,8 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
|
|||
<CompositionTextArea
|
||||
{...props}
|
||||
i18n={i18n}
|
||||
isFormattingSpoilersEnabled
|
||||
isFormattingEnabled
|
||||
onPickEmoji={action('onPickEmoji')}
|
||||
skinTone={0}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -64,6 +64,8 @@ export function WithCaption(): JSX.Element {
|
|||
<CompositionTextArea
|
||||
{...props}
|
||||
i18n={i18n}
|
||||
isFormattingSpoilersEnabled
|
||||
isFormattingEnabled
|
||||
onPickEmoji={action('onPickEmoji')}
|
||||
onSetSkinTone={action('onSetSkinTone')}
|
||||
onTextTooLong={action('onTextTooLong')}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
4
ts/model-types.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
323
ts/quill/formatting/menu.tsx
Normal file
323
ts/quill/formatting/menu.tsx
Normal 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);
|
||||
}
|
||||
}
|
30
ts/quill/formatting/monospaceBlot.ts
Normal file
30
ts/quill/formatting/monospaceBlot.ts
Normal 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);
|
30
ts/quill/formatting/spoilerBlot.ts
Normal file
30
ts/quill/formatting/spoilerBlot.ts
Normal 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
15
ts/quill/types.d.ts
vendored
|
@ -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;
|
||||
|
|
159
ts/quill/util.ts
159
ts/quill/util.ts
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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'
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
|
|
@ -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
|
||||
) => {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 ?? [],
|
||||
};
|
||||
}) ?? []
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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');
|
||||
{
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
4
ts/types/Storage.d.ts
vendored
4
ts/types/Storage.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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(),
|
||||
|
|
18
ts/util/maybeBlockSendForFormattingModal.ts
Normal file
18
ts/util/maybeBlockSendForFormattingModal.ts
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue