From f09d582decebeb620f42e895393babc28367443f Mon Sep 17 00:00:00 2001 From: yash-signal Date: Wed, 25 Feb 2026 13:48:45 -0600 Subject: [PATCH] Send View Once Messages --- _locales/en/messages.json | 16 + images/icons/v3/quality/quality-high.svg | 1 - images/icons/v3/quality/quality-standard.svg | 1 - stylesheets/components/CompositionInput.scss | 38 +++ stylesheets/components/MediaEditor.scss | 3 +- .../components/MediaQualitySelector.scss | 82 +---- ts/components/CompositionArea.dom.stories.tsx | 20 ++ ts/components/CompositionArea.dom.tsx | 259 +++++++++++----- .../CompositionInput.dom.stories.tsx | 30 ++ ts/components/CompositionInput.dom.tsx | 51 +++- ts/components/CompositionTextArea.dom.tsx | 3 + ts/components/MediaEditor.dom.stories.tsx | 7 + ts/components/MediaEditor.dom.tsx | 115 +++++-- ts/components/MediaQualitySelector.dom.tsx | 284 +++++++----------- ts/components/StoryViewsNRepliesModal.dom.tsx | 3 + ts/components/ToastManager.dom.stories.tsx | 4 + ts/components/ToastManager.dom.tsx | 16 + ts/model-types.d.ts | 1 + ts/models/conversations.preload.ts | 1 + ts/state/ducks/composer.preload.ts | 122 +++++++- ts/state/ducks/conversations.preload.ts | 10 + ts/state/selectors/composer.preload.ts | 7 + ts/state/smart/CompositionArea.preload.tsx | 5 + ts/types/Toast.dom.tsx | 4 + ...earConversationDraftAttachments.preload.ts | 1 + ts/util/viewOnceEligibility.std.ts | 16 + 26 files changed, 735 insertions(+), 365 deletions(-) delete mode 100644 images/icons/v3/quality/quality-high.svg delete mode 100644 images/icons/v3/quality/quality-standard.svg create mode 100644 ts/util/viewOnceEligibility.std.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index ed61301aa4..aadaeee081 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3236,6 +3236,14 @@ "messageformat": "Only admins can add member labels in this group.", "description": "Shown when the user clicks on Member Label row in conversation details but they don't have permission to add a member label" }, + "icu:Toast--viewOnceEnabled": { + "messageformat": "View once enabled", + "description": "Toast shown when view once mode is enabled for a message" + }, + "icu:Toast--viewOnceDisabled": { + "messageformat": "View once disabled", + "description": "Toast shown when view once mode is disabled for a message" + }, "icu:ToastManager__CannotEditMessage_24": { "messageformat": "Edits can only be applied within 24 hours from the time you sent this message.", "description": "Error message when you try to send an edit after message becomes too old" @@ -6140,6 +6148,14 @@ "messageformat": "Replace", "description": "Composition Area > GIF Picker > After selecting a GIF > When you already have an attachment > Confirm Dialog > Replace Button" }, + "icu:CompositionArea--viewOnceToggle": { + "messageformat": "View once", + "description": "Aria label for the view once toggle button in the composition input" + }, + "icu:CompositionArea--viewOnceMediaPlaceholder": { + "messageformat": "View once media", + "description": "Placeholder text in composition input when view once mode is active" + }, "icu:CompositionInput__editing-message": { "messageformat": "Edit message", "description": "Status text displayed above composition input when editing a message" diff --git a/images/icons/v3/quality/quality-high.svg b/images/icons/v3/quality/quality-high.svg deleted file mode 100644 index 81ea9cbfcc..0000000000 --- a/images/icons/v3/quality/quality-high.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/images/icons/v3/quality/quality-standard.svg b/images/icons/v3/quality/quality-standard.svg deleted file mode 100644 index 64a21aca0d..0000000000 --- a/images/icons/v3/quality/quality-standard.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/stylesheets/components/CompositionInput.scss b/stylesheets/components/CompositionInput.scss index 21b14a52fd..ae55ca0114 100644 --- a/stylesheets/components/CompositionInput.scss +++ b/stylesheets/components/CompositionInput.scss @@ -47,6 +47,23 @@ overflow: hidden; word-break: break-word; + &--with-view-once { + position: relative; + display: flex; + align-items: center; + } + + &--view-once-active { + .ql-editor { + visibility: hidden; + } + + .module-composition-input__input__scroller { + height: 32px; + overflow: hidden; + } + } + // Override Quill styles .ql-container { // Inherit global font stack @@ -78,8 +95,11 @@ &__scroller { $padding-top: 6px; + position: relative; padding-block: $padding-top; padding-inline: 0; + flex: 1; + min-width: 0; min-height: calc(32px - 2 * $border-size); max-height: calc(72px - 2 * $border-size); @@ -452,6 +472,24 @@ width: 18px; } } + + &__view-once-button { + flex-shrink: 0; + align-self: center; + margin-inline-end: 8px; + margin-inline-start: 4px; + } + + &__view-once-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + + color: light-dark(variables.$color-gray-45, variables.$color-gray-25); + } } div.CompositionInput__link-preview { diff --git a/stylesheets/components/MediaEditor.scss b/stylesheets/components/MediaEditor.scss index 861148b405..60047e2252 100644 --- a/stylesheets/components/MediaEditor.scss +++ b/stylesheets/components/MediaEditor.scss @@ -149,7 +149,7 @@ width: 100%; &--input { - margin-inline: 24px; + margin-inline: 8px; width: 400px; } @@ -200,7 +200,6 @@ flex-grow: 1; flex-wrap: wrap; justify-content: center; - max-width: 596px; min-height: 36px; } diff --git a/stylesheets/components/MediaQualitySelector.scss b/stylesheets/components/MediaQualitySelector.scss index 4ea513583a..d89ef11fc7 100644 --- a/stylesheets/components/MediaQualitySelector.scss +++ b/stylesheets/components/MediaQualitySelector.scss @@ -19,78 +19,6 @@ margin-bottom: 12px; } - &__button { - @include mixins.button-reset(); - & { - align-items: center; - border-radius: 4px; - display: flex; - height: 32px; - justify-content: center; - width: 32px; - } - - @include mixins.keyboard-mode { - &:focus { - outline: 2px solid variables.$color-ultramarine; - } - } - - & { - outline: none; - } - - &::after { - content: ''; - display: block; - flex-shrink: 0; - height: 20px; - width: 20px; - - @include mixins.light-theme { - @include mixins.color-svg( - '../images/icons/v3/quality/quality-standard.svg', - variables.$color-gray-75 - ); - } - @include mixins.dark-theme { - @include mixins.color-svg( - '../images/icons/v3/quality/quality-standard.svg', - variables.$color-gray-15 - ); - } - } - - &--hq { - &::after { - @include mixins.light-theme { - @include mixins.color-svg( - '../images/icons/v3/quality/quality-high.svg', - variables.$color-gray-75 - ); - } - @include mixins.dark-theme { - @include mixins.color-svg( - '../images/icons/v3/quality/quality-high.svg', - variables.$color-gray-15 - ); - } - } - } - - &--active { - opacity: 1; - - @include mixins.light-theme() { - background-color: variables.$color-gray-05; - } - - @include mixins.dark-theme() { - background-color: variables.$color-gray-75; - } - } - } - &__option { @include mixins.button-reset(); @@ -136,12 +64,12 @@ } } - &--focused, - &:focus, - &:active { - border-radius: 6px; - box-shadow: 0 0 1px 1px variables.$color-ultramarine; + &:focus { outline: none; } + + &:focus-visible { + box-shadow: 0 0 1px 1px variables.$color-ultramarine; + } } } diff --git a/ts/components/CompositionArea.dom.stories.tsx b/ts/components/CompositionArea.dom.stories.tsx index 424c382900..4c3d7ae8d0 100644 --- a/ts/components/CompositionArea.dom.stories.tsx +++ b/ts/components/CompositionArea.dom.stories.tsx @@ -112,6 +112,9 @@ export default { // MediaQualitySelector setMediaQualitySetting: action('setMediaQualitySetting'), shouldSendHighQualityAttachments: false, + // ViewOnce + isViewOnce: false, + setViewOnce: action('setViewOnce'), // CompositionInput onEditorStateChange: action('onEditorStateChange'), onTextTooLong: action('onTextTooLong'), @@ -217,6 +220,23 @@ export function Attachments(args: Props): React.JSX.Element { ); } +export function ViewOnceEnabled(args: Props): React.JSX.Element { + const theme = useContext(StorybookThemeContext); + return ( + + ); +} + export function PendingApproval(args: Props): React.JSX.Element { const theme = useContext(StorybookThemeContext); return ; diff --git a/ts/components/CompositionArea.dom.tsx b/ts/components/CompositionArea.dom.tsx index 7f18c52c85..0effc83a72 100644 --- a/ts/components/CompositionArea.dom.tsx +++ b/ts/components/CompositionArea.dom.tsx @@ -1,7 +1,14 @@ // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import React, { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import classNames from 'classnames'; import type { ReadonlyDeep } from 'type-fest'; import type { @@ -32,6 +39,7 @@ import type { InMemoryAttachmentDraftType, } from '../types/Attachment.std.js'; import { isImageAttachment, isVoiceMessage } from '../util/Attachment.std.js'; +import { isViewOnceEligible } from '../util/viewOnceEligibility.std.js'; import type { AciString } from '../types/ServiceId.std.js'; import { AudioCapture } from './conversation/AudioCapture.dom.js'; import { CompositionUpload } from './CompositionUpload.dom.js'; @@ -77,8 +85,7 @@ import { ConfirmationDialog } from './ConfirmationDialog.dom.js'; import type { EmojiSkinTone } from './fun/data/emojis.std.js'; import { FunPickerButton } from './fun/FunButton.dom.js'; import { AxoDropdownMenu } from '../axo/AxoDropdownMenu.dom.js'; -import { AxoSymbol } from '../axo/AxoSymbol.dom.js'; -import { AxoButton } from '../axo/AxoButton.dom.js'; +import { AxoIconButton } from '../axo/AxoIconButton.dom.js'; import { tw } from '../axo/tw.dom.js'; import { isPollSendEnabled, type PollCreateType } from '../types/Polls.dom.js'; import { PollCreateModal } from './PollCreateModal.dom.js'; @@ -168,6 +175,7 @@ export type OwnProps = Readonly<{ options: { draftAttachments?: ReadonlyArray; bodyRanges?: DraftBodyRanges; + isViewOnce?: boolean; message?: string; timestamp?: number; voiceNoteAttachment?: InMemoryAttachmentDraftType; @@ -195,6 +203,12 @@ export type OwnProps = Readonly<{ conversationId: string, messageId: string | undefined ): unknown; + isViewOnce: boolean; + setViewOnce(options: { + conversationId: string; + value: boolean; + toastNotify: boolean; + }): unknown; shouldSendHighQualityAttachments: boolean; showConversation: ShowConversationType; startRecording: (id: string) => unknown; @@ -284,6 +298,9 @@ export const CompositionArea = memo(function CompositionArea({ quotedMessageAuthorAci, quotedMessageSentAt, scrollToMessage, + // View Once + isViewOnce, + setViewOnce, // MediaQualitySelector setMediaQualitySetting, shouldSendHighQualityAttachments, @@ -407,6 +424,7 @@ export const CompositionArea = memo(function CompositionArea({ bodyRanges, message, timestamp, + isViewOnce, }); } setLarge(false); @@ -418,6 +436,7 @@ export const CompositionArea = memo(function CompositionArea({ canSend, draftAttachments, editedMessageId, + isViewOnce, quotedMessageSentAt, quotedMessageAuthorAci, sendEditedMessage, @@ -586,8 +605,38 @@ export const CompositionArea = memo(function CompositionArea({ const showMediaQualitySelector = draftAttachments.some(isImageAttachment); + const showViewOnceToggle = isViewOnceEligible( + draftAttachments, + Boolean(quotedMessageId) + ); + + const isViewOnceActive = isViewOnce && showViewOnceToggle; + + let draftEditMessageForInput = draftEditMessage; + let largeForInput = large; + let linkPreviewLoadingForInput = linkPreviewLoading; + let linkPreviewResultForInput = linkPreviewResult; + let quotedMessageIdForInput = quotedMessageId; + + if (isViewOnceActive) { + draftEditMessageForInput = null; + largeForInput = false; + linkPreviewLoadingForInput = false; + linkPreviewResultForInput = null; + quotedMessageIdForInput = null; + } + const [funPickerOpen, setFunPickerOpen] = useState(false); + const handleToggleViewOnce = useCallback(() => { + setFunPickerOpen(false); + setViewOnce({ + conversationId, + value: !isViewOnce, + toastNotify: true, + }); + }, [conversationId, isViewOnce, setViewOnce]); + const handleFunPickerOpenChange = useCallback( (open: boolean) => { setFunPickerOpen(open); @@ -669,6 +718,27 @@ export const CompositionArea = memo(function CompositionArea({ }); }, [pushPanelForConversation]); + const mediaQualitySelectorFragment = useMemo( + () => + showMediaQualitySelector ? ( +
+ +
+ ) : null, + [ + conversationId, + i18n, + setMediaQualitySetting, + shouldSendHighQualityAttachments, + showMediaQualitySelector, + ] + ); + const leftHandSideButtonsFragment = ( <> {confirmGifSelection && ( @@ -692,7 +762,13 @@ export const CompositionArea = memo(function CompositionArea({ {i18n('icu:CompositionArea__ConfirmGifSelection__Body')} )} -
+
- {showMediaQualitySelector ? ( -
- -
- ) : null} + {mediaQualitySelectorFragment} ); @@ -752,6 +819,9 @@ export const CompositionArea = memo(function CompositionArea({ ) : null; const isRecording = recordingState === RecordingState.Recording; + const actionSlotClassName = tw( + 'flex size-8 shrink-0 items-center justify-center' + ); let attButton; if (draftEditMessage || linkPreviewResult || isRecording) { @@ -760,15 +830,15 @@ export const CompositionArea = memo(function CompositionArea({ attButton = (
-
+
- - - + size="md" + label={i18n('icu:CompositionArea--attach-plus')} + tooltip={false} + symbol="plus" + />
@@ -807,12 +877,14 @@ export const CompositionArea = memo(function CompositionArea({ <>
-
) : null; @@ -1050,6 +1122,9 @@ export const CompositionArea = memo(function CompositionArea({ isCreatingStory={false} isFormattingEnabled={isFormattingEnabled} isSending={false} + isHighQuality={shouldSendHighQualityAttachments} + isViewOnce={isViewOnce} + showViewOnceToggle={showViewOnceToggle} convertDraftBodyRangesIntoHydrated={ convertDraftBodyRangesIntoHydrated } @@ -1060,6 +1135,8 @@ export const CompositionArea = memo(function CompositionArea({ data, contentType, blurHash, + isViewOnce: editorIsViewOnce, + isHighQuality: editorIsHighQuality, }) => { const newAttachment = { ...attachmentToEdit, @@ -1071,6 +1148,25 @@ export const CompositionArea = memo(function CompositionArea({ addAttachment(conversationId, newAttachment); setAttachmentToEdit(undefined); + + if ( + editorIsViewOnce !== undefined && + editorIsViewOnce !== isViewOnce + ) { + setViewOnce({ + conversationId, + value: editorIsViewOnce, + toastNotify: false, + }); + } + + if ( + editorIsHighQuality !== undefined && + editorIsHighQuality !== shouldSendHighQualityAttachments + ) { + setMediaQualitySetting(conversationId, editorIsHighQuality); + } + onEditorStateChange?.({ bodyRanges: captionBodyRanges ?? [], conversationId, @@ -1092,42 +1188,46 @@ export const CompositionArea = memo(function CompositionArea({ sortedGroupMembers={sortedGroupMembers} /> )} -
-
+ {isViewOnceActive ? null : ( +
+
+ )}
- {quotedMessageProps && ( -
- scrollToMessage(conversationId, quotedMessageId) - : undefined - } - onClose={() => { - setQuoteByMessageId(conversationId, undefined); - }} - /> -
- )} + {isViewOnceActive + ? null + : quotedMessageProps && ( +
+ scrollToMessage(conversationId, quotedMessageId) + : undefined + } + onClose={() => { + setQuoteByMessageId(conversationId, undefined); + }} + /> +
+ )} {draftAttachments.length ? (
{!large ? leftHandSideButtonsFragment : null}
- {!large ? ( + {isViewOnceActive && ( +
+
+
+
+ )} + {!isViewOnceActive && !large && ( <> {!dirty ? micButtonFragment : null} {editMessageFragment} {attButton} - ) : null} + )}
- {large ? ( + {!isViewOnceActive && large ? (
= {}): Props => { inputApi: null, shouldHidePopovers: null, linkPreviewResult: null, + showViewOnceButton: false, + isViewOnceActive: false, + onToggleViewOnce: action('onToggleViewOnce'), }; }; @@ -142,3 +145,30 @@ export function Mentions(): React.JSX.Element { export function NoFormattingMenu(): React.JSX.Element { return ; } + +export function ViewOnceButton(): React.JSX.Element { + const [isActive, setIsActive] = React.useState(false); + const props = useProps(); + + return ( + setIsActive(!isActive)} + /> + ); +} + +export function ViewOnceButtonActive(): React.JSX.Element { + const props = useProps(); + + return ( + + ); +} diff --git a/ts/components/CompositionInput.dom.tsx b/ts/components/CompositionInput.dom.tsx index be194ad5e7..032cb16c58 100644 --- a/ts/components/CompositionInput.dom.tsx +++ b/ts/components/CompositionInput.dom.tsx @@ -83,6 +83,9 @@ import type { EmojiCompletionOptions } from '../quill/emoji/completion.dom.js'; import { useFunEmojiLocalizer } from './fun/useFunEmojiLocalizer.dom.js'; import { MAX_BODY_ATTACHMENT_BYTE_LENGTH } from '../util/longAttachment.std.js'; import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.js'; +import { AxoSymbol } from '../axo/AxoSymbol.dom.js'; +import { AxoTooltip } from '../axo/AxoTooltip.dom.js'; +import { tw } from '../axo/tw.dom.js'; const log = createLogger('CompositionInput'); @@ -159,6 +162,9 @@ export type Props = Readonly<{ linkPreviewLoading?: boolean; linkPreviewResult: LinkPreviewForUIType | null; onCloseLinkPreview?(conversationId: string): unknown; + showViewOnceButton: boolean; + isViewOnceActive: boolean; + onToggleViewOnce: () => void; }>; const BASE_CLASS_NAME = 'module-composition-input'; @@ -195,6 +201,9 @@ export function CompositionInput(props: Props): React.ReactElement { sendCounter, sortedGroupMembers, theme, + showViewOnceButton, + isViewOnceActive, + onToggleViewOnce, } = props; const [emojiCompletionElement, setEmojiCompletionElement] = @@ -876,7 +885,7 @@ export function CompositionInput(props: Props): React.ReactElement { }} formats={getQuillFormats()} placeholder={placeholder || i18n('icu:sendMessage')} - readOnly={disabled} + readOnly={disabled || isViewOnceActive} ref={element => { if (!element) { return; @@ -988,16 +997,22 @@ export function CompositionInput(props: Props): React.ReactElement { }; }, [isMouseDown]); + const isInputEnabled = !disabled && !isViewOnceActive; + return ( {({ ref }) => (
{draftEditMessage && ( @@ -1024,6 +1039,11 @@ export function CompositionInput(props: Props): React.ReactElement { /> )} {children} + {isViewOnceActive && ( +
+ {i18n('icu:CompositionArea--viewOnceMediaPlaceholder')} +
+ )}
)}
+ {showViewOnceButton && ( +
+ + + +
+ )}
)}
diff --git a/ts/components/CompositionTextArea.dom.tsx b/ts/components/CompositionTextArea.dom.tsx index 7a9ec36284..ef294cfb1a 100644 --- a/ts/components/CompositionTextArea.dom.tsx +++ b/ts/components/CompositionTextArea.dom.tsx @@ -187,6 +187,9 @@ export function CompositionTextArea({ linkPreviewResult={null} // Panels appear behind this modal shouldHidePopovers={null} + showViewOnceButton={false} + isViewOnceActive={false} + onToggleViewOnce={() => undefined} />
undefined, + isHighQuality: false, i18n, imageToBlurHash: input => Promise.resolve(input.toString()), imageSrc: IMAGE_2, @@ -55,6 +56,12 @@ Portrait.args = { imageSrc: IMAGE_4, }; +export const ViewOnce = Template.bind({}); +ViewOnce.args = { + isViewOnce: false, + showViewOnceToggle: true, +}; + export const Sending = Template.bind({}); Sending.args = { isSending: true, diff --git a/ts/components/MediaEditor.dom.tsx b/ts/components/MediaEditor.dom.tsx index ecfc001a0e..9b79bd7557 100644 --- a/ts/components/MediaEditor.dom.tsx +++ b/ts/components/MediaEditor.dom.tsx @@ -44,7 +44,6 @@ import { ContextMenu } from './ContextMenu.dom.js'; import { IMAGE_PNG } from '../types/MIME.std.js'; import { SizeObserver } from '../hooks/useSizeObserver.dom.js'; import { Slider } from './Slider.dom.js'; -import { Spinner } from './Spinner.dom.js'; import { Theme } from '../util/theme.std.js'; import { ThemeType } from '../types/Util.std.js'; import { arrow } from '../util/keyboard.dom.js'; @@ -62,6 +61,9 @@ import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis.dom.js'; import { FunStickerPicker } from './fun/FunStickerPicker.dom.js'; import type { FunStickerSelection } from './fun/panels/FunPanelStickers.dom.js'; import { drop } from '../util/drop.std.js'; +import { MediaQualitySelector } from './MediaQualitySelector.dom.js'; +import { AxoButton } from '../axo/AxoButton.dom.js'; +import { tw } from '../axo/tw.dom.js'; import type { FunTimeStickerStyle } from './fun/constants.dom.js'; import * as Errors from '../types/errors.std.js'; @@ -75,6 +77,8 @@ export type MediaEditorResultType = Readonly<{ blurHash: string; caption?: string; captionBodyRanges?: DraftBodyRanges; + isViewOnce?: boolean; + isHighQuality?: boolean; }>; export type PropsType = { @@ -89,6 +93,9 @@ export type PropsType = { convertDraftBodyRangesIntoHydrated: ( bodyRanges: DraftBodyRanges | undefined ) => HydratedBodyRangesType | undefined; + isHighQuality?: boolean; + isViewOnce?: boolean; + showViewOnceToggle?: boolean; } & Pick< CompositionInputProps, | 'draftText' @@ -159,6 +166,9 @@ export function MediaEditor({ isSending, onClose, onDone, + isHighQuality, + isViewOnce, + showViewOnceToggle = false, // CompositionInput draftText, @@ -182,6 +192,15 @@ export function MediaEditor({ const [caption, setCaption] = useState(draftText ?? ''); const [captionBodyRanges, setCaptionBodyRanges] = useState(draftBodyRanges); + const [localIsViewOnce, setLocalIsViewOnce] = useState(isViewOnce ?? false); + const hasViewOnceChange = localIsViewOnce !== (isViewOnce ?? false); + const [localIsHighQuality, setLocalIsHighQuality] = useState( + isHighQuality ?? false + ); + const hasHighQualityChange = + typeof isHighQuality === 'boolean' && localIsHighQuality !== isHighQuality; + const showMediaQualitySelector = typeof isHighQuality === 'boolean'; + const pickerTheme = ThemeType.dark; const hydratedBodyRanges = useMemo( () => convertDraftBodyRangesIntoHydrated(captionBodyRanges ?? undefined), @@ -209,6 +228,10 @@ export function MediaEditor({ } }, []); + const handleSelectQuality = useCallback((_id: string, isHQ: boolean) => { + setLocalIsHighQuality(isHQ); + }, []); + const handlePickSticker = useCallback( (_packId: string, _stickerId: number, src: string) => { async function run() { @@ -362,8 +385,18 @@ export function MediaEditor({ }); const onTryClose = useCallback(() => { - confirmDiscardIf(canUndo || isCreatingStory, onClose); - }, [confirmDiscardIf, canUndo, isCreatingStory, onClose]); + confirmDiscardIf( + canUndo || isCreatingStory || hasViewOnceChange || hasHighQualityChange, + onClose + ); + }, [ + confirmDiscardIf, + canUndo, + isCreatingStory, + hasViewOnceChange, + hasHighQualityChange, + onClose, + ]); tryClose.current = onTryClose; // Keyboard support @@ -1317,13 +1350,40 @@ export function MediaEditor({ showTimeStickers onSelectTimeSticker={handlePickTimeSticker} placement="top" - theme={ThemeType.dark} + theme={pickerTheme} >
-
+
+ + + +
+ {showMediaQualitySelector && ( +
+ +
+ )} +
- - - - + showViewOnceButton={showViewOnceToggle} + isViewOnceActive={localIsViewOnce} + onToggleViewOnce={() => { + const newValue = !localIsViewOnce; + setLocalIsViewOnce(newValue); + if (newValue) { + setEmojiPickerOpen(false); + } + }} + />
- + {doneButtonLabel || i18n('icu:save')} +
)} diff --git a/ts/components/MediaQualitySelector.dom.tsx b/ts/components/MediaQualitySelector.dom.tsx index 16a9dfba8e..adce410c53 100644 --- a/ts/components/MediaQualitySelector.dom.tsx +++ b/ts/components/MediaQualitySelector.dom.tsx @@ -2,16 +2,11 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { KeyboardEvent } from 'react'; -import React, { useCallback, useEffect, useState } from 'react'; -import lodash from 'lodash'; -import { createPortal } from 'react-dom'; +import React, { useCallback, useRef, useState } from 'react'; import classNames from 'classnames'; -import { Manager, Popper, Reference } from 'react-popper'; +import { Popover } from 'radix-ui'; import type { LocalizerType } from '../types/Util.std.js'; -import { useRefMerger } from '../hooks/useRefMerger.std.js'; -import { handleOutsideClick } from '../util/handleOutsideClick.dom.js'; - -const { noop } = lodash; +import { AxoIconButton } from '../axo/AxoIconButton.dom.js'; export type PropsType = { conversationId: string; @@ -26,182 +21,115 @@ export function MediaQualitySelector({ isHighQuality, onSelectQuality, }: PropsType): React.JSX.Element { - const [menuShowing, setMenuShowing] = useState(false); - const [popperRoot, setPopperRoot] = useState(null); - const [focusedOption, setFocusedOption] = useState<0 | 1 | undefined>( - undefined + const [open, setOpen] = useState(false); + const standardRef = useRef(null); + const highRef = useRef(null); + + const handleOpenAutoFocus = useCallback( + (e: Event) => { + e.preventDefault(); + if (isHighQuality) { + highRef.current?.focus(); + } else { + standardRef.current?.focus(); + } + }, + [isHighQuality] ); - const buttonRef = React.useRef(null); - const refMerger = useRefMerger(); - - const handleClick = () => { - setMenuShowing(true); - }; - - const handleKeyDown = (ev: KeyboardEvent) => { - if (!popperRoot) { - if (ev.key === 'Enter') { - setFocusedOption(isHighQuality ? 1 : 0); - } - return; - } - + const handleContentKeyDown = useCallback((ev: KeyboardEvent) => { if (ev.key === 'ArrowDown' || ev.key === 'ArrowUp') { - setFocusedOption(oldFocusedOption => (oldFocusedOption === 1 ? 0 : 1)); - ev.stopPropagation(); - ev.preventDefault(); - } - - if (ev.key === 'Enter') { - onSelectQuality(conversationId, Boolean(focusedOption)); - setMenuShowing(false); - ev.stopPropagation(); - ev.preventDefault(); - } - }; - - const handleClose = useCallback(() => { - setMenuShowing(false); - setFocusedOption(undefined); - }, [setMenuShowing]); - - useEffect(() => { - if (menuShowing) { - const root = document.createElement('div'); - setPopperRoot(root); - document.body.appendChild(root); - - return () => { - document.body.removeChild(root); - setPopperRoot(null); - }; - } - - return noop; - }, [menuShowing, setPopperRoot, handleClose]); - - useEffect(() => { - if (!menuShowing) { - return noop; - } - - return handleOutsideClick( - () => { - handleClose(); - return true; - }, - { - containerElements: [popperRoot, buttonRef], - name: 'MediaQualitySelector', + if (document.activeElement === standardRef.current) { + highRef.current?.focus(); + } else { + standardRef.current?.focus(); } - ); - }, [menuShowing, popperRoot, handleClose]); + ev.stopPropagation(); + ev.preventDefault(); + } + }, []); return ( - - - {({ ref }) => ( - - -
+ + + + + {open && ( + + +
+ {i18n('icu:MediaQualitySelector--title')} +
+ + +
+
+ )} +
); } diff --git a/ts/components/StoryViewsNRepliesModal.dom.tsx b/ts/components/StoryViewsNRepliesModal.dom.tsx index 25afa9b39a..3d926c3136 100644 --- a/ts/components/StoryViewsNRepliesModal.dom.tsx +++ b/ts/components/StoryViewsNRepliesModal.dom.tsx @@ -294,6 +294,9 @@ export function StoryViewsNRepliesModal({ large={null} shouldHidePopovers={null} linkPreviewResult={null} + showViewOnceButton={false} + isViewOnceActive={false} + onToggleViewOnce={noop} > + {i18n('icu:Toast--viewOnceEnabled')} + + ); + } + + if (toastType === ToastType.ViewOnceDisabled) { + return ( + + {i18n('icu:Toast--viewOnceDisabled')} + + ); + } + throw missingCaseError(toastType); } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 5a3062ac9e..97f1f00a99 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -382,6 +382,7 @@ export type ConversationAttributesType = { draftChanged?: boolean; draftAttachments?: ReadonlyArray; draftBodyRanges?: DraftBodyRanges; + draftIsViewOnce?: boolean; draftTimestamp?: number | null; hideStory?: boolean; inbox_position?: number; diff --git a/ts/models/conversations.preload.ts b/ts/models/conversations.preload.ts index effc3c126c..b77ce7bb6a 100644 --- a/ts/models/conversations.preload.ts +++ b/ts/models/conversations.preload.ts @@ -4080,6 +4080,7 @@ export class ConversationModel { draft: '', draftEditMessage: undefined, draftBodyRanges: [], + draftIsViewOnce: false, draftTimestamp: null, quotedMessageId: undefined, }; diff --git a/ts/state/ducks/composer.preload.ts b/ts/state/ducks/composer.preload.ts index d9e5eae6ab..05b4c01247 100644 --- a/ts/state/ducks/composer.preload.ts +++ b/ts/state/ducks/composer.preload.ts @@ -17,9 +17,10 @@ import type { InMemoryAttachmentDraftType, } from '../../types/Attachment.std.js'; import { - isVideoAttachment, isImageAttachment, + isVideoAttachment, } from '../../util/Attachment.std.js'; +import { isViewOnceEligible } from '../../util/viewOnceEligibility.std.js'; import { DataReader, DataWriter } from '../../sql/Client.preload.js'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.std.js'; import type { DraftBodyRanges } from '../../types/BodyRange.std.js'; @@ -119,6 +120,7 @@ type ComposerStateByConversationType = { attachments: ReadonlyArray; focusCounter: number; disabledCounter: number; + isViewOnce: boolean; linkPreviewLoading: boolean; linkPreviewResult?: LinkPreviewForUIType; messageCompositionId: string; @@ -144,6 +146,7 @@ function getEmptyComposerState(): ComposerStateByConversationType { attachments: [], focusCounter: 0, disabledCounter: 0, + isViewOnce: false, linkPreviewLoading: false, messageCompositionId: generateUuid(), sendCounter: 0, @@ -166,6 +169,7 @@ const RESET_COMPOSER = 'composer/RESET_COMPOSER'; export const SET_FOCUS = 'composer/SET_FOCUS'; const SET_HIGH_QUALITY_SETTING = 'composer/SET_HIGH_QUALITY_SETTING'; const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE'; +const SET_VIEW_ONCE = 'composer/SET_VIEW_ONCE'; const UPDATE_COMPOSER_DISABLED = 'composer/UPDATE_COMPOSER_DISABLED'; type AddPendingAttachmentActionType = ReadonlyDeep<{ @@ -231,6 +235,14 @@ export type SetQuotedMessageActionType = { }; }; +export type SetViewOnceActionType = ReadonlyDeep<{ + type: typeof SET_VIEW_ONCE; + payload: { + conversationId: string; + value: boolean; + }; +}>; + // eslint-disable-next-line local-rules/type-alias-readonlydeep type ComposerActionType = | AddLinkPreviewActionType @@ -244,7 +256,8 @@ type ComposerActionType = | UpdateComposerDisabledActionType | SetFocusActionType | SetHighQualitySettingActionType - | SetQuotedMessageActionType; + | SetQuotedMessageActionType + | SetViewOnceActionType; // Action Creators @@ -275,6 +288,7 @@ export const actions = { setMediaQualitySetting, setQuoteByMessageId, setQuotedMessage, + setViewOnce, updateComposerDisabled, }; @@ -925,6 +939,7 @@ export function setQuoteByMessageId( quote, }) ); + dispatch(disableViewOnceIfIneligible(conversationId)); dispatch(setComposerFocus(conversation.id)); }; @@ -1377,7 +1392,12 @@ function removeAttachment( export function replaceAttachments( conversationId: string, attachments: ReadonlyArray -): ThunkAction { +): ThunkAction< + void, + RootStateType, + unknown, + ReplaceAttachmentsActionType | SetViewOnceActionType | ShowToastActionType +> { return (dispatch, getState) => { // If the call came from a conversation we are no longer in we do not // update the state. @@ -1397,6 +1417,7 @@ export function replaceAttachments( }, }); dispatch(setComposerFocus(conversationId)); + dispatch(disableViewOnceIfIneligible(conversationId)); }; } @@ -1565,6 +1586,95 @@ function setQuotedMessage( }; } +export function setViewOnce({ + conversationId, + value, + toastNotify, +}: { + conversationId: string; + value: boolean; + toastNotify: boolean; +}): ThunkAction< + void, + RootStateType, + unknown, + SetViewOnceActionType | ShowToastActionType +> { + return async (dispatch, getState) => { + const composerState = getComposerStateForConversation( + getState().composer, + conversationId + ); + const nextValue = + value && + isViewOnceEligible( + composerState.attachments, + Boolean(composerState.quotedMessage) + ); + + if (composerState.isViewOnce !== nextValue) { + dispatch({ + type: SET_VIEW_ONCE, + payload: { + conversationId, + value: nextValue, + }, + }); + + if (toastNotify) { + dispatch( + showToast({ + toastType: nextValue + ? ToastType.ViewOnceEnabled + : ToastType.ViewOnceDisabled, + }) + ); + } + } + + const conversation = window.ConversationController.get(conversationId); + if (conversation && conversation.get('draftIsViewOnce') !== nextValue) { + conversation.set({ + draftIsViewOnce: nextValue, + draftChanged: true, + }); + await DataWriter.updateConversation(conversation.attributes); + } + }; +} + +function disableViewOnceIfIneligible( + conversationId: string, + toastNotify = true +): ThunkAction< + void, + RootStateType, + unknown, + SetViewOnceActionType | ShowToastActionType +> { + return (dispatch, getState) => { + const composerState = getComposerStateForConversation( + getState().composer, + conversationId + ); + if ( + composerState.isViewOnce && + !isViewOnceEligible( + composerState.attachments, + Boolean(composerState.quotedMessage) + ) + ) { + dispatch( + setViewOnce({ + conversationId, + value: false, + toastNotify, + }) + ); + } + }; +} + // Reducer export function getEmptyState(): ComposerStateType { @@ -1702,6 +1812,12 @@ export function reducer( })); } + if (action.type === SET_VIEW_ONCE) { + return updateComposerState(state, action, () => ({ + isViewOnce: action.payload.value, + })); + } + if (action.type === UPDATE_COMPOSER_DISABLED) { return updateComposerState(state, action, oldState => ({ disabledCounter: diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index 790eb7a591..c7d234f857 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -167,6 +167,7 @@ import type { ResetComposerActionType, SetFocusActionType, SetQuotedMessageActionType, + SetViewOnceActionType, } from './composer.preload.js'; import { SET_FOCUS, @@ -175,6 +176,7 @@ import { setQuoteByMessageId, resetComposer, saveDraftRecordingIfNeeded, + setViewOnce, } from './composer.preload.js'; import { ReceiptType } from '../../types/Receipt.std.js'; import { Sound, SoundType } from '../../util/Sound.std.js'; @@ -4883,6 +4885,7 @@ function onConversationOpened( | ResetComposerActionType | SetFocusActionType | SetQuotedMessageActionType + | SetViewOnceActionType > { return async dispatch => { const promises: Array> = []; @@ -4974,6 +4977,13 @@ function onConversationOpened( ) ); dispatch(resetComposer(conversationId)); + dispatch( + setViewOnce({ + conversationId, + value: conversation.get('draftIsViewOnce') ?? false, + toastNotify: false, + }) + ); await Promise.all(promises); if (window.SignalCI) { diff --git a/ts/state/selectors/composer.preload.ts b/ts/state/selectors/composer.preload.ts index 8ed2d33aa0..1d7cc3e6dd 100644 --- a/ts/state/selectors/composer.preload.ts +++ b/ts/state/selectors/composer.preload.ts @@ -25,3 +25,10 @@ export const getQuotedMessageSelector = createSelector( (conversationId: string): QuotedMessageForComposerType | undefined => composerStateForConversationIdSelector(conversationId).quotedMessage ); + +export const getViewOnceSelector = createSelector( + getComposerStateForConversationIdSelector, + composerStateForConversationIdSelector => + (conversationId: string): boolean => + composerStateForConversationIdSelector(conversationId).isViewOnce +); diff --git a/ts/state/smart/CompositionArea.preload.tsx b/ts/state/smart/CompositionArea.preload.tsx index 46381852a8..e052891ddf 100644 --- a/ts/state/smart/CompositionArea.preload.tsx +++ b/ts/state/smart/CompositionArea.preload.tsx @@ -112,6 +112,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ attachments: draftAttachments, focusCounter, disabledCounter, + isViewOnce, linkPreviewLoading, linkPreviewResult, messageCompositionId, @@ -202,6 +203,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ processAttachments, setMediaQualitySetting, setQuoteByMessageId, + setViewOnce, cancelJoinRequest, sendStickerMessage, sendEditedMessage, @@ -301,6 +303,9 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ quotedMessageAuthorAci={quotedMessage?.quote?.authorAci ?? null} quotedMessageSentAt={quotedMessage?.quote?.id ?? null} setQuoteByMessageId={setQuoteByMessageId} + // View Once + isViewOnce={isViewOnce} + setViewOnce={setViewOnce} // Fun Picker emojiSkinToneDefault={emojiSkinToneDefault} onSelectEmoji={onUseEmoji} diff --git a/ts/types/Toast.dom.tsx b/ts/types/Toast.dom.tsx index d4b962dc33..4c95d636e6 100644 --- a/ts/types/Toast.dom.tsx +++ b/ts/types/Toast.dom.tsx @@ -96,6 +96,8 @@ export enum ToastType { UnsupportedOS = 'UnsupportedOS', UserAddedToGroup = 'UserAddedToGroup', UsernameRecovered = 'UsernameRecovered', + ViewOnceDisabled = 'ViewOnceDisabled', + ViewOnceEnabled = 'ViewOnceEnabled', VoiceNoteLimit = 'VoiceNoteLimit', VoiceNoteMustBeTheOnlyAttachment = 'VoiceNoteMustBeTheOnlyAttachment', WhoCanFindMeReadOnly = 'WhoCanFindMeReadOnly', @@ -250,6 +252,8 @@ export type AnyToast = toastType: ToastType.UsernameRecovered; parameters: { username: string }; } + | { toastType: ToastType.ViewOnceDisabled } + | { toastType: ToastType.ViewOnceEnabled } | { toastType: ToastType.VoiceNoteLimit } | { toastType: ToastType.VoiceNoteMustBeTheOnlyAttachment } | { toastType: ToastType.WhoCanFindMeReadOnly }; diff --git a/ts/util/clearConversationDraftAttachments.preload.ts b/ts/util/clearConversationDraftAttachments.preload.ts index 5893003fdc..7ee8158204 100644 --- a/ts/util/clearConversationDraftAttachments.preload.ts +++ b/ts/util/clearConversationDraftAttachments.preload.ts @@ -15,6 +15,7 @@ export async function clearConversationDraftAttachments( conversation.set({ draftAttachments: [], + draftIsViewOnce: false, draftChanged: true, }); diff --git a/ts/util/viewOnceEligibility.std.ts b/ts/util/viewOnceEligibility.std.ts new file mode 100644 index 0000000000..891c5d646e --- /dev/null +++ b/ts/util/viewOnceEligibility.std.ts @@ -0,0 +1,16 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { AttachmentDraftType } from '../types/Attachment.std.js'; +import { isImageAttachment, isVideoAttachment } from './Attachment.std.js'; + +export function isViewOnceEligible( + attachments: ReadonlyArray, + hasQuote: boolean +): boolean { + return Boolean( + attachments.length === 1 && + (isImageAttachment(attachments[0]) || isVideoAttachment(attachments[0])) && + !hasQuote + ); +}