();
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({
void;
+ onCancel: () => void;
+};
+
+export function FormattingWarningModal({
+ i18n,
+ onSendAnyway,
+ onCancel,
+}: PropsType): JSX.Element | null {
+ return (
+
+ {i18n('icu:SendFormatting--dialog--body')}
+
+ );
+}
diff --git a/ts/components/ForwardMessagesModal.stories.tsx b/ts/components/ForwardMessagesModal.stories.tsx
index 24dc5427a05..52f2613864c 100644
--- a/ts/components/ForwardMessagesModal.stories.tsx
+++ b/ts/components/ForwardMessagesModal.stories.tsx
@@ -59,6 +59,8 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({
;
@@ -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({
{
- 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}
{
- onChange(messageText, caretLocation);
- }}
+ onChange={onChange}
onSubmit={onSubmit}
theme={theme}
/>
diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx
index 27d568337ce..43555627ac9 100644
--- a/ts/components/GlobalModalContainer.tsx
+++ b/ts/components/GlobalModalContainer.tsx
@@ -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 | 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 (
+ {
+ showFormattingWarningModal(undefined);
+ resolve(true);
+ }}
+ onCancel={() => {
+ showFormattingWarningModal(undefined);
+ resolve(false);
+ }}
+ />
+ );
+ }
+
if (forwardMessagesProps) {
return renderForwardMessagesModal();
}
diff --git a/ts/components/MediaEditor.stories.tsx b/ts/components/MediaEditor.stories.tsx
index 45cde45236d..dd379c48d27 100644
--- a/ts/components/MediaEditor.stories.tsx
+++ b/ts/components/MediaEditor.stories.tsx
@@ -64,6 +64,8 @@ export function WithCaption(): JSX.Element {
({
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,
+};
diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx
index 7b8166d6286..5d49482d396 100644
--- a/ts/components/Preferences.tsx
+++ b/ts/components/Preferences.tsx
@@ -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;
onSentMediaQualityChange: SelectChangeHandlerType;
onSpellCheckChange: CheckboxChangeHandlerType;
+ onTextFormattingChange: CheckboxChangeHandlerType;
onThemeChange: SelectChangeHandlerType;
onUniversalExpireTimerChange: SelectChangeHandlerType;
onWhoCanSeeMeChange: SelectChangeHandlerType;
@@ -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 && (
+
+ )}
= {}): 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 ;
}
+
+export function NoFormatting(): JSX.Element {
+ const props = createProps({ isFormattingFlagEnabled: false });
+ return ;
+}
+
+export function NoSpoilerFormatting(): JSX.Element {
+ const props = createProps({ isFormattingSpoilersFlagEnabled: false });
+ return ;
+}
diff --git a/ts/components/ShortcutGuide.tsx b/ts/components/ShortcutGuide.tsx
index 9e621200e8a..9b0771699e8 100644
--- a/ts/components/ShortcutGuide.tsx
+++ b/ts/components/ShortcutGuide.tsx
@@ -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 {
];
}
-function getComposerShortcuts(i18n: LocalizerType): Array {
- return [
+function getComposerShortcuts(
+ i18n: LocalizerType,
+ isFormattingFlagEnabled: boolean,
+ isFormattingSpoilersFlagEnabled: boolean
+): Array {
+ const shortcuts: Array = [
{
id: 'Keyboard--add-newline',
description: i18n('icu:Keyboard--add-newline'),
@@ -216,7 +225,7 @@ function getComposerShortcuts(i18n: LocalizerType): Array {
{
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 {
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 {
@@ -287,7 +329,14 @@ function getCallingShortcuts(i18n: LocalizerType): Array {
}
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')}
- {getComposerShortcuts(i18n).map((shortcut, index) =>
+ {getComposerShortcuts(
+ i18n,
+ isFormattingFlagEnabled,
+ isFormattingSpoilersFlagEnabled
+ ).map((shortcut, index) =>
renderShortcut(shortcut, index, isMacOS, i18n)
)}
diff --git a/ts/components/ShortcutGuideModal.tsx b/ts/components/ShortcutGuideModal.tsx
index aee6f89fb77..28904289a5c 100644
--- a/ts/components/ShortcutGuideModal.tsx
+++ b/ts/components/ShortcutGuideModal.tsx
@@ -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(null);
React.useEffect(() => {
@@ -37,6 +45,8 @@ export const ShortcutGuideModal = React.memo(function ShortcutGuideModalInner(
diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx
index eabf563cc9c..c295a5d75db 100644
--- a/ts/components/StoryViewer.tsx
+++ b/ts/components/StoryViewer.tsx
@@ -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,
+ 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}
diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx
index 33e3ee76f41..4879eb5d5f3 100644
--- a/ts/components/StoryViewsNRepliesModal.tsx
+++ b/ts/components/StoryViewsNRepliesModal.tsx
@@ -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,
+ 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);
diff --git a/ts/components/conversation/MessageTextRenderer.tsx b/ts/components/conversation/MessageTextRenderer.tsx
index dc46f8eae9f..96d2c2c82e0 100644
--- a/ts/components/conversation/MessageTextRenderer.tsx
+++ b/ts/components/conversation/MessageTextRenderer.tsx
@@ -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 = {content};
+ }
+ if (node.isItalic) {
+ content = {content};
+ }
+ if (node.isStrikethrough) {
+ content = {content};
+ }
+
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,
diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts
index 0c80cbf7f63..aaa74f32103 100644
--- a/ts/jobs/helpers/sendNormalMessage.ts
+++ b/ts/jobs/helpers/sendNormalMessage.ts
@@ -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;
deletedForEveryoneTimestamp: undefined | number;
expireTimer: undefined | DurationInSeconds;
- mentions: undefined | ReadonlyArray>;
+ bodyRanges: undefined | ReadonlyArray;
messageTimestamp: number;
preview: Array;
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,
diff --git a/ts/main/settingsChannel.ts b/ts/main/settingsChannel.ts
index 24aad04dc6f..9135c4fec4e 100644
--- a/ts/main/settingsChannel.ts
+++ b/ts/main/settingsChannel.ts
@@ -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');
diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts
index 92721770a36..5712a468475 100644
--- a/ts/model-types.d.ts
+++ b/ts/model-types.d.ts
@@ -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;
- draftBodyRanges?: ReadonlyArray;
+ draftBodyRanges?: DraftBodyRanges;
draftTimestamp?: number | null;
hideStory?: boolean;
inbox_position?: number;
diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts
index fe8188bed8c..cfd16bee70f 100644
--- a/ts/models/conversations.ts
+++ b/ts/models/conversations.ts
@@ -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 | 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;
body: string | undefined;
contact?: Array;
- mentions?: ReadonlyArray>;
+ bodyRanges?: DraftBodyRanges;
preview?: Array;
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,
diff --git a/ts/quill/formatting/menu.tsx b/ts/quill/formatting/menu.tsx
new file mode 100644
index 00000000000..16054b1fc57
--- /dev/null
+++ b/ts/quill/formatting/menu.tsx
@@ -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): 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(
+
+ {({ ref, style }) => (
+
+
+
+
+
+ {isSpoilersEnabled ? (
+
+ ) : null}
+
+ )}
+ ,
+ 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);
+ }
+}
diff --git a/ts/quill/formatting/monospaceBlot.ts b/ts/quill/formatting/monospaceBlot.ts
new file mode 100644
index 00000000000..6bba7bf4ff5
--- /dev/null
+++ b/ts/quill/formatting/monospaceBlot.ts
@@ -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;
+
+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);
diff --git a/ts/quill/formatting/spoilerBlot.ts b/ts/quill/formatting/spoilerBlot.ts
new file mode 100644
index 00000000000..7d9e5554b65
--- /dev/null
+++ b/ts/quill/formatting/spoilerBlot.ts
@@ -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;
+
+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);
diff --git a/ts/quill/types.d.ts b/ts/quill/types.d.ts
index fc969fb2d45..1396253ca7f 100644
--- a/ts/quill/types.d.ts
+++ b/ts/quill/types.d.ts
@@ -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;
diff --git a/ts/quill/util.ts b/ts/quill/util.ts
index c7af31142e5..8c57a904da5 100644
--- a/ts/quill/util.ts
+++ b/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 = Op & { insert: { [V in K]: T } };
@@ -60,13 +72,102 @@ export const getTextFromOps = (ops: Array): string =>
}, '')
.trim();
-export const getTextAndMentionsFromOps = (
+const { BOLD, ITALIC, MONOSPACE, SPOILER, STRIKETHROUGH, NONE } =
+ BodyRange.Style;
+
+function extractFormatRange({
+ bodyRanges,
+ index,
+ previousData,
+ hasStyle,
+ style,
+}: {
+ bodyRanges: Array;
+ 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,
+ formats: Record,
+ index: number,
+ op?: Op
+): Record {
+ 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
-): [string, ReadonlyArray] => {
- const mentions: Array = [];
+): { text: string; bodyRanges: DraftBodyRanges } => {
+ const bodyRanges: Array = [];
+ let formats: Record = {
+ [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
+): ReadonlyArray => {
+ let ops: Array = [];
+
+ 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,
- bodyRanges: ReadonlyArray
+ bodyRanges: DraftBodyRanges
): Array => {
const ops = [...incomingOps];
- const sortableBodyRanges: Array = bodyRanges.slice();
+ const sortableBodyRanges: Array = 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): Array => {
+export const insertEmojiOps = (incomingOps: ReadonlyArray): Array => {
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): Array => {
// 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);
}
diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts
index 065dc3a4f32..afa5cf93416 100644
--- a/ts/state/ducks/composer.ts
+++ b/ts/state/ducks/composer.ts
@@ -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;
- draftBodyRanges?: ReadonlyArray;
+ 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;
+ 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
+ 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,
});
diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts
index 97d11a0fe11..cff06d29956 100644
--- a/ts/state/ducks/conversations.ts
+++ b/ts/state/ducks/conversations.ts
@@ -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;
+ draftBodyRanges?: DraftBodyRanges;
// Summary for the left pane
draftPreview?: DraftPreviewType;
diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts
index e8181a8dc0e..92cbbc75554 100644
--- a/ts/state/ducks/globalModals.ts
+++ b/ts/state/ducks/globalModals.ts
@@ -59,6 +59,9 @@ export type SafetyNumberChangedBlockingDataType = ReadonlyDeep<{
promiseUuid: UUIDStringType;
source?: SafetyNumberChangeSource;
}>;
+export type FormattingWarningDataType = ReadonlyDeep<{
+ explodedPromise: ExplodePromiseResultType;
+}>;
export type AuthorizeArtCreatorDataType =
ReadonlyDeep;
@@ -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 | 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 | undefined
+): ShowFormattingWarningModalActionType {
+ return { type: SHOW_FORMATTING_WARNING_MODAL, payload: { explodedPromise } };
+}
+
function showGV2MigrationDialog(
conversationId: string
): ThunkAction {
@@ -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,
diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts
index 441ea4d7e95..10a87297d1c 100644
--- a/ts/state/ducks/stories.ts
+++ b/ts/state/ducks/stories.ts
@@ -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,
+ bodyRanges: DraftBodyRanges,
timestamp: number,
story: StoryViewType
): ThunkAction {
@@ -575,7 +575,7 @@ function replyToStory(
{
body: messageBody,
attachments: [],
- mentions,
+ bodyRanges,
},
{
storyId: story.messageId,
diff --git a/ts/state/selectors/composer.ts b/ts/state/selectors/composer.ts
index f7a62eae60a..09539041d65 100644
--- a/ts/state/selectors/composer.ts
+++ b/ts/state/selectors/composer.ts
@@ -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'
+ )
+ );
+ }
+);
diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts
index 7f95923917a..3ff3e553721 100644
--- a/ts/state/selectors/items.ts
+++ b/ts/state/selectors/items.ts
@@ -48,7 +48,7 @@ export const getUniversalExpireTimer = createSelector(
DurationInSeconds.fromSeconds(state[UNIVERSAL_EXPIRE_TIMER_ITEM] || 0)
);
-const isRemoteConfigFlagEnabled = (
+export const isRemoteConfigFlagEnabled = (
config: Readonly,
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)
+);
diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx
index 797e2206751..889e861243b 100644
--- a/ts/state/smart/CompositionArea.tsx
+++ b/ts/state/smart/CompositionArea.tsx
@@ -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
) => {
diff --git a/ts/state/smart/CompositionTextArea.tsx b/ts/state/smart/CompositionTextArea.tsx
index 66e29ba99e9..9bac7063d44 100644
--- a/ts/state/smart/CompositionTextArea.tsx
+++ b/ts/state/smart/CompositionTextArea.tsx
@@ -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 (
(
- 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 ?? [],
};
}) ?? []
diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx
index b3acea59847..acaa7bf123b 100644
--- a/ts/state/smart/GlobalModalContainer.tsx
+++ b/ts/state/smart/GlobalModalContainer.tsx
@@ -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}
diff --git a/ts/state/smart/ShortcutGuideModal.tsx b/ts/state/smart/ShortcutGuideModal.tsx
index 2d508441dae..d78ecd36894 100644
--- a/ts/state/smart/ShortcutGuideModal.tsx
+++ b/ts/state/smart/ShortcutGuideModal.tsx
@@ -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),
};
diff --git a/ts/state/smart/StoryViewer.tsx b/ts/state/smart/StoryViewer.tsx
index c5673cc5e6c..2aa56d607b5 100644
--- a/ts/state/smart/StoryViewer.tsx
+++ b/ts/state/smart/StoryViewer.tsx
@@ -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}
diff --git a/ts/test-mock/benchmarks/group_send_bench.ts b/ts/test-mock/benchmarks/group_send_bench.ts
index fb1abeb6a47..157cd243205 100644
--- a/ts/test-mock/benchmarks/group_send_bench.ts
+++ b/ts/test-mock/benchmarks/group_send_bench.ts
@@ -124,11 +124,7 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => {
const deltaList = new Array();
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}`);
diff --git a/ts/test-mock/benchmarks/send_bench.ts b/ts/test-mock/benchmarks/send_bench.ts
index 02ba84fe3ee..822558a0e55 100644
--- a/ts/test-mock/benchmarks/send_bench.ts
+++ b/ts/test-mock/benchmarks/send_bench.ts
@@ -78,10 +78,7 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise => {
const deltaList = new Array();
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}`);
diff --git a/ts/test-mock/playwright.ts b/ts/test-mock/playwright.ts
index 9778c5a3642..769d0a8238d 100644
--- a/ts/test-mock/playwright.ts
+++ b/ts/test-mock/playwright.ts
@@ -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 {
+ 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 {
return this.waitForEvent('provisioning-url');
}
diff --git a/ts/test-mock/pnp/merge_test.ts b/ts/test-mock/pnp/merge_test.ts
index 672ef042895..9464e009893 100644
--- a/ts/test-mock/pnp/merge_test.ts
+++ b/ts/test-mock/pnp/merge_test.ts
@@ -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');
diff --git a/ts/test-mock/pnp/pni_change_test.ts b/ts/test-mock/pnp/pni_change_test.ts
index 0851324510c..aca4c21d9b2 100644
--- a/ts/test-mock/pnp/pni_change_test.ts
+++ b/ts/test-mock/pnp/pni_change_test.ts
@@ -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');
diff --git a/ts/test-mock/pnp/pni_signature_test.ts b/ts/test-mock/pnp/pni_signature_test.ts
index bc9f9e5965e..1e4d461d117 100644
--- a/ts/test-mock/pnp/pni_signature_test.ts
+++ b/ts/test-mock/pnp/pni_signature_test.ts
@@ -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');
{
diff --git a/ts/test-mock/pnp/username_test.ts b/ts/test-mock/pnp/username_test.ts
index 9c4346f514f..b58145ef038 100644
--- a/ts/test-mock/pnp/username_test.ts
+++ b/ts/test-mock/pnp/username_test.ts
@@ -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');
diff --git a/ts/test-node/quill/util_test.ts b/ts/test-node/quill/util_test.ts
index 17326a8214f..6cf3acd1252 100644
--- a/ts/test-node/quill/util_test.ts
+++ b/ts/test-node/quill/util_test.ts
@@ -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', () => {
diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts
index db686b48a10..648e54cb8c1 100644
--- a/ts/textsecure/SendMessage.ts
+++ b/ts/textsecure/SendMessage.ts
@@ -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 | null;
body?: string;
+ bodyRanges?: ReadonlyArray;
contact?: Array;
expireTimer?: DurationInSeconds;
flags?: number;
@@ -194,12 +196,12 @@ export type MessageOptionsType = {
reaction?: ReactionType;
deletedForEveryoneTimestamp?: number;
timestamp: number;
- mentions?: ReadonlyArray>;
groupCallUpdate?: GroupCallUpdateType;
storyContext?: StoryContextType;
};
export type GroupSendOptionsType = {
attachments?: Array;
+ bodyRanges?: ReadonlyArray;
contact?: Array;
deletedForEveryoneTimestamp?: number;
expireTimer?: DurationInSeconds;
@@ -207,7 +209,6 @@ export type GroupSendOptionsType = {
groupCallUpdate?: GroupCallUpdateType;
groupV1?: GroupV1InfoType;
groupV2?: GroupV2InfoType;
- mentions?: ReadonlyArray>;
messageText?: string;
preview?: ReadonlyArray;
profileKey?: Uint8Array;
@@ -223,6 +224,8 @@ class Message {
body?: string;
+ bodyRanges?: ReadonlyArray;
+
contact?: Array;
expireTimer?: DurationInSeconds;
@@ -258,8 +261,6 @@ class Message {
deletedForEveryoneTimestamp?: number;
- mentions?: ReadonlyArray>;
-
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 | undefined;
+ bodyRanges?: ReadonlyArray;
contact?: Array;
contentHint: number;
deletedForEveryoneTimestamp: number | undefined;
@@ -1386,6 +1409,7 @@ export default class MessageSender {
return this.sendMessage({
messageOptions: {
attachments,
+ bodyRanges,
body: messageText,
contact,
deletedForEveryoneTimestamp,
diff --git a/ts/types/BodyRange.ts b/ts/types/BodyRange.ts
index 386409741a8..8592e44d45d 100644
--- a/ts/types/BodyRange.ts
+++ b/ts/types/BodyRange.ts
@@ -89,6 +89,10 @@ export type DraftBodyRangeMention = BodyRange<
replacementText: string;
}
>;
+export type DraftBodyRange =
+ | DraftBodyRangeMention
+ | BodyRange;
+export type DraftBodyRanges = ReadonlyArray;
// Fully hydrated body range to be used in UI components.
diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts
index 5c6a2c4db6d..642082dafcb 100644
--- a/ts/types/Storage.d.ts
+++ b/ts/types/Storage.d.ts
@@ -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 | 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;
diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts
index 30fe8137059..a3ef1063bde 100644
--- a/ts/util/createIPCEvents.ts
+++ b/ts/util/createIPCEvents.ts
@@ -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;
installStickerPack: (packId: string, key: string) => Promise;
+ 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(),
diff --git a/ts/util/maybeBlockSendForFormattingModal.ts b/ts/util/maybeBlockSendForFormattingModal.ts
new file mode 100644
index 00000000000..31c902c52b1
--- /dev/null
+++ b/ts/util/maybeBlockSendForFormattingModal.ts
@@ -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 {
+ if (!bodyRanges.some(BodyRange.isFormatting)) {
+ return true;
+ }
+
+ const explodedPromise = explodePromise();
+ window.reduxActions.globalModals.showFormattingWarningModal(explodedPromise);
+ return explodedPromise.promise;
+}
diff --git a/ts/util/maybeForwardMessages.ts b/ts/util/maybeForwardMessages.ts
index 55b72563d7b..0f517154bf0 100644
--- a/ts/util/maybeForwardMessages.ts
+++ b/ts/util/maybeForwardMessages.ts
@@ -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;
- previews: ReadonlyArray;
- isSticker: boolean;
+ bodyRanges?: HydratedBodyRangesType;
hasContact: boolean;
+ isSticker: boolean;
messageBody?: string;
+ originalMessageId: string;
+ previews: ReadonlyArray;
}>;
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;
body: string | undefined;
+ bodyRanges?: DraftBodyRanges;
contact?: Array;
- mentions?: Array;
preview?: Array;
quote?: QuotedMessageType;
sticker?: StickerWithHydratedData;
@@ -215,6 +219,7 @@ export async function maybeForwardMessages(
enqueuedMessage = {
body: messageBody || undefined,
+ bodyRanges,
attachments: attachmentsToSend,
preview,
};
diff --git a/ts/windows/preload.ts b/ts/windows/preload.ts
index 18234beeca0..38cba13c21a 100644
--- a/ts/windows/preload.ts
+++ b/ts/windows/preload.ts
@@ -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');
diff --git a/ts/windows/settings/preload.ts b/ts/windows/settings/preload.ts
index 90a4b2b878d..b5ea315d3c6 100644
--- a/ts/windows/settings/preload.ts
+++ b/ts/windows/settings/preload.ts
@@ -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 => {
return onUniversalExpireTimerChange(