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 { 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;

View File

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

View File

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

View File

@ -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<HTMLCanvasElement>(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)}
</>

View File

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

View File

@ -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 <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:
return <Spoiler containerId={containerId}>{renderNestedMessagePart()}</Spoiler>;
case ApiMessageEntityTypes.CustomEmoji:
@ -677,3 +712,11 @@ function handleCodeClick(e: React.MouseEvent<HTMLElement>) {
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 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();
if (!chatId || !messageId) return;

View File

@ -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<HTMLDivElement>) => 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<OwnProps>(
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<OwnProps>(
isMuted,
isHidden,
playbackRate,
threadId,
timestamp,
maxTimestamp,
};
},
)(MediaViewerContent));

View File

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

View File

@ -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<HTMLDivElement>) => void;
handleSponsoredClick: (isFromMedia?: boolean) => void;
onClose: () => void;
};

View File

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

View File

@ -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<OwnProps & StateProps> = ({
viaBusinessBot,
effect,
poll,
maxTimestamp,
lastPlaybackTimestamp,
onIntersectPinnedMessage,
}) => {
const {
@ -609,6 +616,7 @@ const Message: FC<OwnProps & StateProps> = ({
handleViaBotClick,
handleReplyClick,
handleMediaClick,
handleDocumentClick,
handleAudioPlay,
handleAlbumMediaClick,
handlePhotoMediaClick,
@ -645,6 +653,7 @@ const Message: FC<OwnProps & StateProps> = ({
isReplyPrivate,
isRepliesChat,
isSavedMessages: isChatWithSelf,
lastPlaybackTimestamp,
});
const handleEffectClick = useLastCallback((e: React.MouseEvent<HTMLDivElement>) => {
@ -961,6 +970,8 @@ const Message: FC<OwnProps & StateProps> = ({
withTranslucentThumbs={isCustomShape}
isInSelectMode={isInSelectMode}
canBeEmpty={hasFactCheck}
maxTimestamp={maxTimestamp}
threadId={threadId}
/>
);
}
@ -1194,7 +1205,7 @@ const Message: FC<OwnProps & StateProps> = ({
uploadProgress={uploadProgress}
isSelectable={isInDocumentGroup}
isSelected={isSelected}
onMediaClick={handleMediaClick}
onMediaClick={handleDocumentClick}
onCancelUpload={handleCancelUpload}
isDownloading={isDownloading}
shouldWarnAboutSvg={shouldWarnAboutSvg}
@ -1357,11 +1368,13 @@ const Message: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
isDownloading={isDownloading}
isProtected={isProtected}
asForwarded={asForwarded}
lastPlaybackTimestamp={lastPlaybackTimestamp}
onClick={handleVideoMediaClick}
onCancelUpload={handleCancelUpload}
/>
@ -1724,7 +1738,7 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
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<OwnProps>(
viaBusinessBot,
effect,
poll,
maxTimestamp,
lastPlaybackTimestamp,
};
},
)(Message));

View File

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

View File

@ -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<OwnProps & StateProps> = ({
message,
observeIntersectionForLoading,
observeIntersectionForPlaying,
noAvatars,
canAutoLoad,
canAutoPlay,
@ -85,11 +86,15 @@ const WebPage: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
asForwarded={asForwarded}
isDownloading={isDownloading}
isProtected={isProtected}
lastPlaybackTimestamp={lastPlaybackTimestamp || linkTimestamp}
onClick={isMediaInteractive ? handleMediaClick : undefined}
onCancelUpload={onCancelMediaTransfer}
/>
@ -286,7 +298,7 @@ const WebPage: FC<OwnProps & StateProps> = ({
message={message}
observeIntersection={observeIntersectionForLoading}
autoLoadFileMaxSizeMb={autoLoadFileMaxSizeMb}
onMediaClick={handleMediaClick}
onMediaClick={onDocumentClick}
onCancelUpload={onCancelMediaTransfer}
isDownloading={isDownloading}
shouldWarnAboutSvg={shouldWarnAboutSvg}

View File

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

View File

@ -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<number, number> = {
@ -82,6 +83,7 @@ const AudioPlayer: FC<OwnProps & StateProps> = ({
isPlaybackRateActive,
isMuted,
isFullWidth,
timestamp,
onPaneStateChange,
}) => {
const {
@ -117,6 +119,7 @@ const AudioPlayer: FC<OwnProps & StateProps> = ({
setVolume,
toggleMuted,
setPlaybackRate,
setCurrentTime,
} = useAudioPlayer(
message && makeTrackId(message),
message ? getMediaDuration(message)! : 0,
@ -153,6 +156,12 @@ const AudioPlayer: FC<OwnProps & StateProps> = ({
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<OwnProps>(
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<OwnProps>(
playbackRate,
isPlaybackRateActive,
isMuted,
timestamp,
};
},
)(AudioPlayer);

View File

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

View File

@ -1469,7 +1469,7 @@ addActionHandler('acceptChatInvite', async (global, actions, payload): Promise<v
addActionHandler('openChatByUsername', async (global, actions, payload): Promise<void> => {
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<T extends GlobalState>(
startAttach?: string;
attach?: string;
text?: string;
timestamp?: number;
},
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
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<T extends GlobalState>(
startAttach,
attach,
text,
timestamp,
}, tabId);
}
@ -3184,11 +3215,12 @@ async function openChatWithParams<T extends GlobalState>(
startAttach?: string;
attach?: string;
text?: string;
timestamp?: number;
},
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
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<T extends GlobalState>(
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<T extends GlobalState>(
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<T extends GlobalState>(

View File

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

View File

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

View File

@ -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<T extends GlobalState>(global: T): GlobalState['messages
byChatId,
pollById: pickTruthy(global.messages.pollById, pollIdsToSave),
sponsoredByChatId: {},
playbackByChatId: {},
};
}

View File

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

View File

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

View File

@ -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<T extends GlobalState>(
}
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<T extends GlobalState>(
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) {

View File

@ -46,6 +46,7 @@ import {
getMessageWebPagePhoto,
getMessageWebPageVideo,
getSendingState,
getTimestampableMedia,
hasMessageTtl,
isActionMessage,
isChatBasicGroup,
@ -1501,3 +1502,28 @@ export function selectMessageReplyInfo<T extends GlobalState>(
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[];
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: {

View File

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

View File

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

View File

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

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;
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 { 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<ShareLink>): BuilderReturnType<Sha
function buildPublicMessageLink(params: PublicMessageLinkBuilderParams): BuilderReturnType<PublicMessageLink> {
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<PrivateMessageLink> {
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,
};
}

View File

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

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