From fc0e52e9083f93f80ab3e7fc0c3f4d76834f198b Mon Sep 17 00:00:00 2001 From: zubiden <19638254+zubiden@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:36:51 +0200 Subject: [PATCH] Poll: Support new features (#6819) --- CLAUDE.md | 7 +- src/api/gramjs/apiBuilders/gifts.ts | 11 - src/api/gramjs/apiBuilders/messageActions.ts | 22 +- src/api/gramjs/apiBuilders/messageContent.ts | 93 ++- src/api/gramjs/apiBuilders/messages.ts | 40 +- src/api/gramjs/apiBuilders/symbols.ts | 10 +- src/api/gramjs/gramjsBuilders/index.ts | 151 +++- src/api/gramjs/helpers/localDb.ts | 17 +- src/api/gramjs/methods/messages.ts | 33 +- src/api/gramjs/methods/reactions.ts | 12 +- src/api/gramjs/updates/entityProcessor.ts | 8 +- src/api/gramjs/updates/mtpUpdateHandler.ts | 39 +- src/api/types/messageActions.ts | 15 +- src/api/types/messages.ts | 70 +- src/api/types/updates.ts | 20 +- src/assets/font-icons/previous-link.svg | 1 + src/assets/localization/fallback.strings | 23 +- .../common/AnimatedCounter.module.scss | 1 + .../common/CompactMapPreview.module.scss | 36 + src/components/common/CompactMapPreview.tsx | 63 ++ .../common/CompactMediaPreview.module.scss | 82 ++ src/components/common/CompactMediaPreview.tsx | 254 ++++++ src/components/common/Document.tsx | 17 +- src/components/common/File.tsx | 53 +- src/components/common/MessageSummary.tsx | 4 +- .../common/embedded/EmbeddedMessage.scss | 52 -- .../common/embedded/EmbeddedMessage.tsx | 110 +-- .../common/embedded/EmbeddedStory.tsx | 53 +- .../common/helpers/mediaDimensions.ts | 29 +- src/components/common/profile/ChatExtra.tsx | 26 +- .../gili/primitives/Checkbox.module.scss | 68 +- src/components/gili/primitives/Checkbox.tsx | 6 +- .../gili/primitives/Radio.module.scss | 17 +- src/components/gili/primitives/Radio.tsx | 6 +- .../gili/primitives/Switch.module.scss | 14 +- .../left/settings/SettingsEditProfile.tsx | 4 +- .../left/settings/SettingsPasskeys.tsx | 4 +- .../mediaViewer/helpers/ghostAnimation.ts | 21 +- .../middle/composer/AttachmentModalItem.tsx | 3 +- src/components/middle/composer/PollModal.tsx | 18 +- .../middle/message/ActionMessage.tsx | 2 + .../middle/message/ActionMessageText.tsx | 23 + .../middle/message/ContextMenuContainer.tsx | 4 +- src/components/middle/message/Message.scss | 7 +- src/components/middle/message/Message.tsx | 20 +- .../middle/message/MessageContextMenu.tsx | 4 +- src/components/middle/message/Photo.tsx | 6 +- src/components/middle/message/Poll.scss | 170 ---- src/components/middle/message/Poll.tsx | 357 -------- src/components/middle/message/PollOption.scss | 131 --- src/components/middle/message/PollOption.tsx | 102 --- src/components/middle/message/Video.tsx | 8 +- src/components/middle/message/WebPage.tsx | 4 +- .../message/helpers/buildContentClassName.ts | 10 +- .../middle/message/helpers/mediaDimensions.ts | 10 +- .../middle/message/hooks/useInnerHandlers.ts | 7 +- .../middle/message/poll/Poll.module.scss | 143 ++++ src/components/middle/message/poll/Poll.tsx | 764 ++++++++++++++++++ .../message/poll/PollOption.module.scss | 192 +++++ .../middle/message/poll/PollOption.tsx | 356 ++++++++ .../panes/HeaderPinnedMessage.module.scss | 6 - .../middle/panes/HeaderPinnedMessage.tsx | 69 +- .../modals/birthday/BirthdaySetupModal.tsx | 4 +- src/components/modals/gift/GiftComposer.tsx | 8 +- src/components/modals/gift/GiftModal.tsx | 4 +- .../modals/gift/craft/GiftCraftModal.tsx | 4 +- .../modals/gift/info/GiftInfoModal.tsx | 6 +- .../modals/gift/preview/GiftPreviewModal.tsx | 4 +- .../modals/gift/upgrade/GiftUpgradeModal.tsx | 6 +- src/components/modals/map/MapModal.tsx | 21 +- .../profileRating/ProfileRatingModal.tsx | 6 +- src/components/right/PollResults.tsx | 13 +- src/components/ui/Button.scss | 66 +- src/components/ui/ModalStarBalanceBar.tsx | 4 +- src/components/ui/TextTimer.tsx | 57 +- src/global/actions/apiUpdaters/messages.ts | 14 +- src/global/actions/apiUpdaters/misc.ts | 2 +- src/global/helpers/messages.ts | 7 +- src/global/reducers/messages.ts | 99 ++- src/global/selectors/messages.ts | 8 +- src/global/types/globalState.ts | 4 +- src/styles/icons.css | 673 +++++++-------- src/styles/icons.scss | 667 +++++++-------- src/styles/icons.woff | Bin 42344 -> 42044 bytes src/styles/icons.woff2 | Bin 35276 -> 34908 bytes src/styles/index.scss | 3 +- src/types/icons/font.ts | 663 +++++++-------- src/types/index.ts | 1 + src/types/language.d.ts | 39 + src/util/iteratees.ts | 11 + src/util/localization/dateFormat.ts | 18 + src/util/localization/format.tsx | 11 +- tsconfig.json | 3 +- webpack.config.ts | 1 + 94 files changed, 3849 insertions(+), 2486 deletions(-) create mode 100644 src/assets/font-icons/previous-link.svg create mode 100644 src/components/common/CompactMapPreview.module.scss create mode 100644 src/components/common/CompactMapPreview.tsx create mode 100644 src/components/common/CompactMediaPreview.module.scss create mode 100644 src/components/common/CompactMediaPreview.tsx delete mode 100644 src/components/middle/message/Poll.scss delete mode 100644 src/components/middle/message/Poll.tsx delete mode 100644 src/components/middle/message/PollOption.scss delete mode 100644 src/components/middle/message/PollOption.tsx create mode 100644 src/components/middle/message/poll/Poll.module.scss create mode 100644 src/components/middle/message/poll/Poll.tsx create mode 100644 src/components/middle/message/poll/PollOption.module.scss create mode 100644 src/components/middle/message/poll/PollOption.tsx diff --git a/CLAUDE.md b/CLAUDE.md index e653d22ba..968e0b12f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,8 +30,8 @@ You are an expert in TypeScript, JavaScript, HTML, SCSS and Teact with deep expe - **Code Style:** - Early returns. - - Prefix boolean variables with primary or modal auxiliaries (e.q. `isOpen`, `willUpdate`, `shouldRender`). - - Functions should start with a verb (e.q. `openModal`, `closeDialog`, `handleClick`). + - Prefix boolean variables with primary or modal auxiliaries (e.g. `isOpen`, `willUpdate`, `shouldRender`). + - Functions should start with a verb (e.g. `openModal`, `closeDialog`, `handleClick`). - Prefer checking required parameter before calling a function, avoid making it optional and checking at the beginning of the function. - Only leave comments for complex logic. - Do not use `null`. There's linter rule to enforce it. @@ -160,7 +160,7 @@ addActionHandler('loadUser', async (global, actions, { userId }) => { ### 1. Basics & Imports * All components use JSX and render with Teact. -* Only import from `'react'` when you need React **types** that are not provided in Teact. +* Do not import "react". React types are available globally in React namespace (e.g. React.MouseEvent). * Built-in hooks live in Teact library. Import them from there. ### 2. Props & Types @@ -171,6 +171,7 @@ addActionHandler('loadUser', async (global, actions, { userId }) => { * Merge them as `OwnProps & StateProps` when defining your component. * You can skip one or both if they are not used. * **Order rule**: list any function types *last* in your props definitions. +* Do not pass unmemoized objects as props into memo() components. ### 3. Hooks * **useLastCallback** is your go-to for callbacks, since it won't trigger re-renders and always uses the latest scope. diff --git a/src/api/gramjs/apiBuilders/gifts.ts b/src/api/gramjs/apiBuilders/gifts.ts index 6f55f9180..bbfb3f0e8 100644 --- a/src/api/gramjs/apiBuilders/gifts.ts +++ b/src/api/gramjs/apiBuilders/gifts.ts @@ -23,7 +23,6 @@ import type { import { int2hex } from '../../../util/colors'; import { toJSNumber } from '../../../util/numbers'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; -import { addDocumentToLocalDb } from '../helpers/localDb'; import { buildApiFormattedText } from './common'; import { buildApiCurrencyAmount } from './payments'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; @@ -73,8 +72,6 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift { background, } = starGift; - addDocumentToLocalDb(starGift.sticker); - const sticker = buildStickerFromDocument(starGift.sticker)!; return { @@ -138,8 +135,6 @@ export function buildApiStarGiftAttribute(attribute: GramJs.TypeStarGiftAttribut return undefined; } - addDocumentToLocalDb(attribute.document); - return { type: 'model', name: attribute.name, @@ -154,8 +149,6 @@ export function buildApiStarGiftAttribute(attribute: GramJs.TypeStarGiftAttribut return undefined; } - addDocumentToLocalDb(attribute.document); - return { type: 'pattern', name: attribute.name, @@ -349,10 +342,6 @@ export function buildApiStarGiftCollection(collection: GramJs.StarGiftCollection const { collectionId, title, icon, giftsCount, hash } = collection; - if (icon) { - addDocumentToLocalDb(icon); - } - return { collectionId, title, diff --git a/src/api/gramjs/apiBuilders/messageActions.ts b/src/api/gramjs/apiBuilders/messageActions.ts index 6743dad4b..814d6734c 100644 --- a/src/api/gramjs/apiBuilders/messageActions.ts +++ b/src/api/gramjs/apiBuilders/messageActions.ts @@ -7,7 +7,7 @@ import { toJSNumber } from '../../../util/numbers'; import { buildApiBotApp } from './bots'; import { buildApiFormattedText, buildApiPhoto } from './common'; import { buildApiStarGift } from './gifts'; -import { buildTodoItem } from './messageContent'; +import { buildPollAnswer, buildTodoItem } from './messageContent'; import { buildApiCurrencyAmount } from './payments'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; @@ -525,6 +525,26 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess items: list.map(buildTodoItem), }; } + if (action instanceof GramJs.MessageActionPollAppendAnswer) { + const answer = buildPollAnswer(action.answer); + if (!answer) return UNSUPPORTED_ACTION; + + return { + mediaType: 'action', + type: 'pollAppendAnswer', + answer, + }; + } + if (action instanceof GramJs.MessageActionPollDeleteAnswer) { + const answer = buildPollAnswer(action.answer); + if (!answer) return UNSUPPORTED_ACTION; + + return { + mediaType: 'action', + type: 'pollDeleteAnswer', + answer, + }; + } if (action instanceof GramJs.MessageActionStarGiftPurchaseOffer) { const { accepted, declined, gift, price, expiresAt, diff --git a/src/api/gramjs/apiBuilders/messageContent.ts b/src/api/gramjs/apiBuilders/messageContent.ts index a053cfe87..b4874667c 100644 --- a/src/api/gramjs/apiBuilders/messageContent.ts +++ b/src/api/gramjs/apiBuilders/messageContent.ts @@ -13,11 +13,14 @@ import type { ApiMediaExtendedPreview, ApiMediaInvoice, ApiMediaTodo, + ApiMessagePoll, ApiMessageStoryData, ApiMessageWebPage, ApiPaidMedia, ApiPhoto, ApiPoll, + ApiPollAnswer, + ApiPollResults, ApiStarGiftUnique, ApiSticker, ApiTodoItem, @@ -43,7 +46,7 @@ import { } from '../../../config'; import { addTimestampEntities } from '../../../util/dates/timestamp'; import { generateWaveform } from '../../../util/generateWaveform'; -import { pick } from '../../../util/iteratees'; +import { buildCollectionByKey, pick } from '../../../util/iteratees'; import { toJSNumber } from '../../../util/numbers'; import { addMediaToLocalDb, addStoryToLocalDb, addWebPageMediaToLocalDb, type MediaRepairContext, @@ -77,7 +80,7 @@ export function buildMessageContent( const hasUnsupportedMedia = mtpMessage.media instanceof GramJs.MessageMediaUnsupported; if (mtpMessage.message && !hasUnsupportedMedia - && !content.sticker && !content.pollId && !content.todo && !content.contact && !content.video?.isRound) { + && !content.sticker && !content.todo && !content.contact && !content.video?.isRound) { const text = buildMessageTextContent(mtpMessage.message, mtpMessage.entities); const textWithTimestamps = addTimestampEntities(text); content = { @@ -559,12 +562,12 @@ function buildPollIdFromMedia(media: GramJs.TypeMessageMedia): string | undefine return media.poll.id.toString(); } -export function buildPollFromMedia(media: GramJs.TypeMessageMedia): ApiPoll | undefined { +export function buildMessagePollFromMedia(media: GramJs.TypeMessageMedia): ApiMessagePoll | undefined { if (!(media instanceof GramJs.MessageMediaPoll)) { return undefined; } - return buildPoll(media.poll, media.results); + return buildMessagePoll(media); } function buildTodoFromMedia(media: GramJs.TypeMessageMedia): ApiMediaTodo | undefined { @@ -764,31 +767,53 @@ export function buildMessageStoryData(media: GramJs.TypeMessageMedia): ApiMessag }; } -export function buildPoll(poll: GramJs.Poll, pollResults: GramJs.PollResults): ApiPoll { - const { id, answers: rawAnswers } = poll; - const answers = rawAnswers - .filter((answer): answer is GramJs.PollAnswer => answer instanceof GramJs.PollAnswer) - .map((answer) => ({ - text: buildApiFormattedText(answer.text), - option: serializeBytes(answer.option), - })); +export function buildMessagePoll(media: GramJs.MessageMediaPoll): ApiMessagePoll { + const { poll, results, attachedMedia } = media; return { mediaType: 'poll', - id: String(id), - summary: { - isPublic: poll.publicVoters, - question: buildApiFormattedText(poll.question), - ...pick(poll, [ - 'closed', - 'multipleChoice', - 'quiz', - 'closePeriod', - 'closeDate', - ]), - answers, - }, - results: buildPollResults(pollResults), + summary: buildPoll(poll), + results: buildPollResults(results), + attachedMedia: attachedMedia ? buildMessageMediaContent(attachedMedia) : undefined, + }; +} + +export function buildPollAnswer(answer: GramJs.TypePollAnswer): ApiPollAnswer | undefined { + if (!(answer instanceof GramJs.PollAnswer)) return undefined; + const { text, option, media, addedBy, date } = answer; + + return { + text: buildApiFormattedText(text), + option: serializeBytes(option), + media: media ? buildMessageMediaContent(media) : undefined, + addedByPeerId: addedBy ? getApiChatIdFromMtpPeer(addedBy) : undefined, + date, + }; +} + +export function buildPoll(poll: GramJs.Poll): ApiPoll { + const { + id, closed, publicVoters, multipleChoice, quiz, closePeriod, closeDate, answers, question, creator, + hideResultsUntilClose, revotingDisabled, shuffleAnswers, openAnswers, hash, + } = poll; + const apiAnswers = answers.map(buildPollAnswer).filter(Boolean); + + return { + id: id.toString(), + isClosed: closed, + isPublic: publicVoters, + isMultipleChoice: multipleChoice, + isQuiz: quiz, + closePeriod, + closeDate, + isCreator: creator, + shouldHideResultsUntilClose: hideResultsUntilClose, + isRevoteDisabled: revotingDisabled, + shouldShuffleAnswers: shuffleAnswers, + question: buildApiFormattedText(question), + answers: apiAnswers, + hash: hash.toString(), + canAddAnswers: openAnswers, }; } @@ -843,26 +868,32 @@ export function buildMediaInvoice(media: GramJs.MessageMediaInvoice): ApiMediaIn }; } -export function buildPollResults(pollResults: GramJs.PollResults): ApiPoll['results'] { +export function buildPollResults(pollResults: GramJs.PollResults): ApiPollResults { const { - results: rawResults, totalVoters, recentVoters, solution, solutionEntities: entities, min, + results: rawResults, totalVoters, recentVoters, solution, solutionEntities: entities, min, solutionMedia, } = pollResults; const results = rawResults?.map(({ - option, chosen, correct, voters, + option, chosen, correct, voters, recentVoters: recentAnswerVoters, }) => ({ isChosen: chosen, isCorrect: correct, option: serializeBytes(option), votersCount: voters ?? 0, + recentVoterIds: recentAnswerVoters?.map((peer) => getApiChatIdFromMtpPeer(peer)), })); + if (solutionMedia) { + addMediaToLocalDb(solutionMedia); + } + return { isMin: min, totalVoters, recentVoterIds: recentVoters?.map((peer) => getApiChatIdFromMtpPeer(peer)), - results, + resultByOption: results && buildCollectionByKey(results, 'option'), solution, - ...(entities && { solutionEntities: entities.map(buildApiMessageEntity) }), + solutionEntities: entities?.map(buildApiMessageEntity), + solutionMedia: solutionMedia ? buildMessageMediaContent(solutionMedia) : undefined, }; } diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index d0d3e578f..583cacf70 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -16,13 +16,14 @@ import type { ApiMessage, ApiMessageEntity, ApiMessageForwardInfo, + ApiMessagePoll, ApiMessageReportResult, ApiMessageThreadInfo, ApiNewMediaTodo, ApiNewPoll, ApiPeer, ApiPhoto, - ApiPoll, + ApiPollResult, ApiPreparedInlineMessage, ApiQuickReply, ApiReplyInfo, @@ -49,7 +50,7 @@ import { } from '../../../config'; import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage'; import { addTimestampEntities } from '../../../util/dates/timestamp'; -import { omitUndefined, pick } from '../../../util/iteratees'; +import { omitUndefined } from '../../../util/iteratees'; import { toJSNumber } from '../../../util/numbers'; import { getServerTime } from '../../../util/serverTime'; import { interpolateArray } from '../../../util/waveform'; @@ -418,16 +419,35 @@ export function buildApiFactCheck(factCheck: GramJs.FactCheck): ApiFactCheck { }; } -function buildNewPoll(poll: ApiNewPoll, localId: number): ApiPoll { +function buildNewLocalPoll(poll: ApiNewPoll): ApiMessagePoll { + const resultByOption = poll.correctAnswers?.length + ? poll.summary.answers.reduce((acc, answer, index) => { + const isCorrect = poll.correctAnswers?.includes(index); + + acc[answer.option] = { + option: answer.option, + votersCount: 0, + isCorrect: isCorrect ? true : undefined, + }; + + return acc; + }, {} as Record) + : undefined; + return { mediaType: 'poll', - id: String(localId), - summary: pick(poll.summary, ['question', 'answers']), - results: {}, + summary: poll.summary, + results: { + resultByOption, + solution: poll.solution, + solutionEntities: poll.solutionEntities, + solutionMedia: poll.solutionMedia ? buildUploadingMedia(poll.solutionMedia) : undefined, + }, + attachedMedia: poll.attachedMedia ? buildUploadingMedia(poll.attachedMedia) : undefined, }; } -function buildNewTodo(todo: ApiNewMediaTodo): ApiMediaTodo { +function buildNewLocalTodo(todo: ApiNewMediaTodo): ApiMediaTodo { return { mediaType: 'todo', todo: todo.todo, @@ -487,8 +507,8 @@ export function buildLocalMessage({ const resultReplyInfo = replyInfo && buildReplyInfo(replyInfo, chat.isForum); - const localPoll = poll && buildNewPoll(poll, localId); - const localTodo = todo && buildNewTodo(todo); + const localPoll = poll && buildNewLocalPoll(poll); + const localTodo = todo && buildNewLocalTodo(todo); const localDice = dice ? { mediaType: 'dice', @@ -510,7 +530,7 @@ export function buildLocalMessage({ video: gif || media?.video, contact, storyData: story && { mediaType: 'storyData', ...story }, - pollId: localPoll?.id, + pollId: localPoll?.summary.id, todo: localTodo, dice: localDice, }), diff --git a/src/api/gramjs/apiBuilders/symbols.ts b/src/api/gramjs/apiBuilders/symbols.ts index 6d8462418..397706147 100644 --- a/src/api/gramjs/apiBuilders/symbols.ts +++ b/src/api/gramjs/apiBuilders/symbols.ts @@ -6,6 +6,7 @@ import type { import { LOTTIE_STICKER_MIME_TYPE, VIDEO_STICKER_MIME_TYPE } from '../../../config'; import { compact } from '../../../util/iteratees'; +import { addDocumentToLocalDb } from '../helpers/localDb'; import localDb from '../localDb'; import { buildApiPhotoPreviewSizes, buildApiThumbnailFromCached, buildApiThumbnailFromPath } from './common'; @@ -15,6 +16,8 @@ export function buildStickerFromDocument(document: GramJs.TypeDocument, return undefined; } + addDocumentToLocalDb(document); + const { mimeType, videoThumbs } = document; const stickerAttribute = document.attributes .find((attr: any): attr is GramJs.DocumentAttributeSticker => ( @@ -202,12 +205,7 @@ export function processStickerResult(stickers: GramJs.TypeDocument[]) { return stickers .map((document) => { if (document instanceof GramJs.Document) { - const sticker = buildStickerFromDocument(document); - if (sticker) { - localDb.documents[String(document.id)] = document; - - return sticker; - } + return buildStickerFromDocument(document); } return undefined; diff --git a/src/api/gramjs/gramjsBuilders/index.ts b/src/api/gramjs/gramjsBuilders/index.ts index 596145232..0963bd321 100644 --- a/src/api/gramjs/gramjsBuilders/index.ts +++ b/src/api/gramjs/gramjsBuilders/index.ts @@ -8,6 +8,7 @@ import type { ApiChatFolder, ApiChatReactions, ApiDisallowedGiftsSettings, + ApiDocument, ApiEmojiStatusType, ApiFormattedText, ApiGroupCall, @@ -15,12 +16,13 @@ import type { ApiInputReplyInfo, ApiInputStorePaymentPurpose, ApiInputSuggestedPostInfo, + ApiLocation, ApiMessageEntity, + ApiMessagePoll, ApiNewMediaTodo, ApiNewPoll, ApiPhoneCall, ApiPhoto, - ApiPoll, ApiPremiumGiftCodeOption, ApiPrivacyKey, ApiProfileTab, @@ -35,6 +37,7 @@ import type { ApiThemeParameters, ApiTypeCurrencyAmount, ApiVideo, + MediaContent, } from '../../types'; import { ApiMessageEntityTypes, @@ -183,7 +186,11 @@ export function buildInputStickerSetShortName(shortName: string) { }); } -export function buildInputDocument(media: ApiSticker | ApiVideo) { +export function buildInputDocument(media: ApiSticker | ApiVideo | ApiDocument) { + if (!media.id) { + return undefined; + } + const document = localDb.documents[media.id]; if (!document) { @@ -197,7 +204,7 @@ export function buildInputDocument(media: ApiSticker | ApiVideo) { ])); } -export function buildInputMediaDocument(media: ApiSticker | ApiVideo, spoiler?: true) { +export function buildInputMediaDocument(media: ApiSticker | ApiVideo | ApiDocument, spoiler?: true) { const inputDocument = buildInputDocument(media); if (!inputDocument) { @@ -207,48 +214,47 @@ export function buildInputMediaDocument(media: ApiSticker | ApiVideo, spoiler?: return new GramJs.InputMediaDocument({ id: inputDocument, spoiler }); } -export function buildInputPoll(pollParams: ApiNewPoll, randomId: bigint) { - const { summary, quiz } = pollParams; +export function buildInputPoll( + pollParams: ApiNewPoll, + randomId: bigint, + media?: { + attachedMedia?: GramJs.TypeInputMedia; + solutionMedia?: GramJs.TypeInputMedia; + }, +) { + const { summary: poll, correctAnswers, solution, solutionEntities } = pollParams; - const poll = new GramJs.Poll({ + const inputPoll = new GramJs.Poll({ id: randomId, - publicVoters: summary.isPublic, - question: buildInputTextWithEntities(summary.question), - answers: summary.answers.map(({ text, option }) => { + publicVoters: poll.isPublic, + question: buildInputTextWithEntities(poll.question), + answers: poll.answers.map(({ text, option }) => { return new GramJs.PollAnswer({ text: buildInputTextWithEntities(text), option: deserializeBytes(option), }); }), - quiz: summary.quiz, - multipleChoice: summary.multipleChoice, + quiz: poll.isQuiz, + multipleChoice: poll.isMultipleChoice, hash: DEFAULT_PRIMITIVES.BIGINT, }); - if (!quiz) { - return new GramJs.InputMediaPoll({ poll }); - } - - const correctAnswers = quiz.correctAnswers.map((correctOption) => { - return summary.answers.findIndex((a) => a.option === correctOption); - }).filter((i) => i !== -1); - const { solution } = quiz; - const solutionEntities = quiz.solutionEntities ? quiz.solutionEntities.map(buildMtpMessageEntity) : []; + const inputSolutionEntities = solutionEntities?.map(buildMtpMessageEntity); return new GramJs.InputMediaPoll({ - poll, + poll: inputPoll, correctAnswers, - ...(solution && { - solution, - solutionEntities, - }), + attachedMedia: media?.attachedMedia, + solution, + solutionEntities: inputSolutionEntities, + solutionMedia: media?.solutionMedia, }); } -export function buildInputPollFromExisting(poll: ApiPoll, shouldClose = false) { +export function buildInputPollFromExisting(poll: ApiMessagePoll, shouldClose = false) { return new GramJs.InputMediaPoll({ poll: new GramJs.Poll({ - id: BigInt(poll.id), + id: BigInt(poll.summary.id), publicVoters: poll.summary.isPublic, question: buildInputTextWithEntities(poll.summary.question), answers: poll.summary.answers.map(({ text, option }) => { @@ -257,18 +263,95 @@ export function buildInputPollFromExisting(poll: ApiPoll, shouldClose = false) { option: deserializeBytes(option), }); }), - quiz: poll.summary.quiz, - multipleChoice: poll.summary.multipleChoice, + quiz: poll.summary.isQuiz, + multipleChoice: poll.summary.isMultipleChoice, closeDate: poll.summary.closeDate, closePeriod: poll.summary.closePeriod, - closed: shouldClose ? true : poll.summary.closed, - hash: DEFAULT_PRIMITIVES.BIGINT, + closed: shouldClose ? true : poll.summary.isClosed, + creator: poll.summary.isCreator, + revotingDisabled: poll.summary.isRevoteDisabled, + shuffleAnswers: poll.summary.shouldShuffleAnswers, + hideResultsUntilClose: poll.summary.shouldHideResultsUntilClose, + openAnswers: poll.summary.canAddAnswers, + hash: BigInt(poll.summary.hash), }), - correctAnswers: poll.results.results - ?.map((result, index) => (result.isCorrect ? index : -1)) - .filter((i) => i !== -1), + correctAnswers: poll.summary.answers.map((answer, index) => { + const result = poll.results.resultByOption?.[answer.option]; + return result?.isCorrect ? index : -1; + }).filter((i) => i !== -1), + attachedMedia: buildInputMediaFromContent(poll.attachedMedia), solution: poll.results.solution, solutionEntities: poll.results.solutionEntities?.map(buildMtpMessageEntity), + solutionMedia: buildInputMediaFromContent(poll.results.solutionMedia), + }); +} + +function buildInputMediaFromContent(content?: MediaContent) { + if (!content) { + return undefined; + } + + if (content.photo) { + const inputPhoto = buildInputPhoto(content.photo); + return inputPhoto ? new GramJs.InputMediaPhoto({ + id: inputPhoto, + spoiler: content.photo.isSpoiler || undefined, + }) : undefined; + } + + if (content.video) { + return buildInputMediaDocument(content.video, content.video.isSpoiler || undefined); + } + + if (content.document) { + return buildInputMediaDocument(content.document); + } + + if (content.location) { + return buildInputLocationMedia(content.location); + } + + if (content.sticker) { + return buildInputMediaDocument(content.sticker); + } + + return undefined; +} + +function buildInputLocationMedia(location: ApiLocation) { + const geoPoint = buildInputGeoPoint(location.geo); + + if (!geoPoint) { + return undefined; + } + + if (location.mediaType === 'venue') { + return new GramJs.InputMediaVenue({ + geoPoint, + title: location.title, + address: location.address, + provider: location.provider, + venueId: location.venueId, + venueType: location.venueType, + }); + } + + if (location.mediaType === 'geoLive') { + return new GramJs.InputMediaGeoLive({ + geoPoint, + heading: location.heading, + period: location.period, + }); + } + + return new GramJs.InputMediaGeoPoint({ geoPoint }); +} + +function buildInputGeoPoint(geo: ApiLocation['geo']) { + return new GramJs.InputGeoPoint({ + lat: geo.lat, + long: geo.long, + accuracyRadius: geo.accuracyRadius, }); } diff --git a/src/api/gramjs/helpers/localDb.ts b/src/api/gramjs/helpers/localDb.ts index 853c64d5a..d0af82e75 100644 --- a/src/api/gramjs/helpers/localDb.ts +++ b/src/api/gramjs/helpers/localDb.ts @@ -84,6 +84,12 @@ export function addMediaToLocalDb(media: GramJs.TypeMessageMedia, context?: Medi } }); } + + if (media instanceof GramJs.MessageMediaPoll) { + if (media.attachedMedia) { + addMediaToLocalDb(media.attachedMedia, context); + } + } } export function addStoryToLocalDb(story: GramJs.TypeStoryItem, peerId: string) { @@ -117,9 +123,16 @@ export function addPhotoToLocalDb(photo: GramJs.TypePhoto) { } } -export function addDocumentToLocalDb(document: GramJs.TypeDocument) { +export function addDocumentToLocalDb(document: GramJs.TypeDocument & RepairInfo) { if (document instanceof GramJs.Document) { - localDb.documents[String(document.id)] = document; + const id = String(document.id); + const current = localDb.documents[id]; + if (current && document.accessHash === current.accessHash && document.fileReference === current.fileReference + && !document.localRepairInfo + ) { + return; + } + localDb.documents[id] = document; } } diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 5ec8ccd4a..1b4af3af5 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -18,12 +18,12 @@ import type { ApiInputSuggestedPostInfo, ApiMessage, ApiMessageEntity, + ApiMessagePoll, ApiMessageSearchContext, ApiMessageSearchType, ApiNewMediaTodo, ApiOnProgress, ApiPeer, - ApiPoll, ApiReaction, ApiSearchPostsFlood, ApiSendMessageAction, @@ -64,7 +64,7 @@ import { import { buildApiComposedMessageWithAI, buildApiFormattedText } from '../apiBuilders/common'; import { buildApiTopicWithState } from '../apiBuilders/forums'; import { - buildMessageMediaContent, buildMessageTextContent, buildPollFromMedia, + buildMessageMediaContent, buildMessagePollFromMedia, buildMessageTextContent, buildWebPageFromMedia, } from '../apiBuilders/messageContent'; import { @@ -461,7 +461,28 @@ export function sendApiMessage( } else if (gif) { media = buildInputMediaDocument(gif); } else if (poll) { - media = buildInputPoll(poll, randomId); + try { + const attachedMedia = poll.attachedMedia + ? await uploadMedia(localMessage, poll.attachedMedia, onProgress!) + : undefined; + const solutionMedia = poll.solutionMedia + ? await uploadMedia(localMessage, poll.solutionMedia, onProgress!) + : undefined; + + media = buildInputPoll(poll, randomId, { + attachedMedia, + solutionMedia, + }); + } catch (err) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.warn(err); + } + + await mediaQueue; + + return; + } } else if (todo) { media = buildInputTodo(todo); } else if (story) { @@ -1856,7 +1877,7 @@ export async function closePoll({ }: { chat: ApiChat; messageId: number; - poll: ApiPoll; + poll: ApiMessagePoll; }) { const { id, accessHash } = chat; @@ -2498,7 +2519,7 @@ function handleLocalMessageUpdate( } let newContent: MediaContent | undefined; - let poll: ApiPoll | undefined; + let poll: ApiMessagePoll | undefined; let webPage: ApiWebPage | undefined; if (messageUpdate instanceof GramJs.UpdateShortSentMessage) { if (localMessage.content.text && messageUpdate.entities) { @@ -2513,7 +2534,7 @@ function handleLocalMessageUpdate( peerId: buildPeer(localMessage.chatId), id: messageUpdate.id, }), }; - poll = buildPollFromMedia(messageUpdate.media); + poll = buildMessagePollFromMedia(messageUpdate.media); webPage = buildWebPageFromMedia(messageUpdate.media); } diff --git a/src/api/gramjs/methods/reactions.ts b/src/api/gramjs/methods/reactions.ts index ace0aea1d..6e7227037 100644 --- a/src/api/gramjs/methods/reactions.ts +++ b/src/api/gramjs/methods/reactions.ts @@ -114,12 +114,6 @@ export async function fetchAvailableEffects() { const documentsMap = new Map(result.documents.map((doc) => [String(doc.id), doc])); - result.documents.forEach((document) => { - if (document instanceof GramJs.Document) { - localDb.documents[String(document.id)] = document; - } - }); - const effects = result.effects.map(buildApiAvailableEffect); const stickers: ApiSticker[] = []; @@ -127,12 +121,12 @@ export async function fetchAvailableEffects() { for (const effect of effects) { if (effect.effectAnimationId) { - const document = documentsMap.get(effect.effectStickerId); + const document = documentsMap.get(effect.effectAnimationId); const emoji = document && buildStickerFromDocument(document, false, effect.isPremium); if (emoji) emojis.push(emoji); } else { - const document = localDb.documents[effect.effectStickerId]; - const sticker = buildStickerFromDocument(document); + const document = documentsMap.get(effect.effectStickerId); + const sticker = document && buildStickerFromDocument(document); if (sticker) { stickers.push(sticker); } diff --git a/src/api/gramjs/updates/entityProcessor.ts b/src/api/gramjs/updates/entityProcessor.ts index 5bbb45f3f..944a7c3cc 100644 --- a/src/api/gramjs/updates/entityProcessor.ts +++ b/src/api/gramjs/updates/entityProcessor.ts @@ -1,13 +1,13 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { - ApiChat, ApiPoll, ApiThreadInfo, ApiUser, + ApiChat, ApiMessagePoll, ApiThreadInfo, ApiUser, ApiWebPage, } from '../../types'; import { buildCollectionByKey } from '../../../util/iteratees'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; -import { buildPollFromMedia, buildWebPageFromMedia } from '../apiBuilders/messageContent'; +import { buildMessagePollFromMedia, buildWebPageFromMedia } from '../apiBuilders/messageContent'; import { buildApiThreadInfoFromMessage } from '../apiBuilders/messages'; import { buildApiUser } from '../apiBuilders/users'; import { addChatToLocalDb, addMessageToLocalDb, addUserToLocalDb } from '../helpers/localDb'; @@ -24,7 +24,7 @@ export function processAndUpdateEntities(response?: GramJs.AnyRequest['__respons let userById: Record | undefined; let chatById: Record | undefined; const threadInfos: ApiThreadInfo[] | undefined = []; - const polls: ApiPoll[] | undefined = []; + const polls: ApiMessagePoll[] | undefined = []; const webPages: ApiWebPage[] | undefined = []; if ('users' in response && Array.isArray(response.users) && TYPE_USER.has(response.users[0]?.className)) { @@ -57,7 +57,7 @@ export function processAndUpdateEntities(response?: GramJs.AnyRequest['__respons } if ('media' in message && message.media) { - const poll = buildPollFromMedia(message.media); + const poll = buildMessagePollFromMedia(message.media); if (poll) { polls.push(poll); } diff --git a/src/api/gramjs/updates/mtpUpdateHandler.ts b/src/api/gramjs/updates/mtpUpdateHandler.ts index 3f3c72448..a74529cc4 100644 --- a/src/api/gramjs/updates/mtpUpdateHandler.ts +++ b/src/api/gramjs/updates/mtpUpdateHandler.ts @@ -3,7 +3,10 @@ import { UpdateConnectionState, UpdateServerTimeOffset } from '../../../lib/gram import type { GroupCallConnectionData } from '../../../lib/secret-sauce'; import { - type ApiMessage, type ApiPoll, type ApiStory, type ApiStorySkipped, + type ApiMessage, + type ApiMessagePoll, + type ApiStory, + type ApiStorySkipped, type ApiUpdateConnectionStateType, type ApiWebPage, MAIN_THREAD_ID, @@ -11,7 +14,7 @@ import { import { DEBUG, GENERAL_TOPIC_ID } from '../../../config'; import { - omit, pick, + omit, omitUndefined, pick, } from '../../../util/iteratees'; import { getServerTimeOffset, setServerTimeOffset } from '../../../util/serverTime'; import { buildApiBotCommand, buildApiBotMenuButton } from '../apiBuilders/bots'; @@ -38,8 +41,8 @@ import { omitVirtualClassFields } from '../apiBuilders/helpers'; import { buildApiMessageExtendedMediaPreview, buildBoughtMediaContent, + buildMessagePollFromMedia, buildPoll, - buildPollFromMedia, buildPollResults, buildWebPage, buildWebPageFromMedia, @@ -134,7 +137,7 @@ export function updater(update: Update) { || update instanceof GramJs.UpdateShortMessage ) { let message: ApiMessage | undefined; - let poll: ApiPoll | undefined; + let poll: ApiMessagePoll | undefined; let webPage: ApiWebPage | undefined; let shouldForceReply: boolean | undefined; @@ -159,7 +162,7 @@ export function updater(update: Update) { message = buildApiMessage(mtpMessage)!; if (mtpMessage instanceof GramJs.Message) { - poll = mtpMessage.media && buildPollFromMedia(mtpMessage.media); + poll = mtpMessage.media && buildMessagePollFromMedia(mtpMessage.media); webPage = mtpMessage.media && buildWebPageFromMedia(mtpMessage.media); } @@ -303,7 +306,7 @@ export function updater(update: Update) { if (!message) return; const poll = update.message instanceof GramJs.Message && update.message.media - ? buildPollFromMedia(update.message.media) : undefined; + ? buildMessagePollFromMedia(update.message.media) : undefined; const webPage = update.message instanceof GramJs.Message && update.message.media ? buildWebPageFromMedia(update.message.media) : undefined; @@ -358,7 +361,7 @@ export function updater(update: Update) { const message = omit(buildApiMessage(mtpMessage)!, ['isOutgoing']) as ApiMessage; const poll = mtpMessage instanceof GramJs.Message && mtpMessage.media - ? buildPollFromMedia(mtpMessage.media) : undefined; + ? buildMessagePollFromMedia(mtpMessage.media) : undefined; const webPage = mtpMessage instanceof GramJs.Message && mtpMessage.media ? buildWebPageFromMedia(mtpMessage.media) : undefined; @@ -472,22 +475,14 @@ export function updater(update: Update) { }); } else if (update instanceof GramJs.UpdateMessagePoll) { const { pollId, poll, results } = update; - if (poll) { - const apiPoll = buildPoll(poll, results); + const apiPoll = poll && buildPoll(poll); + const pollResults = buildPollResults(results); - sendApiUpdate({ - '@type': 'updateMessagePoll', - pollId: String(pollId), - pollUpdate: apiPoll, - }); - } else { - const pollResults = buildPollResults(results); - sendApiUpdate({ - '@type': 'updateMessagePoll', - pollId: String(pollId), - pollUpdate: { results: pollResults }, - }); - } + sendApiUpdate({ + '@type': 'updateMessagePoll', + pollId: pollId.toString(), + pollUpdate: omitUndefined({ summary: apiPoll, results: pollResults }), + }); } else if (update instanceof GramJs.UpdateMessagePollVote) { sendApiUpdate({ '@type': 'updateMessagePollVote', diff --git a/src/api/types/messageActions.ts b/src/api/types/messageActions.ts index 77895c5ae..9363665b7 100644 --- a/src/api/types/messageActions.ts +++ b/src/api/types/messageActions.ts @@ -1,5 +1,5 @@ import type { ApiGroupCall, ApiPhoneCallDiscardReason } from './calls'; -import type { ApiBotApp, ApiFormattedText, ApiPhoto, ApiTodoItem } from './messages'; +import type { ApiBotApp, ApiFormattedText, ApiPhoto, ApiPollAnswer, ApiTodoItem } from './messages'; import type { ApiStarGiftRegular, ApiStarGiftUnique, ApiTypeCurrencyAmount } from './stars'; interface ActionMediaType { @@ -330,6 +330,16 @@ export interface ApiMessageActionTodoAppendTasks extends ActionMediaType { items: ApiTodoItem[]; } +export interface ApiMessageActionPollAppendAnswer extends ActionMediaType { + type: 'pollAppendAnswer'; + answer: ApiPollAnswer; +} + +export interface ApiMessageActionPollDeleteAnswer extends ActionMediaType { + type: 'pollDeleteAnswer'; + answer: ApiPollAnswer; +} + export interface ApiMessageActionStarGiftPurchaseOffer extends ActionMediaType { type: 'starGiftPurchaseOffer'; isAccepted?: true; @@ -388,6 +398,7 @@ export type ApiMessageAction = ApiMessageActionUnsupported | ApiMessageActionCha | ApiMessageActionGiftTon | ApiMessageActionPrizeStars | ApiMessageActionStarGift | ApiMessageActionStarGiftUnique | ApiMessageActionPaidMessagesRefunded | ApiMessageActionPaidMessagesPrice | ApiMessageActionSuggestedPostApproval | ApiMessageActionSuggestedPostSuccess | ApiMessageActionSuggestedPostRefund | ApiMessageActionTodoCompletions - | ApiMessageActionTodoAppendTasks | ApiMessageActionStarGiftPurchaseOffer + | ApiMessageActionTodoAppendTasks | ApiMessageActionPollAppendAnswer | ApiMessageActionPollDeleteAnswer + | ApiMessageActionStarGiftPurchaseOffer | ApiMessageActionStarGiftPurchaseOfferDeclined | ApiMessageActionNewCreatorPending | ApiMessageActionChangeCreator | ApiMessageActionNoForwardsToggle | ApiMessageActionNoForwardsRequest; diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 52304abed..1c62a19a7 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -5,7 +5,7 @@ import type { ApiWebDocument, } from './bots'; import type { ApiMessageAction } from './messageActions'; -import type { ApiPeerNotifySettings, ApiRestrictionReason } from './misc'; +import type { ApiAttachment, ApiPeerNotifySettings, ApiRestrictionReason } from './misc'; import type { ApiLabeledPrice, } from './payments'; @@ -186,6 +186,9 @@ export type ApiPaidMedia = { export interface ApiPollAnswer { text: ApiFormattedText; option: string; + media?: MediaContent; + addedByPeerId?: string; + date?: number; } export interface ApiPollResult { @@ -193,29 +196,42 @@ export interface ApiPollResult { isCorrect?: true; option: string; votersCount: number; + recentVoterIds?: string[]; } export interface ApiPoll { - mediaType: 'poll'; id: string; - summary: { - closed?: true; - isPublic?: true; - multipleChoice?: true; - quiz?: true; - question: ApiFormattedText; - answers: ApiPollAnswer[]; - closePeriod?: number; - closeDate?: number; - }; - results: { - isMin?: true; - results?: ApiPollResult[]; - totalVoters?: number; - recentVoterIds?: string[]; - solution?: string; - solutionEntities?: ApiMessageEntity[]; - }; + hash: string; + isClosed?: true; + isPublic?: true; + isMultipleChoice?: true; + isQuiz?: true; + canAddAnswers?: true; + isRevoteDisabled?: true; + shouldShuffleAnswers?: true; + shouldHideResultsUntilClose?: true; + isCreator?: true; + question: ApiFormattedText; + answers: ApiPollAnswer[]; + closePeriod?: number; + closeDate?: number; +} + +export interface ApiPollResults { + isMin?: true; + resultByOption?: Record; + totalVoters?: number; + recentVoterIds?: string[]; + solution?: string; + solutionEntities?: ApiMessageEntity[]; + solutionMedia?: MediaContent; +} + +export interface ApiMessagePoll { + mediaType: 'poll'; + summary: ApiPoll; + results: ApiPollResults; + attachedMedia?: MediaContent; } export interface ApiInvoice { @@ -339,12 +355,12 @@ export type ApiGiveawayResults = { }; export type ApiNewPoll = { - summary: ApiPoll['summary']; - quiz?: { - correctAnswers: string[]; - solution?: string; - solutionEntities?: ApiMessageEntity[]; - }; + summary: ApiPoll; + correctAnswers?: number[]; + solution?: string; + solutionEntities?: ApiMessageEntity[]; + attachedMedia?: ApiAttachment; + solutionMedia?: ApiAttachment; }; export interface ApiTodoItem { @@ -666,7 +682,7 @@ export type MediaContainer = { }; export type StatefulMediaContent = { - poll?: ApiPoll; + poll?: ApiMessagePoll; story?: ApiStory; webPage?: ApiWebPage; }; diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 68e826b21..46295549a 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -24,9 +24,9 @@ import type { ApiFormattedText, ApiMediaExtendedPreview, ApiMessage, + ApiMessagePoll, ApiPaidReactionPrivacyType, ApiPhoto, - ApiPoll, ApiQuickReply, ApiReaction, ApiReactions, @@ -237,7 +237,7 @@ export type ApiUpdateNewScheduledMessage = { id: number; message: ApiMessage; wasDrafted?: boolean; - poll?: ApiPoll; + poll?: ApiMessagePoll; webPage?: ApiWebPage; }; @@ -248,7 +248,7 @@ export type ApiUpdateNewMessage = { message: ApiMessage; shouldForceReply?: boolean; wasDrafted?: boolean; - poll?: ApiPoll; + poll?: ApiMessagePoll; webPage?: ApiWebPage; }; @@ -256,7 +256,7 @@ export type ApiUpdateMessage = { '@type': 'updateMessage'; chatId: string; id: number; - poll?: ApiPoll; + poll?: ApiMessagePoll; webPage?: ApiWebPage; shouldForceReply?: boolean; isFromNew?: true; @@ -274,7 +274,7 @@ export type ApiUpdateScheduledMessage = { '@type': 'updateScheduledMessage'; chatId: string; id: number; - poll?: ApiPoll; + poll?: ApiMessagePoll; webPage?: ApiWebPage; isFromNew?: true; } & ( @@ -291,7 +291,7 @@ export type ApiUpdateQuickReplyMessage = { '@type': 'updateQuickReplyMessage'; id: number; message: Partial; - poll?: ApiPoll; + poll?: ApiMessagePoll; webPage?: ApiWebPage; }; @@ -335,7 +335,7 @@ export type ApiUpdateScheduledMessageSendSucceeded = { chatId: string; localId: number; message: ApiMessage; - poll?: ApiPoll; + poll?: ApiMessagePoll; webPage?: ApiWebPage; }; @@ -344,7 +344,7 @@ export type ApiUpdateMessageSendSucceeded = { chatId: string; localId: number; message: ApiMessage; - poll?: ApiPoll; + poll?: ApiMessagePoll; webPage?: ApiWebPage; }; @@ -385,7 +385,7 @@ export type ApiUpdateChannelMessages = { export type ApiUpdateMessagePoll = { '@type': 'updateMessagePoll'; pollId: string; - pollUpdate: Partial; + pollUpdate: Partial; }; export type ApiUpdateMessagePollVote = { @@ -895,7 +895,7 @@ export type ApiUpdateEntities = { users?: Record; chats?: Record; threadInfos?: ApiThreadInfo[]; - polls?: ApiPoll[]; + polls?: ApiMessagePoll[]; webPages?: ApiWebPage[]; }; diff --git a/src/assets/font-icons/previous-link.svg b/src/assets/font-icons/previous-link.svg new file mode 100644 index 000000000..781349063 --- /dev/null +++ b/src/assets/font-icons/previous-link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 1bd0c7508..11efd9b9d 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -810,13 +810,27 @@ "CallAgain" = "Call Again"; "CallBack" = "Call Back"; "CallMessageWithDuration" = "{time} ({duration})"; -"PollSubmitVotes" = "VOTE"; -"PollViewResults" = "VIEW RESULTS"; +"PollSubmitVotes" = "Vote"; +"PollSubmitAnswers" = "Answer"; +"PollViewResults" = "View Results"; +"PollBackToVote" = "< Vote"; +"PollBackToAnswer" = "< Answer"; +"PollVoteCountButton_one" = "{count} vote >"; +"PollVoteCountButton_other" = "{count} votes >"; +"PollAnswerCountButton_one" = "{count} answered >"; +"PollAnswerCountButton_other" = "{count} answered >"; +"PollEndsTime" = "ends {time}"; +"PollResultsTime" = "results {time}"; "ChatQuizTotalVotesEmpty" = "No answers yet"; "ChatPollTotalVotesResultEmpty" = "No votes"; "Answer_one" = "{count} answer"; "Answer_other" = "{count} answers"; +"PollAnsweredCount_one" = "{count} answered"; +"PollAnsweredCount_other" = "{count} answered"; "Vote" = "Vote"; +"VoteCount_one" = "{count} vote"; +"VoteCount_other" = "{count} votes"; +"TimeIn" = "in {time}"; "MessageRecommendedLabel" = "recommended"; "SponsoredMessageAd" = "Ad"; "SponsoredMessageAdWhatIsThis" = "what's this?"; @@ -1726,6 +1740,7 @@ "StarsSubscribeInfoLink" = "https://telegram.org/tos/stars"; "StarsPerMonth" = "⭐️{amount}/month"; "StarsBalance" = "Balance"; +"OpenMapWith" = "Open Map in"; "OpenApp" = "Open App"; "PopularApps" = "Popular Apps"; "SearchApps" = "Search Apps"; @@ -2376,6 +2391,10 @@ "MessageActionAppendTodoYou" = "You added a new task \"{task}\" to {list}"; "MessageActionAppendTodoMultiple" = "{peer} added {tasks} to {list}"; "MessageActionAppendTodoMultipleYou" = "You added {tasks} to {list}"; +"MessageActionPollAppendAnswer" = "{peer} added \"{option}\" to the poll."; +"MessageActionPollAppendAnswerYou" = "You added \"{option}\" to the poll."; +"MessageActionPollDeleteAnswer" = "{peer} removed \"{option}\" from the poll."; +"MessageActionPollDeleteAnswerYou" = "You removed \"{option}\" from the poll."; "PremiumMore" = "More"; "SubscribeToTelegramPremiumForToggleTask" = "Subscribe to **Telegram Premium** to toggle tasks"; "SubscribeToTelegramPremiumForCreateToDo" = "Subscribe to **Telegram Premium** to create Checklists"; diff --git a/src/components/common/AnimatedCounter.module.scss b/src/components/common/AnimatedCounter.module.scss index b3d836fc4..ad8dd4fde 100644 --- a/src/components/common/AnimatedCounter.module.scss +++ b/src/components/common/AnimatedCounter.module.scss @@ -5,6 +5,7 @@ $animation-time: 200ms; .root { display: inline-flex; + font-variant-numeric: tabular-nums; white-space: pre; &[dir="rtl"] { diff --git a/src/components/common/CompactMapPreview.module.scss b/src/components/common/CompactMapPreview.module.scss new file mode 100644 index 000000000..e16000428 --- /dev/null +++ b/src/components/common/CompactMapPreview.module.scss @@ -0,0 +1,36 @@ +.root { + position: relative; + + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + + vertical-align: top; +} + +.interactive { + cursor: var(--custom-cursor, pointer); +} + +.map, +.skeleton { + width: 100%; + height: 100%; +} + +.map { + object-fit: cover; +} + +.pin { + pointer-events: none; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -100%); + + width: 1rem; + height: 1rem; +} diff --git a/src/components/common/CompactMapPreview.tsx b/src/components/common/CompactMapPreview.tsx new file mode 100644 index 000000000..2062b2d6c --- /dev/null +++ b/src/components/common/CompactMapPreview.tsx @@ -0,0 +1,63 @@ +import { memo } from '../../lib/teact/teact'; + +import type { ApiGeoPoint } from '../../api/types'; + +import { buildStaticMapHash } from '../../global/helpers'; +import buildClassName from '../../util/buildClassName'; + +import useMedia from '../../hooks/useMedia'; +import useDevicePixelRatio from '../../hooks/window/useDevicePixelRatio'; + +import Skeleton from '../ui/placeholder/Skeleton'; + +import styles from './CompactMapPreview.module.scss'; + +import mapPin from '../../assets/map-pin.svg'; + +type OwnProps = { + className?: string; + geo: ApiGeoPoint; + width: number; + height: number; + zoom?: number; + shouldShowPin?: boolean; + onClick?: React.MouseEventHandler; +}; + +const DEFAULT_ZOOM = 15; + +const CompactMapPreview = ({ + className, + geo, + width, + height, + zoom = DEFAULT_ZOOM, + shouldShowPin = true, + onClick, +}: OwnProps) => { + const dpr = useDevicePixelRatio(); + const mediaHash = buildStaticMapHash(geo, width, height, zoom, dpr); + const mapBlobUrl = useMedia(mediaHash); + + return ( +
+ {mapBlobUrl ? ( + + ) : ( + + )} + {shouldShowPin && } +
+ ); +}; + +export default memo(CompactMapPreview); diff --git a/src/components/common/CompactMediaPreview.module.scss b/src/components/common/CompactMediaPreview.module.scss new file mode 100644 index 000000000..44fd58403 --- /dev/null +++ b/src/components/common/CompactMediaPreview.module.scss @@ -0,0 +1,82 @@ +.root { + position: relative; + overflow: hidden; + display: block; + flex-shrink: 0; +} + +.pictogram { + width: 2rem; + height: 2rem; +} + +.interactive { + cursor: var(--custom-cursor, pointer); +} + +.round { + border-radius: 50%; +} + +.media, +.thumb { + position: absolute; + top: 0; + left: 0; + + display: block; + + width: 100%; + height: 100%; +} + +.media { + object-fit: cover; +} + +.withActionIcon { + &::after { + content: ''; + + position: absolute; + z-index: 1; + top: 0; + left: 0; + + width: 100%; + height: 100%; + + opacity: 0; + background-color: rgba(0, 0, 0, 0.5); + + transition: opacity 0.15s; + } + + &:hover::after { + opacity: 1; + } + + &:hover .actionIcon { + opacity: 1; + } +} + +.protector { + pointer-events: none; +} + +.actionIcon { + pointer-events: none; + + position: absolute; + z-index: 3; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + color: white; + + opacity: 0; + + transition: opacity 0.15s; +} diff --git a/src/components/common/CompactMediaPreview.tsx b/src/components/common/CompactMediaPreview.tsx new file mode 100644 index 000000000..749d8e2d6 --- /dev/null +++ b/src/components/common/CompactMediaPreview.tsx @@ -0,0 +1,254 @@ +import type React from '../../lib/teact/teact'; +import { memo, useRef } from '../../lib/teact/teact'; + +import type { + ApiAttachment, + ApiDocument, + ApiPhoto, + ApiVideo, + MediaContent, +} from '../../api/types'; +import type { ObserveFn } from '../../hooks/useIntersectionObserver'; +import type { IconName } from '../../types/icons'; + +import { + getDocumentMediaHash, + getMediaThumbUri, + getPhotoMediaHash, + getVideoMediaHash, +} from '../../global/helpers'; +import { IS_CANVAS_FILTER_SUPPORTED } from '../../util/browser/windowEnvironment'; +import buildClassName from '../../util/buildClassName'; +import { REM } from './helpers/mediaDimensions'; + +import useAppLayout from '../../hooks/useAppLayout'; +import useCanvasBlur from '../../hooks/useCanvasBlur'; +import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; +import useMedia from '../../hooks/useMedia'; +import useMediaTransition from '../../hooks/useMediaTransition'; + +import OptimizedVideo from '../ui/OptimizedVideo'; +import Icon from './icons/Icon'; +import MediaSpoiler from './MediaSpoiler'; + +import styles from './CompactMediaPreview.module.scss'; + +const PICTOGRAM_SIZE = 2 * REM; + +type OwnProps = { + id?: string; + className?: string; + media?: MediaContent; + attachment?: ApiAttachment; + size?: number; + isPictogram?: boolean; + isRound?: boolean; + isProtected?: boolean; + isSpoiler?: boolean; + actionIcon?: IconName; + observeIntersectionForLoading?: ObserveFn; + observeIntersectionForPlaying?: ObserveFn; + onClick?: React.MouseEventHandler; +}; + +export function canRenderCompactMediaPreview( + media?: MediaContent, + attachment?: ApiAttachment, +) { + const photo = media?.photo; + const document = media?.document; + const previewUrl = getPreviewUrl(photo, document, media?.video, attachment); + const video = media?.video || attachment?.gif; + const shouldRenderPreviewAsVideo = shouldUseVideoPreview(video, previewUrl); + const previewVideoUrl = shouldRenderPreviewAsVideo + ? (media?.video?.blobUrl || attachment?.blobUrl) + : undefined; + const previewHash = getPreviewHash(photo, document, video, shouldRenderPreviewAsVideo); + + return Boolean( + previewUrl + || previewVideoUrl + || previewHash + || getThumbDataUri(photo, document, media?.video, attachment), + ); +} + +const CompactMediaPreview = ({ + id, + className, + media, + attachment, + size, + isPictogram, + isRound, + isProtected, + isSpoiler, + actionIcon, + observeIntersectionForLoading, + observeIntersectionForPlaying, + onClick, +}: OwnProps) => { + const ref = useRef(); + const { isMobile } = useAppLayout(); + + const previewSize = size || (isPictogram ? PICTOGRAM_SIZE : undefined); + const photo = media?.photo; + const document = media?.document; + const video = media?.video || attachment?.gif; + const previewUrl = getPreviewUrl(photo, document, media?.video, attachment); + const shouldRenderPreviewAsVideo = shouldUseVideoPreview(video, previewUrl); + + const isIntersectingForLoading = useIsIntersecting(ref, observeIntersectionForLoading); + const isIntersectingForPlaying = ( + useIsIntersecting(ref, observeIntersectionForPlaying) + && isIntersectingForLoading + ); + + const previewHash = getPreviewHash(photo, document, video, shouldRenderPreviewAsVideo); + + const previewVideoUrl = shouldRenderPreviewAsVideo + ? (media?.video?.blobUrl || attachment?.blobUrl) + : undefined; + const thumbDataUri = getThumbDataUri(photo, document, media?.video, attachment); + + const fetchedPreviewUrl = useMedia( + previewHash, + Boolean(!previewHash || previewUrl || previewVideoUrl || !isIntersectingForLoading), + ); + + const resolvedPreviewUrl = previewUrl || (!shouldRenderPreviewAsVideo ? fetchedPreviewUrl : undefined); + const resolvedPreviewVideoUrl = previewVideoUrl || (shouldRenderPreviewAsVideo ? fetchedPreviewUrl : undefined); + const shouldShowSpoiler = isSpoiler ?? photo?.isSpoiler ?? media?.video?.isSpoiler ?? attachment?.shouldSendAsSpoiler; + + const hasResolvedMedia = Boolean(resolvedPreviewUrl || resolvedPreviewVideoUrl); + const shouldShowCanvasThumb = Boolean(thumbDataUri && (shouldShowSpoiler || !hasResolvedMedia)); + const canvasRef = useCanvasBlur( + thumbDataUri, + !thumbDataUri || !shouldShowCanvasThumb, + isMobile && !IS_CANVAS_FILTER_SUPPORTED, + undefined, + previewSize, + previewSize, + ); + useMediaTransition({ + ref: canvasRef, + hasMediaData: shouldShowCanvasThumb, + }); + + const { ref: imageRef } = useMediaTransition({ + hasMediaData: Boolean(resolvedPreviewUrl && !shouldShowSpoiler), + }); + const { ref: videoRef } = useMediaTransition({ + hasMediaData: Boolean(resolvedPreviewVideoUrl && !shouldShowSpoiler), + }); + + const spoilerThumbDataUri = thumbDataUri || resolvedPreviewUrl; + const style = previewSize ? `width: ${previewSize}px; height: ${previewSize}px` : undefined; + + return ( +
+ {thumbDataUri && ( + + )} + {!shouldShowSpoiler && resolvedPreviewUrl && ( + + )} + {!shouldShowSpoiler && resolvedPreviewVideoUrl && ( + + )} + + {isProtected && } + {actionIcon && } +
+ ); +}; + +function getThumbDataUri( + photo?: ApiPhoto, + document?: ApiDocument, + video?: ApiVideo, + attachment?: ApiAttachment, +) { + return (photo && getMediaThumbUri(photo)) + || (document && getMediaThumbUri(document)) + || (video && getMediaThumbUri(video)) + || attachment?.previewBlobUrl + || (attachment?.mimeType.startsWith('image/') ? attachment.blobUrl : undefined); +} + +function getPreviewUrl( + photo?: ApiPhoto, + document?: ApiDocument, + video?: ApiVideo, + attachment?: ApiAttachment, +) { + return photo?.blobUrl + || document?.previewBlobUrl + || video?.previewBlobUrl + || attachment?.previewBlobUrl + || (attachment?.mimeType.startsWith('image/') ? attachment.blobUrl : undefined); +} + +function shouldUseVideoPreview(video: ApiVideo | undefined, previewUrl?: string) { + return Boolean(video?.isGif && !video.previewPhotoSizes?.length && !previewUrl); +} + +function getPreviewHash( + photo?: ApiPhoto, + document?: ApiDocument, + video?: ApiVideo, + shouldRenderPreviewAsVideo?: boolean, +) { + if (photo) { + return getPhotoMediaHash(photo, 'pictogram'); + } + + if (document) { + return getDocumentMediaHash(document, 'pictogram'); + } + + if (video) { + return getVideoMediaHash(video, shouldRenderPreviewAsVideo ? 'full' : 'pictogram'); + } + + return undefined; +} + +export default memo(CompactMediaPreview); diff --git a/src/components/common/Document.tsx b/src/components/common/Document.tsx index 1b8538857..406681676 100644 --- a/src/components/common/Document.tsx +++ b/src/components/common/Document.tsx @@ -1,15 +1,14 @@ import { - memo, useEffect, useRef, useState, + memo, useEffect, useMemo, useRef, useState, } from '../../lib/teact/teact'; import { getActions } from '../../global'; -import type { ApiDocument, ApiMessage } from '../../api/types'; +import type { ApiDocument, ApiMessage, MediaContent } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import { getDocumentMediaHash, getMediaFormat, - getMediaThumbUri, getMediaTransferState, isDocumentVideo, } from '../../global/helpers'; @@ -20,7 +19,6 @@ import { preloadDocumentMedia } from './helpers/preloadDocumentMedia'; import useFlag from '../../hooks/useFlag'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import useLastCallback from '../../hooks/useLastCallback'; -import useMedia from '../../hooks/useMedia'; import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress'; import useOldLang from '../../hooks/useOldLang'; @@ -117,9 +115,10 @@ const Document = ({ ); const hasPreview = getDocumentHasPreview(document); - const thumbDataUri = hasPreview ? getMediaThumbUri(document) : undefined; - const localBlobUrl = hasPreview ? document.previewBlobUrl : undefined; - const previewData = useMedia(getDocumentMediaHash(document, 'pictogram'), !isIntersecting); + const previewMedia = useMemo( + () => (hasPreview ? { document } : undefined), + [document, hasPreview], + ); const shouldForceDownload = document.innerMediaType === 'photo' && document.mediaSize && !document.mediaSize.fromDocumentAttribute && !document.mediaSize.fromPreload; @@ -199,8 +198,8 @@ const Document = ({ extension={extension} size={size} timestamp={datetime} - thumbnailDataUri={thumbDataUri} - previewData={localBlobUrl || previewData} + previewMedia={previewMedia} + observeIntersection={observeIntersection} previewSize={fileSize} isTransferring={isTransferring} isUploading={isUploading} diff --git a/src/components/common/File.tsx b/src/components/common/File.tsx index 68820e4e5..4e48acf40 100644 --- a/src/components/common/File.tsx +++ b/src/components/common/File.tsx @@ -1,27 +1,26 @@ import type { ElementRef } from '../../lib/teact/teact'; import { - memo, useRef, useState, + memo, useRef, } from '../../lib/teact/teact'; +import type { ApiAttachment, MediaContent } from '../../api/types'; +import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { IconName } from '../../types/icons'; -import { IS_CANVAS_FILTER_SUPPORTED } from '../../util/browser/windowEnvironment'; import buildClassName from '../../util/buildClassName'; import { formatMediaDateTime, formatPastTimeShort } from '../../util/dates/oldDateFormat'; import { getColorFromExtension } from './helpers/documentInfo'; import { getDocumentThumbnailDimensions } from './helpers/mediaDimensions'; import renderText from './helpers/renderText'; -import useAppLayout from '../../hooks/useAppLayout'; -import useCanvasBlur from '../../hooks/useCanvasBlur'; import useLang from '../../hooks/useLang'; -import useMediaTransitionDeprecated from '../../hooks/useMediaTransitionDeprecated'; import useOldLang from '../../hooks/useOldLang'; import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated'; import Link from '../ui/Link'; import ProgressSpinner from '../ui/ProgressSpinner'; import AnimatedFileSize from './AnimatedFileSize'; +import CompactMediaPreview, { canRenderCompactMediaPreview } from './CompactMediaPreview'; import Icon from './icons/Icon'; import './File.scss'; @@ -36,8 +35,9 @@ type OwnProps = { size: number; timestamp?: number; sender?: string; - thumbnailDataUri?: string; - previewData?: string; + previewMedia?: MediaContent; + previewAttachment?: ApiAttachment; + observeIntersection?: ObserveFn; className?: string; previewSize?: FileSize; isTransferring?: boolean; @@ -58,8 +58,8 @@ const File = ({ extension = '', timestamp, sender, - thumbnailDataUri, - previewData, + previewMedia, + previewAttachment, className, previewSize = 'medium', isTransferring, @@ -68,6 +68,7 @@ const File = ({ isSelected, transferProgress, actionIcon, + observeIntersection, onClick, onDateClick, }: OwnProps) => { @@ -78,12 +79,6 @@ const File = ({ elementRef = ref; } - const { isMobile } = useAppLayout(); - const [withThumb] = useState(!previewData); - const noThumb = Boolean(previewData); - const thumbRef = useCanvasBlur(thumbnailDataUri, noThumb, isMobile && !IS_CANVAS_FILTER_SUPPORTED); - const thumbClassNames = useMediaTransitionDeprecated(!noThumb); - const { shouldRender: shouldSpinnerRender, transitionClassNames: spinnerClassNames, @@ -91,7 +86,8 @@ const File = ({ const color = getColorFromExtension(extension); - const { width, height } = getDocumentThumbnailDimensions(previewSize); + const { width } = getDocumentThumbnailDimensions(previewSize); + const shouldRenderPreview = canRenderCompactMediaPreview(previewMedia, previewAttachment); const fullClassName = buildClassName( 'File', @@ -109,23 +105,14 @@ const File = ({ )}
- {thumbnailDataUri || previewData ? ( -
- - {withThumb && ( - - )} -
+ {shouldRenderPreview ? ( + ) : (
{extension.length <= 4 && ( diff --git a/src/components/common/MessageSummary.tsx b/src/components/common/MessageSummary.tsx index df2b65f3e..192f336d7 100644 --- a/src/components/common/MessageSummary.tsx +++ b/src/components/common/MessageSummary.tsx @@ -2,7 +2,7 @@ import { memo } from '../../lib/teact/teact'; import { withGlobal } from '../../global'; import type { - ApiFormattedText, ApiMessage, ApiPoll, ApiTypeStory, + ApiFormattedText, ApiMessage, ApiMessagePoll, ApiTypeStory, ApiWebPage, } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; @@ -42,7 +42,7 @@ type OwnProps = { }; type StateProps = { - poll?: ApiPoll; + poll?: ApiMessagePoll; story?: ApiTypeStory; webPage?: ApiWebPage; }; diff --git a/src/components/common/embedded/EmbeddedMessage.scss b/src/components/common/embedded/EmbeddedMessage.scss index 490b73190..d6a4ee8e9 100644 --- a/src/components/common/embedded/EmbeddedMessage.scss +++ b/src/components/common/embedded/EmbeddedMessage.scss @@ -238,58 +238,6 @@ width: 2rem; height: 2rem; border-radius: 0.25rem; - - &.round { - border-radius: 1rem; - } - - &.with-action-icon { - &::after { - content: ''; - - position: absolute; - top: 0; - left: 0; - - width: 100%; - height: 100%; - - opacity: 0; - background-color: rgba(0, 0, 0, 0.5); - - transition: opacity 0.15s; - } - - &:hover::after { - opacity: 1; - } - - &:hover .pictogram-action-icon { - opacity: 1; - } - } - } - - .pictogram { - width: 100%; - height: 100%; - object-fit: cover; - } - - .pictogram-action-icon { - pointer-events: none; - - position: absolute; - z-index: 1; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - - color: white; - - opacity: 0; - - transition: opacity 0.15s; } &.inside-input { diff --git a/src/components/common/embedded/EmbeddedMessage.tsx b/src/components/common/embedded/EmbeddedMessage.tsx index 412635d40..6e866d559 100644 --- a/src/components/common/embedded/EmbeddedMessage.tsx +++ b/src/components/common/embedded/EmbeddedMessage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from '../../../lib/teact/teact'; +import { useMemo } from '../../../lib/teact/teact'; import type { ApiChat, @@ -23,21 +23,16 @@ import buildClassName from '../../../util/buildClassName'; import { formatScheduledDateTime } from '../../../util/dates/oldDateFormat'; import { isUserId } from '../../../util/entities/ids'; import { formatStarsAsIcon, formatTonAsIcon } from '../../../util/localization/format'; -import { getPictogramDimensions } from '../helpers/mediaDimensions'; import renderText from '../helpers/renderText'; import { renderTextWithEntities } from '../helpers/renderTextWithEntities'; -import useMessageMediaHash from '../../../hooks/media/useMessageMediaHash'; -import useThumbnail from '../../../hooks/media/useThumbnail'; -import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import useLang from '../../../hooks/useLang'; -import useMedia from '../../../hooks/useMedia'; import useOldLang from '../../../hooks/useOldLang'; import useMessageTranslation from '../../middle/message/hooks/useMessageTranslation'; import RippleEffect from '../../ui/RippleEffect'; +import CompactMediaPreview, { canRenderCompactMediaPreview } from '../CompactMediaPreview'; import Icon from '../icons/Icon'; -import MediaSpoiler from '../MediaSpoiler'; import MessageSummary from '../MessageSummary'; import PeerColorWrapper from '../PeerColorWrapper'; @@ -96,9 +91,6 @@ const EmbeddedMessage = ({ onClick, onPictogramClick, }: OwnProps) => { - const ref = useRef(); - const isIntersecting = useIsIntersecting(ref, observeIntersectionForLoading); - const containedMedia: MediaContainer | undefined = useMemo(() => { const media = (replyInfo?.type === 'message' && replyInfo.replyMedia) || message?.content; if (!media) { @@ -109,13 +101,7 @@ const EmbeddedMessage = ({ content: media, }; }, [message, replyInfo]); - - const gif = containedMedia?.content?.video?.isGif ? containedMedia.content.video : undefined; - const isVideoThumbnail = Boolean(gif && !gif.previewPhotoSizes?.length); - - const mediaHash = useMessageMediaHash(containedMedia, isVideoThumbnail ? 'full' : 'pictogram'); - const mediaBlobUrl = useMedia(mediaHash, !isIntersecting); - const mediaThumbnail = useThumbnail(containedMedia); + const hasPictogram = canRenderCompactMediaPreview(containedMedia?.content); const isRoundVideo = Boolean(containedMedia && getMessageRoundVideo(containedMedia)); const isSpoiler = Boolean(containedMedia && getMessageIsSpoiler(containedMedia)) || isMediaNsfw; @@ -206,7 +192,7 @@ const EmbeddedMessage = ({ return (
- {mediaThumbnail && renderPictogram({ - thumbDataUri: mediaThumbnail, - blobUrl: mediaBlobUrl, - isFullVideo: isVideoThumbnail, - isRoundVideo, - isProtected, - isSpoiler, - pictogramActionIcon, - onPictogramClick, - })} + {hasPictogram && ( + + )}

{renderTextContent()} @@ -342,65 +331,4 @@ const EmbeddedMessage = ({ ); }; -function renderPictogram({ - thumbDataUri, - blobUrl, - isFullVideo, - isRoundVideo, - isProtected, - isSpoiler, - pictogramActionIcon, - onPictogramClick, -}: { - thumbDataUri: string; - blobUrl?: string; - isFullVideo?: boolean; - isRoundVideo?: boolean; - isProtected?: boolean; - isSpoiler?: boolean; - pictogramActionIcon?: IconName; - onPictogramClick?: ((e: React.MouseEvent) => void); -}) { - const { width, height } = getPictogramDimensions(); - - const srcUrl = blobUrl || thumbDataUri; - const shouldRenderVideo = isFullVideo && blobUrl; - - return ( -

- {!isSpoiler && !shouldRenderVideo && ( - - )} - {!isSpoiler && shouldRenderVideo && ( -
- ); -} - export default EmbeddedMessage; diff --git a/src/components/common/embedded/EmbeddedStory.tsx b/src/components/common/embedded/EmbeddedStory.tsx index 0d12c3f5a..6b1b775fb 100644 --- a/src/components/common/embedded/EmbeddedStory.tsx +++ b/src/components/common/embedded/EmbeddedStory.tsx @@ -1,24 +1,18 @@ import type { FC } from '../../../lib/teact/teact'; -import { useRef } from '../../../lib/teact/teact'; import { getActions } from '../../../global'; import type { ApiPeer, ApiTypeStory } from '../../../api/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; -import { - getStoryMediaHash, -} from '../../../global/helpers'; import { getPeerTitle } from '../../../global/helpers/peers'; import buildClassName from '../../../util/buildClassName'; -import { getPictogramDimensions } from '../helpers/mediaDimensions'; import renderText from '../helpers/renderText'; import { useFastClick } from '../../../hooks/useFastClick'; -import { useIsIntersecting } from '../../../hooks/useIntersectionObserver'; import useLastCallback from '../../../hooks/useLastCallback'; -import useMedia from '../../../hooks/useMedia'; import useOldLang from '../../../hooks/useOldLang'; +import CompactMediaPreview, { canRenderCompactMediaPreview } from '../CompactMediaPreview'; import Icon from '../icons/Icon'; import PeerColorWrapper from '../PeerColorWrapper'; @@ -30,6 +24,7 @@ type OwnProps = { noUserColors?: boolean; isProtected?: boolean; observeIntersectionForLoading?: ObserveFn; + observeIntersectionForPlaying?: ObserveFn; onClick: NoneToVoidFunction; }; @@ -41,22 +36,17 @@ const EmbeddedStory: FC = ({ noUserColors, isProtected, observeIntersectionForLoading, + observeIntersectionForPlaying, onClick, }) => { const { showNotification } = getActions(); const lang = useOldLang(); - - const ref = useRef(); - const isIntersecting = useIsIntersecting(ref, observeIntersectionForLoading); const isFullStory = story && 'content' in story; const isExpiredStory = story && 'isDeleted' in story; - const isVideoStory = isFullStory && Boolean(story.content.video); const title = isFullStory ? 'Story' : (isExpiredStory ? 'ExpiredStory' : 'Loading'); - const mediaBlobUrl = useMedia(isFullStory && getStoryMediaHash(story, 'pictogram'), !isIntersecting); - const mediaThumbnail = isVideoStory ? story.content.video!.thumbnail?.dataUri : undefined; - const pictogramUrl = mediaBlobUrl || mediaThumbnail; + const hasPictogram = isFullStory && canRenderCompactMediaPreview(story.content); const senderTitle = sender ? getPeerTitle(lang, sender) : undefined; const handleFastClick = useLastCallback(() => { @@ -73,18 +63,26 @@ const EmbeddedStory: FC = ({ return ( - {pictogramUrl && renderPictogram(pictogramUrl, isProtected)} + {isFullStory && hasPictogram && ( + + )}

{isExpiredStory && ( @@ -101,25 +99,4 @@ const EmbeddedStory: FC = ({ ); }; -function renderPictogram( - srcUrl: string, - isProtected?: boolean, -) { - const { width, height } = getPictogramDimensions(); - - return ( -

- - {isProtected && } -
- ); -} - export default EmbeddedStory; diff --git a/src/components/common/helpers/mediaDimensions.ts b/src/components/common/helpers/mediaDimensions.ts index 7fe187aff..72087ba62 100644 --- a/src/components/common/helpers/mediaDimensions.ts +++ b/src/components/common/helpers/mediaDimensions.ts @@ -60,11 +60,11 @@ function getMaxMessageWidthRem(fromOwnMessage?: boolean, noAvatars?: boolean, is export function getAvailableWidth( fromOwnMessage?: boolean, - isWebPageMedia?: boolean, + isNestedMedia?: boolean, noAvatars?: boolean, isMobile?: boolean, ) { - const extraPaddingRem = isWebPageMedia ? 1.625 : 0; + const extraPaddingRem = isNestedMedia ? 2.125 : 0; const availableWidthRem = getMaxMessageWidthRem(fromOwnMessage, noAvatars, isMobile) - extraPaddingRem; return availableWidthRem * REM; @@ -85,7 +85,7 @@ export function calculateDimensionsForMessageMedia({ width, height, fromOwnMessage, - isWebPageMedia, + isNestedMedia, isGif, noAvatars, isMobile, @@ -94,13 +94,13 @@ export function calculateDimensionsForMessageMedia({ height: number; fromOwnMessage?: boolean; asForwarded?: boolean; - isWebPageMedia?: boolean; + isNestedMedia?: boolean; isGif?: boolean; noAvatars?: boolean; isMobile?: boolean; }): ApiDimensions { const aspectRatio = height / width; - const availableWidth = getAvailableWidth(fromOwnMessage, isWebPageMedia, noAvatars, isMobile); + const availableWidth = getAvailableWidth(fromOwnMessage, isNestedMedia, noAvatars, isMobile); const availableHeight = getAvailableHeight(isGif, aspectRatio); const mediaWidth = isGif ? Math.max(GIF_MIN_WIDTH, width) : width; const mediaHeight = isGif ? height * (mediaWidth / width) : height; @@ -125,7 +125,7 @@ export function calculateInlineImageDimensions( photo: ApiPhoto, fromOwnMessage?: boolean, asForwarded?: boolean, - isWebPageMedia?: boolean, + isNestedMedia?: boolean, noAvatars?: boolean, isMobile?: boolean, ) { @@ -136,7 +136,7 @@ export function calculateInlineImageDimensions( height, fromOwnMessage, asForwarded, - isWebPageMedia, + isNestedMedia, noAvatars, isMobile, }); @@ -146,7 +146,7 @@ export function calculateVideoDimensions( video: ApiVideo, fromOwnMessage?: boolean, asForwarded?: boolean, - isWebPageMedia?: boolean, + isNestedMedia?: boolean, noAvatars?: boolean, isMobile?: boolean, ) { @@ -157,7 +157,7 @@ export function calculateVideoDimensions( height, fromOwnMessage, asForwarded, - isWebPageMedia, + isNestedMedia, isGif: video.isGif, noAvatars, isMobile, @@ -168,7 +168,7 @@ export function calculateExtendedPreviewDimensions( preview: ApiMediaExtendedPreview, fromOwnMessage?: boolean, asForwarded?: boolean, - isWebPageMedia?: boolean, + isNestedMedia?: boolean, noAvatars?: boolean, isMobile?: boolean, ) { @@ -179,19 +179,12 @@ export function calculateExtendedPreviewDimensions( height, fromOwnMessage, asForwarded, - isWebPageMedia, + isNestedMedia, noAvatars, isMobile, }); } -export function getPictogramDimensions(): ApiDimensions { - return { - width: 2 * REM, - height: 2 * REM, - }; -} - export function getDocumentThumbnailDimensions( size: 'small' | 'medium' | 'large' = 'medium', ): ApiDimensions { diff --git a/src/components/common/profile/ChatExtra.tsx b/src/components/common/profile/ChatExtra.tsx index 481cd71f7..2dcaaf267 100644 --- a/src/components/common/profile/ChatExtra.tsx +++ b/src/components/common/profile/ChatExtra.tsx @@ -18,7 +18,6 @@ import { FRAGMENT_PHONE_CODE, FRAGMENT_PHONE_LENGTH, MUTE_INDEFINITE_TIMESTAMP, UNMUTE_TIMESTAMP, } from '../../../config'; import { - buildStaticMapHash, getChatLink, getHasAdminRight, isChatAdmin, @@ -56,15 +55,13 @@ import useCollapsibleLines from '../../../hooks/element/useCollapsibleLines'; import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; -import useMedia from '../../../hooks/useMedia'; import useOldLang from '../../../hooks/useOldLang'; -import useDevicePixelRatio from '../../../hooks/window/useDevicePixelRatio'; import Chat from '../../left/main/Chat'; import Button from '../../ui/Button'; import ListItem from '../../ui/ListItem'; -import Skeleton from '../../ui/placeholder/Skeleton'; import Switcher from '../../ui/Switcher'; +import CompactMapPreview from '../CompactMapPreview'; import CustomEmoji from '../CustomEmoji'; import Icon from '../icons/Icon'; import SafeLink from '../SafeLink'; @@ -191,19 +188,18 @@ const ChatExtra = ({ }, [peerId, chat, user]); const { width, height, zoom } = DEFAULT_MAP_CONFIG; - const dpr = useDevicePixelRatio(); - const locationMediaHash = businessLocation?.geo - && buildStaticMapHash(businessLocation.geo, width, height, zoom, dpr); - const locationBlobUrl = useMedia(locationMediaHash); - const locationRightComponent = useMemo(() => { if (!businessLocation?.geo) return undefined; - if (locationBlobUrl) { - return ; - } - - return ; - }, [businessLocation, locationBlobUrl]); + return ( + + ); + }, [businessLocation, width, height, zoom]); const isTopicInfo = Boolean(topicId && topicId !== MAIN_THREAD_ID); const shouldRenderAllLinks = (chat && isChatChannel(chat)) || user?.isPremium; diff --git a/src/components/gili/primitives/Checkbox.module.scss b/src/components/gili/primitives/Checkbox.module.scss index 7c25ddaa5..5be8ddb0d 100644 --- a/src/components/gili/primitives/Checkbox.module.scss +++ b/src/components/gili/primitives/Checkbox.module.scss @@ -2,34 +2,72 @@ .root { cursor: var(--custom-cursor, pointer); + position: relative; + + display: grid; flex-shrink: 0; + place-items: center; width: 1.25rem; height: 1.25rem; margin: 0; - border: 0.125rem solid var(--color-borders-input); + border: 0.125rem solid var(--ui-border-color, var(--color-borders-input)); border-radius: 0.25rem; appearance: none; - background-color: var(--color-background); - background-repeat: no-repeat; - background-position: center; - background-size: 0; + background-color: var(--ui-bg-color, transparent); - transition: border-color 0.15s ease, background-color 0.15s ease, background-size 0.15s ease; + transition: border-color 0.15s ease, background-color 0.15s ease; + + &::before { + content: ""; + + transform: scale(0.8); + + box-sizing: border-box; + width: 0; + height: 0; + border: 0 solid transparent; + border-radius: 0; + + opacity: 0; + background-color: transparent; + + transition: opacity 0.15s ease, transform 0.15s ease, background-color 0.15s ease, border-color 0.15s ease; + } &:checked { - border-color: var(--color-primary); - background-color: var(--color-primary); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6L9 17l-5-5'/%3E%3C/svg%3E"); - background-size: 0.75rem; + border-color: var(--ui-accent-color, var(--color-primary)); + background-color: var(--ui-accent-color, var(--color-primary)); + + &::before { + transform: rotate(45deg) translate(-0.0625rem, -0.03125rem); + + width: 0.375rem; + height: 0.625rem; + border-color: var(--ui-check-color, white); + border-style: solid; + border-width: 0 0.125rem 0.125rem 0; + + opacity: 1; + } } &:indeterminate { - border-color: var(--color-primary); - background-color: var(--color-primary); - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round'%3E%3Cpath d='M5 12h14'/%3E%3C/svg%3E"); - background-size: 0.75rem; + border-color: var(--ui-accent-color, var(--color-primary)); + background-color: var(--ui-accent-color, var(--color-primary)); + + &::before { + transform: none; + + width: 0.625rem; + height: 0.125rem; + border-width: 0; + border-radius: 9999px; + + opacity: 1; + background-color: var(--ui-check-color); + } } &:disabled { @@ -38,7 +76,7 @@ } &:focus-visible { - outline: 2px solid var(--color-primary); + outline: 2px solid var(--ui-accent-color, var(--color-primary)); outline-offset: 2px; } } diff --git a/src/components/gili/primitives/Checkbox.tsx b/src/components/gili/primitives/Checkbox.tsx index dde104d5f..2f5fc68e4 100644 --- a/src/components/gili/primitives/Checkbox.tsx +++ b/src/components/gili/primitives/Checkbox.tsx @@ -15,13 +15,13 @@ type InputProps = React.DetailedHTMLProps< >; type OwnProps = { - checked: boolean; + checked?: boolean; disabled?: boolean; isRound?: boolean; indeterminate?: boolean; isInvalid?: boolean; className?: string; - onChange: (checked: boolean) => void; + onChange?: (checked: boolean) => void; }; type Props = OwnProps & Omit; @@ -50,7 +50,7 @@ const Checkbox = ({ }, [indeterminate]); const handleChange = useLastCallback((e: React.ChangeEvent) => { - onChange(e.currentTarget.checked); + onChange?.(e.currentTarget.checked); }); if (interactive?.isLoading) return undefined; diff --git a/src/components/gili/primitives/Radio.module.scss b/src/components/gili/primitives/Radio.module.scss index 2b44480b3..a3a56d38a 100644 --- a/src/components/gili/primitives/Radio.module.scss +++ b/src/components/gili/primitives/Radio.module.scss @@ -7,18 +7,21 @@ width: 1.25rem; height: 1.25rem; margin: 0; - border: 0.125rem solid var(--color-borders-input); + border: 0.125rem solid var(--ui-border-color, var(--color-borders-input)); border-radius: 50%; appearance: none; - background-color: var(--color-background); + background-color: var(--ui-bg-color, transparent); + background-image: radial-gradient(circle, var(--ui-accent-color, var(--color-primary)) 0 55%, transparent 60%); + background-repeat: no-repeat; + background-position: center; + background-size: 0 0; - transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; + transition: border-color 0.15s ease, background-size 0.15s ease; &:checked { - border-color: var(--color-primary); - background-color: var(--color-primary); - box-shadow: inset 0 0 0 0.1875rem var(--color-background); + border-color: var(--ui-accent-color, var(--color-primary)); + background-size: 0.5rem 0.5rem; } &:disabled { @@ -27,7 +30,7 @@ } &:focus-visible { - outline: 2px solid var(--color-primary); + outline: 2px solid var(--ui-accent-color, var(--color-primary)); outline-offset: 2px; } } diff --git a/src/components/gili/primitives/Radio.tsx b/src/components/gili/primitives/Radio.tsx index 0f23de9a8..cc46177ce 100644 --- a/src/components/gili/primitives/Radio.tsx +++ b/src/components/gili/primitives/Radio.tsx @@ -16,10 +16,10 @@ type InputProps = React.DetailedHTMLProps< type OwnProps = { value: string; - checked: boolean; + checked?: boolean; disabled?: boolean; className?: string; - onChange: (value: string) => void; + onChange?: (value: string) => void; }; type Props = OwnProps & Omit; @@ -40,7 +40,7 @@ const Radio = ({ const isDisabled = disabled || interactive?.isDisabled || interactive?.isLoading; const handleChange = useLastCallback(() => { - onChange(value); + onChange?.(value); }); if (interactive?.isLoading) return undefined; diff --git a/src/components/gili/primitives/Switch.module.scss b/src/components/gili/primitives/Switch.module.scss index 0d94b7fc4..e0cdc70e8 100644 --- a/src/components/gili/primitives/Switch.module.scss +++ b/src/components/gili/primitives/Switch.module.scss @@ -12,7 +12,7 @@ border-radius: 0.625rem; appearance: none; - background-color: var(--color-borders-input); + background-color: var(--ui-border-color, var(--color-borders-input)); transition: background-color 0.15s ease, border-color 0.15s ease; @@ -25,21 +25,21 @@ width: 1.25rem; height: 1.25rem; - border: 0.125rem solid var(--color-borders-input); + border: 0.125rem solid var(--ui-border-color, var(--color-borders-input)); border-radius: 50%; - background-color: var(--color-background); + background-color: var(--ui-bg-color, var(--color-background)); transition: transform 0.15s ease, border-color 0.15s ease; } &:checked { - border-color: var(--color-primary); - background-color: var(--color-primary); + border-color: var(--ui-accent-color, var(--color-primary)); + background-color: var(--ui-accent-color, var(--color-primary)); &::before { transform: translateX(0.75rem); - border-color: var(--color-primary); + border-color: var(--ui-accent-color, var(--color-primary)); } } @@ -49,7 +49,7 @@ } &:focus-visible { - outline: 2px solid var(--color-primary); + outline: 2px solid var(--ui-accent-color, var(--color-primary)); outline-offset: 2px; } } diff --git a/src/components/left/settings/SettingsEditProfile.tsx b/src/components/left/settings/SettingsEditProfile.tsx index 118a00b97..593b871bd 100644 --- a/src/components/left/settings/SettingsEditProfile.tsx +++ b/src/components/left/settings/SettingsEditProfile.tsx @@ -12,7 +12,7 @@ import { getChatAvatarHash } from '../../../global/helpers'; import { selectTabState, selectUser, selectUserFullInfo } from '../../../global/selectors'; import { selectCurrentLimit } from '../../../global/selectors/limits'; import { formatDateToString } from '../../../util/dates/oldDateFormat'; -import { getNextArrowReplacement } from '../../../util/localization/format'; +import { NEXT_ARROW_REPLACEMENT } from '../../../util/localization/format'; import { throttle } from '../../../util/schedulers'; import renderText from '../../common/helpers/renderText'; @@ -284,7 +284,7 @@ const SettingsEditProfile = ({ link: ( {lang('BirthdayPrivacySuggestionLink', - undefined, { withNodes: true, specialReplacement: getNextArrowReplacement() })} + undefined, { withNodes: true, specialReplacement: NEXT_ARROW_REPLACEMENT })} ), }, { withNodes: true })} diff --git a/src/components/left/settings/SettingsPasskeys.tsx b/src/components/left/settings/SettingsPasskeys.tsx index fa6e25aa5..d1fc66468 100644 --- a/src/components/left/settings/SettingsPasskeys.tsx +++ b/src/components/left/settings/SettingsPasskeys.tsx @@ -11,7 +11,7 @@ import { IS_WEBAUTHN_SUPPORTED } from '../../../util/browser/windowEnvironment'; import buildClassName from '../../../util/buildClassName'; import { type LangFn } from '../../../util/localization'; import { formatDateTime, getCalendarDayDiff, secondsToDate } from '../../../util/localization/dateFormat'; -import { getNextArrowReplacement } from '../../../util/localization/format'; +import { NEXT_ARROW_REPLACEMENT } from '../../../util/localization/format'; import { LOCAL_TGS_PREVIEW_URLS, LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; import { REM } from '../../common/helpers/mediaDimensions'; @@ -158,7 +158,7 @@ const SettingsPasskeys = ({ link: ( {lang('SettingsPasskeysFooterLink', undefined, - { withNodes: true, specialReplacement: getNextArrowReplacement() })} + { withNodes: true, specialReplacement: NEXT_ARROW_REPLACEMENT })} ), }, { withNodes: true })} diff --git a/src/components/mediaViewer/helpers/ghostAnimation.ts b/src/components/mediaViewer/helpers/ghostAnimation.ts index 78789dd29..7dc16f5fc 100644 --- a/src/components/mediaViewer/helpers/ghostAnimation.ts +++ b/src/components/mediaViewer/helpers/ghostAnimation.ts @@ -211,7 +211,10 @@ export function animateClosing( }); } -function createGhost(source: string | HTMLImageElement | HTMLVideoElement, origin?: MediaViewerOrigin) { +function createGhost( + source: string | HTMLImageElement | HTMLVideoElement | HTMLCanvasElement, + origin?: MediaViewerOrigin, +) { const ghost = document.createElement('div'); ghost.classList.add('ghost'); @@ -221,6 +224,8 @@ function createGhost(source: string | HTMLImageElement | HTMLVideoElement, origi if (typeof source === 'string') { img.src = source; + } else if (source instanceof HTMLCanvasElement) { + img.src = source.toDataURL(); } else if (source instanceof HTMLVideoElement) { img.src = source.poster; } else { @@ -305,6 +310,11 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage, index?: numbe mediaSelector = 'img'; break; + case MediaViewerOrigin.PollPreview: + containerSelector = `#poll-media${getMessageHtmlId(message!.id, index)}`; + mediaSelector = 'img.full-media, video.full-media, img.thumbnail:not(.blurred-bg), img, video'; + break; + case MediaViewerOrigin.SharedMedia: containerSelector = `#shared-media${getMessageHtmlId(message!.id, index)}`; mediaSelector = 'img'; @@ -343,14 +353,18 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage, index?: numbe case MediaViewerOrigin.SponsoredMessage: containerSelector = '.Transition_slide-active > .MessageList .sponsored-media-preview'; - mediaSelector = `${MESSAGE_CONTENT_SELECTOR} .full-media,${MESSAGE_CONTENT_SELECTOR} .thumbnail:not(.blurred-bg)`; + mediaSelector = `${MESSAGE_CONTENT_SELECTOR} img.full-media,` + + `${MESSAGE_CONTENT_SELECTOR} video.full-media,` + + `${MESSAGE_CONTENT_SELECTOR} img.thumbnail:not(.blurred-bg)`; break; case MediaViewerOrigin.ScheduledInline: case MediaViewerOrigin.Inline: default: containerSelector = `.Transition_slide-active > .MessageList #${getMessageHtmlId(message!.id, index)}`; - mediaSelector = `${MESSAGE_CONTENT_SELECTOR} .full-media,${MESSAGE_CONTENT_SELECTOR} .thumbnail:not(.blurred-bg)`; + mediaSelector = `${MESSAGE_CONTENT_SELECTOR} img.full-media,` + + `${MESSAGE_CONTENT_SELECTOR} video.full-media,` + + `${MESSAGE_CONTENT_SELECTOR} img.thumbnail:not(.blurred-bg)`; } const container = document.querySelector(containerSelector)!; @@ -371,6 +385,7 @@ function applyShape(ghost: HTMLDivElement, origin: MediaViewerOrigin) { case MediaViewerOrigin.ScheduledInline: case MediaViewerOrigin.StarsTransaction: case MediaViewerOrigin.PreviewMedia: + case MediaViewerOrigin.PollPreview: ghost.classList.add('rounded-corners'); break; diff --git a/src/components/middle/composer/AttachmentModalItem.tsx b/src/components/middle/composer/AttachmentModalItem.tsx index 30fba1f9d..6c8caa67b 100644 --- a/src/components/middle/composer/AttachmentModalItem.tsx +++ b/src/components/middle/composer/AttachmentModalItem.tsx @@ -84,14 +84,13 @@ const AttachmentModalItem = ({ ); default: { const canEdit = SUPPORTED_PHOTO_CONTENT_TYPES.has(attachment.mimeType) && !isMobile; - const isPhoto = SUPPORTED_PHOTO_CONTENT_TYPES.has(attachment.mimeType); return ( <> answer.option === String(correctOption!)); - payload.quiz = { - correctAnswers: [String(correctOption)], - ...(text && { solution: text }), - ...(entities && { solutionEntities: entities }), - }; + payload.correctAnswers = [correctAnswerIndex]; + payload.solution = text; + payload.solutionEntities = entities; } onSend(payload); diff --git a/src/components/middle/message/ActionMessage.tsx b/src/components/middle/message/ActionMessage.tsx index 6f76cadbb..37721d0e7 100644 --- a/src/components/middle/message/ActionMessage.tsx +++ b/src/components/middle/message/ActionMessage.tsx @@ -114,6 +114,8 @@ const SINGLE_LINE_ACTIONS = new Set([ 'chatDeletePhoto', 'todoCompletions', 'todoAppendTasks', + 'pollAppendAnswer', + 'pollDeleteAnswer', 'unsupported', ]); const HIDDEN_TEXT_ACTIONS = new Set(['giftCode', 'prizeStars', diff --git a/src/components/middle/message/ActionMessageText.tsx b/src/components/middle/message/ActionMessageText.tsx index d134347b9..e4b2e4109 100644 --- a/src/components/middle/message/ActionMessageText.tsx +++ b/src/components/middle/message/ActionMessageText.tsx @@ -1057,6 +1057,29 @@ const ActionMessageText = ({ }); } + case 'pollAppendAnswer': + case 'pollDeleteAnswer': { + const optionLink = renderMessageLink( + replyMessage, + renderTextWithEntities({ + text: action.answer.text.text, + entities: action.answer.text.entities, + asPreview: true, + }), + asPreview, + ); + + return translateWithYou( + lang, + action.type === 'pollAppendAnswer' ? 'MessageActionPollAppendAnswer' : 'MessageActionPollDeleteAnswer', + isOutgoing, + { + peer: senderLink, + option: optionLink, + }, + ); + } + case 'phoneCall': // Rendered as a regular message, but considered an action for the summary return lang(getCallMessageKey(action, isOutgoing)); diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index c4d1c3a0a..4528523f4 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -9,7 +9,7 @@ import type { ApiChat, ApiChatReactions, ApiMessage, - ApiPoll, + ApiMessagePoll, ApiReaction, ApiStickerSet, ApiStickerSetInfo, @@ -108,7 +108,7 @@ export type OwnProps = { type StateProps = { threadId?: ThreadId; - poll?: ApiPoll; + poll?: ApiMessagePoll; webPage?: ApiWebPage; story?: ApiTypeStory; chat?: ApiChat; diff --git a/src/components/middle/message/Message.scss b/src/components/middle/message/Message.scss index 2f3f93825..0995cee8e 100644 --- a/src/components/middle/message/Message.scss +++ b/src/components/middle/message/Message.scss @@ -666,7 +666,7 @@ .message-content { &.has-replies:not(.custom-shape), - &.has-footer:not(.web-page) { + &.has-footer:not(.web-page):not(.poll) { .media-inner, .Album { --border-bottom-left-radius: 0; @@ -674,6 +674,11 @@ } } + &.poll > .content-inner > .media-inner { + --border-bottom-left-radius: 0; + --border-bottom-right-radius: 0; + } + &.text.is-inverted-media { .Album, .media-inner { diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index 37f9c1f95..3ccdfb595 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -17,8 +17,8 @@ import type { ApiKeyboardButton, ApiMessage, ApiMessageOutgoingStatus, + ApiMessagePoll, ApiPeer, - ApiPoll, ApiReaction, ApiReactionKey, ApiSavedReactionTag, @@ -202,7 +202,7 @@ import MessageMeta from './MessageMeta'; import MessagePhoneCall from './MessagePhoneCall'; import PaidMediaOverlay from './PaidMediaOverlay'; import Photo from './Photo'; -import Poll from './Poll'; +import Poll from './poll/Poll'; import Reactions from './reactions/Reactions'; import RoundVideo from './RoundVideo'; import Sticker from './Sticker'; @@ -323,7 +323,7 @@ type StateProps = { canTranscribeVoice?: boolean; viaBusinessBot?: ApiUser; effect?: ApiAvailableEffect; - poll?: ApiPoll; + poll?: ApiMessagePoll; webPage?: ApiWebPage; maxTimestamp?: number; lastPlaybackTimestamp?: number; @@ -680,7 +680,6 @@ const Message = ({ handleOpenThread, handleReadMedia, handleCancelUpload, - handleVoteSend, handleGroupForward, handleForward, handleFocus, @@ -1221,6 +1220,7 @@ const Message = ({ noUserColors={noUserColors} isProtected={isProtected} observeIntersectionForLoading={observeIntersectionForLoading} + observeIntersectionForPlaying={observeIntersectionForPlaying} onClick={handleStoryClick} /> )} @@ -1358,7 +1358,17 @@ const Message = ({ )} {poll && ( - + )} {todo && ( diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index 52b8bc318..6cee520c4 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -9,8 +9,8 @@ import type { ApiChat, ApiChatReactions, ApiMessage, + ApiMessagePoll, ApiPeer, - ApiPoll, ApiReaction, ApiStickerSet, ApiThreadInfo, @@ -57,7 +57,7 @@ type OwnProps = { anchor: IAnchorPosition; targetHref?: string; message: ApiMessage; - poll?: ApiPoll; + poll?: ApiMessagePoll; webPage?: ApiWebPage; story?: ApiTypeStory; canSendNow?: boolean; diff --git a/src/components/middle/message/Photo.tsx b/src/components/middle/message/Photo.tsx index ba816beff..934ea6c72 100644 --- a/src/components/middle/message/Photo.tsx +++ b/src/components/middle/message/Photo.tsx @@ -37,7 +37,7 @@ import ProgressSpinner from '../../ui/ProgressSpinner'; export type OwnProps = { id?: string; photo: ApiPhoto | ApiMediaExtendedPreview; - isInWebPage?: boolean; + isNestedMedia?: boolean; messageText?: string; isOwn?: boolean; noAvatars?: boolean; @@ -85,7 +85,7 @@ const Photo = ({ isDownloading, isProtected, theme, - isInWebPage, + isNestedMedia, clickArg, className, isMediaNsfw, @@ -245,7 +245,7 @@ const Photo = ({ noAvatars, isMobile, messageText, - isInWebPage, + isNestedMedia, }); const componentClassName = buildClassName( diff --git a/src/components/middle/message/Poll.scss b/src/components/middle/message/Poll.scss deleted file mode 100644 index 70306e78e..000000000 --- a/src/components/middle/message/Poll.scss +++ /dev/null @@ -1,170 +0,0 @@ -.Poll { - min-width: 15rem; - text-align: initial; - - .poll-question { - margin: 0.125rem 0; - font-weight: var(--font-weight-medium); - line-height: 1.25rem; - overflow-wrap: anywhere; - } - - .poll-type, - .poll-voters-count { - font-size: 0.875rem; - color: var(--secondary-color); - } - - .poll-type { - display: flex; - align-items: center; - min-height: 1.375rem; - margin-bottom: 0.5rem; - } - - .poll-voters-count { - margin: 0 0 1.125rem; - text-align: center; - } - - .Checkbox, - .Radio { - min-height: 2.5rem; - padding-bottom: 1.25rem; - padding-left: 2.25rem; - - &.disabled { - cursor: var(--custom-cursor, not-allowed); - opacity: 1 !important; - } - - &:hover { - background: none; - } - - .Checkbox-main, - .Radio-main { - - &::before { - --color-borders-input: var(--secondary-color); - - top: 0.6875rem; - left: 0.125rem; - background-color: var(--background-color); - } - - &::after { - top: 0.6875rem; - left: 0.4375rem; - background-color: var(--accent-color); - } - .label { - line-height: 1.3125rem; - } - } - - input:checked ~ .Radio-main, - input:checked ~ .Checkbox-main { - &::before { - border-color: var(--accent-color); - } - } - - .Spinner { - top: 0.6875rem; - left: 0.125rem; - } - } - - .Checkbox { - &.loading { - .Spinner { - top: 0; - } - } - .Checkbox-main { - &::after { - left: 0.125rem; - background-color: var(--accent-color); - - .theme-dark .Message.own & { - color: var(--color-primary); - } - } - } - } - - .Spinner .Spinner__inner { - // gray spinner - background-image: var(--spinner-gray-data); - - .theme-dark & { - background-image: var(--spinner-white-data); - } - - .Message.own & { - // green spinner - background-image: var(--spinner-green-data); - .theme-dark & { - background-image: var(--spinner-white-data); - } - } - } - - .poll-recent-voters { - margin-left: 0.875rem; - } - - .poll-countdown { - margin-left: auto; - font-size: 0.75rem; - transition: color 0.2s; - - &.hurry-up { - color: var(--color-error); - - .poll-countdown-progress { - stroke: var(--color-error); - } - } - - svg { - vertical-align: -3px; - } - } - - .poll-countdown-progress { - fill: transparent; - stroke: var(--color-primary); - stroke-linecap: round; - stroke-width: 2; - - transition: stroke-dashoffset 2s, stroke 0.2s; - } - - .poll-quiz-help { - margin: -0.625rem 0 -0.625rem auto; - .Message:not(.own) & { - color: var(--color-primary); - } - } - - .Button { - font-size: 1rem; - text-transform: none; - - .Message.own & { - --color-primary-shade-rgb: var(--color-accent-own); - - color: var(--accent-color); - } - } - - > .Button { - margin-bottom: 0.1875rem; - } - - @media (max-width: 600px) { - min-width: 50vw; - } -} diff --git a/src/components/middle/message/Poll.tsx b/src/components/middle/message/Poll.tsx deleted file mode 100644 index a556d2e5f..000000000 --- a/src/components/middle/message/Poll.tsx +++ /dev/null @@ -1,357 +0,0 @@ -import type { FC } from '../../../lib/teact/teact'; -import type React from '../../../lib/teact/teact'; -import { - memo, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from '../../../lib/teact/teact'; -import { getActions, getGlobal } from '../../../global'; - -import type { - ApiMessage, ApiPeer, ApiPoll, ApiPollAnswer, -} from '../../../api/types'; -import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; -import type { OldLangFn } from '../../../hooks/useOldLang'; - -import { selectPeer } from '../../../global/selectors'; -import { formatMediaDuration } from '../../../util/dates/oldDateFormat'; -import { getMessageKey } from '../../../util/keys/messageKey'; -import { getServerTime } from '../../../util/serverTime'; -import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities'; - -import useLastCallback from '../../../hooks/useLastCallback'; -import useOldLang from '../../../hooks/useOldLang'; - -import AvatarList from '../../common/AvatarList'; -import Button from '../../ui/Button'; -import CheckboxGroup from '../../ui/CheckboxGroup'; -import RadioGroup from '../../ui/RadioGroup'; -import PollOption from './PollOption'; - -import './Poll.scss'; - -type OwnProps = { - message: ApiMessage; - poll: ApiPoll; - observeIntersectionForLoading?: ObserveFn; - observeIntersectionForPlaying?: ObserveFn; - onSendVote: (options: string[]) => void; -}; - -const SOLUTION_CONTAINER_ID = '#middle-column-portals'; -const SOLUTION_DURATION = 5000; -const TIMER_RADIUS = 6; -const TIMER_CIRCUMFERENCE = TIMER_RADIUS * 2 * Math.PI; -const TIMER_UPDATE_INTERVAL = 1000; -const NBSP = '\u00A0'; - -const Poll: FC = ({ - message, - poll, - observeIntersectionForLoading, - observeIntersectionForPlaying, - onSendVote, -}) => { - const { - loadMessage, openPollResults, requestConfetti, showNotification, - } = getActions(); - - const { id: messageId, chatId } = message; - const { summary, results } = poll; - const [isSubmitting, setIsSubmitting] = useState(false); - const [chosenOptions, setChosenOptions] = useState([]); - const [wasSubmitted, setWasSubmitted] = useState(false); - const [closePeriod, setClosePeriod] = useState(() => ( - !summary.closed && summary.closeDate && summary.closeDate > 0 - ? Math.min(summary.closeDate - getServerTime(), summary.closePeriod!) - : 0 - )); - const countdownRef = useRef(); - const timerCircleRef = useRef(); - const { results: voteResults, totalVoters } = results; - const hasVoted = voteResults && voteResults.some((r) => r.isChosen); - const canVote = !summary.closed && !hasVoted; - const canViewResult = !canVote && summary.isPublic && Number(results.totalVoters) > 0; - const isMultiple = canVote && summary.multipleChoice; - const recentVoterIds = results.recentVoterIds; - const maxVotersCount = voteResults ? Math.max(...voteResults.map((r) => r.votersCount)) : totalVoters; - const correctResults = useMemo(() => { - return voteResults?.filter((r) => r.isCorrect).map((r) => r.option) || []; - }, [voteResults]); - const answers = useMemo(() => summary.answers.map((a) => ({ - label: renderTextWithEntities({ - text: a.text.text, - entities: a.text.entities, - observeIntersectionForLoading, - observeIntersectionForPlaying, - }), - value: a.option, - hidden: Boolean(summary.quiz && summary.closePeriod && closePeriod <= 0), - })), [ - closePeriod, observeIntersectionForLoading, observeIntersectionForPlaying, - summary.answers, summary.closePeriod, summary.quiz, - ]); - - useEffect(() => { - const chosen = poll.results.results?.find((result) => result.isChosen); - if (isSubmitting && chosen) { - if (chosen.isCorrect) { - requestConfetti({}); - } - setIsSubmitting(false); - } - }, [isSubmitting, poll.results.results, requestConfetti]); - - useLayoutEffect(() => { - if (closePeriod > 0) { - window.setTimeout(() => setClosePeriod(closePeriod - 1), TIMER_UPDATE_INTERVAL); - } - if (!timerCircleRef.current) return; - - if (closePeriod <= 5) { - countdownRef.current!.classList.add('hurry-up'); - } - - const strokeDashOffset = ((summary.closePeriod! - closePeriod) / summary.closePeriod!) * TIMER_CIRCUMFERENCE; - timerCircleRef.current.setAttribute('stroke-dashoffset', `-${strokeDashOffset}`); - }, [closePeriod, summary.closePeriod]); - - useEffect(() => { - if (summary.quiz && (closePeriod <= 0 || (hasVoted && !summary.closed))) { - loadMessage({ chatId, messageId }); - } - }, [chatId, closePeriod, hasVoted, loadMessage, messageId, summary.closed, summary.quiz]); - - // If the client time is not synchronized, the poll must be updated after the closePeriod time has expired. - useEffect(() => { - let timer: number | undefined; - - if (summary.quiz && !summary.closed && summary.closePeriod && summary.closePeriod > 0) { - timer = window.setTimeout(() => { - loadMessage({ chatId, messageId }); - }, summary.closePeriod * 1000); - } - - return () => { - if (timer) { - window.clearTimeout(timer); - } - }; - }, [canVote, chatId, loadMessage, messageId, summary.closePeriod, summary.closed, summary.quiz]); - - const recentVoters = useMemo(() => { - // No need for expensive global updates on chats or users, so we avoid them - const global = getGlobal(); - return recentVoterIds ? recentVoterIds.reduce((result: ApiPeer[], id) => { - const peer = selectPeer(global, id); - if (peer) { - result.push(peer); - } - - return result; - }, []) : []; - }, [recentVoterIds]); - - const handleRadioChange = useLastCallback((option: string) => { - setChosenOptions([option]); - setIsSubmitting(true); - setWasSubmitted(true); - onSendVote([option]); - }); - - const handleCheckboxChange = useLastCallback((options: string[]) => { - setChosenOptions(options); - }); - - const handleVoteClick = useLastCallback(() => { - setIsSubmitting(true); - setWasSubmitted(true); - onSendVote(chosenOptions); - }); - - const handleViewResultsClick = useLastCallback(() => { - openPollResults({ chatId, messageId }); - }); - - const showSolution = useLastCallback(() => { - showNotification({ - localId: getMessageKey(message), - message: poll.results.solution!, - messageEntities: poll.results.solutionEntities, - duration: SOLUTION_DURATION, - containerSelector: SOLUTION_CONTAINER_ID, - }); - }); - - // Show the solution to quiz if the answer was incorrect - useEffect(() => { - if (wasSubmitted && hasVoted && summary.quiz && results.results && poll.results.solution) { - const correctResult = results.results.find((r) => r.isChosen && r.isCorrect); - if (!correctResult) { - showSolution(); - } - } - }, [hasVoted, wasSubmitted, results.results, summary.quiz, poll.results.solution]); - - const lang = useOldLang(); - - function renderResultOption(answer: ApiPollAnswer) { - return ( - - ); - } - - function renderRecentVoters() { - return ( - recentVoters.length > 0 && ( -
- -
- ) - ); - } - - return ( -
-
- {renderTextWithEntities({ - text: summary.question.text, - entities: summary.question.entities, - observeIntersectionForLoading, - observeIntersectionForPlaying, - })} -
-
- {lang(getPollTypeString(summary))} - {renderRecentVoters()} - {closePeriod > 0 && canVote && ( -
- {formatMediaDuration(closePeriod)} - - - -
- )} - {summary.quiz && poll.results.solution && !canVote && ( -
- {canVote && ( -
- {isMultiple - ? ( - - ) - : ( - - )} -
- )} - {!canVote && ( -
- {summary.answers.map(renderResultOption)} -
- )} - {!canViewResult && !isMultiple && ( -
{getReadableVotersCount(lang, summary.quiz, results.totalVoters)}
- )} - {isMultiple && ( - - )} - {canViewResult && ( - - )} -
- ); -}; - -function getPollTypeString(summary: ApiPoll['summary']) { - // When we just created the poll, some properties don't exist. - if (typeof summary.isPublic === 'undefined') { - return NBSP; - } - - if (summary.closed) { - return 'FinalResults'; - } - - if (summary.quiz) { - return summary.isPublic ? 'QuizPoll' : 'AnonymousQuizPoll'; - } - - return summary.isPublic ? 'PublicPoll' : 'AnonymousPoll'; -} - -function getReadableVotersCount(lang: OldLangFn, isQuiz: true | undefined, count?: number) { - if (!count) { - return lang(isQuiz ? 'Chat.Quiz.TotalVotesEmpty' : 'Chat.Poll.TotalVotesResultEmpty'); - } - - return lang(isQuiz ? 'Answer' : 'Vote', count, 'i'); -} - -function stopPropagation(e: React.MouseEvent) { - e.stopPropagation(); -} - -export default memo(Poll); diff --git a/src/components/middle/message/PollOption.scss b/src/components/middle/message/PollOption.scss deleted file mode 100644 index d6a8a2654..000000000 --- a/src/components/middle/message/PollOption.scss +++ /dev/null @@ -1,131 +0,0 @@ -.PollOption { - display: flex; - flex-flow: row nowrap; - padding-bottom: 0.5rem; - - &:last-child { - margin-bottom: 0; - } - - .poll-option-text { - overflow-wrap: anywhere; - } - - .poll-option-share { - position: relative; - - flex-shrink: 0; - - width: 1.75rem; - margin-top: 0.125rem; - margin-inline-end: 0.5rem; - - font-size: 0.875rem; - font-weight: var(--font-weight-medium); - text-align: right; - - &.limit-width { - display: inline-flex; - justify-content: center; - font-size: 0.75rem; - } - } - - .poll-option-chosen { - position: absolute; - right: 0; - bottom: -5px; - - width: 1rem; - height: 1rem; - border-radius: 0.5rem; - - font-size: 0.75rem; - color: var(--background-color); - text-align: center; - - background: var(--accent-color); - - &.wrong { - background: var(--color-error); - } - - .poll-option-icon { - line-height: 1rem; - } - - &.animate { - opacity: 0; - - animation-name: PollOptionIconAnimate; - animation-duration: 0.3s; - animation-fill-mode: forwards; - animation-delay: 0.09s; - } - } - - .poll-option-right { - flex-grow: 1; - line-height: 1.3125rem; - } - - .poll-option-answer { - position: relative; - } - - .poll-option-line { - position: relative; - transform-origin: 0 0; - - width: 0; - min-width: 0.5rem; - height: 0.25rem; - margin-top: 0.5rem; - border-radius: 0.125rem; - - background: var(--accent-color); - - transition: transform 0.3s; - transition-delay: 0.09s; - } - - .poll-line { - position: absolute; - bottom: -5px; - left: -27px; - - width: 30px; - height: 35px; - - stroke-dasharray: 0, 200%; - stroke-dashoffset: 0; - - transition: stroke-dashoffset 0.3s, stroke-dasharray 0.3s; - } - - .poll-line path { - fill: none; - stroke: var(--accent-color); - stroke-linecap: round; - stroke-width: 4px; - } - - .wrong { - .poll-option-line { - background: var(--color-error); - } - - .poll-line path { - stroke: var(--color-error); - } - } -} - -@keyframes PollOptionIconAnimate { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } -} diff --git a/src/components/middle/message/PollOption.tsx b/src/components/middle/message/PollOption.tsx deleted file mode 100644 index 096e288a7..000000000 --- a/src/components/middle/message/PollOption.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import type { FC } from '../../../lib/teact/teact'; -import { - useEffect, - useState, -} from '../../../lib/teact/teact'; - -import type { ApiPollAnswer, ApiPollResult } from '../../../api/types'; - -import buildClassName from '../../../util/buildClassName'; -import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities'; - -import useLang from '../../../hooks/useLang'; - -import Icon from '../../common/icons/Icon'; - -import './PollOption.scss'; - -type OwnProps = { - answer: ApiPollAnswer; - voteResults?: ApiPollResult[]; - totalVoters?: number; - maxVotersCount?: number; - correctResults: string[]; - shouldAnimate: boolean; -}; - -const PollOption: FC = ({ - answer, - voteResults, - totalVoters, - maxVotersCount, - correctResults, - shouldAnimate, -}) => { - const lang = useLang(); - const result = voteResults && voteResults.find((r) => r.option === answer.option); - const correctAnswer = correctResults.length === 0 || correctResults.indexOf(answer.option) !== -1; - const showIcon = (correctResults.length > 0 && correctAnswer) || (result?.isChosen); - const answerPercent = result ? getPercentage(result.votersCount, totalVoters || 0) : 0; - const [finalPercent, setFinalPercent] = useState(shouldAnimate ? 0 : answerPercent); - const lineWidth = result ? getPercentage(result.votersCount, maxVotersCount || 0) : 0; - const isAnimationDoesNotStart = finalPercent !== answerPercent; - - useEffect(() => { - if (shouldAnimate) { - setFinalPercent(answerPercent); - } - }, [shouldAnimate, answerPercent]); - - if (!voteResults || !result) { - return undefined; - } - - const lineStyle = `width: ${lineWidth}%; transform:scaleX(${isAnimationDoesNotStart ? 0 : 1})`; - - return ( -
-
- {answerPercent} - % - {showIcon && ( - - - - )} -
-
-
- {renderTextWithEntities({ - text: answer.text.text, - entities: answer.text.entities, - })} -
-
- {shouldAnimate && ( - - - - )} -
-
-
-
- ); -}; - -function getPercentage(value: number, total: number) { - return total > 0 ? ((value / total) * 100).toFixed() : 0; -} - -export default PollOption; diff --git a/src/components/middle/message/Video.tsx b/src/components/middle/message/Video.tsx index cbf137af3..e8fbe237b 100644 --- a/src/components/middle/message/Video.tsx +++ b/src/components/middle/message/Video.tsx @@ -38,7 +38,7 @@ export type OwnProps = { video: ApiVideo | ApiMediaExtendedPreview; lastPlaybackTimestamp?: number; isOwn?: boolean; - isInWebPage?: boolean; + isNestedMedia?: boolean; noAvatars?: boolean; canAutoLoad?: boolean; canAutoPlay?: boolean; @@ -65,7 +65,7 @@ const Video = ({ id, video, isOwn, - isInWebPage, + isNestedMedia, noAvatars, canAutoLoad, canAutoPlay, @@ -209,8 +209,8 @@ const Video = ({ width, height, } = dimensions || ( isPaidPreview - ? calculateExtendedPreviewDimensions(video, Boolean(isOwn), asForwarded, isInWebPage, noAvatars, isMobile) - : calculateVideoDimensions(video, Boolean(isOwn), asForwarded, isInWebPage, noAvatars, isMobile) + ? calculateExtendedPreviewDimensions(video, Boolean(isOwn), asForwarded, isNestedMedia, noAvatars, isMobile) + : calculateVideoDimensions(video, Boolean(isOwn), asForwarded, isNestedMedia, noAvatars, isMobile) ); const handleClick = useLastCallback((e: React.MouseEvent, isFromSpinner?: boolean) => { diff --git a/src/components/middle/message/WebPage.tsx b/src/components/middle/message/WebPage.tsx index 962f2866e..af5bcf4cb 100644 --- a/src/components/middle/message/WebPage.tsx +++ b/src/components/middle/message/WebPage.tsx @@ -247,7 +247,7 @@ const WebPage = ({ { - sendPollVote({ chatId, messageId, options }); - }); - const handleGroupForward = useLastCallback(() => { openForwardMenu({ fromChatId: chatId, groupedId }); }); @@ -305,7 +301,6 @@ export default function useInnerHandlers({ handleOpenThread, handleReadMedia, handleCancelUpload, - handleVoteSend, handleGroupForward, handleForward, handleFocus, diff --git a/src/components/middle/message/poll/Poll.module.scss b/src/components/middle/message/poll/Poll.module.scss new file mode 100644 index 000000000..40ab27881 --- /dev/null +++ b/src/components/middle/message/poll/Poll.module.scss @@ -0,0 +1,143 @@ +.root { + display: flex; + flex-direction: column; + gap: 0.5rem; + + min-width: 15rem; + margin-bottom: 0.75rem; // Space for message meta + padding-top: 0.125rem; + + line-height: 1.25; +} + +.explanation, +.options { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.explanation { + padding-block: 0.375rem; +} + +.explanationMedia, +.metaVoters { + min-width: 0; +} + +.explanationMedia { + :global(.media-inner) { + --border-top-left-radius: var(--border-radius-messages-small); + --border-top-right-radius: var(--border-radius-messages-small); + --border-bottom-left-radius: var(--border-radius-messages-small); + --border-bottom-right-radius: var(--border-radius-messages-small); + + margin: 0 !important; + } +} + +.explanationHeader { + display: flex; + flex-direction: column; + gap: 0.125rem; + font-size: 0.875rem; +} + +.explanationCloseButton { + position: absolute; + top: 0.125rem; + right: 0.125rem; +} + +.explanationTitle { + font-weight: var(--font-weight-semibold); +} + +.explanationText { + color: var(--color-text); +} + +.questionContainer { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + column-gap: 0.5rem; +} + +.question { + grid-column: 1; + grid-row: 1; + min-width: 0; + font-weight: var(--font-weight-semibold); +} + +.explanationToggleButton { + display: flex; + grid-column: 2; + grid-row: 1 / span 2; + align-items: center; +} + +.meta { + display: flex; + grid-column: 1; + grid-row: 2; + gap: 0.5rem; + align-items: center; + + min-width: 0; +} + +.metaInfo { + display: flex; + gap: 0.375rem; + align-items: center; + min-width: 0; +} + +.metaLabel { + font-size: 0.875rem; + color: var(--color-text-secondary); + white-space: nowrap; +} + +.footer, +.footerContentSlide { + display: flex; + align-items: center; + justify-content: center; +} + +.footerContent { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + min-height: 2.5rem; +} + +.footerButton { + padding-block: 0; +} + +.footerSubtext { + display: inline; + font-size: 0.75rem; + color: var(--color-text-secondary); +} + +.footerStatic { + display: flex; + align-items: center; + justify-content: center; + + color: var(--color-text-secondary); + text-align: center; +} + +.stickerPreview { + position: relative; + width: 6rem; + height: 6rem; +} diff --git a/src/components/middle/message/poll/Poll.tsx b/src/components/middle/message/poll/Poll.tsx new file mode 100644 index 000000000..0ec55b185 --- /dev/null +++ b/src/components/middle/message/poll/Poll.tsx @@ -0,0 +1,764 @@ +import { + memo, + type TeactNode, + useEffect, + useMemo, + useRef, + useState, +} from '../../../../lib/teact/teact'; +import { getActions, getGlobal } from '../../../../global'; + +import type { + ApiFormattedText, + ApiLocation, + ApiMessagePoll, + ApiPoll, + ApiSticker, + MediaContent, +} from '../../../../api/types'; +import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; +import { type MediaViewerMedia, MediaViewerOrigin, type ThemeKey } from '../../../../types'; + +import { getMessageHtmlId } from '../../../../global/helpers'; +import { selectPeer } from '../../../../global/selectors'; +import buildClassName from '../../../../util/buildClassName'; +import { buildCollectionByKey, shuffle } from '../../../../util/iteratees'; +import { NEXT_ARROW_REPLACEMENT, PREVIOUS_ARROW_REPLACEMENT } from '../../../../util/localization/format'; +import { getServerTime } from '../../../../util/serverTime'; +import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities'; + +import useTimeout from '../../../../hooks/schedulers/useTimeout'; +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; + +import AvatarList from '../../../common/AvatarList'; +import CompactMapPreview from '../../../common/CompactMapPreview'; +import Document from '../../../common/Document'; +import PeerColorWrapper from '../../../common/PeerColorWrapper'; +import StickerView from '../../../common/StickerView'; +import Button from '../../../ui/Button'; +import TextTimer from '../../../ui/TextTimer'; +import Transition from '../../../ui/Transition'; +import Photo from '../Photo'; +import Video from '../Video'; +import PollOption from './PollOption'; + +import styles from './Poll.module.scss'; + +type OwnProps = { + chatId: string; + messageId: number; + poll: ApiMessagePoll; + messageText?: ApiFormattedText; + theme: ThemeKey; + isInScheduled?: boolean; + observeIntersectionForLoading?: ObserveFn; + observeIntersectionForPlaying?: ObserveFn; +}; + +const ATTACHED_MAP_WIDTH = 350; +const ATTACHED_MAP_HEIGHT = 200; +const ATTACHED_MAP_ZOOM = 15; +const STICKER_PREVIEW_SIZE = 96; +const VOTE_TIMEOUT = 5000; + +const Poll = ({ + chatId, + messageId, + poll, + messageText, + theme, + isInScheduled, + observeIntersectionForLoading, + observeIntersectionForPlaying, +}: OwnProps) => { + const { + openMapModal, + openMediaViewer, + openPollResults, + requestConfetti, + sendPollVote, + loadMessage, + } = getActions(); + const lang = useLang(); + const serverTime = getServerTime(); + + const { summary, results, attachedMedia } = poll; + const { answers, question, isMultipleChoice } = summary; + const [selectedOptions, setSelectedOptions] = useState([]); + const [isExplanationOpen, setIsExplanationOpen] = useState(false); + const [isSendingVote, setIsSendingVote] = useState(false); + const [isViewingAuthorResults, setIsViewingAuthorResults] = useState(false); + const [answerOrder] = useState(() => ( + buildAnswerOrder(answers, summary.shouldShuffleAnswers) + )); + + const hasChosenAnswer = useMemo( + () => Object.values(results.resultByOption || {}).some((result) => result.isChosen), + [results.resultByOption], + ); + const activeCloseDate = !summary.isClosed && summary.closeDate && summary.closeDate > serverTime + ? summary.closeDate + : undefined; + const areResultsHiddenForCurrentUser = Boolean( + summary.shouldHideResultsUntilClose && activeCloseDate && !summary.isCreator, + ); + const hasMaskedResults = areResultsHiddenForCurrentUser && hasChosenAnswer; + const canVote = !summary.isClosed && !hasChosenAnswer; + const hasExplanation = summary.isQuiz && Boolean(results.solution?.trim() || results.solutionMedia); + const hasOptionMedia = useMemo( + () => answers.some((answer) => hasPollOptionMedia(answer)), + [answers], + ); + const selectedOptionsSet = useMemo(() => new Set(selectedOptions), [selectedOptions]); + const hasResultData = Boolean(results.resultByOption); + const totalVoters = results.totalVoters || 0; + const canToggleAuthorResults = summary.isCreator && canVote && hasResultData && totalVoters > 0; + const areInlineResultsVisible = hasResultData && ( + (!canVote && !areResultsHiddenForCurrentUser) || isViewingAuthorResults + ); + const canShowResultsPanel = hasChosenAnswer && summary.isPublic && hasResultData && !areResultsHiddenForCurrentUser; + + useEffect(() => { + if (!canVote) { + setSelectedOptions([]); + setIsSendingVote(false); + } + }, [canVote]); + + useTimeout(() => { + setIsSendingVote(false); + }, isSendingVote ? VOTE_TIMEOUT : undefined); + + useEffect(() => { + if (!summary.isQuiz || !isSendingVote || areResultsHiddenForCurrentUser) { + return; + } + + const resultByOption = results.resultByOption; + if (!resultByOption) { + return; + } + + const pollResults = Object.values(resultByOption); + const chosenResults = pollResults.filter((result) => result.isChosen); + const correctResults = pollResults.filter((result) => result.isCorrect); + + if ( + !chosenResults.length + || chosenResults.length !== correctResults.length + || !chosenResults.every((result) => result.isCorrect) + ) { + return; + } + + requestConfetti({}); + }, [areResultsHiddenForCurrentUser, isSendingVote, results.resultByOption, summary.isQuiz]); + + useEffect(() => { + if (!canToggleAuthorResults) { + setIsViewingAuthorResults(false); + } + }, [canToggleAuthorResults]); + + useEffect(() => { + if (!hasExplanation) { + setIsExplanationOpen(false); + } + }, [hasExplanation]); + + const answersByOption = useMemo(() => buildCollectionByKey(answers, 'option'), [answers]); + + const orderedAnswers = useMemo(() => { + const seen = new Set(answerOrder); + const ordered = answerOrder + .map((option) => answersByOption[option]) + .filter(Boolean); + const appended = answers.filter((answer) => !seen.has(answer.option)); + + return [...ordered, ...appended]; + }, [answerOrder, answers, answersByOption]); + + const previewItems = useMemo(() => { + const items: { + key: string; + media: MediaViewerMedia; + }[] = []; + + if (attachedMedia?.photo) { + items.push({ key: 'attached', media: attachedMedia.photo }); + } else if (attachedMedia?.video) { + items.push({ key: 'attached', media: attachedMedia.video }); + } else if (attachedMedia?.document) { + items.push({ key: 'attached', media: attachedMedia.document }); + } + + if (results.solutionMedia?.photo) { + items.push({ key: 'explanation', media: results.solutionMedia.photo }); + } else if (results.solutionMedia?.video) { + items.push({ key: 'explanation', media: results.solutionMedia.video }); + } else if (results.solutionMedia?.document) { + items.push({ key: 'explanation', media: results.solutionMedia.document }); + } + + orderedAnswers.forEach((answer) => { + if (answer.media?.photo) { + items.push({ key: answer.option, media: answer.media.photo }); + } else if (answer.media?.video) { + items.push({ key: answer.option, media: answer.media.video }); + } + }); + + return items; + }, [attachedMedia, orderedAnswers, results.solutionMedia]); + + const previewIndexByKey = useMemo(() => { + return previewItems.reduce((acc, item, index) => { + acc[item.key] = index; + return acc; + }, {} as Record); + }, [previewItems]); + const standaloneMedia = useMemo(() => previewItems.map((item) => item.media), [previewItems]); + + const pollRecentVoters = useMemo( + () => areResultsHiddenForCurrentUser ? undefined : resolvePeers(results.recentVoterIds), + [areResultsHiddenForCurrentUser, results.recentVoterIds], + ); + + const submitVote = useLastCallback((options: string[]) => { + setIsSendingVote(true); + sendPollVote({ + chatId, + messageId, + options, + }); + }); + + const handleSelectOption = useLastCallback((option: string) => { + if (!canVote || isSendingVote || isViewingAuthorResults) { + return; + } + + if (!isMultipleChoice) { + submitVote([option]); + return; + } + + setSelectedOptions((current) => { + if (current.includes(option)) { + return current.filter((currentOption) => currentOption !== option); + } + + return [...current, option]; + }); + }); + + const handleSendVote = useLastCallback(() => { + if (!canVote || !selectedOptions.length || isSendingVote) { + return; + } + + submitVote(selectedOptions); + }); + + const handleOpenPreview = useLastCallback((previewIndex: number) => { + if (!standaloneMedia.length) { + return; + } + + openMediaViewer({ + chatId, + messageId, + mediaIndex: previewIndex, + standaloneMedia, + origin: MediaViewerOrigin.PollPreview, + }); + }); + + const handleOpenLocation = useLastCallback((location: ApiLocation) => { + openMapModal({ + geoPoint: location.geo, + zoom: ATTACHED_MAP_ZOOM, + }); + }); + + const handleOpenResults = useLastCallback(() => { + if (!canShowResultsPanel) { + return; + } + + openPollResults({ chatId, messageId }); + }); + + const handleToggleAuthorResults = useLastCallback(() => { + if (!canToggleAuthorResults) { + return; + } + + setIsViewingAuthorResults((current) => !current); + }); + + const handleToggleExplanation = useLastCallback(() => { + setIsExplanationOpen((current) => !current); + }); + + const handleCloseDateEnd = useLastCallback(() => { + loadMessage({ chatId, messageId }); + }); + + const attachedPreviewIndex = attachedMedia && previewIndexByKey.attached; + const explanationPreviewIndex = results.solutionMedia && previewIndexByKey.explanation; + const attachedMediaEl = attachedMedia && renderPollMedia({ + content: attachedMedia, + theme, + previewIndex: attachedPreviewIndex, + previewId: attachedPreviewIndex !== undefined ? getPollPreviewId(messageId, attachedPreviewIndex) : undefined, + observeIntersectionForLoading, + observeIntersectionForPlaying, + onOpenLocation: handleOpenLocation, + onOpenPreview: handleOpenPreview, + }); + const explanationMedia = results.solutionMedia && renderPollMedia({ + content: results.solutionMedia, + theme, + previewIndex: explanationPreviewIndex, + previewId: explanationPreviewIndex !== undefined ? getPollPreviewId(messageId, explanationPreviewIndex) : undefined, + isNestedMedia: true, + observeIntersectionForLoading, + observeIntersectionForPlaying, + onOpenLocation: handleOpenLocation, + onOpenPreview: handleOpenPreview, + locationWidth: ATTACHED_MAP_WIDTH, + locationHeight: ATTACHED_MAP_HEIGHT, + }); + + const questionText = useMemo(() => renderTextWithEntities({ + text: question.text, + entities: question.entities, + }), [question.entities, question.text]); + const descriptionText = useMemo(() => messageText && renderTextWithEntities({ + text: messageText.text, + entities: messageText.entities, + }), [messageText]); + const explanationText = useMemo(() => results.solution?.trim() ? renderTextWithEntities({ + text: results.solution, + entities: results.solutionEntities, + }) : undefined, [results.solution, results.solutionEntities]); + const footerSubtext = activeCloseDate + ? lang(areResultsHiddenForCurrentUser ? 'PollResultsTime' : 'PollEndsTime', { + time: ( + + ), + }, { withNodes: true }) + : undefined; + + const footerContent = useMemo(() => { + const renderFooterBody = (label: TeactNode) => ( +
+ {label} + {Boolean(footerSubtext) && ( +
+ {footerSubtext} +
+ )} +
+ ); + + if (canVote && isMultipleChoice && selectedOptions.length) { + return ( + + ); + } + + if (canToggleAuthorResults) { + const label = isViewingAuthorResults + ? lang(summary.isQuiz ? 'PollBackToAnswer' : 'PollBackToVote', undefined, { + withNodes: true, + specialReplacement: PREVIOUS_ARROW_REPLACEMENT, + }) + : lang( + summary.isQuiz ? 'PollAnswerCountButton' : 'PollVoteCountButton', + { count: totalVoters }, + { + withNodes: true, + pluralValue: totalVoters, + specialReplacement: NEXT_ARROW_REPLACEMENT, + }, + ); + + return ( + + ); + } + + if (canVote && isMultipleChoice) { + return ( + + ); + } + + if (canShowResultsPanel) { + return ( + + ); + } + + if (totalVoters) { + return ( +
+ {renderFooterBody(lang(summary.isQuiz ? 'PollAnsweredCount' : 'VoteCount', { + count: totalVoters, + }, { pluralValue: totalVoters }))} +
+ ); + } + + return ( +
+ {renderFooterBody(lang(summary.isQuiz ? 'ChatQuizTotalVotesEmpty' : 'ChatPollTotalVotesResultEmpty'))} +
+ ); + }, [ + canShowResultsPanel, + canToggleAuthorResults, + canVote, + isMultipleChoice, + isSendingVote, + isViewingAuthorResults, + lang, + selectedOptions.length, + summary.isQuiz, + footerSubtext, + totalVoters, + ]); + + return ( + <> + {attachedMediaEl} +
+ {isExplanationOpen && hasExplanation && ( + +
+ + {lang('PollsSolutionTitle')} + + {explanationText && ( +
+ {explanationText} +
+ )} +
+
+ )} +
+
+ + {summary.isClosed + ? lang('FinalResults') + : summary.isQuiz + ? lang(summary.isPublic ? 'QuizPoll' : 'AnonymousQuizPoll') + : lang(summary.isPublic ? 'PublicPoll' : 'AnonymousPoll')} + + {Boolean(pollRecentVoters?.length) && ( + + )} +
+
+
+
+ {orderedAnswers.map((answer) => { + const optionResult = results.resultByOption?.[answer.option]; + const previewIndex = previewIndexByKey[answer.option]; + + return ( + + ); + })} +
+ {footerContent && ( +
+ {footerContent} +
+ )} +
+ + ); +}; + +function buildAnswerOrder(answers: ApiPoll['answers'], shouldShuffle?: true) { + const options = answers.map((answer) => answer.option); + return shouldShuffle ? shuffle(options) : options; +} + +function resolvePeers(peerIds?: string[]) { + if (!peerIds?.length) { + return undefined; + } + + const global = getGlobal(); + + return peerIds + .map((peerId) => selectPeer(global, peerId)) + .filter(Boolean); +} + +function getPollPreviewId(messageId: number, previewIndex: number) { + return `poll-media${getMessageHtmlId(messageId, previewIndex)}`; +} + +function renderPollMedia({ + content, + theme, + className, + previewIndex, + previewId, + observeIntersectionForLoading, + observeIntersectionForPlaying, + onOpenLocation, + onOpenPreview, + locationWidth = ATTACHED_MAP_WIDTH, + locationHeight = ATTACHED_MAP_HEIGHT, + isNestedMedia, +}: { + content: MediaContent; + theme: ThemeKey; + className?: string; + previewIndex?: number; + previewId?: string; + observeIntersectionForLoading?: ObserveFn; + observeIntersectionForPlaying?: ObserveFn; + onOpenLocation: (location: ApiLocation) => void; + onOpenPreview: (previewIndex: number) => void; + locationWidth?: number; + locationHeight?: number; + isNestedMedia?: boolean; +}) { + if (content.photo) { + return ( + + ); + } + + if (content.video) { + return ( +