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

View File

@@ -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 />;

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',

View File

@@ -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')}
/>
);
}

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>

View File

@@ -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

View File

@@ -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,

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>
</>
)}

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>
);
}

View File

@@ -294,6 +294,9 @@ export function StoryViewsNRepliesModal({
large={null}
shouldHidePopovers={null}
linkPreviewResult={null}
showViewOnceButton={false}
isViewOnceActive={false}
onToggleViewOnce={noop}
>
<FunEmojiPicker
open={emojiPickerOpen}

View File

@@ -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:

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);
}