Send media with spoilers

Improving sending huge images
This commit is contained in:
Eduard Kuzmenko 2023-01-11 18:43:14 +04:00
parent 3a04f3b2c9
commit 68355c71b9
13 changed files with 530 additions and 215 deletions

View File

@ -127,7 +127,7 @@ import {BatchProcessor} from '../../helpers/sortedList';
import wrapUrl from '../../lib/richTextProcessor/wrapUrl';
import getMessageThreadId from '../../lib/appManagers/utils/messages/getMessageThreadId';
import wrapTopicNameButton from '../wrappers/topicNameButton';
import wrapMediaSpoiler from '../wrappers/mediaSpoiler';
import wrapMediaSpoiler, {toggleMediaSpoiler} from '../wrappers/mediaSpoiler';
import confirmationPopup from '../confirmationPopup';
import wrapPeerTitle from '../wrappers/peerTitle';
import {PopupPeerCheckboxOptions} from '../popups/peer';
@ -1654,16 +1654,12 @@ export default class ChatBubbles {
video.play().catch(noop);
}
SetTransition({
element: mediaSpoiler,
forwards: true,
className: 'is-revealing',
duration: 250,
onTransitionEnd: () => {
mediaSpoiler.remove();
mediaSpoiler.middlewareHelper.destroy();
}
toggleMediaSpoiler({
mediaSpoiler,
reveal: true,
destroyAfter: true
});
return;
}

View File

@ -944,7 +944,8 @@ export default class ChatInput {
opusDecodeController.setKeepAlive(false);
// тут objectURL ставится уже с audio/wav
this.managers.appMessagesManager.sendFile(peerId, dataBlob, {
this.managers.appMessagesManager.sendFile(peerId, {
file: dataBlob,
isVoiceMessage: true,
isMedia: true,
duration,
@ -2769,10 +2770,11 @@ export default class ChatInput {
return false;
}
this.managers.appMessagesManager.sendFile(this.chat.peerId, document, {
this.managers.appMessagesManager.sendFile(this.chat.peerId, {
...this.chat.getMessageSendingParams(),
file: document,
isMedia: true,
clearDraft: clearDraft || undefined,
clearDraft,
silent
});
this.onMessageSent(clearDraft, true);

View File

@ -5,6 +5,7 @@
*/
import type Chat from '../chat/chat';
import type {SendFileDetails} from '../../lib/appManagers/appMessagesManager';
import InputField from '../inputField';
import PopupElement from '.';
import Scrollable from '../scrollable';
@ -22,27 +23,33 @@ import getGifDuration from '../../helpers/getGifDuration';
import replaceContent from '../../helpers/dom/replaceContent';
import createVideo from '../../helpers/dom/createVideo';
import prepareAlbum from '../prepareAlbum';
import {MediaSize} from '../../helpers/mediaSize';
import {makeMediaSize, MediaSize} from '../../helpers/mediaSize';
import {ThumbCache} from '../../lib/storages/thumbs';
import onMediaLoad from '../../helpers/onMediaLoad';
import apiManagerProxy from '../../lib/mtproto/mtprotoworker';
import {THUMB_TYPE_FULL} from '../../lib/mtproto/mtproto_config';
import wrapDocument from '../wrappers/document';
import createContextMenu from '../../helpers/dom/createContextMenu';
import findUpClassName from '../../helpers/dom/findUpClassName';
import wrapMediaSpoiler, {toggleMediaSpoiler} from '../wrappers/mediaSpoiler';
import {MiddlewareHelper} from '../../helpers/middleware';
import {AnimationItemGroup} from '../animationIntersector';
import scaleMediaElement from '../../helpers/canvas/scaleMediaElement';
import {doubleRaf} from '../../helpers/schedulers';
import defineNotNumerableProperties from '../../helpers/object/defineNotNumerableProperties';
import {Photo, PhotoSize} from '../../layer';
import {getPreviewBytesFromURL} from '../../helpers/bytes/getPreviewURLFromBytes';
import {renderImageFromUrlPromise} from '../../helpers/dom/renderImageFromUrl';
type SendFileParams = Partial<{
file: File,
objectURL: string,
thumb: {
blob: Blob,
url: string,
size: MediaSize
},
width: number,
height: number,
duration: number,
noSound: boolean,
itemDiv: HTMLElement
}>;
type SendFileParams = SendFileDetails & {
file?: File,
scaledBlob?: Blob,
noSound?: boolean,
itemDiv: HTMLElement,
mediaSpoiler?: HTMLElement,
middlewareHelper: MiddlewareHelper
// strippedBytes?: PhotoSize.photoStrippedSize['bytes']
};
let currentPopup: PopupNewMedia;
@ -66,8 +73,22 @@ export default class PopupNewMedia extends PopupElement {
private inputField: InputField;
private captionLengthMax: number;
constructor(private chat: Chat, private files: File[], willAttachType: PopupNewMedia['willAttach']['type']) {
super('popup-send-photo popup-new-media', {closable: true, withConfirm: 'Modal.Send', confirmShortcutIsSendShortcut: true, body: true, title: true});
private animationGroup: AnimationItemGroup;
constructor(
private chat: Chat,
private files: File[],
willAttachType: PopupNewMedia['willAttach']['type']
) {
super('popup-send-photo popup-new-media', {
closable: true,
withConfirm: 'Modal.Send',
confirmShortcutIsSendShortcut: true,
body: true,
title: true
});
this.animationGroup = '';
this.construct(willAttachType);
}
@ -78,8 +99,8 @@ export default class PopupNewMedia extends PopupElement {
group: false
};
const config = await this.managers.apiManager.getConfig();
this.captionLengthMax = config.caption_length_max;
const captionMaxLength = await this.managers.apiManager.getLimit('caption');
this.captionLengthMax = captionMaxLength;
attachClickEvent(this.btnConfirm, () => this.send(), {listenerSetter: this.listenerSetter});
@ -127,13 +148,126 @@ export default class PopupNewMedia extends PopupElement {
this.attachFiles();
this.addEventListener('close', () => {
this.files = [];
currentPopup = undefined;
this.files.length = 0;
this.willAttach.sendFileDetails.length = 0;
if(currentPopup === this) {
currentPopup = undefined;
}
});
let target: HTMLElement, isMedia: boolean, item: SendFileParams;
createContextMenu({
buttons: [{
icon: 'spoiler',
text: 'EnablePhotoSpoiler',
onClick: () => {
this.applyMediaSpoiler(item);
},
verify: () => isMedia && !item.mediaSpoiler
}, {
icon: 'spoiler',
text: 'DisablePhotoSpoiler',
onClick: () => {
toggleMediaSpoiler({
mediaSpoiler: item.mediaSpoiler,
reveal: true,
destroyAfter: true
});
item.mediaSpoiler = undefined;
},
verify: () => !!(isMedia && item.mediaSpoiler)
}],
listenTo: this.mediaContainer,
listenerSetter: this.listenerSetter,
findElement: (e) => {
target = findUpClassName(e.target, 'popup-item');
isMedia = target.classList.contains('popup-item-media');
item = this.willAttach.sendFileDetails.find((i) => i.itemDiv === target);
return target;
}
});
currentPopup = this;
}
private async applyMediaSpoiler(item: SendFileParams, noAnimation?: boolean) {
const middleware = item.middlewareHelper.get();
const {width: widthStr, height: heightStr} = item.itemDiv.style;
let width: number, height: number;
if(item.itemDiv.classList.contains('album-item')) {
const {width: containerWidthStr, height: containerHeightStr} = item.itemDiv.parentElement.style;
const containerWidth = parseInt(containerWidthStr);
const containerHeight = parseInt(containerHeightStr);
width = +widthStr.slice(0, -1) / 100 * containerWidth;
height = +heightStr.slice(0, -1) / 100 * containerHeight;
} else {
width = parseInt(widthStr);
height = parseInt(heightStr);
}
const {url} = await scaleMediaElement({
media: item.itemDiv.firstElementChild as HTMLImageElement,
boxSize: makeMediaSize(40, 40),
mediaSize: makeMediaSize(width, height),
toDataURL: true,
quality: 0.2
});
const strippedBytes = getPreviewBytesFromURL(url);
const photoSize: PhotoSize.photoStrippedSize = {
_: 'photoStrippedSize',
bytes: strippedBytes,
type: 'i'
};
item.strippedBytes = strippedBytes;
const photo: Photo.photo = {
_: 'photo',
sizes: [
photoSize
],
id: 0,
access_hash: 0,
date: 0,
dc_id: 0,
file_reference: []
};
const mediaSpoiler = await wrapMediaSpoiler({
middleware,
width,
height,
animationGroup: this.animationGroup,
media: photo
});
if(!middleware()) {
return;
}
if(!noAnimation) {
mediaSpoiler.classList.add('is-revealing');
}
item.mediaSpoiler = mediaSpoiler;
item.itemDiv.append(mediaSpoiler);
await doubleRaf();
if(!middleware()) {
return;
}
toggleMediaSpoiler({
mediaSpoiler,
reveal: false
});
}
public appendDrops(element: HTMLElement) {
this.body.append(element);
}
@ -153,7 +287,11 @@ export default class PopupNewMedia extends PopupElement {
text: 'PreviewSender.GroupItems',
name: 'group-items'
});
this.container.append(...[this.groupCheckboxField.label, this.mediaCheckboxField?.label, this.inputField.container].filter(Boolean));
this.container.append(...[
this.groupCheckboxField.label,
this.mediaCheckboxField?.label,
this.inputField.container
].filter(Boolean));
this.willAttach.group = true;
this.groupCheckboxField.setValueSilently(this.willAttach.group);
@ -177,7 +315,11 @@ export default class PopupNewMedia extends PopupElement {
text: 'PreviewSender.CompressFile',
name: 'compress-items'
});
this.container.append(...[this.groupCheckboxField?.label, this.mediaCheckboxField.label, this.inputField.container].filter(Boolean));
this.container.append(...[
this.groupCheckboxField?.label,
this.mediaCheckboxField.label,
this.inputField.container
].filter(Boolean));
this.mediaCheckboxField.setValueSilently(this.willAttach.type === 'media');
@ -235,23 +377,16 @@ export default class PopupNewMedia extends PopupElement {
return;
}
this.hide();
const willAttach = this.willAttach;
willAttach.isMedia = willAttach.type === 'media' ? true : undefined;
willAttach.isMedia = willAttach.type === 'media' || undefined;
const {sendFileDetails, isMedia} = willAttach;
// console.log('will send files with options:', willAttach);
const {peerId, input} = this.chat;
sendFileDetails.forEach((d) => {
d.itemDiv = undefined;
});
const {length} = sendFileDetails;
const sendingParams = this.chat.getMessageSendingParams();
this.iterate((sendFileDetails) => {
if(caption && sendFileDetails.length !== length) {
this.iterate((sendFileParams) => {
if(caption && sendFileParams.length !== length) {
this.managers.appMessagesManager.sendText(peerId, caption, {
...sendingParams,
clearDraft: true
@ -260,16 +395,24 @@ export default class PopupNewMedia extends PopupElement {
caption = undefined;
}
const d: SendFileDetails[] = sendFileParams.map((params) => {
return {
...params,
file: params.scaledBlob || params.file,
spoiler: !!params.mediaSpoiler
};
});
const w = {
...willAttach,
sendFileDetails
sendFileDetails: d
};
this.managers.appMessagesManager.sendAlbum(peerId, w.sendFileDetails.map((d) => d.file), Object.assign({
this.managers.appMessagesManager.sendAlbum(peerId, Object.assign({
...sendingParams,
caption,
isMedia: isMedia,
clearDraft: true as true
isMedia,
clearDraft: true
}, w));
caption = undefined;
@ -277,9 +420,34 @@ export default class PopupNewMedia extends PopupElement {
input.replyToMsgId = this.chat.threadId;
input.onMessageSent();
this.hide();
}
private async attachMedia(params: SendFileParams, itemDiv: HTMLElement) {
private async scaleImageForTelegram(image: HTMLImageElement, params: SendFileParams) {
const PHOTO_SIDE_LIMIT = 2560;
const mimeType = params.file.type;
let url = image.src, scaledBlob: Blob;
if(mimeType !== 'image/gif' && Math.max(image.naturalWidth, image.naturalHeight) > PHOTO_SIDE_LIMIT) {
const {blob} = await scaleMediaElement({
media: image,
boxSize: makeMediaSize(PHOTO_SIDE_LIMIT, PHOTO_SIDE_LIMIT),
mediaSize: makeMediaSize(image.naturalWidth, image.naturalHeight),
mimeType: mimeType as any
});
scaledBlob = blob;
URL.revokeObjectURL(url);
url = await apiManagerProxy.invoke('createObjectURL', blob);
await renderImageFromUrlPromise(image, url);
}
params.objectURL = url;
params.scaledBlob = scaledBlob;
}
private async attachMedia(params: SendFileParams) {
const {itemDiv} = params;
itemDiv.classList.add('popup-item-media');
const file = params.file;
@ -319,50 +487,53 @@ export default class PopupNewMedia extends PopupElement {
video.append(source);
} else {
const img = new Image();
promise = new Promise<void>((resolve) => {
img.onload = () => {
params.width = img.naturalWidth;
params.height = img.naturalHeight;
const url = await apiManagerProxy.invoke('createObjectURL', file);
await renderImageFromUrlPromise(img, url);
itemDiv.append(img);
await this.scaleImageForTelegram(img, params);
params.width = img.naturalWidth;
params.height = img.naturalHeight;
if(file.type === 'image/gif') {
params.noSound = true;
itemDiv.append(img);
Promise.all([
getGifDuration(img).then((duration) => {
params.duration = Math.ceil(duration);
}),
if(file.type === 'image/gif') {
params.noSound = true;
createPosterFromMedia(img).then(async(thumb) => {
params.thumb = {
url: await apiManagerProxy.invoke('createObjectURL', thumb.blob),
...thumb
};
})
]).then(() => {
resolve();
});
} else {
resolve();
}
};
});
return Promise.all([
getGifDuration(img).then((duration) => {
params.duration = Math.ceil(duration);
}),
img.src = params.objectURL = await apiManagerProxy.invoke('createObjectURL', file);
createPosterFromMedia(img).then(async(thumb) => {
params.thumb = {
url: await apiManagerProxy.invoke('createObjectURL', thumb.blob),
...thumb
};
})
]);
}
}
return promise;
}
private async attachDocument(params: SendFileParams, itemDiv: HTMLElement): ReturnType<PopupNewMedia['attachMedia']> {
private async attachDocument(params: SendFileParams): ReturnType<PopupNewMedia['attachMedia']> {
const {itemDiv} = params;
itemDiv.classList.add('popup-item-document');
const file = params.file;
const isPhoto = file.type.startsWith('image/');
const isAudio = file.type.startsWith('audio/');
if(isPhoto || isAudio || file.size < 20e6) {
params.objectURL = await apiManagerProxy.invoke('createObjectURL', file);
params.objectURL ||= await apiManagerProxy.invoke('createObjectURL', file);
}
let img: HTMLImageElement;
if(isPhoto) {
img = new Image();
await renderImageFromUrlPromise(img, params.objectURL);
await this.scaleImageForTelegram(img, params);
params.scaledBlob = undefined;
}
const doc = {
@ -398,44 +569,32 @@ export default class PopupNewMedia extends PopupElement {
cacheContext
});
const promise = new Promise<void>((resolve) => {
const finish = () => {
itemDiv.append(docDiv);
resolve();
};
if(isPhoto) {
params.width = img.naturalWidth;
params.height = img.naturalHeight;
}
if(isPhoto) {
const img = new Image();
img.src = params.objectURL;
img.onload = () => {
params.width = img.naturalWidth;
params.height = img.naturalHeight;
finish();
};
img.onerror = finish;
} else {
finish();
}
});
return promise;
itemDiv.append(docDiv);
}
private attachFile = (file: File) => {
const willAttach = this.willAttach;
const shouldCompress = this.shouldCompress(file.type);
const params: SendFileParams = {};
params.file = file;
const itemDiv = document.createElement('div');
itemDiv.classList.add('popup-item');
const params: SendFileParams = {
file
} as any;
// do not pass these properties to worker
defineNotNumerableProperties(params, ['scaledBlob', 'middlewareHelper', 'itemDiv', 'mediaSpoiler']);
params.middlewareHelper = this.middlewareHelper.get().create();
params.itemDiv = itemDiv;
const promise = shouldCompress ? this.attachMedia(params, itemDiv) : this.attachDocument(params, itemDiv);
const promise = shouldCompress ? this.attachMedia(params) : this.attachDocument(params);
willAttach.sendFileDetails.push(params);
return promise;
};
@ -494,14 +653,14 @@ export default class PopupNewMedia extends PopupElement {
replaceContent(title, i18n(key, args));
}
private appendMediaToContainer(div: HTMLElement, params: SendFileParams) {
private appendMediaToContainer(params: SendFileParams) {
if(this.shouldCompress(params.file.type)) {
const size = calcImageInBox(params.width, params.height, 380, 320);
div.style.width = size.width + 'px';
div.style.height = size.height + 'px';
params.itemDiv.style.width = size.width + 'px';
params.itemDiv.style.height = size.height + 'px';
}
this.mediaContainer.append(div);
this.mediaContainer.append(params.itemDiv);
}
private iterate(cb: (sendFileDetails: SendFileParams[]) => void) {
@ -528,13 +687,19 @@ export default class PopupNewMedia extends PopupElement {
private attachFiles() {
const {files, willAttach, mediaContainer} = this;
willAttach.sendFileDetails.length = 0;
const oldSendFileDetails = willAttach.sendFileDetails.splice(0, willAttach.sendFileDetails.length);
oldSendFileDetails.forEach((params) => {
params.middlewareHelper.destroy();
});
this.appendGroupCheckboxField();
this.appendMediaCheckboxField();
Promise.all(files.map(this.attachFile)).then(() => {
mediaContainer.innerHTML = '';
const promises = files.map((file) => this.attachFile(file));
Promise.all(promises).then(() => {
mediaContainer.replaceChildren();
if(!files.length) {
return;
@ -543,7 +708,8 @@ export default class PopupNewMedia extends PopupElement {
this.setTitle();
this.iterate((sendFileDetails) => {
if(this.shouldCompress(sendFileDetails[0].file.type) && sendFileDetails.length > 1) {
const shouldCompress = this.shouldCompress(sendFileDetails[0].file.type);
if(shouldCompress && sendFileDetails.length > 1) {
const albumContainer = document.createElement('div');
albumContainer.classList.add('popup-item-album', 'popup-item');
albumContainer.append(...sendFileDetails.map((s) => s.itemDiv));
@ -559,9 +725,24 @@ export default class PopupNewMedia extends PopupElement {
mediaContainer.append(albumContainer);
} else {
sendFileDetails.forEach((params) => {
this.appendMediaToContainer(params.itemDiv, params);
this.appendMediaToContainer(params);
});
}
if(!shouldCompress) {
return;
}
sendFileDetails.forEach((params) => {
const oldParams = oldSendFileDetails.find((o) => o.file === params.file);
if(!oldParams) {
return;
}
if(oldParams.mediaSpoiler) {
this.applyMediaSpoiler(params, true);
}
});
});
}).then(() => {
this.onRender();

View File

@ -9,29 +9,41 @@ import {Middleware} from '../../helpers/middleware';
import {Document, Photo, PhotoSize} from '../../layer';
import {AnimationItemGroup} from '../animationIntersector';
import DotRenderer from '../dotRenderer';
import SetTransition from '../singleTransition';
export default async function wrapMediaSpoiler({
media,
export function toggleMediaSpoiler(options: {
mediaSpoiler: HTMLElement,
reveal: boolean,
destroyAfter?: boolean
}) {
const {mediaSpoiler, reveal, destroyAfter} = options;
SetTransition({
element: mediaSpoiler,
forwards: reveal,
className: 'is-revealing',
duration: 250,
onTransitionEnd: () => {
if(reveal && destroyAfter) {
mediaSpoiler.remove();
mediaSpoiler.middlewareHelper.destroy();
}
}
});
}
export function wrapMediaSpoilerWithImage({
middleware,
width,
height,
animationGroup
animationGroup,
image
}: {
media: Document.document | Photo.photo,
middleware: Middleware,
width: number,
height: number,
animationGroup: AnimationItemGroup
animationGroup: AnimationItemGroup,
image: Awaited<ReturnType<typeof getImageFromStrippedThumb>>['image']
}) {
const sizes = (media as Photo.photo).sizes || (media as Document.document).thumbs;
const thumb = sizes.find((size) => size._ === 'photoStrippedSize') as PhotoSize.photoStrippedSize;
if(!thumb) {
return;
}
const {image, loadPromise} = getImageFromStrippedThumb(media, thumb, true);
await loadPromise;
if(!middleware()) {
return;
}
@ -53,3 +65,24 @@ export default async function wrapMediaSpoiler({
return container;
}
export default async function wrapMediaSpoiler(
options: Omit<Parameters<typeof wrapMediaSpoilerWithImage>[0], 'image'> & {
media: Document.document | Photo.photo
}
) {
const {media} = options;
const sizes = (media as Photo.photo).sizes || (media as Document.document).thumbs;
const thumb = sizes.find((size) => size._ === 'photoStrippedSize') as PhotoSize.photoStrippedSize;
if(!thumb) {
return;
}
const {image, loadPromise} = getImageFromStrippedThumb(media, thumb, true);
await loadPromise;
return wrapMediaSpoilerWithImage({
...options,
image
});
}

View File

@ -21,7 +21,7 @@ const App = {
version: process.env.VERSION,
versionFull: process.env.VERSION_FULL,
build: +process.env.BUILD,
langPackVersion: '0.7.6',
langPackVersion: '0.7.7',
langPack: 'webk',
langPackCode: 'en',
domains: MAIN_DOMAINS,

View File

@ -7,18 +7,46 @@
import {IS_SAFARI} from '../../environment/userAgent';
import bytesFromHex from './bytesFromHex';
import bytesToDataURL from './bytesToDataURL';
import convertToUint8Array from './convertToUint8Array';
const JPEG_HEADER = bytesFromHex('ffd8ffe000104a46494600010100000100010000ffdb004300281c1e231e19282321232d2b28303c64413c37373c7b585d4964918099968f808c8aa0b4e6c3a0aadaad8a8cc8ffcbdaeef5ffffff9bc1fffffffaffe6fdfff8ffdb0043012b2d2d3c353c76414176f8a58ca5f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8ffc00011080000000003012200021101031101ffc4001f0000010501010101010100000000000000000102030405060708090a0bffc400b5100002010303020403050504040000017d01020300041105122131410613516107227114328191a1082342b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758595a636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f0100030101010101010101010000000000000102030405060708090a0bffc400b51100020102040403040705040400010277000102031104052131061241510761711322328108144291a1b1c109233352f0156272d10a162434e125f11718191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a82838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3f4f5f6f7f8f9faffda000c03010002110311003f00');
const JPEG_HEADER_HEX = 'ffd8ffe000104a46494600010100000100010000ffdb004300281c1e231e19282321232d2b28303c64413c37373c7b585d4964918099968f808c8aa0b4e6c3a0aadaad8a8cc8ffcbdaeef5ffffff9bc1fffffffaffe6fdfff8ffdb0043012b2d2d3c353c76414176f8a58ca5f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8f8ffc00011080000000003012200021101031101ffc4001f0000010501010101010100000000000000000102030405060708090a0bffc400b5100002010303020403050504040000017d01020300041105122131410613516107227114328191a1082342b1c11552d1f02433627282090a161718191a25262728292a3435363738393a434445464748494a535455565758595a636465666768696a737475767778797a838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae1e2e3e4e5e6e7e8e9eaf1f2f3f4f5f6f7f8f9faffc4001f0100030101010101010101010000000000000102030405060708090a0bffc400b51100020102040403040705040400010277000102031104052131061241510761711322328108144291a1b1c109233352f0156272d10a162434e125f11718191a262728292a35363738393a434445464748494a535455565758595a636465666768696a737475767778797a82838485868788898a92939495969798999aa2a3a4a5a6a7a8a9aab2b3b4b5b6b7b8b9bac2c3c4c5c6c7c8c9cad2d3d4d5d6d7d8d9dae2e3e4e5e6e7e8e9eaf2f3f4f5f6f7f8f9faffda000c03010002110311003f00';
const JPEG_HEADER = bytesFromHex(JPEG_HEADER_HEX);
const JPEG_TAIL = bytesFromHex('ffd9');
export function getPreviewBytesFromURL(url: string) {
const needle = 'base64,';
const sliced = url.slice(url.indexOf(needle) + needle.length);
const jpegBytes = [...atob(sliced)].map((char) => char.charCodeAt(0));
return new Uint8Array(jpegBytes);
// console.log('getPreviewBytesFromURL', bytesToHex(jpegBytes));
// const n = JPEG_HEADER_HEX.slice(-10);
// const one = jpegBytes[164];
// const two = jpegBytes[166];
// const body = jpegBytes.slice(bytesToHex(jpegBytes).indexOf(n) / 2 + n.length / 2/* JPEG_HEADER.length */, jpegBytes.length - JPEG_TAIL.length);
// const original = new Uint8Array([
// 0xFF,
// one,
// two,
// ...body
// ]);
// console.log(bytesToHex(body));
// return original;
}
export default function getPreviewURLFromBytes(bytes: Uint8Array | number[], isSticker = false) {
let arr: Uint8Array;
if(!isSticker) {
if(!isSticker && bytes[0] === 0x1) {
arr = new Uint8Array(JPEG_HEADER.concat(Array.from(bytes.slice(3)), JPEG_TAIL));
arr[164] = bytes[1];
arr[166] = bytes[2];
} else {
arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
arr = convertToUint8Array(bytes);
}
let mimeType: string;
@ -28,5 +56,6 @@ export default function getPreviewURLFromBytes(bytes: Uint8Array | number[], isS
mimeType = 'image/jpeg';
}
return bytesToDataURL(arr, mimeType);
const dataURL = bytesToDataURL(arr, mimeType);
return dataURL;
}

View File

@ -1,22 +1,45 @@
import type {MediaSize} from '../mediaSize';
import IS_IMAGE_BITMAP_SUPPORTED from '../../environment/imageBitmapSupport';
export default function scaleMediaElement(options: {
export default function scaleMediaElement<T extends {
media: CanvasImageSource,
mediaSize?: MediaSize,
boxSize?: MediaSize,
quality?: number,
mimeType?: 'image/jpeg' | 'image/png',
size?: MediaSize
}): Promise<{blob: Blob, size: MediaSize}> {
return new Promise((resolve) => {
size?: MediaSize,
toDataURL?: boolean
}>(options: T): Promise<T['toDataURL'] extends true ? {url: string, size: MediaSize} : {blob: Blob, size: MediaSize}> {
return new Promise(async(resolve) => {
const canvas = document.createElement('canvas');
const size = options.size ?? options.mediaSize.aspectFitted(options.boxSize);
canvas.width = size.width * window.devicePixelRatio;
canvas.height = size.height * window.devicePixelRatio;
const dpr = window.devicePixelRatio && 1;
canvas.width = size.width * dpr;
canvas.height = size.height * dpr;
const ctx = canvas.getContext('2d');
ctx.drawImage(options.media, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
resolve({blob, size});
}, options.mimeType ?? 'image/jpeg', options.quality ?? 1);
let source: CanvasImageSource;
if(IS_IMAGE_BITMAP_SUPPORTED) {
source = await createImageBitmap(options.media, {resizeWidth: size.width, resizeHeight: size.height});
} else {
source = options.media;
}
ctx.drawImage(source, 0, 0, canvas.width, canvas.height);
if(IS_IMAGE_BITMAP_SUPPORTED) {
(source as ImageBitmap)?.close();
}
const mimeType = options.mimeType ?? 'image/jpeg';
const quality = options.quality ?? 1;
if(options.toDataURL) {
const url = canvas.toDataURL(mimeType, quality);
resolve({url, size} as any);
} else {
canvas.toBlob((blob) => {
resolve({blob, size} as any);
}, mimeType, quality);
}
});
}

View File

@ -27,7 +27,7 @@ export function createPosterFromMedia(media: HTMLVideoElement | HTMLImageElement
});
}
export function createPosterFromVideo(video: HTMLVideoElement): ReturnType<typeof scaleMediaElement> {
export function createPosterFromVideo(video: HTMLVideoElement): ReturnType<typeof createPosterFromMedia> {
return new Promise((resolve, reject) => {
video.onseeked = () => {
video.onseeked = () => {

View File

@ -867,6 +867,8 @@ const lang = {
'AreYouSureWebSessions': 'Are you sure you want to disconnect all websites where you logged in with Telegram?',
'ClearOtherWebSessionsHelp': 'You can log in on websites that support signing in with Telegram.',
'TerminateWebSessionInfo': 'Tap to disconnect from your Telegram account.',
'EnablePhotoSpoiler': 'Hide with spoiler',
'DisablePhotoSpoiler': 'Remove spoiler',
// * macos
'AccountSettings.Filters': 'Chat Folders',

View File

@ -74,6 +74,22 @@ import getMessageThreadId from './utils/messages/getMessageThreadId';
const APITIMEOUT = 0;
const DO_NOT_READ_HISTORY = false;
export type SendFileDetails = {
file: File | Blob | MyDocument,
} & Partial<{
duration: number,
width: number,
height: number,
objectURL: string,
thumb: {
blob: Blob,
url: string,
size: MediaSize
},
strippedBytes: PhotoSize.photoStrippedSize['bytes'],
spoiler: boolean
}>;
export type HistoryStorage = {
count: number | null,
history: SlicedArray<number>,
@ -676,24 +692,15 @@ export class AppMessagesManager extends AppManager {
return Promise.all(promises).then(noop);
}
public sendFile(peerId: PeerId, file: File | Blob | MyDocument, options: MessageSendingParams & Partial<{
isRoundMessage: true,
isVoiceMessage: true,
isGroupedItem: true,
isMedia: true,
public sendFile(peerId: PeerId, options: MessageSendingParams & SendFileDetails & Partial<{
isRoundMessage: boolean,
isVoiceMessage: boolean,
isGroupedItem: boolean,
isMedia: boolean,
groupId: string,
caption: string,
entities: MessageEntity[],
width: number,
height: number,
objectURL: string,
thumb: {
blob: Blob,
url: string,
size: MediaSize
},
duration: number,
background: boolean,
clearDraft: boolean,
noSound: boolean,
@ -702,7 +709,8 @@ export class AppMessagesManager extends AppManager {
// ! only for internal use
processAfter?: typeof processAfter
}> = {}) {
}>) {
const file = options.file;
peerId = this.appPeersManager.getPeerMigratedTo(peerId) || peerId;
// this.checkSendOptions(options);
@ -728,6 +736,12 @@ export class AppMessagesManager extends AppManager {
const isPhoto = getEnvironment().IMAGE_MIME_TYPES_SUPPORTED.has(fileType);
const strippedPhotoSize: PhotoSize.photoStrippedSize = options.strippedBytes && {
_: 'photoStrippedSize',
bytes: options.strippedBytes,
type: 'i'
};
let photo: MyPhoto, document: MyDocument;
let actionName: Extract<SendMessageAction['_'], 'sendMessageUploadAudioAction' | 'sendMessageUploadDocumentAction' | 'sendMessageUploadPhotoAction' | 'sendMessageUploadVideoAction'>;
@ -747,7 +761,7 @@ export class AppMessagesManager extends AppManager {
const attribute: DocumentAttribute.documentAttributeAudio = {
_: 'documentAttributeAudio',
pFlags: {
voice: options.isVoiceMessage
voice: options.isVoiceMessage || undefined
},
waveform: options.waveform,
duration: options.duration || 0
@ -780,6 +794,10 @@ export class AppMessagesManager extends AppManager {
h: options.height
} as any;
if(strippedPhotoSize) {
photo.sizes.unshift(strippedPhotoSize);
}
const cacheContext = this.thumbsStorage.getCacheContext(photo, photoSize.type);
cacheContext.downloaded = file.size;
cacheContext.url = options.objectURL || '';
@ -793,7 +811,7 @@ export class AppMessagesManager extends AppManager {
const videoAttribute: DocumentAttribute.documentAttributeVideo = {
_: 'documentAttributeVideo',
pFlags: {
round_message: options.isRoundMessage,
round_message: options.isRoundMessage || undefined,
supports_streaming: true
},
duration: options.duration,
@ -874,6 +892,10 @@ export class AppMessagesManager extends AppManager {
thumbs.push(thumb);
}
if(strippedPhotoSize) {
thumbs.unshift(strippedPhotoSize);
}
/* if(thumbs.length) {
const thumb = thumbs[0] as PhotoSize.photoSize;
const docThumb = appPhotosManager.getDocumentCachedThumb(document.id);
@ -908,6 +930,10 @@ export class AppMessagesManager extends AppManager {
if(media) {
defineNotNumerableProperties(media as any, ['promise']);
(media as any).promise = sentDeferred;
if(options.spoiler) {
(media as MessageMedia.messageMediaPhoto).pFlags.spoiler = true;
}
}
message.entities = entities;
@ -989,7 +1015,10 @@ export class AppMessagesManager extends AppManager {
case 'photo':
inputMedia = {
_: 'inputMediaUploadedPhoto',
file: inputFile
file: inputFile,
pFlags: {
spoiler: options.spoiler || undefined
}
};
break;
@ -999,7 +1028,8 @@ export class AppMessagesManager extends AppManager {
file: inputFile,
mime_type: fileType,
pFlags: {
force_file: actionName === 'sendMessageUploadDocumentAction' ? true : undefined
force_file: actionName === 'sendMessageUploadDocumentAction' || undefined,
spoiler: options.spoiler || undefined
// nosound_video: options.noSound ? true : undefined
},
attributes
@ -1094,28 +1124,21 @@ export class AppMessagesManager extends AppManager {
return ret;
}
public async sendAlbum(peerId: PeerId, files: File[], options: MessageSendingParams & Partial<{
isMedia: true,
entities: MessageEntity[],
caption: string,
sendFileDetails: Partial<{
duration: number,
width: number,
height: number,
objectURL: string,
thumbBlob: Blob,
thumbURL: string
}>[],
clearDraft: true
}> = {}) {
public async sendAlbum(peerId: PeerId, options: MessageSendingParams & {
isMedia?: boolean,
entities?: MessageEntity[],
caption?: string,
sendFileDetails: SendFileDetails[],
clearDraft?: boolean
}) {
// this.checkSendOptions(options);
if(options.threadId && !options.replyToMsgId) {
options.replyToMsgId = options.threadId;
}
if(files.length === 1) {
return this.sendFile(peerId, files[0], {...options, ...options.sendFileDetails[0]});
if(options.sendFileDetails.length === 1) {
return this.sendFile(peerId, {...options, ...options.sendFileDetails[0]});
}
peerId = this.appPeersManager.getPeerMigratedTo(peerId) || peerId;
@ -1127,7 +1150,7 @@ export class AppMessagesManager extends AppManager {
caption = parseMarkdown(caption, entities);
}
this.log('sendAlbum', files, options);
this.log('sendAlbum', options);
const groupId = '' + ++this.groupedTempId;
@ -1136,9 +1159,8 @@ export class AppMessagesManager extends AppManager {
callbacks.push(cb);
};
const messages = files.map((file, idx) => {
const details = options.sendFileDetails[idx];
const o: Parameters<AppMessagesManager['sendFile']>[2] = {
const messages = options.sendFileDetails.map((details, idx) => {
const o: Parameters<AppMessagesManager['sendFile']>[1] = {
isGroupedItem: true,
isMedia: options.isMedia,
scheduleDate: options.scheduleDate,
@ -1157,7 +1179,7 @@ export class AppMessagesManager extends AppManager {
// o.replyToMsgId = replyToMsgId;
}
return this.sendFile(peerId, file, o).message;
return this.sendFile(peerId, o).message;
});
if(options.clearDraft) {
@ -1209,12 +1231,15 @@ export class AppMessagesManager extends AppManager {
const promises: Promise<InputSingleMedia>[] = messages.map((message) => {
return (message.send() as Promise<InputMedia>).then((inputMedia) => {
return this.apiManager.invokeApi('messages.uploadMedia', {
peer: inputPeer,
media: inputMedia
});
return Promise.all([
inputMedia,
this.apiManager.invokeApi('messages.uploadMedia', {
peer: inputPeer,
media: inputMedia
})
]);
})
.then((messageMedia) => {
.then(([originalInputMedia, messageMedia]) => {
let inputMedia: InputMedia;
if(messageMedia._ === 'messageMediaPhoto') {
const photo = this.appPhotosManager.savePhoto(messageMedia.photo);
@ -1224,6 +1249,17 @@ export class AppMessagesManager extends AppManager {
inputMedia = getDocumentMediaInput(doc);
}
// copy original flags
const copyProperties: (keyof InputMedia.inputMediaPhoto)[] = [
'pFlags',
'ttl_seconds'
];
copyProperties.forEach((property) => {
// @ts-ignore
inputMedia[property] = originalInputMedia[property] ?? inputMedia[property];
});
const inputSingleMedia: InputSingleMedia = {
_: 'inputSingleMedia',
media: inputMedia,

View File

@ -24,7 +24,7 @@ type HashOptions = {
};
export type ApiLimitType = 'pin' | 'folderPin' | 'folders' |
'favedStickers' | 'reactions' | 'bio' | 'topicPin';
'favedStickers' | 'reactions' | 'bio' | 'topicPin' | 'caption';
export default abstract class ApiManagerMethods extends AppManager {
private afterMessageIdTemp: number;
@ -303,7 +303,8 @@ export default abstract class ApiManagerMethods extends AppManager {
favedStickers: ['stickers_faved_limit_default', 'stickers_faved_limit_premium'],
reactions: ['reactions_user_max_default', 'reactions_user_max_premium'],
bio: ['about_length_limit_default', 'about_length_limit_premium'],
topicPin: 'topics_pinned_limit'
topicPin: 'topics_pinned_limit',
caption: ['caption_length_limit_default', 'caption_length_limit_premium']
};
isPremium ??= this.rootScope.premium;

View File

@ -2385,30 +2385,6 @@ $bubble-border-radius-big: 12px;
margin-top: 1.75rem;
}
}
.media-spoiler {
&-container {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
border-radius: inherit;
&.is-revealing {
opacity: 1;
&:not(.backwards) {
opacity: 0;
}
&.animating {
transition: opacity var(--transition-standard-out);
}
}
}
}
}
// @keyframes arrow-shake {

View File

@ -2076,3 +2076,39 @@ hr {
color: var(--danger-color) !important;
}
}
.media-spoiler {
&-container {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
border-radius: inherit;
&.is-revealing {
opacity: 1;
&:not(.backwards) {
opacity: 0;
}
&.animating {
transition: opacity var(--transition-standard-out);
}
}
}
&-thumbnail,
.canvas-thumbnail {
position: absolute;
// top: 0;
// right: 0;
// bottom: 0;
// left: 0;
object-fit: unset !important;
width: 100%;
height: 100%;
}
}