Send View Once Messages

This commit is contained in:
yash-signal
2026-02-25 13:48:45 -06:00
committed by GitHub
parent 1abce3b627
commit f09d582dec
26 changed files with 735 additions and 365 deletions
+16
View File
@@ -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"
-1
View File
@@ -1 +0,0 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.417 7.292a.625.625 0 1 0 0 1.25h.416a.625.625 0 1 0 0-1.25h-.416Zm2.291.625c0-.345.28-.625.625-.625h.417a.625.625 0 1 1 0 1.25h-.417a.625.625 0 0 1-.625-.625Zm2.929.003a.625.625 0 0 1 .736-.491l.416.083a.625.625 0 1 1-.245 1.226l-.417-.083a.625.625 0 0 1-.49-.736Zm3.309 1.916a.625.625 0 1 0-1.226.245l.084.417a.625.625 0 1 0 1.226-.246l-.084-.416Zm-.404 2.456c.345 0 .625.28.625.625v.416a.625.625 0 0 1-1.25 0v-.416c0-.345.28-.625.625-.625Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M6.719 2.604c-.79 0-1.428 0-1.944.042-.532.044-.998.136-1.43.356A3.646 3.646 0 0 0 1.75 4.595c-.22.432-.311.899-.355 1.43-.042.516-.042 1.154-.042 1.944v4.062c0 .79 0 1.428.042 1.944.044.532.135.998.356 1.43a3.646 3.646 0 0 0 1.593 1.594c.432.22.898.311 1.43.355.516.042 1.153.042 1.944.042h6.562c.79 0 1.428 0 1.944-.042.531-.044.998-.136 1.43-.355a3.646 3.646 0 0 0 1.593-1.594c.22-.432.312-.898.356-1.43.042-.516.042-1.153.042-1.944V7.97c0-.79 0-1.428-.042-1.944-.044-.531-.136-.998-.356-1.43a3.646 3.646 0 0 0-1.593-1.593c-.432-.22-.899-.312-1.43-.356-.516-.042-1.154-.042-1.944-.042H6.72ZM4.007 4.301c.192-.098.445-.165.887-.201.45-.037 1.027-.037 1.856-.037h6.5c.829 0 1.406 0 1.856.037.441.036.695.103.887.2.412.21.746.545.956.957.098.192.165.445.201.887.037.45.038 1.027.038 1.856v4c0 .829-.001 1.406-.038 1.856-.036.442-.103.695-.201.887-.21.412-.544.746-.956.956-.192.098-.446.165-.887.201-.261.022-.567.03-.94.035v-.102a.625.625 0 1 0-1.25 0v.104H6.75c-.829 0-1.407 0-1.856-.037-.442-.036-.695-.103-.887-.2a2.187 2.187 0 0 1-.956-.957c-.098-.192-.165-.445-.201-.887-.037-.45-.038-1.027-.038-1.856V8.542h.105a.625.625 0 0 0 0-1.25h-.103c.002-.47.01-.84.036-1.148.036-.442.103-.695.2-.887.21-.412.545-.746.957-.956Z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1 +0,0 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.792 2.708a.625.625 0 0 0 0 1.25h.416a.625.625 0 1 0 0-1.25h-.416Z" fill="#000"/><path fill-rule="evenodd" clip-rule="evenodd" d="M5.387 7.187c-.558 0-1.02 0-1.398.031-.393.032-.756.101-1.1.276-.528.27-.959.7-1.228 1.23-.175.342-.244.706-.276 1.098-.03.378-.03.84-.03 1.398v2.143c0 .558 0 1.02.03 1.398.032.393.101.756.276 1.1.27.528.7.959 1.229 1.228.343.175.706.244 1.099.276.377.03.84.03 1.398.03h4.643c.558 0 1.02 0 1.398-.03.392-.032.756-.101 1.099-.276.529-.27.96-.7 1.229-1.229.175-.343.244-.707.276-1.099.03-.377.03-.84.03-1.398V11.22c0-.558 0-1.02-.03-1.398-.032-.392-.101-.756-.276-1.099-.27-.53-.7-.96-1.23-1.229-.342-.175-.706-.244-1.098-.276-.378-.03-.84-.03-1.398-.03H5.387ZM3.552 8.793c.103-.052.253-.097.556-.121.31-.026.713-.026 1.309-.026H10c.595 0 .998 0 1.309.026.302.024.453.069.556.121.255.13.462.337.591.592.053.103.098.254.122.556.026.31.026.714.026 1.309v2.083c0 .596 0 .998-.026 1.31-.024.302-.069.452-.121.555a1.36 1.36 0 0 1-.592.592c-.103.052-.254.097-.556.122-.31.025-.714.025-1.309.025H5.417c-.596 0-.998 0-1.31-.025-.302-.025-.452-.07-.555-.122a1.354 1.354 0 0 1-.592-.592c-.052-.103-.097-.253-.122-.556-.025-.31-.026-.713-.026-1.309V11.25c0-.595.001-.998.026-1.309.025-.302.07-.453.122-.556.13-.255.337-.462.592-.592Z" fill="#000"/><path d="M12.083 3.333c0-.345.28-.625.625-.625h.417a.625.625 0 1 1 0 1.25h-.417a.625.625 0 0 1-.625-.625Zm-4.791-.625a.625.625 0 0 1 0 1.25h-.417a.625.625 0 1 1 0-1.25h.417Zm7.72.628a.625.625 0 0 1 .736-.49l.416.083a.625.625 0 0 1-.245 1.225l-.417-.083a.625.625 0 0 1-.49-.735Zm-10.76-.491a.625.625 0 1 1 .246 1.226l-.417.083a.625.625 0 0 1-.245-1.225l.416-.084Zm13.334 1.917a.625.625 0 0 1 .735.49l.083.417a.625.625 0 1 1-1.225.245l-.083-.416a.625.625 0 0 1 .49-.736Zm-15.907.49a.625.625 0 1 1 1.226.246l-.084.416a.625.625 0 1 1-1.226-.245l.084-.417Zm16.238 2.456c.345 0 .625.28.625.625v.417a.625.625 0 0 1-1.25 0v-.417c0-.345.28-.625.625-.625Zm-2.169 9.446a.625.625 0 0 1-.246-1.225l.417-.084a.625.625 0 1 1 .245 1.226l-.416.083Zm1.838-1.916a.625.625 0 0 0 .735-.49l.083-.417a.625.625 0 1 0-1.225-.245l-.083.416a.625.625 0 0 0 .49.736Zm.956-3.571a.625.625 0 0 1-1.25 0v-.417a.625.625 0 1 1 1.25 0v.417Z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

@@ -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 {
+1 -2
View File
@@ -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;
}
@@ -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;
}
}
}
@@ -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 (
<CompositionArea
{...args}
theme={theme}
isViewOnce
draftAttachments={[
fakeDraftAttachment({
contentType: IMAGE_JPEG,
url: landscapeGreenUrl,
}),
]}
/>
);
}
export function PendingApproval(args: Props): React.JSX.Element {
const theme = useContext(StorybookThemeContext);
return <CompositionArea {...args} theme={theme} areWePendingApproval />;
+186 -73
View File
@@ -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<AttachmentDraftType>;
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 ? (
<div className="CompositionArea__button-cell">
<MediaQualitySelector
conversationId={conversationId}
i18n={i18n}
isHighQuality={shouldSendHighQualityAttachments}
onSelectQuality={setMediaQualitySetting}
/>
</div>
) : null,
[
conversationId,
i18n,
setMediaQualitySetting,
shouldSendHighQualityAttachments,
showMediaQualitySelector,
]
);
const leftHandSideButtonsFragment = (
<>
{confirmGifSelection && (
@@ -692,7 +762,13 @@ export const CompositionArea = memo(function CompositionArea({
{i18n('icu:CompositionArea__ConfirmGifSelection__Body')}
</ConfirmationDialog>
)}
<div className="CompositionArea__button-cell">
<div
aria-hidden={isViewOnceActive || undefined}
className={classNames(
'CompositionArea__button-cell',
isViewOnceActive ? tw('invisible') : null
)}
>
<FunPicker
placement="top start"
open={funPickerOpen}
@@ -705,16 +781,7 @@ export const CompositionArea = memo(function CompositionArea({
<FunPickerButton i18n={i18n} />
</FunPicker>
</div>
{showMediaQualitySelector ? (
<div className="CompositionArea__button-cell">
<MediaQualitySelector
conversationId={conversationId}
i18n={i18n}
isHighQuality={shouldSendHighQualityAttachments}
onSelectQuality={setMediaQualitySetting}
/>
</div>
) : 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 = (
<div className="CompositionArea__button-cell">
<AxoDropdownMenu.Root>
<div className={tw('flex h-8 items-center')}>
<div className={actionSlotClassName}>
<AxoDropdownMenu.Trigger>
<AxoButton.Root
<AxoIconButton.Root
variant="borderless-secondary"
size="sm"
aria-label={i18n('icu:CompositionArea--attach-plus')}
>
<AxoSymbol.Icon label={null} symbol="plus" size={20} />
</AxoButton.Root>
size="md"
label={i18n('icu:CompositionArea--attach-plus')}
tooltip={false}
symbol="plus"
/>
</AxoDropdownMenu.Trigger>
</div>
<AxoDropdownMenu.Content>
@@ -807,12 +877,14 @@ export const CompositionArea = memo(function CompositionArea({
<>
<div className="CompositionArea__placeholder" />
<div className="CompositionArea__button-cell">
<button
type="button"
className="CompositionArea__send-button"
onClick={handleForceSend}
aria-label={i18n('icu:sendMessageToContact')}
/>
<div className={actionSlotClassName}>
<button
type="button"
className="CompositionArea__send-button"
onClick={handleForceSend}
aria-label={i18n('icu:sendMessageToContact')}
/>
</div>
</div>
</>
) : 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}
/>
)}
<div className="CompositionArea__toggle-large">
<button
type="button"
className={classNames(
'CompositionArea__toggle-large__button',
large ? 'CompositionArea__toggle-large__button--large-active' : null
)}
// This prevents the user from tabbing here
tabIndex={-1}
onClick={handleToggleLarge}
aria-label={i18n('icu:CompositionArea--expand')}
/>
</div>
{isViewOnceActive ? null : (
<div className="CompositionArea__toggle-large">
<button
type="button"
className={classNames(
'CompositionArea__toggle-large__button',
large
? 'CompositionArea__toggle-large__button--large-active'
: null
)}
onClick={handleToggleLarge}
aria-label={i18n('icu:CompositionArea--expand')}
/>
</div>
)}
<div
className={classNames(
'CompositionArea__row',
'CompositionArea__row--column'
)}
>
{quotedMessageProps && (
<div className="quote-wrapper">
<Quote
isCompose
{...quotedMessageProps}
i18n={i18n}
onClick={
quotedMessageId
? () => scrollToMessage(conversationId, quotedMessageId)
: undefined
}
onClose={() => {
setQuoteByMessageId(conversationId, undefined);
}}
/>
</div>
)}
{isViewOnceActive
? null
: quotedMessageProps && (
<div className="quote-wrapper">
<Quote
isCompose
{...quotedMessageProps}
i18n={i18n}
onClick={
quotedMessageId
? () => scrollToMessage(conversationId, quotedMessageId)
: undefined
}
onClose={() => {
setQuoteByMessageId(conversationId, undefined);
}}
/>
</div>
)}
{draftAttachments.length ? (
<div className="CompositionArea__attachment-list">
<AttachmentList
@@ -1145,32 +1245,31 @@ export const CompositionArea = memo(function CompositionArea({
) : null}
</div>
<div
className={classNames(
'CompositionArea__row',
large ? 'CompositionArea__row--padded' : null
)}
className={classNames('CompositionArea__row', {
'CompositionArea__row--padded': !isViewOnceActive && large,
})}
>
{!large ? leftHandSideButtonsFragment : null}
<div
className={classNames(
'CompositionArea__input',
large ? 'CompositionArea__input--padded' : null
)}
className={classNames('CompositionArea__input', {
'CompositionArea__input--padded': !isViewOnceActive && large,
})}
>
<CompositionInput
conversationId={conversationId}
disabled={isDisabled}
draftBodyRanges={draftBodyRanges}
draftEditMessage={draftEditMessage}
draftText={draftText}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
inputApi={inputApiRef}
isFormattingEnabled={isFormattingEnabled}
isActive={isActive}
large={large}
linkPreviewLoading={linkPreviewLoading}
linkPreviewResult={linkPreviewResult}
draftEditMessage={draftEditMessageForInput}
large={largeForInput}
linkPreviewLoading={linkPreviewLoadingForInput}
linkPreviewResult={linkPreviewResultForInput}
quotedMessageId={quotedMessageIdForInput}
onCloseLinkPreview={onCloseLinkPreview}
onDirtyChange={setDirty}
onEditorStateChange={onEditorStateChange}
@@ -1179,23 +1278,37 @@ export const CompositionArea = memo(function CompositionArea({
onTextTooLong={onTextTooLong}
ourConversationId={ourConversationId}
platform={platform}
quotedMessageId={quotedMessageId}
sendCounter={sendCounter}
shouldHidePopovers={shouldHidePopovers}
emojiSkinToneDefault={emojiSkinToneDefault ?? null}
sortedGroupMembers={sortedGroupMembers}
theme={theme}
showViewOnceButton={showViewOnceToggle}
isViewOnceActive={isViewOnceActive}
onToggleViewOnce={handleToggleViewOnce}
/>
</div>
{!large ? (
{isViewOnceActive && (
<div className="CompositionArea__button-cell">
<div className={actionSlotClassName}>
<button
type="button"
className="CompositionArea__send-button"
onClick={handleForceSend}
aria-label={i18n('icu:sendMessageToContact')}
/>
</div>
</div>
)}
{!isViewOnceActive && !large && (
<>
{!dirty ? micButtonFragment : null}
{editMessageFragment}
{attButton}
</>
) : null}
)}
</div>
{large ? (
{!isViewOnceActive && large ? (
<div
className={classNames(
'CompositionArea__row',
@@ -53,6 +53,9 @@ const useProps = (overrideProps: Partial<Props> = {}): 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 <CompositionInput {...useProps({ isFormattingEnabled: false })} />;
}
export function ViewOnceButton(): React.JSX.Element {
const [isActive, setIsActive] = React.useState(false);
const props = useProps();
return (
<CompositionInput
{...props}
showViewOnceButton
isViewOnceActive={isActive}
onToggleViewOnce={() => setIsActive(!isActive)}
/>
);
}
export function ViewOnceButtonActive(): React.JSX.Element {
const props = useProps();
return (
<CompositionInput
{...props}
showViewOnceButton
isViewOnceActive
onToggleViewOnce={action('onToggleViewOnce')}
/>
);
}
+48 -3
View File
@@ -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 (
<Manager>
<Reference>
{({ ref }) => (
<div
className={getClassName('__input')}
className={classNames(
getClassName('__input'),
showViewOnceButton && getClassName('__input--with-view-once'),
isViewOnceActive && getClassName('__input--view-once-active')
)}
data-supertab
ref={ref}
data-testid="CompositionInput"
data-enabled={disabled ? 'false' : 'true'}
data-enabled={isInputEnabled ? 'true' : 'false'}
onMouseDown={onMouseDown}
>
{draftEditMessage && (
@@ -1024,6 +1039,11 @@ export function CompositionInput(props: Props): React.ReactElement {
/>
)}
{children}
{isViewOnceActive && (
<div className={getClassName('__view-once-placeholder')}>
{i18n('icu:CompositionArea--viewOnceMediaPlaceholder')}
</div>
)}
<div
onClick={focus}
onScroll={onScroll}
@@ -1045,6 +1065,31 @@ export function CompositionInput(props: Props): React.ReactElement {
</>
)}
</div>
{showViewOnceButton && (
<div className={getClassName('__view-once-button')}>
<AxoTooltip.Root
label={i18n('icu:CompositionArea--viewOnceToggle')}
tooltipRepeatsTriggerAccessibleName
>
<button
type="button"
aria-label={i18n('icu:CompositionArea--viewOnceToggle')}
onClick={onToggleViewOnce}
className={tw(
'flex cursor-default items-center justify-center rounded-full',
'outline-border-focused not-forced-colors:outline-0 not-forced-colors:focused:outline-[2.5px]',
'forced-colors:border forced-colors:border-[ButtonBorder]'
)}
>
<AxoSymbol.Icon
size={20}
symbol={isViewOnceActive ? 'viewonce' : 'viewonce-slash'}
label={null}
/>
</button>
</AxoTooltip.Root>
</div>
)}
</div>
)}
</Reference>
@@ -187,6 +187,9 @@ export function CompositionTextArea({
linkPreviewResult={null}
// Panels appear behind this modal
shouldHidePopovers={null}
showViewOnceButton={false}
isViewOnceActive={false}
onToggleViewOnce={() => undefined}
/>
<div className="CompositionTextArea__emoji">
<FunEmojiPicker
@@ -20,6 +20,7 @@ export default {
component: MediaEditor,
args: {
getPreferredBadge: () => 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,
+88 -27
View File
@@ -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 | null>(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}
>
<FunStickerPickerButton i18n={i18n} />
</FunStickerPicker>
</div>
<div className="MediaEditor__tools-row-2">
<div className="MediaEditor__tools--input dark-theme">
<div
className={classNames(
tw('mx-1 flex items-center justify-center'),
localIsViewOnce ? tw('invisible') : null
)}
>
<FunEmojiPicker
open={emojiPickerOpen}
onOpenChange={handleEmojiPickerOpenChange}
onSelectEmoji={handleSelectEmoji}
placement="top"
theme={pickerTheme}
closeOnSelect={false}
>
<FunEmojiPickerButton i18n={i18n} />
</FunEmojiPicker>
</div>
{showMediaQualitySelector && (
<div className={tw('mx-1 flex items-center justify-center')}>
<MediaQualitySelector
conversationId=""
i18n={i18n}
isHighQuality={localIsHighQuality}
onSelectQuality={handleSelectQuality}
/>
</div>
)}
<div className="MediaEditor__tools--input">
<CompositionInput
draftText={caption}
draftBodyRanges={hydratedBodyRanges ?? null}
@@ -1348,7 +1408,7 @@ export function MediaEditor({
quotedMessageId={null}
sendCounter={0}
sortedGroupMembers={sortedGroupMembers}
theme={ThemeType.dark}
theme={pickerTheme}
// Only needed for state updates and we need to override those
conversationId={null}
// Cannot enter media editor while editing
@@ -1359,21 +1419,26 @@ export function MediaEditor({
shouldHidePopovers={null}
// link previews not displayed with media
linkPreviewResult={null}
>
<FunEmojiPicker
open={emojiPickerOpen}
onOpenChange={handleEmojiPickerOpenChange}
onSelectEmoji={handleSelectEmoji}
placement="top"
theme={ThemeType.dark}
closeOnSelect={false}
>
<FunEmojiPickerButton i18n={i18n} />
</FunEmojiPicker>
</CompositionInput>
showViewOnceButton={showViewOnceToggle}
isViewOnceActive={localIsViewOnce}
onToggleViewOnce={() => {
const newValue = !localIsViewOnce;
setLocalIsViewOnce(newValue);
if (newValue) {
setEmojiPickerOpen(false);
}
}}
/>
</div>
<Button
<AxoButton.Root
disabled={!image || isSaving || isSending}
variant="primary"
size="md"
experimentalSpinner={
isSending
? { 'aria-label': doneButtonLabel || i18n('icu:save') }
: null
}
onClick={async () => {
if (!fabricCanvas) {
return;
@@ -1438,17 +1503,13 @@ export function MediaEditor({
caption: caption !== '' ? caption : undefined,
captionBodyRanges: captionBodyRanges ?? undefined,
blurHash,
isViewOnce: localIsViewOnce,
isHighQuality: localIsHighQuality,
});
}}
theme={Theme.Dark}
variant={ButtonVariant.Primary}
>
{isSending ? (
<Spinner svgSize="small" />
) : (
doneButtonLabel || i18n('icu:save')
)}
</Button>
{doneButtonLabel || i18n('icu:save')}
</AxoButton.Root>
</div>
</>
)}
+106 -178
View File
@@ -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<HTMLElement | null>(null);
const [focusedOption, setFocusedOption] = useState<0 | 1 | undefined>(
undefined
const [open, setOpen] = useState(false);
const standardRef = useRef<HTMLButtonElement>(null);
const highRef = useRef<HTMLButtonElement>(null);
const handleOpenAutoFocus = useCallback(
(e: Event) => {
e.preventDefault();
if (isHighQuality) {
highRef.current?.focus();
} else {
standardRef.current?.focus();
}
},
[isHighQuality]
);
const buttonRef = React.useRef<HTMLButtonElement | null>(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 (
<Manager>
<Reference>
{({ ref }) => (
<button
aria-label={i18n('icu:MediaQualitySelector--button')}
className={classNames({
MediaQualitySelector__button: true,
'MediaQualitySelector__button--hq': isHighQuality,
'MediaQualitySelector__button--active': menuShowing,
})}
onClick={handleClick}
onKeyDown={handleKeyDown}
ref={refMerger(buttonRef, ref)}
type="button"
/>
)}
</Reference>
{menuShowing && popperRoot
? createPortal(
<Popper placement="top-start" strategy="fixed">
{({ ref, style, placement }) => (
<div
className="MediaQualitySelector__popper"
data-placement={placement}
ref={ref}
style={style}
>
<div className="MediaQualitySelector__title">
{i18n('icu:MediaQualitySelector--title')}
</div>
<button
aria-label={i18n(
'icu:MediaQualitySelector--standard-quality-title'
)}
className={classNames({
MediaQualitySelector__option: true,
'MediaQualitySelector__option--focused':
focusedOption === 0,
})}
type="button"
onClick={() => {
onSelectQuality(conversationId, false);
setMenuShowing(false);
}}
>
<div
className={classNames({
'MediaQualitySelector__option--checkmark': true,
'MediaQualitySelector__option--selected':
!isHighQuality,
})}
/>
<div>
<div className="MediaQualitySelector__option--title">
{i18n(
'icu:MediaQualitySelector--standard-quality-title'
)}
</div>
<div className="MediaQualitySelector__option--description">
{i18n(
'icu:MediaQualitySelector--standard-quality-description'
)}
</div>
</div>
</button>
<button
aria-label={i18n(
'icu:MediaQualitySelector--high-quality-title'
)}
className={classNames({
MediaQualitySelector__option: true,
'MediaQualitySelector__option--focused':
focusedOption === 1,
})}
type="button"
onClick={() => {
onSelectQuality(conversationId, true);
setMenuShowing(false);
}}
>
<div
className={classNames({
'MediaQualitySelector__option--checkmark': true,
'MediaQualitySelector__option--selected': isHighQuality,
})}
/>
<div>
<div className="MediaQualitySelector__option--title">
{i18n('icu:MediaQualitySelector--high-quality-title')}
</div>
<div className="MediaQualitySelector__option--description">
{i18n(
'icu:MediaQualitySelector--high-quality-description'
)}
</div>
</div>
</button>
</div>
<Popover.Root open={open} onOpenChange={setOpen}>
<Popover.Trigger asChild>
<AxoIconButton.Root
variant="borderless-secondary"
size="md"
symbol={isHighQuality ? 'hd' : 'hd-slash'}
label={i18n('icu:MediaQualitySelector--button')}
tooltip={false}
/>
</Popover.Trigger>
{open && (
<Popover.Portal>
<Popover.Content
className="MediaQualitySelector__popper"
side="top"
align="start"
sideOffset={4}
onOpenAutoFocus={handleOpenAutoFocus}
onKeyDown={handleContentKeyDown}
>
<div className="MediaQualitySelector__title">
{i18n('icu:MediaQualitySelector--title')}
</div>
<button
ref={standardRef}
aria-label={i18n(
'icu:MediaQualitySelector--standard-quality-title'
)}
</Popper>,
popperRoot
)
: null}
</Manager>
className="MediaQualitySelector__option"
type="button"
onClick={() => {
onSelectQuality(conversationId, false);
setOpen(false);
}}
>
<div
className={classNames({
'MediaQualitySelector__option--checkmark': true,
'MediaQualitySelector__option--selected': !isHighQuality,
})}
/>
<div>
<div className="MediaQualitySelector__option--title">
{i18n('icu:MediaQualitySelector--standard-quality-title')}
</div>
<div className="MediaQualitySelector__option--description">
{i18n(
'icu:MediaQualitySelector--standard-quality-description'
)}
</div>
</div>
</button>
<button
ref={highRef}
aria-label={i18n('icu:MediaQualitySelector--high-quality-title')}
className="MediaQualitySelector__option"
type="button"
onClick={() => {
onSelectQuality(conversationId, true);
setOpen(false);
}}
>
<div
className={classNames({
'MediaQualitySelector__option--checkmark': true,
'MediaQualitySelector__option--selected': isHighQuality,
})}
/>
<div>
<div className="MediaQualitySelector__option--title">
{i18n('icu:MediaQualitySelector--high-quality-title')}
</div>
<div className="MediaQualitySelector__option--description">
{i18n('icu:MediaQualitySelector--high-quality-description')}
</div>
</div>
</button>
</Popover.Content>
</Popover.Portal>
)}
</Popover.Root>
);
}
@@ -294,6 +294,9 @@ export function StoryViewsNRepliesModal({
large={null}
shouldHidePopovers={null}
linkPreviewResult={null}
showViewOnceButton={false}
isViewOnceActive={false}
onToggleViewOnce={noop}
>
<FunEmojiPicker
open={emojiPickerOpen}
@@ -275,6 +275,10 @@ function getToast(toastType: ToastType): AnyToast {
group: 'Hike Group 🏔',
},
};
case ToastType.ViewOnceDisabled:
return { toastType: ToastType.ViewOnceDisabled };
case ToastType.ViewOnceEnabled:
return { toastType: ToastType.ViewOnceEnabled };
case ToastType.VoiceNoteLimit:
return { toastType: ToastType.VoiceNoteLimit };
case ToastType.VoiceNoteMustBeTheOnlyAttachment:
+16
View File
@@ -967,6 +967,22 @@ export function renderToast({
);
}
if (toastType === ToastType.ViewOnceEnabled) {
return (
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
{i18n('icu:Toast--viewOnceEnabled')}
</Toast>
);
}
if (toastType === ToastType.ViewOnceDisabled) {
return (
<Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
{i18n('icu:Toast--viewOnceDisabled')}
</Toast>
);
}
throw missingCaseError(toastType);
}
+1
View File
@@ -382,6 +382,7 @@ export type ConversationAttributesType = {
draftChanged?: boolean;
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
draftBodyRanges?: DraftBodyRanges;
draftIsViewOnce?: boolean;
draftTimestamp?: number | null;
hideStory?: boolean;
inbox_position?: number;
+1
View File
@@ -4080,6 +4080,7 @@ export class ConversationModel {
draft: '',
draftEditMessage: undefined,
draftBodyRanges: [],
draftIsViewOnce: false,
draftTimestamp: null,
quotedMessageId: undefined,
};
+119 -3
View File
@@ -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<AttachmentDraftType>;
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<AttachmentDraftType>
): ThunkAction<void, RootStateType, unknown, ReplaceAttachmentsActionType> {
): 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:
+10
View File
@@ -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<Promise<void>> = [];
@@ -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) {
+7
View File
@@ -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
);
@@ -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}
+4
View File
@@ -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 };
@@ -15,6 +15,7 @@ export async function clearConversationDraftAttachments(
conversation.set({
draftAttachments: [],
draftIsViewOnce: false,
draftChanged: true,
});
+16
View File
@@ -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<AttachmentDraftType>,
hasQuote: boolean
): boolean {
return Boolean(
attachments.length === 1 &&
(isImageAttachment(attachments[0]) || isVideoAttachment(attachments[0])) &&
!hasQuote
);
}