Introduce Paid Media (#4729)

This commit is contained in:
zubiden 2024-07-15 15:52:43 +02:00 committed by Alexander Zinchuk
parent aa2cda81c2
commit aad2ed366d
146 changed files with 3139 additions and 1951 deletions

View File

@ -17,7 +17,7 @@ import type {
} from '../../types'; } from '../../types';
import { pick } from '../../../util/iteratees'; import { pick } from '../../../util/iteratees';
import localDb from '../localDb'; import { addDocumentToLocalDb } from '../helpers';
import { buildApiPhoto, buildApiThumbnailFromStripped } from './common'; import { buildApiPhoto, buildApiThumbnailFromStripped } from './common';
import { omitVirtualClassFields } from './helpers'; import { omitVirtualClassFields } from './helpers';
import { buildApiDocument, buildApiWebDocument, buildVideoFromDocument } from './messageContent'; import { buildApiDocument, buildApiWebDocument, buildVideoFromDocument } from './messageContent';
@ -100,7 +100,7 @@ function buildApiAttachMenuIcon(icon: GramJs.AttachMenuBotIcon): ApiAttachBotIco
if (!document) return undefined; if (!document) return undefined;
localDb.documents[String(icon.icon.id)] = icon.icon; addDocumentToLocalDb(icon.icon);
return { return {
name: icon.name, name: icon.name,

View File

@ -85,10 +85,12 @@ export function buildApiPhoto(photo: GramJs.Photo, isSpoiler?: boolean): ApiPhot
.map(buildApiPhotoSize); .map(buildApiPhotoSize);
return { return {
mediaType: 'photo',
id: String(photo.id), id: String(photo.id),
thumbnail: buildApiThumbnailFromStripped(photo.sizes), thumbnail: buildApiThumbnailFromStripped(photo.sizes),
sizes, sizes,
isSpoiler, isSpoiler,
date: photo.date,
...(photo.videoSizes && { videoSizes: compact(photo.videoSizes.map(buildApiVideoSize)), isVideo: true }), ...(photo.videoSizes && { videoSizes: compact(photo.videoSizes.map(buildApiVideoSize)), isVideo: true }),
}; };
} }
@ -115,7 +117,7 @@ export function buildApiPhotoSize(photoSize: GramJs.PhotoSize): ApiPhotoSize {
return { return {
width: w, width: w,
height: h, height: h,
type: type as ('m' | 'x' | 'y'), type: type as ('s' | 'm' | 'x' | 'y' | 'w'),
}; };
} }

View File

@ -10,8 +10,9 @@ import type {
ApiGiveawayResults, ApiGiveawayResults,
ApiInvoice, ApiInvoice,
ApiLocation, ApiLocation,
ApiMessageExtendedMediaPreview, ApiMediaExtendedPreview,
ApiMessageStoryData, ApiMessageStoryData,
ApiPaidMedia,
ApiPhoto, ApiPhoto,
ApiPoll, ApiPoll,
ApiSticker, ApiSticker,
@ -27,7 +28,7 @@ import type { UniversalMessage } from './messages';
import { SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, VIDEO_WEBM_TYPE } from '../../../config'; import { SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, VIDEO_WEBM_TYPE } from '../../../config';
import { pick } from '../../../util/iteratees'; import { pick } from '../../../util/iteratees';
import { addStoryToLocalDb, serializeBytes } from '../helpers'; import { addMediaToLocalDb, addStoryToLocalDb, serializeBytes } from '../helpers';
import { import {
buildApiFormattedText, buildApiFormattedText,
buildApiMessageEntity, buildApiMessageEntity,
@ -74,6 +75,8 @@ export function buildMessageTextContent(
} }
export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): MediaContent | undefined { export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): MediaContent | undefined {
addMediaToLocalDb(media);
const ttlSeconds = 'ttlSeconds' in media ? media.ttlSeconds : undefined; const ttlSeconds = 'ttlSeconds' in media ? media.ttlSeconds : undefined;
const isExpiredVoice = isExpiredVoiceMessage(media); const isExpiredVoice = isExpiredVoiceMessage(media);
@ -98,7 +101,7 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): MediaC
return undefined; return undefined;
} }
if ('extendedMedia' in media && media.extendedMedia instanceof GramJs.MessageExtendedMedia) { if (media instanceof GramJs.MessageMediaInvoice && media.extendedMedia instanceof GramJs.MessageExtendedMedia) {
return buildMessageMediaContent(media.extendedMedia.media); return buildMessageMediaContent(media.extendedMedia.media);
} }
@ -145,6 +148,9 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): MediaC
const giveawayResults = buildGiweawayResultsFromMedia(media); const giveawayResults = buildGiweawayResultsFromMedia(media);
if (giveawayResults) return { giveawayResults }; if (giveawayResults) return { giveawayResults };
const paidMedia = buildPaidMedia(media);
if (paidMedia) return { paidMedia };
return undefined; return undefined;
} }
@ -202,6 +208,7 @@ export function buildVideoFromDocument(document: GramJs.Document, isSpoiler?: bo
} = videoAttr; } = videoAttr;
return { return {
mediaType: 'video',
id: String(id), id: String(id),
mimeType, mimeType,
duration, duration,
@ -241,6 +248,7 @@ export function buildAudioFromDocument(document: GramJs.Document): ApiAudio | un
} = audioAttributes; } = audioAttributes;
return { return {
mediaType: 'audio',
id: String(id), id: String(id),
mimeType, mimeType,
duration, duration,
@ -298,6 +306,7 @@ function buildAudio(media: GramJs.TypeMessageMedia): ApiAudio | undefined {
.map((thumb) => buildApiPhotoSize(thumb)); .map((thumb) => buildApiPhotoSize(thumb));
return { return {
mediaType: 'audio',
id: String(media.document.id), id: String(media.document.id),
fileName: getFilenameFromDocument(media.document, 'audio'), fileName: getFilenameFromDocument(media.document, 'audio'),
thumbnailSizes, thumbnailSizes,
@ -342,7 +351,9 @@ function buildVoice(media: GramJs.TypeMessageMedia): ApiVoice | undefined {
const { duration, waveform } = audioAttribute; const { duration, waveform } = audioAttribute;
return { return {
mediaType: 'voice',
id: String(media.document.id), id: String(media.document.id),
size: media.document.size.toJSNumber(),
duration, duration,
waveform: waveform ? Array.from(waveform) : undefined, waveform: waveform ? Array.from(waveform) : undefined,
}; };
@ -374,7 +385,7 @@ export function buildApiDocument(document: GramJs.TypeDocument): ApiDocument | u
} }
} }
let mediaType: ApiDocument['mediaType'] | undefined; let innerMediaType: ApiDocument['innerMediaType'] | undefined;
let mediaSize: ApiDocument['mediaSize'] | undefined; let mediaSize: ApiDocument['mediaSize'] | undefined;
if (photoSize) { if (photoSize) {
mediaSize = { mediaSize = {
@ -383,7 +394,7 @@ export function buildApiDocument(document: GramJs.TypeDocument): ApiDocument | u
}; };
if (SUPPORTED_IMAGE_CONTENT_TYPES.has(mimeType)) { if (SUPPORTED_IMAGE_CONTENT_TYPES.has(mimeType)) {
mediaType = 'photo'; innerMediaType = 'photo';
const imageAttribute = attributes const imageAttribute = attributes
.find((a: any): a is GramJs.DocumentAttributeImageSize => a instanceof GramJs.DocumentAttributeImageSize); .find((a: any): a is GramJs.DocumentAttributeImageSize => a instanceof GramJs.DocumentAttributeImageSize);
@ -396,7 +407,7 @@ export function buildApiDocument(document: GramJs.TypeDocument): ApiDocument | u
}; };
} }
} else if (SUPPORTED_VIDEO_CONTENT_TYPES.has(mimeType)) { } else if (SUPPORTED_VIDEO_CONTENT_TYPES.has(mimeType)) {
mediaType = 'video'; innerMediaType = 'video';
const videoAttribute = attributes const videoAttribute = attributes
.find((a: any): a is GramJs.DocumentAttributeVideo => a instanceof GramJs.DocumentAttributeVideo); .find((a: any): a is GramJs.DocumentAttributeVideo => a instanceof GramJs.DocumentAttributeVideo);
@ -411,13 +422,14 @@ export function buildApiDocument(document: GramJs.TypeDocument): ApiDocument | u
} }
return { return {
mediaType: 'document',
id: String(id), id: String(id),
size: size.toJSNumber(), size: size.toJSNumber(),
mimeType, mimeType,
timestamp: date, timestamp: date,
fileName: getFilenameFromDocument(document), fileName: getFilenameFromDocument(document),
thumbnail, thumbnail,
mediaType, innerMediaType,
mediaSize, mediaSize,
}; };
} }
@ -432,7 +444,11 @@ function buildContact(media: GramJs.TypeMessageMedia): ApiContact | undefined {
} = media; } = media;
return { return {
firstName, lastName, phoneNumber, userId: buildApiPeerId(userId, 'user'), mediaType: 'contact',
firstName,
lastName,
phoneNumber,
userId: buildApiPeerId(userId, 'user'),
}; };
} }
@ -470,7 +486,7 @@ function buildLocationFromMedia(media: GramJs.TypeMessageMedia): ApiLocation | u
function buildGeo(media: GramJs.MessageMediaGeo): ApiLocation | undefined { function buildGeo(media: GramJs.MessageMediaGeo): ApiLocation | undefined {
const point = buildGeoPoint(media.geo); const point = buildGeoPoint(media.geo);
return point && { type: 'geo', geo: point }; return point && { mediaType: 'geo', geo: point };
} }
function buildVenue(media: GramJs.MessageMediaVenue): ApiLocation | undefined { function buildVenue(media: GramJs.MessageMediaVenue): ApiLocation | undefined {
@ -479,7 +495,7 @@ function buildVenue(media: GramJs.MessageMediaVenue): ApiLocation | undefined {
} = media; } = media;
const point = buildGeoPoint(geo); const point = buildGeoPoint(geo);
return point && { return point && {
type: 'venue', mediaType: 'venue',
geo: point, geo: point,
title, title,
provider, provider,
@ -493,7 +509,7 @@ function buildGeoLive(media: GramJs.MessageMediaGeoLive): ApiLocation | undefine
const { geo, period, heading } = media; const { geo, period, heading } = media;
const point = buildGeoPoint(geo); const point = buildGeoPoint(geo);
return point && { return point && {
type: 'geoLive', mediaType: 'geoLive',
geo: point, geo: point,
period, period,
heading, heading,
@ -530,6 +546,7 @@ function buildGame(media: GramJs.MessageMediaGame): ApiGame | undefined {
const document = apiDocument instanceof GramJs.Document ? buildApiDocument(apiDocument) : undefined; const document = apiDocument instanceof GramJs.Document ? buildApiDocument(apiDocument) : undefined;
return { return {
mediaType: 'game',
id: id.toString(), id: id.toString(),
accessHash: accessHash.toString(), accessHash: accessHash.toString(),
shortName, shortName,
@ -556,6 +573,7 @@ function buildGiveaway(media: GramJs.MessageMediaGiveaway): ApiGiveaway | undefi
const channelIds = channels.map((channel) => buildApiPeerId(channel, 'channel')); const channelIds = channels.map((channel) => buildApiPeerId(channel, 'channel'));
return { return {
mediaType: 'giveaway',
channelIds, channelIds,
months, months,
quantity, quantity,
@ -583,6 +601,7 @@ function buildGiveawayResults(media: GramJs.MessageMediaGiveawayResults): ApiGiv
const winnerIds = winners.map((winner) => buildApiPeerId(winner, 'user')); const winnerIds = winners.map((winner) => buildApiPeerId(winner, 'user'));
return { return {
mediaType: 'giveawayResults',
months, months,
untilDate, untilDate,
isOnlyForNewSubscribers: onlyNewSubscribers, isOnlyForNewSubscribers: onlyNewSubscribers,
@ -604,7 +623,12 @@ export function buildMessageStoryData(media: GramJs.TypeMessageMedia): ApiMessag
const peerId = getApiChatIdFromMtpPeer(media.peer); const peerId = getApiChatIdFromMtpPeer(media.peer);
return { id: media.id, peerId, ...(media.viaMention && { isMention: true }) }; return {
mediaType: 'storyData',
id: media.id,
peerId,
...(media.viaMention && { isMention: true }),
};
} }
export function buildPoll(poll: GramJs.Poll, pollResults: GramJs.PollResults): ApiPoll { export function buildPoll(poll: GramJs.Poll, pollResults: GramJs.PollResults): ApiPoll {
@ -615,6 +639,7 @@ export function buildPoll(poll: GramJs.Poll, pollResults: GramJs.PollResults): A
})); }));
return { return {
mediaType: 'poll',
id: String(id), id: String(id),
summary: { summary: {
isPublic: poll.publicVoters, isPublic: poll.publicVoters,
@ -641,6 +666,7 @@ export function buildInvoice(media: GramJs.MessageMediaInvoice): ApiInvoice {
? buildApiMessageExtendedMediaPreview(extendedMedia) : undefined; ? buildApiMessageExtendedMediaPreview(extendedMedia) : undefined;
return { return {
mediaType: 'invoice',
title, title,
text, text,
photo: buildApiWebDocument(photo), photo: buildApiWebDocument(photo),
@ -722,6 +748,7 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef
} }
return { return {
mediaType: 'webpage',
id: Number(id), id: Number(id),
...pick(media.webpage, [ ...pick(media.webpage, [
'url', 'url',
@ -741,6 +768,40 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef
}; };
} }
function buildPaidMedia(media: GramJs.TypeMessageMedia): ApiPaidMedia | undefined {
if (!(media instanceof GramJs.MessageMediaPaidMedia)) {
return undefined;
}
const { starsAmount, extendedMedia } = media;
const isBought = extendedMedia[0] instanceof GramJs.MessageExtendedMedia;
if (isBought) {
return {
mediaType: 'paidMedia',
starsAmount: starsAmount.toJSNumber(),
isBought,
extendedMedia: extendedMedia
.filter((paidMedia): paidMedia is GramJs.MessageExtendedMedia => (
paidMedia instanceof GramJs.MessageExtendedMedia
))
.map((paidMedia) => buildMessageMediaContent(paidMedia.media))
.filter(Boolean),
};
}
return {
mediaType: 'paidMedia',
starsAmount: starsAmount.toJSNumber(),
extendedMedia: extendedMedia
.filter((paidMedia): paidMedia is GramJs.MessageExtendedMediaPreview => (
paidMedia instanceof GramJs.MessageExtendedMediaPreview
))
.map((paidMedia) => buildApiMessageExtendedMediaPreview(paidMedia)),
};
}
function getFilenameFromDocument(document: GramJs.Document, defaultBase = 'file') { function getFilenameFromDocument(document: GramJs.Document, defaultBase = 'file') {
const { mimeType, attributes } = document; const { mimeType, attributes } = document;
const filenameAttribute = attributes const filenameAttribute = attributes
@ -757,12 +818,13 @@ function getFilenameFromDocument(document: GramJs.Document, defaultBase = 'file'
export function buildApiMessageExtendedMediaPreview( export function buildApiMessageExtendedMediaPreview(
preview: GramJs.MessageExtendedMediaPreview, preview: GramJs.MessageExtendedMediaPreview,
): ApiMessageExtendedMediaPreview { ): ApiMediaExtendedPreview {
const { const {
w, h, thumb, videoDuration, w, h, thumb, videoDuration,
} = preview; } = preview;
return { return {
mediaType: 'extendedMediaPreview',
width: w, width: w,
height: h, height: h,
duration: videoDuration, duration: videoDuration,
@ -783,6 +845,7 @@ export function buildApiWebDocument(document?: GramJs.TypeWebDocument): ApiWebDo
const dimensions = sizeAttr && { width: sizeAttr.w, height: sizeAttr.h }; const dimensions = sizeAttr && { width: sizeAttr.w, height: sizeAttr.h };
return { return {
mediaType: 'webDocument',
url, url,
accessHash, accessHash,
size, size,
@ -790,3 +853,16 @@ export function buildApiWebDocument(document?: GramJs.TypeWebDocument): ApiWebDo
dimensions, dimensions,
}; };
} }
export function buildBoughtMediaContent(media: GramJs.TypeMessageExtendedMedia[]): MediaContent[] | undefined {
const boughtMedia = media
.filter((m): m is GramJs.MessageExtendedMedia => m instanceof GramJs.MessageExtendedMedia)
.map((m) => buildMessageMediaContent(m.media))
.filter(Boolean);
if (!boughtMedia.length) {
return undefined;
}
return boughtMedia;
}

View File

@ -17,6 +17,7 @@ import type {
ApiNewPoll, ApiNewPoll,
ApiPeer, ApiPeer,
ApiPhoto, ApiPhoto,
ApiPoll,
ApiQuickReply, ApiQuickReply,
ApiReplyInfo, ApiReplyInfo,
ApiReplyKeyboard, ApiReplyKeyboard,
@ -246,7 +247,7 @@ export function buildMessageDraft(draft: GramJs.TypeDraftMessage): ApiDraft | un
} }
const { const {
message, entities, replyTo, date, message, entities, replyTo, date, effect,
} = draft; } = draft;
const replyInfo = replyTo instanceof GramJs.InputReplyToMessage ? { const replyInfo = replyTo instanceof GramJs.InputReplyToMessage ? {
@ -261,6 +262,7 @@ export function buildMessageDraft(draft: GramJs.TypeDraftMessage): ApiDraft | un
text: message ? buildMessageTextContent(message, entities) : undefined, text: message ? buildMessageTextContent(message, entities) : undefined,
replyInfo, replyInfo,
date, date,
effectId: effect?.toString(),
}; };
} }
@ -610,6 +612,7 @@ function buildAction(
} }
return { return {
mediaType: 'action',
text, text,
type, type,
targetUserIds, targetUserIds,
@ -777,13 +780,12 @@ function buildReplyButtons(message: UniversalMessage, shouldSkipBuyButton?: bool
}; };
} }
function buildNewPoll(poll: ApiNewPoll, localId: number) { function buildNewPoll(poll: ApiNewPoll, localId: number): ApiPoll {
return { return {
poll: { mediaType: 'poll',
id: String(localId), id: String(localId),
summary: pick(poll.summary, ['question', 'answers']), summary: pick(poll.summary, ['question', 'answers']),
results: {}, results: {},
},
}; };
} }
@ -824,9 +826,12 @@ export function buildLocalMessage(
...media, ...media,
...(sticker && { sticker }), ...(sticker && { sticker }),
...(gif && { video: gif }), ...(gif && { video: gif }),
...(poll && buildNewPoll(poll, localId)), poll: poll && buildNewPoll(poll, localId),
...(contact && { contact }), contact,
...(story && { storyData: story }), storyData: story && {
mediaType: 'storyData',
...story,
},
}, },
date: scheduledAt || Math.round(Date.now() / 1000) + getServerTimeOffset(), date: scheduledAt || Math.round(Date.now() / 1000) + getServerTimeOffset(),
isOutgoing: !isChannel, isOutgoing: !isChannel,
@ -981,10 +986,12 @@ export function buildUploadingMedia(
const { width, height } = attachment.quick; const { width, height } = attachment.quick;
return { return {
photo: { photo: {
mediaType: 'photo',
id: LOCAL_MEDIA_UPLOADING_TEMP_ID, id: LOCAL_MEDIA_UPLOADING_TEMP_ID,
sizes: [], sizes: [],
thumbnail: { width, height, dataUri: previewBlobUrl || blobUrl }, thumbnail: { width, height, dataUri: previewBlobUrl || blobUrl },
blobUrl, blobUrl,
date: Math.round(Date.now() / 1000),
isSpoiler: shouldSendAsSpoiler, isSpoiler: shouldSendAsSpoiler,
}, },
}; };
@ -993,6 +1000,7 @@ export function buildUploadingMedia(
const { width, height, duration } = attachment.quick; const { width, height, duration } = attachment.quick;
return { return {
video: { video: {
mediaType: 'video',
id: LOCAL_MEDIA_UPLOADING_TEMP_ID, id: LOCAL_MEDIA_UPLOADING_TEMP_ID,
mimeType, mimeType,
duration: duration || 0, duration: duration || 0,
@ -1012,9 +1020,11 @@ export function buildUploadingMedia(
const { data: inputWaveform } = interpolateArray(waveform, INPUT_WAVEFORM_LENGTH); const { data: inputWaveform } = interpolateArray(waveform, INPUT_WAVEFORM_LENGTH);
return { return {
voice: { voice: {
mediaType: 'voice',
id: LOCAL_MEDIA_UPLOADING_TEMP_ID, id: LOCAL_MEDIA_UPLOADING_TEMP_ID,
duration, duration,
waveform: inputWaveform, waveform: inputWaveform,
size,
}, },
ttlSeconds, ttlSeconds,
}; };
@ -1023,6 +1033,7 @@ export function buildUploadingMedia(
const { duration, performer, title } = audio || {}; const { duration, performer, title } = audio || {};
return { return {
audio: { audio: {
mediaType: 'audio',
id: LOCAL_MEDIA_UPLOADING_TEMP_ID, id: LOCAL_MEDIA_UPLOADING_TEMP_ID,
mimeType, mimeType,
fileName, fileName,
@ -1036,6 +1047,7 @@ export function buildUploadingMedia(
} }
return { return {
document: { document: {
mediaType: 'document',
mimeType, mimeType,
fileName, fileName,
size, size,

View File

@ -12,12 +12,13 @@ import type {
ApiStarsTransaction, ApiStarsTransaction,
ApiStarsTransactionPeer, ApiStarsTransactionPeer,
ApiStarTopupOption, ApiStarTopupOption,
BoughtPaidMedia,
} from '../../types'; } from '../../types';
import { addWebDocumentToLocalDb } from '../helpers'; import { addWebDocumentToLocalDb } from '../helpers';
import { buildApiMessageEntity } from './common'; import { buildApiMessageEntity } from './common';
import { omitVirtualClassFields } from './helpers'; import { omitVirtualClassFields } from './helpers';
import { buildApiDocument, buildApiWebDocument } from './messageContent'; import { buildApiDocument, buildApiWebDocument, buildMessageMediaContent } from './messageContent';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
import { buildPrepaidGiveaway, buildStatisticsPercentage } from './statistics'; import { buildPrepaidGiveaway, buildStatisticsPercentage } from './statistics';
@ -214,6 +215,7 @@ export function buildApiInvoiceFromForm(form: GramJs.payments.TypePaymentForm):
const totalAmount = prices.reduce((ac, cur) => ac + cur.amount.toJSNumber(), 0); const totalAmount = prices.reduce((ac, cur) => ac + cur.amount.toJSNumber(), 0);
return { return {
mediaType: 'invoice',
text, text,
title, title,
photo: buildApiWebDocument(photo), photo: buildApiWebDocument(photo),
@ -398,6 +400,10 @@ export function buildApiStarsTransactionPeer(peer: GramJs.TypeStarsTransactionPe
return { type: 'fragment' }; return { type: 'fragment' };
} }
if (peer instanceof GramJs.StarsTransactionPeerAds) {
return { type: 'ads' };
}
if (peer instanceof GramJs.StarsTransactionPeer) { if (peer instanceof GramJs.StarsTransactionPeer) {
return { type: 'peer', id: getApiChatIdFromMtpPeer(peer.peer) }; return { type: 'peer', id: getApiChatIdFromMtpPeer(peer.peer) };
} }
@ -407,13 +413,16 @@ export function buildApiStarsTransactionPeer(peer: GramJs.TypeStarsTransactionPe
export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): ApiStarsTransaction { export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): ApiStarsTransaction {
const { const {
date, id, peer, stars, description, photo, title, refund, date, id, peer, stars, description, photo, title, refund, extendedMedia, failed, msgId, pending,
} = transaction; } = transaction;
if (photo) { if (photo) {
addWebDocumentToLocalDb(photo); addWebDocumentToLocalDb(photo);
} }
const boughtExtendedMedia = extendedMedia?.map((m) => buildMessageMediaContent(m))
.filter(Boolean) as BoughtPaidMedia[];
return { return {
id, id,
date, date,
@ -423,6 +432,10 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction):
description, description,
photo: photo && buildApiWebDocument(photo), photo: photo && buildApiWebDocument(photo),
isRefund: refund, isRefund: refund,
hasFailed: failed,
isPending: pending,
messageId: msgId,
extendedMedia: boughtExtendedMedia,
}; };
} }

View File

@ -150,7 +150,7 @@ export function buildApiStealthMode(stealthMode: GramJs.TypeStoriesStealthMode):
function buildApiMediaAreaCoordinates(coordinates: GramJs.TypeMediaAreaCoordinates): ApiMediaAreaCoordinates { function buildApiMediaAreaCoordinates(coordinates: GramJs.TypeMediaAreaCoordinates): ApiMediaAreaCoordinates {
const { const {
x, y, w, h, rotation, x, y, w, h, rotation, radius,
} = coordinates; } = coordinates;
return { return {
@ -159,6 +159,7 @@ function buildApiMediaAreaCoordinates(coordinates: GramJs.TypeMediaAreaCoordinat
width: w, width: w,
height: h, height: h,
rotation, rotation,
radius,
}; };
} }
@ -218,6 +219,16 @@ export function buildApiMediaArea(area: GramJs.TypeMediaArea): ApiMediaArea | un
}; };
} }
if (area instanceof GramJs.MediaAreaUrl) {
const { coordinates, url } = area;
return {
type: 'url',
coordinates: buildApiMediaAreaCoordinates(coordinates),
url,
};
}
return undefined; return undefined;
} }

View File

@ -81,6 +81,7 @@ export function buildStickerFromDocument(document: GramJs.TypeDocument,
.some(({ type }) => type === 'f'); .some(({ type }) => type === 'f');
return { return {
mediaType: 'sticker',
id: String(document.id), id: String(document.id),
stickerSetInfo, stickerSetInfo,
emoji, emoji,

View File

@ -353,36 +353,6 @@ export function buildMtpMessageEntity(entity: ApiMessageEntity): GramJs.TypeMess
} }
} }
export function isMessageWithMedia(message: GramJs.Message | GramJs.UpdateServiceNotification) {
const { media } = message;
if (!media) {
return false;
}
return (
media instanceof GramJs.MessageMediaPhoto
|| media instanceof GramJs.MessageMediaDocument
|| (
media instanceof GramJs.MessageMediaWebPage
&& media.webpage instanceof GramJs.WebPage
&& (
media.webpage.photo instanceof GramJs.Photo || (
media.webpage.document instanceof GramJs.Document
)
)
) || (
media instanceof GramJs.MessageMediaGame
&& (media.game.document instanceof GramJs.Document || media.game.photo instanceof GramJs.Photo)
) || (
media instanceof GramJs.MessageMediaInvoice && (media.photo || media.extendedMedia)
)
);
}
export function isServiceMessageWithMedia(message: GramJs.MessageService) {
return 'photo' in message.action && message.action.photo instanceof GramJs.Photo;
}
export function buildChatPhotoForLocalDb(photo: GramJs.TypePhoto) { export function buildChatPhotoForLocalDb(photo: GramJs.TypePhoto) {
if (photo instanceof GramJs.PhotoEmpty) { if (photo instanceof GramJs.PhotoEmpty) {
return new GramJs.ChatPhotoEmpty(); return new GramJs.ChatPhotoEmpty();

View File

@ -1,6 +1,6 @@
import { Api as GramJs } from '../../lib/gramjs'; import { Api as GramJs } from '../../lib/gramjs';
import type { StoryRepairInfo } from './localDb'; import type { RepairInfo } from './localDb';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers';
import localDb from './localDb'; import localDb from './localDb';
@ -34,57 +34,76 @@ export function isChatFolder(
return filter instanceof GramJs.DialogFilter || filter instanceof GramJs.DialogFilterChatlist; return filter instanceof GramJs.DialogFilter || filter instanceof GramJs.DialogFilterChatlist;
} }
export function addMessageToLocalDb(message: GramJs.Message | GramJs.MessageService) { export function addMessageToLocalDb(message: GramJs.TypeMessage | GramJs.TypeSponsoredMessage) {
const messageFullId = `${resolveMessageApiChatId(message)}-${message.id}`; if (message instanceof GramJs.Message) {
if (message.media) addMediaToLocalDb(message.media, message);
let mockMessage = message; if (message.replyTo instanceof GramJs.MessageReplyHeader && message.replyTo.replyMedia) {
if (message instanceof GramJs.Message addMediaToLocalDb(message.replyTo.replyMedia, message);
&& message.media instanceof GramJs.MessageMediaInvoice
&& message.media.extendedMedia instanceof GramJs.MessageExtendedMedia) {
mockMessage = new GramJs.Message({
...message,
media: message.media.extendedMedia.media,
});
}
localDb.messages[messageFullId] = mockMessage;
if (mockMessage instanceof GramJs.Message) {
if (mockMessage.media) addMediaToLocalDb(mockMessage.media);
if (mockMessage.replyTo instanceof GramJs.MessageReplyHeader && mockMessage.replyTo.replyMedia) {
addMediaToLocalDb(mockMessage.replyTo.replyMedia);
} }
} }
if (mockMessage instanceof GramJs.MessageService && 'photo' in mockMessage.action) { if (message instanceof GramJs.MessageService && 'photo' in message.action) {
addPhotoToLocalDb(mockMessage.action.photo); const photo = addMessageRepairInfo(message.action.photo, message);
addPhotoToLocalDb(photo);
}
if (message instanceof GramJs.SponsoredMessage && message.photo) {
addPhotoToLocalDb(message.photo);
} }
} }
function addMediaToLocalDb(media: GramJs.TypeMessageMedia) { export function addMediaToLocalDb(media: GramJs.TypeMessageMedia, message?: GramJs.TypeMessage) {
if (media instanceof GramJs.MessageMediaDocument if (media instanceof GramJs.MessageMediaDocument && media.document) {
&& media.document instanceof GramJs.Document const document = addMessageRepairInfo(media.document, message);
) { addDocumentToLocalDb(document);
localDb.documents[String(media.document.id)] = media.document;
} }
if (media instanceof GramJs.MessageMediaWebPage if (media instanceof GramJs.MessageMediaWebPage
&& media.webpage instanceof GramJs.WebPage && media.webpage instanceof GramJs.WebPage
&& media.webpage.document instanceof GramJs.Document
) { ) {
localDb.documents[String(media.webpage.document.id)] = media.webpage.document; if (media.webpage.document) {
const document = addMessageRepairInfo(media.webpage.document, message);
addDocumentToLocalDb(document);
}
if (media.webpage.photo) {
const photo = addMessageRepairInfo(media.webpage.photo, message);
addPhotoToLocalDb(photo);
}
} }
if (media instanceof GramJs.MessageMediaGame) { if (media instanceof GramJs.MessageMediaGame) {
if (media.game.document instanceof GramJs.Document) { if (media.game.document) {
localDb.documents[String(media.game.document.id)] = media.game.document; const document = addMessageRepairInfo(media.game.document, message);
addDocumentToLocalDb(document);
} }
addPhotoToLocalDb(media.game.photo);
const photo = addMessageRepairInfo(media.game.photo, message);
addPhotoToLocalDb(photo);
} }
if (media instanceof GramJs.MessageMediaInvoice && media.photo) { if (media instanceof GramJs.MessageMediaPhoto && media.photo) {
addWebDocumentToLocalDb(media.photo); const photo = addMessageRepairInfo(media.photo, message);
addPhotoToLocalDb(photo);
}
if (media instanceof GramJs.MessageMediaInvoice) {
if (media.photo) {
const photo = addMessageRepairInfo(media.photo, message);
addWebDocumentToLocalDb(photo);
}
if (media.extendedMedia instanceof GramJs.MessageExtendedMedia) {
addMediaToLocalDb(media.extendedMedia.media, message);
}
}
if (media instanceof GramJs.MessageMediaPaidMedia) {
media.extendedMedia.forEach((extendedMedia) => {
if (extendedMedia instanceof GramJs.MessageExtendedMedia) {
addMediaToLocalDb(extendedMedia.media, message);
}
});
} }
} }
@ -93,27 +112,20 @@ export function addStoryToLocalDb(story: GramJs.TypeStoryItem, peerId: string) {
return; return;
} }
const storyData = { if (story.media instanceof GramJs.MessageMediaPhoto && story.media.photo) {
id: story.id, const photo = addStoryRepairInfo(story.media.photo, peerId, story);
peerId,
};
if (story.media instanceof GramJs.MessageMediaPhoto) {
const photo = story.media.photo as GramJs.Photo & StoryRepairInfo;
photo.storyData = storyData;
addPhotoToLocalDb(photo); addPhotoToLocalDb(photo);
} }
if (story.media instanceof GramJs.MessageMediaDocument) { if (story.media instanceof GramJs.MessageMediaDocument) {
if (story.media.document instanceof GramJs.Document) { if (story.media.document instanceof GramJs.Document) {
const doc = story.media.document as GramJs.Document & StoryRepairInfo; const doc = addStoryRepairInfo(story.media.document, peerId, story);
doc.storyData = storyData; addDocumentToLocalDb(doc);
localDb.documents[String(story.media.document.id)] = doc;
} }
if (story.media.altDocument instanceof GramJs.Document) { if (story.media.altDocument instanceof GramJs.Document) {
const doc = story.media.altDocument as GramJs.Document & StoryRepairInfo; const doc = addStoryRepairInfo(story.media.altDocument, peerId, story);
doc.storyData = storyData; addDocumentToLocalDb(doc);
localDb.documents[String(story.media.altDocument.id)] = doc;
} }
} }
} }
@ -124,6 +136,42 @@ export function addPhotoToLocalDb(photo: GramJs.TypePhoto) {
} }
} }
export function addDocumentToLocalDb(document: GramJs.TypeDocument) {
if (document instanceof GramJs.Document) {
localDb.documents[String(document.id)] = document;
}
}
export function addStoryRepairInfo<T extends GramJs.TypeDocument | GramJs.TypeWebDocument | GramJs.TypePhoto>(
media: T, peerId: string, story: GramJs.TypeStoryItem,
) : T & RepairInfo {
if (!(media instanceof GramJs.Document && media instanceof GramJs.Photo)) return media;
const repairableMedia = media as T & RepairInfo;
repairableMedia.localRepairInfo = {
type: 'story',
peerId,
id: story.id,
};
return repairableMedia;
}
export function addMessageRepairInfo<T extends GramJs.TypeDocument | GramJs.TypeWebDocument | GramJs.TypePhoto>(
media: T, message?: GramJs.TypeMessage,
) : T & RepairInfo {
if (!message?.peerId) return media;
if (!(media instanceof GramJs.Document && media instanceof GramJs.Photo && media instanceof GramJs.WebDocument)) {
return media;
}
const repairableMedia = media as T & RepairInfo;
repairableMedia.localRepairInfo = {
type: 'message',
peerId: getApiChatIdFromMtpPeer(message.peerId),
id: message.id,
};
return repairableMedia;
}
export function addChatToLocalDb(chat: GramJs.Chat | GramJs.Channel) { export function addChatToLocalDb(chat: GramJs.Chat | GramJs.Channel) {
const id = buildApiPeerId(chat.id, chat instanceof GramJs.Chat ? 'chat' : 'channel'); const id = buildApiPeerId(chat.id, chat instanceof GramJs.Chat ? 'chat' : 'channel');
const storedChat = localDb.chats[id]; const storedChat = localDb.chats[id];
@ -138,6 +186,11 @@ export function addChatToLocalDb(chat: GramJs.Chat | GramJs.Channel) {
export function addUserToLocalDb(user: GramJs.User) { export function addUserToLocalDb(user: GramJs.User) {
const id = buildApiPeerId(user.id, 'user'); const id = buildApiPeerId(user.id, 'user');
const storedUser = localDb.users[id]; const storedUser = localDb.users[id];
if (user.photo instanceof GramJs.Photo) {
addPhotoToLocalDb(user.photo);
}
if (storedUser && !storedUser.min && user.min) return; if (storedUser && !storedUser.min && user.min) return;
localDb.users[id] = user; localDb.users[id] = user;
@ -157,24 +210,6 @@ export function addWebDocumentToLocalDb(webDocument: GramJs.TypeWebDocument) {
localDb.webDocuments[webDocument.url] = webDocument; localDb.webDocuments[webDocument.url] = webDocument;
} }
export function swapLocalInvoiceMedia(
chatId: string, messageId: number, extendedMedia: GramJs.TypeMessageExtendedMedia,
) {
const localMessage = localDb.messages[`${chatId}-${messageId}`];
if (!(localMessage instanceof GramJs.Message) || !localMessage.media) return;
if (extendedMedia instanceof GramJs.MessageExtendedMediaPreview) {
if (!(localMessage.media instanceof GramJs.MessageMediaInvoice)) {
return;
}
localMessage.media.extendedMedia = extendedMedia;
}
if (extendedMedia instanceof GramJs.MessageExtendedMedia) {
localMessage.media = extendedMedia.media;
}
}
export function serializeBytes(value: Buffer) { export function serializeBytes(value: Buffer) {
return String.fromCharCode(...value); return String.fromCharCode(...value);
} }

View File

@ -11,20 +11,28 @@ import { omitVirtualClassFields } from './apiBuilders/helpers';
const IS_MULTITAB_SUPPORTED = 'BroadcastChannel' in self; const IS_MULTITAB_SUPPORTED = 'BroadcastChannel' in self;
export type StoryRepairInfo = { export type StoryRepairInfo = {
storyData?: { type: 'story';
peerId: string; peerId: string;
id: number; id: number;
}; };
export type MessageRepairInfo = {
type: 'message';
peerId: string;
id: number;
};
export type RepairInfo = {
localRepairInfo?: StoryRepairInfo | MessageRepairInfo;
}; };
export interface LocalDb { export interface LocalDb {
// Used for loading avatars and media through in-memory Gram JS instances. // Used for loading avatars and media through in-memory Gram JS instances.
chats: Record<string, GramJs.Chat | GramJs.Channel>; chats: Record<string, GramJs.Chat | GramJs.Channel>;
users: Record<string, GramJs.User>; users: Record<string, GramJs.User>;
messages: Record<string, GramJs.Message | GramJs.MessageService>; documents: Record<string, GramJs.Document & RepairInfo>;
documents: Record<string, GramJs.Document & StoryRepairInfo>;
stickerSets: Record<string, GramJs.StickerSet>; stickerSets: Record<string, GramJs.StickerSet>;
photos: Record<string, GramJs.Photo & StoryRepairInfo>; photos: Record<string, GramJs.Photo & RepairInfo>;
webDocuments: Record<string, GramJs.TypeWebDocument>; webDocuments: Record<string, GramJs.TypeWebDocument>;
commonBoxState: Record<string, number>; commonBoxState: Record<string, number>;
channelPtsById: Record<string, number>; channelPtsById: Record<string, number>;

View File

@ -34,9 +34,13 @@ import {
generateRandomBigInt, generateRandomBigInt,
} from '../gramjsBuilders'; } from '../gramjsBuilders';
import { import {
addEntitiesToLocalDb, addUserToLocalDb, addWebDocumentToLocalDb, deserializeBytes, addDocumentToLocalDb,
addEntitiesToLocalDb,
addPhotoToLocalDb,
addUserToLocalDb,
addWebDocumentToLocalDb,
deserializeBytes,
} from '../helpers'; } from '../helpers';
import localDb from '../localDb';
import { invokeRequest } from './client'; import { invokeRequest } from './client';
let onUpdate: OnApiUpdate; let onUpdate: OnApiUpdate;
@ -209,7 +213,7 @@ export async function requestWebView({
if (result instanceof GramJs.WebViewResultUrl) { if (result instanceof GramJs.WebViewResultUrl) {
return { return {
url: result.url, url: result.url,
queryId: result.queryId.toString(), queryId: result.queryId?.toString(),
}; };
} }
@ -555,14 +559,6 @@ function getInlineBotResultsNextOffset(username: string, nextOffset?: string) {
return username === 'gif' && nextOffset === '0' ? '' : nextOffset; return username === 'gif' && nextOffset === '0' ? '' : nextOffset;
} }
function addDocumentToLocalDb(document: GramJs.Document) {
localDb.documents[String(document.id)] = document;
}
function addPhotoToLocalDb(photo: GramJs.Photo) {
localDb.photos[String(photo.id)] = photo;
}
export function setBotInfo({ export function setBotInfo({
bot, bot,
langCode, langCode,

View File

@ -65,7 +65,6 @@ import {
buildInputReplyTo, buildInputReplyTo,
buildMtpMessageEntity, buildMtpMessageEntity,
generateRandomBigInt, generateRandomBigInt,
isMessageWithMedia,
} from '../gramjsBuilders'; } from '../gramjsBuilders';
import { import {
addEntitiesToLocalDb, addEntitiesToLocalDb,
@ -74,7 +73,6 @@ import {
deserializeBytes, deserializeBytes,
isChatFolder, isChatFolder,
} from '../helpers'; } from '../helpers';
import localDb from '../localDb';
import { scheduleMutedChatUpdate } from '../scheduleUnmute'; import { scheduleMutedChatUpdate } from '../scheduleUnmute';
import { import {
applyState, processAffectedHistory, updateChannelState, applyState, processAffectedHistory, updateChannelState,
@ -518,8 +516,8 @@ async function getFullChatInfo(chatId: string): Promise<FullChatData | undefined
translationsDisabled, translationsDisabled,
} = result.fullChat; } = result.fullChat;
if (chatPhoto instanceof GramJs.Photo) { if (chatPhoto) {
localDb.photos[chatPhoto.id.toString()] = chatPhoto; addPhotoToLocalDb(chatPhoto);
} }
const members = buildChatMembers(participants); const members = buildChatMembers(participants);
@ -606,8 +604,8 @@ async function getFullChannelInfo(
boostsUnrestrict, boostsUnrestrict,
} = result.fullChat; } = result.fullChat;
if (chatPhoto instanceof GramJs.Photo) { if (chatPhoto) {
localDb.photos[chatPhoto.id.toString()] = chatPhoto; addPhotoToLocalDb(chatPhoto);
} }
const inviteLink = exportedInvite instanceof GramJs.ChatInviteExported const inviteLink = exportedInvite instanceof GramJs.ChatInviteExported
@ -1555,9 +1553,7 @@ function updateLocalDb(result: (
if ('messages' in result) { if ('messages' in result) {
result.messages.forEach((message) => { result.messages.forEach((message) => {
if (message instanceof GramJs.Message && isMessageWithMedia(message)) { addMessageToLocalDb(message);
addMessageToLocalDb(message);
}
}); });
} }
} }

View File

@ -20,15 +20,15 @@ import {
DEBUG, DEBUG_GRAMJS, IS_TEST, UPLOAD_WORKERS, DEBUG, DEBUG_GRAMJS, IS_TEST, UPLOAD_WORKERS,
} from '../../../config'; } from '../../../config';
import { pause } from '../../../util/schedulers'; import { pause } from '../../../util/schedulers';
import { setMessageBuilderCurrentUserId } from '../apiBuilders/messages'; import { buildApiMessage, setMessageBuilderCurrentUserId } from '../apiBuilders/messages';
import { buildApiPeerId } from '../apiBuilders/peers'; import { buildApiPeerId } from '../apiBuilders/peers';
import { buildApiStory } from '../apiBuilders/stories';
import { buildApiUser, buildApiUserFullInfo } from '../apiBuilders/users'; import { buildApiUser, buildApiUserFullInfo } from '../apiBuilders/users';
import { buildInputPeerFromLocalDb } from '../gramjsBuilders'; import { buildInputPeerFromLocalDb } from '../gramjsBuilders';
import { import {
addEntitiesToLocalDb, addEntitiesToLocalDb, addMessageToLocalDb, addStoryToLocalDb, addUserToLocalDb, isResponseUpdate, log,
addMessageToLocalDb, addStoryToLocalDb, addUserToLocalDb, isResponseUpdate, log,
} from '../helpers'; } from '../helpers';
import localDb, { clearLocalDb } from '../localDb'; import localDb, { clearLocalDb, type RepairInfo } from '../localDb';
import { import {
getDifference, getDifference,
init as initUpdatesManager, init as initUpdatesManager,
@ -381,9 +381,6 @@ export async function fetchCurrentUser() {
const user = userFull.users[0]; const user = userFull.users[0];
if (user.photo instanceof GramJs.Photo) {
localDb.photos[user.photo.id.toString()] = user.photo;
}
addUserToLocalDb(user); addUserToLocalDb(user);
const currentUserFullInfo = buildApiUserFullInfo(userFull); const currentUserFullInfo = buildApiUserFullInfo(userFull);
const currentUser = buildApiUser(user)!; const currentUser = buildApiUser(user)!;
@ -441,62 +438,100 @@ export async function repairFileReference({
if (!parsed) return undefined; if (!parsed) return undefined;
const { const {
entityType, entityId, mediaMatchType, entityId, mediaMatchType,
} = parsed; } = parsed;
if (mediaMatchType === 'document' || mediaMatchType === 'photo') { if (mediaMatchType === 'document' || mediaMatchType === 'photo' || mediaMatchType === 'webDocument') {
const entity = mediaMatchType === 'document' ? localDb.documents[entityId] : localDb.photos[entityId]; const entity = mediaMatchType === 'document'
if (!entity.storyData) return false; ? localDb.documents[entityId] : mediaMatchType === 'webDocument'
const peer = buildInputPeerFromLocalDb(entity.storyData.peerId); ? localDb.webDocuments[entityId] : localDb.photos[entityId];
if (!peer) return false; if (!entity) return false;
const repairableEntity = entity as RepairInfo;
if (!repairableEntity.localRepairInfo) return false;
const { localRepairInfo } = repairableEntity;
const result = await invokeRequest(new GramJs.stories.GetStoriesByID({ if (localRepairInfo.type === 'story') {
peer, const result = await repairStoryMedia(localRepairInfo.peerId, localRepairInfo.id);
id: [entity.storyData.id], return result;
}));
if (!result) return false;
addEntitiesToLocalDb(result.users);
result.stories.forEach((story) => addStoryToLocalDb(story, entity.storyData!.peerId));
return true;
}
if (entityType === 'msg') {
const entity = localDb.messages[entityId]!;
const messageId = entity.id;
const peer = 'channelId' in entity.peerId ? new GramJs.InputChannel({
channelId: entity.peerId.channelId,
accessHash: (localDb.chats[buildApiPeerId(entity.peerId.channelId, 'channel')] as GramJs.Channel).accessHash!,
}) : undefined;
const result = await invokeRequest(
peer
? new GramJs.channels.GetMessages({
channel: peer,
id: [new GramJs.InputMessageID({ id: messageId })],
})
: new GramJs.messages.GetMessages({
id: [new GramJs.InputMessageID({ id: messageId })],
}),
);
if (!result || result instanceof GramJs.messages.MessagesNotModified) return false;
if (peer && 'pts' in result) {
updateChannelState(buildApiPeerId(peer.channelId, 'channel'), result.pts);
} }
const message = result.messages[0]; if (localRepairInfo.type === 'message') {
if (message instanceof GramJs.MessageEmpty) return false; const result = await repairMessageMedia(localRepairInfo.peerId, localRepairInfo.id);
addEntitiesToLocalDb(result.users); return result;
addEntitiesToLocalDb(result.chats); }
addMessageToLocalDb(message);
return true;
} }
return false; return false;
} }
async function repairMessageMedia(peerId: string, messageId: number) {
const peer = buildInputPeerFromLocalDb(peerId);
if (!peer) return false;
const result = await invokeRequest(
peer
? new GramJs.channels.GetMessages({
channel: peer,
id: [new GramJs.InputMessageID({ id: messageId })],
})
: new GramJs.messages.GetMessages({
id: [new GramJs.InputMessageID({ id: messageId })],
}),
{
shouldIgnoreErrors: true,
},
);
if (!result || result instanceof GramJs.messages.MessagesNotModified) return false;
if (peer && 'pts' in result) {
updateChannelState(peerId, result.pts);
}
const message = result.messages[0];
if (message instanceof GramJs.MessageEmpty) return false;
addEntitiesToLocalDb(result.users);
addEntitiesToLocalDb(result.chats);
addMessageToLocalDb(message);
const apiMessage = buildApiMessage(message);
if (apiMessage) {
onUpdate({
'@type': 'updateMessage',
chatId: apiMessage.chatId,
id: apiMessage.id,
message: apiMessage,
});
}
return true;
}
async function repairStoryMedia(peerId: string, storyId: number) {
const peer = buildInputPeerFromLocalDb(peerId);
if (!peer) return false;
const result = await invokeRequest(new GramJs.stories.GetStoriesByID({
peer,
id: [storyId],
}), {
shouldIgnoreErrors: true,
});
if (!result) return false;
addEntitiesToLocalDb(result.users);
result.stories.forEach((story) => {
addStoryToLocalDb(story, peerId);
const apiStory = buildApiStory(peerId, story);
if (!apiStory || 'isDeleted' in apiStory) return;
onUpdate({
'@type': 'updateStory',
peerId,
story: apiStory,
});
});
return true;
}
export function setForceHttpTransport(forceHttpTransport: boolean) { export function setForceHttpTransport(forceHttpTransport: boolean) {
client.setForceHttpTransport(forceHttpTransport); client.setForceHttpTransport(forceHttpTransport);
} }

View File

@ -17,11 +17,12 @@ import * as cacheApi from '../../../util/cacheApi';
import { getEntityTypeById } from '../gramjsBuilders'; import { getEntityTypeById } from '../gramjsBuilders';
import localDb from '../localDb'; import localDb from '../localDb';
const MEDIA_ENTITY_TYPES = new Set([ const MEDIA_ENTITY_TYPES: Set<EntityType> = new Set([
'msg', 'sticker', 'gif', 'wallpaper', 'photo', 'webDocument', 'document', 'videoAvatar', 'sticker', 'wallpaper', 'photo', 'webDocument', 'document',
]); ]);
const JPEG_SIZE_TYPES = new Set(['s', 'm', 'x', 'y', 'w', 'a', 'b', 'c', 'd']); const JPEG_SIZE_TYPES = new Set(['s', 'm', 'x', 'y', 'w', 'a', 'b', 'c', 'd']);
const MP4_SIZES_TYPES = new Set(['u', 'v']);
export default async function downloadMedia( export default async function downloadMedia(
{ {
@ -66,8 +67,8 @@ export default async function downloadMedia(
} }
export type EntityType = ( export type EntityType = (
'msg' | 'sticker' | 'wallpaper' | 'gif' | 'channel' | 'chat' | 'user' | 'photo' | 'stickerSet' | 'webDocument' | 'sticker' | 'wallpaper' | 'channel' | 'chat' | 'user' | 'photo' | 'stickerSet' | 'webDocument' |
'document' | 'staticMap' | 'videoAvatar' 'document' | 'staticMap'
); );
async function download( async function download(
@ -118,15 +119,11 @@ async function download(
case 'user': case 'user':
entity = localDb.users[entityId]; entity = localDb.users[entityId];
break; break;
case 'msg':
entity = localDb.messages[entityId];
break;
case 'sticker': case 'sticker':
case 'gif':
case 'wallpaper': case 'wallpaper':
case 'document':
entity = localDb.documents[entityId]; entity = localDb.documents[entityId];
break; break;
case 'videoAvatar':
case 'photo': case 'photo':
entity = localDb.photos[entityId]; entity = localDb.photos[entityId];
break; break;
@ -136,9 +133,6 @@ async function download(
case 'webDocument': case 'webDocument':
entity = localDb.webDocuments[entityId]; entity = localDb.webDocuments[entityId];
break; break;
case 'document':
entity = localDb.documents[entityId];
break;
} }
if (!entity) { if (!entity) {
@ -152,36 +146,18 @@ async function download(
let mimeType; let mimeType;
let fullSize; let fullSize;
if (entity instanceof GramJs.MessageService && entity.action instanceof GramJs.MessageActionSuggestProfilePhoto) { if (sizeType && JPEG_SIZE_TYPES.has(sizeType)) {
mimeType = 'image/jpeg'; mimeType = 'image/jpeg';
} else if (entity instanceof GramJs.Message) { } else if (sizeType && MP4_SIZES_TYPES.has(sizeType)) {
mimeType = getMessageMediaMimeType(entity, sizeType); mimeType = 'video/mp4';
if (entity.media instanceof GramJs.MessageMediaDocument && entity.media.document instanceof GramJs.Document) {
fullSize = entity.media.document.size.toJSNumber();
}
if (entity.media instanceof GramJs.MessageMediaWebPage
&& entity.media.webpage instanceof GramJs.WebPage
&& entity.media.webpage.document instanceof GramJs.Document) {
fullSize = entity.media.webpage.document.size.toJSNumber();
}
} else if (entity instanceof GramJs.Photo) { } else if (entity instanceof GramJs.Photo) {
if (entityType === 'videoAvatar') { mimeType = 'image/jpeg';
mimeType = 'video/mp4'; } else if (entity instanceof GramJs.WebDocument) {
} else { mimeType = entity.mimeType;
mimeType = 'image/jpeg'; fullSize = entity.size;
} } else if (entity instanceof GramJs.Document) {
} else if (entityType === 'sticker' && sizeType) { mimeType = entity.mimeType;
mimeType = (entity as GramJs.Document).mimeType; fullSize = entity.size.toJSNumber();
} else if (entityType === 'webDocument') {
mimeType = (entity as GramJs.TypeWebDocument).mimeType;
fullSize = (entity as GramJs.TypeWebDocument).size;
} else {
if (JPEG_SIZE_TYPES.has(sizeType || '')) {
mimeType = 'image/jpeg';
} else {
mimeType = (entity as GramJs.Document).mimeType;
}
fullSize = (entity as GramJs.Document).size.toJSNumber();
} }
// Prevent HTML-in-video attacks // Prevent HTML-in-video attacks
@ -203,41 +179,6 @@ async function download(
} }
} }
function getMessageMediaMimeType(message: GramJs.Message, sizeType?: string) {
if (!message || !message.media) {
return undefined;
}
if (message.media instanceof GramJs.MessageMediaPhoto) {
return 'image/jpeg';
}
if (message.media instanceof GramJs.MessageMediaGeo
|| message.media instanceof GramJs.MessageMediaVenue
|| message.media instanceof GramJs.MessageMediaGeoLive) {
return 'image/png';
}
if (message.media instanceof GramJs.MessageMediaDocument) {
const document = message.media.document;
if (document instanceof GramJs.Document) {
return document.mimeType;
}
}
if (message.media instanceof GramJs.MessageMediaWebPage
&& message.media.webpage instanceof GramJs.WebPage
&& message.media.webpage.document instanceof GramJs.Document) {
if (sizeType) {
return 'image/jpeg';
}
return message.media.webpage.document.mimeType;
}
return undefined;
}
// eslint-disable-next-line no-async-without-await/no-async-without-await // eslint-disable-next-line no-async-without-await/no-async-without-await
async function parseMedia( async function parseMedia(
data: Buffer, mediaFormat: ApiMediaFormat, mimeType?: string, data: Buffer, mediaFormat: ApiMediaFormat, mimeType?: string,
@ -294,7 +235,7 @@ export function parseMediaUrl(url: string) {
? url.match(/(webDocument):(.+)/) ? url.match(/(webDocument):(.+)/)
: url.match( : url.match(
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
/(avatar|profile|photo|msg|stickerSet|sticker|wallpaper|gif|document|videoAvatar)([-\d\w./]+)(?::\d+)?(\?size=\w+)?/, /(avatar|profile|photo|stickerSet|sticker|wallpaper|document)([-\d\w./]+)(?::\d+)?(\?size=\w+)?/,
); );
if (!mediaMatch) { if (!mediaMatch) {
return undefined; return undefined;

View File

@ -77,8 +77,6 @@ import {
buildSendMessageAction, buildSendMessageAction,
generateRandomBigInt, generateRandomBigInt,
getEntityTypeById, getEntityTypeById,
isMessageWithMedia,
isServiceMessageWithMedia,
} from '../gramjsBuilders'; } from '../gramjsBuilders';
import { import {
addEntitiesToLocalDb, addEntitiesToLocalDb,
@ -239,9 +237,7 @@ export async function fetchMessage({ chat, messageId }: { chat: ApiChat; message
return undefined; return undefined;
} }
if (mtpMessage instanceof GramJs.Message) { addMessageToLocalDb(mtpMessage);
addMessageToLocalDb(mtpMessage);
}
const users = result.users.map(buildApiUser).filter(Boolean); const users = result.users.map(buildApiUser).filter(Boolean);
@ -1576,11 +1572,7 @@ function updateLocalDb(result: (
addEntitiesToLocalDb(result.chats); addEntitiesToLocalDb(result.chats);
result.messages.forEach((message) => { result.messages.forEach((message) => {
if ((message instanceof GramJs.Message && isMessageWithMedia(message)) addMessageToLocalDb(message);
|| (message instanceof GramJs.MessageService && isServiceMessageWithMedia(message))
) {
addMessageToLocalDb(message);
}
}); });
} }
@ -1924,9 +1916,7 @@ function handleLocalMessageUpdate(localMessage: ApiMessage, update: GramJs.TypeU
} }
const mtpMessage = buildMessageFromUpdate(messageUpdate.id, localMessage.chatId, messageUpdate); const mtpMessage = buildMessageFromUpdate(messageUpdate.id, localMessage.chatId, messageUpdate);
if (isMessageWithMedia(mtpMessage)) { addMessageToLocalDb(mtpMessage);
addMessageToLocalDb(mtpMessage);
}
} }
// Edge case for "Send When Online" // Edge case for "Send When Online"

View File

@ -3,6 +3,7 @@ import { Api as GramJs } from '../../../lib/gramjs';
import type { import type {
ApiChat, ApiInputStorePaymentPurpose, ApiPeer, ApiRequestInputInvoice, ApiChat, ApiInputStorePaymentPurpose, ApiPeer, ApiRequestInputInvoice,
ApiThemeParameters,
OnApiUpdate, OnApiUpdate,
} from '../../types'; } from '../../types';
@ -25,7 +26,7 @@ import {
} from '../apiBuilders/payments'; } from '../apiBuilders/payments';
import { buildApiUser } from '../apiBuilders/users'; import { buildApiUser } from '../apiBuilders/users';
import { import {
buildInputInvoice, buildInputPeer, buildInputStorePaymentPurpose, buildShippingInfo, buildInputInvoice, buildInputPeer, buildInputStorePaymentPurpose, buildInputThemeParams, buildShippingInfo,
} from '../gramjsBuilders'; } from '../gramjsBuilders';
import { import {
addEntitiesToLocalDb, addEntitiesToLocalDb,
@ -156,9 +157,10 @@ export async function sendStarPaymentForm({
return Boolean(result); return Boolean(result);
} }
export async function getPaymentForm(inputInvoice: ApiRequestInputInvoice) { export async function getPaymentForm(inputInvoice: ApiRequestInputInvoice, theme?: ApiThemeParameters) {
const result = await invokeRequest(new GramJs.payments.GetPaymentForm({ const result = await invokeRequest(new GramJs.payments.GetPaymentForm({
invoice: buildInputInvoice(inputInvoice), invoice: buildInputInvoice(inputInvoice),
themeParams: theme ? buildInputThemeParams(theme) : undefined,
})); }));
if (!result) { if (!result) {

View File

@ -52,21 +52,21 @@ export async function fetchFullUser({
addEntitiesToLocalDb(result.users); addEntitiesToLocalDb(result.users);
addEntitiesToLocalDb(result.chats); addEntitiesToLocalDb(result.chats);
if (result.fullUser.profilePhoto instanceof GramJs.Photo) { if (result.fullUser.profilePhoto) {
localDb.photos[result.fullUser.profilePhoto.id.toString()] = result.fullUser.profilePhoto; addPhotoToLocalDb(result.fullUser.profilePhoto);
} }
if (result.fullUser.personalPhoto instanceof GramJs.Photo) { if (result.fullUser.personalPhoto) {
localDb.photos[result.fullUser.personalPhoto.id.toString()] = result.fullUser.personalPhoto; addPhotoToLocalDb(result.fullUser.personalPhoto);
} }
if (result.fullUser.fallbackPhoto instanceof GramJs.Photo) { if (result.fullUser.fallbackPhoto) {
localDb.photos[result.fullUser.fallbackPhoto.id.toString()] = result.fullUser.fallbackPhoto; addPhotoToLocalDb(result.fullUser.fallbackPhoto);
} }
const botInfo = result.fullUser.botInfo; const botInfo = result.fullUser.botInfo;
if (botInfo?.descriptionPhoto instanceof GramJs.Photo) { if (botInfo?.descriptionPhoto) {
localDb.photos[botInfo.descriptionPhoto.id.toString()] = botInfo.descriptionPhoto; addPhotoToLocalDb(botInfo.descriptionPhoto);
} }
if (botInfo?.descriptionDocument instanceof GramJs.Document) { if (botInfo?.descriptionDocument instanceof GramJs.Document) {
localDb.documents[botInfo.descriptionDocument.id.toString()] = botInfo.descriptionDocument; localDb.documents[botInfo.descriptionDocument.id.toString()] = botInfo.descriptionDocument;

View File

@ -2,8 +2,8 @@ import { Api as GramJs, connection } from '../../../lib/gramjs';
import type { GroupCallConnectionData } from '../../../lib/secret-sauce'; import type { GroupCallConnectionData } from '../../../lib/secret-sauce';
import type { import type {
ApiMessage, ApiMessageExtendedMediaPreview, ApiStory, ApiStorySkipped, ApiMessage, ApiStory, ApiStorySkipped,
ApiUpdate, ApiUpdateConnectionStateType, MediaContent, OnApiUpdate, ApiUpdate, ApiUpdateConnectionStateType, OnApiUpdate,
} from '../../types'; } from '../../types';
import { DEBUG, GENERAL_TOPIC_ID } from '../../../config'; import { DEBUG, GENERAL_TOPIC_ID } from '../../../config';
@ -29,7 +29,7 @@ import { buildApiPhoto, buildApiUsernames, buildPrivacyRules } from '../apiBuild
import { omitVirtualClassFields } from '../apiBuilders/helpers'; import { omitVirtualClassFields } from '../apiBuilders/helpers';
import { import {
buildApiMessageExtendedMediaPreview, buildApiMessageExtendedMediaPreview,
buildMessageMediaContent, buildBoughtMediaContent,
buildPoll, buildPoll,
buildPollResults, buildPollResults,
} from '../apiBuilders/messageContent'; } from '../apiBuilders/messageContent';
@ -61,7 +61,6 @@ import {
import { import {
buildChatPhotoForLocalDb, buildChatPhotoForLocalDb,
buildMessageFromUpdate, buildMessageFromUpdate,
isMessageWithMedia,
} from '../gramjsBuilders'; } from '../gramjsBuilders';
import { import {
addEntitiesToLocalDb, addEntitiesToLocalDb,
@ -72,7 +71,6 @@ import {
log, log,
resolveMessageApiChatId, resolveMessageApiChatId,
serializeBytes, serializeBytes,
swapLocalInvoiceMedia,
} from '../helpers'; } from '../helpers';
import localDb from '../localDb'; import localDb from '../localDb';
import { scheduleMutedChatUpdate, scheduleMutedTopicUpdate } from '../scheduleUnmute'; import { scheduleMutedChatUpdate, scheduleMutedTopicUpdate } from '../scheduleUnmute';
@ -84,8 +82,6 @@ export type Update = (
(GramJs.TypeUpdate | GramJs.TypeUpdates) & { _entities?: (GramJs.TypeUser | GramJs.TypeChat)[] } (GramJs.TypeUpdate | GramJs.TypeUpdates) & { _entities?: (GramJs.TypeUser | GramJs.TypeChat)[] }
) | typeof connection.UpdateConnectionState | UpdatePts | LocalUpdatePremiumFloodWait; ) | typeof connection.UpdateConnectionState | UpdatePts | LocalUpdatePremiumFloodWait;
const DELETE_MISSING_CHANNEL_MESSAGE_DELAY = 1000;
let onUpdate: OnApiUpdate; let onUpdate: OnApiUpdate;
export function init(_onUpdate: OnApiUpdate) { export function init(_onUpdate: OnApiUpdate) {
@ -205,12 +201,7 @@ export function updater(update: Update) {
return; return;
} }
if ((update.message instanceof GramJs.Message && isMessageWithMedia(update.message)) addMessageToLocalDb(update.message);
|| (update.message instanceof GramJs.MessageService
&& update.message.action instanceof GramJs.MessageActionSuggestProfilePhoto)
) {
addMessageToLocalDb(update.message);
}
message = buildApiMessage(update.message)!; message = buildApiMessage(update.message)!;
dispatchThreadInfoUpdates([update.message]); dispatchThreadInfoUpdates([update.message]);
@ -385,9 +376,7 @@ export function updater(update: Update) {
return; return;
} }
if (update.message instanceof GramJs.Message && isMessageWithMedia(update.message)) { addMessageToLocalDb(update.message);
addMessageToLocalDb(update.message);
}
// Workaround for a weird server behavior when own message is marked as incoming // Workaround for a weird server behavior when own message is marked as incoming
const message = omit(buildApiMessage(update.message)!, ['isOutgoing']); const message = omit(buildApiMessage(update.message)!, ['isOutgoing']);
@ -407,28 +396,35 @@ export function updater(update: Update) {
reactions: buildMessageReactions(update.reactions), reactions: buildMessageReactions(update.reactions),
}); });
} else if (update instanceof GramJs.UpdateMessageExtendedMedia) { } else if (update instanceof GramJs.UpdateMessageExtendedMedia) {
let media: MediaContent | undefined;
if (update.extendedMedia instanceof GramJs.MessageExtendedMedia) {
media = buildMessageMediaContent(update.extendedMedia.media);
}
let preview: ApiMessageExtendedMediaPreview | undefined;
if (update.extendedMedia instanceof GramJs.MessageExtendedMediaPreview) {
preview = buildApiMessageExtendedMediaPreview(update.extendedMedia);
}
if (!media && !preview) return;
const chatId = getApiChatIdFromMtpPeer(update.peer); const chatId = getApiChatIdFromMtpPeer(update.peer);
const isBought = update.extendedMedia[0] instanceof GramJs.MessageExtendedMedia;
if (isBought) {
const boughtMedia = buildBoughtMediaContent(update.extendedMedia);
swapLocalInvoiceMedia(chatId, update.msgId, update.extendedMedia); if (!boughtMedia?.length) return;
onUpdate({
'@type': 'updateMessageExtendedMedia',
id: update.msgId,
chatId,
isBought,
extendedMedia: boughtMedia,
});
return;
}
const previewMedia = !isBought ? update.extendedMedia
.filter((m): m is GramJs.MessageExtendedMediaPreview => m instanceof GramJs.MessageExtendedMediaPreview)
.map((m) => buildApiMessageExtendedMediaPreview(m))
.filter(Boolean) : undefined;
if (!previewMedia?.length) return;
onUpdate({ onUpdate({
'@type': 'updateMessageExtendedMedia', '@type': 'updateMessageExtendedMedia',
id: update.msgId, id: update.msgId,
chatId, chatId,
media, extendedMedia: previewMedia,
preview,
}); });
} else if (update instanceof GramJs.UpdateDeleteMessages) { } else if (update instanceof GramJs.UpdateDeleteMessages) {
onUpdate({ onUpdate({
@ -443,43 +439,12 @@ export function updater(update: Update) {
}); });
} else if (update instanceof GramJs.UpdateDeleteChannelMessages) { } else if (update instanceof GramJs.UpdateDeleteChannelMessages) {
const chatId = buildApiPeerId(update.channelId, 'channel'); const chatId = buildApiPeerId(update.channelId, 'channel');
const ids = update.messages;
const existingIds = ids.filter((id) => localDb.messages[`${chatId}-${id}`]);
const missingIds = ids.filter((id) => !localDb.messages[`${chatId}-${id}`]);
const profilePhotoIds = ids.map((id) => {
const message = localDb.messages[`${chatId}-${id}`];
return message && message instanceof GramJs.MessageService && 'photo' in message.action onUpdate({
? String(message.action.photo.id) '@type': 'deleteMessages',
: undefined; ids: update.messages,
}).filter(Boolean); chatId,
});
if (existingIds.length) {
onUpdate({
'@type': 'deleteMessages',
ids: existingIds,
chatId,
});
}
if (profilePhotoIds.length) {
onUpdate({
'@type': 'deleteProfilePhotos',
ids: profilePhotoIds,
chatId,
});
}
// For some reason delete message update sometimes comes before new message update
if (missingIds.length) {
setTimeout(() => {
onUpdate({
'@type': 'deleteMessages',
ids: missingIds,
chatId,
});
}, DELETE_MISSING_CHANNEL_MESSAGE_DELAY);
}
} else if (update instanceof GramJs.UpdateServiceNotification) { } else if (update instanceof GramJs.UpdateServiceNotification) {
if (update.popup) { if (update.popup) {
onUpdate({ onUpdate({
@ -492,9 +457,7 @@ export function updater(update: Update) {
const currentDate = Date.now() / 1000 + getServerTimeOffset(); const currentDate = Date.now() / 1000 + getServerTimeOffset();
const message = buildApiMessageFromNotification(update, currentDate); const message = buildApiMessageFromNotification(update, currentDate);
if (isMessageWithMedia(update)) { addMessageToLocalDb(buildMessageFromUpdate(message.id, message.chatId, update));
addMessageToLocalDb(buildMessageFromUpdate(message.id, message.chatId, update));
}
onUpdate({ onUpdate({
'@type': 'updateServiceNotification', '@type': 'updateServiceNotification',

View File

@ -30,7 +30,6 @@ const requestStatesByCallback = new Map<AnyToVoidFunction, RequestStates>();
const savedLocalDb: LocalDb = { const savedLocalDb: LocalDb = {
chats: {}, chats: {},
users: {}, users: {},
messages: {},
documents: {}, documents: {},
stickerSets: {}, stickerSets: {},
photos: {}, photos: {},

View File

@ -9,6 +9,7 @@ export type ApiInlineResultType = (
); );
export interface ApiWebDocument { export interface ApiWebDocument {
mediaType: 'webDocument';
url: string; url: string;
size: number; size: number;
mimeType: string; mimeType: string;

View File

@ -11,7 +11,7 @@ export interface ApiDimensions {
} }
export interface ApiPhotoSize extends ApiDimensions { export interface ApiPhotoSize extends ApiDimensions {
type: 's' | 'm' | 'x' | 'y' | 'z'; type: 's' | 'm' | 'x' | 'y' | 'w';
} }
export interface ApiVideoSize extends ApiDimensions { export interface ApiVideoSize extends ApiDimensions {
@ -25,7 +25,9 @@ export interface ApiThumbnail extends ApiDimensions {
} }
export interface ApiPhoto { export interface ApiPhoto {
mediaType: 'photo';
id: string; id: string;
date: number;
thumbnail?: ApiThumbnail; thumbnail?: ApiThumbnail;
isVideo?: boolean; isVideo?: boolean;
sizes: ApiPhotoSize[]; sizes: ApiPhotoSize[];
@ -35,6 +37,7 @@ export interface ApiPhoto {
} }
export interface ApiSticker { export interface ApiSticker {
mediaType: 'sticker';
id: string; id: string;
stickerSetInfo: ApiStickerSetInfo; stickerSetInfo: ApiStickerSetInfo;
emoji?: string; emoji?: string;
@ -85,6 +88,7 @@ type ApiStickerSetInfoMissing = {
export type ApiStickerSetInfo = ApiStickerSetInfoShortName | ApiStickerSetInfoId | ApiStickerSetInfoMissing; export type ApiStickerSetInfo = ApiStickerSetInfoShortName | ApiStickerSetInfoId | ApiStickerSetInfoMissing;
export interface ApiVideo { export interface ApiVideo {
mediaType: 'video';
id: string; id: string;
mimeType: string; mimeType: string;
duration: number; duration: number;
@ -103,6 +107,7 @@ export interface ApiVideo {
} }
export interface ApiAudio { export interface ApiAudio {
mediaType: 'audio';
id: string; id: string;
size: number; size: number;
mimeType: string; mimeType: string;
@ -114,12 +119,15 @@ export interface ApiAudio {
} }
export interface ApiVoice { export interface ApiVoice {
mediaType: 'voice';
id: string; id: string;
duration: number; duration: number;
waveform?: number[]; waveform?: number[];
size: number;
} }
export interface ApiDocument { export interface ApiDocument {
mediaType: 'document';
id?: string; id?: string;
fileName: string; fileName: string;
size: number; size: number;
@ -127,17 +135,29 @@ export interface ApiDocument {
mimeType: string; mimeType: string;
thumbnail?: ApiThumbnail; thumbnail?: ApiThumbnail;
previewBlobUrl?: string; previewBlobUrl?: string;
mediaType?: 'photo' | 'video'; innerMediaType?: 'photo' | 'video';
mediaSize?: ApiDimensions; mediaSize?: ApiDimensions;
} }
export interface ApiContact { export interface ApiContact {
mediaType: 'contact';
firstName: string; firstName: string;
lastName: string; lastName: string;
phoneNumber: string; phoneNumber: string;
userId: string; userId: string;
} }
export type ApiPaidMedia = {
mediaType: 'paidMedia';
starsAmount: number;
} & ({
isBought?: true;
extendedMedia: BoughtPaidMedia[];
} | {
isBought?: undefined;
extendedMedia: ApiMediaExtendedPreview[];
});
export interface ApiPollAnswer { export interface ApiPollAnswer {
text: ApiFormattedText; text: ApiFormattedText;
option: string; option: string;
@ -151,6 +171,7 @@ export interface ApiPollResult {
} }
export interface ApiPoll { export interface ApiPoll {
mediaType: 'poll';
id: string; id: string;
summary: { summary: {
closed?: true; closed?: true;
@ -243,6 +264,7 @@ export type ApiRequestInputInvoice = ApiRequestInputInvoiceMessage | ApiRequestI
| ApiRequestInputInvoiceGiveaway | ApiRequestInputInvoiceStars; | ApiRequestInputInvoiceGiveaway | ApiRequestInputInvoiceStars;
export interface ApiInvoice { export interface ApiInvoice {
mediaType: 'invoice';
text: string; text: string;
title: string; title: string;
photo?: ApiWebDocument; photo?: ApiWebDocument;
@ -252,12 +274,13 @@ export interface ApiInvoice {
isTest?: boolean; isTest?: boolean;
isRecurring?: boolean; isRecurring?: boolean;
termsUrl?: string; termsUrl?: string;
extendedMedia?: ApiMessageExtendedMediaPreview; extendedMedia?: ApiMediaExtendedPreview;
maxTipAmount?: number; maxTipAmount?: number;
suggestedTipAmounts?: number[]; suggestedTipAmounts?: number[];
} }
export interface ApiMessageExtendedMediaPreview { export interface ApiMediaExtendedPreview {
mediaType: 'extendedMediaPreview';
width?: number; width?: number;
height?: number; height?: number;
thumbnail?: ApiThumbnail; thumbnail?: ApiThumbnail;
@ -277,12 +300,12 @@ export interface ApiGeoPoint {
} }
interface ApiGeo { interface ApiGeo {
type: 'geo'; mediaType: 'geo';
geo: ApiGeoPoint; geo: ApiGeoPoint;
} }
interface ApiVenue { interface ApiVenue {
type: 'venue'; mediaType: 'venue';
geo: ApiGeoPoint; geo: ApiGeoPoint;
title: string; title: string;
address: string; address: string;
@ -292,7 +315,7 @@ interface ApiVenue {
} }
interface ApiGeoLive { interface ApiGeoLive {
type: 'geoLive'; mediaType: 'geoLive';
geo: ApiGeoPoint; geo: ApiGeoPoint;
heading?: number; heading?: number;
period: number; period: number;
@ -301,6 +324,7 @@ interface ApiGeoLive {
export type ApiLocation = ApiGeo | ApiVenue | ApiGeoLive; export type ApiLocation = ApiGeo | ApiVenue | ApiGeoLive;
export type ApiGame = { export type ApiGame = {
mediaType: 'game';
title: string; title: string;
description: string; description: string;
photo?: ApiPhoto; photo?: ApiPhoto;
@ -311,6 +335,7 @@ export type ApiGame = {
}; };
export type ApiGiveaway = { export type ApiGiveaway = {
mediaType: 'giveaway';
quantity: number; quantity: number;
months: number; months: number;
untilDate: number; untilDate: number;
@ -321,6 +346,7 @@ export type ApiGiveaway = {
}; };
export type ApiGiveawayResults = { export type ApiGiveawayResults = {
mediaType: 'giveawayResults';
months: number; months: number;
untilDate: number; untilDate: number;
isRefunded?: true; isRefunded?: true;
@ -344,6 +370,7 @@ export type ApiNewPoll = {
}; };
export interface ApiAction { export interface ApiAction {
mediaType: 'action';
text: string; text: string;
targetUserIds?: string[]; targetUserIds?: string[];
targetChatId?: string; targetChatId?: string;
@ -378,6 +405,7 @@ export interface ApiAction {
} }
export interface ApiWebPage { export interface ApiWebPage {
mediaType: 'webpage';
id: number; id: number;
url: string; url: string;
displayUrl: string; displayUrl: string;
@ -546,6 +574,7 @@ export type MediaContent = {
storyData?: ApiMessageStoryData; storyData?: ApiMessageStoryData;
giveaway?: ApiGiveaway; giveaway?: ApiGiveaway;
giveawayResults?: ApiGiveawayResults; giveawayResults?: ApiGiveawayResults;
paidMedia?: ApiPaidMedia;
isExpiredVoice?: boolean; isExpiredVoice?: boolean;
isExpiredRoundVideo?: boolean; isExpiredRoundVideo?: boolean;
ttlSeconds?: number; ttlSeconds?: number;
@ -554,6 +583,8 @@ export type MediaContainer = {
content: MediaContent; content: MediaContent;
}; };
export type BoughtPaidMedia = Pick<MediaContent, 'photo' | 'video'>;
export interface ApiMessage { export interface ApiMessage {
id: number; id: number;
chatId: string; chatId: string;
@ -841,6 +872,7 @@ export type ApiThemeParameters = {
section_header_text_color: string; section_header_text_color: string;
subtitle_text_color: string; subtitle_text_color: string;
destructive_text_color: string; destructive_text_color: string;
section_separator_color: string;
}; };
export type ApiBotApp = { export type ApiBotApp = {

View File

@ -2,7 +2,9 @@ import type { ApiPremiumSection } from '../../global/types';
import type { ApiInvoiceContainer } from '../../types'; import type { ApiInvoiceContainer } from '../../types';
import type { ApiWebDocument } from './bots'; import type { ApiWebDocument } from './bots';
import type { ApiChat } from './chats'; import type { ApiChat } from './chats';
import type { ApiDocument, ApiMessageEntity, ApiPaymentCredentials } from './messages'; import type {
ApiDocument, ApiMessageEntity, ApiPaymentCredentials, BoughtPaidMedia, MediaContent,
} from './messages';
import type { PrepaidGiveaway, StatisticsOverviewPercentage } from './statistics'; import type { PrepaidGiveaway, StatisticsOverviewPercentage } from './statistics';
import type { ApiUser } from './users'; import type { ApiUser } from './users';
@ -67,9 +69,11 @@ export interface ApiReceiptStars {
title?: string; title?: string;
text?: string; text?: string;
photo?: ApiWebDocument; photo?: ApiWebDocument;
media?: BoughtPaidMedia[];
currency: string; currency: string;
totalAmount: number; totalAmount: number;
transactionId: string; transactionId: string;
messageId?: number;
} }
export interface ApiReceiptRegular { export interface ApiReceiptRegular {
@ -227,6 +231,10 @@ export interface ApiStarsTransactionPeerFragment {
type: 'fragment'; type: 'fragment';
} }
export interface ApiStarsTransactionPeerAds {
type: 'ads';
}
export interface ApiStarsTransactionPeerPeer { export interface ApiStarsTransactionPeerPeer {
type: 'peer'; type: 'peer';
id: string; id: string;
@ -238,17 +246,22 @@ export type ApiStarsTransactionPeer =
| ApiStarsTransactionPeerPlayMarket | ApiStarsTransactionPeerPlayMarket
| ApiStarsTransactionPeerPremiumBot | ApiStarsTransactionPeerPremiumBot
| ApiStarsTransactionPeerFragment | ApiStarsTransactionPeerFragment
| ApiStarsTransactionPeerAds
| ApiStarsTransactionPeerPeer; | ApiStarsTransactionPeerPeer;
export interface ApiStarsTransaction { export interface ApiStarsTransaction {
id: string; id: string;
peer: ApiStarsTransactionPeer; peer: ApiStarsTransactionPeer;
messageId?: number;
stars: number; stars: number;
isRefund?: true; isRefund?: true;
hasFailed?: true;
isPending?: true;
date: number; date: number;
title?: string; title?: string;
description?: string; description?: string;
photo?: ApiWebDocument; photo?: ApiWebDocument;
extendedMedia?: MediaContent[];
} }
export interface ApiStarTopupOption { export interface ApiStarTopupOption {

View File

@ -66,6 +66,7 @@ export type ApiPeerStories = {
}; };
export type ApiMessageStoryData = { export type ApiMessageStoryData = {
mediaType: 'storyData';
id: number; id: number;
peerId: string; peerId: string;
isMention?: boolean; isMention?: boolean;
@ -124,6 +125,7 @@ export type ApiMediaAreaCoordinates = {
width: number; width: number;
height: number; height: number;
rotation: number; rotation: number;
radius?: number;
}; };
export type ApiMediaAreaVenue = { export type ApiMediaAreaVenue = {
@ -154,5 +156,11 @@ export type ApiMediaAreaChannelPost = {
messageId: number; messageId: number;
}; };
export type ApiMediaAreaUrl = {
type: 'url';
coordinates: ApiMediaAreaCoordinates;
url: string;
};
export type ApiMediaArea = ApiMediaAreaVenue | ApiMediaAreaGeoPoint | ApiMediaAreaSuggestedReaction export type ApiMediaArea = ApiMediaAreaVenue | ApiMediaAreaGeoPoint | ApiMediaAreaSuggestedReaction
| ApiMediaAreaChannelPost; | ApiMediaAreaChannelPost | ApiMediaAreaUrl;

View File

@ -21,8 +21,8 @@ import type {
import type { import type {
ApiFormattedText, ApiFormattedText,
ApiInputInvoice, ApiInputInvoice,
ApiMediaExtendedPreview,
ApiMessage, ApiMessage,
ApiMessageExtendedMediaPreview,
ApiPhoto, ApiPhoto,
ApiPoll, ApiPoll,
ApiQuickReply, ApiQuickReply,
@ -30,7 +30,7 @@ import type {
ApiReactions, ApiReactions,
ApiStickerSet, ApiStickerSet,
ApiThreadInfo, ApiThreadInfo,
MediaContent, BoughtPaidMedia,
} from './messages'; } from './messages';
import type { import type {
ApiEmojiInteraction, ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData, ApiEmojiInteraction, ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData,
@ -344,12 +344,6 @@ export type ApiUpdateDeleteSavedHistory = {
chatId: string; chatId: string;
}; };
export type ApiUpdateDeleteProfilePhotos = {
'@type': 'deleteProfilePhotos';
ids: string[];
chatId: string;
};
export type ApiUpdateResetMessages = { export type ApiUpdateResetMessages = {
'@type': 'resetMessages'; '@type': 'resetMessages';
id: string; id: string;
@ -373,9 +367,13 @@ export type ApiUpdateMessageExtendedMedia = {
'@type': 'updateMessageExtendedMedia'; '@type': 'updateMessageExtendedMedia';
id: number; id: number;
chatId: string; chatId: string;
media?: MediaContent; } & ({
preview?: ApiMessageExtendedMediaPreview; isBought?: true;
}; extendedMedia: BoughtPaidMedia[];
} | {
isBought?: undefined;
extendedMedia: ApiMediaExtendedPreview[];
});
export type ApiDeleteContact = { export type ApiDeleteContact = {
'@type': 'deleteContact'; '@type': 'deleteContact';
@ -753,7 +751,7 @@ export type ApiUpdate = (
ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfos | ApiUpdateCommonBoxMessages | ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfos | ApiUpdateCommonBoxMessages |
ApiUpdateDeleteMessages | ApiUpdateMessagePoll | ApiUpdateMessagePollVote | ApiUpdateDeleteHistory | ApiUpdateDeleteMessages | ApiUpdateMessagePoll | ApiUpdateMessagePollVote | ApiUpdateDeleteHistory |
ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed | ApiUpdateServiceNotification | ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed | ApiUpdateServiceNotification |
ApiDeleteContact | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateUserFullInfo | ApiUpdateDeleteProfilePhotos | ApiDeleteContact | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateUserFullInfo |
ApiUpdateAvatar | ApiUpdateMessageImage | ApiUpdateDraftMessage | ApiUpdateAvatar | ApiUpdateMessageImage | ApiUpdateDraftMessage |
ApiUpdateError | ApiUpdateResetContacts | ApiUpdateStartEmojiInteraction | ApiUpdateError | ApiUpdateResetContacts | ApiUpdateStartEmojiInteraction |
ApiUpdateFavoriteStickers | ApiUpdateStickerSet | ApiUpdateStickerSets | ApiUpdateStickerSetsOrder | ApiUpdateFavoriteStickers | ApiUpdateStickerSet | ApiUpdateStickerSets | ApiUpdateStickerSetsOrder |

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none"><path fill="#000" d="M8.1 10.35a7.9 7.9 0 0 1 15.8 0v4.66a5.1 5.1 0 0 1 1.95 2.34c.39.942.39 2.134.39 4.52s0 3.578-.39 4.52a5.12 5.12 0 0 1-2.77 2.77c-.942.39-2.134.39-4.52.39h-5.12c-2.386 0-3.578 0-4.52-.39a5.12 5.12 0 0 1-2.77-2.77c-.39-.942-.39-2.134-.39-4.52s0-3.578.39-4.52a5.1 5.1 0 0 1 1.95-2.34zm7.9-4.9a4.9 4.9 0 0 0-4.9 4.9v3.865c.63-.025 1.39-.025 2.34-.025h5.12c.949 0 1.709 0 2.34.024V10.35a4.9 4.9 0 0 0-4.9-4.9m-2.417 20.815 2.16-1.384a.46.46 0 0 1 .499 0l2.178 1.395a.46.46 0 0 0 .36.06.5.5 0 0 0 .353-.602l-.597-2.592a.51.51 0 0 1 .154-.497l1.942-1.73a.5.5 0 0 0 .166-.341.49.49 0 0 0-.438-.536l-2.546-.208a.48.48 0 0 1-.404-.307l-.977-2.465a.47.47 0 0 0-.881 0l-.977 2.465a.48.48 0 0 1-.403.307l-2.533.207a.47.47 0 0 0-.328.177.515.515 0 0 0 .06.702l.828.726c.415.364.962.518 1.495.421l2.623-.475a.22.22 0 0 1 .237.128.235.235 0 0 1-.103.308l-2.357 1.183a1.72 1.72 0 0 0-.888 1.134l-.335 1.376a.52.52 0 0 0 .056.383.465.465 0 0 0 .656.165"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@ -4,7 +4,7 @@ import type { ApiSticker } from '../../api/types';
import type { OwnProps as AnimatedIconProps } from './AnimatedIcon'; import type { OwnProps as AnimatedIconProps } from './AnimatedIcon';
import { ApiMediaFormat } from '../../api/types'; import { ApiMediaFormat } from '../../api/types';
import { getStickerPreviewHash } from '../../global/helpers'; import { getStickerMediaHash } from '../../global/helpers';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
@ -22,7 +22,7 @@ function AnimatedIconFromSticker(props: OwnProps) {
const thumbDataUri = sticker?.thumbnail?.dataUri; const thumbDataUri = sticker?.thumbnail?.dataUri;
const localMediaHash = sticker && `sticker${sticker.id}`; const localMediaHash = sticker && `sticker${sticker.id}`;
const previewBlobUrl = useMedia( const previewBlobUrl = useMedia(
sticker ? getStickerPreviewHash(sticker.id) : undefined, sticker ? getStickerMediaHash(sticker, 'preview') : undefined,
noLoad && !forcePreview, noLoad && !forcePreview,
ApiMediaFormat.BlobUrl, ApiMediaFormat.BlobUrl,
); );

View File

@ -13,9 +13,9 @@ import { AudioOrigin } from '../../types';
import { import {
getMediaDuration, getMediaDuration,
getMediaFormat,
getMediaHash,
getMediaTransferState, getMediaTransferState,
getMessageMediaFormat,
getMessageMediaHash,
getMessageWebPageAudio, getMessageWebPageAudio,
hasMessageTtl, hasMessageTtl,
isMessageLocal, isMessageLocal,
@ -72,7 +72,7 @@ type OwnProps = {
onPause?: NoneToVoidFunction; onPause?: NoneToVoidFunction;
onReadMedia?: () => void; onReadMedia?: () => void;
onCancelUpload?: () => void; onCancelUpload?: () => void;
onDateClick?: (messageId: number, chatId: string) => void; onDateClick?: (arg: ApiMessage) => void;
}; };
export const TINY_SCREEN_WIDTH_MQL = window.matchMedia('(max-width: 375px)'); export const TINY_SCREEN_WIDTH_MQL = window.matchMedia('(max-width: 375px)');
@ -108,7 +108,7 @@ const Audio: FC<OwnProps> = ({
onDateClick, onDateClick,
}) => { }) => {
const { const {
cancelMessageMediaDownload, downloadMessageMedia, transcribeAudio, openOneTimeMediaModal, cancelMediaDownload, downloadMedia, transcribeAudio, openOneTimeMediaModal,
} = getActions(); } = getActions();
const { const {
@ -117,6 +117,7 @@ const Audio: FC<OwnProps> = ({
}, isMediaUnread, }, isMediaUnread,
} = message; } = message;
const audio = contentAudio || getMessageWebPageAudio(message); const audio = contentAudio || getMessageWebPageAudio(message);
const media = (voice || video || audio)!;
const isVoice = Boolean(voice || video); const isVoice = Boolean(voice || video);
const isSeeking = useRef<boolean>(false); const isSeeking = useRef<boolean>(false);
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -127,22 +128,22 @@ const Audio: FC<OwnProps> = ({
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const [isActivated, setIsActivated] = useState(false); const [isActivated, setIsActivated] = useState(false);
const shouldLoad = isActivated || PRELOAD; const shouldLoad = isActivated || PRELOAD;
const coverHash = getMessageMediaHash(message, 'pictogram'); const coverHash = getMediaHash(media, 'pictogram');
const coverBlobUrl = useMedia(coverHash, false, ApiMediaFormat.BlobUrl); const coverBlobUrl = useMedia(coverHash, false, ApiMediaFormat.BlobUrl);
const hasTtl = hasMessageTtl(message); const hasTtl = hasMessageTtl(message);
const isInOneTimeModal = origin === AudioOrigin.OneTimeModal; const isInOneTimeModal = origin === AudioOrigin.OneTimeModal;
const trackType = isVoice ? (hasTtl ? 'oneTimeVoice' : 'voice') : 'audio'; const trackType = isVoice ? (hasTtl ? 'oneTimeVoice' : 'voice') : 'audio';
const mediaData = useMedia( const mediaData = useMedia(
getMessageMediaHash(message, 'inline'), getMediaHash(media, 'inline'),
!shouldLoad, !shouldLoad,
getMessageMediaFormat(message, 'inline'), getMediaFormat(media, 'inline'),
); );
const { loadProgress: downloadProgress } = useMediaWithLoadProgress( const { loadProgress: downloadProgress } = useMediaWithLoadProgress(
getMessageMediaHash(message, 'download'), getMediaHash(media, 'download'),
!isDownloading, !isDownloading,
getMessageMediaFormat(message, 'download'), getMediaFormat(media, 'download'),
); );
const handleForcePlay = useLastCallback(() => { const handleForcePlay = useLastCallback(() => {
@ -204,7 +205,6 @@ const Audio: FC<OwnProps> = ({
const { const {
isUploading, isTransferring, transferProgress, isUploading, isTransferring, transferProgress,
} = getMediaTransferState( } = getMediaTransferState(
message,
uploadProgress || downloadProgress, uploadProgress || downloadProgress,
isLoadingForPlaying || isDownloading, isLoadingForPlaying || isDownloading,
uploadProgress !== undefined, uploadProgress !== undefined,
@ -246,9 +246,9 @@ const Audio: FC<OwnProps> = ({
const handleDownloadClick = useLastCallback(() => { const handleDownloadClick = useLastCallback(() => {
if (isDownloading) { if (isDownloading) {
cancelMessageMediaDownload({ message }); cancelMediaDownload({ media });
} else { } else {
downloadMessageMedia({ message }); downloadMedia({ media });
} }
}); });
@ -273,7 +273,7 @@ const Audio: FC<OwnProps> = ({
}); });
const handleDateClick = useLastCallback(() => { const handleDateClick = useLastCallback(() => {
onDateClick!(message.id, message.chatId); onDateClick!(message);
}); });
const handleTranscribe = useLastCallback(() => { const handleTranscribe = useLastCallback(() => {

View File

@ -17,6 +17,7 @@ import {
getChatTitle, getChatTitle,
getPeerStoryHtmlId, getPeerStoryHtmlId,
getUserFullName, getUserFullName,
getVideoAvatarMediaHash,
getWebDocumentHash, getWebDocumentHash,
isAnonymousForwardsChat, isAnonymousForwardsChat,
isChatWithRepliesBot, isChatWithRepliesBot,
@ -120,7 +121,7 @@ const Avatar: FC<OwnProps> = ({
} else if (photo) { } else if (photo) {
imageHash = `photo${photo.id}?size=m`; imageHash = `photo${photo.id}?size=m`;
if (photo.isVideo && withVideo) { if (photo.isVideo && withVideo) {
videoHash = `videoAvatar${photo.id}?size=u`; videoHash = getVideoAvatarMediaHash(photo);
} }
} else if (webPhoto) { } else if (webPhoto) {
imageHash = getWebDocumentHash(webPhoto); imageHash = getWebDocumentHash(webPhoto);

View File

@ -31,11 +31,24 @@
} }
.effect-icon { .effect-icon {
font-size: 1.25rem; display: grid;
position: absolute; width: 1.5rem;
right: 0; height: 1.5rem;
place-items: center;
font-size: 1rem;
line-height: 1;
position: absolute;
right: -0.25rem;
bottom: -0.25rem;
background-color: var(--color-background);
border: 1px solid var(--color-borders);
color: var(--color-text); color: var(--color-text);
border-radius: 50%;
cursor: var(--custom-cursor, pointer);
& > .emoji { & > .emoji {
width: 1rem !important; width: 1rem !important;
height: 1rem !important; height: 1rem !important;

View File

@ -93,6 +93,8 @@ import {
import { selectCurrentLimit } from '../../global/selectors/limits'; import { selectCurrentLimit } from '../../global/selectors/limits';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { formatMediaDuration, formatVoiceRecordDuration } from '../../util/dates/dateFormat'; import { formatMediaDuration, formatVoiceRecordDuration } from '../../util/dates/dateFormat';
import { processDeepLink } from '../../util/deeplink';
import { tryParseDeepLink } from '../../util/deepLinkParser';
import deleteLastCharacterOutsideSelection from '../../util/deleteLastCharacterOutsideSelection'; import deleteLastCharacterOutsideSelection from '../../util/deleteLastCharacterOutsideSelection';
import { processMessageInputForCustomEmoji } from '../../util/emoji/customEmojiManager'; import { processMessageInputForCustomEmoji } from '../../util/emoji/customEmojiManager';
import focusEditableElement from '../../util/focusEditableElement'; import focusEditableElement from '../../util/focusEditableElement';
@ -1093,9 +1095,15 @@ const Composer: FC<OwnProps & StateProps> = ({
return; return;
} }
callAttachBot({ const parsedLink = tryParseDeepLink(botMenuButton.url);
chatId, url: botMenuButton.url, threadId,
}); if (parsedLink?.type === 'publicUsernameOrBotLink' && parsedLink.appName) {
processDeepLink(botMenuButton.url);
} else {
callAttachBot({
chatId, url: botMenuButton.url, threadId,
});
}
}); });
const handleActivateBotCommandMenu = useLastCallback(() => { const handleActivateBotCommandMenu = useLastCallback(() => {
@ -2033,7 +2041,7 @@ const Composer: FC<OwnProps & StateProps> = ({
{isInMessageList && <i className="icon icon-check" />} {isInMessageList && <i className="icon icon-check" />}
</Button> </Button>
{effectEmoji && ( {effectEmoji && (
<span className="effect-icon"> <span className="effect-icon" onClick={handleRemoveEffect}>
{renderText(effectEmoji)} {renderText(effectEmoji)}
</span> </span>
)} )}

View File

@ -1,20 +1,18 @@
import type { FC } from '../../lib/teact/teact';
import React, { import React, {
memo, useEffect, useRef, useState, memo, useEffect, useRef, useState,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions } from '../../global'; import { getActions } from '../../global';
import type { ApiMessage } from '../../api/types'; import type { ApiDocument, ApiMessage } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES } from '../../config'; import { SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES } from '../../config';
import { import {
getDocumentMediaHash,
getMediaFormat,
getMediaThumbUri,
getMediaTransferState, getMediaTransferState,
getMessageMediaFormat, isDocumentVideo,
getMessageMediaHash,
getMessageMediaThumbDataUri,
getMessageWebPageDocument,
isMessageDocumentVideo,
} from '../../global/helpers'; } from '../../global/helpers';
import { getDocumentExtension, getDocumentHasPreview } from './helpers/documentInfo'; import { getDocumentExtension, getDocumentHasPreview } from './helpers/documentInfo';
@ -30,7 +28,7 @@ import ConfirmDialog from '../ui/ConfirmDialog';
import File from './File'; import File from './File';
type OwnProps = { type OwnProps = {
message: ApiMessage; document: ApiDocument;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
smaller?: boolean; smaller?: boolean;
isSelected?: boolean; isSelected?: boolean;
@ -46,14 +44,19 @@ type OwnProps = {
shouldWarnAboutSvg?: boolean; shouldWarnAboutSvg?: boolean;
onCancelUpload?: () => void; onCancelUpload?: () => void;
onMediaClick?: () => void; onMediaClick?: () => void;
onDateClick?: (messageId: number, chatId: string) => void; } & ({
}; message: ApiMessage;
onDateClick: (arg: ApiMessage) => void;
} | {
message?: never;
onDateClick?: never;
});
const BYTES_PER_MB = 1024 * 1024; const BYTES_PER_MB = 1024 * 1024;
const SVG_EXTENSIONS = new Set(['svg', 'svgz']); const SVG_EXTENSIONS = new Set(['svg', 'svgz']);
const Document: FC<OwnProps> = ({ const Document = ({
message, document,
observeIntersection, observeIntersection,
smaller, smaller,
canAutoLoad, canAutoLoad,
@ -67,11 +70,12 @@ const Document: FC<OwnProps> = ({
isSelectable, isSelectable,
shouldWarnAboutSvg, shouldWarnAboutSvg,
isDownloading, isDownloading,
message,
onCancelUpload, onCancelUpload,
onMediaClick, onMediaClick,
onDateClick, onDateClick,
}) => { }: OwnProps) => {
const { cancelMessageMediaDownload, downloadMessageMedia, setSettingOption } = getActions(); const { cancelMediaDownload, downloadMedia, setSettingOption } = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
@ -80,8 +84,6 @@ const Document: FC<OwnProps> = ({
const [isSvgDialogOpen, openSvgDialog, closeSvgDialog] = useFlag(); const [isSvgDialogOpen, openSvgDialog, closeSvgDialog] = useFlag();
const [shouldNotWarnAboutSvg, setShouldNotWarnAboutSvg] = useState(false); const [shouldNotWarnAboutSvg, setShouldNotWarnAboutSvg] = useState(false);
const document = message.content.document! || getMessageWebPageDocument(message);
const { fileName, size, timestamp } = document; const { fileName, size, timestamp } = document;
const extension = getDocumentExtension(document) || ''; const extension = getDocumentExtension(document) || '';
@ -100,32 +102,31 @@ const Document: FC<OwnProps> = ({
const shouldDownload = Boolean(isDownloading || (isLoadAllowed && wasIntersected)); const shouldDownload = Boolean(isDownloading || (isLoadAllowed && wasIntersected));
const documentHash = getMessageMediaHash(message, 'download'); const documentHash = getDocumentMediaHash(document, 'download');
const { loadProgress: downloadProgress, mediaData } = useMediaWithLoadProgress( const { loadProgress: downloadProgress, mediaData } = useMediaWithLoadProgress(
documentHash, !shouldDownload, getMessageMediaFormat(message, 'download'), undefined, true, documentHash, !shouldDownload, getMediaFormat(document, 'download'), undefined, true,
); );
const isLoaded = Boolean(mediaData); const isLoaded = Boolean(mediaData);
const { const {
isUploading, isTransferring, transferProgress, isUploading, isTransferring, transferProgress,
} = getMediaTransferState( } = getMediaTransferState(
message,
uploadProgress || downloadProgress, uploadProgress || downloadProgress,
shouldDownload && !isLoaded, shouldDownload && !isLoaded,
uploadProgress !== undefined, uploadProgress !== undefined,
); );
const hasPreview = getDocumentHasPreview(document); const hasPreview = getDocumentHasPreview(document);
const thumbDataUri = hasPreview ? getMessageMediaThumbDataUri(message) : undefined; const thumbDataUri = hasPreview ? getMediaThumbUri(document) : undefined;
const localBlobUrl = hasPreview ? document.previewBlobUrl : undefined; const localBlobUrl = hasPreview ? document.previewBlobUrl : undefined;
const previewData = useMedia(getMessageMediaHash(message, 'pictogram'), !isIntersecting); const previewData = useMedia(getDocumentMediaHash(document, 'pictogram'), !isIntersecting);
const withMediaViewer = onMediaClick && Boolean(document.mediaType) && ( const withMediaViewer = onMediaClick && Boolean(document.mediaType) && (
SUPPORTED_VIDEO_CONTENT_TYPES.has(document.mimeType) || SUPPORTED_IMAGE_CONTENT_TYPES.has(document.mimeType) SUPPORTED_VIDEO_CONTENT_TYPES.has(document.mimeType) || SUPPORTED_IMAGE_CONTENT_TYPES.has(document.mimeType)
); );
const handleDownload = useLastCallback(() => { const handleDownload = useLastCallback(() => {
downloadMessageMedia({ message }); downloadMedia({ media: document });
}); });
const handleClick = useLastCallback(() => { const handleClick = useLastCallback(() => {
@ -137,7 +138,7 @@ const Document: FC<OwnProps> = ({
} }
if (isDownloading) { if (isDownloading) {
cancelMessageMediaDownload({ message }); cancelMediaDownload({ media: document });
return; return;
} }
@ -166,7 +167,7 @@ const Document: FC<OwnProps> = ({
}); });
const handleDateClick = useLastCallback(() => { const handleDateClick = useLastCallback(() => {
onDateClick!(message.id, message.chatId); onDateClick?.(message);
}); });
return ( return (
@ -187,7 +188,7 @@ const Document: FC<OwnProps> = ({
sender={sender} sender={sender}
isSelectable={isSelectable} isSelectable={isSelectable}
isSelected={isSelected} isSelected={isSelected}
actionIcon={withMediaViewer ? (isMessageDocumentVideo(message) ? 'play' : 'eye') : 'download'} actionIcon={withMediaViewer ? (isDocumentVideo(document) ? 'play' : 'eye') : 'download'}
onClick={handleClick} onClick={handleClick}
onDateClick={onDateClick ? handleDateClick : undefined} onDateClick={onDateClick ? handleDateClick : undefined}
/> />

View File

@ -7,6 +7,7 @@ import type { ApiVideo } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { ApiMediaFormat } from '../../api/types'; import { ApiMediaFormat } from '../../api/types';
import { getVideoMediaHash } from '../../global/helpers';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; import { IS_TOUCH_ENV } from '../../util/windowEnvironment';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur'; import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
@ -52,13 +53,12 @@ const GifButton: FC<OwnProps> = ({
const lang = useOldLang(); const lang = useOldLang();
const localMediaHash = `gif${gif.id}`;
const isIntersecting = useIsIntersecting(ref, observeIntersection); const isIntersecting = useIsIntersecting(ref, observeIntersection);
const loadAndPlay = isIntersecting && !isDisabled; const loadAndPlay = isIntersecting && !isDisabled;
const previewBlobUrl = useMedia(`${localMediaHash}?size=m`, !loadAndPlay, ApiMediaFormat.BlobUrl); const previewBlobUrl = useMedia(getVideoMediaHash(gif, 'preview'), !loadAndPlay, ApiMediaFormat.BlobUrl);
const [withThumb] = useState(gif.thumbnail?.dataUri && !previewBlobUrl); const [withThumb] = useState(gif.thumbnail?.dataUri && !previewBlobUrl);
const thumbRef = useCanvasBlur(gif.thumbnail?.dataUri, !withThumb); const thumbRef = useCanvasBlur(gif.thumbnail?.dataUri, !withThumb);
const videoData = useMedia(localMediaHash, !loadAndPlay, ApiMediaFormat.BlobUrl); const videoData = useMedia(getVideoMediaHash(gif, 'full'), !loadAndPlay, ApiMediaFormat.BlobUrl);
const shouldRenderVideo = Boolean(loadAndPlay && videoData); const shouldRenderVideo = Boolean(loadAndPlay && videoData);
const { isBuffered, bufferingHandlers } = useBuffering(true); const { isBuffered, bufferingHandlers } = useBuffering(true);
const shouldRenderSpinner = loadAndPlay && !isBuffered; const shouldRenderSpinner = loadAndPlay && !isBuffered;
@ -128,7 +128,6 @@ const GifButton: FC<OwnProps> = ({
'GifButton', 'GifButton',
gif.width && gif.height && gif.width < gif.height ? 'vertical' : 'horizontal', gif.width && gif.height && gif.width < gif.height ? 'vertical' : 'horizontal',
onClick && 'interactive', onClick && 'interactive',
localMediaHash,
className, className,
); );

View File

@ -130,8 +130,9 @@ const GroupChatInfo: FC<OwnProps & StateProps> = ({
if (chat && hasMedia) { if (chat && hasMedia) {
e.stopPropagation(); e.stopPropagation();
openMediaViewer({ openMediaViewer({
avatarOwnerId: chat.id, isAvatarView: true,
mediaId: 0, chatId: chat.id,
mediaIndex: 0,
origin: avatarSize === 'jumbo' ? MediaViewerOrigin.ProfileAvatar : MediaViewerOrigin.MiddleHeaderAvatar, origin: avatarSize === 'jumbo' ? MediaViewerOrigin.ProfileAvatar : MediaViewerOrigin.MiddleHeaderAvatar,
}); });
} }

View File

@ -29,6 +29,7 @@
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover;
} }
.dots { .dots {

View File

@ -1,6 +1,7 @@
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, useRef } from '../../lib/teact/teact';
import { requestMutation } from '../../lib/fasterdom/fasterdom';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import useCanvasBlur from '../../hooks/useCanvasBlur'; import useCanvasBlur from '../../hooks/useCanvasBlur';
@ -39,12 +40,15 @@ const MediaSpoiler: FC<OwnProps> = ({
const handleClick = useLastCallback((e: React.MouseEvent<HTMLDivElement>) => { const handleClick = useLastCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!ref.current) return; if (!ref.current) return;
const el = ref.current;
const rect = e.currentTarget.getBoundingClientRect(); const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left; const x = e.clientX - rect.left;
const y = e.clientY - rect.top; const y = e.clientY - rect.top;
const shiftX = x - (rect.width / 2); const shiftX = x - (rect.width / 2);
const shiftY = y - (rect.height / 2); const shiftY = y - (rect.height / 2);
ref.current.setAttribute('style', `--click-shift-x: ${shiftX}px; --click-shift-y: ${shiftY}px`); requestMutation(() => {
el.setAttribute('style', `--click-shift-x: ${shiftX}px; --click-shift-y: ${shiftY}px`);
});
}); });
if (!shouldRender) { if (!shouldRender) {

View File

@ -6,7 +6,8 @@ import type { ApiFormattedText, ApiMessage, ApiStory } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { ApiMessageEntityTypes } from '../../api/types'; import { ApiMessageEntityTypes } from '../../api/types';
import { extractMessageText, getMessageText, stripCustomEmoji } from '../../global/helpers'; import { CONTENT_NOT_SUPPORTED } from '../../config';
import { extractMessageText, stripCustomEmoji } from '../../global/helpers';
import trimText from '../../util/trimText'; import trimText from '../../util/trimText';
import { renderTextWithEntities } from './helpers/renderTextWithEntities'; import { renderTextWithEntities } from './helpers/renderTextWithEntities';
@ -80,8 +81,7 @@ function MessageText({
}, [entities]) || 0; }, [entities]) || 0;
if (!text) { if (!text) {
const contentNotSupportedText = getMessageText(messageOrStory); return <span className="content-unsupported">{CONTENT_NOT_SUPPORTED}</span>;
return contentNotSupportedText ? [trimText(contentNotSupportedText, truncateLength)] : undefined as any;
} }
return ( return (

View File

@ -121,8 +121,9 @@ const PrivateChatInfo: FC<OwnProps & StateProps> = ({
if (user && hasMedia) { if (user && hasMedia) {
e.stopPropagation(); e.stopPropagation();
openMediaViewer({ openMediaViewer({
avatarOwnerId: user.id, isAvatarView: true,
mediaId: 0, chatId: user.id,
mediaIndex: 0,
origin: avatarSize === 'jumbo' ? MediaViewerOrigin.ProfileAvatar : MediaViewerOrigin.MiddleHeaderAvatar, origin: avatarSize === 'jumbo' ? MediaViewerOrigin.ProfileAvatar : MediaViewerOrigin.MiddleHeaderAvatar,
}); });
} }

View File

@ -53,7 +53,7 @@ type StateProps =
user?: ApiUser; user?: ApiUser;
userStatus?: ApiUserStatus; userStatus?: ApiUserStatus;
chat?: ApiChat; chat?: ApiChat;
mediaId?: number; mediaIndex?: number;
avatarOwnerId?: string; avatarOwnerId?: string;
topic?: ApiTopic; topic?: ApiTopic;
messagesCount?: number; messagesCount?: number;
@ -75,7 +75,7 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
userStatus, userStatus,
chat, chat,
isSynced, isSynced,
mediaId, mediaIndex,
avatarOwnerId, avatarOwnerId,
topic, topic,
messagesCount, messagesCount,
@ -98,9 +98,9 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
const { id: userId } = user || {}; const { id: userId } = user || {};
const { id: chatId } = chat || {}; const { id: chatId } = chat || {};
const photos = user?.photos || chat?.photos || MEMO_EMPTY_ARRAY; const photos = user?.photos || chat?.photos || MEMO_EMPTY_ARRAY;
const prevMediaId = usePrevious(mediaId); const prevMediaIndex = usePrevious(mediaIndex);
const prevAvatarOwnerId = usePrevious(avatarOwnerId); const prevAvatarOwnerId = usePrevious(avatarOwnerId);
const mediaIdRef = useStateRef(mediaId); const mediaIndexRef = useStateRef(mediaIndex);
const [hasSlideAnimation, setHasSlideAnimation] = useState(true); const [hasSlideAnimation, setHasSlideAnimation] = useState(true);
// slideOptimized doesn't work well when animation is dynamically disabled // slideOptimized doesn't work well when animation is dynamically disabled
const slideAnimation = hasSlideAnimation ? (lang.isRtl ? 'slideRtl' : 'slide') : 'none'; const slideAnimation = hasSlideAnimation ? (lang.isRtl ? 'slideRtl' : 'slide') : 'none';
@ -111,17 +111,17 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
// Set the current avatar photo to the last selected photo in Media Viewer after it is closed // Set the current avatar photo to the last selected photo in Media Viewer after it is closed
useEffect(() => { useEffect(() => {
if (prevAvatarOwnerId && prevMediaId !== undefined && mediaId === undefined) { if (prevAvatarOwnerId && prevMediaIndex !== undefined && mediaIndex === undefined) {
setHasSlideAnimation(false); setHasSlideAnimation(false);
setCurrentPhotoIndex(prevMediaId); setCurrentPhotoIndex(prevMediaIndex);
} }
}, [mediaId, prevMediaId, prevAvatarOwnerId]); }, [mediaIndex, prevMediaIndex, prevAvatarOwnerId]);
// Reset the current avatar photo to the one selected in Media Viewer if photos have changed // Reset the current avatar photo to the one selected in Media Viewer if photos have changed
useEffect(() => { useEffect(() => {
setHasSlideAnimation(false); setHasSlideAnimation(false);
setCurrentPhotoIndex(mediaIdRef.current || 0); setCurrentPhotoIndex(mediaIndexRef.current || 0);
}, [mediaIdRef, photos]); }, [mediaIndexRef, photos]);
// Deleting the last profile photo may result in an error // Deleting the last profile photo may result in an error
useEffect(() => { useEffect(() => {
@ -141,8 +141,9 @@ const ProfileInfo: FC<OwnProps & StateProps> = ({
const handleProfilePhotoClick = useLastCallback(() => { const handleProfilePhotoClick = useLastCallback(() => {
openMediaViewer({ openMediaViewer({
avatarOwnerId: userId || chatId, isAvatarView: true,
mediaId: currentPhotoIndex, chatId: userId || chatId,
mediaIndex: currentPhotoIndex,
origin: forceShowSelf ? MediaViewerOrigin.SettingsAvatar : MediaViewerOrigin.ProfileAvatar, origin: forceShowSelf ? MediaViewerOrigin.SettingsAvatar : MediaViewerOrigin.ProfileAvatar,
}); });
}); });
@ -386,7 +387,7 @@ export default memo(withGlobal<OwnProps>(
const isPrivate = isUserId(userId); const isPrivate = isUserId(userId);
const userStatus = selectUserStatus(global, userId); const userStatus = selectUserStatus(global, userId);
const chat = selectChat(global, userId); const chat = selectChat(global, userId);
const { mediaId, avatarOwnerId } = selectTabState(global).mediaViewer; const { mediaIndex, chatId: avatarOwnerId } = selectTabState(global).mediaViewer;
const isForum = chat?.isForum; const isForum = chat?.isForum;
const { threadId: currentTopicId } = selectCurrentMessageList(global) || {}; const { threadId: currentTopicId } = selectCurrentMessageList(global) || {};
const topic = isForum && currentTopicId ? chat?.topics?.[currentTopicId] : undefined; const topic = isForum && currentTopicId ? chat?.topics?.[currentTopicId] : undefined;
@ -405,7 +406,7 @@ export default memo(withGlobal<OwnProps>(
userProfilePhoto: userFullInfo?.profilePhoto, userProfilePhoto: userFullInfo?.profilePhoto,
userFallbackPhoto: userFullInfo?.fallbackPhoto, userFallbackPhoto: userFullInfo?.fallbackPhoto,
chatProfilePhoto: chatFullInfo?.profilePhoto, chatProfilePhoto: chatFullInfo?.profilePhoto,
mediaId, mediaIndex,
avatarOwnerId, avatarOwnerId,
emojiStatusSticker, emojiStatusSticker,
...(topic && { ...(topic && {

View File

@ -54,7 +54,7 @@ const ReactionEmoji: FC<OwnProps> = ({
const animationId = availableReaction?.selectAnimation?.id; const animationId = availableReaction?.selectAnimation?.id;
const coords = useCoordsInSharedCanvas(ref, sharedCanvasRef); const coords = useCoordsInSharedCanvas(ref, sharedCanvasRef);
const mediaData = useMedia( const mediaData = useMedia(
availableReaction?.selectAnimation ? getDocumentMediaHash(availableReaction.selectAnimation) : undefined, availableReaction?.selectAnimation ? getDocumentMediaHash(availableReaction.selectAnimation, 'full') : undefined,
!animationId, !animationId,
); );
const handleClick = useLastCallback(() => { const handleClick = useLastCallback(() => {

View File

@ -177,6 +177,7 @@ const StickerSet: FC<OwnProps> = ({
const handleDefaultTopicIconClick = useLastCallback(() => { const handleDefaultTopicIconClick = useLastCallback(() => {
onStickerSelect?.({ onStickerSelect?.({
mediaType: 'sticker',
id: DEFAULT_TOPIC_ICON_STICKER_ID, id: DEFAULT_TOPIC_ICON_STICKER_ID,
isLottie: false, isLottie: false,
isVideo: false, isVideo: false,
@ -188,6 +189,7 @@ const StickerSet: FC<OwnProps> = ({
const handleDefaultStatusIconClick = useLastCallback(() => { const handleDefaultStatusIconClick = useLastCallback(() => {
onStickerSelect?.({ onStickerSelect?.({
mediaType: 'sticker',
id: DEFAULT_STATUS_ICON_ID, id: DEFAULT_STATUS_ICON_ID,
isLottie: false, isLottie: false,
isVideo: false, isVideo: false,

View File

@ -5,7 +5,7 @@ import { getGlobal } from '../../global';
import type { ApiSticker } from '../../api/types'; import type { ApiSticker } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { getStickerPreviewHash } from '../../global/helpers'; import { getStickerMediaHash } from '../../global/helpers';
import { selectIsAlwaysHighPriorityEmoji } from '../../global/selectors'; import { selectIsAlwaysHighPriorityEmoji } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import * as mediaLoader from '../../util/mediaLoader'; import * as mediaLoader from '../../util/mediaLoader';
@ -86,7 +86,7 @@ const StickerView: FC<OwnProps> = ({
const isUnsupportedVideo = sticker.isVideo && (!IS_WEBM_SUPPORTED || isVideoBroken); const isUnsupportedVideo = sticker.isVideo && (!IS_WEBM_SUPPORTED || isVideoBroken);
const isVideo = sticker.isVideo && !isUnsupportedVideo; const isVideo = sticker.isVideo && !isUnsupportedVideo;
const isStatic = !isLottie && !isVideo; const isStatic = !isLottie && !isVideo;
const previewMediaHash = getStickerPreviewHash(sticker.id); const previewMediaHash = getStickerMediaHash(sticker, 'preview');
const dpr = useDevicePixelRatio(); const dpr = useDevicePixelRatio();

View File

@ -31,7 +31,7 @@ type OwnProps = {
senderTitle?: string; senderTitle?: string;
isProtected?: boolean; isProtected?: boolean;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
onMessageClick: (messageId: number, chatId: string) => void; onMessageClick: (message: ApiMessage) => void;
}; };
type ApiWebPageWithFormatted = type ApiWebPageWithFormatted =
@ -61,7 +61,7 @@ const WebLink: FC<OwnProps> = ({
} }
const handleMessageClick = useLastCallback(() => { const handleMessageClick = useLastCallback(() => {
onMessageClick(message.id, message.chatId); onMessageClick(message);
}); });
if (!linkData) { if (!linkData) {

View File

@ -89,16 +89,20 @@ const EmbeddedMessage: FC<OwnProps> = ({
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const isIntersecting = useIsIntersecting(ref, observeIntersectionForLoading); const isIntersecting = useIsIntersecting(ref, observeIntersectionForLoading);
const wrappedMedia = useMemo(() => { const containedMedia: MediaContainer | undefined = useMemo(() => {
const replyMedia = replyInfo?.type === 'message' && replyInfo.replyMedia; const media = (replyInfo?.type === 'message' && replyInfo.replyMedia) || message?.content;
if (!replyMedia) return undefined; if (!media) {
return { return undefined;
content: replyMedia, }
};
}, [replyInfo]);
const mediaBlobUrl = useMedia(message && getMessageMediaHash(message, 'pictogram'), !isIntersecting); return {
const mediaThumbnail = useThumbnail(message || wrappedMedia); content: media,
};
}, [message, replyInfo]);
const mediaHash = containedMedia && getMessageMediaHash(containedMedia, 'pictogram');
const mediaBlobUrl = useMedia(mediaHash, !isIntersecting);
const mediaThumbnail = useThumbnail(containedMedia);
const isRoundVideo = Boolean(message && getMessageRoundVideo(message)); const isRoundVideo = Boolean(message && getMessageRoundVideo(message));
const isSpoiler = Boolean(message && getMessageIsSpoiler(message)); const isSpoiler = Boolean(message && getMessageIsSpoiler(message));
const isQuote = Boolean(replyInfo?.type === 'message' && replyInfo.isQuote); const isQuote = Boolean(replyInfo?.type === 'message' && replyInfo.isQuote);
@ -131,7 +135,7 @@ const EmbeddedMessage: FC<OwnProps> = ({
} }
if (!message) { if (!message) {
return customText || renderMediaContentType(wrappedMedia) || NBSP; return customText || renderMediaContentType(containedMedia) || NBSP;
} }
if (isActionMessage(message)) { if (isActionMessage(message)) {

View File

@ -3,7 +3,7 @@ import React, {
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { getStickerPreviewHash } from '../../../global/helpers'; import { getStickerMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { preloadImage } from '../../../util/files'; import { preloadImage } from '../../../util/files';
import { REM } from '../helpers/mediaDimensions'; import { REM } from '../helpers/mediaDimensions';
@ -78,7 +78,7 @@ const EmojiIconBackground = ({
const lang = useOldLang(); const lang = useOldLang();
const { customEmoji } = useCustomEmoji(emojiDocumentId); const { customEmoji } = useCustomEmoji(emojiDocumentId);
const previewMediaHash = customEmoji ? getStickerPreviewHash(customEmoji.id) : undefined; const previewMediaHash = customEmoji ? getStickerMediaHash(customEmoji, 'preview') : undefined;
const previewUrl = useMedia(previewMediaHash); const previewUrl = useMedia(previewMediaHash);
const customColor = useDynamicColorListener(containerRef); const customColor = useDynamicColorListener(containerRef);

View File

@ -1,5 +1,5 @@
import type { import type {
ApiDimensions, ApiPhoto, ApiSticker, ApiVideo, ApiDimensions, ApiMediaExtendedPreview, ApiPhoto, ApiSticker, ApiVideo,
} from '../../../api/types'; } from '../../../api/types';
import { STICKER_SIZE_INLINE_DESKTOP_FACTOR, STICKER_SIZE_INLINE_MOBILE_FACTOR } from '../../../config'; import { STICKER_SIZE_INLINE_DESKTOP_FACTOR, STICKER_SIZE_INLINE_MOBILE_FACTOR } from '../../../config';
@ -25,7 +25,7 @@ let cachedMaxWidthOwn: number | undefined;
let cachedMaxWidth: number | undefined; let cachedMaxWidth: number | undefined;
let cachedMaxWidthNoAvatar: number | undefined; let cachedMaxWidthNoAvatar: number | undefined;
function getMaxMessageWidthRem(fromOwnMessage: boolean, noAvatars?: boolean, isMobile?: boolean) { function getMaxMessageWidthRem(fromOwnMessage?: boolean, noAvatars?: boolean, isMobile?: boolean) {
const regularMaxWidth = fromOwnMessage ? MESSAGE_OWN_MAX_WIDTH_REM : MESSAGE_MAX_WIDTH_REM; const regularMaxWidth = fromOwnMessage ? MESSAGE_OWN_MAX_WIDTH_REM : MESSAGE_MAX_WIDTH_REM;
if (!isMobile) { if (!isMobile) {
return regularMaxWidth; return regularMaxWidth;
@ -59,7 +59,7 @@ function getMaxMessageWidthRem(fromOwnMessage: boolean, noAvatars?: boolean, isM
} }
export function getAvailableWidth( export function getAvailableWidth(
fromOwnMessage: boolean, fromOwnMessage?: boolean,
asForwarded?: boolean, asForwarded?: boolean,
isWebPageMedia?: boolean, isWebPageMedia?: boolean,
noAvatars?: boolean, noAvatars?: boolean,
@ -94,7 +94,7 @@ export function calculateDimensionsForMessageMedia({
}: { }: {
width: number; width: number;
height: number; height: number;
fromOwnMessage: boolean; fromOwnMessage?: boolean;
asForwarded?: boolean; asForwarded?: boolean;
isWebPageMedia?: boolean; isWebPageMedia?: boolean;
isGif?: boolean; isGif?: boolean;
@ -125,7 +125,7 @@ export function getMediaViewerAvailableDimensions(withFooter: boolean, isVideo:
export function calculateInlineImageDimensions( export function calculateInlineImageDimensions(
photo: ApiPhoto, photo: ApiPhoto,
fromOwnMessage: boolean, fromOwnMessage?: boolean,
asForwarded?: boolean, asForwarded?: boolean,
isWebPageMedia?: boolean, isWebPageMedia?: boolean,
noAvatars?: boolean, noAvatars?: boolean,
@ -146,7 +146,7 @@ export function calculateInlineImageDimensions(
export function calculateVideoDimensions( export function calculateVideoDimensions(
video: ApiVideo, video: ApiVideo,
fromOwnMessage: boolean, fromOwnMessage?: boolean,
asForwarded?: boolean, asForwarded?: boolean,
isWebPageMedia?: boolean, isWebPageMedia?: boolean,
noAvatars?: boolean, noAvatars?: boolean,
@ -166,6 +166,27 @@ export function calculateVideoDimensions(
}); });
} }
export function calculateExtendedPreviewDimensions(
preview: ApiMediaExtendedPreview,
fromOwnMessage?: boolean,
asForwarded?: boolean,
isWebPageMedia?: boolean,
noAvatars?: boolean,
isMobile?: boolean,
) {
const { width = DEFAULT_MEDIA_DIMENSIONS.width, height = DEFAULT_MEDIA_DIMENSIONS.height } = preview;
return calculateDimensionsForMessageMedia({
width,
height,
fromOwnMessage,
asForwarded,
isWebPageMedia,
noAvatars,
isMobile,
});
}
export function getPictogramDimensions(): ApiDimensions { export function getPictogramDimensions(): ApiDimensions {
return { return {
width: 2 * REM, width: 2 * REM,

View File

@ -105,12 +105,12 @@ const UserBirthday = ({
if (!isToday || !numbersForAge) return; if (!isToday || !numbersForAge) return;
numbersForAge.forEach((sticker) => { numbersForAge.forEach((sticker) => {
const hash = getStickerMediaHash(sticker.id); const hash = getStickerMediaHash(sticker, 'preview');
mediaLoader.fetch(hash, ApiMediaFormat.BlobUrl); mediaLoader.fetch(hash, ApiMediaFormat.BlobUrl);
}); });
if (effectSticker) { if (effectSticker) {
const effectHash = getStickerMediaHash(effectSticker.id); const effectHash = getStickerMediaHash(effectSticker, 'preview');
mediaLoader.fetch(effectHash, ApiMediaFormat.BlobUrl); mediaLoader.fetch(effectHash, ApiMediaFormat.BlobUrl);
} }
}, [effectSticker, isToday, numbersForAge]); }, [effectSticker, isToday, numbersForAge]);

View File

@ -3,7 +3,7 @@ import React, { memo, useMemo } from '../../../lib/teact/teact';
import type { ApiEmojiStatus, ApiReactionCustomEmoji } from '../../../api/types'; import type { ApiEmojiStatus, ApiReactionCustomEmoji } from '../../../api/types';
import { getStickerPreviewHash } from '../../../global/helpers'; import { getStickerHashById } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle'; import buildStyle from '../../../util/buildStyle';
import { IS_OFFSET_PATH_SUPPORTED } from '../../../util/windowEnvironment'; import { IS_OFFSET_PATH_SUPPORTED } from '../../../util/windowEnvironment';
@ -31,7 +31,7 @@ const CustomEmojiEffect: FC<OwnProps> = ({
particleSize, particleSize,
onEnded, onEnded,
}) => { }) => {
const stickerHash = getStickerPreviewHash(reaction.documentId); const stickerHash = getStickerHashById(reaction.documentId);
const previewMediaData = useMedia(!isLottie ? stickerHash : undefined); const previewMediaData = useMedia(!isLottie ? stickerHash : undefined);

View File

@ -89,10 +89,10 @@ export default function useChatListEntry({
const replyToMessageId = lastMessage && getMessageReplyInfo(lastMessage)?.replyToMsgId; const replyToMessageId = lastMessage && getMessageReplyInfo(lastMessage)?.replyToMsgId;
useEnsureMessage(chatId, isAction ? replyToMessageId : undefined, actionTargetMessage); useEnsureMessage(chatId, isAction ? replyToMessageId : undefined, actionTargetMessage);
const mediaThumbnail = lastMessage && !getMessageSticker(lastMessage) const mediaHasPreview = lastMessage && !getMessageSticker(lastMessage);
? getMessageMediaThumbDataUri(lastMessage)
: undefined; const mediaThumbnail = mediaHasPreview ? getMessageMediaThumbDataUri(lastMessage) : undefined;
const mediaBlobUrl = useMedia(lastMessage ? getMessageMediaHash(lastMessage, 'micro') : undefined); const mediaBlobUrl = useMedia(mediaHasPreview ? getMessageMediaHash(lastMessage, 'micro') : undefined);
const isRoundVideo = Boolean(lastMessage && getMessageRoundVideo(lastMessage)); const isRoundVideo = Boolean(lastMessage && getMessageRoundVideo(lastMessage));
const actionTargetUsers = useMemo(() => { const actionTargetUsers = useMemo(() => {

View File

@ -2,10 +2,12 @@ import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact'; import React, { memo, useCallback, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiMessage } from '../../../api/types';
import type { StateProps } from './helpers/createMapStateToProps'; import type { StateProps } from './helpers/createMapStateToProps';
import { AudioOrigin, LoadMoreDirection } from '../../../types'; import { AudioOrigin, LoadMoreDirection } from '../../../types';
import { SLIDE_TRANSITION_DURATION } from '../../../config'; import { SLIDE_TRANSITION_DURATION } from '../../../config';
import { getIsDownloading } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { formatMonthAndYear, toYearMonth } from '../../../util/dates/dateFormat'; import { formatMonthAndYear, toYearMonth } from '../../../util/dates/dateFormat';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
@ -70,8 +72,8 @@ const AudioResults: FC<OwnProps & StateProps> = ({
}).filter(Boolean); }).filter(Boolean);
}, [globalMessagesByChatId, foundIds]); }, [globalMessagesByChatId, foundIds]);
const handleMessageFocus = useCallback((messageId: number, chatId: string) => { const handleMessageFocus = useCallback((message: ApiMessage) => {
focusMessage({ chatId, messageId }); focusMessage({ chatId: message.chatId, messageId: message.id });
}, [focusMessage]); }, [focusMessage]);
const handlePlayAudio = useCallback((messageId: number, chatId: string) => { const handlePlayAudio = useCallback((messageId: number, chatId: string) => {
@ -111,7 +113,7 @@ const AudioResults: FC<OwnProps & StateProps> = ({
onPlay={handlePlayAudio} onPlay={handlePlayAudio}
onDateClick={handleMessageFocus} onDateClick={handleMessageFocus}
canDownload={!chatsById[message.chatId]?.isProtected && !message.isProtected} canDownload={!chatsById[message.chatId]?.isProtected && !message.isProtected}
isDownloading={activeDownloads[message.chatId]?.ids?.includes(message.id)} isDownloading={getIsDownloading(activeDownloads, message.content.audio!)}
/> />
</div> </div>
); );

View File

@ -9,7 +9,7 @@ import type { StateProps } from './helpers/createMapStateToProps';
import { LoadMoreDirection } from '../../../types'; import { LoadMoreDirection } from '../../../types';
import { SLIDE_TRANSITION_DURATION } from '../../../config'; import { SLIDE_TRANSITION_DURATION } from '../../../config';
import { getMessageDocument } from '../../../global/helpers'; import { getIsDownloading, getMessageDocument } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { formatMonthAndYear, toYearMonth } from '../../../util/dates/dateFormat'; import { formatMonthAndYear, toYearMonth } from '../../../util/dates/dateFormat';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
@ -84,8 +84,8 @@ const FileResults: FC<OwnProps & StateProps> = ({
}).filter(Boolean) as ApiMessage[]; }).filter(Boolean) as ApiMessage[];
}, [globalMessagesByChatId, foundIds]); }, [globalMessagesByChatId, foundIds]);
const handleMessageFocus = useCallback((messageId: number, chatId: string) => { const handleMessageFocus = useCallback((message: ApiMessage) => {
focusMessage({ chatId, messageId }); focusMessage({ chatId: message.chatId, messageId: message.id });
}, [focusMessage]); }, [focusMessage]);
function renderList() { function renderList() {
@ -111,13 +111,14 @@ const FileResults: FC<OwnProps & StateProps> = ({
</p> </p>
)} )}
<Document <Document
document={getMessageDocument(message)!}
message={message} message={message}
withDate withDate
datetime={message.date} datetime={message.date}
smaller smaller
sender={getSenderName(lang, message, chatsById, usersById)} sender={getSenderName(lang, message, chatsById, usersById)}
className="scroll-item" className="scroll-item"
isDownloading={activeDownloads[message.chatId]?.ids?.includes(message.id)} isDownloading={getIsDownloading(activeDownloads, message.content.document!)}
shouldWarnAboutSvg={shouldWarnAboutSvg} shouldWarnAboutSvg={shouldWarnAboutSvg}
observeIntersection={observeIntersectionForMedia} observeIntersection={observeIntersectionForMedia}
onDateClick={handleMessageFocus} onDateClick={handleMessageFocus}

View File

@ -4,6 +4,7 @@ import React, {
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiMessage } from '../../../api/types';
import type { StateProps } from './helpers/createMapStateToProps'; import type { StateProps } from './helpers/createMapStateToProps';
import { LoadMoreDirection } from '../../../types'; import { LoadMoreDirection } from '../../../types';
@ -80,8 +81,8 @@ const LinkResults: FC<OwnProps & StateProps> = ({
}).filter(Boolean); }).filter(Boolean);
}, [globalMessagesByChatId, foundIds]); }, [globalMessagesByChatId, foundIds]);
const handleMessageFocus = useCallback((messageId: number, chatId: string) => { const handleMessageFocus = useCallback((message: ApiMessage) => {
focusMessage({ chatId, messageId }); focusMessage({ chatId: message.chatId, messageId: message.id });
}, [focusMessage]); }, [focusMessage]);
function renderList() { function renderList() {

View File

@ -80,7 +80,7 @@ const MediaResults: FC<OwnProps & StateProps> = ({
const handleSelectMedia = useCallback((id: number, chatId: string) => { const handleSelectMedia = useCallback((id: number, chatId: string) => {
openMediaViewer({ openMediaViewer({
chatId, chatId,
mediaId: id, messageId: id,
origin: MediaViewerOrigin.SearchResult, origin: MediaViewerOrigin.SearchResult,
}); });
}, [openMediaViewer]); }, [openMediaViewer]);

View File

@ -14,7 +14,7 @@ export type StateProps = {
globalMessagesByChatId?: Record<string, { byId: Record<number, ApiMessage> }>; globalMessagesByChatId?: Record<string, { byId: Record<number, ApiMessage> }>;
foundIds?: string[]; foundIds?: string[];
searchChatId?: string; searchChatId?: string;
activeDownloads: TabState['activeDownloads']['byChatId']; activeDownloads: TabState['activeDownloads'];
isChatProtected?: boolean; isChatProtected?: boolean;
shouldWarnAboutSvg?: boolean; shouldWarnAboutSvg?: boolean;
}; };
@ -36,7 +36,7 @@ export function createMapStateToProps(type: ApiGlobalMessageSearchType) {
const { byChatId: globalMessagesByChatId } = global.messages; const { byChatId: globalMessagesByChatId } = global.messages;
const foundIds = resultsByType?.[currentType]?.foundIds; const foundIds = resultsByType?.[currentType]?.foundIds;
const activeDownloads = tabState.activeDownloads.byChatId; const activeDownloads = tabState.activeDownloads;
return { return {
theme: selectTheme(global), theme: selectTheme(global),

View File

@ -1,17 +1,12 @@
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import { memo, useEffect } from '../../lib/teact/teact'; import { memo, useEffect } from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
import type { ApiMessage } from '../../api/types'; import type { TabState } from '../../global/types';
import type { GlobalState, TabState } from '../../global/types';
import { ApiMediaFormat } from '../../api/types'; import { ApiMediaFormat } from '../../api/types';
import {
getMessageContentFilename, getMessageMediaFormat, getMessageMediaHash,
} from '../../global/helpers';
import { selectTabState } from '../../global/selectors'; import { selectTabState } from '../../global/selectors';
import download from '../../util/download'; import download from '../../util/download';
import { compact } from '../../util/iteratees';
import * as mediaLoader from '../../util/mediaLoader'; import * as mediaLoader from '../../util/mediaLoader';
import { IS_OPFS_SUPPORTED, IS_SERVICE_WORKER_SUPPORTED, MAX_BUFFER_SIZE } from '../../util/windowEnvironment'; import { IS_OPFS_SUPPORTED, IS_SERVICE_WORKER_SUPPORTED, MAX_BUFFER_SIZE } from '../../util/windowEnvironment';
@ -19,85 +14,64 @@ import useLastCallback from '../../hooks/useLastCallback';
import useRunDebounced from '../../hooks/useRunDebounced'; import useRunDebounced from '../../hooks/useRunDebounced';
type StateProps = { type StateProps = {
activeDownloads: TabState['activeDownloads']['byChatId']; activeDownloads: TabState['activeDownloads'];
messages?: GlobalState['messages']['byChatId'];
}; };
const GLOBAL_UPDATE_DEBOUNCE = 1000; const GLOBAL_UPDATE_DEBOUNCE = 1000;
const processedMessages = new Set<ApiMessage>(); const processedHashes = new Set<string>();
const downloadedMessages = new Set<ApiMessage>(); const downloadedHashes = new Set<string>();
const DownloadManager: FC<StateProps> = ({ const DownloadManager: FC<StateProps> = ({
activeDownloads, activeDownloads,
}) => { }) => {
const { cancelMessagesMediaDownload, showNotification } = getActions(); const { cancelMediaHashDownloads, showNotification } = getActions();
const runDebounced = useRunDebounced(GLOBAL_UPDATE_DEBOUNCE, true); const runDebounced = useRunDebounced(GLOBAL_UPDATE_DEBOUNCE, true);
const handleMessageDownloaded = useLastCallback((message: ApiMessage) => { const handleMediaDownloaded = useLastCallback((hash: string) => {
downloadedMessages.add(message); downloadedHashes.add(hash);
runDebounced(() => { runDebounced(() => {
if (downloadedMessages.size) { if (downloadedHashes.size) {
cancelMessagesMediaDownload({ messages: Array.from(downloadedMessages) }); cancelMediaHashDownloads({ mediaHashes: Array.from(downloadedHashes) });
downloadedMessages.clear(); downloadedHashes.clear();
} }
}); });
}); });
useEffect(() => { useEffect(() => {
// No need for expensive global updates on messages, so we avoid them if (!Object.keys(activeDownloads).length) {
const messages = getGlobal().messages.byChatId; processedHashes.clear();
const scheduledMessages = getGlobal().scheduledMessages.byChatId;
const activeMessages = Object.entries(activeDownloads).map(([chatId, chatActiveDownloads]) => {
const chatMessages = chatActiveDownloads.ids?.map((id) => messages[chatId]?.byId[id]);
const chatScheduledMessages = chatActiveDownloads.scheduledIds?.map((id) => scheduledMessages[chatId]?.byId[id]);
return compact([...chatMessages || [], ...chatScheduledMessages || []]);
}).flat();
if (!activeMessages.length) {
processedMessages.clear();
return; return;
} }
activeMessages.forEach((message) => { Object.entries(activeDownloads).forEach(([mediaHash, metadata]) => {
if (processedMessages.has(message)) { if (processedHashes.has(mediaHash)) {
return;
}
processedMessages.add(message);
const downloadHash = getMessageMediaHash(message, 'download');
if (!downloadHash) {
handleMessageDownloaded(message);
return; return;
} }
processedHashes.add(mediaHash);
const mediaData = mediaLoader.getFromMemory(downloadHash); const { size, filename, format: mediaFormat } = metadata;
const mediaData = mediaLoader.getFromMemory(mediaHash);
if (mediaData) { if (mediaData) {
download(mediaData, getMessageContentFilename(message)); download(mediaData, filename);
handleMessageDownloaded(message); handleMediaDownloaded(mediaHash);
return; return;
} }
const { if (size > MAX_BUFFER_SIZE && !IS_OPFS_SUPPORTED && !IS_SERVICE_WORKER_SUPPORTED) {
document, video, audio,
} = message.content;
const mediaSize = (document || video || audio)?.size || 0;
if (mediaSize > MAX_BUFFER_SIZE && !IS_OPFS_SUPPORTED && !IS_SERVICE_WORKER_SUPPORTED) {
showNotification({ showNotification({
message: 'Downloading files bigger than 2GB is not supported in your browser.', message: 'Downloading files bigger than 2GB is not supported in your browser.',
}); });
handleMessageDownloaded(message); handleMediaDownloaded(mediaHash);
return; return;
} }
const mediaFormat = getMessageMediaFormat(message, 'download'); mediaLoader.fetch(mediaHash, mediaFormat, true).then((result) => {
mediaLoader.fetch(downloadHash, mediaFormat, true).then((result) => {
if (mediaFormat === ApiMediaFormat.DownloadUrl) { if (mediaFormat === ApiMediaFormat.DownloadUrl) {
const url = new URL(result, window.document.baseURI); const url = new URL(result, window.document.baseURI);
const filename = getMessageContentFilename(message);
url.searchParams.set('filename', encodeURIComponent(filename)); url.searchParams.set('filename', encodeURIComponent(filename));
const downloadWindow = window.open(url.toString()); const downloadWindow = window.open(url.toString());
downloadWindow?.addEventListener('beforeunload', () => { downloadWindow?.addEventListener('beforeunload', () => {
@ -106,20 +80,20 @@ const DownloadManager: FC<StateProps> = ({
}); });
}); });
} else if (result) { } else if (result) {
download(result, getMessageContentFilename(message)); download(result, filename);
} }
handleMessageDownloaded(message); handleMediaDownloaded(mediaHash);
}); });
}); });
}, [activeDownloads, cancelMessagesMediaDownload, handleMessageDownloaded, showNotification]); }, [activeDownloads]);
return undefined; return undefined;
}; };
export default memo(withGlobal( export default memo(withGlobal(
(global): StateProps => { (global): StateProps => {
const activeDownloads = selectTabState(global).activeDownloads.byChatId; const activeDownloads = selectTabState(global).activeDownloads;
return { return {
activeDownloads, activeDownloads,

View File

@ -46,6 +46,7 @@ export const PREMIUM_FEATURE_TITLES: Record<ApiPremiumSection, string> = {
saved_tags: 'PremiumPreviewTags2', saved_tags: 'PremiumPreviewTags2',
last_seen: 'PremiumPreviewLastSeen', last_seen: 'PremiumPreviewLastSeen',
message_privacy: 'PremiumPreviewMessagePrivacy', message_privacy: 'PremiumPreviewMessagePrivacy',
effects: 'PremiumPreviewEffects',
}; };
export const PREMIUM_FEATURE_DESCRIPTIONS: Record<ApiPremiumSection, string> = { export const PREMIUM_FEATURE_DESCRIPTIONS: Record<ApiPremiumSection, string> = {
@ -66,6 +67,7 @@ export const PREMIUM_FEATURE_DESCRIPTIONS: Record<ApiPremiumSection, string> = {
saved_tags: 'PremiumPreviewTagsDescription2', saved_tags: 'PremiumPreviewTagsDescription2',
last_seen: 'PremiumPreviewLastSeenDescription', last_seen: 'PremiumPreviewLastSeenDescription',
message_privacy: 'PremiumPreviewMessagePrivacyDescription', message_privacy: 'PremiumPreviewMessagePrivacyDescription',
effects: 'PremiumPreviewEffectsDescription',
}; };
const LIMITS_TITLES: Record<ApiLimitTypeForPromo, string> = { const LIMITS_TITLES: Record<ApiLimitTypeForPromo, string> = {

View File

@ -42,6 +42,7 @@ import styles from './PremiumMainModal.module.scss';
import PremiumAds from '../../../assets/premium/PremiumAds.svg'; import PremiumAds from '../../../assets/premium/PremiumAds.svg';
import PremiumBadge from '../../../assets/premium/PremiumBadge.svg'; import PremiumBadge from '../../../assets/premium/PremiumBadge.svg';
import PremiumChats from '../../../assets/premium/PremiumChats.svg'; import PremiumChats from '../../../assets/premium/PremiumChats.svg';
import PremiumEffects from '../../../assets/premium/PremiumEffects.svg';
import PremiumEmoji from '../../../assets/premium/PremiumEmoji.svg'; import PremiumEmoji from '../../../assets/premium/PremiumEmoji.svg';
import PremiumFile from '../../../assets/premium/PremiumFile.svg'; import PremiumFile from '../../../assets/premium/PremiumFile.svg';
import PremiumLastSeen from '../../../assets/premium/PremiumLastSeen.svg'; import PremiumLastSeen from '../../../assets/premium/PremiumLastSeen.svg';
@ -78,6 +79,7 @@ const PREMIUM_FEATURE_COLOR_ICONS: Record<ApiPremiumSection, string> = {
saved_tags: PremiumTags, saved_tags: PremiumTags,
last_seen: PremiumLastSeen, last_seen: PremiumLastSeen,
message_privacy: PremiumMessagePrivacy, message_privacy: PremiumMessagePrivacy,
effects: PremiumEffects,
}; };
export type OwnProps = { export type OwnProps = {

View File

@ -1,18 +1,19 @@
import type { FC } from '../../lib/teact/teact';
import React, { import React, {
memo, useEffect, useMemo, useRef, memo, useEffect, useMemo, useRef,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
import type { import type {
ApiMessage, ApiPeer, ApiPhoto, ApiUser, ApiChat,
ApiMessage, ApiPeer, ApiPhoto,
} from '../../api/types'; } from '../../api/types';
import { MediaViewerOrigin, type ThreadId } from '../../types'; import { type MediaViewerMedia, MediaViewerOrigin, type ThreadId } from '../../types';
import { ANIMATION_END_DELAY } from '../../config'; import { ANIMATION_END_DELAY } from '../../config';
import { getChatMediaMessageIds, isChatAdmin, isUserId } from '../../global/helpers';
import { import {
selectChat, getChatMediaMessageIds, getMessagePaidMedia, isChatAdmin, isUserId,
} from '../../global/helpers';
import {
selectChatMessage, selectChatMessage,
selectChatMessages, selectChatMessages,
selectChatScheduledMessages, selectChatScheduledMessages,
@ -21,17 +22,17 @@ import {
selectIsChatWithSelf, selectIsChatWithSelf,
selectListedIds, selectListedIds,
selectOutlyingListByMessageId, selectOutlyingListByMessageId,
selectPeer,
selectPerformanceSettingsValue, selectPerformanceSettingsValue,
selectScheduledMessage, selectScheduledMessage,
selectTabState, selectTabState,
selectUser,
selectUserFullInfo,
} from '../../global/selectors'; } from '../../global/selectors';
import { stopCurrentAudio } from '../../util/audioPlayer'; import { stopCurrentAudio } from '../../util/audioPlayer';
import captureEscKeyListener from '../../util/captureEscKeyListener'; import captureEscKeyListener from '../../util/captureEscKeyListener';
import { disableDirectTextInput, enableDirectTextInput } from '../../util/directInputManager'; import { disableDirectTextInput, enableDirectTextInput } from '../../util/directInputManager';
import { MEDIA_VIEWER_MEDIA_QUERY } from '../common/helpers/mediaDimensions'; import { MEDIA_VIEWER_MEDIA_QUERY } from '../common/helpers/mediaDimensions';
import { renderMessageText } from '../common/helpers/renderMessageText'; import { renderMessageText } from '../common/helpers/renderMessageText';
import getViewableMedia, { getMediaViewerItem, type MediaViewerItem } from './helpers/getViewableMedia';
import { animateClosing, animateOpening } from './helpers/ghostAnimation'; import { animateClosing, animateOpening } from './helpers/ghostAnimation';
import useAppLayout from '../../hooks/useAppLayout'; import useAppLayout from '../../hooks/useAppLayout';
@ -44,7 +45,6 @@ import useOldLang from '../../hooks/useOldLang';
import { exitPictureInPictureIfNeeded, usePictureInPictureSignal } from '../../hooks/usePictureInPicture'; import { exitPictureInPictureIfNeeded, usePictureInPictureSignal } from '../../hooks/usePictureInPicture';
import usePrevious from '../../hooks/usePrevious'; import usePrevious from '../../hooks/usePrevious';
import { dispatchPriorityPlaybackEvent } from '../../hooks/usePriorityPlaybackCheck'; import { dispatchPriorityPlaybackEvent } from '../../hooks/usePriorityPlaybackCheck';
import { useStateRef } from '../../hooks/useStateRef';
import { useMediaProps } from './hooks/useMediaProps'; import { useMediaProps } from './hooks/useMediaProps';
import ReportModal from '../common/ReportModal'; import ReportModal from '../common/ReportModal';
@ -60,16 +60,17 @@ import './MediaViewer.scss';
type StateProps = { type StateProps = {
chatId?: string; chatId?: string;
threadId?: ThreadId; threadId?: ThreadId;
mediaId?: number; messageId?: number;
senderId?: string; message?: ApiMessage;
collectedMessageIds?: number[];
isChatWithSelf?: boolean; isChatWithSelf?: boolean;
canUpdateMedia?: boolean; canUpdateMedia?: boolean;
origin?: MediaViewerOrigin; origin?: MediaViewerOrigin;
avatar?: ApiPhoto;
avatarOwner?: ApiPeer; avatarOwner?: ApiPeer;
avatarOwnerFallbackPhoto?: ApiPhoto;
message?: ApiMessage;
chatMessages?: Record<number, ApiMessage>; chatMessages?: Record<number, ApiMessage>;
collectionIds?: number[]; standaloneMedia?: MediaViewerMedia[];
mediaIndex?: number;
isHidden?: boolean; isHidden?: boolean;
withAnimation?: boolean; withAnimation?: boolean;
shouldSkipHistoryAnimations?: boolean; shouldSkipHistoryAnimations?: boolean;
@ -80,26 +81,27 @@ type StateProps = {
const ANIMATION_DURATION = 250; const ANIMATION_DURATION = 250;
const MediaViewer: FC<StateProps> = ({ const MediaViewer = ({
chatId, chatId,
threadId, threadId,
mediaId, messageId,
senderId, message,
collectedMessageIds,
isChatWithSelf, isChatWithSelf,
canUpdateMedia, canUpdateMedia,
origin, origin,
avatar,
avatarOwner, avatarOwner,
avatarOwnerFallbackPhoto,
message,
chatMessages, chatMessages,
collectionIds, standaloneMedia,
mediaIndex,
withAnimation, withAnimation,
isHidden, isHidden,
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
withDynamicLoading, withDynamicLoading,
isLoadingMoreMedia, isLoadingMoreMedia,
isSynced, isSynced,
}) => { }: StateProps) => {
const { const {
openMediaViewer, openMediaViewer,
closeMediaViewer, closeMediaViewer,
@ -109,11 +111,12 @@ const MediaViewer: FC<StateProps> = ({
searchChatMediaMessages, searchChatMediaMessages,
} = getActions(); } = getActions();
const isOpen = Boolean(avatarOwner || mediaId); const isOpen = Boolean(avatarOwner || message || standaloneMedia);
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
/* Animation */ /* Animation */
const animationKey = useRef<number>(); const animationKey = useRef<number>();
const senderId = message?.senderId || avatarOwner?.id;
const prevSenderId = usePrevious<string | undefined>(senderId); const prevSenderId = usePrevious<string | undefined>(senderId);
const headerAnimation = withAnimation ? 'slideFade' : 'none'; const headerAnimation = withAnimation ? 'slideFade' : 'none';
const isGhostAnimation = Boolean(withAnimation && !shouldSkipHistoryAnimations); const isGhostAnimation = Boolean(withAnimation && !shouldSkipHistoryAnimations);
@ -121,40 +124,34 @@ const MediaViewer: FC<StateProps> = ({
/* Controls */ /* Controls */
const [isReportModalOpen, openReportModal, closeReportModal] = useFlag(); const [isReportModalOpen, openReportModal, closeReportModal] = useFlag();
const currentItem = getMediaViewerItem({
message, avatarOwner, standaloneMedia, mediaIndex,
});
const { media, isSingle } = getViewableMedia(currentItem) || {};
const { const {
webPagePhoto,
webPageVideo,
isVideo, isVideo,
actionPhoto,
isPhoto, isPhoto,
bestImageData, bestImageData,
bestData, bestData,
dimensions, dimensions,
isGif, isGif,
isFromSharedMedia, isFromSharedMedia,
avatarPhoto,
fileName,
} = useMediaProps({ } = useMediaProps({
message, avatarOwner, mediaId, origin, delay: isGhostAnimation && ANIMATION_DURATION, media, isAvatar: Boolean(avatarOwner), origin, delay: isGhostAnimation && ANIMATION_DURATION,
}); });
const canReport = !!avatarPhoto && !isChatWithSelf; const canReport = avatarOwner && !isChatWithSelf;
const isVisible = !isHidden && isOpen; const isVisible = !isHidden && isOpen;
/* Navigation */ const messageMediaIds = useMemo(() => {
const singleMediaId = webPagePhoto || webPageVideo || actionPhoto || isGif ? mediaId : undefined; return withDynamicLoading
? collectedMessageIds
: getChatMediaMessageIds(chatMessages || {}, collectedMessageIds || [], isFromSharedMedia);
}, [chatMessages, collectedMessageIds, isFromSharedMedia, withDynamicLoading]);
const mediaIds = useMemo(() => { if (isOpen && (!prevSenderId || prevSenderId !== senderId || animationKey.current === undefined)) {
if (singleMediaId) return [singleMediaId]; animationKey.current = isSingle ? 0 : (messageId || mediaIndex);
if (avatarOwner) return avatarOwner.photos?.map((p, i) => i) || [];
if (withDynamicLoading) return collectionIds || [];
return getChatMediaMessageIds(chatMessages || {}, collectionIds || [], isFromSharedMedia);
}, [singleMediaId, avatarOwner, chatMessages, collectionIds, isFromSharedMedia, withDynamicLoading]);
const selectedMediaIndex = mediaId ? mediaIds.indexOf(mediaId) : -1;
if (isOpen && (!prevSenderId || prevSenderId !== senderId || !animationKey.current)) {
animationKey.current = selectedMediaIndex;
} }
const [getIsPictureInPicture] = usePictureInPictureSignal(); const [getIsPictureInPicture] = usePictureInPictureSignal();
@ -202,65 +199,48 @@ const MediaViewer: FC<StateProps> = ({
const prevMessage = usePrevious<ApiMessage | undefined>(message); const prevMessage = usePrevious<ApiMessage | undefined>(message);
const prevIsHidden = usePrevious<boolean | undefined>(isHidden); const prevIsHidden = usePrevious<boolean | undefined>(isHidden);
const prevOrigin = usePrevious(origin); const prevOrigin = usePrevious(origin);
const prevMediaId = usePrevious(mediaId); const prevItem = usePrevious(currentItem);
const prevAvatarOwner = usePrevious<ApiPeer | undefined>(avatarOwner);
const prevBestImageData = usePrevious(bestImageData); const prevBestImageData = usePrevious(bestImageData);
const textParts = message ? renderMessageText({ message, forcePlayback: true, isForMediaViewer: true }) : undefined; const textParts = message ? renderMessageText({ message, forcePlayback: true, isForMediaViewer: true }) : undefined;
const hasFooter = Boolean(textParts); const hasFooter = Boolean(textParts);
const shouldAnimateOpening = prevIsHidden && prevMediaId !== mediaId; const shouldAnimateOpening = prevIsHidden && prevItem !== currentItem;
useEffect(() => { useEffect(() => {
if (isGhostAnimation && isOpen && (!prevMessage || shouldAnimateOpening) && !prevAvatarOwner) { if (isGhostAnimation && isOpen && (shouldAnimateOpening || !prevItem)) {
dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY); dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY);
animateOpening(hasFooter, origin!, bestImageData!, dimensions!, isVideo, message); animateOpening(hasFooter, origin!, bestImageData!, dimensions!, isVideo, message, mediaIndex);
} }
if (isGhostAnimation && !isOpen && (prevMessage || prevAvatarOwner)) { if (isGhostAnimation && !isOpen && prevItem) {
dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY); dispatchHeavyAnimationEvent(ANIMATION_DURATION + ANIMATION_END_DELAY);
animateClosing(prevOrigin!, prevBestImageData!, prevMessage || undefined); animateClosing(prevOrigin!, prevBestImageData!, prevMessage, prevItem?.mediaIndex);
} }
}, [ }, [
isGhostAnimation, isOpen, shouldAnimateOpening, origin, prevOrigin, message, prevMessage, prevAvatarOwner, bestImageData, dimensions, hasFooter, isGhostAnimation, isOpen, isVideo, message, origin,
bestImageData, prevBestImageData, dimensions, isVideo, hasFooter, prevBestImageData, prevItem, prevMessage, prevOrigin, shouldAnimateOpening, mediaIndex,
]); ]);
const handleClose = useLastCallback(() => closeMediaViewer()); const handleClose = useLastCallback(() => closeMediaViewer());
const mediaIdRef = useStateRef(mediaId);
const handleFooterClick = useLastCallback(() => { const handleFooterClick = useLastCallback(() => {
handleClose(); handleClose();
const currentMediaId = mediaIdRef.current; if (!chatId || !messageId) return;
if (!chatId || !currentMediaId) return;
if (isMobile) { if (isMobile) {
setTimeout(() => { setTimeout(() => {
toggleChatInfo({ force: false }, { forceSyncOnIOs: true }); toggleChatInfo({ force: false }, { forceSyncOnIOs: true });
focusMessage({ chatId, threadId, messageId: currentMediaId }); focusMessage({ chatId, threadId, messageId });
}, ANIMATION_DURATION); }, ANIMATION_DURATION);
} else { } else {
focusMessage({ chatId, threadId, messageId: currentMediaId }); focusMessage({ chatId, threadId, messageId });
} }
}); });
const handleForward = useLastCallback(() => { const handleForward = useLastCallback(() => {
openForwardMenu({ openForwardMenu({
fromChatId: chatId!, fromChatId: chatId!,
messageIds: [mediaId!], messageIds: [messageId!],
});
});
const selectMedia = useLastCallback((id?: number) => {
openMediaViewer({
chatId,
threadId,
mediaId: id,
avatarOwnerId: avatarOwner?.id,
origin: origin!,
withDynamicLoading,
}, {
forceOnHeavyAnimation: true,
}); });
}); });
@ -274,56 +254,106 @@ const MediaViewer: FC<StateProps> = ({
} }
}, [isGif, isVideo]); }, [isGif, isVideo]);
const mediaIdsRef = useStateRef(mediaIds); const loadMoreItemsIfNeeded = useLastCallback((item?: MediaViewerItem) => {
if (!item || !withDynamicLoading || isLoadingMoreMedia) return;
const loadMoreMediaIfNeeded = useLastCallback((activeMediaId?: number) => { if (item.type !== 'message') return;
if (!activeMediaId || !withDynamicLoading || isLoadingMoreMedia) return; searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: item.message.id });
searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: activeMediaId });
}); });
const getMediaId = useLastCallback((fromId?: number, direction?: number): number | undefined => { const getNextItem = useLastCallback((from: MediaViewerItem, direction: number): MediaViewerItem | undefined => {
if (fromId === undefined) return undefined; if (direction === 0 || isSingle) return undefined;
const mIds = mediaIdsRef.current;
const index = mIds.indexOf(fromId); if (from.type === 'standalone') {
if ((direction === -1 && index > 0) || (direction === 1 && index < mIds.length - 1)) { const { media: fromMedia, mediaIndex: fromMediaIndex } = from;
return mIds[index + direction]; const nextIndex = fromMediaIndex + direction;
if (nextIndex >= 0 && nextIndex < fromMedia.length) {
return { type: 'standalone', media: fromMedia, mediaIndex: nextIndex };
}
return undefined;
} }
// Fallback
if (isVisible) loadMoreMediaIfNeeded(fromId); if (from.type === 'avatar') {
const { avatarOwner: fromAvatarOwner, mediaIndex: fromMediaIndex } = from;
const nextIndex = fromMediaIndex + direction;
if (nextIndex >= 0 && fromAvatarOwner.photos && nextIndex < fromAvatarOwner.photos.length) {
return { type: 'avatar', avatarOwner: fromAvatarOwner, mediaIndex: nextIndex };
}
return undefined;
}
const { message: fromMessage, mediaIndex: fromMediaIndex } = from;
const paidMedia = getMessagePaidMedia(fromMessage);
if (paidMedia) {
const nextIndex = fromMediaIndex! + direction;
if (nextIndex >= 0 && nextIndex < paidMedia.extendedMedia.length) {
return { type: 'message', message: fromMessage, mediaIndex: nextIndex };
}
}
const index = messageMediaIds?.indexOf(fromMessage.id);
if (index === undefined) return undefined;
const nextIndex = index + direction;
const nextMessageId = messageMediaIds![nextIndex];
const nextMessage = chatMessages?.[nextMessageId];
if (nextMessage) {
return { type: 'message', message: nextMessage };
}
return undefined; return undefined;
}); });
const handleBeforeDelete = useLastCallback(() => { const openMediaViewerItem = useLastCallback((item?: MediaViewerItem) => {
if (mediaIds.length <= 1) { if (!item) {
handleClose(); handleClose();
return; return;
} }
let index = mediaId ? mediaIds.indexOf(mediaId) : -1;
// Before deleting, select previous media or the first one const itemChatId = item.type === 'avatar'
index = index > 0 ? index - 1 : 0; ? item.avatarOwner.id : item.type === 'message'
selectMedia(mediaIds[index]); ? item.message.chatId : undefined;
const itemMessageId = item.type === 'message' ? item.message.id : undefined;
const itemStandaloneMedia = item.type === 'standalone' ? item.media : undefined;
openMediaViewer({
origin: origin!,
chatId: itemChatId,
messageId: itemMessageId,
standaloneMedia: itemStandaloneMedia,
mediaIndex: item.mediaIndex,
isAvatarView: item.type === 'avatar',
withDynamicLoading,
}, {
forceOnHeavyAnimation: true,
});
});
const handleBeforeDelete = useLastCallback(() => {
const mediaCount = avatarOwner?.photos?.length || standaloneMedia?.length || messageMediaIds?.length || 0;
if (mediaCount <= 1 || !currentItem) {
handleClose();
return;
}
// Before deleting, select previous media
const prevMedia = getNextItem(currentItem, -1);
if (prevMedia) {
openMediaViewerItem(prevMedia);
return;
}
if (currentItem.type === 'avatar' || currentItem.type === 'standalone') {
// Keep current item, it'll update when indexes shift
return;
}
handleClose();
}); });
const lang = useOldLang(); const lang = useOldLang();
function renderSenderInfo() {
return avatarOwner ? (
<SenderInfo
key={mediaId}
chatId={avatarOwner.id}
isAvatar
isFallbackAvatar={isUserId(avatarOwner.id)
&& (avatarOwner as ApiUser).photos?.[mediaId!]?.id === avatarOwnerFallbackPhoto?.id}
/>
) : (
<SenderInfo
key={mediaId}
chatId={chatId}
messageId={mediaId}
/>
);
}
return ( return (
<ShowTransition <ShowTransition
id="MediaViewer" id="MediaViewer"
@ -346,18 +376,17 @@ const MediaViewer: FC<StateProps> = ({
</Button> </Button>
)} )}
<Transition activeKey={animationKey.current!} name={headerAnimation}> <Transition activeKey={animationKey.current!} name={headerAnimation}>
{renderSenderInfo()} <SenderInfo
key={media?.id}
item={currentItem}
/>
</Transition> </Transition>
<MediaViewerActions <MediaViewerActions
mediaData={bestData} mediaData={bestData}
isVideo={isVideo} isVideo={isVideo}
message={message} item={currentItem}
canUpdateMedia={canUpdateMedia} canUpdateMedia={canUpdateMedia}
avatarPhoto={avatarPhoto}
avatarOwner={avatarOwner}
fileName={fileName}
canReport={canReport} canReport={canReport}
selectMedia={selectMedia}
onBeforeDelete={handleBeforeDelete} onBeforeDelete={handleBeforeDelete}
onReport={openReportModal} onReport={openReportModal}
onCloseMediaViewer={handleClose} onCloseMediaViewer={handleClose}
@ -367,16 +396,16 @@ const MediaViewer: FC<StateProps> = ({
isOpen={isReportModalOpen} isOpen={isReportModalOpen}
onClose={closeReportModal} onClose={closeReportModal}
subject="media" subject="media"
photo={avatarPhoto} photo={avatar}
peerId={avatarOwner?.id} peerId={avatarOwner?.id}
/> />
</div> </div>
<MediaViewerSlides <MediaViewerSlides
mediaId={mediaId} item={currentItem}
loadMoreMediaIfNeeded={loadMoreMediaIfNeeded} loadMoreItemsIfNeeded={loadMoreItemsIfNeeded}
isLoadingMoreMedia={isLoadingMoreMedia} isLoadingMoreMedia={isLoadingMoreMedia}
isSynced={isSynced} isSynced={isSynced}
getMediaId={getMediaId} getNextItem={getNextItem}
chatId={chatId} chatId={chatId}
isPhoto={isPhoto} isPhoto={isPhoto}
isGif={isGif} isGif={isGif}
@ -388,7 +417,7 @@ const MediaViewer: FC<StateProps> = ({
isVideo={isVideo} isVideo={isVideo}
withAnimation={withAnimation} withAnimation={withAnimation}
onClose={handleClose} onClose={handleClose}
selectMedia={selectMedia} selectItem={openMediaViewerItem}
isHidden={isHidden} isHidden={isHidden}
onFooterClick={handleFooterClick} onFooterClick={handleFooterClick}
/> />
@ -402,122 +431,95 @@ export default memo(withGlobal(
const { const {
chatId, chatId,
threadId, threadId,
mediaId, messageId,
avatarOwnerId,
origin, origin,
isHidden, isHidden,
withDynamicLoading, withDynamicLoading,
standaloneMedia,
mediaIndex,
isAvatarView,
} = mediaViewer; } = mediaViewer;
const withAnimation = selectPerformanceSettingsValue(global, 'mediaViewerAnimations'); const withAnimation = selectPerformanceSettingsValue(global, 'mediaViewerAnimations');
const { currentUserId, isSynced } = global; const { currentUserId, isSynced } = global;
let isChatWithSelf = !!chatId && selectIsChatWithSelf(global, chatId); const isChatWithSelf = Boolean(chatId) && selectIsChatWithSelf(global, chatId);
if (origin === MediaViewerOrigin.SearchResult) { if (isAvatarView) {
if (!(chatId && mediaId)) { const peer = selectPeer(global, chatId!);
return { withAnimation, shouldSkipHistoryAnimations };
}
const message = selectChatMessage(global, chatId, mediaId);
if (!message) {
return { withAnimation, shouldSkipHistoryAnimations };
}
return {
chatId,
mediaId,
senderId: message.senderId,
isChatWithSelf,
origin,
message,
withAnimation,
isHidden,
shouldSkipHistoryAnimations,
};
}
if (avatarOwnerId) {
const user = selectUser(global, avatarOwnerId);
const chat = selectChat(global, avatarOwnerId);
let canUpdateMedia = false; let canUpdateMedia = false;
if (user) { if (peer) {
canUpdateMedia = avatarOwnerId === currentUserId; canUpdateMedia = isUserId(peer.id) ? peer.id === currentUserId : isChatAdmin(peer as ApiChat);
} else if (chat) {
canUpdateMedia = isChatAdmin(chat);
} }
isChatWithSelf = selectIsChatWithSelf(global, avatarOwnerId);
return { return {
mediaId, avatar: peer?.photos?.[mediaIndex!],
senderId: avatarOwnerId, avatarOwner: peer,
avatarOwner: user || chat,
avatarOwnerFallbackPhoto: user ? selectUserFullInfo(global, avatarOwnerId)?.fallbackPhoto : undefined,
isChatWithSelf, isChatWithSelf,
canUpdateMedia, canUpdateMedia,
withAnimation, withAnimation,
origin, origin,
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
isHidden, isHidden,
standaloneMedia,
mediaIndex,
}; };
} }
if (!(chatId && threadId && mediaId)) {
return { withAnimation, shouldSkipHistoryAnimations };
}
let message: ApiMessage | undefined; let message: ApiMessage | undefined;
if (origin && [MediaViewerOrigin.ScheduledAlbum, MediaViewerOrigin.ScheduledInline].includes(origin)) { if (chatId && messageId) {
message = selectScheduledMessage(global, chatId, mediaId); if (origin && [MediaViewerOrigin.ScheduledAlbum, MediaViewerOrigin.ScheduledInline].includes(origin)) {
} else { message = selectScheduledMessage(global, chatId, messageId);
message = selectChatMessage(global, chatId, mediaId); } else {
} message = selectChatMessage(global, chatId, messageId);
}
if (!message) {
return { withAnimation, shouldSkipHistoryAnimations };
} }
let chatMessages: Record<number, ApiMessage> | undefined; let chatMessages: Record<number, ApiMessage> | undefined;
if (origin && [MediaViewerOrigin.ScheduledAlbum, MediaViewerOrigin.ScheduledInline].includes(origin)) { if (chatId) {
chatMessages = selectChatScheduledMessages(global, chatId); if (origin && [MediaViewerOrigin.ScheduledAlbum, MediaViewerOrigin.ScheduledInline].includes(origin)) {
} else { chatMessages = selectChatScheduledMessages(global, chatId);
chatMessages = selectChatMessages(global, chatId); } else {
chatMessages = selectChatMessages(global, chatId);
}
} }
let isLoadingMoreMedia = false; let isLoadingMoreMedia = false;
const isOriginInline = origin === MediaViewerOrigin.Inline; const isOriginInline = origin === MediaViewerOrigin.Inline;
const isOriginAlbum = origin === MediaViewerOrigin.Album; const isOriginAlbum = origin === MediaViewerOrigin.Album;
let collectionIds: number[] | undefined; let collectedMessageIds: number[] | undefined;
if (withDynamicLoading && (isOriginInline || isOriginAlbum)) { if (chatId && threadId && messageId) {
const currentSearch = selectCurrentChatMediaSearch(global); if (withDynamicLoading && (isOriginInline || isOriginAlbum)) {
isLoadingMoreMedia = Boolean(currentSearch?.isLoading); const currentSearch = selectCurrentChatMediaSearch(global);
const { foundIds } = (currentSearch?.currentSegment) || {}; isLoadingMoreMedia = Boolean(currentSearch?.isLoading);
collectionIds = foundIds; const { foundIds } = (currentSearch?.currentSegment) || {};
} else if (origin === MediaViewerOrigin.SharedMedia) { collectedMessageIds = foundIds;
const currentSearch = selectCurrentSharedMediaSearch(global); } else if (origin === MediaViewerOrigin.SharedMedia) {
const { foundIds } = (currentSearch && currentSearch.resultsByType && currentSearch.resultsByType.media) || {}; const currentSearch = selectCurrentSharedMediaSearch(global);
collectionIds = foundIds; const { foundIds } = (currentSearch && currentSearch.resultsByType && currentSearch.resultsByType.media) || {};
} else if (isOriginInline || isOriginAlbum) { collectedMessageIds = foundIds;
const outlyingList = selectOutlyingListByMessageId(global, chatId, threadId, message.id); } else if (isOriginInline || isOriginAlbum) {
collectionIds = outlyingList || selectListedIds(global, chatId, threadId); const outlyingList = selectOutlyingListByMessageId(global, chatId, threadId, messageId);
collectedMessageIds = outlyingList || selectListedIds(global, chatId, threadId);
}
} }
return { return {
chatId, chatId,
threadId, threadId,
mediaId, messageId,
senderId: message.senderId,
isChatWithSelf, isChatWithSelf,
origin, origin,
message, message,
chatMessages, chatMessages,
collectionIds, collectedMessageIds,
withAnimation, withAnimation,
isHidden, isHidden,
shouldSkipHistoryAnimations, shouldSkipHistoryAnimations,
withDynamicLoading, withDynamicLoading,
standaloneMedia,
mediaIndex,
isLoadingMoreMedia, isLoadingMoreMedia,
isSynced, isSynced,
}; };

View File

@ -2,20 +2,27 @@ import type { FC } from '../../lib/teact/teact';
import React, { memo, useMemo } from '../../lib/teact/teact'; import React, { memo, useMemo } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
import type { import type { ActiveDownloads, MessageListType } from '../../global/types';
ApiMessage, ApiPeer, ApiPhoto, import type { MediaViewerOrigin } from '../../types';
} from '../../api/types';
import type { MessageListType } from '../../global/types';
import type { MenuItemProps } from '../ui/MenuItem'; import type { MenuItemProps } from '../ui/MenuItem';
import type { MediaViewerItem } from './helpers/getViewableMedia';
import { getMessageMediaFormat, getMessageMediaHash, isUserId } from '../../global/helpers';
import { import {
getIsDownloading,
getMediaFilename,
getMediaFormat,
getMediaHash,
isUserId,
} from '../../global/helpers';
import {
selectActiveDownloads,
selectAllowedMessageActions, selectAllowedMessageActions,
selectCurrentMessageList, selectCurrentMessageList,
selectIsChatProtected, selectIsChatProtected,
selectIsDownloading,
selectIsMessageProtected, selectIsMessageProtected,
selectTabState,
} from '../../global/selectors'; } from '../../global/selectors';
import getViewableMedia from './helpers/getViewableMedia';
import useAppLayout from '../../hooks/useAppLayout'; import useAppLayout from '../../hooks/useAppLayout';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
@ -34,26 +41,22 @@ import ProgressSpinner from '../ui/ProgressSpinner';
import './MediaViewerActions.scss'; import './MediaViewerActions.scss';
type StateProps = { type StateProps = {
isDownloading?: boolean; activeDownloads: ActiveDownloads;
isProtected?: boolean; isProtected?: boolean;
isChatProtected?: boolean; isChatProtected?: boolean;
canDelete?: boolean; canDelete?: boolean;
canUpdate?: boolean; canUpdate?: boolean;
messageListType?: MessageListType; messageListType?: MessageListType;
avatarOwnerId?: string; origin?: MediaViewerOrigin;
}; };
type OwnProps = { type OwnProps = {
item?: MediaViewerItem;
mediaData?: string; mediaData?: string;
isVideo: boolean; isVideo: boolean;
message?: ApiMessage;
canUpdateMedia?: boolean; canUpdateMedia?: boolean;
isSingleMedia?: boolean;
avatarPhoto?: ApiPhoto;
avatarOwner?: ApiPeer;
fileName?: string;
canReport?: boolean; canReport?: boolean;
selectMedia: (mediaId?: number) => void; activeDownloads?: ActiveDownloads;
onReport: NoneToVoidFunction; onReport: NoneToVoidFunction;
onBeforeDelete: NoneToVoidFunction; onBeforeDelete: NoneToVoidFunction;
onCloseMediaViewer: NoneToVoidFunction; onCloseMediaViewer: NoneToVoidFunction;
@ -61,20 +64,17 @@ type OwnProps = {
}; };
const MediaViewerActions: FC<OwnProps & StateProps> = ({ const MediaViewerActions: FC<OwnProps & StateProps> = ({
item,
mediaData, mediaData,
isVideo, isVideo,
message,
avatarPhoto,
avatarOwnerId,
fileName,
isChatProtected, isChatProtected,
isDownloading,
isProtected, isProtected,
canReport, canReport,
canDelete, canDelete,
canUpdate, canUpdate,
messageListType, messageListType,
selectMedia, activeDownloads,
origin,
onReport, onReport,
onCloseMediaViewer, onCloseMediaViewer,
onBeforeDelete, onBeforeDelete,
@ -85,23 +85,32 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const { const {
downloadMessageMedia, downloadMedia,
cancelMessageMediaDownload, cancelMediaDownload,
updateProfilePhoto, updateProfilePhoto,
updateChatPhoto, updateChatPhoto,
openMediaViewer,
} = getActions(); } = getActions();
const isMessage = item?.type === 'message';
const { media } = getViewableMedia(item) || {};
const fileName = media && getMediaFilename(media);
const isDownloading = media && getIsDownloading(activeDownloads, media);
const { loadProgress: downloadProgress } = useMediaWithLoadProgress( const { loadProgress: downloadProgress } = useMediaWithLoadProgress(
message && getMessageMediaHash(message, 'download'), media && getMediaHash(media, 'download'),
!isDownloading, !isDownloading,
message && getMessageMediaFormat(message, 'download'), media && getMediaFormat(media, 'download'),
); );
const handleDownloadClick = useLastCallback(() => { const handleDownloadClick = useLastCallback(() => {
if (!media) return;
if (isDownloading) { if (isDownloading) {
cancelMessageMediaDownload({ message: message! }); cancelMediaDownload({ media });
} else { } else {
downloadMessageMedia({ message: message! }); downloadMedia({ media });
} }
}); });
@ -118,13 +127,23 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
}); });
const handleUpdate = useLastCallback(() => { const handleUpdate = useLastCallback(() => {
if (!avatarPhoto || !avatarOwnerId) return; if (item?.type !== 'avatar') return;
if (isUserId(avatarOwnerId)) { const { avatarOwner, mediaIndex } = item;
const avatarPhoto = avatarOwner.photos?.[mediaIndex]!;
if (isUserId(avatarOwner.id)) {
updateProfilePhoto({ photo: avatarPhoto }); updateProfilePhoto({ photo: avatarPhoto });
} else { } else {
updateChatPhoto({ chatId: avatarOwnerId, photo: avatarPhoto }); updateChatPhoto({ chatId: avatarOwner.id, photo: avatarPhoto });
} }
selectMedia(0);
openMediaViewer({
origin: origin!,
chatId: avatarOwner.id,
mediaIndex: 0,
isAvatarView: true,
}, {
forceOnHeavyAnimation: true,
});
}); });
const lang = useOldLang(); const lang = useOldLang();
@ -145,29 +164,34 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
}, []); }, []);
function renderDeleteModals() { function renderDeleteModals() {
return message if (item?.type === 'message') {
? ( return (
<DeleteMessageModal <DeleteMessageModal
isOpen={isDeleteModalOpen} isOpen={isDeleteModalOpen}
isSchedule={messageListType === 'scheduled'} isSchedule={messageListType === 'scheduled'}
onClose={closeDeleteModal} onClose={closeDeleteModal}
onConfirm={onBeforeDelete} onConfirm={onBeforeDelete}
message={message} message={item.message}
/> />
) );
: (avatarOwnerId && avatarPhoto) ? ( }
if (item?.type === 'avatar') {
return (
<DeleteProfilePhotoModal <DeleteProfilePhotoModal
isOpen={isDeleteModalOpen} isOpen={isDeleteModalOpen}
onClose={closeDeleteModal} onClose={closeDeleteModal}
onConfirm={onBeforeDelete} onConfirm={onBeforeDelete}
profileId={avatarOwnerId} profileId={item.avatarOwner.id}
photo={avatarPhoto} photo={item.avatarOwner.photos![item.mediaIndex!]}
/> />
) : undefined; );
}
return undefined;
} }
function renderDownloadButton() { function renderDownloadButton() {
if (isProtected) { if (isProtected || item?.type === 'standalone') {
return undefined; return undefined;
} }
@ -201,7 +225,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
if (isMobile) { if (isMobile) {
const menuItems: MenuItemProps[] = []; const menuItems: MenuItemProps[] = [];
if (message?.isForwardingAllowed && !isChatProtected) { if (isMessage && item.message.isForwardingAllowed && !item.message.content.action && !isChatProtected) {
menuItems.push({ menuItems.push({
icon: 'forward', icon: 'forward',
onClick: onForward, onClick: onForward,
@ -283,7 +307,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
return ( return (
<div className="MediaViewerActions"> <div className="MediaViewerActions">
{message?.isForwardingAllowed && !isChatProtected && ( {isMessage && item.message.isForwardingAllowed && !isChatProtected && (
<Button <Button
round round
size="smaller" size="smaller"
@ -362,12 +386,19 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, { (global, {
message, canUpdateMedia, avatarPhoto, avatarOwner, item, canUpdateMedia,
}): StateProps => { }): StateProps => {
const tabState = selectTabState(global);
const { origin } = tabState.mediaViewer;
const message = item?.type === 'message' ? item.message : undefined;
const avatarOwner = item?.type === 'avatar' ? item.avatarOwner : undefined;
const avatarPhoto = avatarOwner?.photos?.[item!.mediaIndex!];
const currentMessageList = selectCurrentMessageList(global); const currentMessageList = selectCurrentMessageList(global);
const { threadId } = selectCurrentMessageList(global) || {}; const { threadId } = selectCurrentMessageList(global) || {};
const isDownloading = message ? selectIsDownloading(global, message) : false;
const isProtected = selectIsMessageProtected(global, message); const isProtected = selectIsMessageProtected(global, message);
const activeDownloads = selectActiveDownloads(global);
const isChatProtected = message && selectIsChatProtected(global, message?.chatId); const isChatProtected = message && selectIsChatProtected(global, message?.chatId);
const { canDelete: canDeleteMessage } = (threadId const { canDelete: canDeleteMessage } = (threadId
&& message && selectAllowedMessageActions(global, message, threadId)) || {}; && message && selectAllowedMessageActions(global, message, threadId)) || {};
@ -378,13 +409,13 @@ export default memo(withGlobal<OwnProps>(
const messageListType = currentMessageList?.type; const messageListType = currentMessageList?.type;
return { return {
isDownloading, activeDownloads,
isProtected, isProtected,
isChatProtected, isChatProtected,
canDelete, canDelete,
canUpdate, canUpdate,
messageListType, messageListType,
avatarOwnerId: avatarOwner?.id, origin,
}; };
}, },
)(MediaViewerActions)); )(MediaViewerActions));

View File

@ -1,20 +1,21 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo } from '../../lib/teact/teact'; import React, { memo } from '../../lib/teact/teact';
import { withGlobal } from '../../global'; import { withGlobal } from '../../global';
import type { import type {
ApiDimensions, ApiMessage, ApiPeer, ApiDimensions, ApiMessage,
} from '../../api/types'; } from '../../api/types';
import { MediaViewerOrigin, type ThreadId } from '../../types'; import type { MediaViewerOrigin } from '../../types';
import type { MediaViewerItem } from './helpers/getViewableMedia';
import { import {
selectChat, selectChatMessage, selectIsMessageProtected, selectScheduledMessage, selectTabState, selectUser, selectIsMessageProtected, 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';
import { ARE_WEBCODECS_SUPPORTED, IS_TOUCH_ENV } from '../../util/windowEnvironment'; import { ARE_WEBCODECS_SUPPORTED, IS_TOUCH_ENV } from '../../util/windowEnvironment';
import { calculateMediaViewerDimensions } from '../common/helpers/mediaDimensions'; import { calculateMediaViewerDimensions } from '../common/helpers/mediaDimensions';
import { renderMessageText } from '../common/helpers/renderMessageText'; import { renderMessageText } from '../common/helpers/renderMessageText';
import getViewableMedia from './helpers/getViewableMedia';
import useAppLayout from '../../hooks/useAppLayout'; import useAppLayout from '../../hooks/useAppLayout';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
@ -29,25 +30,16 @@ import VideoPlayer from './VideoPlayer';
import './MediaViewerContent.scss'; import './MediaViewerContent.scss';
type OwnProps = { type OwnProps = {
mediaId?: number; item: MediaViewerItem;
chatId?: string;
threadId?: ThreadId;
avatarOwnerId?: string;
origin?: MediaViewerOrigin;
isActive?: boolean; isActive?: boolean;
withAnimation?: boolean; withAnimation?: boolean;
isMoving?: boolean;
onClose: () => void; onClose: () => void;
onFooterClick: () => void; onFooterClick: () => void;
isMoving?: boolean;
}; };
type StateProps = { type StateProps = {
chatId?: string; textMessage?: ApiMessage;
mediaId?: number;
senderId?: string;
threadId?: ThreadId;
avatarOwner?: ApiPeer;
message?: ApiMessage;
origin?: MediaViewerOrigin; origin?: MediaViewerOrigin;
isProtected?: boolean; isProtected?: boolean;
volume: number; volume: number;
@ -59,56 +51,56 @@ type StateProps = {
const ANIMATION_DURATION = 350; const ANIMATION_DURATION = 350;
const MOBILE_VERSION_CONTROL_WIDTH = 350; const MOBILE_VERSION_CONTROL_WIDTH = 350;
const MediaViewerContent: FC<OwnProps & StateProps> = (props) => { const MediaViewerContent = ({
const { item,
mediaId, isActive,
isActive, textMessage,
avatarOwner, origin,
chatId, withAnimation,
message, isProtected,
origin, volume,
withAnimation, playbackRate,
isProtected, isMuted,
volume, isHidden,
playbackRate, isMoving,
isMuted, onClose,
isHidden, onFooterClick,
onClose, }: OwnProps & StateProps) => {
onFooterClick,
isMoving,
} = props;
const lang = useOldLang(); const lang = useOldLang();
const isAvatar = item.type === 'avatar';
const { media } = getViewableMedia(item) || {};
const { const {
isVideo, isVideo,
isPhoto, isPhoto,
actionPhoto,
bestImageData, bestImageData,
bestData, bestData,
dimensions, dimensions,
isGif, isGif,
isLocal, isLocal,
isVideoAvatar, isVideoAvatar,
videoSize, mediaSize,
loadProgress, loadProgress,
} = useMediaProps({ } = useMediaProps({
message, avatarOwner, mediaId, origin, delay: withAnimation ? ANIMATION_DURATION : false, media, isAvatar, origin, delay: withAnimation ? ANIMATION_DURATION : false,
}); });
const [, toggleControls] = useControlsSignal(); const [, toggleControls] = useControlsSignal();
const isOpen = Boolean(avatarOwner || mediaId); const isOpen = Boolean(media);
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const toggleControlsOnMove = useLastCallback(() => { const toggleControlsOnMove = useLastCallback(() => {
toggleControls(true); toggleControls(true);
}); });
if (avatarOwner || actionPhoto) { if (!media) return undefined;
if (item.type !== 'message') {
if (!isVideoAvatar) { if (!isVideoAvatar) {
return ( return (
<div key={chatId} className="MediaViewerContent"> <div key={media.id} className="MediaViewerContent">
{renderPhoto( {renderPhoto(
bestData, bestData,
calculateMediaViewerDimensions(dimensions!, false), calculateMediaViewerDimensions(dimensions!, false),
@ -119,15 +111,15 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
); );
} else { } else {
return ( return (
<div key={chatId} className="MediaViewerContent"> <div key={media.id} className="MediaViewerContent">
<VideoPlayer <VideoPlayer
key={mediaId} key={media.id}
url={bestData} url={bestData}
isGif isGif
posterData={bestImageData} posterData={bestImageData}
posterSize={calculateMediaViewerDimensions(dimensions!, false, true)} posterSize={calculateMediaViewerDimensions(dimensions!, false, true)}
loadProgress={loadProgress} loadProgress={loadProgress}
fileSize={videoSize!} fileSize={mediaSize!}
isMediaViewerOpen={isOpen && isActive} isMediaViewerOpen={isOpen && isActive}
isProtected={isProtected} isProtected={isProtected}
isPreviewDisabled={!ARE_WEBCODECS_SUPPORTED || isLocal} isPreviewDisabled={!ARE_WEBCODECS_SUPPORTED || isLocal}
@ -144,13 +136,13 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
} }
} }
if (!message) return undefined; if (!textMessage) return undefined;
const textParts = message.content.action?.type === 'suggestProfilePhoto' const textParts = textMessage.content.action?.type === 'suggestProfilePhoto'
? lang('Conversation.SuggestedPhotoTitle') ? lang('Conversation.SuggestedPhotoTitle')
: renderMessageText({ message, forcePlayback: true, isForMediaViewer: true }); : renderMessageText({ message: textMessage, forcePlayback: true, isForMediaViewer: true });
const hasFooter = Boolean(textParts); const hasFooter = Boolean(textParts);
const posterSize = message && calculateMediaViewerDimensions(dimensions!, hasFooter, isVideo); const posterSize = calculateMediaViewerDimensions(dimensions!, hasFooter, isVideo);
const isForceMobileVersion = isMobile || shouldForceMobileVersion(posterSize); const isForceMobileVersion = isMobile || shouldForceMobileVersion(posterSize);
return ( return (
@ -171,13 +163,13 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
isProtected, isProtected,
) : ( ) : (
<VideoPlayer <VideoPlayer
key={mediaId} key={media.id}
url={bestData} url={bestData}
isGif={isGif} isGif={isGif}
posterData={bestImageData} posterData={bestImageData}
posterSize={posterSize} posterSize={posterSize}
loadProgress={loadProgress} loadProgress={loadProgress}
fileSize={videoSize!} fileSize={mediaSize!}
isMediaViewerOpen={isOpen && isActive} isMediaViewerOpen={isOpen && isActive}
noPlay={!isActive} noPlay={!isActive}
isPreviewDisabled={!ARE_WEBCODECS_SUPPORTED || isLocal} isPreviewDisabled={!ARE_WEBCODECS_SUPPORTED || isLocal}
@ -205,84 +197,20 @@ const MediaViewerContent: FC<OwnProps & StateProps> = (props) => {
}; };
export default memo(withGlobal<OwnProps>( export default memo(withGlobal<OwnProps>(
(global, ownProps): StateProps => { (global, { item }): StateProps => {
const {
chatId,
threadId,
mediaId,
avatarOwnerId,
origin,
} = ownProps;
const { const {
volume, volume,
isMuted, isMuted,
playbackRate, playbackRate,
isHidden, isHidden,
origin,
} = selectTabState(global).mediaViewer; } = selectTabState(global).mediaViewer;
const textMessage = item.type === 'message' ? item.message : undefined;
if (origin === MediaViewerOrigin.SearchResult) {
if (!(chatId && mediaId)) {
return { volume, isMuted, playbackRate };
}
const message = selectChatMessage(global, chatId, mediaId);
if (!message) {
return { volume, isMuted, playbackRate };
}
return {
chatId,
mediaId,
senderId: message.senderId,
origin,
message,
isProtected: selectIsMessageProtected(global, message),
volume,
isMuted,
isHidden,
playbackRate,
};
}
if (avatarOwnerId) {
const sender = selectUser(global, avatarOwnerId) || selectChat(global, avatarOwnerId);
return {
mediaId,
senderId: avatarOwnerId,
avatarOwner: sender,
origin,
volume,
isMuted,
isHidden,
playbackRate,
};
}
if (!(chatId && threadId && mediaId)) {
return { volume, isMuted, playbackRate };
}
let message: ApiMessage | undefined;
if (origin && [MediaViewerOrigin.ScheduledAlbum, MediaViewerOrigin.ScheduledInline].includes(origin)) {
message = selectScheduledMessage(global, chatId, mediaId);
} else {
message = selectChatMessage(global, chatId, mediaId);
}
if (!message) {
return { volume, isMuted, playbackRate };
}
return { return {
chatId,
threadId,
mediaId,
senderId: message.senderId,
origin, origin,
message, textMessage,
isProtected: selectIsMessageProtected(global, message), isProtected: textMessage && selectIsMessageProtected(global, textMessage),
volume, volume,
isMuted, isMuted,
isHidden, isHidden,

View File

@ -1,10 +1,11 @@
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import React, { import React, {
memo, useEffect, useLayoutEffect, useRef, useSignal, useState, memo, useEffect, useLayoutEffect, useMemo, useRef, useSignal, useState,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import type { MediaViewerOrigin, ThreadId } from '../../types'; import type { MediaViewerOrigin, ThreadId } from '../../types';
import type { RealTouchEvent } from '../../util/captureEvents'; import type { RealTouchEvent } from '../../util/captureEvents';
import type { MediaViewerItem } from './helpers/getViewableMedia';
import { animateNumber, timingFunctions } from '../../util/animation'; import { animateNumber, timingFunctions } from '../../util/animation';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
@ -37,25 +38,25 @@ import './MediaViewerSlides.scss';
const { easeOutCubic, easeOutQuart } = timingFunctions; const { easeOutCubic, easeOutQuart } = timingFunctions;
type OwnProps = { type OwnProps = {
mediaId?: number; item?: MediaViewerItem;
loadMoreMediaIfNeeded: (activeMediaId?: number) => void;
isLoadingMoreMedia?: boolean; isLoadingMoreMedia?: boolean;
isSynced?: boolean; isSynced?: boolean;
getMediaId: (fromId?: number, direction?: number) => number | undefined;
isVideo?: boolean; isVideo?: boolean;
isGif?: boolean; isGif?: boolean;
isPhoto?: boolean; isPhoto?: boolean;
isOpen?: boolean; isOpen?: boolean;
selectMedia: (id?: number) => void;
chatId?: string; chatId?: string;
threadId?: ThreadId; threadId?: ThreadId;
avatarOwnerId?: string; avatarOwnerId?: string;
origin?: MediaViewerOrigin; origin?: MediaViewerOrigin;
withAnimation?: boolean; withAnimation?: boolean;
onClose: () => void;
isHidden?: boolean; isHidden?: boolean;
hasFooter?: boolean; hasFooter?: boolean;
getNextItem: (from: MediaViewerItem, direction: number) => MediaViewerItem | undefined;
selectItem: (item: MediaViewerItem) => void;
loadMoreItemsIfNeeded: (item: MediaViewerItem) => void;
onFooterClick: () => void; onFooterClick: () => void;
onClose: () => void;
}; };
const SWIPE_X_THRESHOLD = 50; const SWIPE_X_THRESHOLD = 50;
@ -85,10 +86,7 @@ enum SwipeDirection {
} }
const MediaViewerSlides: FC<OwnProps> = ({ const MediaViewerSlides: FC<OwnProps> = ({
mediaId, item,
loadMoreMediaIfNeeded,
getMediaId,
selectMedia,
isVideo, isVideo,
isGif, isGif,
isOpen, isOpen,
@ -96,7 +94,11 @@ const MediaViewerSlides: FC<OwnProps> = ({
isHidden, isHidden,
isLoadingMoreMedia, isLoadingMoreMedia,
isSynced, isSynced,
...rest loadMoreItemsIfNeeded,
getNextItem,
selectItem,
onClose,
onFooterClick,
}) => { }) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -117,13 +119,12 @@ const MediaViewerSlides: FC<OwnProps> = ({
const [isMouseDown, setIsMouseDown] = useState(false); const [isMouseDown, setIsMouseDown] = useState(false);
const [getTransform, setTransform] = useSignal<Transform>({ x: 0, y: 0, scale: 1 }); const [getTransform, setTransform] = useSignal<Transform>({ x: 0, y: 0, scale: 1 });
const transformRef = useSignalRef(getTransform); const transformRef = useSignalRef(getTransform);
const [getActiveMediaId, setActiveMediaId] = useSignal<number | undefined>(mediaId); const [getActiveItem, setActiveItem] = useSignal<MediaViewerItem | undefined>(item);
const activeMediaIdRef = useSignalRef(getActiveMediaId); const activeItemRef = useSignalRef(getActiveItem);
const isScaled = useDerivedState(() => getTransform().scale !== 1, [getTransform]); const isScaled = useDerivedState(() => getTransform().scale !== 1, [getTransform]);
const activeMediaId = useDerivedState(getActiveMediaId); const activeItem = useDerivedState(getActiveItem);
const { height: windowHeight, width: windowWidth, isResizing } = useWindowSize(); const { height: windowHeight, width: windowWidth, isResizing } = useWindowSize();
const [getControlsVisible, setControlsVisible, lockControls] = useControlsSignal(); const [getControlsVisible, setControlsVisible, lockControls] = useControlsSignal();
const { onClose } = rest;
const lang = useOldLang(); const lang = useOldLang();
@ -133,7 +134,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
shouldBeReplaced: true, shouldBeReplaced: true,
}); });
const selectMediaDebounced = useDebouncedCallback(selectMedia, [selectMedia], DEBOUNCE_SELECT_MEDIA, true); const selectItemDebounced = useDebouncedCallback(selectItem, [selectItem], DEBOUNCE_SELECT_MEDIA, true);
const clearSwipeDirectionDebounced = useDebouncedCallback(() => { const clearSwipeDirectionDebounced = useDebouncedCallback(() => {
swipeDirectionRef.current = undefined; swipeDirectionRef.current = undefined;
}, [], DEBOUNCE_SWIPE, true); }, [], DEBOUNCE_SWIPE, true);
@ -157,14 +158,14 @@ const MediaViewerSlides: FC<OwnProps> = ({
const { scale, x, y } = transformRef.current; const { scale, x, y } = transformRef.current;
// Only update active media if slide is in default position // Only update active media if slide is in default position
if (x === 0 && y === 0 && scale === 1) { if (x === 0 && y === 0 && scale === 1) {
setActiveMediaId(mediaId); setActiveItem(item);
} }
}, [mediaId, setActiveMediaId, transformRef]); }, [item, setActiveItem, transformRef]);
useEffect(() => { useEffect(() => {
if (!isSynced || isLoadingMoreMedia) return; if (!isSynced || !activeItem || isLoadingMoreMedia) return;
loadMoreMediaIfNeeded(activeMediaId); loadMoreItemsIfNeeded(activeItem);
}, [activeMediaId, loadMoreMediaIfNeeded, isSynced, isLoadingMoreMedia]); }, [activeItem, loadMoreItemsIfNeeded, isSynced, isLoadingMoreMedia]);
useLayoutEffect(() => { useLayoutEffect(() => {
const { x, y, scale } = getTransform(); const { x, y, scale } = getTransform();
@ -181,7 +182,7 @@ const MediaViewerSlides: FC<OwnProps> = ({
}, [getTransform, lockControls, windowWidth]); }, [getTransform, lockControls, windowWidth]);
useEffect(() => { useEffect(() => {
if (!containerRef.current || activeMediaIdRef.current === undefined || isHidden || isFullscreen) { if (!containerRef.current || activeItemRef.current === undefined || isHidden || isFullscreen) {
return undefined; return undefined;
} }
let lastTransform = lastTransformRef.current; let lastTransform = lastTransformRef.current;
@ -204,14 +205,16 @@ const MediaViewerSlides: FC<OwnProps> = ({
}, 500, false, true); }, 500, false, true);
const changeSlide = (direction: number) => { const changeSlide = (direction: number) => {
const mId = getMediaId(activeMediaIdRef.current, direction); const cActiveItem = activeItemRef.current;
if (mId !== undefined) { if (cActiveItem === undefined) return false;
const nextItem = getNextItem(cActiveItem, direction);
if (nextItem !== undefined) {
const offset = (windowWidth + SLIDES_GAP) * direction; const offset = (windowWidth + SLIDES_GAP) * direction;
const transform = transformRef.current; const transform = transformRef.current;
const x = transform.x + offset; const x = transform.x + offset;
setIsActive(false); setIsActive(false);
setActiveMediaId(mId); setActiveItem(nextItem);
selectMediaDebounced(mId); selectItemDebounced(nextItem);
setIsActiveDebounced(true); setIsActiveDebounced(true);
lastTransform = { x: 0, y: 0, scale: 1 }; lastTransform = { x: 0, y: 0, scale: 1 };
if (!withAnimation) { if (!withAnimation) {
@ -397,19 +400,20 @@ const MediaViewerSlides: FC<OwnProps> = ({
} }
// Get horizontal swipe direction // Get horizontal swipe direction
const direction = x < 0 ? 1 : -1; const direction = x < 0 ? 1 : -1;
const mId = getMediaId(activeMediaIdRef.current, x < 0 ? 1 : -1); const cActiveItem = activeItemRef.current;
const nextItem = cActiveItem && getNextItem(cActiveItem, x < 0 ? 1 : -1);
// Get the direction of the last pan gesture. // Get the direction of the last pan gesture.
// Could be different from the total horizontal swipe direction // Could be different from the total horizontal swipe direction
// if user starts a swipe in one direction and then changes the direction // if user starts a swipe in one direction and then changes the direction
// we need to cancel slide transition // we need to cancel slide transition
const dirX = panDelta.x < 0 ? -1 : 1; const dirX = panDelta.x < 0 ? -1 : 1;
if (mId !== undefined && absX >= SWIPE_X_THRESHOLD && direction === dirX) { if (nextItem !== undefined && absX >= SWIPE_X_THRESHOLD && direction === dirX) {
const offset = (windowWidth + SLIDES_GAP) * direction; const offset = (windowWidth + SLIDES_GAP) * direction;
// If image is shifted by more than SWIPE_X_THRESHOLD, // If image is shifted by more than SWIPE_X_THRESHOLD,
// We shift everything by one screen width and then set new active message id // We shift everything by one screen width and then set new active message id
x += offset; x += offset;
setActiveMediaId(mId); setActiveItem(nextItem);
selectMediaDebounced(mId); selectItemDebounced(nextItem);
} }
// Then we always return to the original position // Then we always return to the original position
cancelAnimation = animateNumber({ cancelAnimation = animateNumber({
@ -651,25 +655,22 @@ const MediaViewerSlides: FC<OwnProps> = ({
}; };
}, },
[ [
onClose, activeItemRef,
setTransform,
loadMoreMediaIfNeeded,
getMediaId,
windowWidth,
windowHeight,
clickXThreshold,
shouldCloseOnVideo,
selectMediaDebounced,
setIsActiveDebounced,
clearSwipeDirectionDebounced, clearSwipeDirectionDebounced,
withAnimation, clickXThreshold,
setIsMouseDown, getNextItem,
setIsActive,
isHidden,
isFullscreen, isFullscreen,
isHidden,
onClose,
selectItemDebounced,
setActiveItem,
setIsActiveDebounced,
setTransform,
shouldCloseOnVideo,
transformRef, transformRef,
setActiveMediaId, windowHeight,
activeMediaIdRef, windowWidth,
withAnimation,
]); ]);
useEffect(() => { useEffect(() => {
@ -707,12 +708,15 @@ const MediaViewerSlides: FC<OwnProps> = ({
}); });
}, [getZoomChange, isHidden, isFullscreen, transformRef]); }, [getZoomChange, isHidden, isFullscreen, transformRef]);
if (activeMediaId === undefined) return undefined; const [prevItem, nextItem] = useMemo(() => {
if (activeItem === undefined) return [undefined, undefined];
return [getNextItem(activeItem, -1), getNextItem(activeItem, 1)];
}, [activeItem, getNextItem]);
const nextMediaId = getMediaId(activeMediaId, 1); if (activeItem === undefined) return undefined;
const prevMediaId = getMediaId(activeMediaId, -1);
const hasPrev = prevMediaId !== undefined; const hasPrev = prevItem !== undefined;
const hasNext = nextMediaId !== undefined; const hasNext = nextItem !== undefined;
const isMoving = isMouseDown && isScaled; const isMoving = isMouseDown && isScaled;
return ( return (
@ -720,11 +724,11 @@ const MediaViewerSlides: FC<OwnProps> = ({
<div className="MediaViewerSlide" ref={leftSlideRef}> <div className="MediaViewerSlide" ref={leftSlideRef}>
{hasPrev && !isScaled && !isResizing && ( {hasPrev && !isScaled && !isResizing && (
<MediaViewerContent <MediaViewerContent
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...rest}
withAnimation={withAnimation} withAnimation={withAnimation}
isMoving={isMoving} isMoving={isMoving}
mediaId={prevMediaId} item={prevItem}
onClose={onClose}
onFooterClick={onFooterClick}
/> />
)} )}
</div> </div>
@ -738,22 +742,22 @@ const MediaViewerSlides: FC<OwnProps> = ({
ref={activeSlideRef} ref={activeSlideRef}
> >
<MediaViewerContent <MediaViewerContent
/* eslint-disable-next-line react/jsx-props-no-spreading */ item={activeItem}
{...rest}
mediaId={activeMediaId}
withAnimation={withAnimation} withAnimation={withAnimation}
isActive={isActive} isActive={isActive}
isMoving={isMoving} isMoving={isMoving}
onClose={onClose}
onFooterClick={onFooterClick}
/> />
</div> </div>
<div className="MediaViewerSlide" ref={rightSlideRef}> <div className="MediaViewerSlide" ref={rightSlideRef}>
{hasNext && !isScaled && !isResizing && ( {hasNext && !isScaled && !isResizing && (
<MediaViewerContent <MediaViewerContent
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...rest}
withAnimation={withAnimation} withAnimation={withAnimation}
isMoving={isMoving} isMoving={isMoving}
mediaId={nextMediaId} item={nextItem}
onClose={onClose}
onFooterClick={onFooterClick}
/> />
)} )}
</div> </div>

View File

@ -1,14 +1,16 @@
import type { FC } from '../../lib/teact/teact'; import type { FC } from '../../lib/teact/teact';
import React from '../../lib/teact/teact'; import React, { useMemo } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global'; import { getActions, withGlobal } from '../../global';
import type { ApiMessage, ApiPeer } from '../../api/types'; import type { ApiChat, ApiPeer } from '../../api/types';
import type { MediaViewerItem } from './helpers/getViewableMedia';
import { getSenderTitle } from '../../global/helpers';
import { import {
selectChatMessage, getSenderTitle, isChatChannel, isChatGroup, isUserId,
selectPeer, } from '../../global/helpers';
import {
selectSender, selectSender,
selectUserFullInfo,
} from '../../global/selectors'; } from '../../global/selectors';
import { formatMediaDateTime } from '../../util/dates/dateFormat'; import { formatMediaDateTime } from '../../util/dates/dateFormat';
import renderText from '../common/helpers/renderText'; import renderText from '../common/helpers/renderText';
@ -22,26 +24,21 @@ import Avatar from '../common/Avatar';
import './SenderInfo.scss'; import './SenderInfo.scss';
type OwnProps = { type OwnProps = {
chatId?: string; item?: MediaViewerItem;
messageId?: number;
isAvatar?: boolean;
isFallbackAvatar?: boolean;
}; };
type StateProps = { type StateProps = {
sender?: ApiPeer; owner?: ApiPeer;
message?: ApiMessage; isFallbackAvatar?: boolean;
}; };
const BULLET = '\u2022';
const ANIMATION_DURATION = 350; const ANIMATION_DURATION = 350;
const SenderInfo: FC<OwnProps & StateProps> = ({ const SenderInfo: FC<OwnProps & StateProps> = ({
chatId, owner,
messageId, item,
sender,
isFallbackAvatar, isFallbackAvatar,
isAvatar,
message,
}) => { }) => {
const { const {
closeMediaViewer, closeMediaViewer,
@ -54,37 +51,64 @@ const SenderInfo: FC<OwnProps & StateProps> = ({
const handleFocusMessage = useLastCallback(() => { const handleFocusMessage = useLastCallback(() => {
closeMediaViewer(); closeMediaViewer();
if (!chatId || !messageId) return; if (item?.type !== 'message') return;
const message = item.message;
if (isMobile) { if (isMobile) {
setTimeout(() => { setTimeout(() => {
toggleChatInfo({ force: false }, { forceSyncOnIOs: true }); toggleChatInfo({ force: false }, { forceSyncOnIOs: true });
focusMessage({ chatId, messageId }); focusMessage({ chatId: message.chatId, messageId: message.id });
}, ANIMATION_DURATION); }, ANIMATION_DURATION);
} else { } else {
focusMessage({ chatId, messageId }); focusMessage({ chatId: message.chatId, messageId: message.id });
} }
}); });
const lang = useOldLang(); const lang = useOldLang();
if (!sender || (!message && !isAvatar)) { const subtitle = useMemo(() => {
if (!item || item.type === 'standalone') return undefined;
const avatarOwner = item.type === 'avatar' ? item.avatarOwner : undefined;
const avatar = avatarOwner?.photos?.[item.mediaIndex!];
const date = item.type === 'message' ? item.message.date : avatar?.date;
if (!date) return undefined;
const formattedDate = formatMediaDateTime(lang, date * 1000, true);
const parts: string[] = [];
if (avatar) {
const chat = !isUserId(avatarOwner!.id) ? avatarOwner as ApiChat : undefined;
const isChannel = chat && isChatChannel(chat);
const isGroup = chat && isChatGroup(chat);
parts.push(lang(
isFallbackAvatar ? 'lng_mediaview_profile_public_photo'
: isChannel ? 'lng_mediaview_channel_photo'
: isGroup ? 'lng_mediaview_group_photo' : 'lng_mediaview_profile_photo',
));
}
parts.push(formattedDate);
return parts.join(` ${BULLET} `);
}, [item, isFallbackAvatar, lang]);
if (!owner) {
return undefined; return undefined;
} }
const senderTitle = getSenderTitle(lang, sender); const senderTitle = getSenderTitle(lang, owner);
return ( return (
<div className="SenderInfo" onClick={handleFocusMessage}> <div className="SenderInfo" onClick={handleFocusMessage}>
<Avatar key={sender.id} size="medium" peer={sender} /> <Avatar key={owner.id} size="medium" peer={owner} />
<div className="meta"> <div className="meta">
<div className="title" dir="auto"> <div className="title" dir="auto">
{senderTitle && renderText(senderTitle)} {senderTitle && renderText(senderTitle)}
</div> </div>
<div className="date" dir="auto"> <div className="date" dir="auto">
{isAvatar {subtitle}
? lang(isFallbackAvatar ? 'lng_mediaview_profile_public_photo' : 'lng_mediaview_profile_photo')
: formatMediaDateTime(lang, message!.date * 1000, true)}
</div> </div>
</div> </div>
</div> </div>
@ -92,22 +116,21 @@ const SenderInfo: FC<OwnProps & StateProps> = ({
}; };
export default withGlobal<OwnProps>( export default withGlobal<OwnProps>(
(global, { chatId, messageId, isAvatar }): StateProps => { (global, { item }): StateProps => {
if (isAvatar && chatId) { const message = item?.type === 'message' ? item.message : undefined;
return { const messageSender = message && selectSender(global, message);
sender: selectPeer(global, chatId),
};
}
if (!messageId || !chatId) { const owner = item?.type === 'avatar' ? item.avatarOwner : messageSender;
return {};
}
const message = selectChatMessage(global, chatId, messageId); const fallbackAvatar = item?.type === 'avatar'
? selectUserFullInfo(global, item.avatarOwner.id)?.fallbackPhoto : undefined;
const isFallbackAvatar = fallbackAvatar && item?.type === 'avatar'
&& item.avatarOwner.photos?.[item.mediaIndex].id === fallbackAvatar.id;
return { return {
message, owner,
sender: message && selectSender(global, message), isFallbackAvatar,
}; };
}, },
)(SenderInfo); )(SenderInfo);

View File

@ -0,0 +1,129 @@
import type { ApiMessage, ApiPeer } from '../../../api/types';
import type { MediaViewerMedia } from '../../../types';
import { getMessageContent, isDocumentPhoto, isDocumentVideo } from '../../../global/helpers';
export type MediaViewerItem = {
type: 'message';
message: ApiMessage;
mediaIndex?: number;
} | {
type: 'avatar';
avatarOwner: ApiPeer;
mediaIndex: number;
} | {
type: 'standalone';
media: MediaViewerMedia[];
mediaIndex: number;
};
type ViewableMedia = {
media: MediaViewerMedia;
isSingle?: boolean;
};
export function getMediaViewerItem({
message, avatarOwner, standaloneMedia, mediaIndex,
}: {
message?: ApiMessage;
avatarOwner?: ApiPeer;
standaloneMedia?: MediaViewerMedia[];
mediaIndex?: number;
}): MediaViewerItem | undefined {
if (avatarOwner) {
return {
type: 'avatar',
avatarOwner,
mediaIndex: mediaIndex!,
};
}
if (standaloneMedia) {
return {
type: 'standalone',
media: standaloneMedia!,
mediaIndex: mediaIndex!,
};
}
if (message) {
return {
type: 'message',
message,
mediaIndex,
};
}
return undefined;
}
export default function getViewableMedia(params?: MediaViewerItem): ViewableMedia | undefined {
if (!params) return undefined;
if (params.type === 'standalone') {
return {
media: params.media[params.mediaIndex],
isSingle: params.media.length === 1,
};
}
if (params.type === 'avatar') {
const avatar = params.avatarOwner.photos?.[params.mediaIndex];
if (avatar) {
return {
media: avatar,
};
}
return undefined;
}
const {
action, document, photo, video, webPage, paidMedia,
} = getMessageContent(params.message);
if (action?.photo) {
return {
media: action.photo,
isSingle: true,
};
}
if (document && (isDocumentPhoto(document) || isDocumentVideo(document))) {
return {
media: document,
isSingle: true,
};
}
if (webPage) {
const { photo: webPagePhoto, video: webPageVideo } = webPage;
const media = webPageVideo || webPagePhoto;
if (media) {
return {
media,
isSingle: true,
};
}
}
if (paidMedia) {
const extendedMedia = paidMedia.extendedMedia[params.mediaIndex || 0];
if (!('mediaType' in extendedMedia)) {
const { photo: extendedPhoto, video: extendedVideo } = extendedMedia;
return {
media: (extendedPhoto || extendedVideo)!,
};
}
}
const media = video || photo;
if (media) {
return {
media,
isSingle: video?.isGif,
};
}
return undefined;
}

View File

@ -25,8 +25,9 @@ export function animateOpening(
dimensions: ApiDimensions, dimensions: ApiDimensions,
isVideo: boolean, isVideo: boolean,
message?: ApiMessage, message?: ApiMessage,
mediaIndex?: number,
) { ) {
const { mediaEl: fromImage } = getNodes(origin, message); const { mediaEl: fromImage } = getNodes(origin, message, mediaIndex);
if (!fromImage) { if (!fromImage) {
return; return;
} }
@ -93,8 +94,10 @@ export function animateOpening(
}); });
} }
export function animateClosing(origin: MediaViewerOrigin, bestImageData: string, message?: ApiMessage) { export function animateClosing(
const { container, mediaEl: toImage } = getNodes(origin, message); origin: MediaViewerOrigin, bestImageData: string, message?: ApiMessage, mediaIndex?: number,
) {
const { container, mediaEl: toImage } = getNodes(origin, message, mediaIndex);
if (!toImage) { if (!toImage) {
return; return;
} }
@ -102,7 +105,7 @@ export function animateClosing(origin: MediaViewerOrigin, bestImageData: string,
const fromImage = document.getElementById('MediaViewer')!.querySelector<HTMLImageElement>( const fromImage = document.getElementById('MediaViewer')!.querySelector<HTMLImageElement>(
'.MediaViewerSlide--active img, .MediaViewerSlide--active video', '.MediaViewerSlide--active img, .MediaViewerSlide--active video',
); );
if (!fromImage || !toImage) { if (!fromImage) {
return; return;
} }
@ -284,24 +287,25 @@ function getTopOffset(hasFooter: boolean) {
return topOffsetRem * REM; return topOffsetRem * REM;
} }
function getNodes(origin: MediaViewerOrigin, message?: ApiMessage) { function getNodes(origin: MediaViewerOrigin, message?: ApiMessage, index?: number) {
let containerSelector; let containerSelector;
let mediaSelector; let mediaSelector;
switch (origin) { switch (origin) {
case MediaViewerOrigin.Album: case MediaViewerOrigin.Album:
case MediaViewerOrigin.ScheduledAlbum: case MediaViewerOrigin.ScheduledAlbum:
containerSelector = `.Transition_slide-active > .MessageList #album-media-${getMessageHtmlId(message!.id)}`; // eslint-disable-next-line max-len
containerSelector = `.Transition_slide-active > .MessageList #album-media-${getMessageHtmlId(message!.id, index)}`;
mediaSelector = '.full-media'; mediaSelector = '.full-media';
break; break;
case MediaViewerOrigin.SharedMedia: case MediaViewerOrigin.SharedMedia:
containerSelector = `#shared-media${getMessageHtmlId(message!.id)}`; containerSelector = `#shared-media${getMessageHtmlId(message!.id, index)}`;
mediaSelector = 'img'; mediaSelector = 'img';
break; break;
case MediaViewerOrigin.SearchResult: case MediaViewerOrigin.SearchResult:
containerSelector = `#search-media${getMessageHtmlId(message!.id)}`; containerSelector = `#search-media${getMessageHtmlId(message!.id, index)}`;
mediaSelector = 'img'; mediaSelector = 'img';
break; break;
@ -321,19 +325,25 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage) {
break; break;
case MediaViewerOrigin.SuggestedAvatar: case MediaViewerOrigin.SuggestedAvatar:
containerSelector = `.Transition_slide-active > .MessageList #${getMessageHtmlId(message!.id)}`; containerSelector = `.Transition_slide-active > .MessageList #${getMessageHtmlId(message!.id, index)}`;
mediaSelector = '.Avatar img'; mediaSelector = '.Avatar img';
break; break;
case MediaViewerOrigin.StarsTransaction:
containerSelector = '.transaction-media-preview';
mediaSelector = index === 0 ? `.stars-transaction-media-${index} :is(img, video)` : undefined;
break;
case MediaViewerOrigin.ScheduledInline: case MediaViewerOrigin.ScheduledInline:
case MediaViewerOrigin.Inline: case MediaViewerOrigin.Inline:
default: default:
containerSelector = `.Transition_slide-active > .MessageList #${getMessageHtmlId(message!.id)}`; containerSelector = `.Transition_slide-active > .MessageList #${getMessageHtmlId(message!.id, index)}`;
mediaSelector = `${MESSAGE_CONTENT_SELECTOR} .full-media,${MESSAGE_CONTENT_SELECTOR} .thumbnail:not(.blurred-bg)`; mediaSelector = `${MESSAGE_CONTENT_SELECTOR} .full-media,${MESSAGE_CONTENT_SELECTOR} .thumbnail:not(.blurred-bg)`;
} }
const container = document.querySelector<HTMLElement>(containerSelector)!; const container = document.querySelector<HTMLElement>(containerSelector)!;
const mediaEls = container && container.querySelectorAll<HTMLImageElement | HTMLVideoElement>(mediaSelector); const mediaEls = mediaSelector
? container?.querySelectorAll<HTMLImageElement | HTMLVideoElement>(mediaSelector) : undefined;
return { return {
container, container,
@ -347,6 +357,7 @@ function applyShape(ghost: HTMLDivElement, origin: MediaViewerOrigin) {
case MediaViewerOrigin.ScheduledAlbum: case MediaViewerOrigin.ScheduledAlbum:
case MediaViewerOrigin.Inline: case MediaViewerOrigin.Inline:
case MediaViewerOrigin.ScheduledInline: case MediaViewerOrigin.ScheduledInline:
case MediaViewerOrigin.StarsTransaction:
ghost.classList.add('rounded-corners'); ghost.classList.add('rounded-corners');
break; break;

View File

@ -1,29 +1,19 @@
import { useMemo } from '../../../lib/teact/teact'; import { useMemo } from '../../../lib/teact/teact';
import type { import type { MediaViewerMedia } from '../../../types';
ApiMessage, ApiPeer,
} from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types'; import { ApiMediaFormat } from '../../../api/types';
import { MediaViewerOrigin } from '../../../types'; import { MediaViewerOrigin } from '../../../types';
import { import {
getChatAvatarHash, getMediaFileSize,
getMessageActionPhoto, getMediaFormat,
getMessageDocument, getMediaHash,
getMessageFileName, getMediaThumbUri,
getMessageFileSize,
getMessageMediaFormat,
getMessageMediaHash,
getMessageMediaThumbDataUri,
getMessagePhoto,
getMessageVideo,
getMessageWebPagePhoto,
getMessageWebPageVideo,
getPhotoFullDimensions, getPhotoFullDimensions,
getVideoAvatarMediaHash, getVideoAvatarMediaHash,
getVideoDimensions, getVideoDimensions,
isMessageDocumentPhoto, isDocumentPhoto,
isMessageDocumentVideo, isDocumentVideo,
} from '../../../global/helpers'; } from '../../../global/helpers';
import { AVATAR_FULL_DIMENSIONS, VIDEO_AVATAR_FULL_DIMENSIONS } from '../../common/helpers/mediaDimensions'; import { AVATAR_FULL_DIMENSIONS, VIDEO_AVATAR_FULL_DIMENSIONS } from '../../common/helpers/mediaDimensions';
@ -32,67 +22,46 @@ import useMedia from '../../../hooks/useMedia';
import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress'; import useMediaWithLoadProgress from '../../../hooks/useMediaWithLoadProgress';
type UseMediaProps = { type UseMediaProps = {
mediaId?: number; media?: MediaViewerMedia;
message?: ApiMessage; isAvatar?: boolean;
avatarOwner?: ApiPeer;
origin?: MediaViewerOrigin; origin?: MediaViewerOrigin;
delay: number | false; delay: number | false;
}; };
export const useMediaProps = ({ export const useMediaProps = ({
message, media,
mediaId = 0, isAvatar,
avatarOwner,
origin, origin,
delay, delay,
}: UseMediaProps) => { }: UseMediaProps) => {
const photo = message ? getMessagePhoto(message) : undefined; const isVideoAvatar = isAvatar && media?.mediaType === 'photo' && media.isVideo;
const actionPhoto = message ? getMessageActionPhoto(message) : undefined; const isDocument = media?.mediaType === 'document';
const video = message ? getMessageVideo(message) : undefined; const isVideo = (media?.mediaType === 'video' && !media.isRound) || (isDocument && isDocumentVideo(media));
const webPagePhoto = message ? getMessageWebPagePhoto(message) : undefined; const isPhoto = media?.mediaType === 'photo' || (isDocument && isDocumentPhoto(media));
const webPageVideo = message ? getMessageWebPageVideo(message) : undefined; const isGif = media?.mediaType === 'video' && media.isGif;
const isDocumentPhoto = message ? isMessageDocumentPhoto(message) : false;
const isDocumentVideo = message ? isMessageDocumentVideo(message) : false;
const videoSize = message ? getMessageFileSize(message) : undefined;
const avatarMedia = avatarOwner?.photos?.[mediaId];
const isVideoAvatar = Boolean(avatarMedia?.isVideo || actionPhoto?.isVideo);
const isVideo = Boolean(video || webPageVideo || isDocumentVideo);
const isPhoto = Boolean(!isVideo && (photo || webPagePhoto || isDocumentPhoto || actionPhoto));
const { isGif } = video || webPageVideo || {};
const isFromSharedMedia = origin === MediaViewerOrigin.SharedMedia; const isFromSharedMedia = origin === MediaViewerOrigin.SharedMedia;
const isFromSearch = origin === MediaViewerOrigin.SearchResult; const isFromSearch = origin === MediaViewerOrigin.SearchResult;
const getMediaHash = useMemo(() => (isFull?: boolean) => { const getMediaOrAvatarHash = useMemo(() => (isFull?: boolean) => {
if (avatarOwner) { if (!media) return undefined;
if (avatarMedia) {
if (avatarMedia.isVideo && isFull) { if (isVideoAvatar && isFull) {
return getVideoAvatarMediaHash(avatarMedia); return getVideoAvatarMediaHash(media);
} else if (mediaId === 0) {
// Show preloaded avatar if this is the first media (when user clicks on profile info avatar)
return getChatAvatarHash(avatarOwner, isFull ? 'big' : 'normal');
} else {
return `photo${avatarMedia.id}?size=c`;
}
} else {
return getChatAvatarHash(avatarOwner, isFull ? 'big' : 'normal');
}
} }
if (actionPhoto && isVideoAvatar && isFull) {
return `videoAvatar${actionPhoto.id}?size=u`; return getMediaHash(media, isFull ? 'full' : 'preview');
} }, [isVideoAvatar, media]);
return message && getMessageMediaHash(message, isFull ? 'full' : 'preview');
}, [avatarOwner, actionPhoto, isVideoAvatar, message, avatarMedia, mediaId]);
const pictogramBlobUrl = useMedia( const pictogramBlobUrl = useMedia(
message media
// Only use pictogram if it's already loaded // Only use pictogram if it's already loaded
&& (isFromSharedMedia || isFromSearch || isDocumentPhoto || isDocumentVideo) && (isFromSharedMedia || isFromSearch || isDocument)
&& getMessageMediaHash(message, 'pictogram'), && getMediaHash(media, 'pictogram'),
undefined, undefined,
ApiMediaFormat.BlobUrl, ApiMediaFormat.BlobUrl,
delay, delay,
); );
const previewMediaHash = getMediaHash(); const previewMediaHash = getMediaOrAvatarHash();
const previewBlobUrl = useMedia( const previewBlobUrl = useMedia(
previewMediaHash, previewMediaHash,
undefined, undefined,
@ -103,15 +72,15 @@ export const useMediaProps = ({
mediaData: fullMediaBlobUrl, mediaData: fullMediaBlobUrl,
loadProgress, loadProgress,
} = useMediaWithLoadProgress( } = useMediaWithLoadProgress(
getMediaHash(true), getMediaOrAvatarHash(true),
undefined, undefined,
message && getMessageMediaFormat(message, 'full'), media && getMediaFormat(media, 'full'),
delay, delay,
); );
const localBlobUrl = (photo || video) ? (photo || video)!.blobUrl : undefined; const localBlobUrl = media && 'blobUrl' in media ? media.blobUrl : undefined;
let bestImageData = (!isVideo && (localBlobUrl || fullMediaBlobUrl)) || previewBlobUrl || pictogramBlobUrl; let bestImageData = (!isVideo && (localBlobUrl || fullMediaBlobUrl)) || previewBlobUrl || pictogramBlobUrl;
const thumbDataUri = useBlurSync(!bestImageData && message && getMessageMediaThumbDataUri(message)); const thumbDataUri = useBlurSync(!bestImageData && media && getMediaThumbUri(media));
if (!bestImageData && origin !== MediaViewerOrigin.SearchResult) { if (!bestImageData && origin !== MediaViewerOrigin.SearchResult) {
bestImageData = thumbDataUri; bestImageData = thumbDataUri;
} }
@ -122,59 +91,43 @@ export const useMediaProps = ({
(!isVideoAvatar && !isVideo) ? (previewBlobUrl || pictogramBlobUrl || bestImageData) : undefined (!isVideoAvatar && !isVideo) ? (previewBlobUrl || pictogramBlobUrl || bestImageData) : undefined
); );
const mediaSize = media && getMediaFileSize(media);
const isLocal = Boolean(localBlobUrl); const isLocal = Boolean(localBlobUrl);
const fileName = message
? getMessageFileName(message)
: avatarOwner
? `avatar${avatarOwner!.id}.${avatarOwner?.hasVideoAvatar ? 'mp4' : 'jpg'}`
: undefined;
const dimensions = useMemo(() => { const dimensions = useMemo(() => {
if (message) { if (isAvatar) {
if (isDocumentPhoto || isDocumentVideo) {
return getMessageDocument(message)!.mediaSize!;
} else if (photo || webPagePhoto || actionPhoto) {
return getPhotoFullDimensions((photo || webPagePhoto || actionPhoto)!)!;
} else if (video || webPageVideo) {
return getVideoDimensions((video || webPageVideo)!)!;
}
} else {
return isVideoAvatar ? VIDEO_AVATAR_FULL_DIMENSIONS : AVATAR_FULL_DIMENSIONS; return isVideoAvatar ? VIDEO_AVATAR_FULL_DIMENSIONS : AVATAR_FULL_DIMENSIONS;
} }
if (isDocument) {
return media.mediaSize!;
}
if (isPhoto) {
return getPhotoFullDimensions(media);
}
if (isVideo) {
return getVideoDimensions(media);
}
return undefined; return undefined;
}, [ }, [isAvatar, isDocument, isPhoto, isVideo, isVideoAvatar, media]);
isDocumentPhoto,
isDocumentVideo,
isVideoAvatar,
message,
photo,
video,
actionPhoto,
webPagePhoto,
webPageVideo,
]);
return { return {
getMediaHash, getMediaHash: getMediaOrAvatarHash,
photo, media,
video,
webPagePhoto,
actionPhoto,
webPageVideo,
isVideo, isVideo,
isPhoto, isPhoto,
isGif, isGif,
isDocumentPhoto, isDocument,
isDocumentVideo,
fileName,
bestImageData, bestImageData,
bestData, bestData,
dimensions, dimensions,
isFromSharedMedia, isFromSharedMedia,
avatarPhoto: avatarMedia,
isVideoAvatar, isVideoAvatar,
isLocal, isLocal,
loadProgress, loadProgress,
videoSize, mediaSize,
}; };
}; };

View File

@ -4,11 +4,11 @@ import { getActions } from '../../global';
import type { ApiMessage } from '../../api/types'; import type { ApiMessage } from '../../api/types';
import type { TextPart } from '../../types'; import type { TextPart } from '../../types';
import { ApiMediaFormat, MAIN_THREAD_ID } from '../../api/types'; import { MAIN_THREAD_ID } from '../../api/types';
import { MediaViewerOrigin, SettingsScreens } from '../../types'; import { MediaViewerOrigin, SettingsScreens } from '../../types';
import { getMessageMediaHash } from '../../global/helpers'; import { getPhotoMediaHash, getVideoAvatarMediaHash } from '../../global/helpers';
import * as mediaLoader from '../../util/mediaLoader'; import { fetchBlob } from '../../util/files';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
@ -37,7 +37,9 @@ const ActionMessageSuggestedAvatar: FC<OwnProps> = ({
const lang = useOldLang(); const lang = useOldLang();
const [cropModalBlob, setCropModalBlob] = useState<Blob | undefined>(); const [cropModalBlob, setCropModalBlob] = useState<Blob | undefined>();
const [isVideoModalOpen, openVideoModal, closeVideoModal] = useFlag(false); const [isVideoModalOpen, openVideoModal, closeVideoModal] = useFlag(false);
const suggestedPhotoUrl = useMedia(getMessageMediaHash(message, 'full')); const photo = message.content.action!.photo!;
const suggestedPhotoUrl = useMedia(getPhotoMediaHash(photo, 'full'));
const suggestedVideoUrl = useMedia(getVideoAvatarMediaHash(photo));
const isVideo = message.content.action!.photo?.isVideo; const isVideo = message.content.action!.photo?.isVideo;
const showAvatarNotification = useLastCallback(() => { const showAvatarNotification = useLastCallback(() => {
@ -65,13 +67,13 @@ const ActionMessageSuggestedAvatar: FC<OwnProps> = ({
}); });
const handleSetVideo = useLastCallback(async () => { const handleSetVideo = useLastCallback(async () => {
if (!suggestedVideoUrl) return;
closeVideoModal(); closeVideoModal();
showAvatarNotification(); showAvatarNotification();
// TODO Once we support uploading video avatars, add crop/trim modal here // TODO Once we support uploading video avatars, add crop/trim modal here
const photo = message.content.action!.photo!; const blob = await fetchBlob(suggestedVideoUrl);
const blobUrl = await mediaLoader.fetch(`videoAvatar${photo.id}?size=u`, ApiMediaFormat.BlobUrl);
const blob = await fetch(blobUrl).then((r) => r.blob());
uploadProfilePhoto({ uploadProfilePhoto({
file: new File([blob], 'avatar.mp4'), file: new File([blob], 'avatar.mp4'),
isVideo: true, isVideo: true,
@ -84,12 +86,12 @@ const ActionMessageSuggestedAvatar: FC<OwnProps> = ({
if (isVideo) { if (isVideo) {
openVideoModal(); openVideoModal();
} else { } else {
setCropModalBlob(await fetch(suggestedPhotoUrl).then((r) => r.blob())); setCropModalBlob(await fetchBlob(suggestedPhotoUrl));
} }
} else { } else {
openMediaViewer({ openMediaViewer({
chatId: message.chatId, chatId: message.chatId,
mediaId: message.id, messageId: message.id,
threadId: MAIN_THREAD_ID, threadId: MAIN_THREAD_ID,
origin: MediaViewerOrigin.SuggestedAvatar, origin: MediaViewerOrigin.SuggestedAvatar,
}); });

View File

@ -6,9 +6,9 @@ import type { ApiBotInfo } from '../../api/types';
import { import {
getBotCoverMediaHash, getBotCoverMediaHash,
getDocumentMediaHash,
getPhotoFullDimensions, getPhotoFullDimensions,
getVideoDimensions, getVideoDimensions,
getVideoMediaHash,
} from '../../global/helpers'; } from '../../global/helpers';
import { selectBot, selectUserFullInfo } from '../../global/selectors'; import { selectBot, selectUserFullInfo } from '../../global/selectors';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
@ -43,7 +43,7 @@ const MessageListBotInfo: FC<OwnProps & StateProps> = ({
const dpr = useDevicePixelRatio(); const dpr = useDevicePixelRatio();
const botInfoPhotoUrl = useMedia(botInfo?.photo ? getBotCoverMediaHash(botInfo.photo) : undefined); const botInfoPhotoUrl = useMedia(botInfo?.photo ? getBotCoverMediaHash(botInfo.photo) : undefined);
const botInfoGifUrl = useMedia(botInfo?.gif ? getDocumentMediaHash(botInfo.gif) : undefined); const botInfoGifUrl = useMedia(botInfo?.gif ? getVideoMediaHash(botInfo.gif, 'full') : undefined);
const botInfoDimensions = botInfo?.photo ? getPhotoFullDimensions(botInfo.photo) : botInfo?.gif const botInfoDimensions = botInfo?.photo ? getPhotoFullDimensions(botInfo.photo) : botInfo?.gif
? getVideoDimensions(botInfo.gif) : undefined; ? getVideoDimensions(botInfo.gif) : undefined;
const botInfoRealDimensions = botInfoDimensions && { const botInfoRealDimensions = botInfoDimensions && {

View File

@ -27,7 +27,7 @@ const AttachBotIcon: FC<OwnProps> = ({
icon, theme, icon, theme,
}) => { }) => {
const { isTouchScreen } = useAppLayout(); const { isTouchScreen } = useAppLayout();
const mediaData = useMedia(getDocumentMediaHash(icon), false, ApiMediaFormat.Text); const mediaData = useMedia(getDocumentMediaHash(icon, 'full'), false, ApiMediaFormat.Text);
const iconSvg = useMemo(() => { const iconSvg = useMemo(() => {
if (!mediaData) return ''; if (!mediaData) return '';

View File

@ -6,7 +6,7 @@ import type { ApiStickerSet } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { STICKER_SIZE_PICKER_HEADER } from '../../../config'; import { STICKER_SIZE_PICKER_HEADER } from '../../../config';
import { getStickerPreviewHash } from '../../../global/helpers'; import { getStickerMediaHash } from '../../../global/helpers';
import { selectIsAlwaysHighPriorityEmoji } from '../../../global/selectors'; import { selectIsAlwaysHighPriorityEmoji } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { getFirstLetters } from '../../../util/textFormat'; import { getFirstLetters } from '../../../util/textFormat';
@ -62,7 +62,7 @@ const StickerSetCover: FC<OwnProps> = ({
const hasOnlyStaticThumb = hasStaticThumb && !hasVideoThumb && !hasAnimatedThumb && !thumbCustomEmojiId; const hasOnlyStaticThumb = hasStaticThumb && !hasVideoThumb && !hasAnimatedThumb && !thumbCustomEmojiId;
const shouldFallbackToStatic = hasOnlyStaticThumb || (hasVideoThumb && !IS_WEBM_SUPPORTED && !hasAnimatedThumb); const shouldFallbackToStatic = hasOnlyStaticThumb || (hasVideoThumb && !IS_WEBM_SUPPORTED && !hasAnimatedThumb);
const staticHash = shouldFallbackToStatic && getStickerPreviewHash(stickerSet.stickers![0].id); const staticHash = shouldFallbackToStatic && getStickerMediaHash(stickerSet.stickers![0], 'preview');
const staticMediaData = useMedia(staticHash, !isIntersecting); const staticMediaData = useMedia(staticHash, !isIntersecting);
const mediaHash = ((hasThumbnail && !shouldFallbackToStatic) || hasAnimatedThumb) && `stickerSet${stickerSet.id}`; const mediaHash = ((hasThumbnail && !shouldFallbackToStatic) || hasAnimatedThumb) && `stickerSet${stickerSet.id}`;
@ -75,10 +75,7 @@ const StickerSetCover: FC<OwnProps> = ({
useEffect(() => { useEffect(() => {
if (isIntersecting && !stickerSet.stickers?.length) { if (isIntersecting && !stickerSet.stickers?.length) {
loadStickers({ loadStickers({
stickerSetInfo: { stickerSetInfo: stickerSet,
id: stickerSet.id,
accessHash: stickerSet.accessHash,
},
}); });
} }
}, [isIntersecting, loadStickers, stickerSet]); }, [isIntersecting, loadStickers, stickerSet]);

View File

@ -2,6 +2,7 @@ import React, { type TeactNode } from '../../../../lib/teact/teact';
import type { ApiKeyboardButton } from '../../../../api/types'; import type { ApiKeyboardButton } from '../../../../api/types';
import { STARS_ICON_PLACEHOLDER } from '../../../../config';
import { replaceWithTeact } from '../../../../util/replaceWithTeact'; import { replaceWithTeact } from '../../../../util/replaceWithTeact';
import renderText from '../../../common/helpers/renderText'; import renderText from '../../../common/helpers/renderText';
@ -15,7 +16,7 @@ export default function renderKeyboardButtonText(lang: LangFn, button: ApiKeyboa
} }
if (button.type === 'buy') { if (button.type === 'buy') {
return replaceWithTeact(button.text, '⭐', <Icon className="star-currency-icon" name="star" />); return replaceWithTeact(button.text, STARS_ICON_PLACEHOLDER, <Icon className="star-currency-icon" name="star" />);
} }
return renderText(button.text); return renderText(button.text);

View File

@ -39,7 +39,7 @@ export function groupMessages(
messages: [message], messages: [message],
mainMessage: message, mainMessage: message,
hasMultipleCaptions: false, hasMultipleCaptions: false,
}; } satisfies IAlbum;
} else { } else {
currentAlbum.messages.push(message); currentAlbum.messages.push(message);
if (message.hasComments) { if (message.hasComments) {
@ -54,6 +54,14 @@ export function groupMessages(
} }
} }
} }
} else if ((message.content.paidMedia?.extendedMedia.length || 0) > 1) {
currentSenderGroup.push({
albumId: `paid-${message.id}`,
messages: [message],
mainMessage: message,
hasMultipleCaptions: false,
isPaidMedia: true,
} satisfies IAlbum);
} else { } else {
currentSenderGroup.push(message); currentSenderGroup.push(message);
} }
@ -67,6 +75,7 @@ export function groupMessages(
currentSenderGroup.push(currentAlbum); currentSenderGroup.push(currentAlbum);
currentAlbum = undefined; currentAlbum = undefined;
} }
const lastSenderGroupItem = currentSenderGroup[currentSenderGroup.length - 1]; const lastSenderGroupItem = currentSenderGroup[currentSenderGroup.length - 1];
if (nextMessage) { if (nextMessage) {
const nextMessageDayStartsAt = getDayStartAt(nextMessage.date * 1000); const nextMessageDayStartsAt = getDayStartAt(nextMessage.date * 1000);

View File

@ -5,6 +5,10 @@
.message-content.media.text & { .message-content.media.text & {
margin: -0.3125rem -0.5rem 0.3125rem; margin: -0.3125rem -0.5rem 0.3125rem;
+ .message-paid-media-status {
margin-right: -0.5rem;
}
} }
body.is-ios .Message.own .message-content.has-solid-background :not(.forwarded-message) & { body.is-ios .Message.own .message-content.has-solid-background :not(.forwarded-message) & {

View File

@ -1,14 +1,16 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React from '../../../lib/teact/teact'; import React, { useMemo } from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global'; import { getActions, getGlobal, withGlobal } from '../../../global';
import type { ApiMessage } from '../../../api/types'; import type { ApiMessage } from '../../../api/types';
import type { GlobalState } from '../../../global/types'; import type { GlobalState, TabState } from '../../../global/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { IAlbum, ISettings } from '../../../types'; import type { IAlbum, ISettings } from '../../../types';
import type { IAlbumLayout } from './helpers/calculateAlbumLayout'; import type { IAlbumLayout } from './helpers/calculateAlbumLayout';
import { getMessageContent, getMessageHtmlId } from '../../../global/helpers'; import {
getIsDownloading, getMessageContent, getMessageHtmlId, getMessagePhoto,
} from '../../../global/helpers';
import { import {
selectActiveDownloads, selectActiveDownloads,
selectCanAutoLoadMedia, selectCanAutoLoadMedia,
@ -36,13 +38,13 @@ type OwnProps = {
isOwn: boolean; isOwn: boolean;
isProtected?: boolean; isProtected?: boolean;
albumLayout: IAlbumLayout; albumLayout: IAlbumLayout;
onMediaClick: (messageId: number) => void; onMediaClick: (messageId: number, index?: number) => void;
}; };
type StateProps = { type StateProps = {
theme: ISettings['theme']; theme: ISettings['theme'];
uploadsByKey: GlobalState['fileUploads']['byMessageKey']; uploadsByKey: GlobalState['fileUploads']['byMessageKey'];
activeDownloadIds?: number[]; activeDownloads: TabState['activeDownloads'];
}; };
const Album: FC<OwnProps & StateProps> = ({ const Album: FC<OwnProps & StateProps> = ({
@ -54,19 +56,44 @@ const Album: FC<OwnProps & StateProps> = ({
albumLayout, albumLayout,
onMediaClick, onMediaClick,
uploadsByKey, uploadsByKey,
activeDownloadIds, activeDownloads,
theme, theme,
}) => { }) => {
const { cancelUploadMedia } = getActions(); const { cancelUploadMedia } = getActions();
const mediaCount = album.messages.length; const { content: { paidMedia } } = album.mainMessage;
const handleCancelUpload = useLastCallback((message: ApiMessage) => { const mediaCount = album.isPaidMedia ? paidMedia!.extendedMedia.length : album.messages.length;
cancelUploadMedia({ chatId: message.chatId, messageId: message.id });
const handlePaidMediaClick = useLastCallback((index: number) => {
onMediaClick(album.mainMessage.id, index);
}); });
const handleAlbumMessageClick = useLastCallback((messageId: number) => {
onMediaClick(messageId);
});
const handleCancelUpload = useLastCallback((messageId: number) => {
cancelUploadMedia({ chatId: album.mainMessage.chatId, messageId });
});
const messages = useMemo(() => {
if (album.isPaidMedia) {
return album.mainMessage.content.paidMedia!.extendedMedia.map(() => album.mainMessage);
}
return album.messages;
}, [album]);
function renderAlbumMessage(message: ApiMessage, index: number) { function renderAlbumMessage(message: ApiMessage, index: number) {
const { photo, video } = getMessageContent(message); const renderingPaidMedia = album.isPaidMedia ? message.content.paidMedia?.extendedMedia[index] : undefined;
const paidPhotoOrPreview = renderingPaidMedia && 'mediaType' in renderingPaidMedia
? renderingPaidMedia : renderingPaidMedia?.photo;
const paidVideoOrPreview = renderingPaidMedia && 'mediaType' in renderingPaidMedia
? renderingPaidMedia : renderingPaidMedia?.video;
const photo = paidPhotoOrPreview || getMessagePhoto(message);
const video = paidVideoOrPreview || getMessageContent(message).video;
const fileUpload = uploadsByKey[getMessageKey(message)]; const fileUpload = uploadsByKey[getMessageKey(message)];
const uploadProgress = fileUpload?.progress; const uploadProgress = fileUpload?.progress;
const { dimensions, sides } = albumLayout.layout[index]; const { dimensions, sides } = albumLayout.layout[index];
@ -83,17 +110,19 @@ const Album: FC<OwnProps & StateProps> = ({
return ( return (
<PhotoWithSelect <PhotoWithSelect
id={`album-media-${getMessageHtmlId(message.id)}`} id={`album-media-${getMessageHtmlId(message.id, album.isPaidMedia ? index : undefined)}`}
message={message} photo={photo}
isOwn={isOwn}
observeIntersectionForLoading={observeIntersection} observeIntersectionForLoading={observeIntersection}
canAutoLoad={canAutoLoad} canAutoLoad={canAutoLoad}
shouldAffectAppendix={shouldAffectAppendix} shouldAffectAppendix={shouldAffectAppendix}
uploadProgress={uploadProgress} uploadProgress={uploadProgress}
dimensions={dimensions} dimensions={dimensions}
isProtected={isProtected} isProtected={isProtected}
onClick={onMediaClick} clickArg={album.isPaidMedia ? index : message.id}
onClick={album.isPaidMedia ? handlePaidMediaClick : handleAlbumMessageClick}
onCancelUpload={handleCancelUpload} onCancelUpload={handleCancelUpload}
isDownloading={activeDownloadIds?.includes(message.id)} isDownloading={photo.mediaType !== 'extendedMediaPreview' && getIsDownloading(activeDownloads, photo)}
theme={theme} theme={theme}
/> />
); );
@ -101,16 +130,17 @@ const Album: FC<OwnProps & StateProps> = ({
return ( return (
<VideoWithSelect <VideoWithSelect
id={`album-media-${getMessageHtmlId(message.id)}`} id={`album-media-${getMessageHtmlId(message.id)}`}
message={message} video={video}
observeIntersectionForLoading={observeIntersection} observeIntersectionForLoading={observeIntersection}
canAutoLoad={canAutoLoad} canAutoLoad={canAutoLoad}
canAutoPlay={canAutoPlay} canAutoPlay={canAutoPlay}
uploadProgress={uploadProgress} uploadProgress={uploadProgress}
dimensions={dimensions} dimensions={dimensions}
isProtected={isProtected} isProtected={isProtected}
onClick={onMediaClick} clickArg={album.isPaidMedia ? index : message.id}
onClick={album.isPaidMedia ? handlePaidMediaClick : handleAlbumMessageClick}
onCancelUpload={handleCancelUpload} onCancelUpload={handleCancelUpload}
isDownloading={activeDownloadIds?.includes(message.id)} isDownloading={video.mediaType !== 'extendedMediaPreview' && getIsDownloading(activeDownloads, video)}
theme={theme} theme={theme}
/> />
); );
@ -126,22 +156,20 @@ const Album: FC<OwnProps & StateProps> = ({
className="Album" className="Album"
style={`width: ${containerWidth}px; height: ${containerHeight}px;`} style={`width: ${containerWidth}px; height: ${containerHeight}px;`}
> >
{album.messages.map(renderAlbumMessage)} {messages.map(renderAlbumMessage)}
</div> </div>
); );
}; };
export default withGlobal<OwnProps>( export default withGlobal<OwnProps>(
(global, { album }): StateProps => { (global): StateProps => {
const { chatId } = album.mainMessage;
const theme = selectTheme(global); const theme = selectTheme(global);
const activeDownloads = selectActiveDownloads(global, chatId); const activeDownloads = selectActiveDownloads(global);
const isScheduled = album.mainMessage.isScheduled;
return { return {
theme, theme,
uploadsByKey: global.fileUploads.byMessageKey, uploadsByKey: global.fileUploads.byMessageKey,
activeDownloadIds: isScheduled ? activeDownloads?.scheduledIds : activeDownloads?.ids, activeDownloads,
}; };
}, },
)(Album); )(Album);

View File

@ -4,7 +4,7 @@ import React, {
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../../global'; import { getActions, getGlobal, withGlobal } from '../../../global';
import type { MessageListType, TabState } from '../../../global/types'; import type { ActiveDownloads, MessageListType } from '../../../global/types';
import type { IAlbum, IAnchorPosition, ThreadId } from '../../../types'; import type { IAlbum, IAnchorPosition, ThreadId } from '../../../types';
import { import {
type ApiAvailableReaction, type ApiAvailableReaction,
@ -20,6 +20,8 @@ import {
import { PREVIEW_AVATAR_COUNT, SERVICE_NOTIFICATIONS_USER_ID } from '../../../config'; import { PREVIEW_AVATAR_COUNT, SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import { import {
areReactionsEmpty, areReactionsEmpty,
getIsDownloading,
getMessageDownloadableMedia,
getMessageVideo, getMessageVideo,
hasMessageTtl, hasMessageTtl,
isActionMessage, isActionMessage,
@ -120,7 +122,7 @@ type StateProps = {
canClosePoll?: boolean; canClosePoll?: boolean;
canLoadReadDate?: boolean; canLoadReadDate?: boolean;
shouldRenderShowWhen?: boolean; shouldRenderShowWhen?: boolean;
activeDownloads?: TabState['activeDownloads']['byChatId'][number]; activeDownloads: ActiveDownloads;
canShowSeenBy?: boolean; canShowSeenBy?: boolean;
enabledReactions?: ApiChatReactions; enabledReactions?: ApiChatReactions;
canScheduleUntilOnline?: boolean; canScheduleUntilOnline?: boolean;
@ -200,8 +202,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
toggleMessageSelection, toggleMessageSelection,
sendScheduledMessages, sendScheduledMessages,
rescheduleMessage, rescheduleMessage,
downloadMessageMedia, downloadMedia,
cancelMessageMediaDownload, cancelMediaDownload,
loadSeenBy, loadSeenBy,
openSeenByModal, openSeenByModal,
openReactorListModal, openReactorListModal,
@ -291,11 +293,15 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
const isDownloading = useMemo(() => { const isDownloading = useMemo(() => {
if (album) { if (album) {
return album.messages.some((msg) => { return album.messages.some((msg) => {
return activeDownloads?.[message.isScheduled ? 'scheduledIds' : 'ids']?.includes(msg.id); const downloadableMedia = getMessageDownloadableMedia(msg);
if (!downloadableMedia) return false;
return getIsDownloading(activeDownloads, downloadableMedia);
}); });
} }
return activeDownloads?.[message.isScheduled ? 'scheduledIds' : 'ids']?.includes(message.id); const downloadableMedia = getMessageDownloadableMedia(message);
if (!downloadableMedia) return false;
return getIsDownloading(activeDownloads, downloadableMedia);
}, [activeDownloads, album, message]); }, [activeDownloads, album, message]);
const selectionRange = canReply && selection?.rangeCount ? selection.getRangeAt(0) : undefined; const selectionRange = canReply && selection?.rangeCount ? selection.getRangeAt(0) : undefined;
@ -483,10 +489,13 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
const handleDownloadClick = useLastCallback(() => { const handleDownloadClick = useLastCallback(() => {
(album?.messages || [message]).forEach((msg) => { (album?.messages || [message]).forEach((msg) => {
const downloadableMedia = getMessageDownloadableMedia(msg);
if (!downloadableMedia) return;
if (isDownloading) { if (isDownloading) {
cancelMessageMediaDownload({ message: msg }); cancelMediaDownload({ media: downloadableMedia });
} else { } else {
downloadMessageMedia({ message: msg }); downloadMedia({ media: downloadableMedia });
} }
}); });
closeMenu(); closeMenu();
@ -666,7 +675,7 @@ export default memo(withGlobal<OwnProps>(
const { defaultTags, topReactions, availableReactions } = global.reactions; const { defaultTags, topReactions, availableReactions } = global.reactions;
const activeDownloads = selectActiveDownloads(global, message.chatId); const activeDownloads = selectActiveDownloads(global);
const chat = selectChat(global, message.chatId); const chat = selectChat(global, message.chatId);
const { const {
seenByExpiresAt, seenByMaxChatMembers, maxUniqueReactions, readDateExpiresAt, seenByExpiresAt, seenByMaxChatMembers, maxUniqueReactions, readDateExpiresAt,

View File

@ -55,7 +55,7 @@ const Invoice: FC<OwnProps> = ({
const photoUrl = useMedia(getWebDocumentHash(photo)); const photoUrl = useMedia(getWebDocumentHash(photo));
const withBlurredBackground = Boolean(forcedWidth); const withBlurredBackground = Boolean(forcedWidth);
const blurredBackgroundRef = useBlurredMediaThumbRef(message, !withBlurredBackground, photoUrl); const blurredBackgroundRef = useBlurredMediaThumbRef(photoUrl, !withBlurredBackground);
useLayoutEffectWithPrevDeps(([prevShouldAffectAppendix]) => { useLayoutEffectWithPrevDeps(([prevShouldAffectAppendix]) => {
if (!shouldAffectAppendix) { if (!shouldAffectAppendix) {

View File

@ -66,16 +66,16 @@ const Location: FC<OwnProps> = ({
const forceUpdate = useForceUpdate(); const forceUpdate = useForceUpdate();
const location = getMessageLocation(message)!; const location = getMessageLocation(message)!;
const { type, geo } = location; const { mediaType, geo } = location;
const serverTime = getServerTime(); const serverTime = getServerTime();
const isExpired = isGeoLiveExpired(message); const isExpired = isGeoLiveExpired(message);
const secondsBeforeEnd = (type === 'geoLive' && !isExpired) ? message.date + location.period - serverTime const secondsBeforeEnd = (mediaType === 'geoLive' && !isExpired) ? message.date + location.period - serverTime
: undefined; : undefined;
const [point, setPoint] = useState(geo); const [point, setPoint] = useState(geo);
const shouldRenderText = type === 'venue' || (type === 'geoLive' && !isExpired); const shouldRenderText = mediaType === 'venue' || (mediaType === 'geoLive' && !isExpired);
const { width, height, zoom } = DEFAULT_MAP_CONFIG; const { width, height, zoom } = DEFAULT_MAP_CONFIG;
const dpr = useDevicePixelRatio(); const dpr = useDevicePixelRatio();
@ -85,20 +85,20 @@ const Location: FC<OwnProps> = ({
const mapBlobUrl = mediaBlobUrl || prevMediaBlobUrl; const mapBlobUrl = mediaBlobUrl || prevMediaBlobUrl;
const accuracyRadiusPx = useMemo(() => { const accuracyRadiusPx = useMemo(() => {
if (type !== 'geoLive' || !point.accuracyRadius) { if (mediaType !== 'geoLive' || !point.accuracyRadius) {
return 0; return 0;
} }
const { lat, accuracyRadius } = point; const { lat, accuracyRadius } = point;
return accuracyRadius / getMetersPerPixel(lat, zoom); return accuracyRadius / getMetersPerPixel(lat, zoom);
}, [type, point, zoom]); }, [mediaType, point, zoom]);
const handleClick = () => { const handleClick = () => {
openMapModal({ geoPoint: point, zoom }); openMapModal({ geoPoint: point, zoom });
}; };
const updateCountdown = useLastCallback((countdownEl: HTMLDivElement) => { const updateCountdown = useLastCallback((countdownEl: HTMLDivElement) => {
if (type !== 'geoLive') return; if (mediaType !== 'geoLive') return;
const svgEl = countdownEl.lastElementChild!; const svgEl = countdownEl.lastElementChild!;
const timerEl = countdownEl.firstElementChild!; const timerEl = countdownEl.firstElementChild!;
@ -144,7 +144,7 @@ const Location: FC<OwnProps> = ({
function renderInfo() { function renderInfo() {
if (!shouldRenderText) return undefined; if (!shouldRenderText) return undefined;
if (type === 'venue') { if (mediaType === 'venue') {
return ( return (
<div className="location-info"> <div className="location-info">
<div className="location-info-title"> <div className="location-info-title">
@ -156,7 +156,7 @@ const Location: FC<OwnProps> = ({
</div> </div>
); );
} }
if (type === 'geoLive') { if (mediaType === 'geoLive') {
return ( return (
<div className="location-info"> <div className="location-info">
<div className="location-info-title">{lang('AttachLiveLocation')}</div> <div className="location-info-title">{lang('AttachLiveLocation')}</div>
@ -201,10 +201,10 @@ const Location: FC<OwnProps> = ({
function renderPin() { function renderPin() {
const pinClassName = buildClassName( const pinClassName = buildClassName(
'pin', 'pin',
type, mediaType,
isExpired && 'expired', isExpired && 'expired',
); );
if (type === 'geoLive') { if (mediaType === 'geoLive') {
return ( return (
<div className={pinClassName}> <div className={pinClassName}>
<PinSvg /> <PinSvg />
@ -216,7 +216,7 @@ const Location: FC<OwnProps> = ({
); );
} }
if (type === 'venue') { if (mediaType === 'venue') {
const color = getVenueColor(location.venueType); const color = getVenueColor(location.venueType);
const iconSrc = getVenueIconUrl(location.venueType); const iconSrc = getVenueIconUrl(location.venueType);
if (iconSrc) { if (iconSrc) {

View File

@ -38,8 +38,10 @@ import { AudioOrigin } from '../../../types';
import { EMOJI_STATUS_LOOP_LIMIT, GENERAL_TOPIC_ID } from '../../../config'; import { EMOJI_STATUS_LOOP_LIMIT, GENERAL_TOPIC_ID } from '../../../config';
import { import {
areReactionsEmpty, areReactionsEmpty,
getIsDownloading,
getMessageContent, getMessageContent,
getMessageCustomShape, getMessageCustomShape,
getMessageDownloadableMedia,
getMessageHtmlId, getMessageHtmlId,
getMessageSingleCustomEmoji, getMessageSingleCustomEmoji,
getMessageSingleRegularEmoji, getMessageSingleRegularEmoji,
@ -61,6 +63,7 @@ import {
} from '../../../global/helpers'; } from '../../../global/helpers';
import { getMessageReplyInfo, getStoryReplyInfo } from '../../../global/helpers/replies'; import { getMessageReplyInfo, getStoryReplyInfo } from '../../../global/helpers/replies';
import { import {
selectActiveDownloads,
selectAllowedMessageActions, selectAllowedMessageActions,
selectAnimatedEmoji, selectAnimatedEmoji,
selectCanAutoLoadMedia, selectCanAutoLoadMedia,
@ -76,7 +79,6 @@ import {
selectIsChatWithSelf, selectIsChatWithSelf,
selectIsCurrentUserPremium, selectIsCurrentUserPremium,
selectIsDocumentGroupSelected, selectIsDocumentGroupSelected,
selectIsDownloading,
selectIsInSelectMode, selectIsInSelectMode,
selectIsMessageFocused, selectIsMessageFocused,
selectIsMessageProtected, selectIsMessageProtected,
@ -117,6 +119,7 @@ import renderText from '../../common/helpers/renderText';
import { getCustomEmojiSize } from '../composer/helpers/customEmoji'; import { getCustomEmojiSize } from '../composer/helpers/customEmoji';
import { buildContentClassName } from './helpers/buildContentClassName'; import { buildContentClassName } from './helpers/buildContentClassName';
import { calculateAlbumLayout } from './helpers/calculateAlbumLayout'; import { calculateAlbumLayout } from './helpers/calculateAlbumLayout';
import getSingularPaidMedia from './helpers/getSingularPaidMedia';
import { calculateMediaDimensions, getMinMediaWidth, MIN_MEDIA_WIDTH_WITH_TEXT } from './helpers/mediaDimensions'; import { calculateMediaDimensions, getMinMediaWidth, MIN_MEDIA_WIDTH_WITH_TEXT } from './helpers/mediaDimensions';
import useAppLayout from '../../../hooks/useAppLayout'; import useAppLayout from '../../../hooks/useAppLayout';
@ -170,6 +173,7 @@ import MessageAppendix from './MessageAppendix';
import MessageEffect from './MessageEffect'; import MessageEffect from './MessageEffect';
import MessageMeta from './MessageMeta'; import MessageMeta from './MessageMeta';
import MessagePhoneCall from './MessagePhoneCall'; import MessagePhoneCall from './MessagePhoneCall';
import PaidMediaOverlay from './PaidMediaOverlay';
import Photo from './Photo'; import Photo from './Photo';
import Poll from './Poll'; import Poll from './Poll';
import Reactions from './reactions/Reactions'; import Reactions from './reactions/Reactions';
@ -495,6 +499,17 @@ const Message: FC<OwnProps & StateProps> = ({
const isScheduled = messageListType === 'scheduled' || message.isScheduled; const isScheduled = messageListType === 'scheduled' || message.isScheduled;
const hasMessageReply = isReplyToMessage(message) && !shouldHideReply; const hasMessageReply = isReplyToMessage(message) && !shouldHideReply;
const { paidMedia } = getMessageContent(message);
const { photo: paidMediaPhoto, video: paidMediaVideo } = getSingularPaidMedia(paidMedia);
const {
photo = paidMediaPhoto, video = paidMediaVideo, audio,
voice, document, sticker, contact,
poll, webPage, invoice, location,
action, game, storyData, giveaway,
giveawayResults,
} = getMessageContent(message);
const messageReplyInfo = getMessageReplyInfo(message); const messageReplyInfo = getMessageReplyInfo(message);
const storyReplyInfo = getStoryReplyInfo(message); const storyReplyInfo = getStoryReplyInfo(message);
@ -510,11 +525,15 @@ const Message: FC<OwnProps & StateProps> = ({
&& !isAnonymousForwards && !isAnonymousForwards
&& !forwardInfo.isLinkedChannelPost && !forwardInfo.isLinkedChannelPost
&& !isCustomShape && !isCustomShape
) || Boolean(message.content.storyData && !message.content.storyData.isMention); ) || Boolean(storyData && !storyData.isMention);
const canShowSenderBoosts = Boolean(senderBoosts) && !asForwarded && isFirstInGroup; const canShowSenderBoosts = Boolean(senderBoosts) && !asForwarded && isFirstInGroup;
const isStoryMention = message.content.storyData?.isMention; const isStoryMention = storyData?.isMention;
const isAlbum = Boolean(album) && album!.messages.length > 1 const isRoundVideo = video?.mediaType === 'video' && video.isRound;
&& !album?.messages.some((msg) => Object.keys(msg.content).length === 0); const isAlbum = Boolean(album)
&& (
(album.isPaidMedia && paidMedia!.extendedMedia.length > 1)
|| album.messages.length > 1
) && !album.messages.some((msg) => Object.keys(msg.content).length === 0);
const isInDocumentGroupNotFirst = isInDocumentGroup && !isFirstInDocumentGroup; const isInDocumentGroupNotFirst = isInDocumentGroup && !isFirstInDocumentGroup;
const isInDocumentGroupNotLast = isInDocumentGroup && !isLastInDocumentGroup; const isInDocumentGroupNotLast = isInDocumentGroup && !isLastInDocumentGroup;
const isContextMenuShown = contextMenuPosition !== undefined; const isContextMenuShown = contextMenuPosition !== undefined;
@ -552,7 +571,7 @@ const Message: FC<OwnProps & StateProps> = ({
&& (isChatWithSelf || isRepliesChat || isAnonymousForwards || !messageSender); && (isChatWithSelf || isRepliesChat || isAnonymousForwards || !messageSender);
const avatarPeer = shouldPreferOriginSender ? originSender : messageSender; const avatarPeer = shouldPreferOriginSender ? originSender : messageSender;
const messageColorPeer = originSender || sender; const messageColorPeer = originSender || sender;
const senderPeer = (forwardInfo || message.content.storyData) ? originSender : messageSender; const senderPeer = (forwardInfo || storyData) ? originSender : messageSender;
const hasTtl = hasMessageTtl(message); const hasTtl = hasMessageTtl(message);
const { const {
@ -676,13 +695,6 @@ const Message: FC<OwnProps & StateProps> = ({
isStoryMention && 'is-story-mention', isStoryMention && 'is-story-mention',
); );
const {
photo, video, audio,
voice, document, sticker, contact,
poll, webPage, invoice, location,
action, game, storyData, giveaway,
giveawayResults,
} = getMessageContent(message);
const text = textMessage && getMessageContent(textMessage).text; const text = textMessage && getMessageContent(textMessage).text;
const isInvertedMedia = Boolean(message.isInvertedMedia); const isInvertedMedia = Boolean(message.isInvertedMedia);
@ -726,7 +738,7 @@ const Message: FC<OwnProps & StateProps> = ({
&& !isInDocumentGroupNotLast && !isStoryMention && !hasTtl; && !isInDocumentGroupNotLast && !isStoryMention && !hasTtl;
const hasOutsideReactions = hasReactions const hasOutsideReactions = hasReactions
&& (isCustomShape || ((photo || video || storyData || (location?.type === 'geo')) && !hasText)); && (isCustomShape || ((photo || video || storyData || (location?.mediaType === 'geo')) && !hasText));
const contentClassName = buildContentClassName(message, album, { const contentClassName = buildContentClassName(message, album, {
hasSubheader, hasSubheader,
@ -738,7 +750,7 @@ const Message: FC<OwnProps & StateProps> = ({
hasCommentCounter: hasThread && repliesThreadInfo.messagesCount > 0, hasCommentCounter: hasThread && repliesThreadInfo.messagesCount > 0,
hasActionButton: canForward || canFocus, hasActionButton: canForward || canFocus,
hasReactions, hasReactions,
isGeoLiveActive: location?.type === 'geoLive' && !isGeoLiveExpired(message), isGeoLiveActive: location?.mediaType === 'geoLive' && !isGeoLiveExpired(message),
withVoiceTranscription, withVoiceTranscription,
peerColorClass: getPeerColorClass(messageColorPeer, noUserColors), peerColorClass: getPeerColorClass(messageColorPeer, noUserColors),
hasOutsideReactions, hasOutsideReactions,
@ -868,12 +880,24 @@ const Message: FC<OwnProps & StateProps> = ({
if (!isAlbum && (photo || video || invoice?.extendedMedia)) { if (!isAlbum && (photo || video || invoice?.extendedMedia)) {
let width: number | undefined; let width: number | undefined;
if (photo) { if (photo) {
width = calculateMediaDimensions(message, asForwarded, noAvatars, isMobile).width; width = calculateMediaDimensions({
media: photo,
isOwn,
asForwarded,
noAvatars,
isMobile,
}).width;
} else if (video) { } else if (video) {
if (video.isRound) { if (isRoundVideo) {
width = ROUND_VIDEO_DIMENSIONS_PX; width = ROUND_VIDEO_DIMENSIONS_PX;
} else { } else {
width = calculateMediaDimensions(message, asForwarded, noAvatars, isMobile).width; width = calculateMediaDimensions({
media: video,
isOwn,
asForwarded,
noAvatars,
isMobile,
}).width;
} }
} else if (invoice?.extendedMedia && ( } else if (invoice?.extendedMedia && (
invoice.extendedMedia.width && invoice.extendedMedia.height invoice.extendedMedia.width && invoice.extendedMedia.height
@ -921,7 +945,7 @@ const Message: FC<OwnProps & StateProps> = ({
}; };
}, [ }, [
albumLayout, asForwarded, extraPadding, hasSubheader, invoice?.extendedMedia, isAlbum, isMediaWithCommentButton, albumLayout, asForwarded, extraPadding, hasSubheader, invoice?.extendedMedia, isAlbum, isMediaWithCommentButton,
isMobile, isOwn, message, noAvatars, photo, sticker, text?.text, video, isMobile, isOwn, noAvatars, photo, sticker, text?.text, video, isRoundVideo,
]); ]);
const { const {
@ -1133,7 +1157,7 @@ const Message: FC<OwnProps & StateProps> = ({
chatId={chatId} chatId={chatId}
/> />
)} )}
{!isAlbum && video && video.isRound && ( {!isAlbum && isRoundVideo && (
<RoundVideo <RoundVideo
message={message} message={message}
observeIntersection={observeIntersectionForLoading} observeIntersection={observeIntersectionForLoading}
@ -1166,7 +1190,7 @@ const Message: FC<OwnProps & StateProps> = ({
)} )}
{document && ( {document && (
<Document <Document
message={message} document={document}
observeIntersection={observeIntersectionForLoading} observeIntersection={observeIntersectionForLoading}
canAutoLoad={canAutoLoadMedia} canAutoLoad={canAutoLoadMedia}
autoLoadFileMaxSizeMb={autoLoadFileMaxSizeMb} autoLoadFileMaxSizeMb={autoLoadFileMaxSizeMb}
@ -1282,7 +1306,7 @@ const Message: FC<OwnProps & StateProps> = ({
outgoingStatus && 'with-outgoing-icon', outgoingStatus && 'with-outgoing-icon',
); );
const hasMediaAfterText = isAlbum || (!isAlbum && photo) || (!isAlbum && video && !video.isRound); const hasMediaAfterText = isAlbum || (!isAlbum && photo) || (!isAlbum && video && !isRoundVideo);
const hasContentAfterText = hasMediaAfterText || (!hasAnimatedEmoji && hasFactCheck); const hasContentAfterText = hasMediaAfterText || (!hasAnimatedEmoji && hasFactCheck);
const isMetaInText = metaPosition === 'in-text'; const isMetaInText = metaPosition === 'in-text';
@ -1346,8 +1370,8 @@ const Message: FC<OwnProps & StateProps> = ({
); );
} }
function renderInvertibleMediaContent(hasCustomAppendix : boolean) { function renderInvertibleMediaContent(hasCustomAppendix: boolean) {
return ( const content = (
<> <>
{isAlbum && ( {isAlbum && (
<Album <Album
@ -1362,7 +1386,9 @@ const Message: FC<OwnProps & StateProps> = ({
)} )}
{!isAlbum && photo && ( {!isAlbum && photo && (
<Photo <Photo
message={message} messageText={text?.text}
photo={photo}
isOwn={isOwn}
observeIntersection={observeIntersectionForLoading} observeIntersection={observeIntersectionForLoading}
noAvatars={noAvatars} noAvatars={noAvatars}
canAutoLoad={canAutoLoadMedia} canAutoLoad={canAutoLoadMedia}
@ -1377,9 +1403,10 @@ const Message: FC<OwnProps & StateProps> = ({
onCancelUpload={handleCancelUpload} onCancelUpload={handleCancelUpload}
/> />
)} )}
{!isAlbum && video && !video.isRound && ( {!isAlbum && video && !isRoundVideo && (
<Video <Video
message={message} video={video}
isOwn={isOwn}
observeIntersectionForLoading={observeIntersectionForLoading} observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying} observeIntersectionForPlaying={observeIntersectionForPlaying}
forcedWidth={contentWidth} forcedWidth={contentWidth}
@ -1396,10 +1423,20 @@ const Message: FC<OwnProps & StateProps> = ({
)} )}
</> </>
); );
if (paidMedia) {
return (
<PaidMediaOverlay chatId={chatId} messageId={messageId} paidMedia={paidMedia} isOutgoing={isOwn}>
{content}
</PaidMediaOverlay>
);
}
return content;
} }
function renderSenderName() { function renderSenderName() {
const media = photo || video || location; const media = photo || video || location || paidMedia;
const shouldRender = !(isCustomShape && !viaBotId) && ( const shouldRender = !(isCustomShape && !viaBotId) && (
(withSenderName && (!media || hasTopicChip)) || asForwarded || viaBotId || forceSenderName (withSenderName && (!media || hasTopicChip)) || asForwarded || viaBotId || forceSenderName
) && !isInDocumentGroupNotFirst && !(hasMessageReply && isCustomShape); ) && !isInDocumentGroupNotFirst && !(hasMessageReply && isCustomShape);
@ -1714,7 +1751,9 @@ export default memo(withGlobal<OwnProps>(
} }
const { canReply } = (messageListType === 'thread' && selectAllowedMessageActions(global, message, threadId)) || {}; const { canReply } = (messageListType === 'thread' && selectAllowedMessageActions(global, message, threadId)) || {};
const isDownloading = selectIsDownloading(global, message); const activeDownloads = selectActiveDownloads(global);
const downloadableMedia = getMessageDownloadableMedia(message);
const isDownloading = downloadableMedia && getIsDownloading(activeDownloads, downloadableMedia);
const repliesThreadInfo = selectThreadInfo(global, chatId, album?.commentsMessage?.id || id); const repliesThreadInfo = selectThreadInfo(global, chatId, album?.commentsMessage?.id || id);

View File

@ -0,0 +1,25 @@
.root {
position: relative;
}
.buyButton {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.boughtStatus {
right: 0.1875rem;
left: auto !important;
}
.star {
margin-inline: 0.25rem 0.0625rem;
}
.buttonText {
display: flex;
align-items: center;
flex-shrink: 0;
}

View File

@ -0,0 +1,83 @@
import React, { memo, type TeactNode, useMemo } from '../../../lib/teact/teact';
import { getActions } from '../../../global';
import type { ApiPaidMedia } from '../../../api/types';
import { STARS_CURRENCY_CODE, STARS_ICON_PLACEHOLDER } from '../../../config';
import buildClassName from '../../../util/buildClassName';
import { formatCurrency } from '../../../util/formatCurrency';
import { replaceWithTeact } from '../../../util/replaceWithTeact';
import stopEvent from '../../../util/stopEvent';
import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import StarIcon from '../../common/icons/StarIcon';
import Button from '../../ui/Button';
import styles from './PaidMediaOverlay.module.scss';
type OwnProps = {
paidMedia: ApiPaidMedia;
chatId: string;
messageId: number;
isOutgoing?: boolean;
children?: TeactNode;
};
const PaidMediaOverlay = ({
paidMedia,
chatId,
messageId,
isOutgoing,
children,
}: OwnProps) => {
const { openInvoice } = getActions();
const lang = useOldLang();
const isClickable = !paidMedia.isBought;
const buttonText = useMemo(() => {
const value = lang('UnlockPaidContent', paidMedia.starsAmount);
return replaceWithTeact(
value, STARS_ICON_PLACEHOLDER, <StarIcon className={styles.star} type="gold" size="adaptive" />,
);
}, [lang, paidMedia]);
const handleClick = useLastCallback((e: React.MouseEvent) => {
openInvoice({
type: 'message',
chatId,
messageId,
});
stopEvent(e);
});
return (
<div
className={styles.root}
onClick={isClickable ? handleClick : undefined}
>
{children}
{isClickable && (
<Button
className={styles.buyButton}
color="dark"
size="tiny"
fluid
pill
>
<span className={styles.buttonText}>{buttonText}</span>
</Button>
)}
{paidMedia.isBought && (
<div className={buildClassName('message-paid-media-status', styles.boughtStatus)}>
{isOutgoing ? formatCurrency(paidMedia.starsAmount, STARS_CURRENCY_CODE) : lang('Chat.PaidMedia.Purchased')}
</div>
)}
</div>
);
};
export default memo(PaidMediaOverlay);

View File

@ -1,7 +1,6 @@
import type { FC } from '../../../lib/teact/teact';
import React, { useEffect, useRef, useState } from '../../../lib/teact/teact'; import React, { useEffect, useRef, useState } from '../../../lib/teact/teact';
import type { ApiMessage } from '../../../api/types'; import type { ApiMediaExtendedPreview, ApiPhoto } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { ISettings } from '../../../types'; import type { ISettings } from '../../../types';
import type { IMediaDimensions } from './helpers/calculateAlbumLayout'; import type { IMediaDimensions } from './helpers/calculateAlbumLayout';
@ -9,13 +8,10 @@ import type { IMediaDimensions } from './helpers/calculateAlbumLayout';
import { CUSTOM_APPENDIX_ATTRIBUTE, MESSAGE_CONTENT_SELECTOR } from '../../../config'; import { CUSTOM_APPENDIX_ATTRIBUTE, MESSAGE_CONTENT_SELECTOR } from '../../../config';
import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { requestMutation } from '../../../lib/fasterdom/fasterdom';
import { import {
getMediaFormat,
getMediaThumbUri,
getMediaTransferState, getMediaTransferState,
getMessageMediaFormat, getPhotoMediaHash,
getMessageMediaHash,
getMessageMediaThumbDataUri,
getMessagePhoto,
getMessageWebPagePhoto,
isOwnMessage,
} from '../../../global/helpers'; } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import getCustomAppendixBg from './helpers/getCustomAppendixBg'; import getCustomAppendixBg from './helpers/getCustomAppendixBg';
@ -35,9 +31,12 @@ import useBlurredMediaThumbRef from './hooks/useBlurredMediaThumbRef';
import MediaSpoiler from '../../common/MediaSpoiler'; import MediaSpoiler from '../../common/MediaSpoiler';
import ProgressSpinner from '../../ui/ProgressSpinner'; import ProgressSpinner from '../../ui/ProgressSpinner';
export type OwnProps = { export type OwnProps<T> = {
id?: string; id?: string;
message: ApiMessage; photo: ApiPhoto | ApiMediaExtendedPreview;
isInWebPage?: boolean;
messageText?: string;
isOwn?: boolean;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
noAvatars?: boolean; noAvatars?: boolean;
canAutoLoad?: boolean; canAutoLoad?: boolean;
@ -53,13 +52,18 @@ export type OwnProps = {
isDownloading?: boolean; isDownloading?: boolean;
isProtected?: boolean; isProtected?: boolean;
theme: ISettings['theme']; theme: ISettings['theme'];
onClick?: (id: number) => void; className?: string;
onCancelUpload?: (message: ApiMessage) => void; clickArg?: T;
onClick?: (arg: T, e: React.MouseEvent<HTMLElement>) => void;
onCancelUpload?: (arg: T) => void;
}; };
const Photo: FC<OwnProps> = ({ // eslint-disable-next-line @typescript-eslint/comma-dangle
const Photo = <T,>({
id, id,
message, photo,
messageText,
isOwn,
observeIntersection, observeIntersection,
noAvatars, noAvatars,
canAutoLoad, canAutoLoad,
@ -75,53 +79,57 @@ const Photo: FC<OwnProps> = ({
isDownloading, isDownloading,
isProtected, isProtected,
theme, theme,
isInWebPage,
clickArg,
className,
onClick, onClick,
onCancelUpload, onCancelUpload,
}) => { }: OwnProps<T>) => {
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const isPaidPreview = photo.mediaType === 'extendedMediaPreview';
const photo = (getMessagePhoto(message) || getMessageWebPagePhoto(message))!; const localBlobUrl = !isPaidPreview ? photo.blobUrl : undefined;
const localBlobUrl = photo.blobUrl;
const isIntersecting = useIsIntersecting(ref, observeIntersection); const isIntersecting = useIsIntersecting(ref, observeIntersection);
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const [isLoadAllowed, setIsLoadAllowed] = useState(canAutoLoad); const [isLoadAllowed, setIsLoadAllowed] = useState(canAutoLoad);
const shouldLoad = isLoadAllowed && isIntersecting; const shouldLoad = isLoadAllowed && isIntersecting && !isPaidPreview;
const { const {
mediaData, loadProgress, mediaData, loadProgress,
} = useMediaWithLoadProgress(getMessageMediaHash(message, size), !shouldLoad); } = useMediaWithLoadProgress(!isPaidPreview ? getPhotoMediaHash(photo, size) : undefined, !shouldLoad);
const fullMediaData = localBlobUrl || mediaData; const fullMediaData = localBlobUrl || mediaData;
const withBlurredBackground = Boolean(forcedWidth); const withBlurredBackground = Boolean(forcedWidth);
const [withThumb] = useState(!fullMediaData); const [withThumb] = useState(!fullMediaData);
const noThumb = Boolean(fullMediaData); const noThumb = Boolean(fullMediaData);
const thumbRef = useBlurredMediaThumbRef(message, noThumb); const thumbRef = useBlurredMediaThumbRef(photo, noThumb);
const blurredBackgroundRef = useBlurredMediaThumbRef(message, !withBlurredBackground); const blurredBackgroundRef = useBlurredMediaThumbRef(photo, !withBlurredBackground);
const thumbClassNames = useMediaTransition(!noThumb); const thumbClassNames = useMediaTransition(!noThumb);
const thumbDataUri = getMessageMediaThumbDataUri(message); const thumbDataUri = getMediaThumbUri(photo);
const [isSpoilerShown, showSpoiler, hideSpoiler] = useFlag(photo.isSpoiler); const [isSpoilerShown, showSpoiler, hideSpoiler] = useFlag(isPaidPreview || photo.isSpoiler);
useEffect(() => { useEffect(() => {
if (photo.isSpoiler) { if (isPaidPreview || photo.isSpoiler) {
showSpoiler(); showSpoiler();
} else { } else {
hideSpoiler(); hideSpoiler();
} }
}, [photo.isSpoiler]); }, [isPaidPreview, photo]);
const { const {
loadProgress: downloadProgress, loadProgress: downloadProgress,
} = useMediaWithLoadProgress( } = useMediaWithLoadProgress(
getMessageMediaHash(message, 'download'), !isDownloading, getMessageMediaFormat(message, 'download'), !isPaidPreview ? getPhotoMediaHash(photo, 'download') : undefined,
!isDownloading,
!isPaidPreview ? getMediaFormat(photo, 'download') : undefined,
); );
const { const {
isUploading, isTransferring, transferProgress, isUploading, isTransferring, transferProgress,
} = getMediaTransferState( } = getMediaTransferState(
message,
uploadProgress || (isDownloading ? downloadProgress : loadProgress), uploadProgress || (isDownloading ? downloadProgress : loadProgress),
shouldLoad && !fullMediaData, shouldLoad && !fullMediaData,
uploadProgress !== undefined, uploadProgress !== undefined,
@ -137,9 +145,9 @@ const Photo: FC<OwnProps> = ({
transitionClassNames: downloadButtonClassNames, transitionClassNames: downloadButtonClassNames,
} = useShowTransition(!fullMediaData && !isLoadAllowed); } = useShowTransition(!fullMediaData && !isLoadAllowed);
const handleClick = useLastCallback(() => { const handleClick = useLastCallback((e: React.MouseEvent<HTMLElement>) => {
if (isUploading) { if (isUploading) {
onCancelUpload?.(message); onCancelUpload?.(clickArg!);
return; return;
} }
@ -153,10 +161,9 @@ const Photo: FC<OwnProps> = ({
return; return;
} }
onClick?.(message.id); onClick?.(clickArg!, e);
}); });
const isOwn = isOwnMessage(message);
useLayoutEffectWithPrevDeps(([prevShouldAffectAppendix]) => { useLayoutEffectWithPrevDeps(([prevShouldAffectAppendix]) => {
if (!shouldAffectAppendix) { if (!shouldAffectAppendix) {
if (prevShouldAffectAppendix) { if (prevShouldAffectAppendix) {
@ -167,7 +174,7 @@ const Photo: FC<OwnProps> = ({
const contentEl = ref.current!.closest<HTMLDivElement>(MESSAGE_CONTENT_SELECTOR)!; const contentEl = ref.current!.closest<HTMLDivElement>(MESSAGE_CONTENT_SELECTOR)!;
if (fullMediaData) { if (fullMediaData) {
getCustomAppendixBg(fullMediaData, isOwn, isSelected, theme).then((appendixBg) => { getCustomAppendixBg(fullMediaData, Boolean(isOwn), isSelected, theme).then((appendixBg) => {
requestMutation(() => { requestMutation(() => {
contentEl.style.setProperty('--appendix-bg', appendixBg); contentEl.style.setProperty('--appendix-bg', appendixBg);
contentEl.setAttribute(CUSTOM_APPENDIX_ATTRIBUTE, ''); contentEl.setAttribute(CUSTOM_APPENDIX_ATTRIBUTE, '');
@ -178,14 +185,23 @@ const Photo: FC<OwnProps> = ({
} }
}, [shouldAffectAppendix, fullMediaData, isOwn, isInSelectMode, isSelected, theme]); }, [shouldAffectAppendix, fullMediaData, isOwn, isInSelectMode, isSelected, theme]);
const { width, height, isSmall } = dimensions || calculateMediaDimensions(message, asForwarded, noAvatars, isMobile); const { width, height, isSmall } = dimensions || calculateMediaDimensions({
media: photo,
isOwn,
asForwarded,
noAvatars,
isMobile,
messageText,
isInWebPage,
});
const className = buildClassName( const componentClassName = buildClassName(
'media-inner', 'media-inner',
!isUploading && !nonInteractive && 'interactive', !isUploading && !nonInteractive && 'interactive',
isSmall && 'small-image', isSmall && 'small-image',
width === height && 'square-image', width === height && 'square-image',
height < MIN_MEDIA_HEIGHT && 'fix-min-height', height < MIN_MEDIA_HEIGHT && 'fix-min-height',
className,
); );
const dimensionsStyle = dimensions ? ` width: ${width}px; left: ${dimensions.x}px; top: ${dimensions.y}px;` : ''; const dimensionsStyle = dimensions ? ` width: ${width}px; left: ${dimensions.x}px; top: ${dimensions.y}px;` : '';
@ -195,7 +211,7 @@ const Photo: FC<OwnProps> = ({
<div <div
id={id} id={id}
ref={ref} ref={ref}
className={className} className={componentClassName}
style={style} style={style}
onClick={isUploading ? undefined : handleClick} onClick={isUploading ? undefined : handleClick}
> >

View File

@ -13,7 +13,7 @@ import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { ApiMediaFormat } from '../../../api/types'; import { ApiMediaFormat } from '../../../api/types';
import { import {
getMessageMediaFormat, getMessageMediaHash, getMessageMediaThumbDataUri, hasMessageTtl, getMediaFormat, getMessageMediaThumbDataUri, getVideoMediaHash, hasMessageTtl,
} from '../../../global/helpers'; } from '../../../global/helpers';
import { stopCurrentAudio } from '../../../util/audioPlayer'; import { stopCurrentAudio } from '../../../util/audioPlayer';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
@ -76,20 +76,20 @@ const RoundVideo: FC<OwnProps> = ({
const video = message.content.video!; const video = message.content.video!;
const { cancelMessageMediaDownload, openOneTimeMediaModal } = getActions(); const { cancelMediaDownload, openOneTimeMediaModal } = getActions();
const isIntersecting = useIsIntersecting(ref, observeIntersection); const isIntersecting = useIsIntersecting(ref, observeIntersection);
const [isLoadAllowed, setIsLoadAllowed] = useState(canAutoLoad); const [isLoadAllowed, setIsLoadAllowed] = useState(canAutoLoad);
const shouldLoad = Boolean(isLoadAllowed && isIntersecting); const shouldLoad = Boolean(isLoadAllowed && isIntersecting);
const { mediaData, loadProgress } = useMediaWithLoadProgress( const { mediaData, loadProgress } = useMediaWithLoadProgress(
getMessageMediaHash(message, 'inline'), getVideoMediaHash(video, 'inline'),
!shouldLoad, !shouldLoad,
getMessageMediaFormat(message, 'inline'), getMediaFormat(video, 'inline'),
); );
const { loadProgress: downloadProgress } = useMediaWithLoadProgress( const { loadProgress: downloadProgress } = useMediaWithLoadProgress(
getMessageMediaHash(message, 'download'), getVideoMediaHash(video, 'download'),
!isDownloading, !isDownloading,
ApiMediaFormat.BlobUrl, ApiMediaFormat.BlobUrl,
); );
@ -100,7 +100,7 @@ const RoundVideo: FC<OwnProps> = ({
const shouldRenderSpoiler = hasTtl && !isInOneTimeModal; const shouldRenderSpoiler = hasTtl && !isInOneTimeModal;
const hasThumb = Boolean(getMessageMediaThumbDataUri(message)); const hasThumb = Boolean(getMessageMediaThumbDataUri(message));
const noThumb = !hasThumb || isPlayerReady || shouldRenderSpoiler; const noThumb = !hasThumb || isPlayerReady || shouldRenderSpoiler;
const thumbRef = useBlurredMediaThumbRef(message, noThumb); const thumbRef = useBlurredMediaThumbRef(video, noThumb);
const thumbClassNames = useMediaTransition(!noThumb); const thumbClassNames = useMediaTransition(!noThumb);
const thumbDataUri = getMessageMediaThumbDataUri(message); const thumbDataUri = getMessageMediaThumbDataUri(message);
const isTransferring = (isLoadAllowed && !isPlayerReady) || isDownloading; const isTransferring = (isLoadAllowed && !isPlayerReady) || isDownloading;
@ -186,7 +186,7 @@ const RoundVideo: FC<OwnProps> = ({
} }
if (isDownloading) { if (isDownloading) {
cancelMessageMediaDownload({ message }); cancelMediaDownload({ media: video });
return; return;
} }

View File

@ -6,7 +6,7 @@ import type { ApiMessage } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { ApiMediaFormat } from '../../../api/types'; import { ApiMediaFormat } from '../../../api/types';
import { getMessageMediaHash } from '../../../global/helpers'; import { getStickerMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { IS_WEBM_SUPPORTED } from '../../../util/windowEnvironment'; import { IS_WEBM_SUPPORTED } from '../../../util/windowEnvironment';
import { getStickerDimensions } from '../../common/helpers/mediaDimensions'; import { getStickerDimensions } from '../../common/helpers/mediaDimensions';
@ -58,7 +58,7 @@ const Sticker: FC<OwnProps> = ({
const isMirrored = !message.isOutgoing; const isMirrored = !message.isOutgoing;
const mediaHash = sticker.isPreloadedGlobally ? undefined : ( const mediaHash = sticker.isPreloadedGlobally ? undefined : (
getMessageMediaHash(message, isVideo && !IS_WEBM_SUPPORTED ? 'pictogram' : 'inline')! getStickerMediaHash(sticker, isVideo && !IS_WEBM_SUPPORTED ? 'pictogram' : 'inline')!
); );
const canLoad = useIsIntersecting(ref, observeIntersection); const canLoad = useIsIntersecting(ref, observeIntersection);

View File

@ -1,24 +1,20 @@
import type { FC } from '../../../lib/teact/teact';
import React, { useEffect, useRef, useState } from '../../../lib/teact/teact'; import React, { useEffect, useRef, useState } from '../../../lib/teact/teact';
import { getActions } from '../../../global'; import { getActions } from '../../../global';
import type { ApiMessage } from '../../../api/types'; import type { ApiMediaExtendedPreview, ApiVideo } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { IMediaDimensions } from './helpers/calculateAlbumLayout'; import type { IMediaDimensions } from './helpers/calculateAlbumLayout';
import { import {
getMediaFormat,
getMediaThumbUri,
getMediaTransferState, getMediaTransferState,
getMessageMediaFormat, getVideoMediaHash,
getMessageMediaHash,
getMessageMediaThumbDataUri,
getMessageVideo,
getMessageWebPageVideo,
isOwnMessage,
} from '../../../global/helpers'; } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { formatMediaDuration } from '../../../util/dates/dateFormat'; import { formatMediaDuration } from '../../../util/dates/dateFormat';
import * as mediaLoader from '../../../util/mediaLoader'; import * as mediaLoader from '../../../util/mediaLoader';
import { calculateVideoDimensions } from '../../common/helpers/mediaDimensions'; import { calculateExtendedPreviewDimensions, calculateVideoDimensions } from '../../common/helpers/mediaDimensions';
import { MIN_MEDIA_HEIGHT } from './helpers/mediaDimensions'; import { MIN_MEDIA_HEIGHT } from './helpers/mediaDimensions';
import useUnsupportedMedia from '../../../hooks/media/useUnsupportedMedia'; import useUnsupportedMedia from '../../../hooks/media/useUnsupportedMedia';
@ -37,10 +33,12 @@ import MediaSpoiler from '../../common/MediaSpoiler';
import OptimizedVideo from '../../ui/OptimizedVideo'; import OptimizedVideo from '../../ui/OptimizedVideo';
import ProgressSpinner from '../../ui/ProgressSpinner'; import ProgressSpinner from '../../ui/ProgressSpinner';
export type OwnProps = { export type OwnProps<T> = {
id?: string; id?: string;
message: ApiMessage; video: ApiVideo | ApiMediaExtendedPreview;
observeIntersectionForLoading: ObserveFn; isOwn?: boolean;
isInWebPage?: boolean;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn; observeIntersectionForPlaying?: ObserveFn;
noAvatars?: boolean; noAvatars?: boolean;
canAutoLoad?: boolean; canAutoLoad?: boolean;
@ -51,13 +49,18 @@ export type OwnProps = {
asForwarded?: boolean; asForwarded?: boolean;
isDownloading?: boolean; isDownloading?: boolean;
isProtected?: boolean; isProtected?: boolean;
onClick?: (id: number, isGif?: boolean) => void; className?: string;
onCancelUpload?: (message: ApiMessage) => void; clickArg?: T;
onClick?: (arg: T, e: React.MouseEvent<HTMLElement>) => void;
onCancelUpload?: (arg: T) => void;
}; };
const Video: FC<OwnProps> = ({ // eslint-disable-next-line @typescript-eslint/comma-dangle
const Video = <T,>({
id, id,
message, video,
isOwn,
isInWebPage,
observeIntersectionForLoading, observeIntersectionForLoading,
observeIntersectionForPlaying, observeIntersectionForPlaying,
noAvatars, noAvatars,
@ -69,26 +72,30 @@ const Video: FC<OwnProps> = ({
asForwarded, asForwarded,
isDownloading, isDownloading,
isProtected, isProtected,
className,
clickArg,
onClick, onClick,
onCancelUpload, onCancelUpload,
}) => { }: OwnProps<T>) => {
const { cancelMediaDownload } = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const video = (getMessageVideo(message) || getMessageWebPageVideo(message))!; const isPaidPreview = video.mediaType === 'extendedMediaPreview';
const localBlobUrl = video.blobUrl;
const [isSpoilerShown, showSpoiler, hideSpoiler] = useFlag(video.isSpoiler); const localBlobUrl = !isPaidPreview ? video.blobUrl : undefined;
const [isSpoilerShown, showSpoiler, hideSpoiler] = useFlag(isPaidPreview || video.isSpoiler);
useEffect(() => { useEffect(() => {
if (video.isSpoiler) { if (isPaidPreview || video.isSpoiler) {
showSpoiler(); showSpoiler();
} else { } else {
hideSpoiler(); hideSpoiler();
} }
}, [video.isSpoiler]); }, [isPaidPreview, video]);
const isIntersectingForLoading = useIsIntersecting(ref, observeIntersectionForLoading); const isIntersectingForLoading = useIsIntersecting(ref, observeIntersectionForLoading);
const isIntersectingForPlaying = ( const isIntersectingForPlaying = (
@ -102,43 +109,44 @@ const Video: FC<OwnProps> = ({
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const [isLoadAllowed, setIsLoadAllowed] = useState(canAutoLoad); const [isLoadAllowed, setIsLoadAllowed] = useState(canAutoLoad);
const shouldLoad = Boolean(isLoadAllowed && isIntersectingForLoading); const shouldLoad = Boolean(isLoadAllowed && isIntersectingForLoading && !isPaidPreview);
const [isPlayAllowed, setIsPlayAllowed] = useState(Boolean(canAutoPlay && !isSpoilerShown)); const [isPlayAllowed, setIsPlayAllowed] = useState(Boolean(canAutoPlay && !isSpoilerShown));
const fullMediaHash = getMessageMediaHash(message, 'inline'); const fullMediaHash = !isPaidPreview ? getVideoMediaHash(video, 'inline') : undefined;
const [isFullMediaPreloaded] = useState(Boolean(fullMediaHash && mediaLoader.getFromMemory(fullMediaHash))); const [isFullMediaPreloaded] = useState(Boolean(fullMediaHash && mediaLoader.getFromMemory(fullMediaHash)));
const { mediaData, loadProgress } = useMediaWithLoadProgress( const { mediaData, loadProgress } = useMediaWithLoadProgress(
fullMediaHash, !shouldLoad, getMessageMediaFormat(message, 'inline'), fullMediaHash,
!shouldLoad,
!isPaidPreview ? getMediaFormat(video, 'inline') : undefined,
); );
const fullMediaData = localBlobUrl || mediaData; const fullMediaData = localBlobUrl || mediaData;
const [isPlayerReady, markPlayerReady] = useFlag(); const [isPlayerReady, markPlayerReady] = useFlag();
const thumbDataUri = getMessageMediaThumbDataUri(message); const thumbDataUri = getMediaThumbUri(video);
const hasThumb = Boolean(thumbDataUri); const hasThumb = Boolean(thumbDataUri);
const withBlurredBackground = Boolean(forcedWidth); const withBlurredBackground = Boolean(forcedWidth);
const previewMediaHash = getMessageMediaHash(message, 'preview'); const previewMediaHash = !isPaidPreview ? getVideoMediaHash(video, 'preview') : undefined;
const [isPreviewPreloaded] = useState(Boolean(previewMediaHash && mediaLoader.getFromMemory(previewMediaHash))); const [isPreviewPreloaded] = useState(Boolean(previewMediaHash && mediaLoader.getFromMemory(previewMediaHash)));
const canLoadPreview = isIntersectingForLoading; const canLoadPreview = isIntersectingForLoading;
const previewBlobUrl = useMedia(previewMediaHash, !canLoadPreview); const previewBlobUrl = useMedia(previewMediaHash, !canLoadPreview);
const previewClassNames = useMediaTransition((hasThumb || previewBlobUrl) && !isPlayerReady); const previewClassNames = useMediaTransition((hasThumb || previewBlobUrl) && !isPlayerReady);
const noThumb = !hasThumb || previewBlobUrl || isPlayerReady; const noThumb = Boolean(!hasThumb || previewBlobUrl || isPlayerReady);
const thumbRef = useBlurredMediaThumbRef(message, noThumb); const thumbRef = useBlurredMediaThumbRef(video, noThumb);
const blurredBackgroundRef = useBlurredMediaThumbRef(message, !withBlurredBackground); const blurredBackgroundRef = useBlurredMediaThumbRef(video, !withBlurredBackground);
const thumbClassNames = useMediaTransition(!noThumb); const thumbClassNames = useMediaTransition(!noThumb);
const isInline = fullMediaData && wasIntersectedRef.current; const isInline = fullMediaData && wasIntersectedRef.current;
const isUnsupported = useUnsupportedMedia(videoRef, true, !isInline); const isUnsupported = useUnsupportedMedia(videoRef, true, !isInline);
const { loadProgress: downloadProgress } = useMediaWithLoadProgress( const { loadProgress: downloadProgress } = useMediaWithLoadProgress(
getMessageMediaHash(message, 'download'), !isPaidPreview ? getVideoMediaHash(video, 'download') : undefined,
!isDownloading, !isDownloading,
getMessageMediaFormat(message, 'download'), !isPaidPreview ? getMediaFormat(video, 'download') : undefined,
); );
const { isUploading, isTransferring, transferProgress } = getMediaTransferState( const { isUploading, isTransferring, transferProgress } = getMediaTransferState(
message,
uploadProgress || (isDownloading ? downloadProgress : loadProgress), uploadProgress || (isDownloading ? downloadProgress : loadProgress),
(shouldLoad && !isPlayerReady && !isFullMediaPreloaded) || isDownloading, (shouldLoad && !isPlayerReady && !isFullMediaPreloaded) || isDownloading,
uploadProgress !== undefined, uploadProgress !== undefined,
@ -160,20 +168,22 @@ const Video: FC<OwnProps> = ({
const duration = (Number.isFinite(videoRef.current?.duration) ? videoRef.current?.duration : video.duration) || 0; const duration = (Number.isFinite(videoRef.current?.duration) ? videoRef.current?.duration : video.duration) || 0;
const isOwn = isOwnMessage(message);
const isWebPageVideo = Boolean(getMessageWebPageVideo(message));
const { const {
width, height, width, height,
} = dimensions || calculateVideoDimensions(video, isOwn, asForwarded, isWebPageVideo, noAvatars, isMobile); } = dimensions || (
isPaidPreview
? calculateExtendedPreviewDimensions(video, Boolean(isOwn), asForwarded, isInWebPage, noAvatars, isMobile)
: calculateVideoDimensions(video, Boolean(isOwn), asForwarded, isInWebPage, noAvatars, isMobile)
);
const handleClick = useLastCallback(() => { const handleClick = useLastCallback((e: React.MouseEvent<HTMLElement>) => {
if (isUploading) { if (isUploading) {
onCancelUpload?.(message); onCancelUpload?.(clickArg!);
return; return;
} }
if (isDownloading) { if (!isPaidPreview && isDownloading) {
getActions().cancelMessageMediaDownload({ message }); cancelMediaDownload({ media: video });
return; return;
} }
@ -191,13 +201,14 @@ const Video: FC<OwnProps> = ({
return; return;
} }
onClick?.(message.id, video?.isGif); onClick?.(clickArg!, e);
}); });
const className = buildClassName( const componentClassName = buildClassName(
'media-inner dark', 'media-inner dark',
!isUploading && 'interactive', !isUploading && 'interactive',
height < MIN_MEDIA_HEIGHT && 'fix-min-height', height < MIN_MEDIA_HEIGHT && 'fix-min-height',
className,
); );
const dimensionsStyle = dimensions ? ` width: ${width}px; left: ${dimensions.x}px; top: ${dimensions.y}px;` : ''; const dimensionsStyle = dimensions ? ` width: ${width}px; left: ${dimensions.x}px; top: ${dimensions.y}px;` : '';
@ -207,7 +218,7 @@ const Video: FC<OwnProps> = ({
<div <div
ref={ref} ref={ref}
id={id} id={id}
className={className} className={componentClassName}
style={style} style={style}
onClick={isUploading ? undefined : handleClick} onClick={isUploading ? undefined : handleClick}
> >
@ -266,7 +277,7 @@ const Video: FC<OwnProps> = ({
</span> </span>
) : ( ) : (
<div className="message-media-duration"> <div className="message-media-duration">
{video.isGif ? 'GIF' : formatMediaDuration(Math.max(duration - playProgress, 0))} {!isPaidPreview && video.isGif ? 'GIF' : formatMediaDuration(Math.max(duration - playProgress, 0))}
{isUnsupported && <i className="icon icon-message-failed playback-failed" />} {isUnsupported && <i className="icon icon-message-failed playback-failed" />}
</div> </div>
)} )}

View File

@ -137,7 +137,14 @@ const WebPage: FC<OwnProps> = ({
const isArticle = Boolean(truncatedDescription || title || siteName); const isArticle = Boolean(truncatedDescription || title || siteName);
let isSquarePhoto = Boolean(stickers); let isSquarePhoto = Boolean(stickers);
if (isArticle && webPage?.photo && !webPage.video) { if (isArticle && webPage?.photo && !webPage.video) {
const { width, height } = calculateMediaDimensions(message, undefined, undefined, isMobile); const { width, height } = calculateMediaDimensions({
media: webPage.photo,
isOwn: message.isOutgoing,
isInWebPage: true,
asForwarded,
noAvatars,
isMobile,
});
isSquarePhoto = width === height; isSquarePhoto = width === height;
} }
const isMediaInteractive = (photo || video) && onMediaClick && !isSquarePhoto; const isMediaInteractive = (photo || video) && onMediaClick && !isSquarePhoto;
@ -188,7 +195,9 @@ const WebPage: FC<OwnProps> = ({
)} )}
{photo && !video && ( {photo && !video && (
<Photo <Photo
message={message} photo={photo}
isOwn={message.isOutgoing}
isInWebPage
observeIntersection={observeIntersectionForLoading} observeIntersection={observeIntersectionForLoading}
noAvatars={noAvatars} noAvatars={noAvatars}
canAutoLoad={canAutoLoad} canAutoLoad={canAutoLoad}
@ -215,7 +224,9 @@ const WebPage: FC<OwnProps> = ({
)} )}
{!inPreview && video && ( {!inPreview && video && (
<Video <Video
message={message} video={video}
isOwn={message.isOutgoing}
isInWebPage
observeIntersectionForLoading={observeIntersectionForLoading!} observeIntersectionForLoading={observeIntersectionForLoading!}
noAvatars={noAvatars} noAvatars={noAvatars}
canAutoLoad={canAutoLoad} canAutoLoad={canAutoLoad}
@ -240,7 +251,7 @@ const WebPage: FC<OwnProps> = ({
)} )}
{!inPreview && document && ( {!inPreview && document && (
<Document <Document
message={message} document={document}
observeIntersection={observeIntersectionForLoading} observeIntersection={observeIntersectionForLoading}
autoLoadFileMaxSizeMb={autoLoadFileMaxSizeMb} autoLoadFileMaxSizeMb={autoLoadFileMaxSizeMb}
onMediaClick={handleMediaClick} onMediaClick={handleMediaClick}

View File

@ -37,6 +37,11 @@
user-select: text; user-select: text;
} }
.content-unsupported {
font-style: italic;
color: var(--color-text-meta);
}
.text-content, .text-content,
.transcription { .transcription {
margin: 0; margin: 0;
@ -678,7 +683,8 @@
} }
.message-media-duration, .message-media-duration,
.message-transfer-progress { .message-transfer-progress,
.message-paid-media-status {
background: rgba(0, 0, 0, 0.25); background: rgba(0, 0, 0, 0.25);
color: #fff; color: #fff;
font-size: 0.75rem; font-size: 0.75rem;

View File

@ -3,6 +3,7 @@ import type { IAlbum } from '../../../../types';
import { EMOJI_SIZES, MESSAGE_CONTENT_CLASS_NAME } from '../../../../config'; import { EMOJI_SIZES, MESSAGE_CONTENT_CLASS_NAME } from '../../../../config';
import { getMessageContent } from '../../../../global/helpers'; import { getMessageContent } from '../../../../global/helpers';
import getSingularPaidMedia from './getSingularPaidMedia';
export function buildContentClassName( export function buildContentClassName(
message: ApiMessage, message: ApiMessage,
@ -37,19 +38,24 @@ export function buildContentClassName(
hasOutsideReactions?: boolean; hasOutsideReactions?: boolean;
} = {}, } = {},
) { ) {
const { paidMedia } = getMessageContent(message);
const { photo: paidMediaPhoto, video: paidMediaVideo } = getSingularPaidMedia(paidMedia);
const { const {
photo, video, audio, voice, document, poll, webPage, contact, location, invoice, storyData, photo = paidMediaPhoto, video = paidMediaVideo,
audio, voice, document, poll, webPage, contact, location, invoice, storyData,
giveaway, giveawayResults, giveaway, giveawayResults,
} = getMessageContent(message); } = getMessageContent(message);
const text = album?.hasMultipleCaptions ? undefined : getMessageContent(album?.captionMessage || message).text; const text = album?.hasMultipleCaptions ? undefined : getMessageContent(album?.captionMessage || message).text;
const hasFactCheck = Boolean(message.factCheck?.text); const hasFactCheck = Boolean(message.factCheck?.text);
const isRoundVideo = video?.mediaType === 'video' && video.isRound;
const isInvertedMedia = message.isInvertedMedia; const isInvertedMedia = message.isInvertedMedia;
const isInvertibleMedia = photo || (video && !video?.isRound) || album || webPage; const isInvertibleMedia = photo || (video && !isRoundVideo) || album || webPage;
const classNames = [MESSAGE_CONTENT_CLASS_NAME]; const classNames = [MESSAGE_CONTENT_CLASS_NAME];
const isMedia = storyData || photo || video || location || invoice?.extendedMedia; const isMedia = storyData || photo || video || location || invoice?.extendedMedia || paidMedia;
const hasText = text || location?.type === 'venue' || isGeoLiveActive || hasFactCheck; const hasText = text || location?.mediaType === 'venue' || isGeoLiveActive || hasFactCheck;
const isMediaWithNoText = isMedia && !hasText; const isMediaWithNoText = isMedia && !hasText;
const isViaBot = Boolean(message.viaBotId); const isViaBot = Boolean(message.viaBotId);
@ -84,7 +90,7 @@ export function buildContentClassName(
if (isCustomShape) { if (isCustomShape) {
classNames.push('custom-shape'); classNames.push('custom-shape');
if (video?.isRound) { if (isRoundVideo) {
classNames.push('round'); classNames.push('round');
} }

View File

@ -6,6 +6,7 @@
import type { ApiDimensions, ApiMessage } from '../../../../api/types'; import type { ApiDimensions, ApiMessage } from '../../../../api/types';
import type { IAlbum } from '../../../../types'; import type { IAlbum } from '../../../../types';
import { getMessageContent } from '../../../../global/helpers';
import { clamp } from '../../../../util/math'; import { clamp } from '../../../../util/math';
import { getAvailableWidth, REM } from '../../../common/helpers/mediaDimensions'; import { getAvailableWidth, REM } from '../../../common/helpers/mediaDimensions';
import { calculateMediaDimensions } from './mediaDimensions'; import { calculateMediaDimensions } from './mediaDimensions';
@ -46,10 +47,23 @@ export type IAlbumLayout = {
containerStyle: ApiDimensions; containerStyle: ApiDimensions;
}; };
function getRatios(messages: ApiMessage[], isMobile?: boolean) { function getRatios(messages: ApiMessage[], isSingleMessage?: boolean, isMobile?: boolean) {
return messages.map( const isOutgoing = messages[0].isOutgoing;
(message) => { const allMedia = (isSingleMessage
const dimensions = calculateMediaDimensions(message, undefined, undefined, isMobile) as ApiDimensions; ? messages[0].content.paidMedia!.extendedMedia.map((media) => (
'mediaType' in media ? media : (media.photo || media.video)
))
: messages.map((message) => (
getMessageContent(message).photo || getMessageContent(message).video
))
).filter(Boolean);
return allMedia.map(
(media) => {
const dimensions = calculateMediaDimensions({
media,
isOwn: isOutgoing,
isMobile,
}) as ApiDimensions;
return dimensions.width / dimensions.height; return dimensions.width / dimensions.height;
}, },
@ -99,7 +113,7 @@ export function calculateAlbumLayout(
isMobile?: boolean, isMobile?: boolean,
): IAlbumLayout { ): IAlbumLayout {
const spacing = 2; const spacing = 2;
const ratios = getRatios(album.messages, isMobile); const ratios = getRatios(album.messages, album.isPaidMedia, isMobile);
const proportions = getProportions(ratios); const proportions = getProportions(ratios);
const averageRatio = getAverageRatio(ratios); const averageRatio = getAverageRatio(ratios);
const albumCount = ratios.length; const albumCount = ratios.length;

View File

@ -5,12 +5,12 @@ import { ApiMediaFormat } from '../../../../api/types';
import { import {
getMessageContact, getMessageContact,
getMessageHtmlId, getMessageHtmlId,
getMessageMediaHash,
getMessagePhoto, getMessagePhoto,
getMessageText, getMessageText,
getMessageWebPagePhoto, getMessageWebPagePhoto,
getMessageWebPageVideo, getMessageWebPageVideo,
hasMessageLocalBlobUrl, getPhotoMediaHash,
hasMediaLocalBlobUrl,
} from '../../../../global/helpers'; } from '../../../../global/helpers';
import { getMessageTextWithSpoilers } from '../../../../global/helpers/messageSummary'; import { getMessageTextWithSpoilers } from '../../../../global/helpers/messageSummary';
import { import {
@ -44,8 +44,8 @@ export function getMessageCopyOptions(
const photo = getMessagePhoto(message) const photo = getMessagePhoto(message)
|| (!getMessageWebPageVideo(message) ? getMessageWebPagePhoto(message) : undefined); || (!getMessageWebPageVideo(message) ? getMessageWebPagePhoto(message) : undefined);
const contact = getMessageContact(message); const contact = getMessageContact(message);
const mediaHash = getMessageMediaHash(message, 'inline'); const mediaHash = photo ? getPhotoMediaHash(photo, 'inline') : undefined;
const canImageBeCopied = canCopy && photo && (mediaHash || hasMessageLocalBlobUrl(message)) const canImageBeCopied = canCopy && photo && (mediaHash || hasMediaLocalBlobUrl(photo))
&& CLIPBOARD_ITEM_SUPPORTED && !IS_SAFARI; && CLIPBOARD_ITEM_SUPPORTED && !IS_SAFARI;
const selection = window.getSelection(); const selection = window.getSelection();

View File

@ -0,0 +1,17 @@
import type { ApiPaidMedia } from '../../../../api/types';
export default function getSingularPaidMedia(media?: ApiPaidMedia) {
if (!media || media.extendedMedia.length !== 1) {
return {
photo: undefined,
video: undefined,
};
}
const singularMedia = media.extendedMedia[0];
const isPreview = 'mediaType' in singularMedia;
const photo = isPreview ? (!singularMedia.duration ? singularMedia : undefined) : singularMedia.photo;
const video = isPreview ? (singularMedia.duration ? singularMedia : undefined) : singularMedia.video;
return { photo, video };
}

View File

@ -1,14 +1,11 @@
import type { ApiMessage } from '../../../../api/types'; import type { ApiMediaExtendedPreview, ApiPhoto, ApiVideo } from '../../../../api/types';
import { import {
getMessagePhoto, calculateExtendedPreviewDimensions,
getMessageText, calculateInlineImageDimensions,
getMessageVideo, calculateVideoDimensions,
getMessageWebPagePhoto, REM,
getMessageWebPageVideo, } from '../../../common/helpers/mediaDimensions';
isOwnMessage,
} from '../../../../global/helpers';
import { calculateInlineImageDimensions, calculateVideoDimensions, REM } from '../../../common/helpers/mediaDimensions';
const SMALL_IMAGE_THRESHOLD = 12; const SMALL_IMAGE_THRESHOLD = 12;
const MIN_MESSAGE_LENGTH_FOR_BLUR = 40; const MIN_MESSAGE_LENGTH_FOR_BLUR = 40;
@ -22,20 +19,32 @@ export function getMinMediaWidth(text?: string, hasCommentButton?: boolean) {
: MIN_MEDIA_WIDTH; : MIN_MEDIA_WIDTH;
} }
export function calculateMediaDimensions( export function calculateMediaDimensions({
message: ApiMessage, asForwarded?: boolean, noAvatars?: boolean, isMobile?: boolean, media,
) { messageText,
const isOwn = isOwnMessage(message); isOwn,
const photo = getMessagePhoto(message) || getMessageWebPagePhoto(message); isInWebPage,
const video = getMessageVideo(message); asForwarded,
noAvatars,
isMobile,
} : {
media: ApiPhoto | ApiVideo | ApiMediaExtendedPreview;
messageText?: string;
isOwn?: boolean;
isInWebPage?: boolean;
asForwarded?: boolean;
noAvatars?: boolean;
isMobile?: boolean;
}) {
const isPhoto = media.mediaType === 'photo';
const isVideo = media.mediaType === 'video';
const isWebPagePhoto = isPhoto && isInWebPage;
const isWebPageVideo = isVideo && isInWebPage;
const { width, height } = isPhoto
? calculateInlineImageDimensions(media, isOwn, asForwarded, isWebPagePhoto, noAvatars, isMobile)
: isVideo ? calculateVideoDimensions(media, isOwn, asForwarded, isWebPageVideo, noAvatars, isMobile)
: calculateExtendedPreviewDimensions(media, isOwn, asForwarded, isInWebPage, noAvatars, isMobile);
const isWebPagePhoto = Boolean(getMessageWebPagePhoto(message));
const isWebPageVideo = Boolean(getMessageWebPageVideo(message));
const { width, height } = photo
? calculateInlineImageDimensions(photo, isOwn, asForwarded, isWebPagePhoto, noAvatars, isMobile)
: calculateVideoDimensions(video!, isOwn, asForwarded, isWebPageVideo, noAvatars, isMobile);
const messageText = getMessageText(message);
const minMediaWidth = getMinMediaWidth(messageText); const minMediaWidth = getMinMediaWidth(messageText);
let stretchFactor = 1; let stretchFactor = 1;

View File

@ -14,9 +14,10 @@ import buildClassName from '../../../../util/buildClassName';
import useLastCallback from '../../../../hooks/useLastCallback'; import useLastCallback from '../../../../hooks/useLastCallback';
type OwnProps = type OwnProps<T> =
PhotoProps (PhotoProps<T> | VideoProps<T>) & {
& VideoProps; clickArg: number;
};
type StateProps = { type StateProps = {
isInSelectMode?: boolean; isInSelectMode?: boolean;
@ -24,18 +25,19 @@ type StateProps = {
}; };
export default function withSelectControl(WrappedComponent: FC) { export default function withSelectControl(WrappedComponent: FC) {
const ComponentWithSelectControl: FC<OwnProps & StateProps> = (props) => { // eslint-disable-next-line @typescript-eslint/comma-dangle
const ComponentWithSelectControl = <T,>(props: OwnProps<T> & StateProps) => {
const { const {
isInSelectMode, isInSelectMode,
isSelected, isSelected,
message,
dimensions, dimensions,
clickArg,
} = props; } = props;
const { toggleMessageSelection } = getActions(); const { toggleMessageSelection } = getActions();
const handleMessageSelect = useLastCallback((e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => { const handleMessageSelect = useLastCallback((e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => {
e.stopPropagation(); e.stopPropagation();
toggleMessageSelection({ messageId: message.id, withShift: e?.shiftKey }); toggleMessageSelection({ messageId: clickArg, withShift: e?.shiftKey });
}); });
const newProps = useMemo(() => { const newProps = useMemo(() => {
@ -72,13 +74,13 @@ export default function withSelectControl(WrappedComponent: FC) {
); );
}; };
return memo(withGlobal<OwnProps>( return memo(withGlobal<OwnProps<unknown>>(
(global, ownProps) => { (global, ownProps) => {
const { message } = ownProps; const { clickArg } = ownProps;
return { return {
isInSelectMode: selectIsInSelectMode(global), isInSelectMode: selectIsInSelectMode(global),
isSelected: selectIsMessageSelected(global, message.id), isSelected: selectIsMessageSelected(global, clickArg),
}; };
}, },
)(ComponentWithSelectControl)); )(ComponentWithSelectControl)) as typeof ComponentWithSelectControl;
} }

View File

@ -1,23 +1,26 @@
import type { ApiMessage } from '../../../../api/types'; import { getMediaThumbUri, type MediaWithThumbs } from '../../../../global/helpers';
import { getMessageMediaThumbDataUri } from '../../../../global/helpers';
import { IS_CANVAS_FILTER_SUPPORTED } from '../../../../util/windowEnvironment'; import { IS_CANVAS_FILTER_SUPPORTED } from '../../../../util/windowEnvironment';
import useAppLayout from '../../../../hooks/useAppLayout'; import useAppLayout from '../../../../hooks/useAppLayout';
import useCanvasBlur from '../../../../hooks/useCanvasBlur'; import useCanvasBlur from '../../../../hooks/useCanvasBlur';
type CanvasBlurReturnType = ReturnType<typeof useCanvasBlur>;
export default function useBlurredMediaThumbRef( export default function useBlurredMediaThumbRef(
message: ApiMessage, forcedUri: string | undefined, isDisabled: boolean,
isDisabled?: boolean | string, ): CanvasBlurReturnType;
forcedUri?: string, export default function useBlurredMediaThumbRef(media: MediaWithThumbs, isDisabled?: boolean) : CanvasBlurReturnType;
export default function useBlurredMediaThumbRef(
media: MediaWithThumbs | string | undefined,
isDisabled?: boolean,
) { ) {
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
const dataUri = forcedUri || getMessageMediaThumbDataUri(message); const dataUri = media ? typeof media === 'string' ? media : getMediaThumbUri(media) : undefined;
return useCanvasBlur( return useCanvasBlur(
dataUri, dataUri,
Boolean(isDisabled), isDisabled,
isMobile && !IS_CANVAS_FILTER_SUPPORTED, isMobile && !IS_CANVAS_FILTER_SUPPORTED,
); );
} }

View File

@ -39,7 +39,7 @@ export default function useInnerHandlers(
} = getActions(); } = getActions();
const { const {
id: messageId, forwardInfo, groupedId, id: messageId, forwardInfo, groupedId, content: { paidMedia },
} = message; } = message;
const { const {
@ -98,18 +98,20 @@ export default function useInnerHandlers(
openMediaViewer({ openMediaViewer({
chatId, chatId,
threadId, threadId,
mediaId: messageId, messageId,
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) return; // TODO: Implement MV and remove this line
if (withDynamicLoading) { if (withDynamicLoading) {
searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: messageId }); searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: messageId });
} }
openMediaViewer({ openMediaViewer({
chatId, chatId,
threadId, threadId,
mediaId: messageId, messageId,
origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline, origin: isScheduled ? MediaViewerOrigin.ScheduledInline : MediaViewerOrigin.Inline,
withDynamicLoading, withDynamicLoading,
}); });
@ -118,7 +120,8 @@ export default function useInnerHandlers(
const withDynamicLoading = !isScheduled; const withDynamicLoading = !isScheduled;
openMediaViewerWithPhotoOrVideo(withDynamicLoading); openMediaViewerWithPhotoOrVideo(withDynamicLoading);
}); });
const handleVideoMediaClick = useLastCallback((id: number, isGif?: boolean): void => { const handleVideoMediaClick = useLastCallback(() => {
const isGif = message.content?.video?.isGif;
const withDynamicLoading = !isGif && !isScheduled; const withDynamicLoading = !isGif && !isScheduled;
openMediaViewerWithPhotoOrVideo(withDynamicLoading); openMediaViewerWithPhotoOrVideo(withDynamicLoading);
}); });
@ -127,14 +130,17 @@ export default function useInnerHandlers(
openAudioPlayer({ chatId, messageId }); openAudioPlayer({ chatId, messageId });
}); });
const handleAlbumMediaClick = useLastCallback((albumMessageId: number): void => { const handleAlbumMediaClick = useLastCallback((albumMessageId: number, albumIndex?: number): void => {
if (paidMedia && !paidMedia.isBought) return;
searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: messageId }); searchChatMediaMessages({ chatId, threadId, currentMediaMessageId: messageId });
openMediaViewer({ openMediaViewer({
chatId, chatId,
threadId, threadId,
mediaId: albumMessageId, messageId: albumMessageId,
mediaIndex: albumIndex,
origin: isScheduled ? MediaViewerOrigin.ScheduledAlbum : MediaViewerOrigin.Album, origin: isScheduled ? MediaViewerOrigin.ScheduledAlbum : MediaViewerOrigin.Album,
withDynamicLoading: true, withDynamicLoading: !paidMedia,
}); });
}); });

View File

@ -26,9 +26,11 @@ type OwnProps = {
headerImageUrl?: string; headerImageUrl?: string;
headerAvatarPeer?: ApiPeer | CustomPeer; headerAvatarPeer?: ApiPeer | CustomPeer;
headerAvatarWebPhoto?: ApiWebDocument; headerAvatarWebPhoto?: ApiWebDocument;
noHeaderImage?: boolean;
header?: TeactNode; header?: TeactNode;
footer?: TeactNode; footer?: TeactNode;
buttonText?: string; buttonText?: string;
className?: string;
onClose: NoneToVoidFunction; onClose: NoneToVoidFunction;
onButtonClick?: NoneToVoidFunction; onButtonClick?: NoneToVoidFunction;
}; };
@ -40,9 +42,11 @@ const TableInfoModal = ({
headerImageUrl, headerImageUrl,
headerAvatarPeer, headerAvatarPeer,
headerAvatarWebPhoto, headerAvatarWebPhoto,
noHeaderImage,
header, header,
footer, footer,
buttonText, buttonText,
className,
onClose, onClose,
onButtonClick, onButtonClick,
}: OwnProps) => { }: OwnProps) => {
@ -61,13 +65,16 @@ const TableInfoModal = ({
hasAbsoluteCloseButton={!title} hasAbsoluteCloseButton={!title}
isSlim isSlim
title={title} title={title}
className={className}
contentClassName={styles.content} contentClassName={styles.content}
onClose={onClose} onClose={onClose}
> >
{withAvatar ? ( {!noHeaderImage && (
<Avatar peer={headerAvatarPeer} webPhoto={headerAvatarWebPhoto} size="jumbo" className={styles.avatar} /> withAvatar ? (
) : ( <Avatar peer={headerAvatarPeer} webPhoto={headerAvatarWebPhoto} size="jumbo" className={styles.avatar} />
<img className={styles.logo} src={headerImageUrl} alt="" draggable={false} /> ) : (
<img className={styles.logo} src={headerImageUrl} alt="" draggable={false} />
)
)} )}
{header} {header}
<table className={styles.table}> <table className={styles.table}>

View File

@ -0,0 +1,90 @@
.root {
display: grid;
grid-auto-columns: 0.5rem;
grid-auto-rows: 0.5rem;
place-items: center;
place-content: center;
height: 5rem;
position: relative;
z-index: 2;
}
.preview {
height: 2.5rem;
width: 2.5rem;
grid-auto-columns: 0.25rem;
grid-auto-rows: 0.25rem;
}
.count {
display: flex;
align-items: center;
z-index: 1;
font-size: 2rem;
line-height: 1;
color: white;
}
.thumb {
position: relative;
height: 5rem;
width: 5rem;
border-radius: 1rem;
overflow: hidden;
outline: 0.25rem solid var(--color-background);
}
.preview .thumb {
height: 2.5rem;
width: 2.5rem;
border-radius: 0.5rem;
outline-width: 0.125rem;
}
.noOutline {
outline: none;
}
.thumb:nth-child(1),
.itemCount1 .count {
grid-row: 1;
grid-column: 1;
}
.thumb:nth-child(2),
.itemCount2 .count {
grid-row: 2;
grid-column: 2;
}
.thumb:nth-child(3),
.itemCount3 .count {
grid-row: 3;
grid-column: 3;
}
.blurry, .full {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
object-fit: cover;
}
.full {
animation: fadeIn 0.2s ease-in-out;
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}

View File

@ -0,0 +1,92 @@
import React, { memo } from '../../../lib/teact/teact';
import type { ApiMediaExtendedPreview, BoughtPaidMedia } from '../../../api/types';
import { getMediaHash, getMediaThumbUri } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import useMedia from '../../../hooks/useMedia';
import Icon from '../../common/icons/Icon';
import MediaSpoiler from '../../common/MediaSpoiler';
import styles from './PaidMediaThumb.module.scss';
type OwnProps = {
className?: string;
media: ApiMediaExtendedPreview[] | BoughtPaidMedia[];
isTransactionPreview?: boolean;
onClick?: NoneToVoidFunction;
};
const THUMB_LIMIT = 3;
const PREVIEW_THUMB_LIMIT = 2;
const PaidMediaThumb = ({
media, className, isTransactionPreview, onClick,
}: OwnProps) => {
const count = Math.min(media.length, isTransactionPreview ? PREVIEW_THUMB_LIMIT : THUMB_LIMIT);
const isLocked = 'mediaType' in media[0];
return (
<div
className={buildClassName(
styles.root,
styles[`itemCount${count}`],
isTransactionPreview && styles.preview,
className,
)}
dir="rtl"
onClick={onClick}
>
{media.slice(0, count).reverse().map((item, i, arr) => {
const realIndex = arr.length - i - 1;
return 'mediaType' in item ? (
<MediaSpoiler
className={styles.thumb}
isVisible
width={item.width}
height={item.height}
thumbDataUri={item.thumbnail?.dataUri}
/>
) : (
<SingleMediaThumb
className={buildClassName(isTransactionPreview && realIndex > 0 && styles.noOutline)}
boughtMedia={item}
index={realIndex}
/>
);
})}
{isLocked && (
<div className={styles.count}>
<Icon name="stars-lock" />
{media.length > 1 ? media.length : ''}
</div>
)}
</div>
);
};
function SingleMediaThumb({
boughtMedia,
index,
className,
}: {
boughtMedia: BoughtPaidMedia;
index?: number;
className?: string;
}) {
const media = (boughtMedia.video || boughtMedia.photo)!;
const mediaHash = getMediaHash(media, 'pictogram');
const thumb = getMediaThumbUri(media);
const mediaBlob = useMedia(mediaHash);
return (
<div className={buildClassName(styles.thumb, index !== undefined && `stars-transaction-media-${index}`, className)}>
{thumb && <img className={styles.blurry} src={thumb} alt="" />}
{mediaBlob && <img className={styles.full} src={mediaBlob} alt="" />}
</div>
);
}
export default memo(PaidMediaThumb);

View File

@ -1,5 +1,9 @@
@use '../../../styles/mixins'; @use '../../../styles/mixins';
.root {
z-index: calc(var(--z-media-viewer) - 1);
}
.root :global(.modal-content) { .root :global(.modal-content) {
padding: 0; padding: 0;
} }

View File

@ -1,11 +1,15 @@
import React, { memo, useEffect, useMemo } from '../../../lib/teact/teact'; import React, { memo, useEffect, useMemo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiUser } from '../../../api/types'; import type {
ApiChat, ApiMediaExtendedPreview, ApiMessage, ApiUser,
} from '../../../api/types';
import type { GlobalState, TabState } from '../../../global/types'; import type { GlobalState, TabState } from '../../../global/types';
import { getUserFullName } from '../../../global/helpers'; import { getChatTitle, getUserFullName } from '../../../global/helpers';
import { selectTabState, selectUser } from '../../../global/selectors'; import {
selectChat, selectChatMessage, selectTabState, selectUser,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
@ -18,6 +22,7 @@ import StarIcon from '../../common/icons/StarIcon';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import Modal from '../../ui/Modal'; import Modal from '../../ui/Modal';
import BalanceBlock from './BalanceBlock'; import BalanceBlock from './BalanceBlock';
import PaidMediaThumb from './PaidMediaThumb';
import styles from './StarsBalanceModal.module.scss'; import styles from './StarsBalanceModal.module.scss';
@ -31,10 +36,17 @@ type StateProps = {
payment?: TabState['payment']; payment?: TabState['payment'];
starsBalanceState?: GlobalState['stars']; starsBalanceState?: GlobalState['stars'];
bot?: ApiUser; bot?: ApiUser;
paidMediaMessage?: ApiMessage;
paidMediaChat?: ApiChat;
}; };
const StarPaymentModal = ({ const StarPaymentModal = ({
modal, bot, starsBalanceState, payment, modal,
bot,
starsBalanceState,
payment,
paidMediaMessage,
paidMediaChat,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { closePaymentModal, openStarsBalanceModal, sendStarPaymentForm } = getActions(); const { closePaymentModal, openStarsBalanceModal, sendStarPaymentForm } = getActions();
const [isLoading, markLoading, unmarkLoading] = useFlag(); const [isLoading, markLoading, unmarkLoading] = useFlag();
@ -58,8 +70,21 @@ const StarPaymentModal = ({
const botName = getUserFullName(bot); const botName = getUserFullName(bot);
const starsText = lang('Stars.Intro.PurchasedText.Stars', payment.invoice.amount); const starsText = lang('Stars.Intro.PurchasedText.Stars', payment.invoice.amount);
if (paidMediaMessage) {
const extendedMedia = paidMediaMessage.content.paidMedia!.extendedMedia as ApiMediaExtendedPreview[];
const areAllPhotos = extendedMedia.every((media) => !media.duration);
const areAllVideos = extendedMedia.every((media) => !!media.duration);
const mediaText = areAllPhotos ? lang('Stars.Transfer.Photos', extendedMedia.length)
: areAllVideos ? lang('Stars.Transfer.Videos', extendedMedia.length)
: lang('Media', extendedMedia.length);
const channelTitle = getChatTitle(lang, paidMediaChat!);
return lang('Stars.Transfer.UnlockInfo', [mediaText, channelTitle, starsText]);
}
return lang('Stars.Transfer.Info', [payment.invoice.title, botName, starsText]); return lang('Stars.Transfer.Info', [payment.invoice.title, botName, starsText]);
}, [bot, payment, lang]); }, [payment?.invoice, bot, lang, paidMediaMessage, paidMediaChat]);
const handlePayment = useLastCallback(() => { const handlePayment = useLastCallback(() => {
const price = payment?.invoice?.amount; const price = payment?.invoice?.amount;
@ -89,8 +114,14 @@ const StarPaymentModal = ({
> >
<BalanceBlock balance={starsBalanceState?.balance || 0} className={styles.modalBalance} /> <BalanceBlock balance={starsBalanceState?.balance || 0} className={styles.modalBalance} />
<div className={styles.paymentImages} dir={lang.isRtl ? 'ltr' : 'rtl'}> <div className={styles.paymentImages} dir={lang.isRtl ? 'ltr' : 'rtl'}>
<Avatar peer={bot} size="giant" /> {paidMediaMessage ? (
{photo && <Avatar className={styles.paymentPhoto} webPhoto={photo} size="giant" />} <PaidMediaThumb media={paidMediaMessage.content.paidMedia!.extendedMedia} />
) : (
<>
<Avatar peer={bot} size="giant" />
{photo && <Avatar className={styles.paymentPhoto} webPhoto={photo} size="giant" />}
</>
)}
<img className={styles.paymentImageBackground} src={StarsBackground} alt="" draggable={false} /> <img className={styles.paymentImageBackground} src={StarsBackground} alt="" draggable={false} />
</div> </div>
<h2 className={styles.headerText}> <h2 className={styles.headerText}>
@ -114,10 +145,19 @@ export default memo(withGlobal<OwnProps>(
(global): StateProps => { (global): StateProps => {
const payment = selectTabState(global).payment; const payment = selectTabState(global).payment;
const bot = payment?.botId ? selectUser(global, payment.botId) : undefined; const bot = payment?.botId ? selectUser(global, payment.botId) : undefined;
const messageInputInvoice = payment.inputInvoice?.type === 'message' ? payment.inputInvoice : undefined;
const message = messageInputInvoice
? selectChatMessage(global, messageInputInvoice.chatId, messageInputInvoice.messageId) : undefined;
const chat = messageInputInvoice ? selectChat(global, messageInputInvoice.chatId) : undefined;
const isPaidMedia = message?.content.paidMedia;
return { return {
bot, bot,
starsBalanceState: global.stars, starsBalanceState: global.stars,
payment, payment,
paidMediaMessage: isPaidMedia ? message : undefined,
paidMediaChat: isPaidMedia ? chat : undefined,
}; };
}, },
)(StarPaymentModal)); )(StarPaymentModal));

View File

@ -28,6 +28,10 @@
font-weight: 500; font-weight: 500;
} }
.star {
margin-top: -0.25rem;
}
.title, .description, .date { .title, .description, .date {
margin-bottom: 0; margin-bottom: 0;
} }

View File

@ -21,6 +21,7 @@ import useOldLang from '../../../hooks/useOldLang';
import Avatar from '../../common/Avatar'; import Avatar from '../../common/Avatar';
import StarIcon from '../../common/icons/StarIcon'; import StarIcon from '../../common/icons/StarIcon';
import PaidMediaThumb from './PaidMediaThumb';
import styles from './StarsTransactionItem.module.scss'; import styles from './StarsTransactionItem.module.scss';
@ -40,8 +41,8 @@ const StarsTransactionItem = ({ transaction }: OwnProps) => {
date, date,
stars, stars,
photo, photo,
isRefund,
peer: transactionPeer, peer: transactionPeer,
extendedMedia,
} = transaction; } = transaction;
const lang = useOldLang(); const lang = useOldLang();
@ -49,8 +50,9 @@ const StarsTransactionItem = ({ transaction }: OwnProps) => {
const peer = useSelector(selectOptionalPeer(peerId)); const peer = useSelector(selectOptionalPeer(peerId));
const data = useMemo(() => { const data = useMemo(() => {
let title = transaction.title; let title = transaction.title || (transaction.extendedMedia ? lang('StarMediaPurchase') : undefined);
let description; let description;
let status: string | undefined;
let avatarPeer: ApiPeer | CustomPeer | undefined; let avatarPeer: ApiPeer | CustomPeer | undefined;
if (transaction.peer.type === 'peer') { if (transaction.peer.type === 'peer') {
@ -67,10 +69,23 @@ const StarsTransactionItem = ({ transaction }: OwnProps) => {
avatarPeer = undefined; avatarPeer = undefined;
} }
if (transaction.isRefund) {
status = lang('StarsRefunded');
}
if (transaction.hasFailed) {
status = lang('StarsFailed');
}
if (transaction.isPending) {
status = lang('StarsPending');
}
return { return {
title, title,
description, description,
avatarPeer, avatarPeer,
status,
}; };
}, [lang, peer, transaction]); }, [lang, peer, transaction]);
@ -80,20 +95,21 @@ const StarsTransactionItem = ({ transaction }: OwnProps) => {
return ( return (
<div className={styles.root} onClick={handleClick}> <div className={styles.root} onClick={handleClick}>
<Avatar size="medium" webPhoto={photo} peer={data.avatarPeer} /> {extendedMedia ? <PaidMediaThumb media={extendedMedia} isTransactionPreview />
: <Avatar size="medium" webPhoto={photo} peer={data.avatarPeer} />}
<div className={styles.info}> <div className={styles.info}>
<h3 className={styles.title}>{data.title}</h3> <h3 className={styles.title}>{data.title}</h3>
<p className={styles.description}>{data.description}</p> <p className={styles.description}>{data.description}</p>
<p className={styles.date}> <p className={styles.date}>
{formatDateTimeToString(date * 1000, lang.code, true)} {formatDateTimeToString(date * 1000, lang.code, true)}
{isRefund && ` — (${lang('StarsRefunded')})`} {data.status && ` — (${data.status})`}
</p> </p>
</div> </div>
<div className={styles.stars}> <div className={styles.stars}>
<span className={buildClassName(styles.amount, stars < 0 ? styles.negative : styles.positive)}> <span className={buildClassName(styles.amount, stars < 0 ? styles.negative : styles.positive)}>
{formatStarsTransactionAmount(stars)} {formatStarsTransactionAmount(stars)}
</span> </span>
<StarIcon type="gold" size="big" /> <StarIcon className={styles.star} type="gold" size="big" />
</div> </div>
</div> </div>
); );

Some files were not shown because too many files have changed in this diff Show More