diff --git a/src/api/gramjs/apiBuilders/messageContent.ts b/src/api/gramjs/apiBuilders/messageContent.ts index 5906e8ca7..db7d040bb 100644 --- a/src/api/gramjs/apiBuilders/messageContent.ts +++ b/src/api/gramjs/apiBuilders/messageContent.ts @@ -78,10 +78,19 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): MediaC if (isExpiredVoice) { return { isExpiredVoice }; } + const isExpiredRoundVideo = isExpiredRoundVideoMessage(media); + if (isExpiredRoundVideo) { + return { isExpiredRoundVideo }; + } const voice = buildVoice(media); if (voice) return { voice, ttlSeconds }; + if ('round' in media && media.round) { + const video = buildVideo(media); + if (video) return { video, ttlSeconds }; + } + // Other disappearing media types are not supported if (ttlSeconds !== undefined) { return undefined; @@ -270,6 +279,13 @@ function isExpiredVoiceMessage(media: GramJs.TypeMessageMedia): MediaContent['is return !media.document && media.voice; } +function isExpiredRoundVideoMessage(media: GramJs.TypeMessageMedia): MediaContent['isExpiredRoundVideo'] { + if (!(media instanceof GramJs.MessageMediaDocument)) { + return false; + } + return !media.document && media.round; +} + function buildVoice(media: GramJs.TypeMessageMedia): ApiVoice | undefined { if ( !(media instanceof GramJs.MessageMediaDocument) diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 893e0c445..fac30b43c 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -485,8 +485,9 @@ export type MediaContent = { storyData?: ApiMessageStoryData; giveaway?: ApiGiveaway; giveawayResults?: ApiGiveawayResults; - ttlSeconds?: number; isExpiredVoice?: boolean; + isExpiredRoundVideo?: boolean; + ttlSeconds?: number; }; export interface ApiMessage { diff --git a/src/components/common/Audio.tsx b/src/components/common/Audio.tsx index 226260581..ad09c9610 100644 --- a/src/components/common/Audio.tsx +++ b/src/components/common/Audio.tsx @@ -128,7 +128,7 @@ const Audio: FC = ({ const coverHash = getMessageMediaHash(message, 'pictogram'); const coverBlobUrl = useMedia(coverHash, false, ApiMediaFormat.BlobUrl); const hasTtl = hasMessageTtl(message); - const isOneTimeModalOrigin = origin === AudioOrigin.OneTimeModal; + const isInOneTimeModal = origin === AudioOrigin.OneTimeModal; const trackType = isVoice ? (hasTtl ? 'oneTimeVoice' : 'voice') : 'audio'; const mediaData = useMedia( @@ -156,7 +156,7 @@ const Audio: FC = ({ isBuffered, bufferedRanges, bufferingHandlers, checkBuffering, } = useBuffering(); - const noReset = isOneTimeModalOrigin; + const noReset = isInOneTimeModal; const { isPlaying, playProgress, playPause, setCurrentTime, duration, } = useAudioPlayer( @@ -179,7 +179,7 @@ const Audio: FC = ({ const reversePlayProgress = 1 - playProgress; const isOwn = isOwnMessage(message); - const isReverse = hasTtl && isOneTimeModalOrigin; + const isReverse = hasTtl && isInOneTimeModal; const waveformCanvasRef = useWaveformCanvas( theme, @@ -279,14 +279,14 @@ const Audio: FC = ({ }); useEffect(() => { - if (!seekerRef.current || !withSeekline || isOneTimeModalOrigin) return undefined; + if (!seekerRef.current || !withSeekline || isInOneTimeModal) return undefined; return captureEvents(seekerRef.current, { onCapture: handleStartSeek, onRelease: handleStopSeek, onClick: handleStopSeek, onDrag: handleSeek, }); - }, [withSeekline, handleStartSeek, handleSeek, handleStopSeek, isOneTimeModalOrigin]); + }, [withSeekline, handleStartSeek, handleSeek, handleStopSeek, isInOneTimeModal]); function renderFirstLine() { if (isVoice) { @@ -323,7 +323,7 @@ const Audio: FC = ({ const fullClassName = buildClassName( 'Audio', className, - isOneTimeModalOrigin && 'non-interactive', + isInOneTimeModal && 'non-interactive', origin === AudioOrigin.Inline && 'inline', isOwn && origin === AudioOrigin.Inline && 'own', (origin === AudioOrigin.Search || origin === AudioOrigin.SharedMedia) && 'bigger', @@ -383,11 +383,11 @@ const Audio: FC = ({ onClick={handleButtonClick} isRtl={lang.isRtl} backgroundImage={coverBlobUrl} - nonInteractive={isOneTimeModalOrigin} + nonInteractive={isInOneTimeModal} > - {!isOneTimeModalOrigin && } - {!isOneTimeModalOrigin && } - {isOneTimeModalOrigin && ( + {!isInOneTimeModal && } + {!isInOneTimeModal && } + {isInOneTimeModal && ( = ({ /> )} - {hasTtl && !isOneTimeModalOrigin && ( + {hasTtl && !isInOneTimeModal && ( )} @@ -424,7 +424,7 @@ const Audio: FC = ({ /> )} - {isOneTimeModalOrigin && !shouldRenderSpinner && ( + {isInOneTimeModal && !shouldRenderSpinner && (
= ({ onDateClick ? handleDateClick : undefined, )} {origin === AudioOrigin.SharedMedia && (voice || video) && renderWithTitle()} - {(origin === AudioOrigin.Inline || isOneTimeModalOrigin) && voice && ( + {(origin === AudioOrigin.Inline || isInOneTimeModal) && voice && ( renderVoice( voice, seekerRef, diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index af49c4457..4522b8390 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -964,6 +964,7 @@ const Message: FC = ({ metaPosition === 'in-text' && 'with-meta', outgoingStatus && 'with-outgoing-icon', ); + const shouldReadMedia = !hasTtl || !isOwn || isChatWithSelf; return (
@@ -1086,6 +1087,7 @@ const Message: FC = ({ observeIntersection={observeIntersectionForLoading} canAutoLoad={canAutoLoadMedia} isDownloading={isDownloading} + onReadMedia={shouldReadMedia ? handleReadMedia : undefined} /> )} {!isAlbum && video && !video.isRound && ( @@ -1115,7 +1117,7 @@ const Message: FC = ({ isSelected={isSelected} noAvatars={noAvatars} onPlay={handleAudioPlay} - onReadMedia={voice && (!isOwn || isChatWithSelf || (isOwn && !hasTtl)) ? handleReadMedia : undefined} + onReadMedia={voice && shouldReadMedia ? handleReadMedia : undefined} onCancelUpload={handleCancelUpload} isDownloading={isDownloading} isTranscribing={isTranscribing} diff --git a/src/components/middle/message/RoundVideo.scss b/src/components/middle/message/RoundVideo.scss index d6245234c..20de74465 100644 --- a/src/components/middle/message/RoundVideo.scss +++ b/src/components/middle/message/RoundVideo.scss @@ -4,15 +4,24 @@ height: 15rem; cursor: var(--custom-cursor, pointer); + &.non-interactive { + pointer-events: none; + } + .video-wrapper { position: absolute; left: 0; top: 0; + width: 100%; + height: 100%; border-radius: 50%; overflow: hidden; + display: flex; + justify-content: center; + align-items: center; } - canvas { + .thumbnail { border-radius: 50%; } @@ -38,4 +47,24 @@ video::-webkit-media-controls-start-playback-button { display: none; } + + .play-wrapper { + position: absolute; + + .icon-play { + margin: 0 auto; + } + + .icon-view-once { + position: absolute; + background-color: rgba(0, 0, 0, 0.75); + padding: 0.125rem; + left: 1.625rem; + bottom: 0; + font-size: 1rem; + border-radius: 50%; + color: var(--color-white); + z-index: var(--z-badge); + } + } } diff --git a/src/components/middle/message/RoundVideo.tsx b/src/components/middle/message/RoundVideo.tsx index 466e16702..3c4cdbd64 100644 --- a/src/components/middle/message/RoundVideo.tsx +++ b/src/components/middle/message/RoundVideo.tsx @@ -11,7 +11,9 @@ import type { ApiMessage } from '../../../api/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import { ApiMediaFormat } from '../../../api/types'; -import { getMessageMediaFormat, getMessageMediaHash, getMessageMediaThumbDataUri } from '../../../global/helpers'; +import { + getMessageMediaFormat, getMessageMediaHash, getMessageMediaThumbDataUri, hasMessageTtl, +} from '../../../global/helpers'; import { stopCurrentAudio } from '../../../util/audioPlayer'; import buildClassName from '../../../util/buildClassName'; import { formatMediaDuration } from '../../../util/dateFormat'; @@ -29,6 +31,9 @@ import useShowTransition from '../../../hooks/useShowTransition'; import useSignal from '../../../hooks/useSignal'; import useBlurredMediaThumbRef from './hooks/useBlurredMediaThumbRef'; +import Icon from '../../common/Icon'; +import MediaSpoiler from '../../common/MediaSpoiler'; +import Button from '../../ui/Button'; import OptimizedVideo from '../../ui/OptimizedVideo'; import ProgressSpinner from '../../ui/ProgressSpinner'; @@ -36,9 +41,13 @@ import './RoundVideo.scss'; type OwnProps = { message: ApiMessage; - observeIntersection: ObserveFn; + className?: string; canAutoLoad?: boolean; isDownloading?: boolean; + origin?: 'oneTimeModal'; + observeIntersection?: ObserveFn; + onStop?: NoneToVoidFunction; + onReadMedia?: NoneToVoidFunction; }; const PROGRESS_CENTER = ROUND_VIDEO_DIMENSIONS_PX / 2; @@ -50,9 +59,13 @@ let stopPrevious: NoneToVoidFunction; const RoundVideo: FC = ({ message, - observeIntersection, + className, canAutoLoad, isDownloading, + origin, + observeIntersection, + onStop, + onReadMedia, }) => { // eslint-disable-next-line no-null/no-null const ref = useRef(null); @@ -63,6 +76,8 @@ const RoundVideo: FC = ({ const video = message.content.video!; + const { cancelMessageMediaDownload, openOneTimeMediaModal } = getActions(); + const isIntersecting = useIsIntersecting(ref, observeIntersection); const [isLoadAllowed, setIsLoadAllowed] = useState(canAutoLoad); @@ -80,11 +95,14 @@ const RoundVideo: FC = ({ ); const [isPlayerReady, markPlayerReady] = useFlag(); + const hasTtl = hasMessageTtl(message); + const isInOneTimeModal = origin === 'oneTimeModal'; + const shouldRenderSpoiler = hasTtl && !isInOneTimeModal; const hasThumb = Boolean(getMessageMediaThumbDataUri(message)); - const noThumb = !hasThumb || isPlayerReady; + const noThumb = !hasThumb || isPlayerReady || shouldRenderSpoiler; const thumbRef = useBlurredMediaThumbRef(message, noThumb); const thumbClassNames = useMediaTransition(!noThumb); - + const thumbDataUri = getMessageMediaThumbDataUri(message); const isTransferring = (isLoadAllowed && !isPlayerReady) || isDownloading; const wasLoadDisabled = usePrevious(isLoadAllowed) === false; @@ -133,18 +151,7 @@ const RoundVideo: FC = ({ stopPrevious = stopPlaying; }); - const handleClick = useLastCallback(() => { - if (!mediaData) { - setIsLoadAllowed((isAllowed) => !isAllowed); - - return; - } - - if (isDownloading) { - getActions().cancelMessageMediaDownload({ message }); - return; - } - + const tooglePlaying = useLastCallback(() => { const playerEl = playerRef.current!; if (isActivated) { if (playerEl.paused) { @@ -160,25 +167,77 @@ const RoundVideo: FC = ({ playerEl.currentTime = 0; safePlay(playerEl); stopCurrentAudio(); - setIsActivated(true); } }); + useEffect(() => { + if (!isInOneTimeModal) { + return; + } + tooglePlaying(); + }, [isInOneTimeModal]); + + const handleClick = useLastCallback(() => { + if (!mediaData) { + setIsLoadAllowed((isAllowed) => !isAllowed); + + return; + } + + if (isDownloading) { + cancelMessageMediaDownload({ message }); + return; + } + + if (hasTtl && !isInOneTimeModal) { + openOneTimeMediaModal({ message }); + onReadMedia?.(); + return; + } + + tooglePlaying(); + }); + const handleTimeUpdate = useLastCallback((e: React.UIEvent) => { const playerEl = e.currentTarget; - setProgress(playerEl.currentTime / playerEl.duration); }); + function renderPlayWrapper() { + return ( +
+ + +
+ ); + } + return (
{mediaData && (
+ {shouldRenderSpoiler && ( + + )} = ({ className="full-media" width={ROUND_VIDEO_DIMENSIONS_PX} height={ROUND_VIDEO_DIMENSIONS_PX} - autoPlay + autoPlay={!shouldRenderSpoiler} disablePictureInPicture muted={!isActivated} loop={!isActivated} playsInline - onEnded={isActivated ? stopPlaying : undefined} + onEnded={isActivated ? onStop ?? stopPlaying : undefined} onTimeUpdate={isActivated ? handleTimeUpdate : undefined} onReady={markPlayerReady} />
)} - + {!shouldRenderSpoiler && ( + + )}
{isActivated && ( @@ -223,13 +284,16 @@ const RoundVideo: FC = ({
)} + {shouldRenderSpoiler && !shouldSpinnerRender && renderPlayWrapper()} {!mediaData && !isLoadAllowed && ( )} -
- {isActivated ? formatMediaDuration(playerRef.current!.currentTime) : formatMediaDuration(video.duration)} - {(!isActivated || playerRef.current!.paused) && } -
+ {!isInOneTimeModal && ( +
+ {isActivated ? formatMediaDuration(playerRef.current!.currentTime) : formatMediaDuration(video.duration)} + {(!isActivated || playerRef.current!.paused) && } +
+ )}
); }; diff --git a/src/components/modals/oneTimeMedia/OneTimeMediaModal.module.scss b/src/components/modals/oneTimeMedia/OneTimeMediaModal.module.scss index 002e2863f..dc87964b7 100644 --- a/src/components/modals/oneTimeMedia/OneTimeMediaModal.module.scss +++ b/src/components/modals/oneTimeMedia/OneTimeMediaModal.module.scss @@ -10,7 +10,7 @@ flex-direction: column; backdrop-filter: blur(2rem); animation: fade-in-opacity 0.3s ease; - background-color: rgba(0, 0, 0, 0.25); + background-color: rgba(0, 0, 0, 0.5); z-index: var(--z-modal-confirm); align-items: center; transition: opacity 0.3s ease; @@ -20,10 +20,14 @@ } } -.main { - background-color: var(--color-background); +.voice { padding: 0.6875rem; border-radius: 1rem; + background-color: var(--color-background); +} + +.video { + background: transparent; } .footer { diff --git a/src/components/modals/oneTimeMedia/OneTimeMediaModal.tsx b/src/components/modals/oneTimeMedia/OneTimeMediaModal.tsx index 20fd968af..cfef01749 100644 --- a/src/components/modals/oneTimeMedia/OneTimeMediaModal.tsx +++ b/src/components/modals/oneTimeMedia/OneTimeMediaModal.tsx @@ -14,6 +14,7 @@ import useLastCallback from '../../../hooks/useLastCallback'; import useShowTransition from '../../../hooks/useShowTransition'; import Audio from '../../common/Audio'; +import RoundVideo from '../../middle/message/RoundVideo'; import Button from '../../ui/Button'; import styles from './OneTimeMediaModal.module.scss'; @@ -54,9 +55,14 @@ const OneTimeMediaModal = ({ const closeBtnTitle = isOwn ? lang('Chat.Voice.Single.Close') : lang('Chat.Voice.Single.DeleteAndClose'); function renderMedia() { - if (message?.content?.voice) { + if (!message?.content) { + return undefined; + } + const { voice, video } = message.content; + if (voice) { return (