diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 6883e2bbbcdf..4328c4e1bed8 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -2349,6 +2349,10 @@ "messageformat": "Select", "description": "Shown on the drop-down menu for an individual message, opens the conversation in select mode with the current message selected" }, + "icu:MessageTextRenderer--spoiler--label": { + "messageformat": "Spoiler", + "description": "Used as a label for screenreaders on 'spoiler' text, which is hidden by default" + }, "retrySend": { "message": "Retry Send", "description": "(deleted 03/29/2023) Shown on the drop-down menu for an individual message, but only if it is an outgoing message that failed to send" diff --git a/package.json b/package.json index 74e9701d0a92..9998d58abfb9 100644 --- a/package.json +++ b/package.json @@ -290,7 +290,7 @@ "ts-loader": "4.1.0", "ts-node": "8.3.0", "typed-scss-modules": "4.1.1", - "typescript": "5.0.2", + "typescript": "4.9.5", "webpack": "5.76.0", "webpack-cli": "4.9.2", "webpack-dev-server": "4.11.1" diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 5e5dcf1b979a..efed4a78a290 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -307,12 +307,22 @@ message DataMessage { } message BodyRange { - optional uint32 start = 1; + enum Style { + NONE = 0; + BOLD = 1; + ITALIC = 2; + SPOILER = 3; + STRIKETHROUGH = 4; + MONOSPACE = 5; + } + + optional uint32 start = 1; optional uint32 length = 2; - - // oneof associatedValue { - optional string mentionUuid = 3; - //} + + oneof associatedValue { + string mentionUuid = 3; + Style style = 4; + } } message GroupCallUpdate { @@ -399,6 +409,7 @@ message StoryMessage { TextAttachment textAttachment = 4; } optional bool allowsReplies = 5; + repeated BodyRange bodyRanges = 6; } message TextAttachment { diff --git a/stylesheets/_emoji.scss b/stylesheets/_emoji.scss index d126ee5da1d8..5ba924741bb4 100644 --- a/stylesheets/_emoji.scss +++ b/stylesheets/_emoji.scss @@ -73,6 +73,10 @@ img.emoji.max { height: 56px; } +img.emoji--invisible { + visibility: hidden; +} + // we need these, or we'll make conversation items too big in the left-nav .conversations img.emoji.small { width: 1em; diff --git a/stylesheets/components/MessageBody.scss b/stylesheets/components/MessageBody.scss index 891b045811e3..4b7f91117e8f 100644 --- a/stylesheets/components/MessageBody.scss +++ b/stylesheets/components/MessageBody.scss @@ -53,6 +53,10 @@ &--outgoing { background-color: $color-black-alpha-40; } + + &--invisible { + visibility: hidden; + } } &__author { diff --git a/stylesheets/components/MessageTextRenderer.scss b/stylesheets/components/MessageTextRenderer.scss new file mode 100644 index 000000000000..3412d93a604a --- /dev/null +++ b/stylesheets/components/MessageTextRenderer.scss @@ -0,0 +1,112 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.MessageTextRenderer { + &__formatting { + &--bold { + font-weight: 600; + } + &--italic { + font-style: italic; + } + &--monospace { + font-family: monospace; + } + &--strikethrough { + text-decoration: line-through; + bdi { + text-decoration: line-through; + } + } + &--none { + text-decoration: none; + font-weight: 400; + bdi { + text-decoration: none; + } + } + + // Note: only used in the left pane for search results, not in message bubbles + &--keywordHighlight { + font-weight: 600; + + // To differentiate it from bold formatting, we increase the color contrast + @include light-theme { + color: $color-black; // vs color-gray-60 normally + } + @include dark-theme { + color: $color-white; // vs color-gray-25 normally + } + } + + // Note: Spoiler must be last to override any other formatting applied to the section + &--spoiler { + user-select: none; + cursor: pointer; + + // make child text invisible + color: transparent; + + // fix outline + outline: none; + + @include keyboard-mode { + &:focus { + box-shadow: 0 0 0px 1px $color-ultramarine; + } + } + } + + &--spoiler--noninteractive { + cursor: default; + box-shadow: none; + } + + // The simplest; always in dark mode + &--spoiler-StoryViewer { + background-color: $color-white; + } + + // The left pane + &--spoiler-ConversationList, + &--spoiler-SearchResult { + @include light-theme { + background-color: $color-gray-60; + } + @include dark-theme { + background-color: $color-gray-25; + } + } + + // The timeline + &--spoiler-Quote { + @include light-theme { + background-color: $color-gray-90; + } + @include dark-theme { + background-color: $color-gray-05; + } + } + + &--spoiler-Timeline--incoming { + @include light-theme { + background-color: $color-gray-90; + } + @include dark-theme { + background-color: $color-gray-05; + } + } + &--spoiler-Timeline--outgoing { + @include light-theme { + background-color: rgba(255, 255, 255, 0.9); + } + @include dark-theme { + background-color: rgba(255, 255, 255, 0.9); + } + } + + &--invisible { + visibility: hidden; + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 1279dc7b4f42..4991b4954f44 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -98,6 +98,7 @@ @import './components/MediaQualitySelector.scss'; @import './components/MessageAudio.scss'; @import './components/MessageBody.scss'; +@import './components/MessageTextRenderer.scss'; @import './components/MessageDetail.scss'; @import './components/MiniPlayer.scss'; @import './components/Modal.scss'; diff --git a/ts/components/AnnouncementsOnlyGroupBanner.tsx b/ts/components/AnnouncementsOnlyGroupBanner.tsx index 04902decb360..2103791253c3 100644 --- a/ts/components/AnnouncementsOnlyGroupBanner.tsx +++ b/ts/components/AnnouncementsOnlyGroupBanner.tsx @@ -38,7 +38,7 @@ export function AnnouncementsOnlyGroupBanner({ {groupAdmins.map(admin => ( ; - mentions?: DraftBodyRangesType; + mentions?: ReadonlyArray; message?: string; timestamp?: number; voiceNoteAttachment?: InMemoryAttachmentDraftType; @@ -233,8 +230,8 @@ export function CompositionArea({ shouldSendHighQualityAttachments, // CompositionInput clearQuotedMessage, - draftText, draftBodyRanges, + draftText, getPreferredBadge, getQuotedMessage, onEditorStateChange, @@ -311,7 +308,11 @@ export function CompositionArea({ }, [inputApiRef, setLarge]); const handleSubmit = useCallback( - (message: string, mentions: DraftBodyRangesType, timestamp: number) => { + ( + message: string, + mentions: ReadonlyArray, + timestamp: number + ) => { emojiButtonRef.current?.close(); sendMultiMediaMessage(conversationId, { draftAttachments, diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index 91198ae17362..45fac6049317 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -14,11 +14,8 @@ import { MentionCompletion } from '../quill/mentions/completion'; import { EmojiBlot, EmojiCompletion } from '../quill/emoji'; import type { EmojiPickDataType } from './emoji/EmojiPicker'; import { convertShortName } from './emoji/lib'; -import type { - LocalizerType, - DraftBodyRangesType, - ThemeType, -} from '../types/Util'; +import type { DraftBodyRangeMention } from '../types/BodyRange'; +import type { LocalizerType, ThemeType } from '../types/Util'; import type { ConversationType } from '../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import { isValidUuid } from '../types/UUID'; @@ -64,7 +61,7 @@ export type InputApi = { insertEmoji: (e: EmojiPickDataType) => void; setContents: ( text: string, - draftBodyRanges?: DraftBodyRangesType, + draftBodyRanges?: ReadonlyArray, cursorToEnd?: boolean ) => void; reset: () => void; @@ -82,7 +79,7 @@ export type Props = Readonly<{ sendCounter: number; skinTone?: EmojiPickDataType['skinTone']; draftText?: string; - draftBodyRanges?: DraftBodyRangesType; + draftBodyRanges?: ReadonlyArray; moduleClassName?: string; theme: ThemeType; placeholder?: string; @@ -90,7 +87,7 @@ export type Props = Readonly<{ scrollerRef?: React.RefObject; onDirtyChange?(dirty: boolean): unknown; onEditorStateChange?(options: { - bodyRanges: DraftBodyRangesType; + bodyRanges: ReadonlyArray; caretLocation?: number; conversationId: string | undefined; messageText: string; @@ -100,7 +97,7 @@ export type Props = Readonly<{ onPickEmoji(o: EmojiPickDataType): unknown; onSubmit( message: string, - mentions: DraftBodyRangesType, + mentions: ReadonlyArray, timestamp: number ): unknown; onScroll?: (ev: React.UIEvent) => void; @@ -164,16 +161,19 @@ export function CompositionInput(props: Props): React.ReactElement { const generateDelta = ( text: string, - bodyRanges: DraftBodyRangesType + mentions: ReadonlyArray ): Delta => { const initialOps = [{ insert: text }]; - const opsWithMentions = insertMentionOps(initialOps, bodyRanges); + const opsWithMentions = insertMentionOps(initialOps, mentions); const opsWithEmojis = insertEmojiOps(opsWithMentions); return new Delta(opsWithEmojis); }; - const getTextAndMentions = (): [string, DraftBodyRangesType] => { + const getTextAndMentions = (): [ + string, + ReadonlyArray + ] => { const quill = quillRef.current; if (quill === undefined) { @@ -251,7 +251,7 @@ export function CompositionInput(props: Props): React.ReactElement { const setContents = ( text: string, - bodyRanges?: DraftBodyRangesType, + mentions?: ReadonlyArray, cursorToEnd?: boolean ) => { const quill = quillRef.current; @@ -260,7 +260,7 @@ export function CompositionInput(props: Props): React.ReactElement { return; } - const delta = generateDelta(text || '', bodyRanges || []); + const delta = generateDelta(text || '', mentions || []); canSendRef.current = true; // We need to cast here because we use @types/quill@1.3.10 which has types diff --git a/ts/components/CompositionTextArea.tsx b/ts/components/CompositionTextArea.tsx index 502b0b9c80f7..22fded53895f 100644 --- a/ts/components/CompositionTextArea.tsx +++ b/ts/components/CompositionTextArea.tsx @@ -9,7 +9,8 @@ import { shouldNeverBeCalled } from '../util/shouldNeverBeCalled'; import type { InputApi } from './CompositionInput'; import { CompositionInput } from './CompositionInput'; import { EmojiButton } from './emoji/EmojiButton'; -import type { DraftBodyRangesType, ThemeType } from '../types/Util'; +import type { DraftBodyRangeMention } from '../types/BodyRange'; +import type { ThemeType } from '../types/Util'; import type { Props as EmojiButtonProps } from './emoji/EmojiButton'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import * as grapheme from '../util/grapheme'; @@ -24,13 +25,13 @@ export type CompositionTextAreaProps = { onPickEmoji: (e: EmojiPickDataType) => void; onChange: ( messageText: string, - bodyRanges: DraftBodyRangesType, + draftBodyRanges: ReadonlyArray, caretLocation?: number | undefined ) => void; onSetSkinTone: (tone: number) => void; onSubmit: ( message: string, - mentions: DraftBodyRangesType, + draftBodyRanges: ReadonlyArray, timestamp: number ) => void; onTextTooLong: () => void; diff --git a/ts/components/ConversationList.stories.tsx b/ts/components/ConversationList.stories.tsx index 18d86982adbf..c52db15d04c3 100644 --- a/ts/components/ConversationList.stories.tsx +++ b/ts/components/ConversationList.stories.tsx @@ -391,7 +391,11 @@ ConversationTypingStatus.story = { export const ConversationWithDraft = (): JSX.Element => renderConversation({ shouldShowDraft: true, - draftPreview: "I'm in the middle of typing this...", + draftPreview: { + text: "I'm in the middle of typing this...", + prefix: '🎤', + bodyRanges: [], + }, }); ConversationWithDraft.story = { diff --git a/ts/components/EditHistoryMessagesModal.tsx b/ts/components/EditHistoryMessagesModal.tsx index 42745a550789..079cdd4869b3 100644 --- a/ts/components/EditHistoryMessagesModal.tsx +++ b/ts/components/EditHistoryMessagesModal.tsx @@ -1,7 +1,7 @@ // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, useRef } from 'react'; +import React, { useCallback, useState, useRef } from 'react'; import { noop } from 'lodash'; import type { AttachmentType } from '../types/Attachment'; @@ -86,6 +86,14 @@ export function EditHistoryMessagesModal({ [closeEditHistoryModal, showLightbox] ); + // These states aren't in redux; they are meant to last only as long as this dialog. + const [revealedSpoilersById, setRevealedSpoilersById] = useState< + Record + >({}); + const [displayLimitById, setDisplayLimitById] = useState< + Record + >({}); + return (
- {editHistoryMessages.map(messageAttributes => ( - - ))} + {editHistoryMessages.map(messageAttributes => { + const syntheticId = `${messageAttributes.id}.${messageAttributes.timestamp}`; + + return ( + { + const update = { + ...displayLimitById, + [messageId]: displayLimit, + }; + setDisplayLimitById(update); + }} + platform={platform} + showLightbox={closeAndShowLightbox} + showSpoiler={messageId => { + const update = { + ...revealedSpoilersById, + [messageId]: true, + }; + setRevealedSpoilersById(update); + }} + theme={theme} + /> + ); + })}
); diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 002d4928958e..80f7a3af0d7d 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -10,7 +10,8 @@ import React, { useState, } from 'react'; import classNames from 'classnames'; -import type { DraftBodyRangesType, LocalizerType } from '../types/Util'; +import type { DraftBodyRangeMention } from '../types/BodyRange'; +import type { LocalizerType } from '../types/Util'; import type { ContextMenuOptionType } from './ContextMenu'; import type { ConversationType, @@ -27,7 +28,6 @@ import { AnimatedEmojiGalore } from './AnimatedEmojiGalore'; import { Avatar, AvatarSize } from './Avatar'; import { ConfirmationDialog } from './ConfirmationDialog'; import { ContextMenu } from './ContextMenu'; -import { Emojify } from './conversation/Emojify'; import { Intl } from './Intl'; import { MessageTimestamp } from './conversation/MessageTimestamp'; import { SendStatus } from '../messages/MessageSendState'; @@ -53,6 +53,8 @@ import { useEscapeHandling } from '../hooks/useEscapeHandling'; import { useRetryStorySend } from '../hooks/useRetryStorySend'; import { resolveStorySendStatus } from '../util/resolveStorySendStatus'; import { strictAssert } from '../util/assert'; +import { MessageBody } from './conversation/MessageBody'; +import { RenderLocation } from './conversation/MessageTextRenderer'; function renderStrong(parts: Array) { return {parts}; @@ -95,7 +97,7 @@ export type PropsType = { onReactToStory: (emoji: string, story: StoryViewType) => unknown; onReplyToStory: ( message: string, - mentions: DraftBodyRangesType, + mentions: ReadonlyArray, timestamp: number, story: StoryViewType ) => unknown; @@ -184,6 +186,7 @@ export function StoryViewer({ const { attachment, + bodyRanges, canReply, isHidden, messageId, @@ -234,6 +237,7 @@ export function StoryViewer({ // Caption related hooks const [hasExpandedCaption, setHasExpandedCaption] = useState(false); + const [isSpoilerExpanded, setIsSpoilerExpanded] = useState(false); const caption = useMemo(() => { if (!attachment?.caption) { @@ -250,6 +254,7 @@ export function StoryViewer({ // Reset expansion if messageId changes useEffect(() => { setHasExpandedCaption(false); + setIsSpoilerExpanded(false); }, [messageId]); // messageId is set as a dependency so that we can reset the story duration @@ -333,6 +338,7 @@ export function StoryViewer({ setConfirmDeleteStory(undefined); setHasConfirmHideStory(false); setHasExpandedCaption(false); + setIsSpoilerExpanded(false); setIsShowingContextMenu(false); setPauseStory(false); @@ -644,7 +650,7 @@ export function StoryViewer({ {hasExpandedCaption && (