Media: Support timestamps (#5649)
This commit is contained in:
parent
48b98f7494
commit
c8f82b1f91
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
|
||||
@ -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)}
|
||||
</>
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
});
|
||||
|
||||
@ -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: {},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -133,6 +133,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
|
||||
byChatId: {},
|
||||
sponsoredByChatId: {},
|
||||
pollById: {},
|
||||
playbackByChatId: {},
|
||||
},
|
||||
|
||||
stories: {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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];
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
|
||||
@ -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
133
src/util/dates/timestamp.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user