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