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

View File

@ -85,10 +85,12 @@ export function buildApiPhoto(photo: GramJs.Photo, isSpoiler?: boolean): ApiPhot
.map(buildApiPhotoSize);
return {
mediaType: 'photo',
id: String(photo.id),
thumbnail: buildApiThumbnailFromStripped(photo.sizes),
sizes,
isSpoiler,
date: photo.date,
...(photo.videoSizes && { videoSizes: compact(photo.videoSizes.map(buildApiVideoSize)), isVideo: true }),
};
}
@ -115,7 +117,7 @@ export function buildApiPhotoSize(photoSize: GramJs.PhotoSize): ApiPhotoSize {
return {
width: w,
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,
ApiInvoice,
ApiLocation,
ApiMessageExtendedMediaPreview,
ApiMediaExtendedPreview,
ApiMessageStoryData,
ApiPaidMedia,
ApiPhoto,
ApiPoll,
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 { pick } from '../../../util/iteratees';
import { addStoryToLocalDb, serializeBytes } from '../helpers';
import { addMediaToLocalDb, addStoryToLocalDb, serializeBytes } from '../helpers';
import {
buildApiFormattedText,
buildApiMessageEntity,
@ -74,6 +75,8 @@ export function buildMessageTextContent(
}
export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): MediaContent | undefined {
addMediaToLocalDb(media);
const ttlSeconds = 'ttlSeconds' in media ? media.ttlSeconds : undefined;
const isExpiredVoice = isExpiredVoiceMessage(media);
@ -98,7 +101,7 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): MediaC
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);
}
@ -145,6 +148,9 @@ export function buildMessageMediaContent(media: GramJs.TypeMessageMedia): MediaC
const giveawayResults = buildGiweawayResultsFromMedia(media);
if (giveawayResults) return { giveawayResults };
const paidMedia = buildPaidMedia(media);
if (paidMedia) return { paidMedia };
return undefined;
}
@ -202,6 +208,7 @@ export function buildVideoFromDocument(document: GramJs.Document, isSpoiler?: bo
} = videoAttr;
return {
mediaType: 'video',
id: String(id),
mimeType,
duration,
@ -241,6 +248,7 @@ export function buildAudioFromDocument(document: GramJs.Document): ApiAudio | un
} = audioAttributes;
return {
mediaType: 'audio',
id: String(id),
mimeType,
duration,
@ -298,6 +306,7 @@ function buildAudio(media: GramJs.TypeMessageMedia): ApiAudio | undefined {
.map((thumb) => buildApiPhotoSize(thumb));
return {
mediaType: 'audio',
id: String(media.document.id),
fileName: getFilenameFromDocument(media.document, 'audio'),
thumbnailSizes,
@ -342,7 +351,9 @@ function buildVoice(media: GramJs.TypeMessageMedia): ApiVoice | undefined {
const { duration, waveform } = audioAttribute;
return {
mediaType: 'voice',
id: String(media.document.id),
size: media.document.size.toJSNumber(),
duration,
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;
if (photoSize) {
mediaSize = {
@ -383,7 +394,7 @@ export function buildApiDocument(document: GramJs.TypeDocument): ApiDocument | u
};
if (SUPPORTED_IMAGE_CONTENT_TYPES.has(mimeType)) {
mediaType = 'photo';
innerMediaType = 'photo';
const imageAttribute = attributes
.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)) {
mediaType = 'video';
innerMediaType = 'video';
const videoAttribute = attributes
.find((a: any): a is GramJs.DocumentAttributeVideo => a instanceof GramJs.DocumentAttributeVideo);
@ -411,13 +422,14 @@ export function buildApiDocument(document: GramJs.TypeDocument): ApiDocument | u
}
return {
mediaType: 'document',
id: String(id),
size: size.toJSNumber(),
mimeType,
timestamp: date,
fileName: getFilenameFromDocument(document),
thumbnail,
mediaType,
innerMediaType,
mediaSize,
};
}
@ -432,7 +444,11 @@ function buildContact(media: GramJs.TypeMessageMedia): ApiContact | undefined {
} = media;
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 {
const point = buildGeoPoint(media.geo);
return point && { type: 'geo', geo: point };
return point && { mediaType: 'geo', geo: point };
}
function buildVenue(media: GramJs.MessageMediaVenue): ApiLocation | undefined {
@ -479,7 +495,7 @@ function buildVenue(media: GramJs.MessageMediaVenue): ApiLocation | undefined {
} = media;
const point = buildGeoPoint(geo);
return point && {
type: 'venue',
mediaType: 'venue',
geo: point,
title,
provider,
@ -493,7 +509,7 @@ function buildGeoLive(media: GramJs.MessageMediaGeoLive): ApiLocation | undefine
const { geo, period, heading } = media;
const point = buildGeoPoint(geo);
return point && {
type: 'geoLive',
mediaType: 'geoLive',
geo: point,
period,
heading,
@ -530,6 +546,7 @@ function buildGame(media: GramJs.MessageMediaGame): ApiGame | undefined {
const document = apiDocument instanceof GramJs.Document ? buildApiDocument(apiDocument) : undefined;
return {
mediaType: 'game',
id: id.toString(),
accessHash: accessHash.toString(),
shortName,
@ -556,6 +573,7 @@ function buildGiveaway(media: GramJs.MessageMediaGiveaway): ApiGiveaway | undefi
const channelIds = channels.map((channel) => buildApiPeerId(channel, 'channel'));
return {
mediaType: 'giveaway',
channelIds,
months,
quantity,
@ -583,6 +601,7 @@ function buildGiveawayResults(media: GramJs.MessageMediaGiveawayResults): ApiGiv
const winnerIds = winners.map((winner) => buildApiPeerId(winner, 'user'));
return {
mediaType: 'giveawayResults',
months,
untilDate,
isOnlyForNewSubscribers: onlyNewSubscribers,
@ -604,7 +623,12 @@ export function buildMessageStoryData(media: GramJs.TypeMessageMedia): ApiMessag
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 {
@ -615,6 +639,7 @@ export function buildPoll(poll: GramJs.Poll, pollResults: GramJs.PollResults): A
}));
return {
mediaType: 'poll',
id: String(id),
summary: {
isPublic: poll.publicVoters,
@ -641,6 +666,7 @@ export function buildInvoice(media: GramJs.MessageMediaInvoice): ApiInvoice {
? buildApiMessageExtendedMediaPreview(extendedMedia) : undefined;
return {
mediaType: 'invoice',
title,
text,
photo: buildApiWebDocument(photo),
@ -722,6 +748,7 @@ export function buildWebPage(media: GramJs.TypeMessageMedia): ApiWebPage | undef
}
return {
mediaType: 'webpage',
id: Number(id),
...pick(media.webpage, [
'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') {
const { mimeType, attributes } = document;
const filenameAttribute = attributes
@ -757,12 +818,13 @@ function getFilenameFromDocument(document: GramJs.Document, defaultBase = 'file'
export function buildApiMessageExtendedMediaPreview(
preview: GramJs.MessageExtendedMediaPreview,
): ApiMessageExtendedMediaPreview {
): ApiMediaExtendedPreview {
const {
w, h, thumb, videoDuration,
} = preview;
return {
mediaType: 'extendedMediaPreview',
width: w,
height: h,
duration: videoDuration,
@ -783,6 +845,7 @@ export function buildApiWebDocument(document?: GramJs.TypeWebDocument): ApiWebDo
const dimensions = sizeAttr && { width: sizeAttr.w, height: sizeAttr.h };
return {
mediaType: 'webDocument',
url,
accessHash,
size,
@ -790,3 +853,16 @@ export function buildApiWebDocument(document?: GramJs.TypeWebDocument): ApiWebDo
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,
ApiPeer,
ApiPhoto,
ApiPoll,
ApiQuickReply,
ApiReplyInfo,
ApiReplyKeyboard,
@ -246,7 +247,7 @@ export function buildMessageDraft(draft: GramJs.TypeDraftMessage): ApiDraft | un
}
const {
message, entities, replyTo, date,
message, entities, replyTo, date, effect,
} = draft;
const replyInfo = replyTo instanceof GramJs.InputReplyToMessage ? {
@ -261,6 +262,7 @@ export function buildMessageDraft(draft: GramJs.TypeDraftMessage): ApiDraft | un
text: message ? buildMessageTextContent(message, entities) : undefined,
replyInfo,
date,
effectId: effect?.toString(),
};
}
@ -610,6 +612,7 @@ function buildAction(
}
return {
mediaType: 'action',
text,
type,
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 {
poll: {
id: String(localId),
summary: pick(poll.summary, ['question', 'answers']),
results: {},
},
mediaType: 'poll',
id: String(localId),
summary: pick(poll.summary, ['question', 'answers']),
results: {},
};
}
@ -824,9 +826,12 @@ export function buildLocalMessage(
...media,
...(sticker && { sticker }),
...(gif && { video: gif }),
...(poll && buildNewPoll(poll, localId)),
...(contact && { contact }),
...(story && { storyData: story }),
poll: poll && buildNewPoll(poll, localId),
contact,
storyData: story && {
mediaType: 'storyData',
...story,
},
},
date: scheduledAt || Math.round(Date.now() / 1000) + getServerTimeOffset(),
isOutgoing: !isChannel,
@ -981,10 +986,12 @@ export function buildUploadingMedia(
const { width, height } = attachment.quick;
return {
photo: {
mediaType: 'photo',
id: LOCAL_MEDIA_UPLOADING_TEMP_ID,
sizes: [],
thumbnail: { width, height, dataUri: previewBlobUrl || blobUrl },
blobUrl,
date: Math.round(Date.now() / 1000),
isSpoiler: shouldSendAsSpoiler,
},
};
@ -993,6 +1000,7 @@ export function buildUploadingMedia(
const { width, height, duration } = attachment.quick;
return {
video: {
mediaType: 'video',
id: LOCAL_MEDIA_UPLOADING_TEMP_ID,
mimeType,
duration: duration || 0,
@ -1012,9 +1020,11 @@ export function buildUploadingMedia(
const { data: inputWaveform } = interpolateArray(waveform, INPUT_WAVEFORM_LENGTH);
return {
voice: {
mediaType: 'voice',
id: LOCAL_MEDIA_UPLOADING_TEMP_ID,
duration,
waveform: inputWaveform,
size,
},
ttlSeconds,
};
@ -1023,6 +1033,7 @@ export function buildUploadingMedia(
const { duration, performer, title } = audio || {};
return {
audio: {
mediaType: 'audio',
id: LOCAL_MEDIA_UPLOADING_TEMP_ID,
mimeType,
fileName,
@ -1036,6 +1047,7 @@ export function buildUploadingMedia(
}
return {
document: {
mediaType: 'document',
mimeType,
fileName,
size,

View File

@ -12,12 +12,13 @@ import type {
ApiStarsTransaction,
ApiStarsTransactionPeer,
ApiStarTopupOption,
BoughtPaidMedia,
} from '../../types';
import { addWebDocumentToLocalDb } from '../helpers';
import { buildApiMessageEntity } from './common';
import { omitVirtualClassFields } from './helpers';
import { buildApiDocument, buildApiWebDocument } from './messageContent';
import { buildApiDocument, buildApiWebDocument, buildMessageMediaContent } from './messageContent';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
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);
return {
mediaType: 'invoice',
text,
title,
photo: buildApiWebDocument(photo),
@ -398,6 +400,10 @@ export function buildApiStarsTransactionPeer(peer: GramJs.TypeStarsTransactionPe
return { type: 'fragment' };
}
if (peer instanceof GramJs.StarsTransactionPeerAds) {
return { type: 'ads' };
}
if (peer instanceof GramJs.StarsTransactionPeer) {
return { type: 'peer', id: getApiChatIdFromMtpPeer(peer.peer) };
}
@ -407,13 +413,16 @@ export function buildApiStarsTransactionPeer(peer: GramJs.TypeStarsTransactionPe
export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction): ApiStarsTransaction {
const {
date, id, peer, stars, description, photo, title, refund,
date, id, peer, stars, description, photo, title, refund, extendedMedia, failed, msgId, pending,
} = transaction;
if (photo) {
addWebDocumentToLocalDb(photo);
}
const boughtExtendedMedia = extendedMedia?.map((m) => buildMessageMediaContent(m))
.filter(Boolean) as BoughtPaidMedia[];
return {
id,
date,
@ -423,6 +432,10 @@ export function buildApiStarsTransaction(transaction: GramJs.StarsTransaction):
description,
photo: photo && buildApiWebDocument(photo),
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 {
const {
x, y, w, h, rotation,
x, y, w, h, rotation, radius,
} = coordinates;
return {
@ -159,6 +159,7 @@ function buildApiMediaAreaCoordinates(coordinates: GramJs.TypeMediaAreaCoordinat
width: w,
height: h,
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;
}

View File

@ -81,6 +81,7 @@ export function buildStickerFromDocument(document: GramJs.TypeDocument,
.some(({ type }) => type === 'f');
return {
mediaType: 'sticker',
id: String(document.id),
stickerSetInfo,
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) {
if (photo instanceof GramJs.PhotoEmpty) {
return new GramJs.ChatPhotoEmpty();

View File

@ -1,6 +1,6 @@
import { Api as GramJs } from '../../lib/gramjs';
import type { StoryRepairInfo } from './localDb';
import type { RepairInfo } from './localDb';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './apiBuilders/peers';
import localDb from './localDb';
@ -34,57 +34,76 @@ export function isChatFolder(
return filter instanceof GramJs.DialogFilter || filter instanceof GramJs.DialogFilterChatlist;
}
export function addMessageToLocalDb(message: GramJs.Message | GramJs.MessageService) {
const messageFullId = `${resolveMessageApiChatId(message)}-${message.id}`;
export function addMessageToLocalDb(message: GramJs.TypeMessage | GramJs.TypeSponsoredMessage) {
if (message instanceof GramJs.Message) {
if (message.media) addMediaToLocalDb(message.media, message);
let mockMessage = message;
if (message instanceof GramJs.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 (message.replyTo instanceof GramJs.MessageReplyHeader && message.replyTo.replyMedia) {
addMediaToLocalDb(message.replyTo.replyMedia, message);
}
}
if (mockMessage instanceof GramJs.MessageService && 'photo' in mockMessage.action) {
addPhotoToLocalDb(mockMessage.action.photo);
if (message instanceof GramJs.MessageService && 'photo' in message.action) {
const photo = addMessageRepairInfo(message.action.photo, message);
addPhotoToLocalDb(photo);
}
if (message instanceof GramJs.SponsoredMessage && message.photo) {
addPhotoToLocalDb(message.photo);
}
}
function addMediaToLocalDb(media: GramJs.TypeMessageMedia) {
if (media instanceof GramJs.MessageMediaDocument
&& media.document instanceof GramJs.Document
) {
localDb.documents[String(media.document.id)] = media.document;
export function addMediaToLocalDb(media: GramJs.TypeMessageMedia, message?: GramJs.TypeMessage) {
if (media instanceof GramJs.MessageMediaDocument && media.document) {
const document = addMessageRepairInfo(media.document, message);
addDocumentToLocalDb(document);
}
if (media instanceof GramJs.MessageMediaWebPage
&& 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.game.document instanceof GramJs.Document) {
localDb.documents[String(media.game.document.id)] = media.game.document;
if (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) {
addWebDocumentToLocalDb(media.photo);
if (media instanceof GramJs.MessageMediaPhoto && 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;
}
const storyData = {
id: story.id,
peerId,
};
if (story.media instanceof GramJs.MessageMediaPhoto) {
const photo = story.media.photo as GramJs.Photo & StoryRepairInfo;
photo.storyData = storyData;
if (story.media instanceof GramJs.MessageMediaPhoto && story.media.photo) {
const photo = addStoryRepairInfo(story.media.photo, peerId, story);
addPhotoToLocalDb(photo);
}
if (story.media instanceof GramJs.MessageMediaDocument) {
if (story.media.document instanceof GramJs.Document) {
const doc = story.media.document as GramJs.Document & StoryRepairInfo;
doc.storyData = storyData;
localDb.documents[String(story.media.document.id)] = doc;
const doc = addStoryRepairInfo(story.media.document, peerId, story);
addDocumentToLocalDb(doc);
}
if (story.media.altDocument instanceof GramJs.Document) {
const doc = story.media.altDocument as GramJs.Document & StoryRepairInfo;
doc.storyData = storyData;
localDb.documents[String(story.media.altDocument.id)] = doc;
const doc = addStoryRepairInfo(story.media.altDocument, peerId, story);
addDocumentToLocalDb(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) {
const id = buildApiPeerId(chat.id, chat instanceof GramJs.Chat ? 'chat' : 'channel');
const storedChat = localDb.chats[id];
@ -138,6 +186,11 @@ export function addChatToLocalDb(chat: GramJs.Chat | GramJs.Channel) {
export function addUserToLocalDb(user: GramJs.User) {
const id = buildApiPeerId(user.id, 'user');
const storedUser = localDb.users[id];
if (user.photo instanceof GramJs.Photo) {
addPhotoToLocalDb(user.photo);
}
if (storedUser && !storedUser.min && user.min) return;
localDb.users[id] = user;
@ -157,24 +210,6 @@ export function addWebDocumentToLocalDb(webDocument: GramJs.TypeWebDocument) {
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) {
return String.fromCharCode(...value);
}

View File

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

View File

@ -34,9 +34,13 @@ import {
generateRandomBigInt,
} from '../gramjsBuilders';
import {
addEntitiesToLocalDb, addUserToLocalDb, addWebDocumentToLocalDb, deserializeBytes,
addDocumentToLocalDb,
addEntitiesToLocalDb,
addPhotoToLocalDb,
addUserToLocalDb,
addWebDocumentToLocalDb,
deserializeBytes,
} from '../helpers';
import localDb from '../localDb';
import { invokeRequest } from './client';
let onUpdate: OnApiUpdate;
@ -209,7 +213,7 @@ export async function requestWebView({
if (result instanceof GramJs.WebViewResultUrl) {
return {
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;
}
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({
bot,
langCode,

View File

@ -65,7 +65,6 @@ import {
buildInputReplyTo,
buildMtpMessageEntity,
generateRandomBigInt,
isMessageWithMedia,
} from '../gramjsBuilders';
import {
addEntitiesToLocalDb,
@ -74,7 +73,6 @@ import {
deserializeBytes,
isChatFolder,
} from '../helpers';
import localDb from '../localDb';
import { scheduleMutedChatUpdate } from '../scheduleUnmute';
import {
applyState, processAffectedHistory, updateChannelState,
@ -518,8 +516,8 @@ async function getFullChatInfo(chatId: string): Promise<FullChatData | undefined
translationsDisabled,
} = result.fullChat;
if (chatPhoto instanceof GramJs.Photo) {
localDb.photos[chatPhoto.id.toString()] = chatPhoto;
if (chatPhoto) {
addPhotoToLocalDb(chatPhoto);
}
const members = buildChatMembers(participants);
@ -606,8 +604,8 @@ async function getFullChannelInfo(
boostsUnrestrict,
} = result.fullChat;
if (chatPhoto instanceof GramJs.Photo) {
localDb.photos[chatPhoto.id.toString()] = chatPhoto;
if (chatPhoto) {
addPhotoToLocalDb(chatPhoto);
}
const inviteLink = exportedInvite instanceof GramJs.ChatInviteExported
@ -1555,9 +1553,7 @@ function updateLocalDb(result: (
if ('messages' in result) {
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,
} from '../../../config';
import { pause } from '../../../util/schedulers';
import { setMessageBuilderCurrentUserId } from '../apiBuilders/messages';
import { buildApiMessage, setMessageBuilderCurrentUserId } from '../apiBuilders/messages';
import { buildApiPeerId } from '../apiBuilders/peers';
import { buildApiStory } from '../apiBuilders/stories';
import { buildApiUser, buildApiUserFullInfo } from '../apiBuilders/users';
import { buildInputPeerFromLocalDb } from '../gramjsBuilders';
import {
addEntitiesToLocalDb,
addMessageToLocalDb, addStoryToLocalDb, addUserToLocalDb, isResponseUpdate, log,
addEntitiesToLocalDb, addMessageToLocalDb, addStoryToLocalDb, addUserToLocalDb, isResponseUpdate, log,
} from '../helpers';
import localDb, { clearLocalDb } from '../localDb';
import localDb, { clearLocalDb, type RepairInfo } from '../localDb';
import {
getDifference,
init as initUpdatesManager,
@ -381,9 +381,6 @@ export async function fetchCurrentUser() {
const user = userFull.users[0];
if (user.photo instanceof GramJs.Photo) {
localDb.photos[user.photo.id.toString()] = user.photo;
}
addUserToLocalDb(user);
const currentUserFullInfo = buildApiUserFullInfo(userFull);
const currentUser = buildApiUser(user)!;
@ -441,62 +438,100 @@ export async function repairFileReference({
if (!parsed) return undefined;
const {
entityType, entityId, mediaMatchType,
entityId, mediaMatchType,
} = parsed;
if (mediaMatchType === 'document' || mediaMatchType === 'photo') {
const entity = mediaMatchType === 'document' ? localDb.documents[entityId] : localDb.photos[entityId];
if (!entity.storyData) return false;
const peer = buildInputPeerFromLocalDb(entity.storyData.peerId);
if (!peer) return false;
if (mediaMatchType === 'document' || mediaMatchType === 'photo' || mediaMatchType === 'webDocument') {
const entity = mediaMatchType === 'document'
? localDb.documents[entityId] : mediaMatchType === 'webDocument'
? localDb.webDocuments[entityId] : localDb.photos[entityId];
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({
peer,
id: [entity.storyData.id],
}));
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);
if (localRepairInfo.type === 'story') {
const result = await repairStoryMedia(localRepairInfo.peerId, localRepairInfo.id);
return result;
}
const message = result.messages[0];
if (message instanceof GramJs.MessageEmpty) return false;
addEntitiesToLocalDb(result.users);
addEntitiesToLocalDb(result.chats);
addMessageToLocalDb(message);
return true;
if (localRepairInfo.type === 'message') {
const result = await repairMessageMedia(localRepairInfo.peerId, localRepairInfo.id);
return result;
}
}
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) {
client.setForceHttpTransport(forceHttpTransport);
}

View File

@ -17,11 +17,12 @@ import * as cacheApi from '../../../util/cacheApi';
import { getEntityTypeById } from '../gramjsBuilders';
import localDb from '../localDb';
const MEDIA_ENTITY_TYPES = new Set([
'msg', 'sticker', 'gif', 'wallpaper', 'photo', 'webDocument', 'document', 'videoAvatar',
const MEDIA_ENTITY_TYPES: Set<EntityType> = new Set([
'sticker', 'wallpaper', 'photo', 'webDocument', 'document',
]);
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(
{
@ -66,8 +67,8 @@ export default async function downloadMedia(
}
export type EntityType = (
'msg' | 'sticker' | 'wallpaper' | 'gif' | 'channel' | 'chat' | 'user' | 'photo' | 'stickerSet' | 'webDocument' |
'document' | 'staticMap' | 'videoAvatar'
'sticker' | 'wallpaper' | 'channel' | 'chat' | 'user' | 'photo' | 'stickerSet' | 'webDocument' |
'document' | 'staticMap'
);
async function download(
@ -118,15 +119,11 @@ async function download(
case 'user':
entity = localDb.users[entityId];
break;
case 'msg':
entity = localDb.messages[entityId];
break;
case 'sticker':
case 'gif':
case 'wallpaper':
case 'document':
entity = localDb.documents[entityId];
break;
case 'videoAvatar':
case 'photo':
entity = localDb.photos[entityId];
break;
@ -136,9 +133,6 @@ async function download(
case 'webDocument':
entity = localDb.webDocuments[entityId];
break;
case 'document':
entity = localDb.documents[entityId];
break;
}
if (!entity) {
@ -152,36 +146,18 @@ async function download(
let mimeType;
let fullSize;
if (entity instanceof GramJs.MessageService && entity.action instanceof GramJs.MessageActionSuggestProfilePhoto) {
if (sizeType && JPEG_SIZE_TYPES.has(sizeType)) {
mimeType = 'image/jpeg';
} else if (entity instanceof GramJs.Message) {
mimeType = getMessageMediaMimeType(entity, sizeType);
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 (sizeType && MP4_SIZES_TYPES.has(sizeType)) {
mimeType = 'video/mp4';
} else if (entity instanceof GramJs.Photo) {
if (entityType === 'videoAvatar') {
mimeType = 'video/mp4';
} else {
mimeType = 'image/jpeg';
}
} else if (entityType === 'sticker' && sizeType) {
mimeType = (entity as GramJs.Document).mimeType;
} 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();
mimeType = 'image/jpeg';
} else if (entity instanceof GramJs.WebDocument) {
mimeType = entity.mimeType;
fullSize = entity.size;
} else if (entity instanceof GramJs.Document) {
mimeType = entity.mimeType;
fullSize = entity.size.toJSNumber();
}
// 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
async function parseMedia(
data: Buffer, mediaFormat: ApiMediaFormat, mimeType?: string,
@ -294,7 +235,7 @@ export function parseMediaUrl(url: string) {
? url.match(/(webDocument):(.+)/)
: url.match(
// 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) {
return undefined;

View File

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

View File

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

View File

@ -52,21 +52,21 @@ export async function fetchFullUser({
addEntitiesToLocalDb(result.users);
addEntitiesToLocalDb(result.chats);
if (result.fullUser.profilePhoto instanceof GramJs.Photo) {
localDb.photos[result.fullUser.profilePhoto.id.toString()] = result.fullUser.profilePhoto;
if (result.fullUser.profilePhoto) {
addPhotoToLocalDb(result.fullUser.profilePhoto);
}
if (result.fullUser.personalPhoto instanceof GramJs.Photo) {
localDb.photos[result.fullUser.personalPhoto.id.toString()] = result.fullUser.personalPhoto;
if (result.fullUser.personalPhoto) {
addPhotoToLocalDb(result.fullUser.personalPhoto);
}
if (result.fullUser.fallbackPhoto instanceof GramJs.Photo) {
localDb.photos[result.fullUser.fallbackPhoto.id.toString()] = result.fullUser.fallbackPhoto;
if (result.fullUser.fallbackPhoto) {
addPhotoToLocalDb(result.fullUser.fallbackPhoto);
}
const botInfo = result.fullUser.botInfo;
if (botInfo?.descriptionPhoto instanceof GramJs.Photo) {
localDb.photos[botInfo.descriptionPhoto.id.toString()] = botInfo.descriptionPhoto;
if (botInfo?.descriptionPhoto) {
addPhotoToLocalDb(botInfo.descriptionPhoto);
}
if (botInfo?.descriptionDocument instanceof GramJs.Document) {
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 {
ApiMessage, ApiMessageExtendedMediaPreview, ApiStory, ApiStorySkipped,
ApiUpdate, ApiUpdateConnectionStateType, MediaContent, OnApiUpdate,
ApiMessage, ApiStory, ApiStorySkipped,
ApiUpdate, ApiUpdateConnectionStateType, OnApiUpdate,
} from '../../types';
import { DEBUG, GENERAL_TOPIC_ID } from '../../../config';
@ -29,7 +29,7 @@ import { buildApiPhoto, buildApiUsernames, buildPrivacyRules } from '../apiBuild
import { omitVirtualClassFields } from '../apiBuilders/helpers';
import {
buildApiMessageExtendedMediaPreview,
buildMessageMediaContent,
buildBoughtMediaContent,
buildPoll,
buildPollResults,
} from '../apiBuilders/messageContent';
@ -61,7 +61,6 @@ import {
import {
buildChatPhotoForLocalDb,
buildMessageFromUpdate,
isMessageWithMedia,
} from '../gramjsBuilders';
import {
addEntitiesToLocalDb,
@ -72,7 +71,6 @@ import {
log,
resolveMessageApiChatId,
serializeBytes,
swapLocalInvoiceMedia,
} from '../helpers';
import localDb from '../localDb';
import { scheduleMutedChatUpdate, scheduleMutedTopicUpdate } from '../scheduleUnmute';
@ -84,8 +82,6 @@ export type Update = (
(GramJs.TypeUpdate | GramJs.TypeUpdates) & { _entities?: (GramJs.TypeUser | GramJs.TypeChat)[] }
) | typeof connection.UpdateConnectionState | UpdatePts | LocalUpdatePremiumFloodWait;
const DELETE_MISSING_CHANNEL_MESSAGE_DELAY = 1000;
let onUpdate: OnApiUpdate;
export function init(_onUpdate: OnApiUpdate) {
@ -205,12 +201,7 @@ export function updater(update: Update) {
return;
}
if ((update.message instanceof GramJs.Message && isMessageWithMedia(update.message))
|| (update.message instanceof GramJs.MessageService
&& update.message.action instanceof GramJs.MessageActionSuggestProfilePhoto)
) {
addMessageToLocalDb(update.message);
}
addMessageToLocalDb(update.message);
message = buildApiMessage(update.message)!;
dispatchThreadInfoUpdates([update.message]);
@ -385,9 +376,7 @@ export function updater(update: Update) {
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
const message = omit(buildApiMessage(update.message)!, ['isOutgoing']);
@ -407,28 +396,35 @@ export function updater(update: Update) {
reactions: buildMessageReactions(update.reactions),
});
} 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 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({
'@type': 'updateMessageExtendedMedia',
id: update.msgId,
chatId,
media,
preview,
extendedMedia: previewMedia,
});
} else if (update instanceof GramJs.UpdateDeleteMessages) {
onUpdate({
@ -443,43 +439,12 @@ export function updater(update: Update) {
});
} else if (update instanceof GramJs.UpdateDeleteChannelMessages) {
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
? String(message.action.photo.id)
: undefined;
}).filter(Boolean);
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);
}
onUpdate({
'@type': 'deleteMessages',
ids: update.messages,
chatId,
});
} else if (update instanceof GramJs.UpdateServiceNotification) {
if (update.popup) {
onUpdate({
@ -492,9 +457,7 @@ export function updater(update: Update) {
const currentDate = Date.now() / 1000 + getServerTimeOffset();
const message = buildApiMessageFromNotification(update, currentDate);
if (isMessageWithMedia(update)) {
addMessageToLocalDb(buildMessageFromUpdate(message.id, message.chatId, update));
}
addMessageToLocalDb(buildMessageFromUpdate(message.id, message.chatId, update));
onUpdate({
'@type': 'updateServiceNotification',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,8 +21,8 @@ import type {
import type {
ApiFormattedText,
ApiInputInvoice,
ApiMediaExtendedPreview,
ApiMessage,
ApiMessageExtendedMediaPreview,
ApiPhoto,
ApiPoll,
ApiQuickReply,
@ -30,7 +30,7 @@ import type {
ApiReactions,
ApiStickerSet,
ApiThreadInfo,
MediaContent,
BoughtPaidMedia,
} from './messages';
import type {
ApiEmojiInteraction, ApiError, ApiInviteInfo, ApiNotifyException, ApiSessionData,
@ -344,12 +344,6 @@ export type ApiUpdateDeleteSavedHistory = {
chatId: string;
};
export type ApiUpdateDeleteProfilePhotos = {
'@type': 'deleteProfilePhotos';
ids: string[];
chatId: string;
};
export type ApiUpdateResetMessages = {
'@type': 'resetMessages';
id: string;
@ -373,9 +367,13 @@ export type ApiUpdateMessageExtendedMedia = {
'@type': 'updateMessageExtendedMedia';
id: number;
chatId: string;
media?: MediaContent;
preview?: ApiMessageExtendedMediaPreview;
};
} & ({
isBought?: true;
extendedMedia: BoughtPaidMedia[];
} | {
isBought?: undefined;
extendedMedia: ApiMediaExtendedPreview[];
});
export type ApiDeleteContact = {
'@type': 'deleteContact';
@ -753,7 +751,7 @@ export type ApiUpdate = (
ApiUpdateNewMessage | ApiUpdateMessage | ApiUpdateThreadInfos | ApiUpdateCommonBoxMessages |
ApiUpdateDeleteMessages | ApiUpdateMessagePoll | ApiUpdateMessagePollVote | ApiUpdateDeleteHistory |
ApiUpdateMessageSendSucceeded | ApiUpdateMessageSendFailed | ApiUpdateServiceNotification |
ApiDeleteContact | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateUserFullInfo | ApiUpdateDeleteProfilePhotos |
ApiDeleteContact | ApiUpdateUser | ApiUpdateUserStatus | ApiUpdateUserFullInfo |
ApiUpdateAvatar | ApiUpdateMessageImage | ApiUpdateDraftMessage |
ApiUpdateError | ApiUpdateResetContacts | ApiUpdateStartEmojiInteraction |
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 { ApiMediaFormat } from '../../api/types';
import { getStickerPreviewHash } from '../../global/helpers';
import { getStickerMediaHash } from '../../global/helpers';
import useMedia from '../../hooks/useMedia';
@ -22,7 +22,7 @@ function AnimatedIconFromSticker(props: OwnProps) {
const thumbDataUri = sticker?.thumbnail?.dataUri;
const localMediaHash = sticker && `sticker${sticker.id}`;
const previewBlobUrl = useMedia(
sticker ? getStickerPreviewHash(sticker.id) : undefined,
sticker ? getStickerMediaHash(sticker, 'preview') : undefined,
noLoad && !forcePreview,
ApiMediaFormat.BlobUrl,
);

View File

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

View File

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

View File

@ -31,11 +31,24 @@
}
.effect-icon {
font-size: 1.25rem;
position: absolute;
right: 0;
display: grid;
width: 1.5rem;
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);
border-radius: 50%;
cursor: var(--custom-cursor, pointer);
& > .emoji {
width: 1rem !important;
height: 1rem !important;

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import type { ApiVideo } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { ApiMediaFormat } from '../../api/types';
import { getVideoMediaHash } from '../../global/helpers';
import buildClassName from '../../util/buildClassName';
import { IS_TOUCH_ENV } from '../../util/windowEnvironment';
import { preventMessageInputBlurWithBubbling } from '../middle/helpers/preventMessageInputBlur';
@ -52,13 +53,12 @@ const GifButton: FC<OwnProps> = ({
const lang = useOldLang();
const localMediaHash = `gif${gif.id}`;
const isIntersecting = useIsIntersecting(ref, observeIntersection);
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 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 { isBuffered, bufferingHandlers } = useBuffering(true);
const shouldRenderSpinner = loadAndPlay && !isBuffered;
@ -128,7 +128,6 @@ const GifButton: FC<OwnProps> = ({
'GifButton',
gif.width && gif.height && gif.width < gif.height ? 'vertical' : 'horizontal',
onClick && 'interactive',
localMediaHash,
className,
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import type {
ApiDimensions, ApiPhoto, ApiSticker, ApiVideo,
ApiDimensions, ApiMediaExtendedPreview, ApiPhoto, ApiSticker, ApiVideo,
} from '../../../api/types';
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 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;
if (!isMobile) {
return regularMaxWidth;
@ -59,7 +59,7 @@ function getMaxMessageWidthRem(fromOwnMessage: boolean, noAvatars?: boolean, isM
}
export function getAvailableWidth(
fromOwnMessage: boolean,
fromOwnMessage?: boolean,
asForwarded?: boolean,
isWebPageMedia?: boolean,
noAvatars?: boolean,
@ -94,7 +94,7 @@ export function calculateDimensionsForMessageMedia({
}: {
width: number;
height: number;
fromOwnMessage: boolean;
fromOwnMessage?: boolean;
asForwarded?: boolean;
isWebPageMedia?: boolean;
isGif?: boolean;
@ -125,7 +125,7 @@ export function getMediaViewerAvailableDimensions(withFooter: boolean, isVideo:
export function calculateInlineImageDimensions(
photo: ApiPhoto,
fromOwnMessage: boolean,
fromOwnMessage?: boolean,
asForwarded?: boolean,
isWebPageMedia?: boolean,
noAvatars?: boolean,
@ -146,7 +146,7 @@ export function calculateInlineImageDimensions(
export function calculateVideoDimensions(
video: ApiVideo,
fromOwnMessage: boolean,
fromOwnMessage?: boolean,
asForwarded?: boolean,
isWebPageMedia?: 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 {
return {
width: 2 * REM,

View File

@ -105,12 +105,12 @@ const UserBirthday = ({
if (!isToday || !numbersForAge) return;
numbersForAge.forEach((sticker) => {
const hash = getStickerMediaHash(sticker.id);
const hash = getStickerMediaHash(sticker, 'preview');
mediaLoader.fetch(hash, ApiMediaFormat.BlobUrl);
});
if (effectSticker) {
const effectHash = getStickerMediaHash(effectSticker.id);
const effectHash = getStickerMediaHash(effectSticker, 'preview');
mediaLoader.fetch(effectHash, ApiMediaFormat.BlobUrl);
}
}, [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 { getStickerPreviewHash } from '../../../global/helpers';
import { getStickerHashById } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import buildStyle from '../../../util/buildStyle';
import { IS_OFFSET_PATH_SUPPORTED } from '../../../util/windowEnvironment';
@ -31,7 +31,7 @@ const CustomEmojiEffect: FC<OwnProps> = ({
particleSize,
onEnded,
}) => {
const stickerHash = getStickerPreviewHash(reaction.documentId);
const stickerHash = getStickerHashById(reaction.documentId);
const previewMediaData = useMedia(!isLottie ? stickerHash : undefined);

View File

@ -89,10 +89,10 @@ export default function useChatListEntry({
const replyToMessageId = lastMessage && getMessageReplyInfo(lastMessage)?.replyToMsgId;
useEnsureMessage(chatId, isAction ? replyToMessageId : undefined, actionTargetMessage);
const mediaThumbnail = lastMessage && !getMessageSticker(lastMessage)
? getMessageMediaThumbDataUri(lastMessage)
: undefined;
const mediaBlobUrl = useMedia(lastMessage ? getMessageMediaHash(lastMessage, 'micro') : undefined);
const mediaHasPreview = lastMessage && !getMessageSticker(lastMessage);
const mediaThumbnail = mediaHasPreview ? getMessageMediaThumbDataUri(lastMessage) : undefined;
const mediaBlobUrl = useMedia(mediaHasPreview ? getMessageMediaHash(lastMessage, 'micro') : undefined);
const isRoundVideo = Boolean(lastMessage && getMessageRoundVideo(lastMessage));
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 { getActions, withGlobal } from '../../../global';
import type { ApiMessage } from '../../../api/types';
import type { StateProps } from './helpers/createMapStateToProps';
import { AudioOrigin, LoadMoreDirection } from '../../../types';
import { SLIDE_TRANSITION_DURATION } from '../../../config';
import { getIsDownloading } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import { formatMonthAndYear, toYearMonth } from '../../../util/dates/dateFormat';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
@ -70,8 +72,8 @@ const AudioResults: FC<OwnProps & StateProps> = ({
}).filter(Boolean);
}, [globalMessagesByChatId, foundIds]);
const handleMessageFocus = useCallback((messageId: number, chatId: string) => {
focusMessage({ chatId, messageId });
const handleMessageFocus = useCallback((message: ApiMessage) => {
focusMessage({ chatId: message.chatId, messageId: message.id });
}, [focusMessage]);
const handlePlayAudio = useCallback((messageId: number, chatId: string) => {
@ -111,7 +113,7 @@ const AudioResults: FC<OwnProps & StateProps> = ({
onPlay={handlePlayAudio}
onDateClick={handleMessageFocus}
canDownload={!chatsById[message.chatId]?.isProtected && !message.isProtected}
isDownloading={activeDownloads[message.chatId]?.ids?.includes(message.id)}
isDownloading={getIsDownloading(activeDownloads, message.content.audio!)}
/>
</div>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -46,6 +46,7 @@ export const PREMIUM_FEATURE_TITLES: Record<ApiPremiumSection, string> = {
saved_tags: 'PremiumPreviewTags2',
last_seen: 'PremiumPreviewLastSeen',
message_privacy: 'PremiumPreviewMessagePrivacy',
effects: 'PremiumPreviewEffects',
};
export const PREMIUM_FEATURE_DESCRIPTIONS: Record<ApiPremiumSection, string> = {
@ -66,6 +67,7 @@ export const PREMIUM_FEATURE_DESCRIPTIONS: Record<ApiPremiumSection, string> = {
saved_tags: 'PremiumPreviewTagsDescription2',
last_seen: 'PremiumPreviewLastSeenDescription',
message_privacy: 'PremiumPreviewMessagePrivacyDescription',
effects: 'PremiumPreviewEffectsDescription',
};
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 PremiumBadge from '../../../assets/premium/PremiumBadge.svg';
import PremiumChats from '../../../assets/premium/PremiumChats.svg';
import PremiumEffects from '../../../assets/premium/PremiumEffects.svg';
import PremiumEmoji from '../../../assets/premium/PremiumEmoji.svg';
import PremiumFile from '../../../assets/premium/PremiumFile.svg';
import PremiumLastSeen from '../../../assets/premium/PremiumLastSeen.svg';
@ -78,6 +79,7 @@ const PREMIUM_FEATURE_COLOR_ICONS: Record<ApiPremiumSection, string> = {
saved_tags: PremiumTags,
last_seen: PremiumLastSeen,
message_privacy: PremiumMessagePrivacy,
effects: PremiumEffects,
};
export type OwnProps = {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,14 +1,16 @@
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 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 {
selectChatMessage,
selectPeer,
getSenderTitle, isChatChannel, isChatGroup, isUserId,
} from '../../global/helpers';
import {
selectSender,
selectUserFullInfo,
} from '../../global/selectors';
import { formatMediaDateTime } from '../../util/dates/dateFormat';
import renderText from '../common/helpers/renderText';
@ -22,26 +24,21 @@ import Avatar from '../common/Avatar';
import './SenderInfo.scss';
type OwnProps = {
chatId?: string;
messageId?: number;
isAvatar?: boolean;
isFallbackAvatar?: boolean;
item?: MediaViewerItem;
};
type StateProps = {
sender?: ApiPeer;
message?: ApiMessage;
owner?: ApiPeer;
isFallbackAvatar?: boolean;
};
const BULLET = '\u2022';
const ANIMATION_DURATION = 350;
const SenderInfo: FC<OwnProps & StateProps> = ({
chatId,
messageId,
sender,
owner,
item,
isFallbackAvatar,
isAvatar,
message,
}) => {
const {
closeMediaViewer,
@ -54,37 +51,64 @@ const SenderInfo: FC<OwnProps & StateProps> = ({
const handleFocusMessage = useLastCallback(() => {
closeMediaViewer();
if (!chatId || !messageId) return;
if (item?.type !== 'message') return;
const message = item.message;
if (isMobile) {
setTimeout(() => {
toggleChatInfo({ force: false }, { forceSyncOnIOs: true });
focusMessage({ chatId, messageId });
focusMessage({ chatId: message.chatId, messageId: message.id });
}, ANIMATION_DURATION);
} else {
focusMessage({ chatId, messageId });
focusMessage({ chatId: message.chatId, messageId: message.id });
}
});
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;
}
const senderTitle = getSenderTitle(lang, sender);
const senderTitle = getSenderTitle(lang, owner);
return (
<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="title" dir="auto">
{senderTitle && renderText(senderTitle)}
</div>
<div className="date" dir="auto">
{isAvatar
? lang(isFallbackAvatar ? 'lng_mediaview_profile_public_photo' : 'lng_mediaview_profile_photo')
: formatMediaDateTime(lang, message!.date * 1000, true)}
{subtitle}
</div>
</div>
</div>
@ -92,22 +116,21 @@ const SenderInfo: FC<OwnProps & StateProps> = ({
};
export default withGlobal<OwnProps>(
(global, { chatId, messageId, isAvatar }): StateProps => {
if (isAvatar && chatId) {
return {
sender: selectPeer(global, chatId),
};
}
(global, { item }): StateProps => {
const message = item?.type === 'message' ? item.message : undefined;
const messageSender = message && selectSender(global, message);
if (!messageId || !chatId) {
return {};
}
const owner = item?.type === 'avatar' ? item.avatarOwner : messageSender;
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 {
message,
sender: message && selectSender(global, message),
owner,
isFallbackAvatar,
};
},
)(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,
isVideo: boolean,
message?: ApiMessage,
mediaIndex?: number,
) {
const { mediaEl: fromImage } = getNodes(origin, message);
const { mediaEl: fromImage } = getNodes(origin, message, mediaIndex);
if (!fromImage) {
return;
}
@ -93,8 +94,10 @@ export function animateOpening(
});
}
export function animateClosing(origin: MediaViewerOrigin, bestImageData: string, message?: ApiMessage) {
const { container, mediaEl: toImage } = getNodes(origin, message);
export function animateClosing(
origin: MediaViewerOrigin, bestImageData: string, message?: ApiMessage, mediaIndex?: number,
) {
const { container, mediaEl: toImage } = getNodes(origin, message, mediaIndex);
if (!toImage) {
return;
}
@ -102,7 +105,7 @@ export function animateClosing(origin: MediaViewerOrigin, bestImageData: string,
const fromImage = document.getElementById('MediaViewer')!.querySelector<HTMLImageElement>(
'.MediaViewerSlide--active img, .MediaViewerSlide--active video',
);
if (!fromImage || !toImage) {
if (!fromImage) {
return;
}
@ -284,24 +287,25 @@ function getTopOffset(hasFooter: boolean) {
return topOffsetRem * REM;
}
function getNodes(origin: MediaViewerOrigin, message?: ApiMessage) {
function getNodes(origin: MediaViewerOrigin, message?: ApiMessage, index?: number) {
let containerSelector;
let mediaSelector;
switch (origin) {
case MediaViewerOrigin.Album:
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';
break;
case MediaViewerOrigin.SharedMedia:
containerSelector = `#shared-media${getMessageHtmlId(message!.id)}`;
containerSelector = `#shared-media${getMessageHtmlId(message!.id, index)}`;
mediaSelector = 'img';
break;
case MediaViewerOrigin.SearchResult:
containerSelector = `#search-media${getMessageHtmlId(message!.id)}`;
containerSelector = `#search-media${getMessageHtmlId(message!.id, index)}`;
mediaSelector = 'img';
break;
@ -321,19 +325,25 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage) {
break;
case MediaViewerOrigin.SuggestedAvatar:
containerSelector = `.Transition_slide-active > .MessageList #${getMessageHtmlId(message!.id)}`;
containerSelector = `.Transition_slide-active > .MessageList #${getMessageHtmlId(message!.id, index)}`;
mediaSelector = '.Avatar img';
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.Inline:
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)`;
}
const container = document.querySelector<HTMLElement>(containerSelector)!;
const mediaEls = container && container.querySelectorAll<HTMLImageElement | HTMLVideoElement>(mediaSelector);
const mediaEls = mediaSelector
? container?.querySelectorAll<HTMLImageElement | HTMLVideoElement>(mediaSelector) : undefined;
return {
container,
@ -347,6 +357,7 @@ function applyShape(ghost: HTMLDivElement, origin: MediaViewerOrigin) {
case MediaViewerOrigin.ScheduledAlbum:
case MediaViewerOrigin.Inline:
case MediaViewerOrigin.ScheduledInline:
case MediaViewerOrigin.StarsTransaction:
ghost.classList.add('rounded-corners');
break;

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,7 @@ import type { ApiStickerSet } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { STICKER_SIZE_PICKER_HEADER } from '../../../config';
import { getStickerPreviewHash } from '../../../global/helpers';
import { getStickerMediaHash } from '../../../global/helpers';
import { selectIsAlwaysHighPriorityEmoji } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import { getFirstLetters } from '../../../util/textFormat';
@ -62,7 +62,7 @@ const StickerSetCover: FC<OwnProps> = ({
const hasOnlyStaticThumb = hasStaticThumb && !hasVideoThumb && !hasAnimatedThumb && !thumbCustomEmojiId;
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 mediaHash = ((hasThumbnail && !shouldFallbackToStatic) || hasAnimatedThumb) && `stickerSet${stickerSet.id}`;
@ -75,10 +75,7 @@ const StickerSetCover: FC<OwnProps> = ({
useEffect(() => {
if (isIntersecting && !stickerSet.stickers?.length) {
loadStickers({
stickerSetInfo: {
id: stickerSet.id,
accessHash: stickerSet.accessHash,
},
stickerSetInfo: 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 { STARS_ICON_PLACEHOLDER } from '../../../../config';
import { replaceWithTeact } from '../../../../util/replaceWithTeact';
import renderText from '../../../common/helpers/renderText';
@ -15,7 +16,7 @@ export default function renderKeyboardButtonText(lang: LangFn, button: ApiKeyboa
}
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);

View File

@ -39,7 +39,7 @@ export function groupMessages(
messages: [message],
mainMessage: message,
hasMultipleCaptions: false,
};
} satisfies IAlbum;
} else {
currentAlbum.messages.push(message);
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 {
currentSenderGroup.push(message);
}
@ -67,6 +75,7 @@ export function groupMessages(
currentSenderGroup.push(currentAlbum);
currentAlbum = undefined;
}
const lastSenderGroupItem = currentSenderGroup[currentSenderGroup.length - 1];
if (nextMessage) {
const nextMessageDayStartsAt = getDayStartAt(nextMessage.date * 1000);

View File

@ -5,6 +5,10 @@
.message-content.media.text & {
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) & {

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -6,7 +6,7 @@ import type { ApiMessage } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import { ApiMediaFormat } from '../../../api/types';
import { getMessageMediaHash } from '../../../global/helpers';
import { getStickerMediaHash } from '../../../global/helpers';
import buildClassName from '../../../util/buildClassName';
import { IS_WEBM_SUPPORTED } from '../../../util/windowEnvironment';
import { getStickerDimensions } from '../../common/helpers/mediaDimensions';
@ -58,7 +58,7 @@ const Sticker: FC<OwnProps> = ({
const isMirrored = !message.isOutgoing;
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);

View File

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

View File

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

View File

@ -37,6 +37,11 @@
user-select: text;
}
.content-unsupported {
font-style: italic;
color: var(--color-text-meta);
}
.text-content,
.transcription {
margin: 0;
@ -678,7 +683,8 @@
}
.message-media-duration,
.message-transfer-progress {
.message-transfer-progress,
.message-paid-media-status {
background: rgba(0, 0, 0, 0.25);
color: #fff;
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 { getMessageContent } from '../../../../global/helpers';
import getSingularPaidMedia from './getSingularPaidMedia';
export function buildContentClassName(
message: ApiMessage,
@ -37,19 +38,24 @@ export function buildContentClassName(
hasOutsideReactions?: boolean;
} = {},
) {
const { paidMedia } = getMessageContent(message);
const { photo: paidMediaPhoto, video: paidMediaVideo } = getSingularPaidMedia(paidMedia);
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,
} = getMessageContent(message);
const text = album?.hasMultipleCaptions ? undefined : getMessageContent(album?.captionMessage || message).text;
const hasFactCheck = Boolean(message.factCheck?.text);
const isRoundVideo = video?.mediaType === 'video' && video.isRound;
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 isMedia = storyData || photo || video || location || invoice?.extendedMedia;
const hasText = text || location?.type === 'venue' || isGeoLiveActive || hasFactCheck;
const isMedia = storyData || photo || video || location || invoice?.extendedMedia || paidMedia;
const hasText = text || location?.mediaType === 'venue' || isGeoLiveActive || hasFactCheck;
const isMediaWithNoText = isMedia && !hasText;
const isViaBot = Boolean(message.viaBotId);
@ -84,7 +90,7 @@ export function buildContentClassName(
if (isCustomShape) {
classNames.push('custom-shape');
if (video?.isRound) {
if (isRoundVideo) {
classNames.push('round');
}

View File

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

View File

@ -5,12 +5,12 @@ import { ApiMediaFormat } from '../../../../api/types';
import {
getMessageContact,
getMessageHtmlId,
getMessageMediaHash,
getMessagePhoto,
getMessageText,
getMessageWebPagePhoto,
getMessageWebPageVideo,
hasMessageLocalBlobUrl,
getPhotoMediaHash,
hasMediaLocalBlobUrl,
} from '../../../../global/helpers';
import { getMessageTextWithSpoilers } from '../../../../global/helpers/messageSummary';
import {
@ -44,8 +44,8 @@ export function getMessageCopyOptions(
const photo = getMessagePhoto(message)
|| (!getMessageWebPageVideo(message) ? getMessageWebPagePhoto(message) : undefined);
const contact = getMessageContact(message);
const mediaHash = getMessageMediaHash(message, 'inline');
const canImageBeCopied = canCopy && photo && (mediaHash || hasMessageLocalBlobUrl(message))
const mediaHash = photo ? getPhotoMediaHash(photo, 'inline') : undefined;
const canImageBeCopied = canCopy && photo && (mediaHash || hasMediaLocalBlobUrl(photo))
&& CLIPBOARD_ITEM_SUPPORTED && !IS_SAFARI;
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 {
getMessagePhoto,
getMessageText,
getMessageVideo,
getMessageWebPagePhoto,
getMessageWebPageVideo,
isOwnMessage,
} from '../../../../global/helpers';
import { calculateInlineImageDimensions, calculateVideoDimensions, REM } from '../../../common/helpers/mediaDimensions';
calculateExtendedPreviewDimensions,
calculateInlineImageDimensions,
calculateVideoDimensions,
REM,
} from '../../../common/helpers/mediaDimensions';
const SMALL_IMAGE_THRESHOLD = 12;
const MIN_MESSAGE_LENGTH_FOR_BLUR = 40;
@ -22,20 +19,32 @@ export function getMinMediaWidth(text?: string, hasCommentButton?: boolean) {
: MIN_MEDIA_WIDTH;
}
export function calculateMediaDimensions(
message: ApiMessage, asForwarded?: boolean, noAvatars?: boolean, isMobile?: boolean,
) {
const isOwn = isOwnMessage(message);
const photo = getMessagePhoto(message) || getMessageWebPagePhoto(message);
const video = getMessageVideo(message);
export function calculateMediaDimensions({
media,
messageText,
isOwn,
isInWebPage,
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);
let stretchFactor = 1;

View File

@ -14,9 +14,10 @@ import buildClassName from '../../../../util/buildClassName';
import useLastCallback from '../../../../hooks/useLastCallback';
type OwnProps =
PhotoProps
& VideoProps;
type OwnProps<T> =
(PhotoProps<T> | VideoProps<T>) & {
clickArg: number;
};
type StateProps = {
isInSelectMode?: boolean;
@ -24,18 +25,19 @@ type StateProps = {
};
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 {
isInSelectMode,
isSelected,
message,
dimensions,
clickArg,
} = props;
const { toggleMessageSelection } = getActions();
const handleMessageSelect = useLastCallback((e: ReactMouseEvent<HTMLDivElement, MouseEvent>) => {
e.stopPropagation();
toggleMessageSelection({ messageId: message.id, withShift: e?.shiftKey });
toggleMessageSelection({ messageId: clickArg, withShift: e?.shiftKey });
});
const newProps = useMemo(() => {
@ -72,13 +74,13 @@ export default function withSelectControl(WrappedComponent: FC) {
);
};
return memo(withGlobal<OwnProps>(
return memo(withGlobal<OwnProps<unknown>>(
(global, ownProps) => {
const { message } = ownProps;
const { clickArg } = ownProps;
return {
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 { getMessageMediaThumbDataUri } from '../../../../global/helpers';
import { getMediaThumbUri, type MediaWithThumbs } from '../../../../global/helpers';
import { IS_CANVAS_FILTER_SUPPORTED } from '../../../../util/windowEnvironment';
import useAppLayout from '../../../../hooks/useAppLayout';
import useCanvasBlur from '../../../../hooks/useCanvasBlur';
type CanvasBlurReturnType = ReturnType<typeof useCanvasBlur>;
export default function useBlurredMediaThumbRef(
message: ApiMessage,
isDisabled?: boolean | string,
forcedUri?: string,
forcedUri: string | undefined, isDisabled: boolean,
): CanvasBlurReturnType;
export default function useBlurredMediaThumbRef(media: MediaWithThumbs, isDisabled?: boolean) : CanvasBlurReturnType;
export default function useBlurredMediaThumbRef(
media: MediaWithThumbs | string | undefined,
isDisabled?: boolean,
) {
const { isMobile } = useAppLayout();
const dataUri = forcedUri || getMessageMediaThumbDataUri(message);
const dataUri = media ? typeof media === 'string' ? media : getMediaThumbUri(media) : undefined;
return useCanvasBlur(
dataUri,
Boolean(isDisabled),
isDisabled,
isMobile && !IS_CANVAS_FILTER_SUPPORTED,
);
}

View File

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

View File

@ -26,9 +26,11 @@ type OwnProps = {
headerImageUrl?: string;
headerAvatarPeer?: ApiPeer | CustomPeer;
headerAvatarWebPhoto?: ApiWebDocument;
noHeaderImage?: boolean;
header?: TeactNode;
footer?: TeactNode;
buttonText?: string;
className?: string;
onClose: NoneToVoidFunction;
onButtonClick?: NoneToVoidFunction;
};
@ -40,9 +42,11 @@ const TableInfoModal = ({
headerImageUrl,
headerAvatarPeer,
headerAvatarWebPhoto,
noHeaderImage,
header,
footer,
buttonText,
className,
onClose,
onButtonClick,
}: OwnProps) => {
@ -61,13 +65,16 @@ const TableInfoModal = ({
hasAbsoluteCloseButton={!title}
isSlim
title={title}
className={className}
contentClassName={styles.content}
onClose={onClose}
>
{withAvatar ? (
<Avatar peer={headerAvatarPeer} webPhoto={headerAvatarWebPhoto} size="jumbo" className={styles.avatar} />
) : (
<img className={styles.logo} src={headerImageUrl} alt="" draggable={false} />
{!noHeaderImage && (
withAvatar ? (
<Avatar peer={headerAvatarPeer} webPhoto={headerAvatarWebPhoto} size="jumbo" className={styles.avatar} />
) : (
<img className={styles.logo} src={headerImageUrl} alt="" draggable={false} />
)
)}
{header}
<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';
.root {
z-index: calc(var(--z-media-viewer) - 1);
}
.root :global(.modal-content) {
padding: 0;
}

View File

@ -1,11 +1,15 @@
import React, { memo, useEffect, useMemo } from '../../../lib/teact/teact';
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 { getUserFullName } from '../../../global/helpers';
import { selectTabState, selectUser } from '../../../global/selectors';
import { getChatTitle, getUserFullName } from '../../../global/helpers';
import {
selectChat, selectChatMessage, selectTabState, selectUser,
} from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import renderText from '../../common/helpers/renderText';
@ -18,6 +22,7 @@ import StarIcon from '../../common/icons/StarIcon';
import Button from '../../ui/Button';
import Modal from '../../ui/Modal';
import BalanceBlock from './BalanceBlock';
import PaidMediaThumb from './PaidMediaThumb';
import styles from './StarsBalanceModal.module.scss';
@ -31,10 +36,17 @@ type StateProps = {
payment?: TabState['payment'];
starsBalanceState?: GlobalState['stars'];
bot?: ApiUser;
paidMediaMessage?: ApiMessage;
paidMediaChat?: ApiChat;
};
const StarPaymentModal = ({
modal, bot, starsBalanceState, payment,
modal,
bot,
starsBalanceState,
payment,
paidMediaMessage,
paidMediaChat,
}: OwnProps & StateProps) => {
const { closePaymentModal, openStarsBalanceModal, sendStarPaymentForm } = getActions();
const [isLoading, markLoading, unmarkLoading] = useFlag();
@ -58,8 +70,21 @@ const StarPaymentModal = ({
const botName = getUserFullName(bot);
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]);
}, [bot, payment, lang]);
}, [payment?.invoice, bot, lang, paidMediaMessage, paidMediaChat]);
const handlePayment = useLastCallback(() => {
const price = payment?.invoice?.amount;
@ -89,8 +114,14 @@ const StarPaymentModal = ({
>
<BalanceBlock balance={starsBalanceState?.balance || 0} className={styles.modalBalance} />
<div className={styles.paymentImages} dir={lang.isRtl ? 'ltr' : 'rtl'}>
<Avatar peer={bot} size="giant" />
{photo && <Avatar className={styles.paymentPhoto} webPhoto={photo} size="giant" />}
{paidMediaMessage ? (
<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} />
</div>
<h2 className={styles.headerText}>
@ -114,10 +145,19 @@ export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const payment = selectTabState(global).payment;
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 {
bot,
starsBalanceState: global.stars,
payment,
paidMediaMessage: isPaidMedia ? message : undefined,
paidMediaChat: isPaidMedia ? chat : undefined,
};
},
)(StarPaymentModal));

View File

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

View File

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

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