Media: Support timestamps (#5649)

This commit is contained in:
zubiden 2025-03-01 17:59:48 +01:00 committed by Alexander Zinchuk
parent 48b98f7494
commit c8f82b1f91
34 changed files with 675 additions and 58 deletions

View File

@ -29,6 +29,7 @@ import type {
import type { UniversalMessage } from './messages'; import type { UniversalMessage } from './messages';
import { SUPPORTED_PHOTO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, VIDEO_WEBM_TYPE } from '../../../config'; 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 { generateWaveform } from '../../../util/generateWaveform';
import { pick } from '../../../util/iteratees'; import { pick } from '../../../util/iteratees';
import { import {
@ -64,9 +65,11 @@ export function buildMessageContent(
if (mtpMessage.message && !hasUnsupportedMedia if (mtpMessage.message && !hasUnsupportedMedia
&& !content.sticker && !content.pollId && !content.contact && !content.video?.isRound) { && !content.sticker && !content.pollId && !content.contact && !content.video?.isRound) {
const text = buildMessageTextContent(mtpMessage.message, mtpMessage.entities);
const textWithTimestamps = addTimestampEntities(text);
content = { content = {
...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); 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) { if (document instanceof GramJs.DocumentEmpty) {
return undefined; return undefined;
} }
const { isSpoiler, timestamp } = params || {};
const { const {
id, mimeType, thumbs, size, videoThumbs, attributes, id, mimeType, thumbs, size, videoThumbs, attributes,
} = document; } = document;
@ -249,6 +257,7 @@ export function buildVideoFromDocument(document: GramJs.Document, isSpoiler?: bo
thumbnail: buildApiThumbnailFromStripped(thumbs), thumbnail: buildApiThumbnailFromStripped(thumbs),
size: size.toJSNumber(), size: size.toJSNumber(),
isSpoiler, isSpoiler,
timestamp,
hasVideoPreview, hasVideoPreview,
previewPhotoSizes, previewPhotoSizes,
waveform, waveform,
@ -299,7 +308,7 @@ function buildVideo(media: GramJs.TypeMessageMedia): ApiVideo | undefined {
return 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 { 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 => ( const altVideos = media.altDocuments.filter((d): d is GramJs.Document => (
d instanceof GramJs.Document && d.mimeType.startsWith('video') d instanceof GramJs.Document && d.mimeType.startsWith('video')
)).map((alt) => buildVideoFromDocument(alt, media.spoiler)) )).map((alt) => buildVideoFromDocument(alt, { isSpoiler: media.spoiler }))
.filter(Boolean); .filter(Boolean);
if (!altVideos.length) { if (!altVideos.length) {
return undefined; return undefined;

View File

@ -41,6 +41,7 @@ import {
SUPPORTED_VIDEO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES,
} from '../../../config'; } from '../../../config';
import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage'; import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage';
import { addTimestampEntities } from '../../../util/dates/timestamp';
import { omitUndefined, pick } from '../../../util/iteratees'; import { omitUndefined, pick } from '../../../util/iteratees';
import { getServerTime, getServerTimeOffset } from '../../../util/serverTime'; import { getServerTime, getServerTimeOffset } from '../../../util/serverTime';
import { interpolateArray } from '../../../util/waveform'; import { interpolateArray } from '../../../util/waveform';
@ -544,14 +545,13 @@ export function buildLocalMessage(
const localPoll = poll && buildNewPoll(poll, localId); const localPoll = poll && buildNewPoll(poll, localId);
const formattedText = text ? addTimestampEntities({ text, entities }) : undefined;
const message = { const message = {
id: localId, id: localId,
chatId: chat.id, chatId: chat.id,
content: omitUndefined({ content: omitUndefined({
text: text ? { text: formattedText,
text,
entities,
} : undefined,
...media, ...media,
sticker, sticker,
video: gif || media?.video, video: gif || media?.video,
@ -628,11 +628,12 @@ export function buildLocalForwardedMessage({
text: content.text.text, text: content.text.text,
entities: content.text.entities.filter((entity) => entity.type !== ApiMessageEntityTypes.CustomEmoji), entities: content.text.entities.filter((entity) => entity.type !== ApiMessageEntityTypes.CustomEmoji),
} : content.text; } : content.text;
const textWithTimestamps = strippedText && addTimestampEntities(strippedText);
const emojiOnlyCount = getEmojiOnlyCountForMessage(content, groupedId); const emojiOnlyCount = getEmojiOnlyCountForMessage(content, groupedId);
const updatedContent = { const updatedContent = {
...content, ...content,
text: !shouldHideText ? strippedText : undefined, text: !shouldHideText ? textWithTimestamps : undefined,
}; };
// TODO Prepare reply info between forwarded messages locally, to prevent height jumps // TODO Prepare reply info between forwarded messages locally, to prevent height jumps

View File

@ -113,6 +113,7 @@ export interface ApiVideo {
size: number; size: number;
noSound?: boolean; noSound?: boolean;
waveform?: number[]; waveform?: number[];
timestamp?: number;
} }
export interface ApiAudio { export interface ApiAudio {
@ -410,7 +411,7 @@ export type ApiMessageEntityDefault = {
type: Exclude< type: Exclude<
`${ApiMessageEntityTypes}`, `${ApiMessageEntityTypes}`,
`${ApiMessageEntityTypes.Pre}` | `${ApiMessageEntityTypes.TextUrl}` | `${ApiMessageEntityTypes.MentionName}` | `${ApiMessageEntityTypes.Pre}` | `${ApiMessageEntityTypes.TextUrl}` | `${ApiMessageEntityTypes.MentionName}` |
`${ApiMessageEntityTypes.CustomEmoji}` | `${ApiMessageEntityTypes.Blockquote}` `${ApiMessageEntityTypes.CustomEmoji}` | `${ApiMessageEntityTypes.Blockquote}` | `${ApiMessageEntityTypes.Timestamp}`
>; >;
offset: number; offset: number;
length: number; length: number;
@ -451,8 +452,15 @@ export type ApiMessageEntityCustomEmoji = {
documentId: string; documentId: string;
}; };
export type ApiMessageEntityTimestamp = {
type: ApiMessageEntityTypes.Timestamp;
offset: number;
length: number;
timestamp: number;
};
export type ApiMessageEntity = ApiMessageEntityDefault | ApiMessageEntityPre | ApiMessageEntityTextUrl | export type ApiMessageEntity = ApiMessageEntityDefault | ApiMessageEntityPre | ApiMessageEntityTextUrl |
ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote; ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote | ApiMessageEntityTimestamp;
export enum ApiMessageEntityTypes { export enum ApiMessageEntityTypes {
Bold = 'MessageEntityBold', Bold = 'MessageEntityBold',
@ -473,6 +481,7 @@ export enum ApiMessageEntityTypes {
Underline = 'MessageEntityUnderline', Underline = 'MessageEntityUnderline',
Spoiler = 'MessageEntitySpoiler', Spoiler = 'MessageEntitySpoiler',
CustomEmoji = 'MessageEntityCustomEmoji', CustomEmoji = 'MessageEntityCustomEmoji',
Timestamp = 'MessageEntityTimestamp',
Unknown = 'MessageEntityUnknown', Unknown = 'MessageEntityUnknown',
} }

View File

@ -4,6 +4,7 @@ import React, {
import type { ApiFormattedText, ApiMessage, ApiStory } from '../../api/types'; import type { ApiFormattedText, ApiMessage, ApiStory } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { ThreadId } from '../../types';
import { ApiMessageEntityTypes } from '../../api/types'; import { ApiMessageEntityTypes } from '../../api/types';
import { CONTENT_NOT_SUPPORTED } from '../../config'; import { CONTENT_NOT_SUPPORTED } from '../../config';
@ -16,6 +17,7 @@ import useUniqueId from '../../hooks/useUniqueId';
interface OwnProps { interface OwnProps {
messageOrStory: ApiMessage | ApiStory; messageOrStory: ApiMessage | ApiStory;
threadId?: ThreadId;
translatedText?: ApiFormattedText; translatedText?: ApiFormattedText;
isForAnimation?: boolean; isForAnimation?: boolean;
emojiSize?: number; emojiSize?: number;
@ -32,6 +34,7 @@ interface OwnProps {
focusedQuote?: string; focusedQuote?: string;
isInSelectMode?: boolean; isInSelectMode?: boolean;
canBeEmpty?: boolean; canBeEmpty?: boolean;
maxTimestamp?: number;
} }
const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3; const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3;
@ -54,6 +57,8 @@ function MessageText({
focusedQuote, focusedQuote,
isInSelectMode, isInSelectMode,
canBeEmpty, canBeEmpty,
maxTimestamp,
threadId,
}: OwnProps) { }: OwnProps) {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const sharedCanvasRef = useRef<HTMLCanvasElement>(null); const sharedCanvasRef = useRef<HTMLCanvasElement>(null);
@ -109,6 +114,10 @@ function MessageText({
forcePlayback, forcePlayback,
focusedQuote, focusedQuote,
isInSelectMode, isInSelectMode,
maxTimestamp,
chatId: 'chatId' in messageOrStory ? messageOrStory.chatId : undefined,
messageId: messageOrStory.id,
threadId,
}), }),
].flat().filter(Boolean)} ].flat().filter(Boolean)}
</> </>

View File

@ -2,7 +2,7 @@ import { getGlobal } from '../../../global';
import type { ApiMessage, ApiSponsoredMessage } from '../../../api/types'; import type { ApiMessage, ApiSponsoredMessage } from '../../../api/types';
import type { OldLangFn } from '../../../hooks/useOldLang'; import type { OldLangFn } from '../../../hooks/useOldLang';
import type { TextPart } from '../../../types'; import type { TextPart, ThreadId } from '../../../types';
import { ApiMessageEntityTypes } from '../../../api/types'; import { ApiMessageEntityTypes } from '../../../api/types';
import { import {
@ -30,6 +30,8 @@ export function renderMessageText({
forcePlayback, forcePlayback,
shouldRenderAsHtml, shouldRenderAsHtml,
isForMediaViewer, isForMediaViewer,
threadId,
maxTimestamp,
} : { } : {
message: ApiMessage | ApiSponsoredMessage; message: ApiMessage | ApiSponsoredMessage;
highlight?: string; highlight?: string;
@ -40,6 +42,8 @@ export function renderMessageText({
forcePlayback?: boolean; forcePlayback?: boolean;
shouldRenderAsHtml?: boolean; shouldRenderAsHtml?: boolean;
isForMediaViewer?: boolean; isForMediaViewer?: boolean;
threadId?: ThreadId;
maxTimestamp?: number;
}) { }) {
const { text, entities } = message.content.text || {}; const { text, entities } = message.content.text || {};
@ -60,6 +64,10 @@ export function renderMessageText({
asPreview, asPreview,
isProtected, isProtected,
forcePlayback, forcePlayback,
messageId: 'id' in message ? message.id : undefined,
chatId: message.chatId,
threadId,
maxTimestamp,
}); });
} }

View File

@ -3,7 +3,7 @@ import { getActions } from '../../../global';
import type { ApiFormattedText, ApiMessageEntity } from '../../../api/types'; import type { ApiFormattedText, ApiMessageEntity } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { TextPart } from '../../../types'; import type { TextPart, ThreadId } from '../../../types';
import type { TextFilter } from './renderText'; import type { TextFilter } from './renderText';
import { ApiMessageEntityTypes } from '../../../api/types'; import { ApiMessageEntityTypes } from '../../../api/types';
@ -48,6 +48,10 @@ export function renderTextWithEntities({
noCustomEmojiPlayback, noCustomEmojiPlayback,
focusedQuote, focusedQuote,
isInSelectMode, isInSelectMode,
chatId,
messageId,
threadId,
maxTimestamp,
}: { }: {
text: string; text: string;
entities?: ApiMessageEntity[]; entities?: ApiMessageEntity[];
@ -67,6 +71,10 @@ export function renderTextWithEntities({
noCustomEmojiPlayback?: boolean; noCustomEmojiPlayback?: boolean;
focusedQuote?: string; focusedQuote?: string;
isInSelectMode?: boolean; isInSelectMode?: boolean;
chatId?: string;
messageId?: number;
threadId?: ThreadId;
maxTimestamp?: number;
}) { }) {
if (!entities?.length) { if (!entities?.length) {
return renderMessagePart({ return renderMessagePart({
@ -172,6 +180,10 @@ export function renderTextWithEntities({
forcePlayback, forcePlayback,
noCustomEmojiPlayback, noCustomEmojiPlayback,
isInSelectMode, isInSelectMode,
chatId,
messageId,
threadId,
maxTimestamp,
}); });
if (Array.isArray(newEntity)) { if (Array.isArray(newEntity)) {
@ -383,6 +395,10 @@ function processEntity({
forcePlayback, forcePlayback,
noCustomEmojiPlayback, noCustomEmojiPlayback,
isInSelectMode, isInSelectMode,
chatId,
messageId,
threadId,
maxTimestamp,
} : { } : {
entity: ApiMessageEntity; entity: ApiMessageEntity;
entityContent: TextPart; entityContent: TextPart;
@ -402,6 +418,10 @@ function processEntity({
forcePlayback?: boolean; forcePlayback?: boolean;
noCustomEmojiPlayback?: boolean; noCustomEmojiPlayback?: boolean;
isInSelectMode?: boolean; isInSelectMode?: boolean;
chatId?: string;
messageId?: number;
threadId?: ThreadId;
maxTimestamp?: number;
}) { }) {
const entityText = typeof entityContent === 'string' && entityContent; const entityText = typeof entityContent === 'string' && entityContent;
const renderedContent = nestedEntityContent.length ? nestedEntityContent : entityContent; const renderedContent = nestedEntityContent.length ? nestedEntityContent : entityContent;
@ -559,6 +579,21 @@ function processEntity({
); );
case ApiMessageEntityTypes.Underline: case ApiMessageEntityTypes.Underline:
return <ins data-entity-type={entity.type}>{renderNestedMessagePart()}</ins>; return <ins data-entity-type={entity.type}>{renderNestedMessagePart()}</ins>;
case ApiMessageEntityTypes.Timestamp:
if (!chatId || !messageId || !maxTimestamp || entity.timestamp > maxTimestamp) {
return renderNestedMessagePart();
}
return (
<a
onClick={() => handleTimecodeClick(chatId, messageId, threadId, entity.timestamp)}
className="text-entity-link"
dir="auto"
data-entity-type={entity.type}
>
{renderNestedMessagePart()}
</a>
);
case ApiMessageEntityTypes.Spoiler: case ApiMessageEntityTypes.Spoiler:
return <Spoiler containerId={containerId}>{renderNestedMessagePart()}</Spoiler>; return <Spoiler containerId={containerId}>{renderNestedMessagePart()}</Spoiler>;
case ApiMessageEntityTypes.CustomEmoji: case ApiMessageEntityTypes.CustomEmoji:
@ -677,3 +712,11 @@ function handleCodeClick(e: React.MouseEvent<HTMLElement>) {
message: oldTranslate('TextCopied'), message: oldTranslate('TextCopied'),
}); });
} }
function handleTimecodeClick(
chatId: string, messageId: number, threadId: ThreadId | undefined, timestamp: number,
) {
getActions().openMediaFromTimestamp({
chatId, messageId, threadId, timestamp,
});
}

View File

@ -245,7 +245,9 @@ const MediaViewer = ({
const handleClose = useLastCallback(() => closeMediaViewer()); const handleClose = useLastCallback(() => closeMediaViewer());
const handleFooterClick = useLastCallback(() => { const handleFooterClick = useLastCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (e.target instanceof HTMLElement && e.target.closest('a')) return; // Prevent closing on timestamp click
handleClose(); handleClose();
if (!chatId || !messageId) return; if (!chatId || !messageId) return;

View File

@ -1,14 +1,15 @@
import React, { memo } from '../../lib/teact/teact'; import React, { memo } from '../../lib/teact/teact';
import { withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
import type { import type {
ApiDimensions, ApiMessage, ApiSponsoredMessage, ApiDimensions, ApiMessage, ApiSponsoredMessage,
} from '../../api/types'; } from '../../api/types';
import type { MediaViewerOrigin } from '../../types'; import type { MediaViewerOrigin, ThreadId } from '../../types';
import type { MediaViewerItem } from './helpers/getViewableMedia'; import type { MediaViewerItem } from './helpers/getViewableMedia';
import { MEDIA_TIMESTAMP_SAVE_MINIMUM_DURATION } from '../../config';
import { import {
selectIsMessageProtected, selectTabState, selectIsMessageProtected, selectMessageTimestampableDuration, selectTabState,
} from '../../global/selectors'; } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import stopEvent from '../../util/stopEvent'; import stopEvent from '../../util/stopEvent';
@ -18,8 +19,11 @@ import { renderMessageText } from '../common/helpers/renderMessageText';
import getViewableMedia from './helpers/getViewableMedia'; import getViewableMedia from './helpers/getViewableMedia';
import useAppLayout from '../../hooks/useAppLayout'; import useAppLayout from '../../hooks/useAppLayout';
import useCurrentTimeSignal from '../../hooks/useCurrentTimeSignal';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang'; import useOldLang from '../../hooks/useOldLang';
import { useSignalEffect } from '../../hooks/useSignalEffect';
import useThrottledCallback from '../../hooks/useThrottledCallback';
import useControlsSignal from './hooks/useControlsSignal'; import useControlsSignal from './hooks/useControlsSignal';
import { useMediaProps } from './hooks/useMediaProps'; import { useMediaProps } from './hooks/useMediaProps';
@ -35,7 +39,7 @@ type OwnProps = {
withAnimation?: boolean; withAnimation?: boolean;
isMoving?: boolean; isMoving?: boolean;
onClose: () => void; onClose: () => void;
onFooterClick: () => void; onFooterClick: (e: React.MouseEvent<HTMLDivElement>) => void;
handleSponsoredClick: () => void; handleSponsoredClick: () => void;
}; };
@ -47,10 +51,14 @@ type StateProps = {
isMuted: boolean; isMuted: boolean;
isHidden?: boolean; isHidden?: boolean;
playbackRate: number; playbackRate: number;
threadId?: ThreadId;
timestamp?: number;
maxTimestamp?: number;
}; };
const ANIMATION_DURATION = 350; const ANIMATION_DURATION = 350;
const MOBILE_VERSION_CONTROL_WIDTH = 350; const MOBILE_VERSION_CONTROL_WIDTH = 350;
const PLAYBACK_SAVE_INTERVAL = 1000;
const MediaViewerContent = ({ const MediaViewerContent = ({
item, item,
@ -64,10 +72,15 @@ const MediaViewerContent = ({
isMuted, isMuted,
isHidden, isHidden,
isMoving, isMoving,
threadId,
timestamp,
maxTimestamp,
onClose, onClose,
onFooterClick, onFooterClick,
handleSponsoredClick, handleSponsoredClick,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { updateLastPlaybackTimestamp } = getActions();
const lang = useOldLang(); const lang = useOldLang();
const isAvatar = item.type === 'avatar'; const isAvatar = item.type === 'avatar';
@ -90,6 +103,7 @@ const MediaViewerContent = ({
}); });
const [, toggleControls] = useControlsSignal(); const [, toggleControls] = useControlsSignal();
const [getCurrentTime] = useCurrentTimeSignal();
const isOpen = Boolean(media); const isOpen = Boolean(media);
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
@ -98,6 +112,21 @@ const MediaViewerContent = ({
toggleControls(true); 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 (!media) return undefined;
if (item.type === 'avatar') { if (item.type === 'avatar') {
@ -143,7 +172,9 @@ const MediaViewerContent = ({
const textParts = textMessage && (textMessage.content.action?.type === 'suggestProfilePhoto' const textParts = textMessage && (textMessage.content.action?.type === 'suggestProfilePhoto'
? lang('Conversation.SuggestedPhotoTitle') ? 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 buttonText = textMessage && 'buttonText' in textMessage ? textMessage.buttonText : undefined;
const hasFooter = Boolean(textParts); const hasFooter = Boolean(textParts);
const posterSize = calculateMediaViewerDimensions(dimensions!, hasFooter, isVideo); const posterSize = calculateMediaViewerDimensions(dimensions!, hasFooter, isVideo);
@ -187,6 +218,7 @@ const MediaViewerContent = ({
playbackRate={playbackRate} playbackRate={playbackRate}
isSponsoredMessage={isSponsoredMessage} isSponsoredMessage={isSponsoredMessage}
handleSponsoredClick={handleSponsoredClick} handleSponsoredClick={handleSponsoredClick}
timestamp={timestamp}
/> />
))} ))}
{textParts && ( {textParts && (
@ -212,11 +244,15 @@ export default memo(withGlobal<OwnProps>(
playbackRate, playbackRate,
isHidden, isHidden,
origin, origin,
timestamp,
threadId,
} = selectTabState(global).mediaViewer; } = selectTabState(global).mediaViewer;
const message = item.type === 'message' ? item.message : undefined; const message = item.type === 'message' ? item.message : undefined;
const sponsoredMessage = item.type === 'sponsoredMessage' ? item.message : undefined; const sponsoredMessage = item.type === 'sponsoredMessage' ? item.message : undefined;
const textMessage = message || sponsoredMessage; const textMessage = message || sponsoredMessage;
const maxTimestamp = message && selectMessageTimestampableDuration(global, message, true);
return { return {
origin, origin,
textMessage, textMessage,
@ -225,6 +261,9 @@ export default memo(withGlobal<OwnProps>(
isMuted, isMuted,
isHidden, isHidden,
playbackRate, playbackRate,
threadId,
timestamp,
maxTimestamp,
}; };
}, },
)(MediaViewerContent)); )(MediaViewerContent));

View File

@ -22,7 +22,7 @@ const RESIZE_THROTTLE_MS = 500;
type OwnProps = { type OwnProps = {
text: TextPart | TextPart[]; text: TextPart | TextPart[];
buttonText?: string; buttonText?: string;
onClick: () => void; onClick: (e: React.MouseEvent<HTMLDivElement>) => void;
handleSponsoredClick: (isFromMedia?: boolean) => void; handleSponsoredClick: (isFromMedia?: boolean) => void;
isForVideo: boolean; isForVideo: boolean;
isForceMobileVersion?: boolean; isForceMobileVersion?: boolean;

View File

@ -55,7 +55,7 @@ type OwnProps = {
getNextItem: (from: MediaViewerItem, direction: number) => MediaViewerItem | undefined; getNextItem: (from: MediaViewerItem, direction: number) => MediaViewerItem | undefined;
selectItem: (item: MediaViewerItem) => void; selectItem: (item: MediaViewerItem) => void;
loadMoreItemsIfNeeded: (item: MediaViewerItem) => void; loadMoreItemsIfNeeded: (item: MediaViewerItem) => void;
onFooterClick: () => void; onFooterClick: (e: React.MouseEvent<HTMLDivElement>) => void;
handleSponsoredClick: (isFromMedia?: boolean) => void; handleSponsoredClick: (isFromMedia?: boolean) => void;
onClose: () => void; onClose: () => void;
}; };

View File

@ -47,10 +47,11 @@ type OwnProps = {
isProtected?: boolean; isProtected?: boolean;
shouldCloseOnClick?: boolean; shouldCloseOnClick?: boolean;
isForceMobileVersion?: boolean; isForceMobileVersion?: boolean;
onClose: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
isClickDisabled?: boolean; isClickDisabled?: boolean;
isSponsoredMessage?: boolean; isSponsoredMessage?: boolean;
timestamp?: number;
handleSponsoredClick?: (isFromMedia?: boolean) => void; handleSponsoredClick?: (isFromMedia?: boolean) => void;
onClose: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
}; };
const MAX_LOOP_DURATION = 30; // Seconds const MAX_LOOP_DURATION = 30; // Seconds
@ -69,14 +70,15 @@ const VideoPlayer: FC<OwnProps> = ({
volume, volume,
isMuted, isMuted,
playbackRate, playbackRate,
onClose,
isForceMobileVersion, isForceMobileVersion,
shouldCloseOnClick, shouldCloseOnClick,
isProtected, isProtected,
isClickDisabled, isClickDisabled,
isPreviewDisabled, isPreviewDisabled,
isSponsoredMessage, isSponsoredMessage,
timestamp,
handleSponsoredClick, handleSponsoredClick,
onClose,
}) => { }) => {
const { const {
setMediaViewerVolume, setMediaViewerVolume,
@ -141,6 +143,9 @@ const VideoPlayer: FC<OwnProps> = ({
IS_IOS && !isPlaying && !shouldRenderSpinner && !isUnsupported, undefined, undefined, 'slow', IS_IOS && !isPlaying && !shouldRenderSpinner && !isUnsupported, undefined, undefined, 'slow',
); );
const [, setCurrentTime] = useCurrentTimeSignal();
const [, setIsVideoWaiting] = useVideoWaitingSignal();
useEffect(() => { useEffect(() => {
lockControls(shouldRenderSpinner); lockControls(shouldRenderSpinner);
}, [lockControls, shouldRenderSpinner]); }, [lockControls, shouldRenderSpinner]);
@ -164,6 +169,12 @@ const VideoPlayer: FC<OwnProps> = ({
videoRef.current!.playbackRate = playbackRate; videoRef.current!.playbackRate = playbackRate;
}, [playbackRate]); }, [playbackRate]);
useEffect(() => {
if (!timestamp) return;
videoRef.current!.currentTime = timestamp;
setCurrentTime(timestamp);
}, [setCurrentTime, timestamp]);
const togglePlayState = useLastCallback((e: React.MouseEvent<HTMLElement, MouseEvent> | KeyboardEvent) => { const togglePlayState = useLastCallback((e: React.MouseEvent<HTMLElement, MouseEvent> | KeyboardEvent) => {
e.stopPropagation(); e.stopPropagation();
if (isPlaying) { if (isPlaying) {
@ -192,9 +203,6 @@ const VideoPlayer: FC<OwnProps> = ({
useVideoCleanup(videoRef, bufferingHandlers); useVideoCleanup(videoRef, bufferingHandlers);
const [, setCurrentTime] = useCurrentTimeSignal();
const [, setIsVideoWaiting] = useVideoWaitingSignal();
const handleTimeUpdate = useLastCallback((e: React.SyntheticEvent<HTMLVideoElement>) => { const handleTimeUpdate = useLastCallback((e: React.SyntheticEvent<HTMLVideoElement>) => {
const video = e.currentTarget; const video = e.currentTarget;
if (video.readyState >= MIN_READY_STATE) { if (video.readyState >= MIN_READY_STATE) {

View File

@ -92,11 +92,14 @@ import {
selectIsMessageProtected, selectIsMessageProtected,
selectIsMessageSelected, selectIsMessageSelected,
selectMessageIdsByGroupId, selectMessageIdsByGroupId,
selectMessageLastPlaybackTimestamp,
selectMessageTimestampableDuration,
selectOutgoingStatus, selectOutgoingStatus,
selectPeer, selectPeer,
selectPeerStory, selectPeerStory,
selectPerformanceSettingsValue, selectPerformanceSettingsValue,
selectPollFromMessage, selectPollFromMessage,
selectReplyMessage,
selectRequestedChatTranslationLanguage, selectRequestedChatTranslationLanguage,
selectRequestedMessageTranslationLanguage, selectRequestedMessageTranslationLanguage,
selectSender, selectSender,
@ -296,6 +299,8 @@ type StateProps = {
viaBusinessBot?: ApiUser; viaBusinessBot?: ApiUser;
effect?: ApiAvailableEffect; effect?: ApiAvailableEffect;
poll?: ApiPoll; poll?: ApiPoll;
maxTimestamp?: number;
lastPlaybackTimestamp?: number;
}; };
type MetaPosition = type MetaPosition =
@ -413,6 +418,8 @@ const Message: FC<OwnProps & StateProps> = ({
viaBusinessBot, viaBusinessBot,
effect, effect,
poll, poll,
maxTimestamp,
lastPlaybackTimestamp,
onIntersectPinnedMessage, onIntersectPinnedMessage,
}) => { }) => {
const { const {
@ -609,6 +616,7 @@ const Message: FC<OwnProps & StateProps> = ({
handleViaBotClick, handleViaBotClick,
handleReplyClick, handleReplyClick,
handleMediaClick, handleMediaClick,
handleDocumentClick,
handleAudioPlay, handleAudioPlay,
handleAlbumMediaClick, handleAlbumMediaClick,
handlePhotoMediaClick, handlePhotoMediaClick,
@ -645,6 +653,7 @@ const Message: FC<OwnProps & StateProps> = ({
isReplyPrivate, isReplyPrivate,
isRepliesChat, isRepliesChat,
isSavedMessages: isChatWithSelf, isSavedMessages: isChatWithSelf,
lastPlaybackTimestamp,
}); });
const handleEffectClick = useLastCallback((e: React.MouseEvent<HTMLDivElement>) => { const handleEffectClick = useLastCallback((e: React.MouseEvent<HTMLDivElement>) => {
@ -961,6 +970,8 @@ const Message: FC<OwnProps & StateProps> = ({
withTranslucentThumbs={isCustomShape} withTranslucentThumbs={isCustomShape}
isInSelectMode={isInSelectMode} isInSelectMode={isInSelectMode}
canBeEmpty={hasFactCheck} canBeEmpty={hasFactCheck}
maxTimestamp={maxTimestamp}
threadId={threadId}
/> />
); );
} }
@ -1194,7 +1205,7 @@ const Message: FC<OwnProps & StateProps> = ({
uploadProgress={uploadProgress} uploadProgress={uploadProgress}
isSelectable={isInDocumentGroup} isSelectable={isInDocumentGroup}
isSelected={isSelected} isSelected={isSelected}
onMediaClick={handleMediaClick} onMediaClick={handleDocumentClick}
onCancelUpload={handleCancelUpload} onCancelUpload={handleCancelUpload}
isDownloading={isDownloading} isDownloading={isDownloading}
shouldWarnAboutSvg={shouldWarnAboutSvg} shouldWarnAboutSvg={shouldWarnAboutSvg}
@ -1357,11 +1368,13 @@ const Message: FC<OwnProps & StateProps> = ({
theme={theme} theme={theme}
story={webPageStory} story={webPageStory}
isConnected={isConnected} isConnected={isConnected}
lastPlaybackTimestamp={lastPlaybackTimestamp}
backgroundEmojiId={messageColorPeer?.color?.backgroundEmojiId} backgroundEmojiId={messageColorPeer?.color?.backgroundEmojiId}
shouldWarnAboutSvg={shouldWarnAboutSvg} shouldWarnAboutSvg={shouldWarnAboutSvg}
autoLoadFileMaxSizeMb={autoLoadFileMaxSizeMb} autoLoadFileMaxSizeMb={autoLoadFileMaxSizeMb}
onAudioPlay={handleAudioPlay} onAudioPlay={handleAudioPlay}
onMediaClick={handleMediaClick} onMediaClick={handleMediaClick}
onDocumentClick={handleDocumentClick}
onCancelMediaTransfer={handleCancelUpload} onCancelMediaTransfer={handleCancelUpload}
/> />
); );
@ -1414,6 +1427,7 @@ const Message: FC<OwnProps & StateProps> = ({
isDownloading={isDownloading} isDownloading={isDownloading}
isProtected={isProtected} isProtected={isProtected}
asForwarded={asForwarded} asForwarded={asForwarded}
lastPlaybackTimestamp={lastPlaybackTimestamp}
onClick={handleVideoMediaClick} onClick={handleVideoMediaClick}
onCancelUpload={handleCancelUpload} onCancelUpload={handleCancelUpload}
/> />
@ -1724,7 +1738,7 @@ export default memo(withGlobal<OwnProps>(
const { peerId: storyReplyPeerId, storyId: storyReplyId } = getStoryReplyInfo(message) || {}; const { peerId: storyReplyPeerId, storyId: storyReplyId } = getStoryReplyInfo(message) || {};
const shouldHideReply = replyToMsgId && replyToMsgId === threadId; const shouldHideReply = replyToMsgId && replyToMsgId === threadId;
const replyMessage = replyToMsgId ? selectChatMessage(global, replyToPeerId || chatId, replyToMsgId) : undefined; const replyMessage = selectReplyMessage(global, message);
const forwardHeader = forwardInfo || replyFrom; const forwardHeader = forwardInfo || replyFrom;
const replyMessageSender = replyMessage ? selectSender(global, replyMessage) const replyMessageSender = replyMessage ? selectSender(global, replyMessage)
: forwardHeader && !isSystemBotChat && !isAnonymousForwards : forwardHeader && !isSystemBotChat && !isAnonymousForwards
@ -1813,6 +1827,10 @@ export default memo(withGlobal<OwnProps>(
const poll = selectPollFromMessage(global, message); const poll = selectPollFromMessage(global, message);
const maxTimestamp = selectMessageTimestampableDuration(global, message);
const lastPlaybackTimestamp = selectMessageLastPlaybackTimestamp(global, chatId, message.id);
return { return {
theme: selectTheme(global), theme: selectTheme(global),
forceSenderName, forceSenderName,
@ -1900,6 +1918,8 @@ export default memo(withGlobal<OwnProps>(
viaBusinessBot, viaBusinessBot,
effect, effect,
poll, poll,
maxTimestamp,
lastPlaybackTimestamp,
}; };
}, },
)(Message)); )(Message));

View File

@ -34,6 +34,7 @@ import ProgressSpinner from '../../ui/ProgressSpinner';
export type OwnProps<T> = { export type OwnProps<T> = {
id?: string; id?: string;
video: ApiVideo | ApiMediaExtendedPreview; video: ApiVideo | ApiMediaExtendedPreview;
lastPlaybackTimestamp?: number;
isOwn?: boolean; isOwn?: boolean;
isInWebPage?: boolean; isInWebPage?: boolean;
observeIntersectionForLoading?: ObserveFn; observeIntersectionForLoading?: ObserveFn;
@ -71,6 +72,7 @@ const Video = <T,>({
isDownloading, isDownloading,
isProtected, isProtected,
className, className,
lastPlaybackTimestamp,
clickArg, clickArg,
onClick, onClick,
onCancelUpload, onCancelUpload,
@ -302,6 +304,12 @@ const Video = <T,>({
{isUnsupported && <Icon name="message-failed" className="playback-failed" />} {isUnsupported && <Icon name="message-failed" className="playback-failed" />}
</div> </div>
)} )}
{Boolean(lastPlaybackTimestamp) && (
<div
className="message-media-last-progress"
style={`--_progress: ${Math.floor((lastPlaybackTimestamp / duration) * 100)}%`}
/>
)}
</div> </div>
); );
}; };

View File

@ -1,5 +1,5 @@
import type { FC } from '../../../lib/teact/teact'; 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 { getActions, withGlobal } from '../../../global';
import type { ApiMessage, ApiTypeStory } from '../../../api/types'; import type { ApiMessage, ApiTypeStory } from '../../../api/types';
@ -9,6 +9,7 @@ import { AudioOrigin, type ISettings } from '../../../types';
import { getMessageWebPage } from '../../../global/helpers'; import { getMessageWebPage } from '../../../global/helpers';
import { selectCanPlayAnimatedEmojis } from '../../../global/selectors'; import { selectCanPlayAnimatedEmojis } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { tryParseDeepLink } from '../../../util/deepLinkParser';
import trimText from '../../../util/trimText'; import trimText from '../../../util/trimText';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
import { calculateMediaDimensions } from './helpers/mediaDimensions'; import { calculateMediaDimensions } from './helpers/mediaDimensions';
@ -43,8 +44,6 @@ const EMOJI_SIZE = 38;
type OwnProps = { type OwnProps = {
message: ApiMessage; message: ApiMessage;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
noAvatars?: boolean; noAvatars?: boolean;
canAutoLoad?: boolean; canAutoLoad?: boolean;
canAutoPlay?: boolean; canAutoPlay?: boolean;
@ -58,11 +57,15 @@ type OwnProps = {
story?: ApiTypeStory; story?: ApiTypeStory;
shouldWarnAboutSvg?: boolean; shouldWarnAboutSvg?: boolean;
autoLoadFileMaxSizeMb?: number; autoLoadFileMaxSizeMb?: number;
lastPlaybackTimestamp?: number;
isEditing?: boolean;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
onAudioPlay?: NoneToVoidFunction; onAudioPlay?: NoneToVoidFunction;
onMediaClick?: NoneToVoidFunction; onMediaClick?: NoneToVoidFunction;
onDocumentClick?: NoneToVoidFunction;
onCancelMediaTransfer?: NoneToVoidFunction; onCancelMediaTransfer?: NoneToVoidFunction;
onContainerClick?: ((e: React.MouseEvent) => void); onContainerClick?: ((e: React.MouseEvent) => void);
isEditing?: boolean;
}; };
type StateProps = { type StateProps = {
canPlayAnimatedEmojis: boolean; canPlayAnimatedEmojis: boolean;
@ -70,8 +73,6 @@ type StateProps = {
const WebPage: FC<OwnProps & StateProps> = ({ const WebPage: FC<OwnProps & StateProps> = ({
message, message,
observeIntersectionForLoading,
observeIntersectionForPlaying,
noAvatars, noAvatars,
canAutoLoad, canAutoLoad,
canAutoPlay, canAutoPlay,
@ -85,11 +86,15 @@ const WebPage: FC<OwnProps & StateProps> = ({
backgroundEmojiId, backgroundEmojiId,
shouldWarnAboutSvg, shouldWarnAboutSvg,
autoLoadFileMaxSizeMb, autoLoadFileMaxSizeMb,
lastPlaybackTimestamp,
isEditing,
observeIntersectionForLoading,
observeIntersectionForPlaying,
onMediaClick, onMediaClick,
onDocumentClick,
onContainerClick, onContainerClick,
onAudioPlay, onAudioPlay,
onCancelMediaTransfer, onCancelMediaTransfer,
isEditing,
}) => { }) => {
const { openUrl, openTelegramLink } = getActions(); const { openUrl, openTelegramLink } = getActions();
const webPage = getMessageWebPage(message); const webPage = getMessageWebPage(message);
@ -123,6 +128,12 @@ const WebPage: FC<OwnProps & StateProps> = ({
const hasCustomColor = stickers?.isWithTextColor || stickers?.documents?.[0]?.shouldUseTextColor; const hasCustomColor = stickers?.isWithTextColor || stickers?.documents?.[0]?.shouldUseTextColor;
const customColor = useDynamicColorListener(stickersRef, undefined, !hasCustomColor); 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) { if (!webPage) {
return undefined; return undefined;
} }
@ -265,6 +276,7 @@ const WebPage: FC<OwnProps & StateProps> = ({
asForwarded={asForwarded} asForwarded={asForwarded}
isDownloading={isDownloading} isDownloading={isDownloading}
isProtected={isProtected} isProtected={isProtected}
lastPlaybackTimestamp={lastPlaybackTimestamp || linkTimestamp}
onClick={isMediaInteractive ? handleMediaClick : undefined} onClick={isMediaInteractive ? handleMediaClick : undefined}
onCancelUpload={onCancelMediaTransfer} onCancelUpload={onCancelMediaTransfer}
/> />
@ -286,7 +298,7 @@ const WebPage: FC<OwnProps & StateProps> = ({
message={message} message={message}
observeIntersection={observeIntersectionForLoading} observeIntersection={observeIntersectionForLoading}
autoLoadFileMaxSizeMb={autoLoadFileMaxSizeMb} autoLoadFileMaxSizeMb={autoLoadFileMaxSizeMb}
onMediaClick={handleMediaClick} onMediaClick={onDocumentClick}
onCancelUpload={onCancelMediaTransfer} onCancelUpload={onCancelMediaTransfer}
isDownloading={isDownloading} isDownloading={isDownloading}
shouldWarnAboutSvg={shouldWarnAboutSvg} shouldWarnAboutSvg={shouldWarnAboutSvg}

View File

@ -1,4 +1,3 @@
import type React from '../../../../lib/teact/teact';
import { getActions } from '../../../../global'; import { getActions } from '../../../../global';
import type { import type {
@ -9,7 +8,9 @@ import type { IAlbum, ThreadId } from '../../../../types';
import { MAIN_THREAD_ID } from '../../../../api/types'; import { MAIN_THREAD_ID } from '../../../../api/types';
import { MediaViewerOrigin } from '../../../../types'; import { MediaViewerOrigin } from '../../../../types';
import { getMessagePhoto, getMessageWebPagePhoto } from '../../../../global/helpers';
import { getMessageReplyInfo } from '../../../../global/helpers/replies'; import { getMessageReplyInfo } from '../../../../global/helpers/replies';
import { tryParseDeepLink } from '../../../../util/deepLinkParser';
import useLastCallback from '../../../../hooks/useLastCallback'; import useLastCallback from '../../../../hooks/useLastCallback';
@ -31,6 +32,7 @@ export default function useInnerHandlers({
isReplyPrivate, isReplyPrivate,
isRepliesChat, isRepliesChat,
isSavedMessages, isSavedMessages,
lastPlaybackTimestamp,
}: { }: {
lang: OldLangFn; lang: OldLangFn;
selectMessage: (e: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => void; selectMessage: (e: React.MouseEvent<HTMLDivElement, MouseEvent>, groupedId?: string) => void;
@ -50,6 +52,7 @@ export default function useInnerHandlers({
isReplyPrivate?: boolean; isReplyPrivate?: boolean;
isRepliesChat?: boolean; isRepliesChat?: boolean;
isSavedMessages?: boolean; isSavedMessages?: boolean;
lastPlaybackTimestamp?: number;
}) { }) {
const { const {
openChat, showNotification, focusMessage, openMediaViewer, openAudioPlayer, openChat, showNotification, focusMessage, openMediaViewer, openAudioPlayer,
@ -58,7 +61,7 @@ export default function useInnerHandlers({
} = getActions(); } = getActions();
const { const {
id: messageId, forwardInfo, groupedId, content: { paidMedia }, id: messageId, forwardInfo, groupedId, content: { paidMedia, video, webPage },
} = message; } = message;
const { const {
@ -115,7 +118,7 @@ export default function useInnerHandlers({
}); });
}); });
const handleMediaClick = useLastCallback((): void => { const handleDocumentClick = useLastCallback((): void => {
openMediaViewer({ openMediaViewer({
chatId, chatId,
threadId, threadId,
@ -123,16 +126,24 @@ export default function useInnerHandlers({
origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline, origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline,
}); });
}); });
const openMediaViewerWithPhotoOrVideo = useLastCallback((withDynamicLoading: boolean): void => { const openMediaViewerWithPhotoOrVideo = useLastCallback((withDynamicLoading: boolean): void => {
if (paidMedia && !paidMedia.isBought) return; if (paidMedia && !paidMedia.isBought) return;
if (withDynamicLoading) { if (withDynamicLoading) {
searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: messageId }); 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({ openMediaViewer({
chatId, chatId,
threadId, threadId,
messageId, messageId,
origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline, origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline,
timestamp: lastPlaybackTimestamp || videoContent?.timestamp || webpageTimestamp,
withDynamicLoading, withDynamicLoading,
}); });
}); });
@ -146,6 +157,15 @@ export default function useInnerHandlers({
openMediaViewerWithPhotoOrVideo(withDynamicLoading); openMediaViewerWithPhotoOrVideo(withDynamicLoading);
}); });
const handleMediaClick = useLastCallback((): void => {
const photo = getMessagePhoto(message) || getMessageWebPagePhoto(message);
if (photo) {
handlePhotoMediaClick();
}
handleVideoMediaClick();
});
const handleAudioPlay = useLastCallback((): void => { const handleAudioPlay = useLastCallback((): void => {
openAudioPlayer({ chatId, messageId }); openAudioPlayer({ chatId, messageId });
}); });
@ -268,6 +288,7 @@ export default function useInnerHandlers({
handleSenderClick, handleSenderClick,
handleViaBotClick, handleViaBotClick,
handleReplyClick, handleReplyClick,
handleDocumentClick,
handleMediaClick, handleMediaClick,
handleAudioPlay, handleAudioPlay,
handleAlbumMediaClick, handleAlbumMediaClick,

View File

@ -1,5 +1,5 @@
import type { FC } from '../../../lib/teact/teact'; 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 { getActions, withGlobal } from '../../../global';
import type { import type {
@ -57,6 +57,7 @@ type StateProps = {
playbackRate: number; playbackRate: number;
isPlaybackRateActive?: boolean; isPlaybackRateActive?: boolean;
isMuted: boolean; isMuted: boolean;
timestamp?: number;
}; };
const PLAYBACK_RATES: Record<number, number> = { const PLAYBACK_RATES: Record<number, number> = {
@ -82,6 +83,7 @@ const AudioPlayer: FC<OwnProps & StateProps> = ({
isPlaybackRateActive, isPlaybackRateActive,
isMuted, isMuted,
isFullWidth, isFullWidth,
timestamp,
onPaneStateChange, onPaneStateChange,
}) => { }) => {
const { const {
@ -117,6 +119,7 @@ const AudioPlayer: FC<OwnProps & StateProps> = ({
setVolume, setVolume,
toggleMuted, toggleMuted,
setPlaybackRate, setPlaybackRate,
setCurrentTime,
} = useAudioPlayer( } = useAudioPlayer(
message && makeTrackId(message), message && makeTrackId(message),
message ? getMediaDuration(message)! : 0, message ? getMediaDuration(message)! : 0,
@ -153,6 +156,12 @@ const AudioPlayer: FC<OwnProps & StateProps> = ({
handleContextMenuClose, handleContextMenuHide, handleContextMenuClose, handleContextMenuHide,
} = useContextMenuHandlers(transitionRef, !shouldRender); } = useContextMenuHandlers(transitionRef, !shouldRender);
useEffect(() => {
if (timestamp) {
setCurrentTime(timestamp);
}
}, [timestamp, setCurrentTime]);
const handleClick = useLastCallback(() => { const handleClick = useLastCallback(() => {
const { chatId, id } = renderingMessage!; const { chatId, id } = renderingMessage!;
focusMessage({ chatId, messageId: id }); focusMessage({ chatId, messageId: id });
@ -407,7 +416,7 @@ export default withGlobal<OwnProps>(
const sender = message && selectSender(global, message); const sender = message && selectSender(global, message);
const chat = message && selectChat(global, message.chatId); const chat = message && selectChat(global, message.chatId);
const { const {
volume, playbackRate, isMuted, isPlaybackRateActive, volume, playbackRate, isMuted, isPlaybackRateActive, timestamp,
} = selectTabState(global).audioPlayer; } = selectTabState(global).audioPlayer;
return { return {
@ -418,6 +427,7 @@ export default withGlobal<OwnProps>(
playbackRate, playbackRate,
isPlaybackRateActive, isPlaybackRateActive,
isMuted, isMuted,
timestamp,
}; };
}, },
)(AudioPlayer); )(AudioPlayer);

View File

@ -100,6 +100,8 @@ export const STORY_VIEWS_MIN_SEARCH = 15;
export const STORY_MIN_REACTIONS_SORT = 10; export const STORY_MIN_REACTIONS_SORT = 10;
export const STORY_VIEWS_MIN_CONTACTS_FILTER = 20; export const STORY_VIEWS_MIN_CONTACTS_FILTER = 20;
export const MEDIA_TIMESTAMP_SAVE_MINIMUM_DURATION = 30; // 30s
export const GLOBAL_SUGGESTED_CHANNELS_ID = 'global'; export const GLOBAL_SUGGESTED_CHANNELS_ID = 'global';
// As in Telegram for Android // As in Telegram for Android

View File

@ -1469,7 +1469,7 @@ addActionHandler('acceptChatInvite', async (global, actions, payload): Promise<v
addActionHandler('openChatByUsername', async (global, actions, payload): Promise<void> => { addActionHandler('openChatByUsername', async (global, actions, payload): Promise<void> => {
const { const {
username, messageId, commentId, startParam, startAttach, attach, threadId, originalParts, startApp, mode, username, messageId, commentId, startParam, startAttach, attach, threadId, originalParts, startApp, mode,
text, onChatChanged, choose, ref, text, onChatChanged, choose, ref, timestamp,
tabId = getCurrentTabId(), tabId = getCurrentTabId(),
} = payload; } = payload;
@ -1481,7 +1481,7 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise
if (startAttach === undefined && messageId && !startParam && !ref if (startAttach === undefined && messageId && !startParam && !ref
&& chat?.usernames?.some((c) => c.username === username)) { && chat?.usernames?.some((c) => c.username === username)) {
actions.focusMessage({ actions.focusMessage({
chatId: chat.id, threadId, messageId, tabId, chatId: chat.id, threadId, messageId, timestamp, tabId,
}); });
return; return;
} }
@ -1523,6 +1523,7 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise
startAttach, startAttach,
attach, attach,
text, text,
timestamp,
}, tabId, }, tabId,
); );
if (onChatChanged) { if (onChatChanged) {
@ -1542,6 +1543,14 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise
tabId, tabId,
focusMessageId: commentId, focusMessageId: commentId,
}); });
if (timestamp) {
actions.openMediaFromTimestamp({
chatId: usernameChat.id,
messageId: commentId,
timestamp,
tabId,
});
}
return; return;
} }
@ -1574,6 +1583,16 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise
tabId, tabId,
focusMessageId: commentId, focusMessageId: commentId,
}); });
if (timestamp) {
actions.openMediaFromTimestamp({
chatId: chatByUsername.id,
messageId: commentId || messageId!,
timestamp,
tabId,
});
}
if (onChatChanged) { if (onChatChanged) {
// @ts-ignore // @ts-ignore
actions[onChatChanged.action](onChatChanged.payload); actions[onChatChanged.action](onChatChanged.payload);
@ -1582,7 +1601,7 @@ addActionHandler('openChatByUsername', async (global, actions, payload): Promise
addActionHandler('openPrivateChannel', (global, actions, payload): ActionReturnType => { addActionHandler('openPrivateChannel', (global, actions, payload): ActionReturnType => {
const { const {
id, commentId, messageId, threadId, tabId = getCurrentTabId(), id, commentId, messageId, threadId, timestamp, tabId = getCurrentTabId(),
} = payload; } = payload;
const chat = selectChat(global, id); const chat = selectChat(global, id);
if (!chat) { if (!chat) {
@ -1600,10 +1619,19 @@ addActionHandler('openPrivateChannel', (global, actions, payload): ActionReturnT
return; return;
} }
if (timestamp) {
actions.openMediaFromTimestamp({
chatId: id,
messageId: commentId || messageId!,
timestamp,
tabId,
});
}
if (commentId && messageId) { if (commentId && messageId) {
actions.openThread({ actions.openThread({
isComments: true, isComments: true,
originChannelId: chat.id, originChannelId: id,
originMessageId: messageId, originMessageId: messageId,
tabId, tabId,
focusMessageId: commentId, focusMessageId: commentId,
@ -1614,6 +1642,7 @@ addActionHandler('openPrivateChannel', (global, actions, payload): ActionReturnT
openChatWithParams(global, actions, chat, { openChatWithParams(global, actions, chat, {
messageId, messageId,
threadId, threadId,
timestamp,
}, tabId); }, tabId);
}); });
@ -3109,11 +3138,12 @@ async function openChatByUsername<T extends GlobalState>(
startAttach?: string; startAttach?: string;
attach?: string; attach?: string;
text?: string; text?: string;
timestamp?: number;
}, },
...[tabId = getCurrentTabId()]: TabArgs<T> ...[tabId = getCurrentTabId()]: TabArgs<T>
) { ) {
const { const {
username, threadId, channelPostId, startParam, ref, startAttach, attach, text, username, threadId, channelPostId, startParam, ref, startAttach, attach, text, timestamp,
} = params; } = params;
const currentChat = selectCurrentChat(global, tabId); const currentChat = selectCurrentChat(global, tabId);
@ -3168,6 +3198,7 @@ async function openChatByUsername<T extends GlobalState>(
startAttach, startAttach,
attach, attach,
text, text,
timestamp,
}, tabId); }, tabId);
} }
@ -3184,11 +3215,12 @@ async function openChatWithParams<T extends GlobalState>(
startAttach?: string; startAttach?: string;
attach?: string; attach?: string;
text?: string; text?: string;
timestamp?: number;
}, },
...[tabId = getCurrentTabId()]: TabArgs<T> ...[tabId = getCurrentTabId()]: TabArgs<T>
) { ) {
const { const {
isCurrentChat, threadId, messageId, startParam, referrer, startAttach, attach, text, isCurrentChat, threadId, messageId, startParam, referrer, startAttach, attach, text, timestamp,
} = params; } = params;
if (messageId) { if (messageId) {
@ -3211,7 +3243,7 @@ async function openChatWithParams<T extends GlobalState>(
if (!isTopicProcessed) { if (!isTopicProcessed) {
actions.focusMessage({ actions.focusMessage({
chatId: chat.id, threadId, messageId, tabId, chatId: chat.id, threadId, messageId, timestamp, tabId,
}); });
} }
} else if (!isCurrentChat) { } else if (!isCurrentChat) {
@ -3230,6 +3262,12 @@ async function openChatWithParams<T extends GlobalState>(
if (text) { if (text) {
actions.openChatWithDraft({ chatId: chat.id, text: { text }, tabId }); actions.openChatWithDraft({ chatId: chat.id, text: { text }, tabId });
} }
if (messageId && timestamp) {
actions.openMediaFromTimestamp({
chatId: chat.id, threadId, messageId, timestamp, tabId,
});
}
} }
async function openAttachMenuFromLink<T extends GlobalState>( async function openAttachMenuFromLink<T extends GlobalState>(

View File

@ -1,14 +1,18 @@
import type { ActionReturnType } from '../../types'; import type { ActionReturnType } from '../../types';
import { MAIN_THREAD_ID } from '../../../api/types'; import { MAIN_THREAD_ID } from '../../../api/types';
import { AudioOrigin, MediaViewerOrigin } from '../../../types';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { omit } from '../../../util/iteratees';
import { getTimestampableMedia } from '../../helpers';
import { getMessageReplyInfo } from '../../helpers/replies';
import { addActionHandler } from '../../index'; import { addActionHandler } from '../../index';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { selectTabState } from '../../selectors'; import { selectChatMessage, selectReplyMessage, selectTabState } from '../../selectors';
addActionHandler('openMediaViewer', (global, actions, payload): ActionReturnType => { addActionHandler('openMediaViewer', (global, actions, payload): ActionReturnType => {
const { 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(), withDynamicLoading, standaloneMedia, tabId = getCurrentTabId(),
} = payload; } = payload;
@ -27,6 +31,7 @@ addActionHandler('openMediaViewer', (global, actions, payload): ActionReturnType
standaloneMedia, standaloneMedia,
isHidden: false, isHidden: false,
withDynamicLoading, withDynamicLoading,
timestamp,
}, },
forwardMessages: {}, forwardMessages: {},
isShareMessageModalShown: false, isShareMessageModalShown: false,
@ -49,6 +54,108 @@ addActionHandler('closeMediaViewer', (global, actions, payload): ActionReturnTyp
}, tabId); }, 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 => { addActionHandler('setMediaViewerVolume', (global, actions, payload): ActionReturnType => {
const { const {
volume, volume,

View File

@ -186,7 +186,7 @@ addActionHandler('replyToNextMessage', (global, actions, payload): ActionReturnT
addActionHandler('openAudioPlayer', (global, actions, payload): ActionReturnType => { addActionHandler('openAudioPlayer', (global, actions, payload): ActionReturnType => {
const { const {
chatId, threadId, messageId, origin, volume, playbackRate, isMuted, chatId, threadId, messageId, origin, volume, playbackRate, isMuted, timestamp,
tabId = getCurrentTabId(), tabId = getCurrentTabId(),
} = payload; } = payload;
@ -196,6 +196,7 @@ addActionHandler('openAudioPlayer', (global, actions, payload): ActionReturnType
chatId, chatId,
threadId, threadId,
messageId, messageId,
timestamp,
origin: origin ?? tabState.audioPlayer.origin, origin: origin ?? tabState.audioPlayer.origin,
volume: volume ?? tabState.audioPlayer.volume, volume: volume ?? tabState.audioPlayer.volume,
playbackRate: playbackRate || tabState.audioPlayer.playbackRate || global.audioPlayer.lastPlaybackRate, playbackRate: playbackRate || tabState.audioPlayer.playbackRate || global.audioPlayer.lastPlaybackRate,
@ -404,7 +405,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
const { const {
chatId, threadId = MAIN_THREAD_ID, messageListType = 'thread', noHighlight, groupedId, groupedChatId, chatId, threadId = MAIN_THREAD_ID, messageListType = 'thread', noHighlight, groupedId, groupedChatId,
replyMessageId, isResizingContainer, shouldReplaceHistory, noForumTopicPanel, quote, scrollTargetPosition, replyMessageId, isResizingContainer, shouldReplaceHistory, noForumTopicPanel, quote, scrollTargetPosition,
tabId = getCurrentTabId(), timestamp, tabId = getCurrentTabId(),
} = payload; } = payload;
let { messageId } = payload; let { messageId } = payload;
@ -415,6 +416,11 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
return undefined; return undefined;
} }
const onMessageReady = timestamp
? () => actions.openMediaFromTimestamp({
chatId, threadId, messageId, timestamp, tabId,
}) : undefined;
if (groupedId !== undefined) { if (groupedId !== undefined) {
const ids = selectForwardedMessageIdsByGroupId(global, groupedChatId!, groupedId); const ids = selectForwardedMessageIdsByGroupId(global, groupedChatId!, groupedId);
if (ids?.length) { if (ids?.length) {
@ -472,6 +478,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
noForumTopicPanel, noForumTopicPanel,
tabId, tabId,
}); });
onMessageReady?.();
return undefined; return undefined;
} }
@ -503,6 +510,7 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
threadId, threadId,
tabId, tabId,
shouldForceRender: true, shouldForceRender: true,
onLoaded: onMessageReady,
}); });
return undefined; return undefined;
}); });

View File

@ -285,6 +285,10 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) {
cached.chats.listIds = initialState.chats.listIds; cached.chats.listIds = initialState.chats.listIds;
} }
if (!cached.messages.playbackByChatId) {
cached.messages.playbackByChatId = initialState.messages.playbackByChatId;
}
if (cached.cacheVersion < 2) { if (cached.cacheVersion < 2) {
if (cached.settings.themes.dark) { if (cached.settings.themes.dark) {
cached.settings.themes.dark.patternColor = initialState.settings.themes.dark!.patternColor; cached.settings.themes.dark.patternColor = initialState.settings.themes.dark!.patternColor;
@ -594,6 +598,7 @@ function reduceMessages<T extends GlobalState>(global: T): GlobalState['messages
byChatId, byChatId,
pollById: pickTruthy(global.messages.pollById, pollIdsToSave), pollById: pickTruthy(global.messages.pollById, pollIdsToSave),
sponsoredByChatId: {}, sponsoredByChatId: {},
playbackByChatId: {},
}; };
} }

View File

@ -663,3 +663,10 @@ export function getIsDownloading(activeDownloads: ActiveDownloads, media: Downlo
if (!hash) return false; if (!hash) return false;
return Boolean(activeDownloads[hash]); return Boolean(activeDownloads[hash]);
} }
export function getTimestampableMedia(message: MediaContainer) {
return getMessageVideo(message)
|| getMessageWebPageVideo(message)
|| getMessageAudio(message)
|| getMessageVoice(message);
}

View File

@ -133,6 +133,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
byChatId: {}, byChatId: {},
sponsoredByChatId: {}, sponsoredByChatId: {},
pollById: {}, pollById: {},
playbackByChatId: {},
}, },
stories: { stories: {

View File

@ -19,6 +19,7 @@ import {
IS_MOCKED_CLIENT, IS_TEST, MESSAGE_LIST_SLICE, MESSAGE_LIST_VIEWPORT_LIMIT, TMP_CHAT_ID, IS_MOCKED_CLIENT, IS_TEST, MESSAGE_LIST_SLICE, MESSAGE_LIST_VIEWPORT_LIMIT, TMP_CHAT_ID,
} from '../../config'; } from '../../config';
import { areDeepEqual } from '../../util/areDeepEqual'; import { areDeepEqual } from '../../util/areDeepEqual';
import { addTimestampEntities } from '../../util/dates/timestamp';
import { getCurrentTabId } from '../../util/establishMultitabRole'; import { getCurrentTabId } from '../../util/establishMultitabRole';
import { import {
areSortedArraysEqual, excludeSortedArray, omit, omitUndefined, pick, pickTruthy, unique, areSortedArraysEqual, excludeSortedArray, omit, omitUndefined, pick, pickTruthy, unique,
@ -271,16 +272,19 @@ export function updateChatMessage<T extends GlobalState>(
} }
let emojiOnlyCount = message?.emojiOnlyCount; let emojiOnlyCount = message?.emojiOnlyCount;
let text = message?.content?.text;
if (messageUpdate.content) { if (messageUpdate.content) {
emojiOnlyCount = getEmojiOnlyCountForMessage( emojiOnlyCount = getEmojiOnlyCountForMessage(
messageUpdate.content, message?.groupedId || messageUpdate.groupedId, messageUpdate.content, message?.groupedId || messageUpdate.groupedId,
); );
text = messageUpdate.content.text ? addTimestampEntities(messageUpdate.content.text) : text;
} }
const updatedMessage = omitUndefined({ const updatedMessage = omitUndefined({
...message, ...message,
...messageUpdate, ...messageUpdate,
emojiOnlyCount, emojiOnlyCount,
text,
}); });
if (!updatedMessage.id) { if (!updatedMessage.id) {
@ -299,16 +303,19 @@ export function updateScheduledMessage<T extends GlobalState>(
const message = selectScheduledMessage(global, chatId, messageId)!; const message = selectScheduledMessage(global, chatId, messageId)!;
let emojiOnlyCount = message?.emojiOnlyCount; let emojiOnlyCount = message?.emojiOnlyCount;
let text = message?.content?.text;
if (messageUpdate.content) { if (messageUpdate.content) {
emojiOnlyCount = getEmojiOnlyCountForMessage( emojiOnlyCount = getEmojiOnlyCountForMessage(
messageUpdate.content, message?.groupedId || messageUpdate.groupedId, messageUpdate.content, message?.groupedId || messageUpdate.groupedId,
); );
text = messageUpdate.content.text ? addTimestampEntities(messageUpdate.content.text) : text;
} }
const updatedMessage = { const updatedMessage = {
...message, ...message,
...messageUpdate, ...messageUpdate,
emojiOnlyCount, emojiOnlyCount,
text,
}; };
if (!updatedMessage.id) { if (!updatedMessage.id) {

View File

@ -46,6 +46,7 @@ import {
getMessageWebPagePhoto, getMessageWebPagePhoto,
getMessageWebPageVideo, getMessageWebPageVideo,
getSendingState, getSendingState,
getTimestampableMedia,
hasMessageTtl, hasMessageTtl,
isActionMessage, isActionMessage,
isChatBasicGroup, isChatBasicGroup,
@ -1501,3 +1502,28 @@ export function selectMessageReplyInfo<T extends GlobalState>(
return replyInfo; return replyInfo;
} }
export function selectReplyMessage<T extends GlobalState>(global: T, message: ApiMessage) {
const { replyToMsgId, replyToPeerId } = getMessageReplyInfo(message) || {};
const replyMessage = replyToMsgId
? selectChatMessage(global, replyToPeerId || message.chatId, replyToMsgId) : undefined;
return replyMessage;
}
export function selectMessageTimestampableDuration<T extends GlobalState>(
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<T extends GlobalState>(
global: T, chatId: string, messageId: number,
) {
return global.messages.playbackByChatId[chatId]?.byId[messageId];
}

View File

@ -614,6 +614,7 @@ export interface ActionPayloads {
choose?: ApiChatType[]; choose?: ApiChatType[];
text?: string; text?: string;
originalParts?: (string | undefined)[]; originalParts?: (string | undefined)[];
timestamp?: number;
onChatChanged?: CallbackAction; onChatChanged?: CallbackAction;
} & WithTabId; } & WithTabId;
processBoostParameters: { processBoostParameters: {
@ -946,6 +947,7 @@ export interface ActionPayloads {
noForumTopicPanel?: boolean; noForumTopicPanel?: boolean;
quote?: string; quote?: string;
scrollTargetPosition?: ScrollTargetPosition; scrollTargetPosition?: ScrollTargetPosition;
timestamp?: number;
} & WithTabId; } & WithTabId;
focusLastMessage: WithTabId | undefined; focusLastMessage: WithTabId | undefined;
@ -1104,6 +1106,7 @@ export interface ActionPayloads {
threadId?: ThreadId; threadId?: ThreadId;
messageId?: number; messageId?: number;
commentId?: number; commentId?: number;
timestamp?: number;
} & WithTabId; } & WithTabId;
loadFullChat: { loadFullChat: {
chatId: string; chatId: string;
@ -1595,8 +1598,14 @@ export interface ActionPayloads {
isSponsoredMessage?: boolean; isSponsoredMessage?: boolean;
origin: MediaViewerOrigin; origin: MediaViewerOrigin;
withDynamicLoading?: boolean; withDynamicLoading?: boolean;
timestamp?: number;
} & WithTabId; } & WithTabId;
closeMediaViewer: WithTabId | undefined; closeMediaViewer: WithTabId | undefined;
updateLastPlaybackTimestamp: {
chatId: string;
messageId: number;
timestamp?: number;
};
setMediaViewerVolume: { setMediaViewerVolume: {
volume: number; volume: number;
} & WithTabId; } & WithTabId;
@ -1609,6 +1618,12 @@ export interface ActionPayloads {
setMediaViewerHidden: { setMediaViewerHidden: {
isHidden: boolean; isHidden: boolean;
} & WithTabId; } & WithTabId;
openMediaFromTimestamp: {
chatId: string;
messageId: number;
threadId?: ThreadId;
timestamp: number;
} & WithTabId;
openAudioPlayer: { openAudioPlayer: {
chatId: string; chatId: string;
threadId?: ThreadId; threadId?: ThreadId;
@ -1617,6 +1632,7 @@ export interface ActionPayloads {
volume?: number; volume?: number;
playbackRate?: number; playbackRate?: number;
isMuted?: boolean; isMuted?: boolean;
timestamp?: number;
} & WithTabId; } & WithTabId;
closeAudioPlayer: WithTabId | undefined; closeAudioPlayer: WithTabId | undefined;
setAudioPlayerVolume: { setAudioPlayerVolume: {

View File

@ -230,6 +230,9 @@ export type GlobalState = {
byId: Record<number, ApiMessage>; byId: Record<number, ApiMessage>;
threadsById: Record<ThreadId, Thread>; threadsById: Record<ThreadId, Thread>;
}>; }>;
playbackByChatId: Record<string, {
byId: Record<number, number>;
}>;
sponsoredByChatId: Record<string, ApiSponsoredMessage>; sponsoredByChatId: Record<string, ApiSponsoredMessage>;
pollById: Record<string, ApiPoll>; pollById: Record<string, ApiPoll>;
}; };

View File

@ -321,6 +321,7 @@ export type TabState = {
playbackRate: number; playbackRate: number;
isMuted: boolean; isMuted: boolean;
isHidden?: boolean; isHidden?: boolean;
timestamp?: number;
}; };
audioPlayer: { audioPlayer: {
@ -331,6 +332,7 @@ export type TabState = {
volume: number; volume: number;
playbackRate: number; playbackRate: number;
isPlaybackRateActive?: boolean; isPlaybackRateActive?: boolean;
timestamp?: number;
isMuted: boolean; isMuted: boolean;
}; };

View File

@ -43,6 +43,34 @@
&.fix-min-height { &.fix-min-height {
min-height: 5rem; 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 { .animated-close-icon {

133
src/util/dates/timestamp.ts Normal file
View File

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

View File

@ -17,3 +17,7 @@ export function getDays(seconds: number, roundDown?: boolean) {
const roundFunc = roundDown ? Math.floor : Math.ceil; const roundFunc = roundDown ? Math.floor : Math.ceil;
return roundFunc(seconds / DAY); return roundFunc(seconds / DAY);
} }
export function getSeconds(hours: number, minutes: number, seconds: number) {
return hours * HOUR + minutes * MINUTE + seconds;
}

View File

@ -2,6 +2,7 @@ import type { ThreadId } from '../types';
import { RE_TG_LINK, RE_TME_LINK } from '../config'; import { RE_TG_LINK, RE_TME_LINK } from '../config';
import { toChannelId } from '../global/helpers'; import { toChannelId } from '../global/helpers';
import { parseTimestampDuration } from './dates/timestamp';
import { ensureProtocol } from './ensureProtocol'; import { ensureProtocol } from './ensureProtocol';
import { isUsernameValid } from './username'; import { isUsernameValid } from './username';
import { IS_BAD_URL_PARSER } from './windowEnvironment'; import { IS_BAD_URL_PARSER } from './windowEnvironment';
@ -18,6 +19,7 @@ interface PublicMessageLink {
isSingle: boolean; isSingle: boolean;
threadId?: ThreadId; threadId?: ThreadId;
commentId?: number; commentId?: number;
timestamp?: number;
} }
export interface PrivateMessageLink { export interface PrivateMessageLink {
@ -27,6 +29,7 @@ export interface PrivateMessageLink {
isSingle: boolean; isSingle: boolean;
threadId?: ThreadId; threadId?: ThreadId;
commentId?: number; commentId?: number;
timestamp?: number;
} }
interface ShareLink { interface ShareLink {
@ -171,7 +174,7 @@ function parseTgLink(url: URL) {
switch (deepLinkType) { switch (deepLinkType) {
case 'publicMessageLink': { case 'publicMessageLink': {
const { const {
domain, post, single, thread, comment, domain, post, single, thread, comment, t,
} = queryParams; } = queryParams;
return buildPublicMessageLink({ return buildPublicMessageLink({
username: domain, username: domain,
@ -179,11 +182,12 @@ function parseTgLink(url: URL) {
single, single,
threadId: thread, threadId: thread,
commentId: comment, commentId: comment,
timestamp: t,
}); });
} }
case 'privateMessageLink': { case 'privateMessageLink': {
const { const {
channel, post, single, thread, comment, channel, post, single, thread, comment, t,
} = queryParams; } = queryParams;
return buildPrivateMessageLink({ return buildPrivateMessageLink({
channelId: channel, channelId: channel,
@ -191,6 +195,7 @@ function parseTgLink(url: URL) {
single, single,
threadId: thread, threadId: thread,
commentId: comment, commentId: comment,
timestamp: t,
}); });
} }
case 'shareLink': case 'shareLink':
@ -251,7 +256,7 @@ function parseHttpLink(url: URL) {
switch (deepLinkType) { switch (deepLinkType) {
case 'publicMessageLink': { case 'publicMessageLink': {
const { const {
single, comment, single, comment, t,
} = queryParams; } = queryParams;
const { const {
username, username,
@ -272,11 +277,12 @@ function parseHttpLink(url: URL) {
single, single,
threadId: thread, threadId: thread,
commentId: comment, commentId: comment,
timestamp: t,
}); });
} }
case 'privateMessageLink': { case 'privateMessageLink': {
const { const {
single, comment, single, comment, t,
} = queryParams; } = queryParams;
const { const {
channelId, channelId,
@ -297,6 +303,7 @@ function parseHttpLink(url: URL) {
single, single,
threadId: thread, threadId: thread,
commentId: comment, commentId: comment,
timestamp: t,
}); });
} }
case 'shareLink': { case 'shareLink': {
@ -457,7 +464,7 @@ function buildShareLink(params: BuilderParams<ShareLink>): BuilderReturnType<Sha
function buildPublicMessageLink(params: PublicMessageLinkBuilderParams): BuilderReturnType<PublicMessageLink> { function buildPublicMessageLink(params: PublicMessageLinkBuilderParams): BuilderReturnType<PublicMessageLink> {
const { const {
messageId, threadId, commentId, username, single, messageId, threadId, commentId, username, single, timestamp,
} = params; } = params;
if (!username || !isUsernameValid(username)) { if (!username || !isUsernameValid(username)) {
return undefined; return undefined;
@ -478,12 +485,13 @@ function buildPublicMessageLink(params: PublicMessageLinkBuilderParams): Builder
isSingle: single === '', isSingle: single === '',
threadId: threadId ? Number(threadId) : undefined, threadId: threadId ? Number(threadId) : undefined,
commentId: commentId ? Number(commentId) : undefined, commentId: commentId ? Number(commentId) : undefined,
timestamp: timestamp ? parseTimestampDuration(timestamp) : undefined,
}; };
} }
function buildPrivateMessageLink(params: PrivateMessageLinkBuilderParams): BuilderReturnType<PrivateMessageLink> { function buildPrivateMessageLink(params: PrivateMessageLinkBuilderParams): BuilderReturnType<PrivateMessageLink> {
const { const {
messageId, threadId, commentId, channelId, single, messageId, threadId, commentId, channelId, single, timestamp,
} = params; } = params;
if (!channelId || !isNumber(channelId)) { if (!channelId || !isNumber(channelId)) {
return undefined; return undefined;
@ -504,6 +512,7 @@ function buildPrivateMessageLink(params: PrivateMessageLinkBuilderParams): Build
isSingle: single === '', isSingle: single === '',
threadId: threadId ? Number(threadId) : undefined, threadId: threadId ? Number(threadId) : undefined,
commentId: commentId ? Number(commentId) : undefined, commentId: commentId ? Number(commentId) : undefined,
timestamp: timestamp ? parseTimestampDuration(timestamp) : undefined,
}; };
} }

View File

@ -19,6 +19,7 @@ export const processDeepLink = (url: string): boolean => {
threadId: parsedLink.threadId, threadId: parsedLink.threadId,
messageId: parsedLink.messageId, messageId: parsedLink.messageId,
commentId: parsedLink.commentId, commentId: parsedLink.commentId,
timestamp: parsedLink.timestamp,
}); });
return true; return true;
case 'publicMessageLink': { case 'publicMessageLink': {
@ -27,6 +28,7 @@ export const processDeepLink = (url: string): boolean => {
threadId: parsedLink.threadId, threadId: parsedLink.threadId,
messageId: parsedLink.messageId, messageId: parsedLink.messageId,
commentId: parsedLink.commentId, commentId: parsedLink.commentId,
timestamp: parsedLink.timestamp,
}); });
return true; return true;
} }

View File

@ -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 { return {
index, index,
entity: { entity: {