diff --git a/src/api/gramjs/apiBuilders/messageContent.ts b/src/api/gramjs/apiBuilders/messageContent.ts index 6154d84fe..01f18eb95 100644 --- a/src/api/gramjs/apiBuilders/messageContent.ts +++ b/src/api/gramjs/apiBuilders/messageContent.ts @@ -29,6 +29,7 @@ import type { import type { UniversalMessage } from './messages'; import { SUPPORTED_PHOTO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, VIDEO_WEBM_TYPE } from '../../../config'; +import { addTimestampEntities } from '../../../util/dates/timestamp'; import { generateWaveform } from '../../../util/generateWaveform'; import { pick } from '../../../util/iteratees'; import { @@ -64,9 +65,11 @@ export function buildMessageContent( if (mtpMessage.message && !hasUnsupportedMedia && !content.sticker && !content.pollId && !content.contact && !content.video?.isRound) { + const text = buildMessageTextContent(mtpMessage.message, mtpMessage.entities); + const textWithTimestamps = addTimestampEntities(text); content = { ...content, - text: buildMessageTextContent(mtpMessage.message, mtpMessage.entities), + text: textWithTimestamps, }; } @@ -197,11 +200,16 @@ function buildPhoto(media: GramJs.TypeMessageMedia): ApiPhoto | undefined { return buildApiPhoto(media.photo, media.spoiler); } -export function buildVideoFromDocument(document: GramJs.Document, isSpoiler?: boolean): ApiVideo | undefined { +export function buildVideoFromDocument(document: GramJs.Document, params?: { + isSpoiler?: boolean; + timestamp?: number; +}): ApiVideo | undefined { if (document instanceof GramJs.DocumentEmpty) { return undefined; } + const { isSpoiler, timestamp } = params || {}; + const { id, mimeType, thumbs, size, videoThumbs, attributes, } = document; @@ -249,6 +257,7 @@ export function buildVideoFromDocument(document: GramJs.Document, isSpoiler?: bo thumbnail: buildApiThumbnailFromStripped(thumbs), size: size.toJSNumber(), isSpoiler, + timestamp, hasVideoPreview, previewPhotoSizes, waveform, @@ -299,7 +308,7 @@ function buildVideo(media: GramJs.TypeMessageMedia): ApiVideo | undefined { return undefined; } - return buildVideoFromDocument(media.document, media.spoiler); + return buildVideoFromDocument(media.document, { isSpoiler: media.spoiler, timestamp: media.videoTimestamp }); } function buildAltVideos(media: GramJs.TypeMessageMedia): ApiVideo[] | undefined { @@ -309,7 +318,7 @@ function buildAltVideos(media: GramJs.TypeMessageMedia): ApiVideo[] | undefined const altVideos = media.altDocuments.filter((d): d is GramJs.Document => ( d instanceof GramJs.Document && d.mimeType.startsWith('video') - )).map((alt) => buildVideoFromDocument(alt, media.spoiler)) + )).map((alt) => buildVideoFromDocument(alt, { isSpoiler: media.spoiler })) .filter(Boolean); if (!altVideos.length) { return undefined; diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index b7d92342f..37383b2da 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -41,6 +41,7 @@ import { SUPPORTED_VIDEO_CONTENT_TYPES, } from '../../../config'; import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage'; +import { addTimestampEntities } from '../../../util/dates/timestamp'; import { omitUndefined, pick } from '../../../util/iteratees'; import { getServerTime, getServerTimeOffset } from '../../../util/serverTime'; import { interpolateArray } from '../../../util/waveform'; @@ -544,14 +545,13 @@ export function buildLocalMessage( const localPoll = poll && buildNewPoll(poll, localId); + const formattedText = text ? addTimestampEntities({ text, entities }) : undefined; + const message = { id: localId, chatId: chat.id, content: omitUndefined({ - text: text ? { - text, - entities, - } : undefined, + text: formattedText, ...media, sticker, video: gif || media?.video, @@ -628,11 +628,12 @@ export function buildLocalForwardedMessage({ text: content.text.text, entities: content.text.entities.filter((entity) => entity.type !== ApiMessageEntityTypes.CustomEmoji), } : content.text; + const textWithTimestamps = strippedText && addTimestampEntities(strippedText); const emojiOnlyCount = getEmojiOnlyCountForMessage(content, groupedId); const updatedContent = { ...content, - text: !shouldHideText ? strippedText : undefined, + text: !shouldHideText ? textWithTimestamps : undefined, }; // TODO Prepare reply info between forwarded messages locally, to prevent height jumps diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 13f887ae1..2db8f464a 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -113,6 +113,7 @@ export interface ApiVideo { size: number; noSound?: boolean; waveform?: number[]; + timestamp?: number; } export interface ApiAudio { @@ -410,7 +411,7 @@ export type ApiMessageEntityDefault = { type: Exclude< `${ApiMessageEntityTypes}`, `${ApiMessageEntityTypes.Pre}` | `${ApiMessageEntityTypes.TextUrl}` | `${ApiMessageEntityTypes.MentionName}` | - `${ApiMessageEntityTypes.CustomEmoji}` | `${ApiMessageEntityTypes.Blockquote}` + `${ApiMessageEntityTypes.CustomEmoji}` | `${ApiMessageEntityTypes.Blockquote}` | `${ApiMessageEntityTypes.Timestamp}` >; offset: number; length: number; @@ -451,8 +452,15 @@ export type ApiMessageEntityCustomEmoji = { documentId: string; }; +export type ApiMessageEntityTimestamp = { + type: ApiMessageEntityTypes.Timestamp; + offset: number; + length: number; + timestamp: number; +}; + export type ApiMessageEntity = ApiMessageEntityDefault | ApiMessageEntityPre | ApiMessageEntityTextUrl | -ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote; +ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote | ApiMessageEntityTimestamp; export enum ApiMessageEntityTypes { Bold = 'MessageEntityBold', @@ -473,6 +481,7 @@ export enum ApiMessageEntityTypes { Underline = 'MessageEntityUnderline', Spoiler = 'MessageEntitySpoiler', CustomEmoji = 'MessageEntityCustomEmoji', + Timestamp = 'MessageEntityTimestamp', Unknown = 'MessageEntityUnknown', } diff --git a/src/components/common/MessageText.tsx b/src/components/common/MessageText.tsx index a5d305749..824a96189 100644 --- a/src/components/common/MessageText.tsx +++ b/src/components/common/MessageText.tsx @@ -4,6 +4,7 @@ import React, { import type { ApiFormattedText, ApiMessage, ApiStory } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; +import type { ThreadId } from '../../types'; import { ApiMessageEntityTypes } from '../../api/types'; import { CONTENT_NOT_SUPPORTED } from '../../config'; @@ -16,6 +17,7 @@ import useUniqueId from '../../hooks/useUniqueId'; interface OwnProps { messageOrStory: ApiMessage | ApiStory; + threadId?: ThreadId; translatedText?: ApiFormattedText; isForAnimation?: boolean; emojiSize?: number; @@ -32,6 +34,7 @@ interface OwnProps { focusedQuote?: string; isInSelectMode?: boolean; canBeEmpty?: boolean; + maxTimestamp?: number; } const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3; @@ -54,6 +57,8 @@ function MessageText({ focusedQuote, isInSelectMode, canBeEmpty, + maxTimestamp, + threadId, }: OwnProps) { // eslint-disable-next-line no-null/no-null const sharedCanvasRef = useRef(null); @@ -109,6 +114,10 @@ function MessageText({ forcePlayback, focusedQuote, isInSelectMode, + maxTimestamp, + chatId: 'chatId' in messageOrStory ? messageOrStory.chatId : undefined, + messageId: messageOrStory.id, + threadId, }), ].flat().filter(Boolean)} diff --git a/src/components/common/helpers/renderMessageText.ts b/src/components/common/helpers/renderMessageText.ts index 738df237b..d4e91c2c1 100644 --- a/src/components/common/helpers/renderMessageText.ts +++ b/src/components/common/helpers/renderMessageText.ts @@ -2,7 +2,7 @@ import { getGlobal } from '../../../global'; import type { ApiMessage, ApiSponsoredMessage } from '../../../api/types'; import type { OldLangFn } from '../../../hooks/useOldLang'; -import type { TextPart } from '../../../types'; +import type { TextPart, ThreadId } from '../../../types'; import { ApiMessageEntityTypes } from '../../../api/types'; import { @@ -30,6 +30,8 @@ export function renderMessageText({ forcePlayback, shouldRenderAsHtml, isForMediaViewer, + threadId, + maxTimestamp, } : { message: ApiMessage | ApiSponsoredMessage; highlight?: string; @@ -40,6 +42,8 @@ export function renderMessageText({ forcePlayback?: boolean; shouldRenderAsHtml?: boolean; isForMediaViewer?: boolean; + threadId?: ThreadId; + maxTimestamp?: number; }) { const { text, entities } = message.content.text || {}; @@ -60,6 +64,10 @@ export function renderMessageText({ asPreview, isProtected, forcePlayback, + messageId: 'id' in message ? message.id : undefined, + chatId: message.chatId, + threadId, + maxTimestamp, }); } diff --git a/src/components/common/helpers/renderTextWithEntities.tsx b/src/components/common/helpers/renderTextWithEntities.tsx index 6e86fed0e..b10b39dab 100644 --- a/src/components/common/helpers/renderTextWithEntities.tsx +++ b/src/components/common/helpers/renderTextWithEntities.tsx @@ -3,7 +3,7 @@ import { getActions } from '../../../global'; import type { ApiFormattedText, ApiMessageEntity } from '../../../api/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; -import type { TextPart } from '../../../types'; +import type { TextPart, ThreadId } from '../../../types'; import type { TextFilter } from './renderText'; import { ApiMessageEntityTypes } from '../../../api/types'; @@ -48,6 +48,10 @@ export function renderTextWithEntities({ noCustomEmojiPlayback, focusedQuote, isInSelectMode, + chatId, + messageId, + threadId, + maxTimestamp, }: { text: string; entities?: ApiMessageEntity[]; @@ -67,6 +71,10 @@ export function renderTextWithEntities({ noCustomEmojiPlayback?: boolean; focusedQuote?: string; isInSelectMode?: boolean; + chatId?: string; + messageId?: number; + threadId?: ThreadId; + maxTimestamp?: number; }) { if (!entities?.length) { return renderMessagePart({ @@ -172,6 +180,10 @@ export function renderTextWithEntities({ forcePlayback, noCustomEmojiPlayback, isInSelectMode, + chatId, + messageId, + threadId, + maxTimestamp, }); if (Array.isArray(newEntity)) { @@ -383,6 +395,10 @@ function processEntity({ forcePlayback, noCustomEmojiPlayback, isInSelectMode, + chatId, + messageId, + threadId, + maxTimestamp, } : { entity: ApiMessageEntity; entityContent: TextPart; @@ -402,6 +418,10 @@ function processEntity({ forcePlayback?: boolean; noCustomEmojiPlayback?: boolean; isInSelectMode?: boolean; + chatId?: string; + messageId?: number; + threadId?: ThreadId; + maxTimestamp?: number; }) { const entityText = typeof entityContent === 'string' && entityContent; const renderedContent = nestedEntityContent.length ? nestedEntityContent : entityContent; @@ -559,6 +579,21 @@ function processEntity({ ); case ApiMessageEntityTypes.Underline: return {renderNestedMessagePart()}; + case ApiMessageEntityTypes.Timestamp: + if (!chatId || !messageId || !maxTimestamp || entity.timestamp > maxTimestamp) { + return renderNestedMessagePart(); + } + + return ( + handleTimecodeClick(chatId, messageId, threadId, entity.timestamp)} + className="text-entity-link" + dir="auto" + data-entity-type={entity.type} + > + {renderNestedMessagePart()} + + ); case ApiMessageEntityTypes.Spoiler: return {renderNestedMessagePart()}; case ApiMessageEntityTypes.CustomEmoji: @@ -677,3 +712,11 @@ function handleCodeClick(e: React.MouseEvent) { message: oldTranslate('TextCopied'), }); } + +function handleTimecodeClick( + chatId: string, messageId: number, threadId: ThreadId | undefined, timestamp: number, +) { + getActions().openMediaFromTimestamp({ + chatId, messageId, threadId, timestamp, + }); +} diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index 08b314fe1..bd6500df6 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -245,7 +245,9 @@ const MediaViewer = ({ const handleClose = useLastCallback(() => closeMediaViewer()); - const handleFooterClick = useLastCallback(() => { + const handleFooterClick = useLastCallback((e: React.MouseEvent) => { + if (e.target instanceof HTMLElement && e.target.closest('a')) return; // Prevent closing on timestamp click + handleClose(); if (!chatId || !messageId) return; diff --git a/src/components/mediaViewer/MediaViewerContent.tsx b/src/components/mediaViewer/MediaViewerContent.tsx index 1dc8af230..9ed1789e8 100644 --- a/src/components/mediaViewer/MediaViewerContent.tsx +++ b/src/components/mediaViewer/MediaViewerContent.tsx @@ -1,14 +1,15 @@ import React, { memo } from '../../lib/teact/teact'; -import { withGlobal } from '../../global'; +import { getActions, withGlobal } from '../../global'; import type { ApiDimensions, ApiMessage, ApiSponsoredMessage, } from '../../api/types'; -import type { MediaViewerOrigin } from '../../types'; +import type { MediaViewerOrigin, ThreadId } from '../../types'; import type { MediaViewerItem } from './helpers/getViewableMedia'; +import { MEDIA_TIMESTAMP_SAVE_MINIMUM_DURATION } from '../../config'; import { - selectIsMessageProtected, selectTabState, + selectIsMessageProtected, selectMessageTimestampableDuration, selectTabState, } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import stopEvent from '../../util/stopEvent'; @@ -18,8 +19,11 @@ import { renderMessageText } from '../common/helpers/renderMessageText'; import getViewableMedia from './helpers/getViewableMedia'; import useAppLayout from '../../hooks/useAppLayout'; +import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal'; import useLastCallback from '../../hooks/useLastCallback'; import useOldLang from '../../hooks/useOldLang'; +import { useSignalEffect } from '../../hooks/useSignalEffect'; +import useThrottledCallback from '../../hooks/useThrottledCallback'; import useControlsSignal from './hooks/useControlsSignal'; import { useMediaProps } from './hooks/useMediaProps'; @@ -35,7 +39,7 @@ type OwnProps = { withAnimation?: boolean; isMoving?: boolean; onClose: () => void; - onFooterClick: () => void; + onFooterClick: (e: React.MouseEvent) => void; handleSponsoredClick: () => void; }; @@ -47,10 +51,14 @@ type StateProps = { isMuted: boolean; isHidden?: boolean; playbackRate: number; + threadId?: ThreadId; + timestamp?: number; + maxTimestamp?: number; }; const ANIMATION_DURATION = 350; const MOBILE_VERSION_CONTROL_WIDTH = 350; +const PLAYBACK_SAVE_INTERVAL = 1000; const MediaViewerContent = ({ item, @@ -64,10 +72,15 @@ const MediaViewerContent = ({ isMuted, isHidden, isMoving, + threadId, + timestamp, + maxTimestamp, onClose, onFooterClick, handleSponsoredClick, }: OwnProps & StateProps) => { + const { updateLastPlaybackTimestamp } = getActions(); + const lang = useOldLang(); const isAvatar = item.type === 'avatar'; @@ -90,6 +103,7 @@ const MediaViewerContent = ({ }); const [, toggleControls] = useControlsSignal(); + const [getCurrentTime] = useCurrentTimeSignal(); const isOpen = Boolean(media); const { isMobile } = useAppLayout(); @@ -98,6 +112,21 @@ const MediaViewerContent = ({ toggleControls(true); }); + const updatePlaybackTimestamp = useThrottledCallback(() => { + if (!isActive || !textMessage || media?.mediaType !== 'video') return; + if (media.duration < MEDIA_TIMESTAMP_SAVE_MINIMUM_DURATION) return; + + const message = 'id' in textMessage ? textMessage : undefined; + const currentTime = getCurrentTime(); + if (!currentTime || !message || message.isInAlbum) return; + + // Reset timestamp if we are close to the end of the video + const newTimestamp = media.duration - currentTime > PLAYBACK_SAVE_INTERVAL / 1000 ? currentTime : undefined; + updateLastPlaybackTimestamp({ chatId: message.chatId, messageId: message.id, timestamp: newTimestamp }); + }, [getCurrentTime, isActive, media, textMessage], PLAYBACK_SAVE_INTERVAL); + + useSignalEffect(updatePlaybackTimestamp, [getCurrentTime]); + if (!media) return undefined; if (item.type === 'avatar') { @@ -143,7 +172,9 @@ const MediaViewerContent = ({ const textParts = textMessage && (textMessage.content.action?.type === 'suggestProfilePhoto' ? lang('Conversation.SuggestedPhotoTitle') - : renderMessageText({ message: textMessage, forcePlayback: true, isForMediaViewer: true })); + : renderMessageText({ + message: textMessage, maxTimestamp, threadId, forcePlayback: true, isForMediaViewer: true, + })); const buttonText = textMessage && 'buttonText' in textMessage ? textMessage.buttonText : undefined; const hasFooter = Boolean(textParts); const posterSize = calculateMediaViewerDimensions(dimensions!, hasFooter, isVideo); @@ -187,6 +218,7 @@ const MediaViewerContent = ({ playbackRate={playbackRate} isSponsoredMessage={isSponsoredMessage} handleSponsoredClick={handleSponsoredClick} + timestamp={timestamp} /> ))} {textParts && ( @@ -212,11 +244,15 @@ export default memo(withGlobal( playbackRate, isHidden, origin, + timestamp, + threadId, } = selectTabState(global).mediaViewer; const message = item.type === 'message' ? item.message : undefined; const sponsoredMessage = item.type === 'sponsoredMessage' ? item.message : undefined; const textMessage = message || sponsoredMessage; + const maxTimestamp = message && selectMessageTimestampableDuration(global, message, true); + return { origin, textMessage, @@ -225,6 +261,9 @@ export default memo(withGlobal( isMuted, isHidden, playbackRate, + threadId, + timestamp, + maxTimestamp, }; }, )(MediaViewerContent)); diff --git a/src/components/mediaViewer/MediaViewerFooter.tsx b/src/components/mediaViewer/MediaViewerFooter.tsx index 453478c1d..ad75ef3d8 100644 --- a/src/components/mediaViewer/MediaViewerFooter.tsx +++ b/src/components/mediaViewer/MediaViewerFooter.tsx @@ -22,7 +22,7 @@ const RESIZE_THROTTLE_MS = 500; type OwnProps = { text: TextPart | TextPart[]; buttonText?: string; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; handleSponsoredClick: (isFromMedia?: boolean) => void; isForVideo: boolean; isForceMobileVersion?: boolean; diff --git a/src/components/mediaViewer/MediaViewerSlides.tsx b/src/components/mediaViewer/MediaViewerSlides.tsx index d859e7067..3e76f58c5 100644 --- a/src/components/mediaViewer/MediaViewerSlides.tsx +++ b/src/components/mediaViewer/MediaViewerSlides.tsx @@ -55,7 +55,7 @@ type OwnProps = { getNextItem: (from: MediaViewerItem, direction: number) => MediaViewerItem | undefined; selectItem: (item: MediaViewerItem) => void; loadMoreItemsIfNeeded: (item: MediaViewerItem) => void; - onFooterClick: () => void; + onFooterClick: (e: React.MouseEvent) => void; handleSponsoredClick: (isFromMedia?: boolean) => void; onClose: () => void; }; diff --git a/src/components/mediaViewer/VideoPlayer.tsx b/src/components/mediaViewer/VideoPlayer.tsx index 3d631e255..8d75acdeb 100644 --- a/src/components/mediaViewer/VideoPlayer.tsx +++ b/src/components/mediaViewer/VideoPlayer.tsx @@ -47,10 +47,11 @@ type OwnProps = { isProtected?: boolean; shouldCloseOnClick?: boolean; isForceMobileVersion?: boolean; - onClose: (e: React.MouseEvent) => void; isClickDisabled?: boolean; isSponsoredMessage?: boolean; + timestamp?: number; handleSponsoredClick?: (isFromMedia?: boolean) => void; + onClose: (e: React.MouseEvent) => void; }; const MAX_LOOP_DURATION = 30; // Seconds @@ -69,14 +70,15 @@ const VideoPlayer: FC = ({ volume, isMuted, playbackRate, - onClose, isForceMobileVersion, shouldCloseOnClick, isProtected, isClickDisabled, isPreviewDisabled, isSponsoredMessage, + timestamp, handleSponsoredClick, + onClose, }) => { const { setMediaViewerVolume, @@ -141,6 +143,9 @@ const VideoPlayer: FC = ({ IS_IOS && !isPlaying && !shouldRenderSpinner && !isUnsupported, undefined, undefined, 'slow', ); + const [, setCurrentTime] = useCurrentTimeSignal(); + const [, setIsVideoWaiting] = useVideoWaitingSignal(); + useEffect(() => { lockControls(shouldRenderSpinner); }, [lockControls, shouldRenderSpinner]); @@ -164,6 +169,12 @@ const VideoPlayer: FC = ({ videoRef.current!.playbackRate = playbackRate; }, [playbackRate]); + useEffect(() => { + if (!timestamp) return; + videoRef.current!.currentTime = timestamp; + setCurrentTime(timestamp); + }, [setCurrentTime, timestamp]); + const togglePlayState = useLastCallback((e: React.MouseEvent | KeyboardEvent) => { e.stopPropagation(); if (isPlaying) { @@ -192,9 +203,6 @@ const VideoPlayer: FC = ({ useVideoCleanup(videoRef, bufferingHandlers); - const [, setCurrentTime] = useCurrentTimeSignal(); - const [, setIsVideoWaiting] = useVideoWaitingSignal(); - const handleTimeUpdate = useLastCallback((e: React.SyntheticEvent) => { const video = e.currentTarget; if (video.readyState >= MIN_READY_STATE) { diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index a10272231..50665ca48 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -92,11 +92,14 @@ import { selectIsMessageProtected, selectIsMessageSelected, selectMessageIdsByGroupId, + selectMessageLastPlaybackTimestamp, + selectMessageTimestampableDuration, selectOutgoingStatus, selectPeer, selectPeerStory, selectPerformanceSettingsValue, selectPollFromMessage, + selectReplyMessage, selectRequestedChatTranslationLanguage, selectRequestedMessageTranslationLanguage, selectSender, @@ -296,6 +299,8 @@ type StateProps = { viaBusinessBot?: ApiUser; effect?: ApiAvailableEffect; poll?: ApiPoll; + maxTimestamp?: number; + lastPlaybackTimestamp?: number; }; type MetaPosition = @@ -413,6 +418,8 @@ const Message: FC = ({ viaBusinessBot, effect, poll, + maxTimestamp, + lastPlaybackTimestamp, onIntersectPinnedMessage, }) => { const { @@ -609,6 +616,7 @@ const Message: FC = ({ handleViaBotClick, handleReplyClick, handleMediaClick, + handleDocumentClick, handleAudioPlay, handleAlbumMediaClick, handlePhotoMediaClick, @@ -645,6 +653,7 @@ const Message: FC = ({ isReplyPrivate, isRepliesChat, isSavedMessages: isChatWithSelf, + lastPlaybackTimestamp, }); const handleEffectClick = useLastCallback((e: React.MouseEvent) => { @@ -961,6 +970,8 @@ const Message: FC = ({ withTranslucentThumbs={isCustomShape} isInSelectMode={isInSelectMode} canBeEmpty={hasFactCheck} + maxTimestamp={maxTimestamp} + threadId={threadId} /> ); } @@ -1194,7 +1205,7 @@ const Message: FC = ({ uploadProgress={uploadProgress} isSelectable={isInDocumentGroup} isSelected={isSelected} - onMediaClick={handleMediaClick} + onMediaClick={handleDocumentClick} onCancelUpload={handleCancelUpload} isDownloading={isDownloading} shouldWarnAboutSvg={shouldWarnAboutSvg} @@ -1357,11 +1368,13 @@ const Message: FC = ({ theme={theme} story={webPageStory} isConnected={isConnected} + lastPlaybackTimestamp={lastPlaybackTimestamp} backgroundEmojiId={messageColorPeer?.color?.backgroundEmojiId} shouldWarnAboutSvg={shouldWarnAboutSvg} autoLoadFileMaxSizeMb={autoLoadFileMaxSizeMb} onAudioPlay={handleAudioPlay} onMediaClick={handleMediaClick} + onDocumentClick={handleDocumentClick} onCancelMediaTransfer={handleCancelUpload} /> ); @@ -1414,6 +1427,7 @@ const Message: FC = ({ isDownloading={isDownloading} isProtected={isProtected} asForwarded={asForwarded} + lastPlaybackTimestamp={lastPlaybackTimestamp} onClick={handleVideoMediaClick} onCancelUpload={handleCancelUpload} /> @@ -1724,7 +1738,7 @@ export default memo(withGlobal( const { peerId: storyReplyPeerId, storyId: storyReplyId } = getStoryReplyInfo(message) || {}; const shouldHideReply = replyToMsgId && replyToMsgId === threadId; - const replyMessage = replyToMsgId ? selectChatMessage(global, replyToPeerId || chatId, replyToMsgId) : undefined; + const replyMessage = selectReplyMessage(global, message); const forwardHeader = forwardInfo || replyFrom; const replyMessageSender = replyMessage ? selectSender(global, replyMessage) : forwardHeader && !isSystemBotChat && !isAnonymousForwards @@ -1813,6 +1827,10 @@ export default memo(withGlobal( const poll = selectPollFromMessage(global, message); + const maxTimestamp = selectMessageTimestampableDuration(global, message); + + const lastPlaybackTimestamp = selectMessageLastPlaybackTimestamp(global, chatId, message.id); + return { theme: selectTheme(global), forceSenderName, @@ -1900,6 +1918,8 @@ export default memo(withGlobal( viaBusinessBot, effect, poll, + maxTimestamp, + lastPlaybackTimestamp, }; }, )(Message)); diff --git a/src/components/middle/message/Video.tsx b/src/components/middle/message/Video.tsx index 94ae9bdd9..86257def5 100644 --- a/src/components/middle/message/Video.tsx +++ b/src/components/middle/message/Video.tsx @@ -34,6 +34,7 @@ import ProgressSpinner from '../../ui/ProgressSpinner'; export type OwnProps = { id?: string; video: ApiVideo | ApiMediaExtendedPreview; + lastPlaybackTimestamp?: number; isOwn?: boolean; isInWebPage?: boolean; observeIntersectionForLoading?: ObserveFn; @@ -71,6 +72,7 @@ const Video = ({ isDownloading, isProtected, className, + lastPlaybackTimestamp, clickArg, onClick, onCancelUpload, @@ -302,6 +304,12 @@ const Video = ({ {isUnsupported && } )} + {Boolean(lastPlaybackTimestamp) && ( +
+ )}
); }; diff --git a/src/components/middle/message/WebPage.tsx b/src/components/middle/message/WebPage.tsx index 30d41bce3..def922f6c 100644 --- a/src/components/middle/message/WebPage.tsx +++ b/src/components/middle/message/WebPage.tsx @@ -1,5 +1,5 @@ import type { FC } from '../../../lib/teact/teact'; -import React, { memo, useRef } from '../../../lib/teact/teact'; +import React, { memo, useMemo, useRef } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { ApiMessage, ApiTypeStory } from '../../../api/types'; @@ -9,6 +9,7 @@ import { AudioOrigin, type ISettings } from '../../../types'; import { getMessageWebPage } from '../../../global/helpers'; import { selectCanPlayAnimatedEmojis } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; +import { tryParseDeepLink } from '../../../util/deepLinkParser'; import trimText from '../../../util/trimText'; import renderText from '../../common/helpers/renderText'; import { calculateMediaDimensions } from './helpers/mediaDimensions'; @@ -43,8 +44,6 @@ const EMOJI_SIZE = 38; type OwnProps = { message: ApiMessage; - observeIntersectionForLoading?: ObserveFn; - observeIntersectionForPlaying?: ObserveFn; noAvatars?: boolean; canAutoLoad?: boolean; canAutoPlay?: boolean; @@ -58,11 +57,15 @@ type OwnProps = { story?: ApiTypeStory; shouldWarnAboutSvg?: boolean; autoLoadFileMaxSizeMb?: number; + lastPlaybackTimestamp?: number; + isEditing?: boolean; + observeIntersectionForLoading?: ObserveFn; + observeIntersectionForPlaying?: ObserveFn; onAudioPlay?: NoneToVoidFunction; onMediaClick?: NoneToVoidFunction; + onDocumentClick?: NoneToVoidFunction; onCancelMediaTransfer?: NoneToVoidFunction; onContainerClick?: ((e: React.MouseEvent) => void); - isEditing?: boolean; }; type StateProps = { canPlayAnimatedEmojis: boolean; @@ -70,8 +73,6 @@ type StateProps = { const WebPage: FC = ({ message, - observeIntersectionForLoading, - observeIntersectionForPlaying, noAvatars, canAutoLoad, canAutoPlay, @@ -85,11 +86,15 @@ const WebPage: FC = ({ backgroundEmojiId, shouldWarnAboutSvg, autoLoadFileMaxSizeMb, + lastPlaybackTimestamp, + isEditing, + observeIntersectionForLoading, + observeIntersectionForPlaying, onMediaClick, + onDocumentClick, onContainerClick, onAudioPlay, onCancelMediaTransfer, - isEditing, }) => { const { openUrl, openTelegramLink } = getActions(); const webPage = getMessageWebPage(message); @@ -123,6 +128,12 @@ const WebPage: FC = ({ const hasCustomColor = stickers?.isWithTextColor || stickers?.documents?.[0]?.shouldUseTextColor; const customColor = useDynamicColorListener(stickersRef, undefined, !hasCustomColor); + const linkTimestamp = useMemo(() => { + const parsedLink = webPage?.url && tryParseDeepLink(webPage?.url); + if (!parsedLink || !('timestamp' in parsedLink)) return undefined; + return parsedLink.timestamp; + }, [webPage?.url]); + if (!webPage) { return undefined; } @@ -265,6 +276,7 @@ const WebPage: FC = ({ asForwarded={asForwarded} isDownloading={isDownloading} isProtected={isProtected} + lastPlaybackTimestamp={lastPlaybackTimestamp || linkTimestamp} onClick={isMediaInteractive ? handleMediaClick : undefined} onCancelUpload={onCancelMediaTransfer} /> @@ -286,7 +298,7 @@ const WebPage: FC = ({ message={message} observeIntersection={observeIntersectionForLoading} autoLoadFileMaxSizeMb={autoLoadFileMaxSizeMb} - onMediaClick={handleMediaClick} + onMediaClick={onDocumentClick} onCancelUpload={onCancelMediaTransfer} isDownloading={isDownloading} shouldWarnAboutSvg={shouldWarnAboutSvg} diff --git a/src/components/middle/message/hooks/useInnerHandlers.ts b/src/components/middle/message/hooks/useInnerHandlers.ts index 934a87ed8..b53022714 100644 --- a/src/components/middle/message/hooks/useInnerHandlers.ts +++ b/src/components/middle/message/hooks/useInnerHandlers.ts @@ -1,4 +1,3 @@ -import type React from '../../../../lib/teact/teact'; import { getActions } from '../../../../global'; import type { @@ -9,7 +8,9 @@ import type { IAlbum, ThreadId } from '../../../../types'; import { MAIN_THREAD_ID } from '../../../../api/types'; import { MediaViewerOrigin } from '../../../../types'; +import { getMessagePhoto, getMessageWebPagePhoto } from '../../../../global/helpers'; import { getMessageReplyInfo } from '../../../../global/helpers/replies'; +import { tryParseDeepLink } from '../../../../util/deepLinkParser'; import useLastCallback from '../../../../hooks/useLastCallback'; @@ -31,6 +32,7 @@ export default function useInnerHandlers({ isReplyPrivate, isRepliesChat, isSavedMessages, + lastPlaybackTimestamp, }: { lang: OldLangFn; selectMessage: (e: React.MouseEvent, groupedId?: string) => void; @@ -50,6 +52,7 @@ export default function useInnerHandlers({ isReplyPrivate?: boolean; isRepliesChat?: boolean; isSavedMessages?: boolean; + lastPlaybackTimestamp?: number; }) { const { openChat, showNotification, focusMessage, openMediaViewer, openAudioPlayer, @@ -58,7 +61,7 @@ export default function useInnerHandlers({ } = getActions(); const { - id: messageId, forwardInfo, groupedId, content: { paidMedia }, + id: messageId, forwardInfo, groupedId, content: { paidMedia, video, webPage }, } = message; const { @@ -115,7 +118,7 @@ export default function useInnerHandlers({ }); }); - const handleMediaClick = useLastCallback((): void => { + const handleDocumentClick = useLastCallback((): void => { openMediaViewer({ chatId, threadId, @@ -123,16 +126,24 @@ export default function useInnerHandlers({ origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline, }); }); + const openMediaViewerWithPhotoOrVideo = useLastCallback((withDynamicLoading: boolean): void => { if (paidMedia && !paidMedia.isBought) return; if (withDynamicLoading) { searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: messageId }); } + + const parsedLink = webPage?.url && tryParseDeepLink(webPage.url); + + const videoContent = video || webPage?.video; + const webpageTimestamp = parsedLink && 'timestamp' in parsedLink ? parsedLink.timestamp : undefined; + openMediaViewer({ chatId, threadId, messageId, origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline, + timestamp: lastPlaybackTimestamp || videoContent?.timestamp || webpageTimestamp, withDynamicLoading, }); }); @@ -146,6 +157,15 @@ export default function useInnerHandlers({ openMediaViewerWithPhotoOrVideo(withDynamicLoading); }); + const handleMediaClick = useLastCallback((): void => { + const photo = getMessagePhoto(message) || getMessageWebPagePhoto(message); + if (photo) { + handlePhotoMediaClick(); + } + + handleVideoMediaClick(); + }); + const handleAudioPlay = useLastCallback((): void => { openAudioPlayer({ chatId, messageId }); }); @@ -268,6 +288,7 @@ export default function useInnerHandlers({ handleSenderClick, handleViaBotClick, handleReplyClick, + handleDocumentClick, handleMediaClick, handleAudioPlay, handleAlbumMediaClick, diff --git a/src/components/middle/panes/AudioPlayer.tsx b/src/components/middle/panes/AudioPlayer.tsx index a1a9dabb8..08014ffc9 100644 --- a/src/components/middle/panes/AudioPlayer.tsx +++ b/src/components/middle/panes/AudioPlayer.tsx @@ -1,5 +1,5 @@ import type { FC } from '../../../lib/teact/teact'; -import React, { useMemo } from '../../../lib/teact/teact'; +import React, { useEffect, useMemo } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { @@ -57,6 +57,7 @@ type StateProps = { playbackRate: number; isPlaybackRateActive?: boolean; isMuted: boolean; + timestamp?: number; }; const PLAYBACK_RATES: Record = { @@ -82,6 +83,7 @@ const AudioPlayer: FC = ({ isPlaybackRateActive, isMuted, isFullWidth, + timestamp, onPaneStateChange, }) => { const { @@ -117,6 +119,7 @@ const AudioPlayer: FC = ({ setVolume, toggleMuted, setPlaybackRate, + setCurrentTime, } = useAudioPlayer( message && makeTrackId(message), message ? getMediaDuration(message)! : 0, @@ -153,6 +156,12 @@ const AudioPlayer: FC = ({ handleContextMenuClose, handleContextMenuHide, } = useContextMenuHandlers(transitionRef, !shouldRender); + useEffect(() => { + if (timestamp) { + setCurrentTime(timestamp); + } + }, [timestamp, setCurrentTime]); + const handleClick = useLastCallback(() => { const { chatId, id } = renderingMessage!; focusMessage({ chatId, messageId: id }); @@ -407,7 +416,7 @@ export default withGlobal( const sender = message && selectSender(global, message); const chat = message && selectChat(global, message.chatId); const { - volume, playbackRate, isMuted, isPlaybackRateActive, + volume, playbackRate, isMuted, isPlaybackRateActive, timestamp, } = selectTabState(global).audioPlayer; return { @@ -418,6 +427,7 @@ export default withGlobal( playbackRate, isPlaybackRateActive, isMuted, + timestamp, }; }, )(AudioPlayer); diff --git a/src/config.ts b/src/config.ts index fafb0e715..46dd901b6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -100,6 +100,8 @@ export const STORY_VIEWS_MIN_SEARCH = 15; export const STORY_MIN_REACTIONS_SORT = 10; export const STORY_VIEWS_MIN_CONTACTS_FILTER = 20; +export const MEDIA_TIMESTAMP_SAVE_MINIMUM_DURATION = 30; // 30s + export const GLOBAL_SUGGESTED_CHANNELS_ID = 'global'; // As in Telegram for Android diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index f2c4f3dba..cdd485229 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -1469,7 +1469,7 @@ addActionHandler('acceptChatInvite', async (global, actions, payload): Promise => { const { username, messageId, commentId, startParam, startAttach, attach, threadId, originalParts, startApp, mode, - text, onChatChanged, choose, ref, + text, onChatChanged, choose, ref, timestamp, tabId = getCurrentTabId(), } = payload; @@ -1481,7 +1481,7 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise if (startAttach === undefined && messageId && !startParam && !ref && chat?.usernames?.some((c) => c.username === username)) { actions.focusMessage({ - chatId: chat.id, threadId, messageId, tabId, + chatId: chat.id, threadId, messageId, timestamp, tabId, }); return; } @@ -1523,6 +1523,7 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise startAttach, attach, text, + timestamp, }, tabId, ); if (onChatChanged) { @@ -1542,6 +1543,14 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise tabId, focusMessageId: commentId, }); + if (timestamp) { + actions.openMediaFromTimestamp({ + chatId: usernameChat.id, + messageId: commentId, + timestamp, + tabId, + }); + } return; } @@ -1574,6 +1583,16 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise tabId, focusMessageId: commentId, }); + + if (timestamp) { + actions.openMediaFromTimestamp({ + chatId: chatByUsername.id, + messageId: commentId || messageId!, + timestamp, + tabId, + }); + } + if (onChatChanged) { // @ts-ignore actions[onChatChanged.action](onChatChanged.payload); @@ -1582,7 +1601,7 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise addActionHandler('openPrivateChannel', (global, actions, payload): ActionReturnType => { const { - id, commentId, messageId, threadId, tabId = getCurrentTabId(), + id, commentId, messageId, threadId, timestamp, tabId = getCurrentTabId(), } = payload; const chat = selectChat(global, id); if (!chat) { @@ -1600,10 +1619,19 @@ addActionHandler('openPrivateChannel', (global, actions, payload): ActionReturnT return; } + if (timestamp) { + actions.openMediaFromTimestamp({ + chatId: id, + messageId: commentId || messageId!, + timestamp, + tabId, + }); + } + if (commentId && messageId) { actions.openThread({ isComments: true, - originChannelId: chat.id, + originChannelId: id, originMessageId: messageId, tabId, focusMessageId: commentId, @@ -1614,6 +1642,7 @@ addActionHandler('openPrivateChannel', (global, actions, payload): ActionReturnT openChatWithParams(global, actions, chat, { messageId, threadId, + timestamp, }, tabId); }); @@ -3109,11 +3138,12 @@ async function openChatByUsername( startAttach?: string; attach?: string; text?: string; + timestamp?: number; }, ...[tabId = getCurrentTabId()]: TabArgs ) { const { - username, threadId, channelPostId, startParam, ref, startAttach, attach, text, + username, threadId, channelPostId, startParam, ref, startAttach, attach, text, timestamp, } = params; const currentChat = selectCurrentChat(global, tabId); @@ -3168,6 +3198,7 @@ async function openChatByUsername( startAttach, attach, text, + timestamp, }, tabId); } @@ -3184,11 +3215,12 @@ async function openChatWithParams( startAttach?: string; attach?: string; text?: string; + timestamp?: number; }, ...[tabId = getCurrentTabId()]: TabArgs ) { const { - isCurrentChat, threadId, messageId, startParam, referrer, startAttach, attach, text, + isCurrentChat, threadId, messageId, startParam, referrer, startAttach, attach, text, timestamp, } = params; if (messageId) { @@ -3211,7 +3243,7 @@ async function openChatWithParams( if (!isTopicProcessed) { actions.focusMessage({ - chatId: chat.id, threadId, messageId, tabId, + chatId: chat.id, threadId, messageId, timestamp, tabId, }); } } else if (!isCurrentChat) { @@ -3230,6 +3262,12 @@ async function openChatWithParams( if (text) { actions.openChatWithDraft({ chatId: chat.id, text: { text }, tabId }); } + + if (messageId && timestamp) { + actions.openMediaFromTimestamp({ + chatId: chat.id, threadId, messageId, timestamp, tabId, + }); + } } async function openAttachMenuFromLink( diff --git a/src/global/actions/ui/mediaViewer.ts b/src/global/actions/ui/mediaViewer.ts index 0d9062e41..afc5b5ffd 100644 --- a/src/global/actions/ui/mediaViewer.ts +++ b/src/global/actions/ui/mediaViewer.ts @@ -1,14 +1,18 @@ import type { ActionReturnType } from '../../types'; import { MAIN_THREAD_ID } from '../../../api/types'; +import { AudioOrigin, MediaViewerOrigin } from '../../../types'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; +import { omit } from '../../../util/iteratees'; +import { getTimestampableMedia } from '../../helpers'; +import { getMessageReplyInfo } from '../../helpers/replies'; import { addActionHandler } from '../../index'; import { updateTabState } from '../../reducers/tabs'; -import { selectTabState } from '../../selectors'; +import { selectChatMessage, selectReplyMessage, selectTabState } from '../../selectors'; addActionHandler('openMediaViewer', (global, actions, payload): ActionReturnType => { const { - chatId, threadId = MAIN_THREAD_ID, messageId, mediaIndex, isAvatarView, isSponsoredMessage, origin, + chatId, threadId = MAIN_THREAD_ID, messageId, timestamp, mediaIndex, isAvatarView, isSponsoredMessage, origin, withDynamicLoading, standaloneMedia, tabId = getCurrentTabId(), } = payload; @@ -27,6 +31,7 @@ addActionHandler('openMediaViewer', (global, actions, payload): ActionReturnType standaloneMedia, isHidden: false, withDynamicLoading, + timestamp, }, forwardMessages: {}, isShareMessageModalShown: false, @@ -49,6 +54,108 @@ addActionHandler('closeMediaViewer', (global, actions, payload): ActionReturnTyp }, tabId); }); +addActionHandler('openMediaFromTimestamp', (global, actions, payload): ActionReturnType => { + const { + chatId, messageId, threadId, timestamp, tabId = getCurrentTabId(), + } = payload; + + const message = selectChatMessage(global, chatId, messageId); + if (!message) return; + + const replyInfo = getMessageReplyInfo(message); + const replyMessage = selectReplyMessage(global, message); + + const messageMedia = getTimestampableMedia(message); + const maxMessageDuration = messageMedia?.duration; + if (maxMessageDuration) { + if (maxMessageDuration <= timestamp) return; + + if (messageMedia.mediaType === 'video') { + actions.openMediaViewer({ + chatId, + messageId, + threadId, + origin: MediaViewerOrigin.Inline, + timestamp, + tabId, + }); + return; + } + + actions.openAudioPlayer({ + chatId, + messageId, + threadId, + origin: AudioOrigin.Inline, + timestamp, + tabId, + }); + return; + } + + const replyMessageMedia = replyMessage ? getTimestampableMedia(replyMessage) : undefined; + const maxReplyMessageDuration = replyMessageMedia?.duration; + if (!maxReplyMessageDuration || maxReplyMessageDuration <= timestamp) return; + + if (replyMessageMedia.mediaType === 'video') { + actions.openMediaViewer({ + chatId: replyMessage!.chatId, + messageId: replyMessage!.id, + threadId: replyInfo?.replyToTopId, + origin: MediaViewerOrigin.Inline, + timestamp, + tabId, + }); + return; + } + + actions.openAudioPlayer({ + chatId: replyMessage!.chatId, + messageId: replyMessage!.id, + threadId: replyInfo?.replyToTopId, + origin: AudioOrigin.Inline, + timestamp, + tabId, + }); +}); + +addActionHandler('updateLastPlaybackTimestamp', (global, actions, payload): ActionReturnType => { + const { chatId, messageId, timestamp } = payload; + + const currentChatPlaybacks = global.messages.playbackByChatId[chatId]?.byId || {}; + + if (!timestamp) { + return { + ...global, + messages: { + ...global.messages, + playbackByChatId: { + ...global.messages.playbackByChatId, + [chatId]: { + byId: omit(currentChatPlaybacks, [messageId]), + }, + }, + }, + }; + } + + return { + ...global, + messages: { + ...global.messages, + playbackByChatId: { + ...global.messages.playbackByChatId, + [chatId]: { + byId: { + ...currentChatPlaybacks, + [messageId]: timestamp, + }, + }, + }, + }, + }; +}); + addActionHandler('setMediaViewerVolume', (global, actions, payload): ActionReturnType => { const { volume, diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index d1f6a4438..2eafde4a1 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -186,7 +186,7 @@ addActionHandler('replyToNextMessage', (global, actions, payload): ActionReturnT addActionHandler('openAudioPlayer', (global, actions, payload): ActionReturnType => { const { - chatId, threadId, messageId, origin, volume, playbackRate, isMuted, + chatId, threadId, messageId, origin, volume, playbackRate, isMuted, timestamp, tabId = getCurrentTabId(), } = payload; @@ -196,6 +196,7 @@ addActionHandler('openAudioPlayer', (global, actions, payload): ActionReturnType chatId, threadId, messageId, + timestamp, origin: origin ?? tabState.audioPlayer.origin, volume: volume ?? tabState.audioPlayer.volume, playbackRate: playbackRate || tabState.audioPlayer.playbackRate || global.audioPlayer.lastPlaybackRate, @@ -404,7 +405,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => const { chatId, threadId = MAIN_THREAD_ID, messageListType = 'thread', noHighlight, groupedId, groupedChatId, replyMessageId, isResizingContainer, shouldReplaceHistory, noForumTopicPanel, quote, scrollTargetPosition, - tabId = getCurrentTabId(), + timestamp, tabId = getCurrentTabId(), } = payload; let { messageId } = payload; @@ -415,6 +416,11 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => return undefined; } + const onMessageReady = timestamp + ? () => actions.openMediaFromTimestamp({ + chatId, threadId, messageId, timestamp, tabId, + }) : undefined; + if (groupedId !== undefined) { const ids = selectForwardedMessageIdsByGroupId(global, groupedChatId!, groupedId); if (ids?.length) { @@ -472,6 +478,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => noForumTopicPanel, tabId, }); + onMessageReady?.(); return undefined; } @@ -503,6 +510,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType => threadId, tabId, shouldForceRender: true, + onLoaded: onMessageReady, }); return undefined; }); diff --git a/src/global/cache.ts b/src/global/cache.ts index cc0c00dbd..030730288 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -285,6 +285,10 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) { cached.chats.listIds = initialState.chats.listIds; } + if (!cached.messages.playbackByChatId) { + cached.messages.playbackByChatId = initialState.messages.playbackByChatId; + } + if (cached.cacheVersion < 2) { if (cached.settings.themes.dark) { cached.settings.themes.dark.patternColor = initialState.settings.themes.dark!.patternColor; @@ -594,6 +598,7 @@ function reduceMessages(global: T): GlobalState['messages byChatId, pollById: pickTruthy(global.messages.pollById, pollIdsToSave), sponsoredByChatId: {}, + playbackByChatId: {}, }; } diff --git a/src/global/helpers/messageMedia.ts b/src/global/helpers/messageMedia.ts index c1efde5f9..45e4222da 100644 --- a/src/global/helpers/messageMedia.ts +++ b/src/global/helpers/messageMedia.ts @@ -663,3 +663,10 @@ export function getIsDownloading(activeDownloads: ActiveDownloads, media: Downlo if (!hash) return false; return Boolean(activeDownloads[hash]); } + +export function getTimestampableMedia(message: MediaContainer) { + return getMessageVideo(message) + || getMessageWebPageVideo(message) + || getMessageAudio(message) + || getMessageVoice(message); +} diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 49bf695cc..6f6c0fac8 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -133,6 +133,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { byChatId: {}, sponsoredByChatId: {}, pollById: {}, + playbackByChatId: {}, }, stories: { diff --git a/src/global/reducers/messages.ts b/src/global/reducers/messages.ts index 8991aa5ed..9b139f78a 100644 --- a/src/global/reducers/messages.ts +++ b/src/global/reducers/messages.ts @@ -19,6 +19,7 @@ import { IS_MOCKED_CLIENT, IS_TEST, MESSAGE_LIST_SLICE, MESSAGE_LIST_VIEWPORT_LIMIT, TMP_CHAT_ID, } from '../../config'; import { areDeepEqual } from '../../util/areDeepEqual'; +import { addTimestampEntities } from '../../util/dates/timestamp'; import { getCurrentTabId } from '../../util/establishMultitabRole'; import { areSortedArraysEqual, excludeSortedArray, omit, omitUndefined, pick, pickTruthy, unique, @@ -271,16 +272,19 @@ export function updateChatMessage( } let emojiOnlyCount = message?.emojiOnlyCount; + let text = message?.content?.text; if (messageUpdate.content) { emojiOnlyCount = getEmojiOnlyCountForMessage( messageUpdate.content, message?.groupedId || messageUpdate.groupedId, ); + text = messageUpdate.content.text ? addTimestampEntities(messageUpdate.content.text) : text; } const updatedMessage = omitUndefined({ ...message, ...messageUpdate, emojiOnlyCount, + text, }); if (!updatedMessage.id) { @@ -299,16 +303,19 @@ export function updateScheduledMessage( const message = selectScheduledMessage(global, chatId, messageId)!; let emojiOnlyCount = message?.emojiOnlyCount; + let text = message?.content?.text; if (messageUpdate.content) { emojiOnlyCount = getEmojiOnlyCountForMessage( messageUpdate.content, message?.groupedId || messageUpdate.groupedId, ); + text = messageUpdate.content.text ? addTimestampEntities(messageUpdate.content.text) : text; } const updatedMessage = { ...message, ...messageUpdate, emojiOnlyCount, + text, }; if (!updatedMessage.id) { diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index f6e3d4dcd..4656b8d63 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -46,6 +46,7 @@ import { getMessageWebPagePhoto, getMessageWebPageVideo, getSendingState, + getTimestampableMedia, hasMessageTtl, isActionMessage, isChatBasicGroup, @@ -1501,3 +1502,28 @@ export function selectMessageReplyInfo( return replyInfo; } + +export function selectReplyMessage(global: T, message: ApiMessage) { + const { replyToMsgId, replyToPeerId } = getMessageReplyInfo(message) || {}; + const replyMessage = replyToMsgId + ? selectChatMessage(global, replyToPeerId || message.chatId, replyToMsgId) : undefined; + + return replyMessage; +} + +export function selectMessageTimestampableDuration( + global: T, message: ApiMessage, noReplies?: boolean, +) { + const replyMessage = !noReplies ? selectReplyMessage(global, message) : undefined; + + const timestampableMedia = getTimestampableMedia(message); + const replyTimestampableMedia = replyMessage && getTimestampableMedia(replyMessage); + + return timestampableMedia?.duration || replyTimestampableMedia?.duration; +} + +export function selectMessageLastPlaybackTimestamp( + global: T, chatId: string, messageId: number, +) { + return global.messages.playbackByChatId[chatId]?.byId[messageId]; +} diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 1ae6d6c37..b15b872f6 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -614,6 +614,7 @@ export interface ActionPayloads { choose?: ApiChatType[]; text?: string; originalParts?: (string | undefined)[]; + timestamp?: number; onChatChanged?: CallbackAction; } & WithTabId; processBoostParameters: { @@ -946,6 +947,7 @@ export interface ActionPayloads { noForumTopicPanel?: boolean; quote?: string; scrollTargetPosition?: ScrollTargetPosition; + timestamp?: number; } & WithTabId; focusLastMessage: WithTabId | undefined; @@ -1104,6 +1106,7 @@ export interface ActionPayloads { threadId?: ThreadId; messageId?: number; commentId?: number; + timestamp?: number; } & WithTabId; loadFullChat: { chatId: string; @@ -1595,8 +1598,14 @@ export interface ActionPayloads { isSponsoredMessage?: boolean; origin: MediaViewerOrigin; withDynamicLoading?: boolean; + timestamp?: number; } & WithTabId; closeMediaViewer: WithTabId | undefined; + updateLastPlaybackTimestamp: { + chatId: string; + messageId: number; + timestamp?: number; + }; setMediaViewerVolume: { volume: number; } & WithTabId; @@ -1609,6 +1618,12 @@ export interface ActionPayloads { setMediaViewerHidden: { isHidden: boolean; } & WithTabId; + openMediaFromTimestamp: { + chatId: string; + messageId: number; + threadId?: ThreadId; + timestamp: number; + } & WithTabId; openAudioPlayer: { chatId: string; threadId?: ThreadId; @@ -1617,6 +1632,7 @@ export interface ActionPayloads { volume?: number; playbackRate?: number; isMuted?: boolean; + timestamp?: number; } & WithTabId; closeAudioPlayer: WithTabId | undefined; setAudioPlayerVolume: { diff --git a/src/global/types/globalState.ts b/src/global/types/globalState.ts index b9c299f9c..1ec5dfc60 100644 --- a/src/global/types/globalState.ts +++ b/src/global/types/globalState.ts @@ -230,6 +230,9 @@ export type GlobalState = { byId: Record; threadsById: Record; }>; + playbackByChatId: Record; + }>; sponsoredByChatId: Record; pollById: Record; }; diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index 12c48ae48..8406d630a 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -321,6 +321,7 @@ export type TabState = { playbackRate: number; isMuted: boolean; isHidden?: boolean; + timestamp?: number; }; audioPlayer: { @@ -331,6 +332,7 @@ export type TabState = { volume: number; playbackRate: number; isPlaybackRateActive?: boolean; + timestamp?: number; isMuted: boolean; }; diff --git a/src/styles/_common.scss b/src/styles/_common.scss index 6e5ac0889..5a7ea6990 100644 --- a/src/styles/_common.scss +++ b/src/styles/_common.scss @@ -43,6 +43,34 @@ &.fix-min-height { min-height: 5rem; } + + .message-media-last-progress { + --_progress: 0%; + --_color: var(--color-primary); + + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 0.25rem; + background-color: rgba(255 255 255 / 0.3); + + .theme-dark & { + --_color: white; + } + + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: var(--_progress); + height: 100%; + background-color: var(--_color); + border-top-right-radius: 0.125rem; + border-bottom-right-radius: 0.125; + } + } } .animated-close-icon { diff --git a/src/util/dates/timestamp.ts b/src/util/dates/timestamp.ts new file mode 100644 index 000000000..b06a104a6 --- /dev/null +++ b/src/util/dates/timestamp.ts @@ -0,0 +1,133 @@ +import { + type ApiFormattedText, + type ApiMessageEntityTimestamp, + ApiMessageEntityTypes, +} from '../../api/types'; + +import { getSeconds } from './units'; + +const TIMESTAMP_RE = /\b(?:(\d{1,2}):)?([0-5]?\d):([0-5]\d)\b/g; + +export function addTimestampEntities(apiText: ApiFormattedText): ApiFormattedText { + const resultText = { + text: apiText.text, + entities: apiText.entities?.filter((e) => e.type !== ApiMessageEntityTypes.Timestamp) || [], + }; + + const text = resultText.text; + + for (const match of text.matchAll(TIMESTAMP_RE)) { + const fullMatch = match[0]; + const hourStr = match[1]; + const minuteStr = match[2]; + const secondStr = match[3]; + const offset = match.index ?? 0; + const length = fullMatch.length; + + const minutes = parseInt(minuteStr, 10); + const seconds = parseInt(secondStr, 10); + + if (minutes > 59 || seconds > 59) { + continue; + } + + let totalSeconds: number; + if (hourStr !== undefined) { + const hours = parseInt(hourStr, 10); + totalSeconds = getSeconds(hours, minutes, seconds); + } else { + totalSeconds = getSeconds(0, minutes, seconds); + } + + let overlaps = false; + for (const entity of resultText.entities) { + if (offset < entity.offset + entity.length && offset + length > entity.offset) { + overlaps = true; + break; + } + } + if (overlaps) { + continue; + } + + const newEntity: ApiMessageEntityTimestamp = { + type: ApiMessageEntityTypes.Timestamp, + offset, + length, + timestamp: totalSeconds, + }; + + let inserted = false; + for (let i = 0; i < resultText.entities.length; i++) { + if (offset < resultText.entities[i].offset) { + resultText.entities.splice(i, 0, newEntity); + inserted = true; + break; + } + } + if (!inserted) { + resultText.entities.push(newEntity); + } + } + + return resultText; +} + +export function parseTimestampDuration(input: string): number | undefined { + input = input.trim(); + + if (!input.startsWith('-') && Number.isInteger(Number(input))) { + return parseInt(input, 10); + } + + if (input.includes(':')) { + const parts = input.split(':'); + + if (parts.length === 2) { + const minutes = parseInt(parts[0], 10); + const seconds = parseInt(parts[1], 10); + + if ( + Number.isNaN(minutes) || Number.isNaN(seconds) + || minutes < 0 || seconds < 0 || seconds >= 60 + ) { + return undefined; + } + return minutes * 60 + seconds; + } + + if (parts.length === 3) { + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + const seconds = parseInt(parts[2], 10); + + if ( + Number.isNaN(hours) || Number.isNaN(minutes) || Number.isNaN(seconds) + || hours < 0 || minutes < 0 || seconds < 0 || minutes >= 60 || seconds >= 60 + ) { + return undefined; + } + return hours * 3600 + minutes * 60 + seconds; + } + + return undefined; + } + + const regex = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/i; + const match = input.match(regex); + if (!match) { + return undefined; + } + + const hours = match[1] ? parseInt(match[1], 10) : 0; + const minutes = match[2] ? parseInt(match[2], 10) : 0; + const seconds = match[3] ? parseInt(match[3], 10) : 0; + + if ( + Number.isNaN(hours) || Number.isNaN(minutes) || Number.isNaN(seconds) + || minutes >= 60 || seconds >= 60 + ) { + return undefined; + } + return hours * 3600 + minutes * 60 + seconds; +} diff --git a/src/util/dates/units.ts b/src/util/dates/units.ts index cb1bdc1a6..b70d0b4db 100644 --- a/src/util/dates/units.ts +++ b/src/util/dates/units.ts @@ -17,3 +17,7 @@ export function getDays(seconds: number, roundDown?: boolean) { const roundFunc = roundDown ? Math.floor : Math.ceil; return roundFunc(seconds / DAY); } + +export function getSeconds(hours: number, minutes: number, seconds: number) { + return hours * HOUR + minutes * MINUTE + seconds; +} diff --git a/src/util/deepLinkParser.ts b/src/util/deepLinkParser.ts index 9fd0e4cd0..407ca5568 100644 --- a/src/util/deepLinkParser.ts +++ b/src/util/deepLinkParser.ts @@ -2,6 +2,7 @@ import type { ThreadId } from '../types'; import { RE_TG_LINK, RE_TME_LINK } from '../config'; import { toChannelId } from '../global/helpers'; +import { parseTimestampDuration } from './dates/timestamp'; import { ensureProtocol } from './ensureProtocol'; import { isUsernameValid } from './username'; import { IS_BAD_URL_PARSER } from './windowEnvironment'; @@ -18,6 +19,7 @@ interface PublicMessageLink { isSingle: boolean; threadId?: ThreadId; commentId?: number; + timestamp?: number; } export interface PrivateMessageLink { @@ -27,6 +29,7 @@ export interface PrivateMessageLink { isSingle: boolean; threadId?: ThreadId; commentId?: number; + timestamp?: number; } interface ShareLink { @@ -171,7 +174,7 @@ function parseTgLink(url: URL) { switch (deepLinkType) { case 'publicMessageLink': { const { - domain, post, single, thread, comment, + domain, post, single, thread, comment, t, } = queryParams; return buildPublicMessageLink({ username: domain, @@ -179,11 +182,12 @@ function parseTgLink(url: URL) { single, threadId: thread, commentId: comment, + timestamp: t, }); } case 'privateMessageLink': { const { - channel, post, single, thread, comment, + channel, post, single, thread, comment, t, } = queryParams; return buildPrivateMessageLink({ channelId: channel, @@ -191,6 +195,7 @@ function parseTgLink(url: URL) { single, threadId: thread, commentId: comment, + timestamp: t, }); } case 'shareLink': @@ -251,7 +256,7 @@ function parseHttpLink(url: URL) { switch (deepLinkType) { case 'publicMessageLink': { const { - single, comment, + single, comment, t, } = queryParams; const { username, @@ -272,11 +277,12 @@ function parseHttpLink(url: URL) { single, threadId: thread, commentId: comment, + timestamp: t, }); } case 'privateMessageLink': { const { - single, comment, + single, comment, t, } = queryParams; const { channelId, @@ -297,6 +303,7 @@ function parseHttpLink(url: URL) { single, threadId: thread, commentId: comment, + timestamp: t, }); } case 'shareLink': { @@ -457,7 +464,7 @@ function buildShareLink(params: BuilderParams): BuilderReturnType { const { - messageId, threadId, commentId, username, single, + messageId, threadId, commentId, username, single, timestamp, } = params; if (!username || !isUsernameValid(username)) { return undefined; @@ -478,12 +485,13 @@ function buildPublicMessageLink(params: PublicMessageLinkBuilderParams): Builder isSingle: single === '', threadId: threadId ? Number(threadId) : undefined, commentId: commentId ? Number(commentId) : undefined, + timestamp: timestamp ? parseTimestampDuration(timestamp) : undefined, }; } function buildPrivateMessageLink(params: PrivateMessageLinkBuilderParams): BuilderReturnType { const { - messageId, threadId, commentId, channelId, single, + messageId, threadId, commentId, channelId, single, timestamp, } = params; if (!channelId || !isNumber(channelId)) { return undefined; @@ -504,6 +512,7 @@ function buildPrivateMessageLink(params: PrivateMessageLinkBuilderParams): Build isSingle: single === '', threadId: threadId ? Number(threadId) : undefined, commentId: commentId ? Number(commentId) : undefined, + timestamp: timestamp ? parseTimestampDuration(timestamp) : undefined, }; } diff --git a/src/util/deeplink.ts b/src/util/deeplink.ts index 5f1cdaa67..71dd65540 100644 --- a/src/util/deeplink.ts +++ b/src/util/deeplink.ts @@ -19,6 +19,7 @@ export const processDeepLink = (url: string): boolean => { threadId: parsedLink.threadId, messageId: parsedLink.messageId, commentId: parsedLink.commentId, + timestamp: parsedLink.timestamp, }); return true; case 'publicMessageLink': { @@ -27,6 +28,7 @@ export const processDeepLink = (url: string): boolean => { threadId: parsedLink.threadId, messageId: parsedLink.messageId, commentId: parsedLink.commentId, + timestamp: parsedLink.timestamp, }); return true; } diff --git a/src/util/parseHtmlAsFormattedText.ts b/src/util/parseHtmlAsFormattedText.ts index ee78d4566..cab4c3a2b 100644 --- a/src/util/parseHtmlAsFormattedText.ts +++ b/src/util/parseHtmlAsFormattedText.ts @@ -209,6 +209,26 @@ function getEntityDataFromNode( }; } + if (type === ApiMessageEntityTypes.Timestamp) { + const timestamp = Number((node as HTMLElement).dataset.timestamp); + if (Number.isNaN(timestamp)) { + return { + index, + entity: undefined, + }; + } + + return { + index, + entity: { + type, + offset, + length, + timestamp, + }, + }; + } + return { index, entity: {