{!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 }) => (
-
- )}
-
- {menuShowing && popperRoot
- ? createPortal(
-
- {({ ref, style, placement }) => (
-
-
- {i18n('icu:MediaQualitySelector--title')}
-
-
-
-
+
+
+
+
+ {open && (
+
+
+
+ {i18n('icu:MediaQualitySelector--title')}
+
+
+ className="MediaQualitySelector__option"
+ type="button"
+ onClick={() => {
+ onSelectQuality(conversationId, false);
+ setOpen(false);
+ }}
+ >
+
+
+
+ {i18n('icu:MediaQualitySelector--standard-quality-title')}
+
+
+ {i18n(
+ 'icu:MediaQualitySelector--standard-quality-description'
+ )}
+
+
+
+
+
+
+ )}
+
);
}
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
+ );
+}