mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-04-17 23:34:14 +01:00
Send View Once Messages
This commit is contained in:
@@ -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 />;
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
ts/model-types.d.ts
vendored
1
ts/model-types.d.ts
vendored
@@ -382,6 +382,7 @@ export type ConversationAttributesType = {
|
||||
draftChanged?: boolean;
|
||||
draftAttachments?: ReadonlyArray<AttachmentDraftType>;
|
||||
draftBodyRanges?: DraftBodyRanges;
|
||||
draftIsViewOnce?: boolean;
|
||||
draftTimestamp?: number | null;
|
||||
hideStory?: boolean;
|
||||
inbox_position?: number;
|
||||
|
||||
@@ -4080,6 +4080,7 @@ export class ConversationModel {
|
||||
draft: '',
|
||||
draftEditMessage: undefined,
|
||||
draftBodyRanges: [],
|
||||
draftIsViewOnce: false,
|
||||
draftTimestamp: null,
|
||||
quotedMessageId: undefined,
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
ts/util/viewOnceEligibility.std.ts
Normal file
16
ts/util/viewOnceEligibility.std.ts
Normal 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user