diff --git a/src/components/appMediaPlaybackController.ts b/src/components/appMediaPlaybackController.ts index c6743ddf..5c85cae2 100644 --- a/src/components/appMediaPlaybackController.ts +++ b/src/components/appMediaPlaybackController.ts @@ -640,7 +640,7 @@ export class AppMediaPlaybackController extends EventListenerBase<{ }; private onEnded = (e?: Event) => { - if(!e.isTrusted) { + if(e && !e.isTrusted) { return; } @@ -697,7 +697,7 @@ export class AppMediaPlaybackController extends EventListenerBase<{ return this.toggle(false); }; - public stop = (media = this.playingMedia) => { + public stop = (media = this.playingMedia, force?: boolean) => { if(!media) { return false; } @@ -707,7 +707,6 @@ export class AppMediaPlaybackController extends EventListenerBase<{ } media.currentTime = 0; - simulateEvent(media, 'ended'); if(media === this.playingMedia) { const details = this.mediaDetails.get(media); @@ -733,6 +732,10 @@ export class AppMediaPlaybackController extends EventListenerBase<{ this.playingMediaType = undefined; } + if(force) { + this.dispatchEvent('stop'); + } + return true; }; diff --git a/src/components/appMediaViewer.ts b/src/components/appMediaViewer.ts index bb5cbf1d..8669bd3b 100644 --- a/src/components/appMediaViewer.ts +++ b/src/components/appMediaViewer.ts @@ -134,6 +134,10 @@ export default class AppMediaViewer extends AppMediaViewerBase<'caption', 'delet const onCaptionClick = (e: MouseEvent) => { const a = findUpTag(e.target, 'A'); + if(a.classList.contains('timestamp')) { + return; + } + const spoiler = findUpClassName(e.target, 'spoiler'); if(a instanceof HTMLAnchorElement && (!spoiler || this.content.caption.classList.contains('is-spoiler-visible'))) { // close viewer if it's t.me/ redirect const onclick = a.getAttribute('onclick'); @@ -172,11 +176,19 @@ export default class AppMediaViewer extends AppMediaViewerBase<'caption', 'delet } onPrevClick = async(target: AppMediaViewerTargetType) => { - this.openMedia(await this.getMessageByPeer(target.peerId, target.mid), target.element, -1); + this.openMedia({ + message: await this.getMessageByPeer(target.peerId, target.mid), + target: target.element, + fromRight: -1 + }); }; onNextClick = async(target: AppMediaViewerTargetType) => { - this.openMedia(await this.getMessageByPeer(target.peerId, target.mid), target.element, 1); + this.openMedia({ + message: await this.getMessageByPeer(target.peerId, target.mid), + target: target.element, + fromRight: 1 + }); }; onDeleteClick = () => { @@ -235,8 +247,11 @@ export default class AppMediaViewer extends AppMediaViewerBase<'caption', 'delet const caption = (message as Message.message).message; let html: Parameters[1] = ''; if(caption) { + const media = getMediaFromMessage(message, true); + html = wrapRichText(caption, { - entities: (message as Message.message).totalEntities + entities: (message as Message.message).totalEntities, + maxMediaTimestamp: ((media as MyDocument)?.type === 'video' && (media as MyDocument).duration) || undefined }); } @@ -252,8 +267,24 @@ export default class AppMediaViewer extends AppMediaViewerBase<'caption', 'delet return this; } - public async openMedia(message: MyMessage, target?: HTMLElement, fromRight = 0, reverse = false, - prevTargets: AppMediaViewerTargetType[] = [], nextTargets: AppMediaViewerTargetType[] = []/* , needLoadMore = true */) { + public async openMedia({ + message, + target, + fromRight = 0, + reverse = false, + prevTargets = [], + nextTargets = [], + mediaTimestamp + }: { + message: MyMessage, + target?: HTMLElement, + fromRight?: number, + reverse?: boolean, + prevTargets?: AppMediaViewerTargetType[], + nextTargets?: AppMediaViewerTargetType[], + mediaTimestamp?: number + /* , needLoadMore = true */ + }) { if(this.setMoverPromise) return this.setMoverPromise; const mid = message.mid; @@ -283,7 +314,19 @@ export default class AppMediaViewer extends AppMediaViewerBase<'caption', 'delet this.wholeDiv.classList.toggle('no-forwards', cantDownloadMessage); this.setCaption(message); - const promise = super._openMedia(media as MyPhoto | MyDocument, message.date, fromId, fromRight, target, reverse, prevTargets, nextTargets, message/* , needLoadMore */); + const promise = super._openMedia({ + media: media as MyPhoto | MyDocument, + timestamp: message.date, + fromId, + fromRight, + target, + reverse, + prevTargets, + nextTargets, + message, + mediaTimestamp + /* , needLoadMore */ + }); this.target.mid = mid; this.target.peerId = message.peerId; this.target.message = message; diff --git a/src/components/appMediaViewerAvatar.ts b/src/components/appMediaViewerAvatar.ts index 4d76df70..880cd9aa 100644 --- a/src/components/appMediaViewerAvatar.ts +++ b/src/components/appMediaViewerAvatar.ts @@ -36,11 +36,19 @@ export default class AppMediaViewerAvatar extends AppMediaViewerBase<'', 'delete } onPrevClick = (target: AppMediaViewerAvatarTargetType) => { - this.openMedia(target.photoId, target.element, -1); + this.openMedia({ + photoId: target.photoId, + target: target.element, + fromRight: -1 + }); }; onNextClick = (target: AppMediaViewerAvatarTargetType) => { - this.openMedia(target.photoId, target.element, 1); + this.openMedia({ + photoId: target.photoId, + target: target.element, + fromRight: 1 + }); }; onDownloadClick = () => { @@ -50,11 +58,32 @@ export default class AppMediaViewerAvatar extends AppMediaViewerBase<'', 'delete }); }; - public async openMedia(photoId: Photo.photo['id'], target?: HTMLElement, fromRight = 0, prevTargets?: AppMediaViewerAvatarTargetType[], nextTargets?: AppMediaViewerAvatarTargetType[]) { + public async openMedia({ + photoId, + target, + fromRight = 0, + prevTargets, + nextTargets + }: { + photoId: Photo.photo['id'], + target?: HTMLElement, + fromRight?: number, + prevTargets?: AppMediaViewerAvatarTargetType[], + nextTargets?: AppMediaViewerAvatarTargetType[] + }) { if(this.setMoverPromise) return this.setMoverPromise; const photo = await this.managers.appPhotosManager.getPhoto(photoId); - const ret = super._openMedia(photo, photo.date, this.peerId, fromRight, target, false, prevTargets, nextTargets); + const ret = super._openMedia({ + media: photo, + timestamp: photo.date, + fromId: this.peerId, + fromRight, + target, + reverse: false, + prevTargets, + nextTargets + }); this.target.photoId = photo.id; this.target.photo = photo; diff --git a/src/components/appMediaViewerBase.ts b/src/components/appMediaViewerBase.ts index b97144f8..de47b0b9 100644 --- a/src/components/appMediaViewerBase.ts +++ b/src/components/appMediaViewerBase.ts @@ -139,7 +139,7 @@ export default class AppMediaViewerBase< protected lastDragDelta: {x: number, y: number} = this.transform; protected lastGestureTime: number; protected clampZoomDebounced: ReturnType void>>; - ignoreNextClick: boolean; + protected ignoreNextClick: boolean; get target() { return this.listLoader.current; @@ -758,6 +758,10 @@ export default class AppMediaViewerBase< window.addEventListener('keyup', this.onKeyUp); } + public setMediaTimestamp(timestamp: number) { + this.videoPlayer?.setTimestamp(timestamp); + } + onClick = (e: MouseEvent) => { if(this.ignoreNextClick) { this.ignoreNextClick = undefined; @@ -1471,18 +1475,30 @@ export default class AppMediaViewerBase< }); } - protected async _openMedia( + protected async _openMedia({ + media, + timestamp, + fromId, + fromRight, + target, + reverse = false, + prevTargets = [], + nextTargets = [], + message, + mediaTimestamp + }: { media: MyDocument | MyPhoto, timestamp: number, fromId: PeerId | string, fromRight: number, target?: HTMLElement, - reverse = false, - prevTargets: TargetType[] = [], - nextTargets: TargetType[] = [], - message?: MyMessage + reverse?: boolean, + prevTargets?: TargetType[], + nextTargets?: TargetType[], + message?: MyMessage, + mediaTimestamp?: number /* , needLoadMore = true */ - ) { + }) { if(this.setMoverPromise) return this.setMoverPromise; /* if(DEBUG) { @@ -1528,7 +1544,7 @@ export default class AppMediaViewerBase< const tempId = ++this.tempId; if(container.firstElementChild) { - container.innerHTML = ''; + container.replaceChildren(); } // ok set @@ -1672,6 +1688,10 @@ export default class AppMediaViewerBase< video.loop = true; } + if(mediaTimestamp !== undefined) { + video.currentTime = mediaTimestamp; + } + // if(!video.parentElement) { div.append(video); // } diff --git a/src/components/appSearchSuper..ts b/src/components/appSearchSuper..ts index ec6091f8..35823088 100644 --- a/src/components/appSearchSuper..ts +++ b/src/components/appSearchSuper..ts @@ -586,7 +586,14 @@ export default class AppSearchSuper { const message = await this.managers.appMessagesManager.getMessageByPeer(peerId, mid); new AppMediaViewer() .setSearchContext(this.copySearchContext(inputFilter)) - .openMedia(message, targets[idx].element, 0, false, targets.slice(0, idx), targets.slice(idx + 1)); + .openMedia({ + message, + target: targets[idx].element, + fromRight: 0, + reverse: false, + prevTargets: targets.slice(0, idx), + nextTargets: targets.slice(idx + 1) + }); }; attachClickEvent(this.tabs.inputMessagesFilterPhotoVideo, onMediaClick.bind(null, 'grid-item', 'grid-item', 'inputMessagesFilterPhotoVideo'), {listenerSetter: this.listenerSetter}); diff --git a/src/components/audio.ts b/src/components/audio.ts index a94ddebb..b54d4cc3 100644 --- a/src/components/audio.ts +++ b/src/components/audio.ts @@ -262,7 +262,7 @@ async function wrapVoiceMessage(audioEl: AudioElement) { let mousedown = false, mousemove = false; progress.addEventListener('mouseleave', (e) => { if(mousedown) { - audio.play(); + audioEl.togglePlay(undefined, true); mousedown = false; } mousemove = false; @@ -275,7 +275,7 @@ async function wrapVoiceMessage(audioEl: AudioElement) { e.preventDefault(); if(e.button !== 0) return; if(!audio.paused) { - audio.pause(); + audioEl.togglePlay(undefined, false); } scrub(e); @@ -283,7 +283,7 @@ async function wrapVoiceMessage(audioEl: AudioElement) { }); progress.addEventListener('mouseup', (e) => { if(mousemove && mousedown) { - audio.play(); + audioEl.togglePlay(undefined, true); mousedown = false; } }); @@ -491,6 +491,7 @@ export default class AudioElement extends HTMLElement { private onTypeDisconnect: () => void; public onLoad: (autoload?: boolean) => void; public readyPromise: CancellablePromise; + public load: (shouldPlay: boolean, controlledAutoplay?: boolean) => void; public async render() { this.classList.add('audio'); @@ -562,27 +563,7 @@ export default class AudioElement extends HTMLElement { onPlay(); } - const togglePlay = (e?: Event, paused = audio.paused) => { - e && cancelEvent(e); - - if(paused) { - const hadSearchContext = !!this.searchContext; - if(appMediaPlaybackController.setSearchContext(this.searchContext || { - peerId: NULL_PEER_ID, - inputFilter: {_: 'inputMessagesFilterEmpty'}, - useSearch: false - })) { - const [prev, next] = !hadSearchContext ? [] : findMediaTargets(this, this.message.mid/* , this.searchContext.useSearch */); - appMediaPlaybackController.setTargets({peerId: this.message.peerId, mid: this.message.mid}, prev, next); - } - - audio.play().catch(() => {}); - } else { - audio.pause(); - } - }; - - attachClickEvent(toggle, (e) => togglePlay(e), {listenerSetter: this.listenerSetter}); + attachClickEvent(toggle, (e) => this.togglePlay(e), {listenerSetter: this.listenerSetter}); this.addAudioListener('ended', () => { toggle.classList.remove('playing'); @@ -599,8 +580,6 @@ export default class AudioElement extends HTMLElement { }); this.addAudioListener('play', onPlay); - - return togglePlay; }; if(doc.thumbs?.length) { @@ -629,24 +608,16 @@ export default class AudioElement extends HTMLElement { const autoDownload = doc.type !== 'audio'/* || !this.noAutoDownload */; onLoad(autoDownload); - const r = (shouldPlay: boolean) => { + const r = this.load = (shouldPlay: boolean, controlledAutoplay?: boolean) => { + this.load = undefined; + if(this.audio.src) { return; } appMediaPlaybackController.resolveWaitingForLoadMedia(this.message.peerId, this.message.mid, this.message.pFlags.is_scheduled); - const onDownloadInit = () => { - if(shouldPlay) { - appMediaPlaybackController.willBePlayed(this.audio); // prepare for loading audio - - if(IS_SAFARI && !this.audio.autoplay) { - this.audio.autoplay = true; - } - } - }; - - onDownloadInit(); + this.onDownloadInit(shouldPlay); if(!preloader) { if(doc.supportsStreaming) { @@ -673,7 +644,7 @@ export default class AudioElement extends HTMLElement { deferred.cancel(); }, {once: true}) as any; - onDownloadInit(); + this.onDownloadInit(shouldPlay); }; /* if(!this.audio.paused) { @@ -683,7 +654,7 @@ export default class AudioElement extends HTMLElement { const playListener: any = this.addAudioListener('play', onPlay); this.readyPromise.then(() => { this.listenerSetter.remove(playListener); - this.listenerSetter.remove(pauseListener); + pauseListener && this.listenerSetter.remove(pauseListener); }); } else { preloader = constructDownloadPreloader(); @@ -693,7 +664,7 @@ export default class AudioElement extends HTMLElement { } const load = () => { - onDownloadInit(); + this.onDownloadInit(shouldPlay); const download = appDownloadManager.downloadMediaURL({media: doc}); @@ -729,7 +700,7 @@ export default class AudioElement extends HTMLElement { // setTimeout(() => { // release loaded audio - if(appMediaPlaybackController.willBePlayedMedia === this.audio) { + if(!controlledAutoplay && appMediaPlaybackController.willBePlayedMedia === this.audio) { this.audio.play(); appMediaPlaybackController.willBePlayed(undefined); } @@ -755,6 +726,54 @@ export default class AudioElement extends HTMLElement { } } + private onDownloadInit(shouldPlay: boolean) { + if(shouldPlay) { + appMediaPlaybackController.willBePlayed(this.audio); // prepare for loading audio + + if(IS_SAFARI && !this.audio.autoplay) { + this.audio.autoplay = true; + } + } + } + + public togglePlay(e?: Event, paused = this.audio.paused) { + e && cancelEvent(e); + + if(paused) { + this.setTargetsIfNeeded(); + this.audio.play().catch(() => {}); + } else { + this.audio.pause(); + } + } + + public setTargetsIfNeeded() { + const hadSearchContext = !!this.searchContext; + if(appMediaPlaybackController.setSearchContext(this.searchContext || { + peerId: NULL_PEER_ID, + inputFilter: {_: 'inputMessagesFilterEmpty'}, + useSearch: false + })) { + const [prev, next] = !hadSearchContext ? [] : findMediaTargets(this, this.message.mid/* , this.searchContext.useSearch */); + appMediaPlaybackController.setTargets({peerId: this.message.peerId, mid: this.message.mid}, prev, next); + } + } + + public playWithTimestamp(timestamp: number) { + this.load?.(true, true); + appMediaPlaybackController.willBePlayed(this.audio); // prepare for loading audio + this.readyPromise.then(() => { + if(appMediaPlaybackController.willBePlayedMedia !== this.audio && this.audio.paused) { + return; + } + + appMediaPlaybackController.willBePlayed(undefined); + + this.audio.currentTime = timestamp; + this.togglePlay(undefined, true); + }); + } + get addAudioListener() { return this.listenerSetter.add(this.audio); } diff --git a/src/components/avatar.ts b/src/components/avatar.ts index 3b4323ee..af3d9b50 100644 --- a/src/components/avatar.ts +++ b/src/components/avatar.ts @@ -96,7 +96,12 @@ export async function openAvatarViewer( peerId, inputFilter: {_: inputFilter} }) - .openMedia(message, getTarget(), undefined, undefined, prevTargets ? f(prevTargets) : undefined, nextTargets ? f(nextTargets) : undefined); + .openMedia({ + message, + target: getTarget(), + prevTargets: prevTargets ? f(prevTargets) : undefined, + nextTargets: nextTargets ? f(nextTargets) : undefined + }); return; } @@ -112,13 +117,12 @@ export async function openAvatarViewer( photoId: el.item as string })); - new AppMediaViewerAvatar(peerId).openMedia( - photo.id, - getTarget(), - undefined, - prevTargets ? f(prevTargets) : undefined, - nextTargets ? f(nextTargets) : undefined - ); + new AppMediaViewerAvatar(peerId).openMedia({ + photoId: photo.id, + target: getTarget(), + prevTargets: prevTargets ? f(prevTargets) : undefined, + nextTargets: nextTargets ? f(nextTargets) : undefined + }); } } diff --git a/src/components/chat/audio.ts b/src/components/chat/audio.ts index ea3704d3..eb39990e 100644 --- a/src/components/chat/audio.ts +++ b/src/components/chat/audio.ts @@ -45,7 +45,7 @@ export default class ChatAudio extends PinnedContainer { } ), onClose: () => { - appMediaPlaybackController.stop(); + appMediaPlaybackController.stop(undefined, true); }, floating: true }); diff --git a/src/components/chat/bubbles.ts b/src/components/chat/bubbles.ts index 562c631e..15d90c51 100644 --- a/src/components/chat/bubbles.ts +++ b/src/components/chat/bubbles.ts @@ -565,8 +565,8 @@ export default class ChatBubbles { if(element instanceof AudioElement || element.classList.contains('media-round')) { element.dataset.mid = '' + message.mid; delete element.dataset.isOutgoing; - (element as any).message = message; - (element as any).onLoad(true); + (element as AudioElement).message = message; + (element as AudioElement).onLoad(true); } else { element.dataset.docId = '' + doc.id; (element as any).doc = doc; @@ -1920,6 +1920,68 @@ export default class ChatBubbles { return; } + if(await this.checkTargetForMediaViewer(target, e)) { + return; + } + + if(['IMG', 'DIV', 'SPAN'/* , 'A' */].indexOf(target.tagName) === -1) target = findUpTag(target, 'DIV'); + + if(['DIV', 'SPAN'].indexOf(target.tagName) !== -1/* || target.tagName === 'A' */) { + if(target.classList.contains('goto-original')) { + const savedFrom = bubble.dataset.savedFrom; + const [peerId, mid] = savedFrom.split('_'); + // //this.log('savedFrom', peerId, msgID); + this.chat.appImManager.setInnerPeer({ + peerId: peerId.toPeerId(), + lastMsgId: +mid + }); + return; + } else if(target.classList.contains('forward')) { + const mid = +bubble.dataset.mid; + const message = await this.managers.appMessagesManager.getMessageByPeer(this.peerId, mid); + new PopupForward({ + [this.peerId]: await this.managers.appMessagesManager.getMidsByMessage(message) + }); + // appSidebarRight.forwardTab.open([mid]); + return; + } + + let isReplyClick = false; + + try { + isReplyClick = !!findUpClassName(e.target, 'reply'); + } catch(err) {} + + if(isReplyClick && bubble.classList.contains('is-reply')/* || bubble.classList.contains('forwarded') */) { + const bubbleMid = +bubble.dataset.mid; + this.followStack.push(bubbleMid); + + const message = (await this.chat.getMessage(bubbleMid)) as Message.message; + + const replyToPeerId = message.reply_to.reply_to_peer_id ? getPeerId(message.reply_to.reply_to_peer_id) : this.peerId; + const replyToMid = message.reply_to.reply_to_msg_id; + + this.chat.appImManager.setInnerPeer({ + peerId: replyToPeerId, + lastMsgId: replyToMid, + type: this.chat.type, + threadId: this.chat.threadId + }); + + /* if(this.chat.type === 'discussion') { + this.chat.appImManager.setMessageId(, originalMessageId); + } else { + this.chat.appImManager.setInnerPeer(this.peerId, originalMessageId); + } */ + // this.chat.setMessageId(, originalMessageId); + } + } + + // console.log('chatInner click', e); + }; + + public async checkTargetForMediaViewer(target: HTMLElement, e?: Event, mediaTimestamp?: number) { + const bubble = findUpClassName(target, 'bubble'); const documentDiv = findUpClassName(target, 'document-with-thumb'); if((target.tagName === 'IMG' && !target.classList.contains('emoji') && !target.classList.contains('document-thumb')) || target.classList.contains('album-item') || @@ -1929,7 +1991,7 @@ export default class ChatBubbles { target.classList.contains('canvas-thumbnail')) { const groupedItem = findUpClassName(target, 'album-item') || findUpClassName(target, 'document-container'); const preloader = (groupedItem || bubble).querySelector('.preloader-container'); - if(preloader) { + if(preloader && e) { simulateClickEvent(preloader); cancelEvent(e); return; @@ -2027,67 +2089,18 @@ export default class ChatBubbles { useSearch: this.chat.type !== 'scheduled' && !isSingleMedia, isScheduled: this.chat.type === 'scheduled' }) - .openMedia(message, targets[idx].element, 0, true, targets.slice(0, idx), targets.slice(idx + 1)); - - // appMediaViewer.openMedia(message, target as HTMLImageElement); - return; + .openMedia({ + message: message, + target: targets[idx].element, + fromRight: 0, + reverse: true, + prevTargets: targets.slice(0, idx), + nextTargets: targets.slice(idx + 1), + mediaTimestamp + }); + return true; } - - if(['IMG', 'DIV', 'SPAN'/* , 'A' */].indexOf(target.tagName) === -1) target = findUpTag(target, 'DIV'); - - if(['DIV', 'SPAN'].indexOf(target.tagName) !== -1/* || target.tagName === 'A' */) { - if(target.classList.contains('goto-original')) { - const savedFrom = bubble.dataset.savedFrom; - const [peerId, mid] = savedFrom.split('_'); - // //this.log('savedFrom', peerId, msgID); - this.chat.appImManager.setInnerPeer({ - peerId: peerId.toPeerId(), - lastMsgId: +mid - }); - return; - } else if(target.classList.contains('forward')) { - const mid = +bubble.dataset.mid; - const message = await this.managers.appMessagesManager.getMessageByPeer(this.peerId, mid); - new PopupForward({ - [this.peerId]: await this.managers.appMessagesManager.getMidsByMessage(message) - }); - // appSidebarRight.forwardTab.open([mid]); - return; - } - - let isReplyClick = false; - - try { - isReplyClick = !!findUpClassName(e.target, 'reply'); - } catch(err) {} - - if(isReplyClick && bubble.classList.contains('is-reply')/* || bubble.classList.contains('forwarded') */) { - const bubbleMid = +bubble.dataset.mid; - this.followStack.push(bubbleMid); - - const message = (await this.chat.getMessage(bubbleMid)) as Message.message; - - const replyToPeerId = message.reply_to.reply_to_peer_id ? getPeerId(message.reply_to.reply_to_peer_id) : this.peerId; - const replyToMid = message.reply_to.reply_to_msg_id; - - this.chat.appImManager.setInnerPeer({ - peerId: replyToPeerId, - lastMsgId: replyToMid, - type: this.chat.type, - threadId: this.chat.threadId - }); - - /* if(this.chat.type === 'discussion') { - this.chat.appImManager.setMessageId(, originalMessageId); - } else { - this.chat.appImManager.setInnerPeer(this.peerId, originalMessageId); - } */ - // this.chat.setMessageId(, originalMessageId); - } - } - - // console.log('chatInner click', e); - }; + } public async onGoDownClick() { if(!this.followStack.length) { @@ -3219,10 +3232,33 @@ export default class ChatBubbles { this.onScroll(); - const afterSetPromise = Promise.all([setPeerPromise, getHeavyAnimationPromise()]); + const afterSetPromise = Promise.all([ + setPeerPromise, + getHeavyAnimationPromise() + ]); afterSetPromise.then(() => { // check whether list isn't full + if(!middleware()) { + return; + } + scrollable.checkForTriggers(); + if(options.mediaTimestamp !== undefined) { + // ! :( + const p = cached && !samePeer && liteMode.isAvailable('animations') && this.chat.appImManager.chats.length > 1 ? + pause(400) : + Promise.resolve(); + p.then(() => { + return this.getMountedBubble(lastMsgId); + }).then((mounted) => { + if(!middleware() || !mounted) { + return; + } + + this.playMediaWithTimestamp(mounted.bubble, options.mediaTimestamp); + }); + } + // if(cached) { // this.onRenderScrollSet(); // } @@ -3267,6 +3303,21 @@ export default class ChatBubbles { return {cached, promise: setPeerPromise}; } + public playMediaWithTimestamp(bubble: HTMLElement, timestamp: number) { + const attachment = bubble.querySelector('.attachment'); + if(attachment) { + const media = attachment.querySelector('img, video, canvas'); + this.checkTargetForMediaViewer(media, undefined, timestamp); + return; + } + + const audio = bubble.querySelector('.audio'); + if(audio) { + audio.playWithTimestamp(timestamp); + return; + } + } + private async setFetchReactionsInterval(afterSetPromise: Promise) { const middleware = this.getMiddleware(); const needReactionsInterval = this.chat.isChannel; @@ -3874,6 +3925,8 @@ export default class ChatBubbles { customEmojiSize ??= this.chat.appImManager.customEmojiSize; + const doc = (messageMedia as MessageMedia.messageMediaDocument)?.document as MyDocument; + const richText = wrapRichText(messageMessage, { entities: totalEntities, passEntities: this.passEntities, @@ -3881,7 +3934,8 @@ export default class ChatBubbles { lazyLoadQueue: this.lazyLoadQueue, customEmojiSize, middleware, - animationGroup: this.chat.animationGroup + animationGroup: this.chat.animationGroup, + maxMediaTimestamp: ((['voice', 'audio', 'video'] as MyDocument['type'][]).includes(doc?.type) && doc.duration) || undefined }); let canHaveTail = true; diff --git a/src/components/popups/forward.ts b/src/components/popups/forward.ts index 821f6c5c..0de5aeef 100644 --- a/src/components/popups/forward.ts +++ b/src/components/popups/forward.ts @@ -100,6 +100,9 @@ export default class PopupForward extends PopupPickUser { case 'voice': action = 'send_voices'; break; + case 'video': + action = 'send_videos'; + break; default: action = 'send_docs'; break; diff --git a/src/components/wrappers/video.ts b/src/components/wrappers/video.ts index 07bf9b1a..f8def7aa 100644 --- a/src/components/wrappers/video.ts +++ b/src/components/wrappers/video.ts @@ -34,7 +34,7 @@ import rootScope from '../../lib/rootScope'; import {ThumbCache} from '../../lib/storages/thumbs'; import animationIntersector, {AnimationItemGroup} from '../animationIntersector'; import appMediaPlaybackController, {MediaSearchContext} from '../appMediaPlaybackController'; -import {findMediaTargets} from '../audio'; +import AudioElement, {findMediaTargets} from '../audio'; import LazyLoadQueue from '../lazyLoadQueue'; import ProgressivePreloader from '../preloader'; import wrapPhoto from './photo'; @@ -338,7 +338,8 @@ export default async function wrapVideo({doc, container, message, boxWidth, boxH }; if(message.pFlags.is_outgoing) { - (divRound as any).onLoad = onLoad; + // ! WARNING ! just to type-check + (divRound as any as AudioElement).onLoad = onLoad; divRound.dataset.isOutgoing = '1'; } else { onLoad(); @@ -567,6 +568,8 @@ export default async function wrapVideo({doc, container, message, boxWidth, boxH preloader.setDownloadFunction(load); } + (container as any).preloader = preloader; + /* if(doc.size >= 20e6 && !doc.downloaded) { let downloadDiv = document.createElement('div'); downloadDiv.classList.add('download'); diff --git a/src/helpers/addAnchorListener.ts b/src/helpers/addAnchorListener.ts index 5aedada3..d917c69c 100644 --- a/src/helpers/addAnchorListener.ts +++ b/src/helpers/addAnchorListener.ts @@ -11,7 +11,7 @@ import parseUriParams from './string/parseUriParams'; export default function addAnchorListener(options: { name: 'showMaskedAlert' | 'execBotCommand' | 'searchByHashtag' | 'addstickers' | 'im' | 'resolve' | 'privatepost' | 'addstickers' | 'voicechat' | 'joinchat' | 'join' | 'invoice' | - 'addemoji', + 'addemoji' | 'setMediaTimestamp', protocol?: 'tg', callback: (params: Params, element?: HTMLAnchorElement) => boolean | any, noPathnameParams?: boolean, diff --git a/src/layer.d.ts b/src/layer.d.ts index 696b9f6f..def06352 100644 --- a/src/layer.d.ts +++ b/src/layer.d.ts @@ -4525,7 +4525,7 @@ export namespace ReplyMarkup { /** * @link https://core.telegram.org/type/MessageEntity */ -export type MessageEntity = MessageEntity.messageEntityUnknown | MessageEntity.messageEntityMention | MessageEntity.messageEntityHashtag | MessageEntity.messageEntityBotCommand | MessageEntity.messageEntityUrl | MessageEntity.messageEntityEmail | MessageEntity.messageEntityBold | MessageEntity.messageEntityItalic | MessageEntity.messageEntityCode | MessageEntity.messageEntityPre | MessageEntity.messageEntityTextUrl | MessageEntity.messageEntityMentionName | MessageEntity.inputMessageEntityMentionName | MessageEntity.messageEntityPhone | MessageEntity.messageEntityCashtag | MessageEntity.messageEntityUnderline | MessageEntity.messageEntityStrike | MessageEntity.messageEntityBlockquote | MessageEntity.messageEntityBankCard | MessageEntity.messageEntitySpoiler | MessageEntity.messageEntityCustomEmoji | MessageEntity.messageEntityEmoji | MessageEntity.messageEntityHighlight | MessageEntity.messageEntityLinebreak | MessageEntity.messageEntityCaret; +export type MessageEntity = MessageEntity.messageEntityUnknown | MessageEntity.messageEntityMention | MessageEntity.messageEntityHashtag | MessageEntity.messageEntityBotCommand | MessageEntity.messageEntityUrl | MessageEntity.messageEntityEmail | MessageEntity.messageEntityBold | MessageEntity.messageEntityItalic | MessageEntity.messageEntityCode | MessageEntity.messageEntityPre | MessageEntity.messageEntityTextUrl | MessageEntity.messageEntityMentionName | MessageEntity.inputMessageEntityMentionName | MessageEntity.messageEntityPhone | MessageEntity.messageEntityCashtag | MessageEntity.messageEntityUnderline | MessageEntity.messageEntityStrike | MessageEntity.messageEntityBlockquote | MessageEntity.messageEntityBankCard | MessageEntity.messageEntitySpoiler | MessageEntity.messageEntityCustomEmoji | MessageEntity.messageEntityEmoji | MessageEntity.messageEntityHighlight | MessageEntity.messageEntityLinebreak | MessageEntity.messageEntityCaret | MessageEntity.messageEntityTimestamp; export namespace MessageEntity { export type messageEntityUnknown = { @@ -4684,6 +4684,14 @@ export namespace MessageEntity { offset?: number, length?: number }; + + export type messageEntityTimestamp = { + _: 'messageEntityTimestamp', + offset?: number, + length?: number, + time?: number, + raw?: string + }; } /** @@ -11858,6 +11866,7 @@ export interface ConstructorDeclMap { 'messageEntityHighlight': MessageEntity.messageEntityHighlight, 'messageEntityLinebreak': MessageEntity.messageEntityLinebreak, 'messageEntityCaret': MessageEntity.messageEntityCaret, + 'messageEntityTimestamp': MessageEntity.messageEntityTimestamp, 'messageActionDiscussionStarted': MessageAction.messageActionDiscussionStarted, 'messageActionChatLeave': MessageAction.messageActionChatLeave, 'messageActionChannelDeletePhoto': MessageAction.messageActionChannelDeletePhoto, diff --git a/src/lib/appManagers/appImManager.ts b/src/lib/appManagers/appImManager.ts index 48321f24..f89df9aa 100644 --- a/src/lib/appManagers/appImManager.ts +++ b/src/lib/appManagers/appImManager.ts @@ -120,7 +120,8 @@ export type ChatSetPeerOptions = { startParam?: string, stack?: number, commentId?: number, - type?: ChatType + type?: ChatType, + mediaTimestamp?: number }; export type ChatSetInnerPeerOptions = Modify({ + name: 'setMediaTimestamp', + callback: (_, element) => { + const timestamp = +element.dataset.timestamp; + const bubble = findUpClassName(element, 'bubble'); + if(bubble) { + this.chat.bubbles.playMediaWithTimestamp(bubble, timestamp); + return; + } + + if(findUpClassName(element, 'media-viewer-caption')) { + const appMediaViewer = (window as any).appMediaViewer; + appMediaViewer.setMediaTimestamp(timestamp); + } + } + }); + ([ ['addstickers', INTERNAL_LINK_TYPE.STICKER_SET], ['addemoji', INTERNAL_LINK_TYPE.EMOJI_SET] @@ -707,7 +725,7 @@ export class AppImManager extends EventListenerBase<{ // pathnameParams: [string, string?], // uriParams: {comment?: number} pathnameParams: ['c', string, string] | [string, string?], - uriParams: {thread?: string, comment?: string} | {comment?: string, start?: string} + uriParams: {thread?: string, comment?: string, t?: string} | {comment?: string, start?: string, t?: string} }>({ name: 'im', callback: async({pathnameParams, uriParams}, element) => { @@ -726,7 +744,8 @@ export class AppImManager extends EventListenerBase<{ post: pathnameParams[2] || pathnameParams[1], thread, comment: uriParams.comment, - stack: this.getStackFromElement(element) + stack: this.getStackFromElement(element), + t: uriParams.t }; } else { const thread = 'thread' in uriParams ? uriParams.thread : pathnameParams[2] && pathnameParams[1]; @@ -737,7 +756,8 @@ export class AppImManager extends EventListenerBase<{ thread, comment: uriParams.comment, start: 'start' in uriParams ? uriParams.start : undefined, - stack: this.getStackFromElement(element) + stack: this.getStackFromElement(element), + t: uriParams.t }; } @@ -765,7 +785,8 @@ export class AppImManager extends EventListenerBase<{ post?: string, thread?: string, comment?: string, - phone?: string + phone?: string, + t?: string } }>({ name: 'resolve', @@ -1127,7 +1148,8 @@ export class AppImManager extends EventListenerBase<{ commentId, startParam: link.start, stack: link.stack, - threadId + threadId, + mediaTimestamp: link.t && +link.t }); break; } @@ -1153,7 +1175,8 @@ export class AppImManager extends EventListenerBase<{ peer: chat, lastMsgId: postId, threadId, - stack: link.stack + stack: link.stack, + mediaTimestamp: link.t && +link.t }); break; } diff --git a/src/lib/appManagers/internalLink.ts b/src/lib/appManagers/internalLink.ts index 9234bb68..b8a6b49b 100644 --- a/src/lib/appManagers/internalLink.ts +++ b/src/lib/appManagers/internalLink.ts @@ -25,6 +25,7 @@ export namespace InternalLink { comment?: string, thread?: string, start?: string, + t?: string, // media timestamp stack?: number // local } @@ -34,6 +35,7 @@ export namespace InternalLink { post: string, thread?: string, comment?: string, + t?: string // media timestamp stack?: number // local } diff --git a/src/lib/mediaPlayer.ts b/src/lib/mediaPlayer.ts index dcc0cacc..2548f6e5 100644 --- a/src/lib/mediaPlayer.ts +++ b/src/lib/mediaPlayer.ts @@ -270,8 +270,7 @@ export default class VideoPlayer extends ControlsHover { } } - protected togglePlay() { - const isPaused = this.video.paused; + protected togglePlay(isPaused = this.video.paused) { this.video[isPaused ? 'play' : 'pause'](); } @@ -394,6 +393,11 @@ export default class VideoPlayer extends ControlsHover { } } + public setTimestamp(timestamp: number) { + this.video.currentTime = timestamp; + this.togglePlay(true); + } + public cleanup() { super.cleanup(); this.listenerSetter.removeAll(); diff --git a/src/lib/richTextProcessor/index.ts b/src/lib/richTextProcessor/index.ts index f6f8fa3d..400f5bd9 100644 --- a/src/lib/richTextProcessor/index.ts +++ b/src/lib/richTextProcessor/index.ts @@ -69,8 +69,9 @@ export const URL_REG_EXP = URL_PROTOCOL_REG_EXP_PART + export const URL_PROTOCOL_REG_EXP = new RegExp('^' + URL_PROTOCOL_REG_EXP_PART.slice(0, -1), 'i'); export const URL_ANY_PROTOCOL_REG_EXP = /^((?:[^\/]+?):\/\/|mailto:)/; export const USERNAME_REG_EXP = '[a-zA-Z\\d_]{5,32}'; +export const TIMESTAMP_REG_EXP = '(?:\\s|^)((?:\\d{1,2}:)?(?:[0-5]?[0-9]):(?:[0-5][0-9]))(?:\\s|$)'; export const BOT_COMMAND_REG_EXP = '\\/([a-zA-Z\\d_]{1,32})(?:@(' + USERNAME_REG_EXP + '))?(\\b|$)'; -export const FULL_REG_EXP = new RegExp('(^| )(@)(' + USERNAME_REG_EXP + ')|(' + URL_REG_EXP + ')|(\\n)|(' + emojiRegExp + ')|(^|[\\s\\(\\]])(#[' + ALPHA_NUMERIC_REG_EXP + ']{2,64})|(^|\\s)' + BOT_COMMAND_REG_EXP, 'i'); +export const FULL_REG_EXP = new RegExp('(^| )(@)(' + USERNAME_REG_EXP + ')|(' + URL_REG_EXP + ')|(\\n)|(' + emojiRegExp + ')|(^|[\\s\\(\\]])(#[' + ALPHA_NUMERIC_REG_EXP + ']{2,64})|(^|\\s)' + BOT_COMMAND_REG_EXP + '|' + TIMESTAMP_REG_EXP + '', 'i'); export const EMAIL_REG_EXP = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; // const markdownTestRegExp = /[`_*@~]/; export const MARKDOWN_REG_EXP = /(^|\s|\n)(````?)([\s\S]+?)(````?)([\s\n\.,:?!;]|$)|(^|\s|\x01)(`|~~|\*\*|__|_-_|\|\|)([^\n]+?)\7([\x01\s\.,:?!;]|$)|@(\d+)\s*\((.+?)\)|(\[(.+?)\]\((.+?)\))/m; diff --git a/src/lib/richTextProcessor/parseEntities.ts b/src/lib/richTextProcessor/parseEntities.ts index bfa9e12c..f77ec7fc 100644 --- a/src/lib/richTextProcessor/parseEntities.ts +++ b/src/lib/richTextProcessor/parseEntities.ts @@ -16,14 +16,14 @@ import checkBrackets from './checkBrackets'; import getEmojiUnified from './getEmojiUnified'; export default function parseEntities(text: string) { - let match: any; + let match: RegExpMatchArray; let raw = text; const entities: MessageEntity[] = []; let matchIndex; let rawOffset = 0; // var start = tsNow() FULL_REG_EXP.lastIndex = 0; - while((match = raw.match(FULL_REG_EXP))) { + while(match = raw.match(FULL_REG_EXP)) { matchIndex = rawOffset + match.index; // console.log('parseEntities match:', match); @@ -101,6 +101,20 @@ export default function parseEntities(text: string) { length: 1 + match[13].length + (match[14] ? 1 + match[14].length : 0), unsafe: true }); + } else if(match[16]) { // Media timestamp + const timestamp = match[16]; + const splitted: string[] = timestamp.split(':'); + const splittedLength = splitted.length; + const hours = splittedLength === 3 ? +splitted[0] : 0; + const minutes = +splitted[splittedLength === 3 ? 1 : 0]; + const seconds = +splitted[splittedLength - 1]; + entities.push({ + _: 'messageEntityTimestamp', + offset: matchIndex + (/\D/.test(match[0][0]) ? 1 : 0), + length: timestamp.length, + raw: timestamp, + time: hours * 3600 + minutes * 60 + seconds + }); } raw = raw.substr(match.index + match[0].length); diff --git a/src/lib/richTextProcessor/wrapRichText.ts b/src/lib/richTextProcessor/wrapRichText.ts index a30fde9d..45229224 100644 --- a/src/lib/richTextProcessor/wrapRichText.ts +++ b/src/lib/richTextProcessor/wrapRichText.ts @@ -975,6 +975,7 @@ export default function wrapRichText(text: string, options: Partial<{ passEntities: Partial<{ [_ in MessageEntity['_']]: boolean }>, + maxMediaTimestamp: number, noEncoding: boolean, isSelectable: boolean, @@ -1462,6 +1463,20 @@ export default function wrapRichText(text: string, options: Partial<{ break; } + + case 'messageEntityTimestamp': { + if(!options.maxMediaTimestamp || entity.time > options.maxMediaTimestamp) { + break; + } + + element = document.createElement('a'); + element.classList.add('timestamp'); + element.dataset.timestamp = '' + entity.time; + (element as HTMLAnchorElement).href = '#'; + element.setAttribute('onclick', 'setMediaTimestamp(this)'); + + break; + } } if(!usedText && partText) { diff --git a/src/scripts/in/schema_additional_params.json b/src/scripts/in/schema_additional_params.json index 9a6d3716..4c243c74 100644 --- a/src/scripts/in/schema_additional_params.json +++ b/src/scripts/in/schema_additional_params.json @@ -168,6 +168,15 @@ "params": [ {"name": "unsafe", "type": "boolean"} ] +}, { + "predicate": "messageEntityTimestamp", + "params": [ + {"name": "offset", "type": "number"}, + {"name": "length", "type": "number"}, + {"name": "time", "type": "number"}, + {"name": "raw", "type": "string"} + ], + "type": "MessageEntity" }, { "predicate": "user", "params": [ diff --git a/src/scss/partials/_chatBubble.scss b/src/scss/partials/_chatBubble.scss index 4baf3d2f..5143aad8 100644 --- a/src/scss/partials/_chatBubble.scss +++ b/src/scss/partials/_chatBubble.scss @@ -2765,6 +2765,7 @@ $bubble-border-radius-big: 12px; --selection-background-color: var(--message-out-selection-background-color); --message-time-color: var(--message-out-time-color); --message-status-color: var(--message-out-status-color); + --link-color: var(--message-primary-color); .bubble-content { margin-left: auto; @@ -2807,10 +2808,6 @@ $bubble-border-radius-big: 12px; } } - .anchor-url { - color: var(--message-primary-color); - } - /* .bubble-content-wrapper { > .user-avatar { left: auto;