diff --git a/.stylelintrc.json b/.stylelintrc.json index 9e945e829..61d981f38 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -12,6 +12,15 @@ "./dev/wholePixel.js" ], "rules": { + "property-no-unknown": [ + true, + { + "ignoreProperties": [ + "composes", + "compose-with" + ] + } + ], "number-leading-zero": "always", "selector-attribute-quotes": "always", "scss/operator-no-unspaced": null, diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 8f9c21c0e..e4842af1d 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -3,9 +3,18 @@ import BigInt from 'big-integer'; import localDb from '../localDb'; import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiAppConfig } from '../../types'; +import type { ApiLimitType } from '../../../global/types'; import { buildJson } from './misc'; +import { DEFAULT_LIMITS } from '../../../config'; -type GramJsAppConfig = { +type LimitType = 'default' | 'premium'; +type Limit = 'upload_max_fileparts' | 'stickers_faved_limit' | 'saved_gifs_limit' | 'dialog_filters_chats_limit' | +'dialog_filters_limit' | 'dialogs_folder_pinned_limit' | 'dialogs_pinned_limit' | 'caption_length_limit' | +'channels_limit' | 'channels_public_limit' | 'about_length_limit'; +type LimitKey = `${Limit}_${LimitType}`; +type LimitsConfig = Record; + +interface GramJsAppConfig extends LimitsConfig { emojies_sounds: Record buildApiBotCommand(botId, command)); + const botId = userId && buildApiPeerId(userId, 'user'); + const photo = descriptionPhoto instanceof GramJs.Photo ? buildApiPhoto(descriptionPhoto) : undefined; + const gif = descriptionDocument instanceof GramJs.Document ? buildVideoFromDocument(descriptionDocument) : undefined; + + const commandsArray = commands?.map((command) => buildApiBotCommand(botId || chatId, command)); return { - botId, + botId: botId || chatId, description, + gif, + photo, menuButton: buildApiBotMenuButton(menuButton), - commands: commandsArray.length ? commandsArray : undefined, + commands: commandsArray?.length ? commandsArray : undefined, }; } @@ -112,7 +124,7 @@ function buildApiBotCommand(botId: string, command: GramJs.BotCommand): ApiBotCo }; } -export function buildApiBotMenuButton(menuButton: GramJs.TypeBotMenuButton): ApiBotMenuButton { +export function buildApiBotMenuButton(menuButton?: GramJs.TypeBotMenuButton): ApiBotMenuButton { if (menuButton instanceof GramJs.BotMenuButton) { return { type: 'webApp', diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index d13ae5278..56e49f52a 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -31,17 +31,22 @@ function buildApiChatFieldsFromPeerEntity( ): PeerEntityApiChatFields { const isMin = Boolean('min' in peerEntity && peerEntity.min); const accessHash = ('accessHash' in peerEntity) && String(peerEntity.accessHash); + const hasVideoAvatar = 'photo' in peerEntity && peerEntity.photo && 'hasVideo' in peerEntity.photo + && peerEntity.photo.hasVideo; const avatarHash = ('photo' in peerEntity) && peerEntity.photo && buildAvatarHash(peerEntity.photo); const isSignaturesShown = Boolean('signatures' in peerEntity && peerEntity.signatures); const hasPrivateLink = Boolean('hasLink' in peerEntity && peerEntity.hasLink); const isScam = Boolean('scam' in peerEntity && peerEntity.scam); const isFake = Boolean('fake' in peerEntity && peerEntity.fake); + const isJoinToSend = Boolean('joinToSend' in peerEntity && peerEntity.joinToSend); + const isJoinRequest = Boolean('joinRequest' in peerEntity && peerEntity.joinRequest); return { isMin, hasPrivateLink, isSignaturesShown, ...(accessHash && { accessHash }), + hasVideoAvatar, ...(avatarHash && { avatarHash }), ...( (peerEntity instanceof GramJs.Channel || peerEntity instanceof GramJs.User) @@ -63,6 +68,8 @@ function buildApiChatFieldsFromPeerEntity( ...buildApiChatRestrictions(peerEntity), ...buildApiChatMigrationInfo(peerEntity), fakeType: isScam ? 'scam' : (isFake ? 'fake' : undefined), + isJoinToSend, + isJoinRequest, }; } @@ -379,9 +386,10 @@ export function buildApiChatFolder(filter: GramJs.DialogFilter): ApiChatFolder { export function buildApiChatFolderFromSuggested({ filter, description, }: { - filter: GramJs.DialogFilter; + filter: GramJs.TypeDialogFilter; description: string; -}): ApiChatFolder { +}): ApiChatFolder | undefined { + if (!(filter instanceof GramJs.DialogFilter)) return undefined; return { ...buildApiChatFolder(filter), description, @@ -390,12 +398,14 @@ export function buildApiChatFolderFromSuggested({ export function buildApiChatBotCommands(botInfos: GramJs.BotInfo[]) { return botInfos.reduce((botCommands, botInfo) => { - const botId = buildApiPeerId(botInfo.userId, 'user'); + const botId = buildApiPeerId(botInfo.userId!, 'user'); - botCommands = botCommands.concat(botInfo.commands.map((mtpCommand) => ({ - botId, - ...omitVirtualClassFields(mtpCommand), - }))); + if (botInfo.commands) { + botCommands = botCommands.concat(botInfo.commands.map((mtpCommand) => ({ + botId, + ...omitVirtualClassFields(mtpCommand), + }))); + } return botCommands; }, [] as ApiBotCommand[]); diff --git a/src/api/gramjs/apiBuilders/common.ts b/src/api/gramjs/apiBuilders/common.ts index 85e2ac727..7c2f6ceb2 100644 --- a/src/api/gramjs/apiBuilders/common.ts +++ b/src/api/gramjs/apiBuilders/common.ts @@ -2,7 +2,7 @@ import { Api as GramJs } from '../../../lib/gramjs'; import { strippedPhotoToJpg } from '../../../lib/gramjs/Utils'; import type { - ApiPhoto, ApiPhotoSize, ApiThumbnail, + ApiPhoto, ApiPhotoSize, ApiThumbnail, ApiVideoSize, } from '../../types'; import { bytesToDataUri } from './helpers'; import { pathBytesToSvg } from './pathBytesToSvg'; @@ -73,6 +73,21 @@ export function buildApiPhoto(photo: GramJs.Photo): ApiPhoto { id: String(photo.id), thumbnail: buildApiThumbnailFromStripped(photo.sizes), sizes, + ...(photo.videoSizes && { videoSizes: photo.videoSizes.map(buildApiVideoSize), isVideo: true }), + }; +} + +export function buildApiVideoSize(videoSize: GramJs.VideoSize): ApiVideoSize { + const { + videoStartTs, size, h, w, type, + } = videoSize; + + return { + videoStartTs, + size, + height: h, + width: w, + type: type as ('u' | 'v'), }; } diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 0a114fb25..c1a695279 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -31,6 +31,7 @@ import type { ApiLocation, ApiGame, PhoneCallAction, + ApiWebDocument, } from '../../types'; import { @@ -66,7 +67,7 @@ export function setMessageBuilderCurrentUserId(_currentUserId: string) { export function buildApiSponsoredMessage(mtpMessage: GramJs.SponsoredMessage): ApiSponsoredMessage | undefined { const { - fromId, message, entities, startParam, channelPost, chatInvite, chatInviteHash, randomId, + fromId, message, entities, startParam, channelPost, chatInvite, chatInviteHash, randomId, recommended, } = mtpMessage; const chatId = fromId ? getApiChatIdFromMtpPeer(fromId) : undefined; const chatInviteTitle = chatInvite @@ -80,6 +81,7 @@ export function buildApiSponsoredMessage(mtpMessage: GramJs.SponsoredMessage): A isBot: fromId ? isPeerUser(fromId) : false, text: buildMessageTextContent(message, entities), expiresAt: Math.round(Date.now() / 1000) + SPONSORED_MESSAGE_CACHE_MS, + isRecommended: Boolean(recommended), ...(chatId && { chatId }), ...(chatInviteHash && { chatInviteHash }), ...(chatInvite && { chatInviteTitle }), @@ -237,17 +239,21 @@ export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReactio export function buildApiAvailableReaction(availableReaction: GramJs.AvailableReaction): ApiAvailableReaction { const { selectAnimation, staticIcon, reaction, title, - inactive, aroundAnimation, centerIcon, + inactive, aroundAnimation, centerIcon, effectAnimation, activateAnimation, + premium, } = availableReaction; return { selectAnimation: buildApiDocument(selectAnimation), + activateAnimation: buildApiDocument(activateAnimation), + effectAnimation: buildApiDocument(effectAnimation), staticIcon: buildApiDocument(staticIcon), aroundAnimation: aroundAnimation ? buildApiDocument(aroundAnimation) : undefined, centerIcon: centerIcon ? buildApiDocument(centerIcon) : undefined, reaction, title, isInactive: inactive, + isPremium: premium, }; } @@ -368,7 +374,7 @@ function buildSticker(media: GramJs.TypeMessageMedia): ApiSticker | undefined { return undefined; } - return buildStickerFromDocument(media.document); + return buildStickerFromDocument(media.document, media.nopremium); } function buildPhoto(media: GramJs.TypeMessageMedia): ApiPhoto | undefined { @@ -427,7 +433,7 @@ export function buildVideoFromDocument(document: GramJs.Document): ApiVideo | un isRound, isGif: Boolean(gifAttr), thumbnail: buildApiThumbnailFromStripped(thumbs), - size, + size: size.toJSNumber(), }; } @@ -469,7 +475,8 @@ function buildAudio(media: GramJs.TypeMessageMedia): ApiAudio | undefined { id: String(media.document.id), fileName: getFilenameFromDocument(media.document, 'audio'), thumbnailSizes, - ...pick(media.document, ['size', 'mimeType']), + size: media.document.size.toJSNumber(), + ...pick(media.document, ['mimeType']), ...pick(audioAttribute, ['duration', 'performer', 'title']), }; } @@ -559,7 +566,7 @@ export function buildApiDocument(document: GramJs.TypeDocument): ApiDocument | u return { id: String(id), - size, + size: size.toJSNumber(), mimeType, timestamp: date, fileName: getFilenameFromDocument(document), @@ -717,22 +724,10 @@ export function buildInvoice(media: GramJs.MessageMediaInvoice): ApiInvoice { description: text, title, photo, test, totalAmount, currency, receiptMsgId, } = media; - const imageAttribute = photo?.attributes - .find((a: any): a is GramJs.DocumentAttributeImageSize => a instanceof GramJs.DocumentAttributeImageSize); - - let photoWidth: number | undefined; - let photoHeight: number | undefined; - if (imageAttribute) { - photoWidth = imageAttribute.w; - photoHeight = imageAttribute.h; - } - return { text, title, - photoUrl: photo?.url, - photoWidth, - photoHeight, + photo: buildApiWebDocument(photo), receiptMsgId, amount: Number(totalAmount), currency, @@ -1311,6 +1306,27 @@ function buildUploadingMedia( }; } +export function buildApiWebDocument(document?: GramJs.TypeWebDocument): ApiWebDocument | undefined { + if (!document) return undefined; + + const { + url, size, mimeType, + } = document; + const accessHash = document instanceof GramJs.WebDocument ? document.accessHash.toString() : undefined; + const sizeAttr = document.attributes.find((attr): attr is GramJs.DocumentAttributeImageSize => ( + attr instanceof GramJs.DocumentAttributeImageSize + )); + const dimensions = sizeAttr && { width: sizeAttr.w, height: sizeAttr.h }; + + return { + url, + accessHash, + size, + mimeType, + dimensions, + }; +} + function buildNewPoll(poll: ApiNewPoll, localId: number) { return { poll: { @@ -1321,7 +1337,7 @@ function buildNewPoll(poll: ApiNewPoll, localId: number) { }; } -function buildApiMessageEntity(entity: GramJs.TypeMessageEntity): ApiMessageEntity { +export function buildApiMessageEntity(entity: GramJs.TypeMessageEntity): ApiMessageEntity { const { className: type, offset, length } = entity; return { type, diff --git a/src/api/gramjs/apiBuilders/payments.ts b/src/api/gramjs/apiBuilders/payments.ts index c8752fd8b..20ce8cb59 100644 --- a/src/api/gramjs/apiBuilders/payments.ts +++ b/src/api/gramjs/apiBuilders/payments.ts @@ -1,5 +1,10 @@ import type { Api as GramJs } from '../../../lib/gramjs'; +import type { ApiInvoice, ApiPaymentSavedInfo, ApiPremiumPromo } from '../../types'; + +import { buildApiDocument, buildApiMessageEntity, buildApiWebDocument } from './messages'; +import { omitVirtualClassFields } from './helpers'; + export function buildShippingOptions(shippingOptions: GramJs.ShippingOption[] | undefined) { if (!shippingOptions) { return undefined; @@ -8,11 +13,11 @@ export function buildShippingOptions(shippingOptions: GramJs.ShippingOption[] | return { id: option.id, title: option.title, - amount: option.prices.reduce((ac, cur) => ac + Number((cur.amount as any).value), 0), + amount: option.prices.reduce((ac, cur) => ac + cur.amount.toJSNumber(), 0), prices: option.prices.map(({ label, amount }) => { return { label, - amount: Number((amount as any).value), + amount: amount.toJSNumber(), }; }), }; @@ -34,7 +39,7 @@ export function buildReceipt(receipt: GramJs.payments.PaymentReceipt) { const { prices } = invoice; const mapedPrices = prices.map(({ label, amount }) => ({ label, - amount: Number((amount as any).value), + amount: amount.toJSNumber(), })); let shippingPrices; @@ -44,7 +49,7 @@ export function buildReceipt(receipt: GramJs.payments.PaymentReceipt) { shippingPrices = shipping.prices.map(({ label, amount }) => { return { label, - amount: Number((amount as any).value), + amount: amount.toJSNumber(), }; }); shippingMethod = shipping.title; @@ -54,7 +59,7 @@ export function buildReceipt(receipt: GramJs.payments.PaymentReceipt) { currency, prices: mapedPrices, info: { shippingAddress, phone, name }, - totalAmount: Number((totalAmount as any).value), + totalAmount: totalAmount.toJSNumber(), credentialsTitle, shippingPrices, shippingMethod, @@ -86,19 +91,25 @@ export function buildPaymentForm(form: GramJs.payments.PaymentForm) { prices, } = invoice; - const mapedPrices = prices.map(({ label, amount }) => ({ + const mappedPrices = prices.map(({ label, amount }) => ({ label, - amount: Number((amount as any).value), + amount: amount.toJSNumber(), })); + const { shippingAddress } = savedInfo || {}; + const cleanedInfo: ApiPaymentSavedInfo | undefined = savedInfo ? omitVirtualClassFields(savedInfo) : undefined; + if (cleanedInfo && shippingAddress) { + cleanedInfo.shippingAddress = omitVirtualClassFields(shippingAddress); + } const nativeData = nativeParams ? JSON.parse(nativeParams.data) : {}; + return { canSaveCredentials, passwordMissing, formId: String(formId), providerId: String(providerId), nativeProvider, - savedInfo, + savedInfo: cleanedInfo, invoice: { test, nameRequested, @@ -109,7 +120,7 @@ export function buildPaymentForm(form: GramJs.payments.PaymentForm) { phoneToProvider, emailToProvider, currency, - prices: mapedPrices, + prices: mappedPrices, }, nativeParams: { needCardholderName: nativeData.need_cardholder_name, @@ -120,3 +131,40 @@ export function buildPaymentForm(form: GramJs.payments.PaymentForm) { }, }; } + +export function buildApiInvoiceFromForm(form: GramJs.payments.PaymentForm): ApiInvoice { + const { + invoice, description: text, title, photo, + } = form; + const { + test, currency, prices, recurring, recurringTermsUrl, + } = invoice; + + const totalAmount = prices.reduce((ac, cur) => ac + cur.amount.toJSNumber(), 0); + + return { + text, + title, + photo: buildApiWebDocument(photo), + amount: totalAmount, + currency, + isTest: test, + isRecurring: recurring, + recurringTermsUrl, + }; +} + +export function buildApiPremiumPromo(promo: GramJs.help.PremiumPromo): ApiPremiumPromo { + const { + statusText, statusEntities, videos, videoSections, currency, monthlyAmount, + } = promo; + + return { + statusText, + statusEntities: statusEntities.map((l) => buildApiMessageEntity(l)), + videoSections, + currency, + videos: videos.map(buildApiDocument).filter(Boolean), + monthlyAmount: monthlyAmount.toString(), + }; +} diff --git a/src/api/gramjs/apiBuilders/symbols.ts b/src/api/gramjs/apiBuilders/symbols.ts index bde29e8c5..c0a727ebe 100644 --- a/src/api/gramjs/apiBuilders/symbols.ts +++ b/src/api/gramjs/apiBuilders/symbols.ts @@ -10,12 +10,12 @@ import localDb from '../localDb'; const LOTTIE_STICKER_MIME_TYPE = 'application/x-tgsticker'; const VIDEO_STICKER_MIME_TYPE = 'video/webm'; -export function buildStickerFromDocument(document: GramJs.TypeDocument): ApiSticker | undefined { +export function buildStickerFromDocument(document: GramJs.TypeDocument, isNoPremium?: boolean): ApiSticker | undefined { if (document instanceof GramJs.DocumentEmpty) { return undefined; } - const { mimeType } = document; + const { mimeType, videoThumbs } = document; const stickerAttribute = document.attributes .find((attr: any): attr is GramJs.DocumentAttributeSticker => ( attr instanceof GramJs.DocumentAttributeSticker @@ -78,6 +78,8 @@ export function buildStickerFromDocument(document: GramJs.TypeDocument): ApiStic const { w: width, h: height } = cachedThumb as GramJs.PhotoCachedSize || sizeAttribute || {}; + const hasEffect = !isNoPremium && videoThumbs?.some(({ type }) => type === 'f'); + return { id: String(document.id), stickerSetId: stickerSetInfo ? String(stickerSetInfo.id) : NO_STICKER_SET_ID, @@ -88,6 +90,7 @@ export function buildStickerFromDocument(document: GramJs.TypeDocument): ApiStic width, height, thumbnail, + hasEffect, }; } diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index 12449de27..19697e3c7 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -4,11 +4,13 @@ import type { } from '../../types'; import { buildApiPeerId } from './peers'; import { buildApiBotInfo } from './bots'; +import { buildApiPhoto } from './common'; export function buildApiUserFromFull(mtpUserFull: GramJs.users.UserFull): ApiUser { const { fullUser: { about, commonChatsCount, pinnedMsgId, botInfo, blocked, + profilePhoto, }, users, } = mtpUserFull; @@ -18,11 +20,12 @@ export function buildApiUserFromFull(mtpUserFull: GramJs.users.UserFull): ApiUse return { ...user, fullInfo: { + ...(profilePhoto instanceof GramJs.Photo && { profilePhoto: buildApiPhoto(profilePhoto) }), bio: about, commonChatsCount, pinnedMessageId: pinnedMsgId, isBlocked: Boolean(blocked), - ...(botInfo && { botInfo: buildApiBotInfo(botInfo) }), + ...(botInfo && { botInfo: buildApiBotInfo(botInfo, user.id) }), }, }; } @@ -35,6 +38,9 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { const { id, firstName, lastName, fake, scam, } = mtpUser; + const hasVideoAvatar = mtpUser.photo instanceof GramJs.UserProfilePhoto + ? Boolean(mtpUser.photo.hasVideo) + : undefined; const avatarHash = mtpUser.photo instanceof GramJs.UserProfilePhoto ? String(mtpUser.photo.photoId) : undefined; @@ -45,6 +51,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { isMin: Boolean(mtpUser.min), fakeType: scam ? 'scam' : (fake ? 'fake' : undefined), ...(mtpUser.self && { isSelf: true }), + isPremium: Boolean(mtpUser.premium), ...(mtpUser.verified && { isVerified: true }), ...((mtpUser.contact || mtpUser.mutualContact) && { isContact: true }), type: userType, @@ -56,6 +63,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined { noStatus: !mtpUser.status, ...(mtpUser.accessHash && { accessHash: String(mtpUser.accessHash) }), ...(avatarHash && { avatarHash }), + hasVideoAvatar, ...(mtpUser.bot && mtpUser.botInlinePlaceholder && { botPlaceholder: mtpUser.botInlinePlaceholder }), ...(mtpUser.bot && mtpUser.botAttachMenu && { isAttachMenuBot: mtpUser.botAttachMenu }), }; diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index a43510120..81a091d12 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -19,6 +19,7 @@ import type { ApiVideo, ApiThemeParameters, ApiPoll, + ApiRequestInputInvoice, } from '../../types'; import { ApiMessageEntityTypes, @@ -349,6 +350,8 @@ export function isMessageWithMedia(message: GramJs.Message | GramJs.UpdateServic ) || ( media instanceof GramJs.MessageMediaGame && (media.game.document instanceof GramJs.Document || media.game.photo instanceof GramJs.Photo) + ) || ( + media instanceof GramJs.MessageMediaInvoice && media.photo ) ); } @@ -525,3 +528,16 @@ export function buildInputPhoneCall({ id, accessHash }: ApiPhoneCall) { accessHash: BigInt(accessHash!), }); } + +export function buildInputInvoice(invoice: ApiRequestInputInvoice) { + if ('slug' in invoice) { + return new GramJs.InputInvoiceSlug({ + slug: invoice.slug, + }); + } else { + return new GramJs.InputInvoiceMessage({ + peer: buildInputPeer(invoice.chat.id, invoice.chat.accessHash), + msgId: invoice.messageId, + }); + } +} diff --git a/src/api/gramjs/helpers.ts b/src/api/gramjs/helpers.ts index 1755e504b..75f41f6df 100644 --- a/src/api/gramjs/helpers.ts +++ b/src/api/gramjs/helpers.ts @@ -14,30 +14,31 @@ export function addMessageToLocalDb(message: GramJs.Message | GramJs.MessageServ const messageFullId = `${resolveMessageApiChatId(message)}-${message.id}`; localDb.messages[messageFullId] = message; - if ( - message instanceof GramJs.Message - && message.media instanceof GramJs.MessageMediaDocument - && message.media.document instanceof GramJs.Document - ) { - localDb.documents[String(message.media.document.id)] = message.media.document; - } - - if ( - message instanceof GramJs.Message - && message.media instanceof GramJs.MessageMediaWebPage - && message.media.webpage instanceof GramJs.WebPage - && message.media.webpage.document instanceof GramJs.Document - ) { - localDb.documents[String(message.media.webpage.document.id)] = message.media.webpage.document; - } - - if (message instanceof GramJs.Message - && message.media instanceof GramJs.MessageMediaGame - ) { - if (message.media.game.document instanceof GramJs.Document) { - localDb.documents[String(message.media.game.document.id)] = message.media.game.document; + if (message instanceof GramJs.Message) { + if (message.media instanceof GramJs.MessageMediaDocument + && message.media.document instanceof GramJs.Document + ) { + localDb.documents[String(message.media.document.id)] = message.media.document; + } + + if (message.media instanceof GramJs.MessageMediaWebPage + && message.media.webpage instanceof GramJs.WebPage + && message.media.webpage.document instanceof GramJs.Document + ) { + localDb.documents[String(message.media.webpage.document.id)] = message.media.webpage.document; + } + + if (message.media instanceof GramJs.MessageMediaGame) { + if (message.media.game.document instanceof GramJs.Document) { + localDb.documents[String(message.media.game.document.id)] = message.media.game.document; + } + addPhotoToLocalDb(message.media.game.photo); + } + + if (message.media instanceof GramJs.MessageMediaInvoice + && message.media.photo) { + localDb.webDocuments[String(message.media.photo.url)] = message.media.photo; } - addPhotoToLocalDb(message.media.game.photo); } if (message instanceof GramJs.MessageService && 'photo' in message.action) { diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index 11adfcbd6..76e15b258 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -157,6 +157,7 @@ export async function requestWebView({ startParam, replyToMessageId, theme, + sendAs, isFromBotMenu, }: { isSilent?: boolean; @@ -166,6 +167,7 @@ export async function requestWebView({ startParam?: string; replyToMessageId?: number; theme?: ApiThemeParameters; + sendAs?: ApiUser | ApiChat; isFromBotMenu?: boolean; }) { const result = await invokeRequest(new GramJs.messages.RequestWebView({ @@ -177,6 +179,7 @@ export async function requestWebView({ startParam, themeParams: theme ? buildInputThemeParams(theme) : undefined, fromBotMenu: isFromBotMenu || undefined, + ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), })); if (result instanceof GramJs.WebViewResultUrl) { @@ -211,12 +214,14 @@ export function prolongWebView({ bot, queryId, replyToMessageId, + sendAs, }: { isSilent?: boolean; peer: ApiChat | ApiUser; bot: ApiUser; queryId: string; replyToMessageId?: number; + sendAs?: ApiUser | ApiChat; }) { return invokeRequest(new GramJs.messages.ProlongWebView({ silent: isSilent || undefined, @@ -224,6 +229,7 @@ export function prolongWebView({ bot: buildInputPeer(bot.id, bot.accessHash), queryId: BigInt(queryId), replyToMsgId: replyToMessageId, + ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), })); } diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 90cf7dd2f..7baf0bb31 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -16,7 +16,7 @@ import type { } from '../../types'; import { - DEBUG, ARCHIVED_FOLDER_ID, MEMBERS_LOAD_SLICE, SERVICE_NOTIFICATIONS_USER_ID, + DEBUG, ARCHIVED_FOLDER_ID, MEMBERS_LOAD_SLICE, SERVICE_NOTIFICATIONS_USER_ID, ALL_FOLDER_ID, } from '../../../config'; import { invokeRequest, uploadFile } from './client'; import { @@ -45,6 +45,7 @@ import { addEntitiesWithPhotosToLocalDb, addMessageToLocalDb, addPhotoToLocalDb import { buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; import { buildApiPhoto } from '../apiBuilders/common'; import { buildStickerSet } from '../apiBuilders/symbols'; +import localDb from '../localDb'; type FullChatData = { fullInfo: ApiChatFullInfo; @@ -365,8 +366,13 @@ async function getFullChatInfo(chatId: string): Promise isAdmin || isOwner) : undefined; const botCommands = botInfo ? buildApiChatBotCommands(botInfo) : undefined; @@ -374,12 +380,14 @@ async function getFullChatInfo(chatId: string): Promise l instanceof GramJs.DialogFilterDefault); + const dialogFilters = result.filter((df): df is GramJs.DialogFilter => df instanceof GramJs.DialogFilter); + const orderedIds = dialogFilters.map(({ id }) => id); + if (defaultFolderPosition !== -1) { + orderedIds.splice(defaultFolderPosition, 0, ALL_FOLDER_ID); + } return { - byId: buildCollectionByKey(result.map(buildApiChatFolder), 'id') as Record, - orderedIds: result.map(({ id }) => id), + byId: buildCollectionByKey( + dialogFilters + .map(buildApiChatFolder), 'id', + ) as Record, + orderedIds, }; } @@ -760,7 +783,7 @@ export async function fetchRecommendedChatFolders() { return undefined; } - return results.map(buildApiChatFolderFromSuggested); + return results.map(buildApiChatFolderFromSuggested).filter(Boolean); } export async function editChatFolder({ @@ -1038,6 +1061,8 @@ export function setDiscussionGroup({ export async function migrateChat(chat: ApiChat) { const result = await invokeRequest( new GramJs.messages.MigrateChat({ chatId: buildInputEntity(chat.id) as BigInt.BigInteger }), + undefined, + true, ); // `migrateChat` can return a lot of different update types according to docs, @@ -1156,6 +1181,20 @@ export function deleteChatMember(chat: ApiChat, user: ApiUser) { } } +export function toggleJoinToSend(chat: ApiChat, isEnabled: boolean) { + return invokeRequest(new GramJs.channels.ToggleJoinToSend({ + channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel, + enabled: isEnabled, + }), true); +} + +export function toggleJoinRequest(chat: ApiChat, isEnabled: boolean) { + return invokeRequest(new GramJs.channels.ToggleJoinRequest({ + channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel, + enabled: isEnabled, + }), true); +} + function preparePeers( result: GramJs.messages.Dialogs | GramJs.messages.DialogsSlice | GramJs.messages.PeerDialogs, ) { diff --git a/src/api/gramjs/methods/client.ts b/src/api/gramjs/methods/client.ts index 1995e6571..35728f906 100644 --- a/src/api/gramjs/methods/client.ts +++ b/src/api/gramjs/methods/client.ts @@ -312,6 +312,9 @@ export async function fetchCurrentUser() { const user = userFull.users[0]; + if (user.photo instanceof GramJs.Photo) { + localDb.photos[user.photo.id.toString()] = user.photo; + } localDb.users[buildApiPeerId(user.id, 'user')] = user; const currentUser = buildApiUserFromFull(userFull); diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index a2802b919..302ada0f3 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -19,7 +19,7 @@ export { getChatByUsername, togglePreHistoryHidden, updateChatDefaultBannedRights, updateChatMemberBannedRights, updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup, migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember, toggleIsProtected, - getChatByPhoneNumber, + getChatByPhoneNumber, toggleJoinToSend, toggleJoinRequest, } from './chats'; export { @@ -28,7 +28,8 @@ export { fetchWebPagePreview, editMessage, forwardMessages, loadPollOptionResults, sendPollVote, findFirstMessageIdAfterDate, fetchPinnedMessages, fetchScheduledHistory, sendScheduledMessages, rescheduleMessage, deleteScheduledMessages, reportMessages, sendMessageAction, fetchSeenBy, fetchSponsoredMessages, viewSponsoredMessage, fetchSendAs, - saveDefaultSendAs, fetchUnreadReactions, readAllReactions, fetchUnreadMentions, readAllMentions, closePoll, + saveDefaultSendAs, fetchUnreadReactions, readAllReactions, fetchUnreadMentions, readAllMentions, transcribeAudio, + closePoll, } from './messages'; export { @@ -58,6 +59,7 @@ export { fetchNotificationExceptions, fetchNotificationSettings, updateContactSignUpNotification, updateNotificationSettings, fetchLanguages, fetchLangPack, fetchPrivacySettings, setPrivacySettings, registerDevice, unregisterDevice, updateIsOnline, fetchContentSettings, updateContentSettings, fetchLangStrings, fetchCountryList, fetchAppConfig, + fetchGlobalPrivacySettings, updateGlobalPrivacySettings, } from './settings'; export { @@ -71,7 +73,7 @@ export { } from './bots'; export { - validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt, + validateRequestedInfo, sendPaymentForm, getPaymentForm, getReceipt, fetchPremiumPromo, } from './payments'; export { diff --git a/src/api/gramjs/methods/management.ts b/src/api/gramjs/methods/management.ts index d5d230f48..6907c7cb0 100644 --- a/src/api/gramjs/methods/management.ts +++ b/src/api/gramjs/methods/management.ts @@ -49,9 +49,8 @@ export async function updatePrivateLink({ expireDate, })); - if (!result) { - return undefined; - } + // TODO Verify Exported Invite logic + if (!(result instanceof GramJs.ChatInviteExported)) return undefined; onUpdate({ '@type': 'updateChatFullInfo', @@ -76,7 +75,10 @@ export async function fetchExportedChatInvites({ if (!exportedInvites) return undefined; addEntitiesWithPhotosToLocalDb(exportedInvites.users); - return exportedInvites.invites.map(buildApiExportedInvite); + // TODO Verify Exported Invite logic + return (exportedInvites.invites + .filter((l) => l instanceof GramJs.ChatInviteExported) as GramJs.ChatInviteExported[]) + .map(buildApiExportedInvite); } export async function editExportedChatInvite({ @@ -90,6 +92,7 @@ export async function editExportedChatInvite({ isRequestNeeded?: boolean; title?: string; }) { + // TODO Verify Exported Invite logic const invite = await invokeRequest(new GramJs.messages.EditExportedChatInvite({ link, peer: buildInputPeer(peer.id, peer.accessHash), @@ -103,7 +106,7 @@ export async function editExportedChatInvite({ if (!invite) return undefined; addEntitiesWithPhotosToLocalDb(invite.users); - if (invite instanceof GramJs.messages.ExportedChatInvite) { + if (invite instanceof GramJs.messages.ExportedChatInvite && invite.invite instanceof GramJs.ChatInviteExported) { const replaceInvite = buildApiExportedInvite(invite.invite); return { oldInvite: replaceInvite, @@ -111,7 +114,9 @@ export async function editExportedChatInvite({ }; } - if (invite instanceof GramJs.messages.ExportedChatInviteReplaced) { + if (invite instanceof GramJs.messages.ExportedChatInviteReplaced + && invite.invite instanceof GramJs.ChatInviteExported + && invite.newInvite instanceof GramJs.ChatInviteExported) { const oldInvite = buildApiExportedInvite(invite.invite); const newInvite = buildApiExportedInvite(invite.newInvite); return { @@ -139,7 +144,8 @@ export async function exportChatInvite({ title, })); - if (!invite) return undefined; + // TODO Verify Exported Invite logic + if (!(invite instanceof GramJs.ChatInviteExported)) return undefined; return buildApiExportedInvite(invite); } diff --git a/src/api/gramjs/methods/media.ts b/src/api/gramjs/methods/media.ts index cec15d0b8..5db806a28 100644 --- a/src/api/gramjs/methods/media.ts +++ b/src/api/gramjs/methods/media.ts @@ -16,7 +16,9 @@ import localDb from '../localDb'; import * as cacheApi from '../../../util/cacheApi'; import { getEntityTypeById } from '../gramjsBuilders'; -const MEDIA_ENTITY_TYPES = new Set(['msg', 'sticker', 'gif', 'wallpaper', 'photo', 'webDocument', 'document']); +const MEDIA_ENTITY_TYPES = new Set([ + 'msg', 'sticker', 'gif', 'wallpaper', 'photo', 'webDocument', 'document', 'videoAvatar', +]); export default async function downloadMedia( { @@ -31,6 +33,7 @@ export default async function downloadMedia( const { data, mimeType, fullSize, } = await download(url, client, isConnected, onProgress, start, end, mediaFormat, isHtmlAllowed) || {}; + if (!data) { return undefined; } @@ -62,7 +65,7 @@ export default async function downloadMedia( export type EntityType = ( 'msg' | 'sticker' | 'wallpaper' | 'gif' | 'channel' | 'chat' | 'user' | 'photo' | 'stickerSet' | 'webDocument' | - 'document' | 'staticMap' + 'document' | 'staticMap' | 'videoAvatar' ); async function download( @@ -127,6 +130,7 @@ async function download( case 'wallpaper': entity = localDb.documents[entityId]; break; + case 'videoAvatar': case 'photo': entity = localDb.photos[entityId]; break; @@ -159,22 +163,27 @@ async function download( 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; + 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; + fullSize = entity.media.webpage.document.size.toJSNumber(); } } else if (entity instanceof GramJs.Photo) { - mimeType = 'image/jpeg'; + if (entityType === 'videoAvatar') { + mimeType = 'video/mp4'; + } else { + mimeType = 'image/jpeg'; + } } else if (entityType === 'sticker' && sizeType) { mimeType = 'image/webp'; } else if (entityType === 'webDocument') { mimeType = (entity as GramJs.TypeWebDocument).mimeType; + fullSize = (entity as GramJs.TypeWebDocument).size; } else { mimeType = (entity as GramJs.Document).mimeType; - fullSize = (entity as GramJs.Document).size; + fullSize = (entity as GramJs.Document).size.toJSNumber(); } // Prevent HTML-in-video attacks @@ -296,7 +305,8 @@ export function parseMediaUrl(url: string) { : url.startsWith('webDocument') ? url.match(/(webDocument):(.+)/) : url.match( - /(avatar|profile|photo|msg|stickerSet|sticker|wallpaper|gif|document)([-\d\w./]+)(?::\d+)?(\?size=\w+)?/, + // eslint-disable-next-line max-len + /(avatar|profile|photo|msg|stickerSet|sticker|wallpaper|gif|document|videoAvatar)([-\d\w./]+)(?::\d+)?(\?size=\w+)?/, ); if (!mediaMatch) { return undefined; diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 4e22d3f4a..1f8062725 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -1431,3 +1431,25 @@ export async function fetchUnreadReactions({ chats, }; } + +export async function transcribeAudio({ + chat, messageId, +}: { + chat: ApiChat; messageId: number; +}) { + const result = await invokeRequest(new GramJs.messages.TranscribeAudio({ + msgId: messageId, + peer: buildInputPeer(chat.id, chat.accessHash), + })); + + if (!result) return undefined; + + onUpdate({ + '@type': 'updateTranscribedAudio', + isPending: result.pending, + transcriptionId: result.transcriptionId.toString(), + text: result.text, + }); + + return result.transcriptionId.toString(); +} diff --git a/src/api/gramjs/methods/payments.ts b/src/api/gramjs/methods/payments.ts index 936fef336..65838cf56 100644 --- a/src/api/gramjs/methods/payments.ts +++ b/src/api/gramjs/methods/payments.ts @@ -1,9 +1,16 @@ import BigInt from 'big-integer'; import { Api as GramJs } from '../../../lib/gramjs'; import { invokeRequest } from './client'; -import { buildInputPeer, buildShippingInfo } from '../gramjsBuilders'; -import { buildShippingOptions, buildPaymentForm, buildReceipt } from '../apiBuilders/payments'; -import type { ApiChat, OnApiUpdate } from '../../types'; +import { buildInputInvoice, buildInputPeer, buildShippingInfo } from '../gramjsBuilders'; +import { + buildShippingOptions, buildPaymentForm, buildReceipt, buildApiPremiumPromo, buildApiInvoiceFromForm, +} from '../apiBuilders/payments'; +import type { + ApiChat, OnApiUpdate, ApiRequestInputInvoice, +} from '../../types'; +import localDb from '../localDb'; +import { addEntitiesWithPhotosToLocalDb } from '../helpers'; +import { buildApiUser } from '../apiBuilders/users'; let onUpdate: OnApiUpdate; @@ -12,13 +19,11 @@ export function init(_onUpdate: OnApiUpdate) { } export async function validateRequestedInfo({ - chat, - messageId, + inputInvoice, requestInfo, shouldSave, }: { - chat: ApiChat; - messageId: number; + inputInvoice: ApiRequestInputInvoice; requestInfo: GramJs.TypePaymentRequestedInfo; shouldSave?: boolean; }): Promise<{ @@ -26,8 +31,7 @@ export async function validateRequestedInfo({ shippingOptions: any; } | undefined> { const result = await invokeRequest(new GramJs.payments.ValidateRequestedInfo({ - peer: buildInputPeer(chat.id, chat.accessHash), - msgId: messageId, + invoice: buildInputInvoice(inputInvoice), save: shouldSave || undefined, info: buildShippingInfo(requestInfo), })); @@ -47,15 +51,13 @@ export async function validateRequestedInfo({ } export async function sendPaymentForm({ - chat, - messageId, + inputInvoice, formId, requestedInfoId, shippingOptionId, credentials, }: { - chat: ApiChat; - messageId: number; + inputInvoice: ApiRequestInputInvoice; formId: string; credentials: any; requestedInfoId?: string; @@ -63,8 +65,7 @@ export async function sendPaymentForm({ }) { const result = await invokeRequest(new GramJs.payments.SendPaymentForm({ formId: BigInt(formId), - peer: buildInputPeer(chat.id, chat.accessHash), - msgId: messageId, + invoice: buildInputInvoice(inputInvoice), requestedInfoId, shippingOptionId, credentials: new GramJs.InputPaymentCredentials({ @@ -85,22 +86,23 @@ export async function sendPaymentForm({ return Boolean(result); } -export async function getPaymentForm({ - chat, messageId, -}: { - chat: ApiChat; - messageId: number; -}) { +export async function getPaymentForm(inputInvoice: ApiRequestInputInvoice) { const result = await invokeRequest(new GramJs.payments.GetPaymentForm({ - peer: buildInputPeer(chat.id, chat.accessHash), - msgId: messageId, + invoice: buildInputInvoice(inputInvoice), })); if (!result) { return undefined; } - return buildPaymentForm(result); + if (result.photo) { + localDb.webDocuments[result.photo.url] = result.photo; + } + + return { + form: buildPaymentForm(result), + invoice: buildApiInvoiceFromForm(result), + }; } export async function getReceipt(chat: ApiChat, msgId: number) { @@ -114,3 +116,22 @@ export async function getReceipt(chat: ApiChat, msgId: number) { return buildReceipt(result); } + +export async function fetchPremiumPromo() { + const result = await invokeRequest(new GramJs.help.GetPremiumPromo()); + if (!result) return undefined; + + addEntitiesWithPhotosToLocalDb(result.users); + + const users = result.users.map(buildApiUser).filter(Boolean); + result.videos.forEach((video) => { + if (video instanceof GramJs.Document) { + localDb.documents[video.id.toString()] = video; + } + }); + + return { + promo: buildApiPremiumPromo(result), + users, + }; +} diff --git a/src/api/gramjs/methods/settings.ts b/src/api/gramjs/methods/settings.ts index 38e1cb1a5..4eb73f604 100644 --- a/src/api/gramjs/methods/settings.ts +++ b/src/api/gramjs/methods/settings.ts @@ -492,3 +492,33 @@ export async function fetchCountryList({ langCode = 'en' }: { langCode?: LangCod } return buildApiCountryList(countryList.countries); } + +export async function fetchGlobalPrivacySettings() { + const result = await invokeRequest(new GramJs.account.GetGlobalPrivacySettings()); + + if (!result) { + return undefined; + } + + return { + shouldArchiveAndMuteNewNonContact: Boolean(result.archiveAndMuteNewNoncontactPeers), + }; +} + +export async function updateGlobalPrivacySettings({ shouldArchiveAndMuteNewNonContact } : { + shouldArchiveAndMuteNewNonContact: boolean; +}) { + const result = await invokeRequest(new GramJs.account.SetGlobalPrivacySettings({ + settings: new GramJs.GlobalPrivacySettings({ + archiveAndMuteNewNoncontactPeers: shouldArchiveAndMuteNewNonContact, + }), + })); + + if (!result) { + return undefined; + } + + return { + shouldArchiveAndMuteNewNonContact: Boolean(result.archiveAndMuteNewNoncontactPeers), + }; +} diff --git a/src/api/gramjs/methods/symbols.ts b/src/api/gramjs/methods/symbols.ts index 6d1f736b3..d8d7fa04c 100644 --- a/src/api/gramjs/methods/symbols.ts +++ b/src/api/gramjs/methods/symbols.ts @@ -70,6 +70,7 @@ export async function fetchFeaturedStickers({ hash = '0' }: { hash?: string }) { return { hash: String(result.hash), + isPremium: Boolean(result.premium), sets: result.sets.map(buildStickerSetCovered), }; } diff --git a/src/api/gramjs/methods/users.ts b/src/api/gramjs/methods/users.ts index 647063b32..76f12156c 100644 --- a/src/api/gramjs/methods/users.ts +++ b/src/api/gramjs/methods/users.ts @@ -19,6 +19,7 @@ import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { buildApiPhoto } from '../apiBuilders/common'; import { addEntitiesWithPhotosToLocalDb, addPhotoToLocalDb, addUserToLocalDb } from '../helpers'; import { buildApiPeerId } from '../apiBuilders/peers'; +import localDb from '../localDb'; let onUpdate: OnApiUpdate; @@ -44,6 +45,18 @@ export async function fetchFullUser({ return; } + if (fullInfo.fullUser.profilePhoto instanceof GramJs.Photo) { + localDb.photos[fullInfo.fullUser.profilePhoto.id.toString()] = fullInfo.fullUser.profilePhoto; + } + + const botInfo = fullInfo.fullUser.botInfo; + if (botInfo?.descriptionPhoto instanceof GramJs.Photo) { + localDb.photos[botInfo.descriptionPhoto.id.toString()] = botInfo.descriptionPhoto; + } + if (botInfo?.descriptionDocument instanceof GramJs.Document) { + localDb.documents[botInfo.descriptionDocument.id.toString()] = botInfo.descriptionDocument; + } + const userWithFullInfo = buildApiUserFromFull(fullInfo); onUpdate({ diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index 60475114e..e1ae999f1 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -194,6 +194,7 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { if (action instanceof GramJs.MessageActionPaymentSent) { onUpdate({ '@type': 'updatePaymentStateCompleted', + slug: action.invoiceSlug, }); } else if (action instanceof GramJs.MessageActionChatEditTitle) { onUpdate({ @@ -559,7 +560,7 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { }); } else if (update instanceof GramJs.UpdateDialogFilter) { const { id, filter } = update; - const folder = filter ? buildApiChatFolder(filter) : undefined; + const folder = filter instanceof GramJs.DialogFilter ? buildApiChatFolder(filter) : undefined; onUpdate({ '@type': 'updateChatFolder', @@ -941,7 +942,10 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { queryId: queryId.toString(), }); } else if (update instanceof GramJs.UpdateBotMenuButton) { - const { botId, button } = update; + const { + botId, + button, + } = update; const id = buildApiPeerId(botId, 'user'); @@ -950,6 +954,27 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { botId: id, button: buildApiBotMenuButton(button), }); + } else if (update instanceof GramJs.UpdateTranscribedAudio) { + // eslint-disable-next-line no-underscore-dangle + const entities = update._entities; + if (entities) { + addEntitiesWithPhotosToLocalDb(entities); + dispatchUserAndChatUpdates(entities); + } + + onUpdate({ + '@type': 'updateTranscribedAudio', + transcriptionId: update.transcriptionId.toString(), + text: update.text, + isPending: update.pending, + }); + } else if (update instanceof GramJs.UpdateConfig) { + // eslint-disable-next-line no-underscore-dangle + const entities = update._entities; + if (entities) { + addEntitiesWithPhotosToLocalDb(entities); + dispatchUserAndChatUpdates(entities); + } } else if (DEBUG) { const params = typeof update === 'object' && 'className' in update ? update.className : update; // eslint-disable-next-line no-console diff --git a/src/api/types/bots.ts b/src/api/types/bots.ts index 13a54c40b..364188e2d 100644 --- a/src/api/types/bots.ts +++ b/src/api/types/bots.ts @@ -1,4 +1,5 @@ import type { + ApiDimensions, ApiPhoto, ApiSticker, ApiThumbnail, ApiVideo, } from './messages'; @@ -9,7 +10,10 @@ export type ApiInlineResultType = ( export interface ApiWebDocument { url: string; + size: number; mimeType: string; + accessHash?: string; + dimensions?: ApiDimensions; } export interface ApiBotInlineResult { @@ -60,6 +64,8 @@ export type ApiBotMenuButton = ApiBotMenuButtonWebApp | ApiBotMenuButtonCommands export interface ApiBotInfo { botId: string; commands?: ApiBotCommand[]; - description: string; + description?: string; + photo?: ApiPhoto; + gif?: ApiVideo; menuButton: ApiBotMenuButton; } diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 4443488a8..2fa707ff2 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -27,6 +27,7 @@ export interface ApiChat { hasPrivateLink?: boolean; accessHash?: string; isMin?: boolean; + hasVideoAvatar?: boolean; avatarHash?: string; username?: string; membersCount?: number; @@ -65,6 +66,8 @@ export interface ApiChat { typingStatus?: ApiTypingStatus; joinRequests?: ApiChatInviteImporter[]; + isJoinToSend?: boolean; + isJoinRequest?: boolean; sendAsIds?: string[]; unreadReactions?: number[]; @@ -105,6 +108,7 @@ export interface ApiChatFullInfo { requestsPending?: number; statisticsDcId?: number; stickerSet?: ApiStickerSet; + profilePhoto?: ApiPhoto; } export interface ApiChatMember { diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index fd8daac6a..7c189213a 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -1,4 +1,6 @@ +import type { ApiWebDocument } from './bots'; import type { ApiGroupCall, PhoneCallAction } from './calls'; +import type { ApiChat } from './chats'; export interface ApiDimensions { width: number; @@ -9,6 +11,12 @@ export interface ApiPhotoSize extends ApiDimensions { type: 's' | 'm' | 'x' | 'y' | 'z'; } +export interface ApiVideoSize extends ApiDimensions { + type: 'u' | 'v'; + videoStartTs: number; + size: number; +} + export interface ApiThumbnail extends ApiDimensions { dataUri: string; } @@ -16,7 +24,9 @@ export interface ApiThumbnail extends ApiDimensions { export interface ApiPhoto { id: string; thumbnail?: ApiThumbnail; + isVideo?: boolean; sizes: ApiPhotoSize[]; + videoSizes?: ApiVideoSize[]; blobUrl?: string; } @@ -31,6 +41,7 @@ export interface ApiSticker { height?: number; thumbnail?: ApiThumbnail; isPreloadedGlobally?: boolean; + hasEffect?: boolean; } export interface ApiStickerSet { @@ -134,16 +145,31 @@ export interface ApiPoll { }; } +// First type used for state, second - for API requests +export type ApiInputInvoice = { + chatId: string; + messageId: number; +} | { + slug: string; +}; + +export type ApiRequestInputInvoice = { + chat: ApiChat; + messageId: number; +} | { + slug: string; +}; + export interface ApiInvoice { text: string; title: string; - photoUrl?: string; - photoWidth?: number; - photoHeight?: number; + photo?: ApiWebDocument; amount: number; currency: string; receiptMsgId?: number; isTest?: boolean; + isRecurring?: boolean; + recurringTermsUrl?: string; } interface ApiGeoPoint { @@ -321,6 +347,8 @@ export interface ApiMessage { isFromScheduled?: boolean; seenByUserIds?: string[]; isProtected?: boolean; + transcriptionId?: string; + isTranscriptionError?: boolean; reactors?: { nextOffset?: string; count: number; @@ -350,12 +378,15 @@ export interface ApiReactionCount { export interface ApiAvailableReaction { selectAnimation?: ApiDocument; + activateAnimation?: ApiDocument; + effectAnimation?: ApiDocument; staticIcon?: ApiDocument; centerIcon?: ApiDocument; aroundAnimation?: ApiDocument; reaction: string; title: string; isInactive?: boolean; + isPremium?: boolean; } export interface ApiThreadInfo { @@ -374,6 +405,7 @@ export type ApiMessageOutgoingStatus = 'read' | 'succeeded' | 'pending' | 'faile export type ApiSponsoredMessage = { chatId?: string; randomId: string; + isRecommended?: boolean; isBot?: boolean; channelPostId?: number; startParam?: string; @@ -446,6 +478,12 @@ interface ApiKeyboardButtonUrlAuth { buttonId: number; } +export type ApiTranscription = { + text: string; + isPending?: boolean; + transcriptionId: string; +}; + export type ApiKeyboardButton = ( ApiKeyboardButtonSimple | ApiKeyboardButtonReceipt @@ -484,6 +522,7 @@ export type ApiThemeParameters = { link_color: string; button_color: string; button_text_color: string; + secondary_bg_color: string; }; export const MAIN_THREAD_ID = -1; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 345f7d4c4..48f8fc537 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -1,5 +1,6 @@ import type { ApiDocument, ApiPhoto } from './messages'; import type { ApiUser } from './users'; +import type { ApiLimitType } from '../../global/types'; export interface ApiInitialArgs { userAgent: string; @@ -93,7 +94,11 @@ export type ApiNotifyException = { export type ApiNotification = { localId: string; + title?: string; message: string; + actionText?: string; + action: VoidFunction; + className?: string; }; export type ApiError = { @@ -161,6 +166,10 @@ export interface ApiAppConfig { autologinDomains: string[]; autologinToken: string; urlAuthDomains: string[]; + premiumInvoiceSlug: string; + premiumBotUsername: string; + isPremiumPurchaseBlocked: boolean; + limits: Record; } export interface GramJsEmojiInteraction { diff --git a/src/api/types/payments.ts b/src/api/types/payments.ts index c3fe6cc1e..a160dde9a 100644 --- a/src/api/types/payments.ts +++ b/src/api/types/payments.ts @@ -1,3 +1,5 @@ +import type { ApiDocument, ApiMessageEntity } from './messages'; + export interface ApiShippingAddress { streetLine1: string; streetLine2: string; @@ -61,3 +63,12 @@ export interface ApiReceipt { shippingPrices?: ApiLabeledPrice[]; shippingMethod?: string; } + +export interface ApiPremiumPromo { + currency: string; + monthlyAmount: string; + videoSections: string[]; + videos: ApiDocument[]; + statusText: string; + statusEntities: ApiMessageEntity[]; +} diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index dc43c88a7..5a5d10f5e 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -412,6 +412,7 @@ export type ApiUpdatePaymentVerificationNeeded = { export type ApiUpdatePaymentStateCompleted = { '@type': 'updatePaymentStateCompleted'; + slug?: string; }; export type ApiUpdatePrivacy = { @@ -516,6 +517,13 @@ export type ApiUpdateBotMenuButton = { button: ApiBotMenuButton; }; +export type ApiUpdateTranscribedAudio = { + '@type': 'updateTranscribedAudio'; + transcriptionId: string; + text: string; + isPending?: boolean; +}; + export type ApiUpdate = ( ApiUpdateReady | ApiUpdateSession | ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser | @@ -539,7 +547,7 @@ export type ApiUpdate = ( ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId | ApiUpdatePendingJoinRequests | ApiUpdatePaymentVerificationNeeded | ApiUpdatePaymentStateCompleted | ApiUpdatePhoneCall | ApiUpdatePhoneCallSignalingData | ApiUpdatePhoneCallMediaState | - ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton + ApiUpdatePhoneCallConnectionState | ApiUpdateBotMenuButton | ApiUpdateTranscribedAudio ); export type OnApiUpdate = (update: ApiUpdate) => void; diff --git a/src/api/types/users.ts b/src/api/types/users.ts index 2e36fdcb6..7ea24dcd8 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -6,6 +6,7 @@ export interface ApiUser { isMin: boolean; isSelf?: true; isVerified?: true; + isPremium?: boolean; isContact?: true; type: ApiUserType; firstName?: string; @@ -14,6 +15,7 @@ export interface ApiUser { username: string; phoneNumber: string; accessHash?: string; + hasVideoAvatar?: boolean; avatarHash?: string; photos?: ApiPhoto[]; botPlaceholder?: string; @@ -36,6 +38,7 @@ export interface ApiUserFullInfo { commonChatsCount?: number; pinnedMessageId?: number; botInfo?: ApiBotInfo; + profilePhoto?: ApiPhoto; } export type ApiFakeType = 'fake' | 'scam'; @@ -51,9 +54,13 @@ export interface ApiUserStatus { expires?: number; } +export type ApiAttachMenuPeerType = 'self' | 'bot' | 'private' | 'chat' | 'channel'; + export interface ApiAttachMenuBot { id: string; + hasSettings?: boolean; shortName: string; + peerTypes: ApiAttachMenuPeerType[]; icons: ApiAttachMenuBotIcon[]; } diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index ff124f775..5ad06c7c3 100644 Binary files a/src/assets/fonts/icomoon.woff and b/src/assets/fonts/icomoon.woff differ diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2 index 7b711129f..62d7a4a6e 100644 Binary files a/src/assets/fonts/icomoon.woff2 and b/src/assets/fonts/icomoon.woff2 differ diff --git a/src/assets/premium/DeviceFrame.svg b/src/assets/premium/DeviceFrame.svg new file mode 100644 index 000000000..f2de1789d --- /dev/null +++ b/src/assets/premium/DeviceFrame.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/premium/PremiumAds.svg b/src/assets/premium/PremiumAds.svg new file mode 100644 index 000000000..490a62c8c --- /dev/null +++ b/src/assets/premium/PremiumAds.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/premium/PremiumBadge.svg b/src/assets/premium/PremiumBadge.svg new file mode 100644 index 000000000..9284aaf79 --- /dev/null +++ b/src/assets/premium/PremiumBadge.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/premium/PremiumChats.svg b/src/assets/premium/PremiumChats.svg new file mode 100644 index 000000000..d9bfe816b --- /dev/null +++ b/src/assets/premium/PremiumChats.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/premium/PremiumFile.svg b/src/assets/premium/PremiumFile.svg new file mode 100644 index 000000000..c7da3403b --- /dev/null +++ b/src/assets/premium/PremiumFile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/premium/PremiumLimits.svg b/src/assets/premium/PremiumLimits.svg new file mode 100644 index 000000000..e2e0da61b --- /dev/null +++ b/src/assets/premium/PremiumLimits.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/premium/PremiumLogo.svg b/src/assets/premium/PremiumLogo.svg new file mode 100644 index 000000000..ebefe6c80 --- /dev/null +++ b/src/assets/premium/PremiumLogo.svg @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/premium/PremiumReactions.svg b/src/assets/premium/PremiumReactions.svg new file mode 100644 index 000000000..48d307a30 --- /dev/null +++ b/src/assets/premium/PremiumReactions.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/premium/PremiumSpeed.svg b/src/assets/premium/PremiumSpeed.svg new file mode 100644 index 000000000..3b7ae10d7 --- /dev/null +++ b/src/assets/premium/PremiumSpeed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/premium/PremiumStickers.svg b/src/assets/premium/PremiumStickers.svg new file mode 100644 index 000000000..c9d4a8f62 --- /dev/null +++ b/src/assets/premium/PremiumStickers.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/premium/PremiumVideo.svg b/src/assets/premium/PremiumVideo.svg new file mode 100644 index 000000000..7f11bd99b --- /dev/null +++ b/src/assets/premium/PremiumVideo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/premium/PremiumVoice.svg b/src/assets/premium/PremiumVoice.svg new file mode 100644 index 000000000..1b95419c9 --- /dev/null +++ b/src/assets/premium/PremiumVoice.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/reaction-thumbs-premium.png b/src/assets/reaction-thumbs-premium.png new file mode 100644 index 000000000..0e6bf57ed Binary files /dev/null and b/src/assets/reaction-thumbs-premium.png differ diff --git a/src/assets/reaction-thumbs.png b/src/assets/reaction-thumbs.png index 19796b81a..7f12d288b 100644 Binary files a/src/assets/reaction-thumbs.png and b/src/assets/reaction-thumbs.png differ diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 0bbfa2cf8..f4c34825f 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -11,7 +11,11 @@ export { default as NewContactModal } from '../components/main/NewContactModal'; export { default as WebAppModal } from '../components/main/WebAppModal'; export { default as BotTrustModal } from '../components/main/BotTrustModal'; export { default as BotAttachModal } from '../components/main/BotAttachModal'; +export { default as DeleteFolderDialog } from '../components/main/DeleteFolderDialog'; +export { default as PremiumMainModal } from '../components/main/premium/PremiumMainModal'; +export { default as PremiumLimitReachedModal } from '../components/main/premium/common/PremiumLimitReachedModal'; +export { default as AboutAdsModal } from '../components/common/AboutAdsModal'; export { default as CalendarModal } from '../components/common/CalendarModal'; export { default as DeleteMessageModal } from '../components/common/DeleteMessageModal'; export { default as PinMessageModal } from '../components/common/PinMessageModal'; @@ -34,6 +38,8 @@ export { default as ArchivedChats } from '../components/left/ArchivedChats'; export { default as ChatFolderModal } from '../components/left/ChatFolderModal'; export { default as ContextMenuContainer } from '../components/middle/message/ContextMenuContainer'; +export { default as SponsoredMessageContextMenuContainer } + from '../components/middle/message/SponsoredMessageContextMenuContainer'; // eslint-disable-next-line import/no-cycle export { default as StickerSetModal } from '../components/common/StickerSetModal'; export { default as HeaderMenuContainer } from '../components/middle/HeaderMenuContainer'; diff --git a/src/components/calls/group/GroupCallParticipant.tsx b/src/components/calls/group/GroupCallParticipant.tsx index d1b10c89c..e5023ba71 100644 --- a/src/components/calls/group/GroupCallParticipant.tsx +++ b/src/components/calls/group/GroupCallParticipant.tsx @@ -79,7 +79,7 @@ const GroupCallParticipant: FC = ({ onClick={handleOnClick} ref={anchorRef} > - +
{name} {aboutText} diff --git a/src/components/calls/group/GroupCallParticipantVideo.tsx b/src/components/calls/group/GroupCallParticipantVideo.tsx index 8314c619e..9443518c5 100644 --- a/src/components/calls/group/GroupCallParticipantVideo.tsx +++ b/src/components/calls/group/GroupCallParticipantVideo.tsx @@ -60,7 +60,7 @@ const GroupCallParticipantVideo: FC = ({ {lang('Back')} )} - + {ENABLE_THUMBNAIL_VIDEO && (
); }; +function getSeeklineSpikeAmounts() { + return { + MIN_SPIKES: IS_SINGLE_COLUMN_LAYOUT ? (TINY_SCREEN_WIDTH_MQL.matches ? 16 : 20) : 25, + MAX_SPIKES: IS_SINGLE_COLUMN_LAYOUT ? (TINY_SCREEN_WIDTH_MQL.matches ? 35 : 48) : 75, + }; +} + function renderAudio( lang: LangFn, audio: ApiAudio, @@ -436,15 +472,42 @@ function renderVoice( waveformCanvasRef: React.Ref, playProgress: number, isMediaUnread?: boolean, + isTranscribing?: boolean, + isTranscriptionHidden?: boolean, + isTranscribed?: boolean, + isTranscriptionError?: boolean, + onClickTranscribe?: VoidFunction, + onHideTranscription?: (isHidden: boolean) => void, ) { return (
-
- +
+
+ +
+ {onClickTranscribe && ( + // eslint-disable-next-line react/jsx-no-bind + + )}

{playProgress === 0 ? formatMediaDuration(voice.duration) : formatMediaDuration(voice.duration * playProgress)} @@ -475,6 +538,7 @@ function useWaveformCanvas( }; } + const { MIN_SPIKES, MAX_SPIKES } = getSeeklineSpikeAmounts(); const durationFactor = Math.min(duration / AVG_VOICE_DURATION, 1); const spikesCount = Math.round(MIN_SPIKES + (MAX_SPIKES - MIN_SPIKES) * durationFactor); const decodedWaveform = decodeWaveform(new Uint8Array(waveform)); diff --git a/src/components/common/Avatar.scss b/src/components/common/Avatar.scss index 0e69ba473..ce2c97ea5 100644 --- a/src/components/common/Avatar.scss +++ b/src/components/common/Avatar.scss @@ -12,11 +12,13 @@ display: flex; white-space: nowrap; user-select: none; + position: relative; - &__img { + &__media { border-radius: 50%; width: 100%; height: 100%; + z-index: 1; } .emoji { @@ -105,8 +107,6 @@ } &.online { - position: relative; - &::after { content: ""; display: block; @@ -119,10 +119,18 @@ border: 2px solid var(--color-background); background-color: #0ac630; flex-shrink: 0; + z-index: 1; } } &.interactive { cursor: pointer; } + + .poster { + position: absolute; + left: 0; + top: 0; + z-index: 0; + } } diff --git a/src/components/common/Avatar.tsx b/src/components/common/Avatar.tsx index fecb2bb9d..1898a7cf1 100644 --- a/src/components/common/Avatar.tsx +++ b/src/components/common/Avatar.tsx @@ -1,10 +1,14 @@ import type { MouseEvent as ReactMouseEvent } from 'react'; -import type { FC, TeactNode } from '../../lib/teact/teact'; -import React, { memo, useCallback } from '../../lib/teact/teact'; +import React, { + memo, useCallback, useEffect, useRef, +} from '../../lib/teact/teact'; +import { getActions } from '../../global'; +import type { FC, TeactNode } from '../../lib/teact/teact'; import type { ApiChat, ApiPhoto, ApiUser, ApiUserStatus, } from '../../api/types'; +import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import { ApiMediaFormat } from '../../api/types'; import { IS_TEST } from '../../config'; @@ -21,14 +25,17 @@ import { import { getFirstLetters } from '../../util/textFormat'; import buildClassName, { createClassNameBuilder } from '../../util/buildClassName'; import renderText from './helpers/renderText'; + import useMedia from '../../hooks/useMedia'; import useShowTransition from '../../hooks/useShowTransition'; import useLang from '../../hooks/useLang'; +import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import './Avatar.scss'; +import useVideoAutoPause from '../middle/message/hooks/useVideoAutoPause'; const cn = createClassNameBuilder('Avatar'); -cn.img = cn('img'); +cn.media = cn('media'); cn.icon = cn('icon'); type OwnProps = { @@ -40,8 +47,11 @@ type OwnProps = { userStatus?: ApiUserStatus; text?: string; isSavedMessages?: boolean; + noVideo?: boolean; + noLoop?: boolean; lastSyncTime?: number; - onClick?: (e: ReactMouseEvent, hasPhoto: boolean) => void; + observeIntersection?: ObserveFn; + onClick?: (e: ReactMouseEvent, hasMedia: boolean) => void; }; const Avatar: FC = ({ @@ -53,15 +63,33 @@ const Avatar: FC = ({ userStatus, text, isSavedMessages, + noVideo, + noLoop, lastSyncTime, + observeIntersection, onClick, }) => { + const { loadFullUser } = getActions(); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + // eslint-disable-next-line no-null/no-null + const videoRef = useRef(null); + const isIntersecting = useIsIntersecting(ref, observeIntersection); const isDeleted = user && isDeletedUser(user); const isReplies = user && isChatWithRepliesBot(user.id); let imageHash: string | undefined; + let videoHash: string | undefined; + + const hasVideoAvatar = (user || chat)?.hasVideoAvatar; + const profilePhoto = (user?.fullInfo?.profilePhoto || chat?.fullInfo?.profilePhoto); + const shouldShowVideo = !noVideo && Boolean(user?.isPremium && profilePhoto?.isVideo); + const shouldPlayVideo = isIntersecting && shouldShowVideo; const shouldFetchBig = size === 'jumbo'; if (!isSavedMessages && !isDeleted) { + if (shouldShowVideo) { + videoHash = getChatAvatarHash(user!, undefined, 'video'); + } if (user) { imageHash = getChatAvatarHash(user, shouldFetchBig ? 'big' : undefined); } else if (chat) { @@ -71,8 +99,29 @@ const Avatar: FC = ({ } } - const blobUrl = useMedia(imageHash, false, ApiMediaFormat.BlobUrl, lastSyncTime); - const hasBlobUrl = Boolean(blobUrl); + useVideoAutoPause(videoRef, shouldPlayVideo); + + useEffect(() => { + const video = videoRef.current; + if (!video || !noLoop) return undefined; + + const returnToStart = () => { + video.currentTime = 0; + }; + + video.addEventListener('ended', returnToStart); + return () => video.removeEventListener('ended', returnToStart); + }, [noLoop]); + + useEffect(() => { + if (isIntersecting && !noVideo && user && hasVideoAvatar && !profilePhoto) { + loadFullUser({ userId: user.id }); + } + }, [hasVideoAvatar, profilePhoto, loadFullUser, user, noVideo, isIntersecting]); + + const imgBlobUrl = useMedia(imageHash, false, ApiMediaFormat.BlobUrl, lastSyncTime); + const videoBlobUrl = useMedia(videoHash, false, ApiMediaFormat.BlobUrl, lastSyncTime); + const hasBlobUrl = Boolean(imgBlobUrl || videoBlobUrl); const { transitionClassNames } = useShowTransition(hasBlobUrl, undefined, hasBlobUrl, 'slow'); const lang = useLang(); @@ -86,14 +135,27 @@ const Avatar: FC = ({ content = ; } else if (isReplies) { content = ; - } else if (blobUrl) { + } else if (hasBlobUrl) { content = ( - {author} + <> + {author} + {videoBlobUrl && ( +

= ({ narrow isStatic > - + {renderText(description, ['br', 'links', 'emoji'])} {lang(userId ? 'UserBio' : 'Info')} diff --git a/src/components/common/EmbeddedMessage.scss b/src/components/common/EmbeddedMessage.scss index 7b8201f00..9592d1f92 100644 --- a/src/components/common/EmbeddedMessage.scss +++ b/src/components/common/EmbeddedMessage.scss @@ -11,6 +11,12 @@ cursor: pointer; direction: ltr; + @for $i from 1 through 8 { + &.color-#{$i} { + --accent-color: var(--color-user-#{$i}); + } + } + body.animation-level-1 & { .ripple-container { display: none; @@ -100,7 +106,7 @@ } .embedded-action-message { - color: var(--accent-color); + color: var(---secondary-color); opacity: 0.75; } diff --git a/src/components/common/EmbeddedMessage.tsx b/src/components/common/EmbeddedMessage.tsx index 70e18e3b6..c27b134eb 100644 --- a/src/components/common/EmbeddedMessage.tsx +++ b/src/components/common/EmbeddedMessage.tsx @@ -8,6 +8,7 @@ import { isActionMessage, getSenderTitle, getMessageRoundVideo, + getUserColorKey, } from '../../global/helpers'; import renderText from './helpers/renderText'; import { getPictogramDimensions } from './helpers/mediaDimensions'; @@ -30,6 +31,7 @@ type OwnProps = { sender?: ApiUser | ApiChat; title?: string; customText?: string; + noUserColors?: boolean; isProtected?: boolean; onClick: NoneToVoidFunction; }; @@ -43,6 +45,7 @@ const EmbeddedMessage: FC = ({ title, customText, isProtected, + noUserColors, observeIntersection, onClick, }) => { @@ -61,7 +64,11 @@ const EmbeddedMessage: FC = ({ return (
{mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isRoundVideo, isProtected)} diff --git a/src/components/common/GroupChatInfo.tsx b/src/components/common/GroupChatInfo.tsx index 3e1fa557a..88f195ab0 100644 --- a/src/components/common/GroupChatInfo.tsx +++ b/src/components/common/GroupChatInfo.tsx @@ -34,6 +34,7 @@ type OwnProps = { withFullInfo?: boolean; withUpdatingStatus?: boolean; withChatType?: boolean; + withVideoAvatar?: boolean; noRtl?: boolean; }; @@ -55,6 +56,7 @@ const GroupChatInfo: FC = ({ withFullInfo, withUpdatingStatus, withChatType, + withVideoAvatar, noRtl, chat, onlineCount, @@ -75,8 +77,8 @@ const GroupChatInfo: FC = ({ } }, [chatId, isMin, lastSyncTime, withFullInfo, loadFullChat, isSuperGroup]); - const handleAvatarViewerOpen = useCallback((e: ReactMouseEvent, hasPhoto: boolean) => { - if (chat && hasPhoto) { + const handleAvatarViewerOpen = useCallback((e: ReactMouseEvent, hasMedia: boolean) => { + if (chat && hasMedia) { e.stopPropagation(); openMediaViewer({ avatarOwnerId: chat.id, @@ -140,6 +142,7 @@ const GroupChatInfo: FC = ({ size={avatarSize} chat={chat} onClick={withMediaViewer ? handleAvatarViewerOpen : undefined} + noVideo={!withVideoAvatar} />
diff --git a/src/components/common/PickerSelectedItem.tsx b/src/components/common/PickerSelectedItem.tsx index 1ec4cb5e0..afc1932b0 100644 --- a/src/components/common/PickerSelectedItem.tsx +++ b/src/components/common/PickerSelectedItem.tsx @@ -61,6 +61,7 @@ const PickerSelectedItem: FC = ({ user={user} size="small" isSavedMessages={user?.isSelf} + noVideo /> ); diff --git a/src/components/common/PremiumIcon.scss b/src/components/common/PremiumIcon.scss new file mode 100644 index 000000000..3a0ff4e87 --- /dev/null +++ b/src/components/common/PremiumIcon.scss @@ -0,0 +1,27 @@ +.PremiumIcon { + display: inline-block; + flex-shrink: 0; + width: 1rem; + height: 1rem; + + &.big { + width: 1.5rem; + height: 1.5rem; + } + + --color-fill: var(--color-primary); + + & > svg { + width: 100%; + height: 100%; + } + + &:not(.gradient) { + transform: translateY(-2px); + } + + &.clickable { + cursor: pointer; + pointer-events: auto; + } +} diff --git a/src/components/common/PremiumIcon.tsx b/src/components/common/PremiumIcon.tsx new file mode 100644 index 000000000..67396320d --- /dev/null +++ b/src/components/common/PremiumIcon.tsx @@ -0,0 +1,47 @@ +import type { FC } from '../../lib/teact/teact'; +import React, { memo, useMemo } from '../../lib/teact/teact'; + +import generateIdFor from '../../util/generateIdFor'; + +import buildClassName from '../../util/buildClassName'; + +import './PremiumIcon.scss'; + +// eslint-disable-next-line max-len +const PREMIUM_ICON = { __html: '' }; +const store: Record = {}; + +type OwnProps = { + withGradient?: boolean; + big?: boolean; + onClick?: VoidFunction; +}; + +const PremiumIcon: FC = ({ + withGradient, + big, + onClick, +}) => { + const html = useMemo(() => { + return withGradient ? getPremiumIconGradient() : PREMIUM_ICON; + }, [withGradient]); + + return ( + + ); +}; + +function getPremiumIconGradient() { + const id = generateIdFor(store); + return { + // eslint-disable-next-line max-len + __html: ``, + }; +} + +export default memo(PremiumIcon); diff --git a/src/components/common/PrivateChatInfo.tsx b/src/components/common/PrivateChatInfo.tsx index d03c6aa31..0ed37f0e9 100644 --- a/src/components/common/PrivateChatInfo.tsx +++ b/src/components/common/PrivateChatInfo.tsx @@ -17,6 +17,7 @@ import VerifiedIcon from './VerifiedIcon'; import TypingStatus from './TypingStatus'; import DotAnimation from './DotAnimation'; import FakeIcon from './FakeIcon'; +import PremiumIcon from './PremiumIcon'; type OwnProps = { userId: string; @@ -29,6 +30,7 @@ type OwnProps = { withUsername?: boolean; withFullInfo?: boolean; withUpdatingStatus?: boolean; + withVideoAvatar?: boolean; noStatusOrTyping?: boolean; noRtl?: boolean; }; @@ -52,6 +54,7 @@ const PrivateChatInfo: FC = ({ withUsername, withFullInfo, withUpdatingStatus, + withVideoAvatar, noStatusOrTyping, noRtl, user, @@ -75,8 +78,8 @@ const PrivateChatInfo: FC = ({ } }, [userId, loadFullUser, lastSyncTime, withFullInfo]); - const handleAvatarViewerOpen = useCallback((e: ReactMouseEvent, hasPhoto: boolean) => { - if (user && hasPhoto) { + const handleAvatarViewerOpen = useCallback((e: ReactMouseEvent, hasMedia: boolean) => { + if (user && hasMedia) { e.stopPropagation(); openMediaViewer({ avatarOwnerId: user.id, @@ -130,6 +133,7 @@ const PrivateChatInfo: FC = ({ user={user} isSavedMessages={isSavedMessages} onClick={withMediaViewer ? handleAvatarViewerOpen : undefined} + noVideo={!withVideoAvatar} />
{isSavedMessages ? ( @@ -140,6 +144,7 @@ const PrivateChatInfo: FC = ({

{fullName && renderText(fullName)}

{user?.isVerified && } + {user.isPremium && } {user.fakeType && }
)} diff --git a/src/components/common/ProfileInfo.scss b/src/components/common/ProfileInfo.scss index f42e8000b..cf6707891 100644 --- a/src/components/common/ProfileInfo.scss +++ b/src/components/common/ProfileInfo.scss @@ -122,18 +122,21 @@ display: flex; align-items: center; - h3 { + .fullName { font-weight: 500; font-size: 1.25rem; line-height: 1.375rem; white-space: pre-wrap; word-break: break-word; - margin-bottom: 0.25rem; + margin-bottom: 0; } - .VerifiedIcon { + .VerifiedIcon, .PremiumIcon { margin-left: 0.25rem; - margin-top: -0.125rem; + z-index: 2; + --color-fill: var(--color-white); + --color-checkmark: var(--color-primary); + opacity: 0.8; } .emoji { diff --git a/src/components/common/ProfileInfo.tsx b/src/components/common/ProfileInfo.tsx index e3cb3099a..13b0766ec 100644 --- a/src/components/common/ProfileInfo.tsx +++ b/src/components/common/ProfileInfo.tsx @@ -23,6 +23,7 @@ import VerifiedIcon from './VerifiedIcon'; import ProfilePhoto from './ProfilePhoto'; import Transition from '../ui/Transition'; import FakeIcon from './FakeIcon'; +import PremiumIcon from './PremiumIcon'; import './ProfileInfo.scss'; @@ -55,6 +56,7 @@ const ProfileInfo: FC = ({ const { loadFullUser, openMediaViewer, + openPremiumModal, } = getActions(); const lang = useLang(); @@ -94,6 +96,12 @@ const ProfileInfo: FC = ({ }); }, [openMediaViewer, userId, chatId, currentPhotoIndex, forceShowSelf]); + const handleClickPremium = useCallback(() => { + if (!user) return; + + openPremiumModal({ fromUserId: user.id }); + }, [openPremiumModal, user]); + const selectPreviousMedia = useCallback(() => { if (isFirst) { return; @@ -151,9 +159,8 @@ const ProfileInfo: FC = ({ ); } - function renderPhoto() { - const photo = !isSavedMessages && photos && photos.length > 0 ? photos[currentPhotoIndex] : undefined; - + function renderPhoto(isActive?: boolean) { + const photo = !isSavedMessages && photos.length > 0 ? photos[currentPhotoIndex] : undefined; return ( = ({ photo={photo} isSavedMessages={isSavedMessages} isFirstPhoto={isFirst} + notActive={!isActive} onClick={handleProfilePhotoClick} /> ); @@ -187,6 +195,7 @@ const ProfileInfo: FC = ({ } const isVerifiedIconShown = (user || chat)?.isVerified; + const isPremiumIconShown = user?.isPremium; const fakeType = (user || chat)?.fakeType; return ( @@ -194,7 +203,7 @@ const ProfileInfo: FC = ({
{renderPhotoTabs()} - {renderPhoto()} + {renderPhoto} {!isFirst && ( @@ -218,12 +227,13 @@ const ProfileInfo: FC = ({
{isSavedMessages ? (
-

{lang('SavedMessages')}

+
{lang('SavedMessages')}
) : (
-

{fullName && renderText(fullName)}

+
{fullName && renderText(fullName)}
{isVerifiedIconShown && } + {isPremiumIconShown && } {fakeType && }
)} diff --git a/src/components/common/ProfilePhoto.tsx b/src/components/common/ProfilePhoto.tsx index a4939ba0d..74124a127 100644 --- a/src/components/common/ProfilePhoto.tsx +++ b/src/components/common/ProfilePhoto.tsx @@ -1,6 +1,6 @@ -import type { FC, TeactNode } from '../../lib/teact/teact'; -import React, { memo } from '../../lib/teact/teact'; +import React, { memo, useEffect, useRef } from '../../lib/teact/teact'; +import type { FC, TeactNode } from '../../lib/teact/teact'; import type { ApiChat, ApiPhoto, ApiUser } from '../../api/types'; import { ApiMediaFormat } from '../../api/types'; @@ -11,7 +11,7 @@ import { getUserFullName, isUserId, isChatWithRepliesBot, - isDeletedUser, + isDeletedUser, getVideoAvatarMediaHash, } from '../../global/helpers'; import renderText from './helpers/renderText'; import buildClassName from '../../util/buildClassName'; @@ -30,6 +30,7 @@ type OwnProps = { isSavedMessages?: boolean; photo?: ApiPhoto; lastSyncTime?: number; + notActive?: boolean; onClick: NoneToVoidFunction; }; @@ -39,34 +40,55 @@ const ProfilePhoto: FC = ({ photo, isFirstPhoto, isSavedMessages, + notActive, lastSyncTime, onClick, }) => { + // eslint-disable-next-line no-null/no-null + const videoRef = useRef(null); const lang = useLang(); const isDeleted = user && isDeletedUser(user); const isRepliesChat = chat && isChatWithRepliesBot(chat.id); - function getMediaHash(size: 'normal' | 'big', forceAvatar?: boolean) { - if (photo && !forceAvatar) { - return `photo${photo.id}?size=c`; - } + function getMediaHash(size: 'normal' | 'big', type: 'photo' | 'video' = 'photo') { + const userOrChat = user || chat; + const profilePhoto = photo || userOrChat?.fullInfo?.profilePhoto; + const hasVideo = profilePhoto?.isVideo; + const forceAvatar = isFirstPhoto; - let hash: string | undefined; - if (!isSavedMessages && !isDeleted && !isRepliesChat) { - if (user) { - hash = getChatAvatarHash(user, size); - } else if (chat) { - hash = getChatAvatarHash(chat, size); + if (type === 'video' && !hasVideo) return undefined; + + if (photo && !forceAvatar) { + if (hasVideo && type === 'video') { + return getVideoAvatarMediaHash(photo); + } + if (type === 'photo') { + return `photo${photo.id}?size=c`; } } - return hash; + if (!isSavedMessages && !isDeleted && !isRepliesChat && userOrChat) { + return getChatAvatarHash(userOrChat, size, type); + } + + return undefined; } - const photoBlobUrl = useMedia(getMediaHash('big'), false, ApiMediaFormat.BlobUrl, lastSyncTime); - const avatarMediaHash = isFirstPhoto && !photoBlobUrl ? getMediaHash('normal', true) : undefined; - const avatarBlobUrl = useMedia(avatarMediaHash, false, ApiMediaFormat.BlobUrl, lastSyncTime); - const imageSrc = photoBlobUrl || avatarBlobUrl || photo?.thumbnail?.dataUri; + useEffect(() => { + if (!videoRef.current) return; + if (notActive) { + videoRef.current.pause(); + videoRef.current.currentTime = 0; + } else { + videoRef.current.play(); + } + }, [notActive]); + + const photoHash = getMediaHash('big', 'photo'); + const photoBlobUrl = useMedia(photoHash, false, ApiMediaFormat.BlobUrl, lastSyncTime); + const videoHash = getMediaHash('normal', 'video'); + const videoBlobUrl = useMedia(videoHash, false, ApiMediaFormat.BlobUrl, lastSyncTime); + const imageSrc = videoBlobUrl || photoBlobUrl || photo?.thumbnail?.dataUri; let content: TeactNode | undefined; @@ -77,7 +99,21 @@ const ProfilePhoto: FC = ({ } else if (isRepliesChat) { content = ; } else if (imageSrc) { - content = ; + if (videoBlobUrl) { + content = ( +