Poll: Support new features (#6819)

This commit is contained in:
zubiden 2026-04-14 14:36:51 +02:00 committed by Alexander Zinchuk
parent d4138b0ebd
commit fc0e52e908
94 changed files with 3849 additions and 2486 deletions

View File

@ -30,8 +30,8 @@ You are an expert in TypeScript, JavaScript, HTML, SCSS and Teact with deep expe
- **Code Style:** - **Code Style:**
- Early returns. - Early returns.
- Prefix boolean variables with primary or modal auxiliaries (e.q. `isOpen`, `willUpdate`, `shouldRender`). - Prefix boolean variables with primary or modal auxiliaries (e.g. `isOpen`, `willUpdate`, `shouldRender`).
- Functions should start with a verb (e.q. `openModal`, `closeDialog`, `handleClick`). - 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. - 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. - Only leave comments for complex logic.
- Do not use `null`. There's linter rule to enforce it. - Do not use `null`. There's linter rule to enforce it.
@ -160,7 +160,7 @@ addActionHandler('loadUser', async (global, actions, { userId }) => {
### 1. Basics & Imports ### 1. Basics & Imports
* All components use JSX and render with Teact. * 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. * Built-in hooks live in Teact library. Import them from there.
### 2. Props & Types ### 2. Props & Types
@ -171,6 +171,7 @@ addActionHandler('loadUser', async (global, actions, { userId }) => {
* Merge them as `OwnProps & StateProps` when defining your component. * Merge them as `OwnProps & StateProps` when defining your component.
* You can skip one or both if they are not used. * You can skip one or both if they are not used.
* **Order rule**: list any function types *last* in your props definitions. * **Order rule**: list any function types *last* in your props definitions.
* Do not pass unmemoized objects as props into memo() components.
### 3. Hooks ### 3. Hooks
* **useLastCallback** is your go-to for callbacks, since it won't trigger re-renders and always uses the latest scope. * **useLastCallback** is your go-to for callbacks, since it won't trigger re-renders and always uses the latest scope.

View File

@ -23,7 +23,6 @@ import type {
import { int2hex } from '../../../util/colors'; import { int2hex } from '../../../util/colors';
import { toJSNumber } from '../../../util/numbers'; import { toJSNumber } from '../../../util/numbers';
import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { addDocumentToLocalDb } from '../helpers/localDb';
import { buildApiFormattedText } from './common'; import { buildApiFormattedText } from './common';
import { buildApiCurrencyAmount } from './payments'; import { buildApiCurrencyAmount } from './payments';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
@ -73,8 +72,6 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift {
background, background,
} = starGift; } = starGift;
addDocumentToLocalDb(starGift.sticker);
const sticker = buildStickerFromDocument(starGift.sticker)!; const sticker = buildStickerFromDocument(starGift.sticker)!;
return { return {
@ -138,8 +135,6 @@ export function buildApiStarGiftAttribute(attribute: GramJs.TypeStarGiftAttribut
return undefined; return undefined;
} }
addDocumentToLocalDb(attribute.document);
return { return {
type: 'model', type: 'model',
name: attribute.name, name: attribute.name,
@ -154,8 +149,6 @@ export function buildApiStarGiftAttribute(attribute: GramJs.TypeStarGiftAttribut
return undefined; return undefined;
} }
addDocumentToLocalDb(attribute.document);
return { return {
type: 'pattern', type: 'pattern',
name: attribute.name, name: attribute.name,
@ -349,10 +342,6 @@ export function buildApiStarGiftCollection(collection: GramJs.StarGiftCollection
const { collectionId, title, icon, giftsCount, hash } = collection; const { collectionId, title, icon, giftsCount, hash } = collection;
if (icon) {
addDocumentToLocalDb(icon);
}
return { return {
collectionId, collectionId,
title, title,

View File

@ -7,7 +7,7 @@ import { toJSNumber } from '../../../util/numbers';
import { buildApiBotApp } from './bots'; import { buildApiBotApp } from './bots';
import { buildApiFormattedText, buildApiPhoto } from './common'; import { buildApiFormattedText, buildApiPhoto } from './common';
import { buildApiStarGift } from './gifts'; import { buildApiStarGift } from './gifts';
import { buildTodoItem } from './messageContent'; import { buildPollAnswer, buildTodoItem } from './messageContent';
import { buildApiCurrencyAmount } from './payments'; import { buildApiCurrencyAmount } from './payments';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers'; import { buildApiPeerId, getApiChatIdFromMtpPeer } from './peers';
@ -525,6 +525,26 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess
items: list.map(buildTodoItem), 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) { if (action instanceof GramJs.MessageActionStarGiftPurchaseOffer) {
const { const {
accepted, declined, gift, price, expiresAt, accepted, declined, gift, price, expiresAt,

View File

@ -13,11 +13,14 @@ import type {
ApiMediaExtendedPreview, ApiMediaExtendedPreview,
ApiMediaInvoice, ApiMediaInvoice,
ApiMediaTodo, ApiMediaTodo,
ApiMessagePoll,
ApiMessageStoryData, ApiMessageStoryData,
ApiMessageWebPage, ApiMessageWebPage,
ApiPaidMedia, ApiPaidMedia,
ApiPhoto, ApiPhoto,
ApiPoll, ApiPoll,
ApiPollAnswer,
ApiPollResults,
ApiStarGiftUnique, ApiStarGiftUnique,
ApiSticker, ApiSticker,
ApiTodoItem, ApiTodoItem,
@ -43,7 +46,7 @@ import {
} from '../../../config'; } from '../../../config';
import { addTimestampEntities } from '../../../util/dates/timestamp'; import { addTimestampEntities } from '../../../util/dates/timestamp';
import { generateWaveform } from '../../../util/generateWaveform'; import { generateWaveform } from '../../../util/generateWaveform';
import { pick } from '../../../util/iteratees'; import { buildCollectionByKey, pick } from '../../../util/iteratees';
import { toJSNumber } from '../../../util/numbers'; import { toJSNumber } from '../../../util/numbers';
import { import {
addMediaToLocalDb, addStoryToLocalDb, addWebPageMediaToLocalDb, type MediaRepairContext, addMediaToLocalDb, addStoryToLocalDb, addWebPageMediaToLocalDb, type MediaRepairContext,
@ -77,7 +80,7 @@ export function buildMessageContent(
const hasUnsupportedMedia = mtpMessage.media instanceof GramJs.MessageMediaUnsupported; const hasUnsupportedMedia = mtpMessage.media instanceof GramJs.MessageMediaUnsupported;
if (mtpMessage.message && !hasUnsupportedMedia 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 text = buildMessageTextContent(mtpMessage.message, mtpMessage.entities);
const textWithTimestamps = addTimestampEntities(text); const textWithTimestamps = addTimestampEntities(text);
content = { content = {
@ -559,12 +562,12 @@ function buildPollIdFromMedia(media: GramJs.TypeMessageMedia): string | undefine
return media.poll.id.toString(); 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)) { if (!(media instanceof GramJs.MessageMediaPoll)) {
return undefined; return undefined;
} }
return buildPoll(media.poll, media.results); return buildMessagePoll(media);
} }
function buildTodoFromMedia(media: GramJs.TypeMessageMedia): ApiMediaTodo | undefined { 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 { export function buildMessagePoll(media: GramJs.MessageMediaPoll): ApiMessagePoll {
const { id, answers: rawAnswers } = poll; const { poll, results, attachedMedia } = media;
const answers = rawAnswers
.filter((answer): answer is GramJs.PollAnswer => answer instanceof GramJs.PollAnswer)
.map((answer) => ({
text: buildApiFormattedText(answer.text),
option: serializeBytes(answer.option),
}));
return { return {
mediaType: 'poll', mediaType: 'poll',
id: String(id), summary: buildPoll(poll),
summary: { results: buildPollResults(results),
isPublic: poll.publicVoters, attachedMedia: attachedMedia ? buildMessageMediaContent(attachedMedia) : undefined,
question: buildApiFormattedText(poll.question), };
...pick(poll, [ }
'closed',
'multipleChoice', export function buildPollAnswer(answer: GramJs.TypePollAnswer): ApiPollAnswer | undefined {
'quiz', if (!(answer instanceof GramJs.PollAnswer)) return undefined;
'closePeriod', const { text, option, media, addedBy, date } = answer;
'closeDate',
]), return {
answers, text: buildApiFormattedText(text),
}, option: serializeBytes(option),
results: buildPollResults(pollResults), 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 { const {
results: rawResults, totalVoters, recentVoters, solution, solutionEntities: entities, min, results: rawResults, totalVoters, recentVoters, solution, solutionEntities: entities, min, solutionMedia,
} = pollResults; } = pollResults;
const results = rawResults?.map(({ const results = rawResults?.map(({
option, chosen, correct, voters, option, chosen, correct, voters, recentVoters: recentAnswerVoters,
}) => ({ }) => ({
isChosen: chosen, isChosen: chosen,
isCorrect: correct, isCorrect: correct,
option: serializeBytes(option), option: serializeBytes(option),
votersCount: voters ?? 0, votersCount: voters ?? 0,
recentVoterIds: recentAnswerVoters?.map((peer) => getApiChatIdFromMtpPeer(peer)),
})); }));
if (solutionMedia) {
addMediaToLocalDb(solutionMedia);
}
return { return {
isMin: min, isMin: min,
totalVoters, totalVoters,
recentVoterIds: recentVoters?.map((peer) => getApiChatIdFromMtpPeer(peer)), recentVoterIds: recentVoters?.map((peer) => getApiChatIdFromMtpPeer(peer)),
results, resultByOption: results && buildCollectionByKey(results, 'option'),
solution, solution,
...(entities && { solutionEntities: entities.map(buildApiMessageEntity) }), solutionEntities: entities?.map(buildApiMessageEntity),
solutionMedia: solutionMedia ? buildMessageMediaContent(solutionMedia) : undefined,
}; };
} }

View File

@ -16,13 +16,14 @@ import type {
ApiMessage, ApiMessage,
ApiMessageEntity, ApiMessageEntity,
ApiMessageForwardInfo, ApiMessageForwardInfo,
ApiMessagePoll,
ApiMessageReportResult, ApiMessageReportResult,
ApiMessageThreadInfo, ApiMessageThreadInfo,
ApiNewMediaTodo, ApiNewMediaTodo,
ApiNewPoll, ApiNewPoll,
ApiPeer, ApiPeer,
ApiPhoto, ApiPhoto,
ApiPoll, ApiPollResult,
ApiPreparedInlineMessage, ApiPreparedInlineMessage,
ApiQuickReply, ApiQuickReply,
ApiReplyInfo, ApiReplyInfo,
@ -49,7 +50,7 @@ import {
} from '../../../config'; } from '../../../config';
import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage'; import { getEmojiOnlyCountForMessage } from '../../../global/helpers/getEmojiOnlyCountForMessage';
import { addTimestampEntities } from '../../../util/dates/timestamp'; import { addTimestampEntities } from '../../../util/dates/timestamp';
import { omitUndefined, pick } from '../../../util/iteratees'; import { omitUndefined } from '../../../util/iteratees';
import { toJSNumber } from '../../../util/numbers'; import { toJSNumber } from '../../../util/numbers';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import { interpolateArray } from '../../../util/waveform'; 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<string, ApiPollResult>)
: undefined;
return { return {
mediaType: 'poll', mediaType: 'poll',
id: String(localId), summary: poll.summary,
summary: pick(poll.summary, ['question', 'answers']), results: {
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 { return {
mediaType: 'todo', mediaType: 'todo',
todo: todo.todo, todo: todo.todo,
@ -487,8 +507,8 @@ export function buildLocalMessage({
const resultReplyInfo = replyInfo && buildReplyInfo(replyInfo, chat.isForum); const resultReplyInfo = replyInfo && buildReplyInfo(replyInfo, chat.isForum);
const localPoll = poll && buildNewPoll(poll, localId); const localPoll = poll && buildNewLocalPoll(poll);
const localTodo = todo && buildNewTodo(todo); const localTodo = todo && buildNewLocalTodo(todo);
const localDice = dice ? { const localDice = dice ? {
mediaType: 'dice', mediaType: 'dice',
@ -510,7 +530,7 @@ export function buildLocalMessage({
video: gif || media?.video, video: gif || media?.video,
contact, contact,
storyData: story && { mediaType: 'storyData', ...story }, storyData: story && { mediaType: 'storyData', ...story },
pollId: localPoll?.id, pollId: localPoll?.summary.id,
todo: localTodo, todo: localTodo,
dice: localDice, dice: localDice,
}), }),

View File

@ -6,6 +6,7 @@ import type {
import { LOTTIE_STICKER_MIME_TYPE, VIDEO_STICKER_MIME_TYPE } from '../../../config'; import { LOTTIE_STICKER_MIME_TYPE, VIDEO_STICKER_MIME_TYPE } from '../../../config';
import { compact } from '../../../util/iteratees'; import { compact } from '../../../util/iteratees';
import { addDocumentToLocalDb } from '../helpers/localDb';
import localDb from '../localDb'; import localDb from '../localDb';
import { buildApiPhotoPreviewSizes, buildApiThumbnailFromCached, buildApiThumbnailFromPath } from './common'; import { buildApiPhotoPreviewSizes, buildApiThumbnailFromCached, buildApiThumbnailFromPath } from './common';
@ -15,6 +16,8 @@ export function buildStickerFromDocument(document: GramJs.TypeDocument,
return undefined; return undefined;
} }
addDocumentToLocalDb(document);
const { mimeType, videoThumbs } = document; const { mimeType, videoThumbs } = document;
const stickerAttribute = document.attributes const stickerAttribute = document.attributes
.find((attr: any): attr is GramJs.DocumentAttributeSticker => ( .find((attr: any): attr is GramJs.DocumentAttributeSticker => (
@ -202,12 +205,7 @@ export function processStickerResult(stickers: GramJs.TypeDocument[]) {
return stickers return stickers
.map((document) => { .map((document) => {
if (document instanceof GramJs.Document) { if (document instanceof GramJs.Document) {
const sticker = buildStickerFromDocument(document); return buildStickerFromDocument(document);
if (sticker) {
localDb.documents[String(document.id)] = document;
return sticker;
}
} }
return undefined; return undefined;

View File

@ -8,6 +8,7 @@ import type {
ApiChatFolder, ApiChatFolder,
ApiChatReactions, ApiChatReactions,
ApiDisallowedGiftsSettings, ApiDisallowedGiftsSettings,
ApiDocument,
ApiEmojiStatusType, ApiEmojiStatusType,
ApiFormattedText, ApiFormattedText,
ApiGroupCall, ApiGroupCall,
@ -15,12 +16,13 @@ import type {
ApiInputReplyInfo, ApiInputReplyInfo,
ApiInputStorePaymentPurpose, ApiInputStorePaymentPurpose,
ApiInputSuggestedPostInfo, ApiInputSuggestedPostInfo,
ApiLocation,
ApiMessageEntity, ApiMessageEntity,
ApiMessagePoll,
ApiNewMediaTodo, ApiNewMediaTodo,
ApiNewPoll, ApiNewPoll,
ApiPhoneCall, ApiPhoneCall,
ApiPhoto, ApiPhoto,
ApiPoll,
ApiPremiumGiftCodeOption, ApiPremiumGiftCodeOption,
ApiPrivacyKey, ApiPrivacyKey,
ApiProfileTab, ApiProfileTab,
@ -35,6 +37,7 @@ import type {
ApiThemeParameters, ApiThemeParameters,
ApiTypeCurrencyAmount, ApiTypeCurrencyAmount,
ApiVideo, ApiVideo,
MediaContent,
} from '../../types'; } from '../../types';
import { import {
ApiMessageEntityTypes, 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]; const document = localDb.documents[media.id];
if (!document) { 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); const inputDocument = buildInputDocument(media);
if (!inputDocument) { if (!inputDocument) {
@ -207,48 +214,47 @@ export function buildInputMediaDocument(media: ApiSticker | ApiVideo, spoiler?:
return new GramJs.InputMediaDocument({ id: inputDocument, spoiler }); return new GramJs.InputMediaDocument({ id: inputDocument, spoiler });
} }
export function buildInputPoll(pollParams: ApiNewPoll, randomId: bigint) { export function buildInputPoll(
const { summary, quiz } = pollParams; 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, id: randomId,
publicVoters: summary.isPublic, publicVoters: poll.isPublic,
question: buildInputTextWithEntities(summary.question), question: buildInputTextWithEntities(poll.question),
answers: summary.answers.map(({ text, option }) => { answers: poll.answers.map(({ text, option }) => {
return new GramJs.PollAnswer({ return new GramJs.PollAnswer({
text: buildInputTextWithEntities(text), text: buildInputTextWithEntities(text),
option: deserializeBytes(option), option: deserializeBytes(option),
}); });
}), }),
quiz: summary.quiz, quiz: poll.isQuiz,
multipleChoice: summary.multipleChoice, multipleChoice: poll.isMultipleChoice,
hash: DEFAULT_PRIMITIVES.BIGINT, hash: DEFAULT_PRIMITIVES.BIGINT,
}); });
if (!quiz) { const inputSolutionEntities = solutionEntities?.map(buildMtpMessageEntity);
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) : [];
return new GramJs.InputMediaPoll({ return new GramJs.InputMediaPoll({
poll, poll: inputPoll,
correctAnswers, correctAnswers,
...(solution && { attachedMedia: media?.attachedMedia,
solution, solution,
solutionEntities, solutionEntities: inputSolutionEntities,
}), solutionMedia: media?.solutionMedia,
}); });
} }
export function buildInputPollFromExisting(poll: ApiPoll, shouldClose = false) { export function buildInputPollFromExisting(poll: ApiMessagePoll, shouldClose = false) {
return new GramJs.InputMediaPoll({ return new GramJs.InputMediaPoll({
poll: new GramJs.Poll({ poll: new GramJs.Poll({
id: BigInt(poll.id), id: BigInt(poll.summary.id),
publicVoters: poll.summary.isPublic, publicVoters: poll.summary.isPublic,
question: buildInputTextWithEntities(poll.summary.question), question: buildInputTextWithEntities(poll.summary.question),
answers: poll.summary.answers.map(({ text, option }) => { answers: poll.summary.answers.map(({ text, option }) => {
@ -257,18 +263,95 @@ export function buildInputPollFromExisting(poll: ApiPoll, shouldClose = false) {
option: deserializeBytes(option), option: deserializeBytes(option),
}); });
}), }),
quiz: poll.summary.quiz, quiz: poll.summary.isQuiz,
multipleChoice: poll.summary.multipleChoice, multipleChoice: poll.summary.isMultipleChoice,
closeDate: poll.summary.closeDate, closeDate: poll.summary.closeDate,
closePeriod: poll.summary.closePeriod, closePeriod: poll.summary.closePeriod,
closed: shouldClose ? true : poll.summary.closed, closed: shouldClose ? true : poll.summary.isClosed,
hash: DEFAULT_PRIMITIVES.BIGINT, 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 correctAnswers: poll.summary.answers.map((answer, index) => {
?.map((result, index) => (result.isCorrect ? index : -1)) const result = poll.results.resultByOption?.[answer.option];
.filter((i) => i !== -1), return result?.isCorrect ? index : -1;
}).filter((i) => i !== -1),
attachedMedia: buildInputMediaFromContent(poll.attachedMedia),
solution: poll.results.solution, solution: poll.results.solution,
solutionEntities: poll.results.solutionEntities?.map(buildMtpMessageEntity), 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,
}); });
} }

View File

@ -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) { 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) { 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;
} }
} }

View File

@ -18,12 +18,12 @@ import type {
ApiInputSuggestedPostInfo, ApiInputSuggestedPostInfo,
ApiMessage, ApiMessage,
ApiMessageEntity, ApiMessageEntity,
ApiMessagePoll,
ApiMessageSearchContext, ApiMessageSearchContext,
ApiMessageSearchType, ApiMessageSearchType,
ApiNewMediaTodo, ApiNewMediaTodo,
ApiOnProgress, ApiOnProgress,
ApiPeer, ApiPeer,
ApiPoll,
ApiReaction, ApiReaction,
ApiSearchPostsFlood, ApiSearchPostsFlood,
ApiSendMessageAction, ApiSendMessageAction,
@ -64,7 +64,7 @@ import {
import { buildApiComposedMessageWithAI, buildApiFormattedText } from '../apiBuilders/common'; import { buildApiComposedMessageWithAI, buildApiFormattedText } from '../apiBuilders/common';
import { buildApiTopicWithState } from '../apiBuilders/forums'; import { buildApiTopicWithState } from '../apiBuilders/forums';
import { import {
buildMessageMediaContent, buildMessageTextContent, buildPollFromMedia, buildMessageMediaContent, buildMessagePollFromMedia, buildMessageTextContent,
buildWebPageFromMedia, buildWebPageFromMedia,
} from '../apiBuilders/messageContent'; } from '../apiBuilders/messageContent';
import { import {
@ -461,7 +461,28 @@ export function sendApiMessage(
} else if (gif) { } else if (gif) {
media = buildInputMediaDocument(gif); media = buildInputMediaDocument(gif);
} else if (poll) { } 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) { } else if (todo) {
media = buildInputTodo(todo); media = buildInputTodo(todo);
} else if (story) { } else if (story) {
@ -1856,7 +1877,7 @@ export async function closePoll({
}: { }: {
chat: ApiChat; chat: ApiChat;
messageId: number; messageId: number;
poll: ApiPoll; poll: ApiMessagePoll;
}) { }) {
const { id, accessHash } = chat; const { id, accessHash } = chat;
@ -2498,7 +2519,7 @@ function handleLocalMessageUpdate(
} }
let newContent: MediaContent | undefined; let newContent: MediaContent | undefined;
let poll: ApiPoll | undefined; let poll: ApiMessagePoll | undefined;
let webPage: ApiWebPage | undefined; let webPage: ApiWebPage | undefined;
if (messageUpdate instanceof GramJs.UpdateShortSentMessage) { if (messageUpdate instanceof GramJs.UpdateShortSentMessage) {
if (localMessage.content.text && messageUpdate.entities) { if (localMessage.content.text && messageUpdate.entities) {
@ -2513,7 +2534,7 @@ function handleLocalMessageUpdate(
peerId: buildPeer(localMessage.chatId), id: messageUpdate.id, peerId: buildPeer(localMessage.chatId), id: messageUpdate.id,
}), }),
}; };
poll = buildPollFromMedia(messageUpdate.media); poll = buildMessagePollFromMedia(messageUpdate.media);
webPage = buildWebPageFromMedia(messageUpdate.media); webPage = buildWebPageFromMedia(messageUpdate.media);
} }

View File

@ -114,12 +114,6 @@ export async function fetchAvailableEffects() {
const documentsMap = new Map(result.documents.map((doc) => [String(doc.id), doc])); 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 effects = result.effects.map(buildApiAvailableEffect);
const stickers: ApiSticker[] = []; const stickers: ApiSticker[] = [];
@ -127,12 +121,12 @@ export async function fetchAvailableEffects() {
for (const effect of effects) { for (const effect of effects) {
if (effect.effectAnimationId) { if (effect.effectAnimationId) {
const document = documentsMap.get(effect.effectStickerId); const document = documentsMap.get(effect.effectAnimationId);
const emoji = document && buildStickerFromDocument(document, false, effect.isPremium); const emoji = document && buildStickerFromDocument(document, false, effect.isPremium);
if (emoji) emojis.push(emoji); if (emoji) emojis.push(emoji);
} else { } else {
const document = localDb.documents[effect.effectStickerId]; const document = documentsMap.get(effect.effectStickerId);
const sticker = buildStickerFromDocument(document); const sticker = document && buildStickerFromDocument(document);
if (sticker) { if (sticker) {
stickers.push(sticker); stickers.push(sticker);
} }

View File

@ -1,13 +1,13 @@
import { Api as GramJs } from '../../../lib/gramjs'; import { Api as GramJs } from '../../../lib/gramjs';
import type { import type {
ApiChat, ApiPoll, ApiThreadInfo, ApiUser, ApiChat, ApiMessagePoll, ApiThreadInfo, ApiUser,
ApiWebPage, ApiWebPage,
} from '../../types'; } from '../../types';
import { buildCollectionByKey } from '../../../util/iteratees'; import { buildCollectionByKey } from '../../../util/iteratees';
import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { buildApiChatFromPreview } from '../apiBuilders/chats';
import { buildPollFromMedia, buildWebPageFromMedia } from '../apiBuilders/messageContent'; import { buildMessagePollFromMedia, buildWebPageFromMedia } from '../apiBuilders/messageContent';
import { buildApiThreadInfoFromMessage } from '../apiBuilders/messages'; import { buildApiThreadInfoFromMessage } from '../apiBuilders/messages';
import { buildApiUser } from '../apiBuilders/users'; import { buildApiUser } from '../apiBuilders/users';
import { addChatToLocalDb, addMessageToLocalDb, addUserToLocalDb } from '../helpers/localDb'; import { addChatToLocalDb, addMessageToLocalDb, addUserToLocalDb } from '../helpers/localDb';
@ -24,7 +24,7 @@ export function processAndUpdateEntities(response?: GramJs.AnyRequest['__respons
let userById: Record<string, ApiUser> | undefined; let userById: Record<string, ApiUser> | undefined;
let chatById: Record<string, ApiChat> | undefined; let chatById: Record<string, ApiChat> | undefined;
const threadInfos: ApiThreadInfo[] | undefined = []; const threadInfos: ApiThreadInfo[] | undefined = [];
const polls: ApiPoll[] | undefined = []; const polls: ApiMessagePoll[] | undefined = [];
const webPages: ApiWebPage[] | undefined = []; const webPages: ApiWebPage[] | undefined = [];
if ('users' in response && Array.isArray(response.users) && TYPE_USER.has(response.users[0]?.className)) { 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) { if ('media' in message && message.media) {
const poll = buildPollFromMedia(message.media); const poll = buildMessagePollFromMedia(message.media);
if (poll) { if (poll) {
polls.push(poll); polls.push(poll);
} }

View File

@ -3,7 +3,10 @@ import { UpdateConnectionState, UpdateServerTimeOffset } from '../../../lib/gram
import type { GroupCallConnectionData } from '../../../lib/secret-sauce'; import type { GroupCallConnectionData } from '../../../lib/secret-sauce';
import { import {
type ApiMessage, type ApiPoll, type ApiStory, type ApiStorySkipped, type ApiMessage,
type ApiMessagePoll,
type ApiStory,
type ApiStorySkipped,
type ApiUpdateConnectionStateType, type ApiUpdateConnectionStateType,
type ApiWebPage, type ApiWebPage,
MAIN_THREAD_ID, MAIN_THREAD_ID,
@ -11,7 +14,7 @@ import {
import { DEBUG, GENERAL_TOPIC_ID } from '../../../config'; import { DEBUG, GENERAL_TOPIC_ID } from '../../../config';
import { import {
omit, pick, omit, omitUndefined, pick,
} from '../../../util/iteratees'; } from '../../../util/iteratees';
import { getServerTimeOffset, setServerTimeOffset } from '../../../util/serverTime'; import { getServerTimeOffset, setServerTimeOffset } from '../../../util/serverTime';
import { buildApiBotCommand, buildApiBotMenuButton } from '../apiBuilders/bots'; import { buildApiBotCommand, buildApiBotMenuButton } from '../apiBuilders/bots';
@ -38,8 +41,8 @@ import { omitVirtualClassFields } from '../apiBuilders/helpers';
import { import {
buildApiMessageExtendedMediaPreview, buildApiMessageExtendedMediaPreview,
buildBoughtMediaContent, buildBoughtMediaContent,
buildMessagePollFromMedia,
buildPoll, buildPoll,
buildPollFromMedia,
buildPollResults, buildPollResults,
buildWebPage, buildWebPage,
buildWebPageFromMedia, buildWebPageFromMedia,
@ -134,7 +137,7 @@ export function updater(update: Update) {
|| update instanceof GramJs.UpdateShortMessage || update instanceof GramJs.UpdateShortMessage
) { ) {
let message: ApiMessage | undefined; let message: ApiMessage | undefined;
let poll: ApiPoll | undefined; let poll: ApiMessagePoll | undefined;
let webPage: ApiWebPage | undefined; let webPage: ApiWebPage | undefined;
let shouldForceReply: boolean | undefined; let shouldForceReply: boolean | undefined;
@ -159,7 +162,7 @@ export function updater(update: Update) {
message = buildApiMessage(mtpMessage)!; message = buildApiMessage(mtpMessage)!;
if (mtpMessage instanceof GramJs.Message) { if (mtpMessage instanceof GramJs.Message) {
poll = mtpMessage.media && buildPollFromMedia(mtpMessage.media); poll = mtpMessage.media && buildMessagePollFromMedia(mtpMessage.media);
webPage = mtpMessage.media && buildWebPageFromMedia(mtpMessage.media); webPage = mtpMessage.media && buildWebPageFromMedia(mtpMessage.media);
} }
@ -303,7 +306,7 @@ export function updater(update: Update) {
if (!message) return; if (!message) return;
const poll = update.message instanceof GramJs.Message && update.message.media 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 const webPage = update.message instanceof GramJs.Message && update.message.media
? buildWebPageFromMedia(update.message.media) : undefined; ? buildWebPageFromMedia(update.message.media) : undefined;
@ -358,7 +361,7 @@ export function updater(update: Update) {
const message = omit(buildApiMessage(mtpMessage)!, ['isOutgoing']) as ApiMessage; const message = omit(buildApiMessage(mtpMessage)!, ['isOutgoing']) as ApiMessage;
const poll = mtpMessage instanceof GramJs.Message && mtpMessage.media const poll = mtpMessage instanceof GramJs.Message && mtpMessage.media
? buildPollFromMedia(mtpMessage.media) : undefined; ? buildMessagePollFromMedia(mtpMessage.media) : undefined;
const webPage = mtpMessage instanceof GramJs.Message && mtpMessage.media const webPage = mtpMessage instanceof GramJs.Message && mtpMessage.media
? buildWebPageFromMedia(mtpMessage.media) : undefined; ? buildWebPageFromMedia(mtpMessage.media) : undefined;
@ -472,22 +475,14 @@ export function updater(update: Update) {
}); });
} else if (update instanceof GramJs.UpdateMessagePoll) { } else if (update instanceof GramJs.UpdateMessagePoll) {
const { pollId, poll, results } = update; const { pollId, poll, results } = update;
if (poll) { const apiPoll = poll && buildPoll(poll);
const apiPoll = buildPoll(poll, results); const pollResults = buildPollResults(results);
sendApiUpdate({ sendApiUpdate({
'@type': 'updateMessagePoll', '@type': 'updateMessagePoll',
pollId: String(pollId), pollId: pollId.toString(),
pollUpdate: apiPoll, pollUpdate: omitUndefined({ summary: apiPoll, results: pollResults }),
}); });
} else {
const pollResults = buildPollResults(results);
sendApiUpdate({
'@type': 'updateMessagePoll',
pollId: String(pollId),
pollUpdate: { results: pollResults },
});
}
} else if (update instanceof GramJs.UpdateMessagePollVote) { } else if (update instanceof GramJs.UpdateMessagePollVote) {
sendApiUpdate({ sendApiUpdate({
'@type': 'updateMessagePollVote', '@type': 'updateMessagePollVote',

View File

@ -1,5 +1,5 @@
import type { ApiGroupCall, ApiPhoneCallDiscardReason } from './calls'; 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'; import type { ApiStarGiftRegular, ApiStarGiftUnique, ApiTypeCurrencyAmount } from './stars';
interface ActionMediaType { interface ActionMediaType {
@ -330,6 +330,16 @@ export interface ApiMessageActionTodoAppendTasks extends ActionMediaType {
items: ApiTodoItem[]; 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 { export interface ApiMessageActionStarGiftPurchaseOffer extends ActionMediaType {
type: 'starGiftPurchaseOffer'; type: 'starGiftPurchaseOffer';
isAccepted?: true; isAccepted?: true;
@ -388,6 +398,7 @@ export type ApiMessageAction = ApiMessageActionUnsupported | ApiMessageActionCha
| ApiMessageActionGiftTon | ApiMessageActionPrizeStars | ApiMessageActionStarGift | ApiMessageActionStarGiftUnique | ApiMessageActionGiftTon | ApiMessageActionPrizeStars | ApiMessageActionStarGift | ApiMessageActionStarGiftUnique
| ApiMessageActionPaidMessagesRefunded | ApiMessageActionPaidMessagesPrice | ApiMessageActionSuggestedPostApproval | ApiMessageActionPaidMessagesRefunded | ApiMessageActionPaidMessagesPrice | ApiMessageActionSuggestedPostApproval
| ApiMessageActionSuggestedPostSuccess | ApiMessageActionSuggestedPostRefund | ApiMessageActionTodoCompletions | ApiMessageActionSuggestedPostSuccess | ApiMessageActionSuggestedPostRefund | ApiMessageActionTodoCompletions
| ApiMessageActionTodoAppendTasks | ApiMessageActionStarGiftPurchaseOffer | ApiMessageActionTodoAppendTasks | ApiMessageActionPollAppendAnswer | ApiMessageActionPollDeleteAnswer
| ApiMessageActionStarGiftPurchaseOffer
| ApiMessageActionStarGiftPurchaseOfferDeclined | ApiMessageActionNewCreatorPending | ApiMessageActionStarGiftPurchaseOfferDeclined | ApiMessageActionNewCreatorPending
| ApiMessageActionChangeCreator | ApiMessageActionNoForwardsToggle | ApiMessageActionNoForwardsRequest; | ApiMessageActionChangeCreator | ApiMessageActionNoForwardsToggle | ApiMessageActionNoForwardsRequest;

View File

@ -5,7 +5,7 @@ import type {
ApiWebDocument, ApiWebDocument,
} from './bots'; } from './bots';
import type { ApiMessageAction } from './messageActions'; import type { ApiMessageAction } from './messageActions';
import type { ApiPeerNotifySettings, ApiRestrictionReason } from './misc'; import type { ApiAttachment, ApiPeerNotifySettings, ApiRestrictionReason } from './misc';
import type { import type {
ApiLabeledPrice, ApiLabeledPrice,
} from './payments'; } from './payments';
@ -186,6 +186,9 @@ export type ApiPaidMedia = {
export interface ApiPollAnswer { export interface ApiPollAnswer {
text: ApiFormattedText; text: ApiFormattedText;
option: string; option: string;
media?: MediaContent;
addedByPeerId?: string;
date?: number;
} }
export interface ApiPollResult { export interface ApiPollResult {
@ -193,29 +196,42 @@ export interface ApiPollResult {
isCorrect?: true; isCorrect?: true;
option: string; option: string;
votersCount: number; votersCount: number;
recentVoterIds?: string[];
} }
export interface ApiPoll { export interface ApiPoll {
mediaType: 'poll';
id: string; id: string;
summary: { hash: string;
closed?: true; isClosed?: true;
isPublic?: true; isPublic?: true;
multipleChoice?: true; isMultipleChoice?: true;
quiz?: true; isQuiz?: true;
question: ApiFormattedText; canAddAnswers?: true;
answers: ApiPollAnswer[]; isRevoteDisabled?: true;
closePeriod?: number; shouldShuffleAnswers?: true;
closeDate?: number; shouldHideResultsUntilClose?: true;
}; isCreator?: true;
results: { question: ApiFormattedText;
isMin?: true; answers: ApiPollAnswer[];
results?: ApiPollResult[]; closePeriod?: number;
totalVoters?: number; closeDate?: number;
recentVoterIds?: string[]; }
solution?: string;
solutionEntities?: ApiMessageEntity[]; export interface ApiPollResults {
}; isMin?: true;
resultByOption?: Record<string, ApiPollResult>;
totalVoters?: number;
recentVoterIds?: string[];
solution?: string;
solutionEntities?: ApiMessageEntity[];
solutionMedia?: MediaContent;
}
export interface ApiMessagePoll {
mediaType: 'poll';
summary: ApiPoll;
results: ApiPollResults;
attachedMedia?: MediaContent;
} }
export interface ApiInvoice { export interface ApiInvoice {
@ -339,12 +355,12 @@ export type ApiGiveawayResults = {
}; };
export type ApiNewPoll = { export type ApiNewPoll = {
summary: ApiPoll['summary']; summary: ApiPoll;
quiz?: { correctAnswers?: number[];
correctAnswers: string[]; solution?: string;
solution?: string; solutionEntities?: ApiMessageEntity[];
solutionEntities?: ApiMessageEntity[]; attachedMedia?: ApiAttachment;
}; solutionMedia?: ApiAttachment;
}; };
export interface ApiTodoItem { export interface ApiTodoItem {
@ -666,7 +682,7 @@ export type MediaContainer = {
}; };
export type StatefulMediaContent = { export type StatefulMediaContent = {
poll?: ApiPoll; poll?: ApiMessagePoll;
story?: ApiStory; story?: ApiStory;
webPage?: ApiWebPage; webPage?: ApiWebPage;
}; };

View File

@ -24,9 +24,9 @@ import type {
ApiFormattedText, ApiFormattedText,
ApiMediaExtendedPreview, ApiMediaExtendedPreview,
ApiMessage, ApiMessage,
ApiMessagePoll,
ApiPaidReactionPrivacyType, ApiPaidReactionPrivacyType,
ApiPhoto, ApiPhoto,
ApiPoll,
ApiQuickReply, ApiQuickReply,
ApiReaction, ApiReaction,
ApiReactions, ApiReactions,
@ -237,7 +237,7 @@ export type ApiUpdateNewScheduledMessage = {
id: number; id: number;
message: ApiMessage; message: ApiMessage;
wasDrafted?: boolean; wasDrafted?: boolean;
poll?: ApiPoll; poll?: ApiMessagePoll;
webPage?: ApiWebPage; webPage?: ApiWebPage;
}; };
@ -248,7 +248,7 @@ export type ApiUpdateNewMessage = {
message: ApiMessage; message: ApiMessage;
shouldForceReply?: boolean; shouldForceReply?: boolean;
wasDrafted?: boolean; wasDrafted?: boolean;
poll?: ApiPoll; poll?: ApiMessagePoll;
webPage?: ApiWebPage; webPage?: ApiWebPage;
}; };
@ -256,7 +256,7 @@ export type ApiUpdateMessage = {
'@type': 'updateMessage'; '@type': 'updateMessage';
chatId: string; chatId: string;
id: number; id: number;
poll?: ApiPoll; poll?: ApiMessagePoll;
webPage?: ApiWebPage; webPage?: ApiWebPage;
shouldForceReply?: boolean; shouldForceReply?: boolean;
isFromNew?: true; isFromNew?: true;
@ -274,7 +274,7 @@ export type ApiUpdateScheduledMessage = {
'@type': 'updateScheduledMessage'; '@type': 'updateScheduledMessage';
chatId: string; chatId: string;
id: number; id: number;
poll?: ApiPoll; poll?: ApiMessagePoll;
webPage?: ApiWebPage; webPage?: ApiWebPage;
isFromNew?: true; isFromNew?: true;
} & ( } & (
@ -291,7 +291,7 @@ export type ApiUpdateQuickReplyMessage = {
'@type': 'updateQuickReplyMessage'; '@type': 'updateQuickReplyMessage';
id: number; id: number;
message: Partial<ApiMessage>; message: Partial<ApiMessage>;
poll?: ApiPoll; poll?: ApiMessagePoll;
webPage?: ApiWebPage; webPage?: ApiWebPage;
}; };
@ -335,7 +335,7 @@ export type ApiUpdateScheduledMessageSendSucceeded = {
chatId: string; chatId: string;
localId: number; localId: number;
message: ApiMessage; message: ApiMessage;
poll?: ApiPoll; poll?: ApiMessagePoll;
webPage?: ApiWebPage; webPage?: ApiWebPage;
}; };
@ -344,7 +344,7 @@ export type ApiUpdateMessageSendSucceeded = {
chatId: string; chatId: string;
localId: number; localId: number;
message: ApiMessage; message: ApiMessage;
poll?: ApiPoll; poll?: ApiMessagePoll;
webPage?: ApiWebPage; webPage?: ApiWebPage;
}; };
@ -385,7 +385,7 @@ export type ApiUpdateChannelMessages = {
export type ApiUpdateMessagePoll = { export type ApiUpdateMessagePoll = {
'@type': 'updateMessagePoll'; '@type': 'updateMessagePoll';
pollId: string; pollId: string;
pollUpdate: Partial<ApiPoll>; pollUpdate: Partial<ApiMessagePoll>;
}; };
export type ApiUpdateMessagePollVote = { export type ApiUpdateMessagePollVote = {
@ -895,7 +895,7 @@ export type ApiUpdateEntities = {
users?: Record<string, ApiUser>; users?: Record<string, ApiUser>;
chats?: Record<string, ApiChat>; chats?: Record<string, ApiChat>;
threadInfos?: ApiThreadInfo[]; threadInfos?: ApiThreadInfo[];
polls?: ApiPoll[]; polls?: ApiMessagePoll[];
webPages?: ApiWebPage[]; webPages?: ApiWebPage[];
}; };

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><path d="M7.523 14.376a2.284 2.284 0 0 0-.19 3.016l.19.216 13.71 13.71a2.284 2.284 0 0 0 3.421-3.016l-.19-.216-12.092-12.093L24.463 3.9a2.284 2.284 0 0 0 .19-3.016l-.19-.216a2.284 2.284 0 0 0-3.015-.19l-.215.19z"/></svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@ -810,13 +810,27 @@
"CallAgain" = "Call Again"; "CallAgain" = "Call Again";
"CallBack" = "Call Back"; "CallBack" = "Call Back";
"CallMessageWithDuration" = "{time} ({duration})"; "CallMessageWithDuration" = "{time} ({duration})";
"PollSubmitVotes" = "VOTE"; "PollSubmitVotes" = "Vote";
"PollViewResults" = "VIEW RESULTS"; "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"; "ChatQuizTotalVotesEmpty" = "No answers yet";
"ChatPollTotalVotesResultEmpty" = "No votes"; "ChatPollTotalVotesResultEmpty" = "No votes";
"Answer_one" = "{count} answer"; "Answer_one" = "{count} answer";
"Answer_other" = "{count} answers"; "Answer_other" = "{count} answers";
"PollAnsweredCount_one" = "{count} answered";
"PollAnsweredCount_other" = "{count} answered";
"Vote" = "Vote"; "Vote" = "Vote";
"VoteCount_one" = "{count} vote";
"VoteCount_other" = "{count} votes";
"TimeIn" = "in {time}";
"MessageRecommendedLabel" = "recommended"; "MessageRecommendedLabel" = "recommended";
"SponsoredMessageAd" = "Ad"; "SponsoredMessageAd" = "Ad";
"SponsoredMessageAdWhatIsThis" = "what's this?"; "SponsoredMessageAdWhatIsThis" = "what's this?";
@ -1726,6 +1740,7 @@
"StarsSubscribeInfoLink" = "https://telegram.org/tos/stars"; "StarsSubscribeInfoLink" = "https://telegram.org/tos/stars";
"StarsPerMonth" = "⭐️{amount}/month"; "StarsPerMonth" = "⭐️{amount}/month";
"StarsBalance" = "Balance"; "StarsBalance" = "Balance";
"OpenMapWith" = "Open Map in";
"OpenApp" = "Open App"; "OpenApp" = "Open App";
"PopularApps" = "Popular Apps"; "PopularApps" = "Popular Apps";
"SearchApps" = "Search Apps"; "SearchApps" = "Search Apps";
@ -2376,6 +2391,10 @@
"MessageActionAppendTodoYou" = "You added a new task \"{task}\" to {list}"; "MessageActionAppendTodoYou" = "You added a new task \"{task}\" to {list}";
"MessageActionAppendTodoMultiple" = "{peer} added {tasks} to {list}"; "MessageActionAppendTodoMultiple" = "{peer} added {tasks} to {list}";
"MessageActionAppendTodoMultipleYou" = "You 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"; "PremiumMore" = "More";
"SubscribeToTelegramPremiumForToggleTask" = "Subscribe to **Telegram Premium** to toggle tasks"; "SubscribeToTelegramPremiumForToggleTask" = "Subscribe to **Telegram Premium** to toggle tasks";
"SubscribeToTelegramPremiumForCreateToDo" = "Subscribe to **Telegram Premium** to create Checklists"; "SubscribeToTelegramPremiumForCreateToDo" = "Subscribe to **Telegram Premium** to create Checklists";

View File

@ -5,6 +5,7 @@ $animation-time: 200ms;
.root { .root {
display: inline-flex; display: inline-flex;
font-variant-numeric: tabular-nums;
white-space: pre; white-space: pre;
&[dir="rtl"] { &[dir="rtl"] {

View File

@ -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;
}

View File

@ -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<HTMLDivElement>;
};
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 (
<div
className={buildClassName(styles.root, onClick && styles.interactive, className)}
style={`width: ${width}px; height: ${height}px;`}
onClick={onClick}
>
{mapBlobUrl ? (
<img
src={mapBlobUrl}
alt=""
className={styles.map}
draggable={false}
/>
) : (
<Skeleton className={styles.skeleton} width={width} height={height} animation="wave" />
)}
{shouldShowPin && <img src={mapPin} alt="" className={styles.pin} draggable={false} />}
</div>
);
};
export default memo(CompactMapPreview);

View File

@ -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;
}

View File

@ -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<HTMLDivElement>;
};
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<HTMLDivElement>();
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<HTMLImageElement>({
hasMediaData: Boolean(resolvedPreviewUrl && !shouldShowSpoiler),
});
const { ref: videoRef } = useMediaTransition<HTMLVideoElement>({
hasMediaData: Boolean(resolvedPreviewVideoUrl && !shouldShowSpoiler),
});
const spoilerThumbDataUri = thumbDataUri || resolvedPreviewUrl;
const style = previewSize ? `width: ${previewSize}px; height: ${previewSize}px` : undefined;
return (
<div
ref={ref}
id={id}
className={buildClassName(
styles.root,
className,
isPictogram && styles.pictogram,
isRound && styles.round,
actionIcon && styles.withActionIcon,
onClick && styles.interactive,
)}
style={style}
onClick={onClick}
>
{thumbDataUri && (
<canvas
ref={canvasRef}
className={buildClassName('thumbnail', styles.thumb)}
/>
)}
{!shouldShowSpoiler && resolvedPreviewUrl && (
<img
ref={imageRef}
src={resolvedPreviewUrl}
alt=""
className={buildClassName('full-media', styles.media)}
draggable={false}
/>
)}
{!shouldShowSpoiler && resolvedPreviewVideoUrl && (
<OptimizedVideo
ref={videoRef}
className={buildClassName('full-media', styles.media)}
src={resolvedPreviewVideoUrl}
canPlay={isIntersectingForPlaying}
poster={thumbDataUri}
loop
playsInline
muted
disablePictureInPicture
/>
)}
<MediaSpoiler
thumbDataUri={spoilerThumbDataUri}
isVisible={Boolean(shouldShowSpoiler)}
width={previewSize}
height={previewSize}
/>
{isProtected && <span className={buildClassName('protector', styles.protector)} />}
{actionIcon && <Icon name={actionIcon} className={styles.actionIcon} />}
</div>
);
};
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);

View File

@ -1,15 +1,14 @@
import { import {
memo, useEffect, useRef, useState, memo, useEffect, useMemo, useRef, useState,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import { getActions } from '../../global'; 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 type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { import {
getDocumentMediaHash, getDocumentMediaHash,
getMediaFormat, getMediaFormat,
getMediaThumbUri,
getMediaTransferState, getMediaTransferState,
isDocumentVideo, isDocumentVideo,
} from '../../global/helpers'; } from '../../global/helpers';
@ -20,7 +19,6 @@ import { preloadDocumentMedia } from './helpers/preloadDocumentMedia';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useMedia from '../../hooks/useMedia';
import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress'; import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
import useOldLang from '../../hooks/useOldLang'; import useOldLang from '../../hooks/useOldLang';
@ -117,9 +115,10 @@ const Document = ({
); );
const hasPreview = getDocumentHasPreview(document); const hasPreview = getDocumentHasPreview(document);
const thumbDataUri = hasPreview ? getMediaThumbUri(document) : undefined; const previewMedia = useMemo<MediaContent | undefined>(
const localBlobUrl = hasPreview ? document.previewBlobUrl : undefined; () => (hasPreview ? { document } : undefined),
const previewData = useMedia(getDocumentMediaHash(document, 'pictogram'), !isIntersecting); [document, hasPreview],
);
const shouldForceDownload = document.innerMediaType === 'photo' && document.mediaSize const shouldForceDownload = document.innerMediaType === 'photo' && document.mediaSize
&& !document.mediaSize.fromDocumentAttribute && !document.mediaSize.fromPreload; && !document.mediaSize.fromDocumentAttribute && !document.mediaSize.fromPreload;
@ -199,8 +198,8 @@ const Document = ({
extension={extension} extension={extension}
size={size} size={size}
timestamp={datetime} timestamp={datetime}
thumbnailDataUri={thumbDataUri} previewMedia={previewMedia}
previewData={localBlobUrl || previewData} observeIntersection={observeIntersection}
previewSize={fileSize} previewSize={fileSize}
isTransferring={isTransferring} isTransferring={isTransferring}
isUploading={isUploading} isUploading={isUploading}

View File

@ -1,27 +1,26 @@
import type { ElementRef } from '../../lib/teact/teact'; import type { ElementRef } from '../../lib/teact/teact';
import { import {
memo, useRef, useState, memo, useRef,
} from '../../lib/teact/teact'; } from '../../lib/teact/teact';
import type { ApiAttachment, MediaContent } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { IconName } from '../../types/icons'; import type { IconName } from '../../types/icons';
import { IS_CANVAS_FILTER_SUPPORTED } from '../../util/browser/windowEnvironment';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { formatMediaDateTime, formatPastTimeShort } from '../../util/dates/oldDateFormat'; import { formatMediaDateTime, formatPastTimeShort } from '../../util/dates/oldDateFormat';
import { getColorFromExtension } from './helpers/documentInfo'; import { getColorFromExtension } from './helpers/documentInfo';
import { getDocumentThumbnailDimensions } from './helpers/mediaDimensions'; import { getDocumentThumbnailDimensions } from './helpers/mediaDimensions';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
import useAppLayout from '../../hooks/useAppLayout';
import useCanvasBlur from '../../hooks/useCanvasBlur';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useMediaTransitionDeprecated from '../../hooks/useMediaTransitionDeprecated';
import useOldLang from '../../hooks/useOldLang'; import useOldLang from '../../hooks/useOldLang';
import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated'; import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated';
import Link from '../ui/Link'; import Link from '../ui/Link';
import ProgressSpinner from '../ui/ProgressSpinner'; import ProgressSpinner from '../ui/ProgressSpinner';
import AnimatedFileSize from './AnimatedFileSize'; import AnimatedFileSize from './AnimatedFileSize';
import CompactMediaPreview, { canRenderCompactMediaPreview } from './CompactMediaPreview';
import Icon from './icons/Icon'; import Icon from './icons/Icon';
import './File.scss'; import './File.scss';
@ -36,8 +35,9 @@ type OwnProps = {
size: number; size: number;
timestamp?: number; timestamp?: number;
sender?: string; sender?: string;
thumbnailDataUri?: string; previewMedia?: MediaContent;
previewData?: string; previewAttachment?: ApiAttachment;
observeIntersection?: ObserveFn;
className?: string; className?: string;
previewSize?: FileSize; previewSize?: FileSize;
isTransferring?: boolean; isTransferring?: boolean;
@ -58,8 +58,8 @@ const File = ({
extension = '', extension = '',
timestamp, timestamp,
sender, sender,
thumbnailDataUri, previewMedia,
previewData, previewAttachment,
className, className,
previewSize = 'medium', previewSize = 'medium',
isTransferring, isTransferring,
@ -68,6 +68,7 @@ const File = ({
isSelected, isSelected,
transferProgress, transferProgress,
actionIcon, actionIcon,
observeIntersection,
onClick, onClick,
onDateClick, onDateClick,
}: OwnProps) => { }: OwnProps) => {
@ -78,12 +79,6 @@ const File = ({
elementRef = ref; 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 { const {
shouldRender: shouldSpinnerRender, shouldRender: shouldSpinnerRender,
transitionClassNames: spinnerClassNames, transitionClassNames: spinnerClassNames,
@ -91,7 +86,8 @@ const File = ({
const color = getColorFromExtension(extension); const color = getColorFromExtension(extension);
const { width, height } = getDocumentThumbnailDimensions(previewSize); const { width } = getDocumentThumbnailDimensions(previewSize);
const shouldRenderPreview = canRenderCompactMediaPreview(previewMedia, previewAttachment);
const fullClassName = buildClassName( const fullClassName = buildClassName(
'File', 'File',
@ -109,23 +105,14 @@ const File = ({
</div> </div>
)} )}
<div className="file-icon-container" onClick={isUploading ? undefined : onClick}> <div className="file-icon-container" onClick={isUploading ? undefined : onClick}>
{thumbnailDataUri || previewData ? ( {shouldRenderPreview ? (
<div className="file-preview media-inner"> <CompactMediaPreview
<img className="file-preview media-inner"
src={previewData} media={previewMedia}
className="full-media" attachment={previewAttachment}
width={width} size={width}
height={height} observeIntersectionForLoading={observeIntersection}
draggable={false} />
alt=""
/>
{withThumb && (
<canvas
ref={thumbRef}
className={buildClassName('thumbnail', thumbClassNames)}
/>
)}
</div>
) : ( ) : (
<div className={`file-icon ${color}`}> <div className={`file-icon ${color}`}>
{extension.length <= 4 && ( {extension.length <= 4 && (

View File

@ -2,7 +2,7 @@ import { memo } from '../../lib/teact/teact';
import { withGlobal } from '../../global'; import { withGlobal } from '../../global';
import type { import type {
ApiFormattedText, ApiMessage, ApiPoll, ApiTypeStory, ApiFormattedText, ApiMessage, ApiMessagePoll, ApiTypeStory,
ApiWebPage, ApiWebPage,
} from '../../api/types'; } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
@ -42,7 +42,7 @@ type OwnProps = {
}; };
type StateProps = { type StateProps = {
poll?: ApiPoll; poll?: ApiMessagePoll;
story?: ApiTypeStory; story?: ApiTypeStory;
webPage?: ApiWebPage; webPage?: ApiWebPage;
}; };

View File

@ -238,58 +238,6 @@
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
border-radius: 0.25rem; 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 { &.inside-input {

View File

@ -1,4 +1,4 @@
import { useMemo, useRef } from '../../../lib/teact/teact'; import { useMemo } from '../../../lib/teact/teact';
import type { import type {
ApiChat, ApiChat,
@ -23,21 +23,16 @@ import buildClassName from '../../../util/buildClassName';
import { formatScheduledDateTime } from '../../../util/dates/oldDateFormat'; import { formatScheduledDateTime } from '../../../util/dates/oldDateFormat';
import { isUserId } from '../../../util/entities/ids'; import { isUserId } from '../../../util/entities/ids';
import { formatStarsAsIcon, formatTonAsIcon } from '../../../util/localization/format'; import { formatStarsAsIcon, formatTonAsIcon } from '../../../util/localization/format';
import { getPictogramDimensions } from '../helpers/mediaDimensions';
import renderText from '../helpers/renderText'; import renderText from '../helpers/renderText';
import { renderTextWithEntities } from '../helpers/renderTextWithEntities'; 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 useLang from '../../../hooks/useLang';
import useMedia from '../../../hooks/useMedia';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import useMessageTranslation from '../../middle/message/hooks/useMessageTranslation'; import useMessageTranslation from '../../middle/message/hooks/useMessageTranslation';
import RippleEffect from '../../ui/RippleEffect'; import RippleEffect from '../../ui/RippleEffect';
import CompactMediaPreview, { canRenderCompactMediaPreview } from '../CompactMediaPreview';
import Icon from '../icons/Icon'; import Icon from '../icons/Icon';
import MediaSpoiler from '../MediaSpoiler';
import MessageSummary from '../MessageSummary'; import MessageSummary from '../MessageSummary';
import PeerColorWrapper from '../PeerColorWrapper'; import PeerColorWrapper from '../PeerColorWrapper';
@ -96,9 +91,6 @@ const EmbeddedMessage = ({
onClick, onClick,
onPictogramClick, onPictogramClick,
}: OwnProps) => { }: OwnProps) => {
const ref = useRef<HTMLDivElement>();
const isIntersecting = useIsIntersecting(ref, observeIntersectionForLoading);
const containedMedia: MediaContainer | undefined = useMemo(() => { const containedMedia: MediaContainer | undefined = useMemo(() => {
const media = (replyInfo?.type === 'message' && replyInfo.replyMedia) || message?.content; const media = (replyInfo?.type === 'message' && replyInfo.replyMedia) || message?.content;
if (!media) { if (!media) {
@ -109,13 +101,7 @@ const EmbeddedMessage = ({
content: media, content: media,
}; };
}, [message, replyInfo]); }, [message, replyInfo]);
const hasPictogram = canRenderCompactMediaPreview(containedMedia?.content);
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 isRoundVideo = Boolean(containedMedia && getMessageRoundVideo(containedMedia)); const isRoundVideo = Boolean(containedMedia && getMessageRoundVideo(containedMedia));
const isSpoiler = Boolean(containedMedia && getMessageIsSpoiler(containedMedia)) || isMediaNsfw; const isSpoiler = Boolean(containedMedia && getMessageIsSpoiler(containedMedia)) || isMediaNsfw;
@ -206,7 +192,7 @@ const EmbeddedMessage = ({
return ( return (
<MessageSummary <MessageSummary
message={message} message={message}
noEmoji={Boolean(mediaThumbnail)} noEmoji={hasPictogram}
forcedText={translatedText} forcedText={translatedText}
observeIntersectionForLoading={observeIntersectionForLoading} observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying} observeIntersectionForPlaying={observeIntersectionForPlaying}
@ -301,7 +287,6 @@ const EmbeddedMessage = ({
<PeerColorWrapper <PeerColorWrapper
peer={sender} peer={sender}
emojiIconClassName="EmbeddedMessage--background-icons" emojiIconClassName="EmbeddedMessage--background-icons"
ref={ref}
shouldReset shouldReset
isReply={Boolean(replyInfo)} isReply={Boolean(replyInfo)}
noUserColors={noUserColors} noUserColors={noUserColors}
@ -309,7 +294,7 @@ const EmbeddedMessage = ({
'EmbeddedMessage', 'EmbeddedMessage',
className, className,
isQuote && 'is-quote', isQuote && 'is-quote',
mediaThumbnail && 'with-thumb', hasPictogram && 'with-thumb',
'no-selection', 'no-selection',
composerForwardSenders && 'is-input-forward', composerForwardSenders && 'is-input-forward',
suggestedPostInfo && 'is-suggested-post', suggestedPostInfo && 'is-suggested-post',
@ -319,16 +304,20 @@ const EmbeddedMessage = ({
> >
<div className="hover-effect" /> <div className="hover-effect" />
<RippleEffect /> <RippleEffect />
{mediaThumbnail && renderPictogram({ {hasPictogram && (
thumbDataUri: mediaThumbnail, <CompactMediaPreview
blobUrl: mediaBlobUrl, media={containedMedia?.content}
isFullVideo: isVideoThumbnail, className="embedded-thumb"
isRoundVideo, isPictogram
isProtected, isRound={isRoundVideo}
isSpoiler, isProtected={isProtected}
pictogramActionIcon, isSpoiler={isSpoiler}
onPictogramClick, actionIcon={pictogramActionIcon}
})} observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onClick={onPictogramClick}
/>
)}
<div className="message-text"> <div className="message-text">
<p className={buildClassName('embedded-text-wrapper', isQuote && 'multiline')}> <p className={buildClassName('embedded-text-wrapper', isQuote && 'multiline')}>
{renderTextContent()} {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 (
<div
className={buildClassName('embedded-thumb', isRoundVideo && 'round', pictogramActionIcon && 'with-action-icon')}
onClick={onPictogramClick}
>
{!isSpoiler && !shouldRenderVideo && (
<img
src={srcUrl}
width={width}
height={height}
alt=""
className="pictogram"
draggable={false}
/>
)}
{!isSpoiler && shouldRenderVideo && (
<video
src={blobUrl}
width={width}
height={height}
playsInline
disablePictureInPicture
className="pictogram"
/>
)}
<MediaSpoiler
thumbDataUri={shouldRenderVideo ? thumbDataUri : srcUrl}
isVisible={Boolean(isSpoiler)}
width={width}
height={height}
/>
{isProtected && <span className="protector" />}
{pictogramActionIcon && <Icon name={pictogramActionIcon} className="pictogram-action-icon" />}
</div>
);
}
export default EmbeddedMessage; export default EmbeddedMessage;

View File

@ -1,24 +1,18 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import { useRef } from '../../../lib/teact/teact';
import { getActions } from '../../../global'; import { getActions } from '../../../global';
import type { ApiPeer, ApiTypeStory } from '../../../api/types'; import type { ApiPeer, ApiTypeStory } from '../../../api/types';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import {
getStoryMediaHash,
} from '../../../global/helpers';
import { getPeerTitle } from '../../../global/helpers/peers'; import { getPeerTitle } from '../../../global/helpers/peers';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { getPictogramDimensions } from '../helpers/mediaDimensions';
import renderText from '../helpers/renderText'; import renderText from '../helpers/renderText';
import { useFastClick } from '../../../hooks/useFastClick'; import { useFastClick } from '../../../hooks/useFastClick';
import { useIsIntersecting } from '../../../hooks/useIntersectionObserver';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import CompactMediaPreview, { canRenderCompactMediaPreview } from '../CompactMediaPreview';
import Icon from '../icons/Icon'; import Icon from '../icons/Icon';
import PeerColorWrapper from '../PeerColorWrapper'; import PeerColorWrapper from '../PeerColorWrapper';
@ -30,6 +24,7 @@ type OwnProps = {
noUserColors?: boolean; noUserColors?: boolean;
isProtected?: boolean; isProtected?: boolean;
observeIntersectionForLoading?: ObserveFn; observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
onClick: NoneToVoidFunction; onClick: NoneToVoidFunction;
}; };
@ -41,22 +36,17 @@ const EmbeddedStory: FC<OwnProps> = ({
noUserColors, noUserColors,
isProtected, isProtected,
observeIntersectionForLoading, observeIntersectionForLoading,
observeIntersectionForPlaying,
onClick, onClick,
}) => { }) => {
const { showNotification } = getActions(); const { showNotification } = getActions();
const lang = useOldLang(); const lang = useOldLang();
const ref = useRef<HTMLDivElement>();
const isIntersecting = useIsIntersecting(ref, observeIntersectionForLoading);
const isFullStory = story && 'content' in story; const isFullStory = story && 'content' in story;
const isExpiredStory = story && 'isDeleted' in story; const isExpiredStory = story && 'isDeleted' in story;
const isVideoStory = isFullStory && Boolean(story.content.video);
const title = isFullStory ? 'Story' : (isExpiredStory ? 'ExpiredStory' : 'Loading'); const title = isFullStory ? 'Story' : (isExpiredStory ? 'ExpiredStory' : 'Loading');
const mediaBlobUrl = useMedia(isFullStory && getStoryMediaHash(story, 'pictogram'), !isIntersecting); const hasPictogram = isFullStory && canRenderCompactMediaPreview(story.content);
const mediaThumbnail = isVideoStory ? story.content.video!.thumbnail?.dataUri : undefined;
const pictogramUrl = mediaBlobUrl || mediaThumbnail;
const senderTitle = sender ? getPeerTitle(lang, sender) : undefined; const senderTitle = sender ? getPeerTitle(lang, sender) : undefined;
const handleFastClick = useLastCallback(() => { const handleFastClick = useLastCallback(() => {
@ -73,18 +63,26 @@ const EmbeddedStory: FC<OwnProps> = ({
return ( return (
<PeerColorWrapper <PeerColorWrapper
ref={ref}
peerColor={sender?.color} peerColor={sender?.color}
noUserColors={noUserColors} noUserColors={noUserColors}
shouldReset shouldReset
className={buildClassName( className={buildClassName(
'EmbeddedMessage', 'EmbeddedMessage',
pictogramUrl && 'with-thumb', hasPictogram && 'with-thumb',
)} )}
onClick={handleClick} onClick={handleClick}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
> >
{pictogramUrl && renderPictogram(pictogramUrl, isProtected)} {isFullStory && hasPictogram && (
<CompactMediaPreview
media={story.content}
className="embedded-thumb"
isPictogram
isProtected={isProtected}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
/>
)}
<div className="message-text with-message-color"> <div className="message-text with-message-color">
<p className="embedded-text-wrapper"> <p className="embedded-text-wrapper">
{isExpiredStory && ( {isExpiredStory && (
@ -101,25 +99,4 @@ const EmbeddedStory: FC<OwnProps> = ({
); );
}; };
function renderPictogram(
srcUrl: string,
isProtected?: boolean,
) {
const { width, height } = getPictogramDimensions();
return (
<div className="embedded-thumb">
<img
src={srcUrl}
width={width}
height={height}
alt=""
className="pictogram"
draggable={false}
/>
{isProtected && <span className="protector" />}
</div>
);
}
export default EmbeddedStory; export default EmbeddedStory;

View File

@ -60,11 +60,11 @@ function getMaxMessageWidthRem(fromOwnMessage?: boolean, noAvatars?: boolean, is
export function getAvailableWidth( export function getAvailableWidth(
fromOwnMessage?: boolean, fromOwnMessage?: boolean,
isWebPageMedia?: boolean, isNestedMedia?: boolean,
noAvatars?: boolean, noAvatars?: boolean,
isMobile?: boolean, isMobile?: boolean,
) { ) {
const extraPaddingRem = isWebPageMedia ? 1.625 : 0; const extraPaddingRem = isNestedMedia ? 2.125 : 0;
const availableWidthRem = getMaxMessageWidthRem(fromOwnMessage, noAvatars, isMobile) - extraPaddingRem; const availableWidthRem = getMaxMessageWidthRem(fromOwnMessage, noAvatars, isMobile) - extraPaddingRem;
return availableWidthRem * REM; return availableWidthRem * REM;
@ -85,7 +85,7 @@ export function calculateDimensionsForMessageMedia({
width, width,
height, height,
fromOwnMessage, fromOwnMessage,
isWebPageMedia, isNestedMedia,
isGif, isGif,
noAvatars, noAvatars,
isMobile, isMobile,
@ -94,13 +94,13 @@ export function calculateDimensionsForMessageMedia({
height: number; height: number;
fromOwnMessage?: boolean; fromOwnMessage?: boolean;
asForwarded?: boolean; asForwarded?: boolean;
isWebPageMedia?: boolean; isNestedMedia?: boolean;
isGif?: boolean; isGif?: boolean;
noAvatars?: boolean; noAvatars?: boolean;
isMobile?: boolean; isMobile?: boolean;
}): ApiDimensions { }): ApiDimensions {
const aspectRatio = height / width; const aspectRatio = height / width;
const availableWidth = getAvailableWidth(fromOwnMessage, isWebPageMedia, noAvatars, isMobile); const availableWidth = getAvailableWidth(fromOwnMessage, isNestedMedia, noAvatars, isMobile);
const availableHeight = getAvailableHeight(isGif, aspectRatio); const availableHeight = getAvailableHeight(isGif, aspectRatio);
const mediaWidth = isGif ? Math.max(GIF_MIN_WIDTH, width) : width; const mediaWidth = isGif ? Math.max(GIF_MIN_WIDTH, width) : width;
const mediaHeight = isGif ? height * (mediaWidth / width) : height; const mediaHeight = isGif ? height * (mediaWidth / width) : height;
@ -125,7 +125,7 @@ export function calculateInlineImageDimensions(
photo: ApiPhoto, photo: ApiPhoto,
fromOwnMessage?: boolean, fromOwnMessage?: boolean,
asForwarded?: boolean, asForwarded?: boolean,
isWebPageMedia?: boolean, isNestedMedia?: boolean,
noAvatars?: boolean, noAvatars?: boolean,
isMobile?: boolean, isMobile?: boolean,
) { ) {
@ -136,7 +136,7 @@ export function calculateInlineImageDimensions(
height, height,
fromOwnMessage, fromOwnMessage,
asForwarded, asForwarded,
isWebPageMedia, isNestedMedia,
noAvatars, noAvatars,
isMobile, isMobile,
}); });
@ -146,7 +146,7 @@ export function calculateVideoDimensions(
video: ApiVideo, video: ApiVideo,
fromOwnMessage?: boolean, fromOwnMessage?: boolean,
asForwarded?: boolean, asForwarded?: boolean,
isWebPageMedia?: boolean, isNestedMedia?: boolean,
noAvatars?: boolean, noAvatars?: boolean,
isMobile?: boolean, isMobile?: boolean,
) { ) {
@ -157,7 +157,7 @@ export function calculateVideoDimensions(
height, height,
fromOwnMessage, fromOwnMessage,
asForwarded, asForwarded,
isWebPageMedia, isNestedMedia,
isGif: video.isGif, isGif: video.isGif,
noAvatars, noAvatars,
isMobile, isMobile,
@ -168,7 +168,7 @@ export function calculateExtendedPreviewDimensions(
preview: ApiMediaExtendedPreview, preview: ApiMediaExtendedPreview,
fromOwnMessage?: boolean, fromOwnMessage?: boolean,
asForwarded?: boolean, asForwarded?: boolean,
isWebPageMedia?: boolean, isNestedMedia?: boolean,
noAvatars?: boolean, noAvatars?: boolean,
isMobile?: boolean, isMobile?: boolean,
) { ) {
@ -179,19 +179,12 @@ export function calculateExtendedPreviewDimensions(
height, height,
fromOwnMessage, fromOwnMessage,
asForwarded, asForwarded,
isWebPageMedia, isNestedMedia,
noAvatars, noAvatars,
isMobile, isMobile,
}); });
} }
export function getPictogramDimensions(): ApiDimensions {
return {
width: 2 * REM,
height: 2 * REM,
};
}
export function getDocumentThumbnailDimensions( export function getDocumentThumbnailDimensions(
size: 'small' | 'medium' | 'large' = 'medium', size: 'small' | 'medium' | 'large' = 'medium',
): ApiDimensions { ): ApiDimensions {

View File

@ -18,7 +18,6 @@ import {
FRAGMENT_PHONE_CODE, FRAGMENT_PHONE_LENGTH, MUTE_INDEFINITE_TIMESTAMP, UNMUTE_TIMESTAMP, FRAGMENT_PHONE_CODE, FRAGMENT_PHONE_LENGTH, MUTE_INDEFINITE_TIMESTAMP, UNMUTE_TIMESTAMP,
} from '../../../config'; } from '../../../config';
import { import {
buildStaticMapHash,
getChatLink, getChatLink,
getHasAdminRight, getHasAdminRight,
isChatAdmin, isChatAdmin,
@ -56,15 +55,13 @@ import useCollapsibleLines from '../../../hooks/element/useCollapsibleLines';
import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia';
import useOldLang from '../../../hooks/useOldLang'; import useOldLang from '../../../hooks/useOldLang';
import useDevicePixelRatio from '../../../hooks/window/useDevicePixelRatio';
import Chat from '../../left/main/Chat'; import Chat from '../../left/main/Chat';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import ListItem from '../../ui/ListItem'; import ListItem from '../../ui/ListItem';
import Skeleton from '../../ui/placeholder/Skeleton';
import Switcher from '../../ui/Switcher'; import Switcher from '../../ui/Switcher';
import CompactMapPreview from '../CompactMapPreview';
import CustomEmoji from '../CustomEmoji'; import CustomEmoji from '../CustomEmoji';
import Icon from '../icons/Icon'; import Icon from '../icons/Icon';
import SafeLink from '../SafeLink'; import SafeLink from '../SafeLink';
@ -191,19 +188,18 @@ const ChatExtra = ({
}, [peerId, chat, user]); }, [peerId, chat, user]);
const { width, height, zoom } = DEFAULT_MAP_CONFIG; 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(() => { const locationRightComponent = useMemo(() => {
if (!businessLocation?.geo) return undefined; if (!businessLocation?.geo) return undefined;
if (locationBlobUrl) { return (
return <img src={locationBlobUrl} alt="" className={styles.businessLocation} />; <CompactMapPreview
} className={styles.businessLocation}
geo={businessLocation.geo}
return <Skeleton className={styles.businessLocation} animation="wave" />; width={width}
}, [businessLocation, locationBlobUrl]); height={height}
zoom={zoom}
/>
);
}, [businessLocation, width, height, zoom]);
const isTopicInfo = Boolean(topicId && topicId !== MAIN_THREAD_ID); const isTopicInfo = Boolean(topicId && topicId !== MAIN_THREAD_ID);
const shouldRenderAllLinks = (chat && isChatChannel(chat)) || user?.isPremium; const shouldRenderAllLinks = (chat && isChatChannel(chat)) || user?.isPremium;

View File

@ -2,34 +2,72 @@
.root { .root {
cursor: var(--custom-cursor, pointer); cursor: var(--custom-cursor, pointer);
position: relative;
display: grid;
flex-shrink: 0; flex-shrink: 0;
place-items: center;
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
margin: 0; 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; border-radius: 0.25rem;
appearance: none; appearance: none;
background-color: var(--color-background); background-color: var(--ui-bg-color, transparent);
background-repeat: no-repeat;
background-position: center;
background-size: 0;
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 { &:checked {
border-color: var(--color-primary); border-color: var(--ui-accent-color, var(--color-primary));
background-color: var(--color-primary); background-color: var(--ui-accent-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; &::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 { &:indeterminate {
border-color: var(--color-primary); border-color: var(--ui-accent-color, var(--color-primary));
background-color: var(--color-primary); background-color: var(--ui-accent-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; &::before {
transform: none;
width: 0.625rem;
height: 0.125rem;
border-width: 0;
border-radius: 9999px;
opacity: 1;
background-color: var(--ui-check-color);
}
} }
&:disabled { &:disabled {
@ -38,7 +76,7 @@
} }
&:focus-visible { &:focus-visible {
outline: 2px solid var(--color-primary); outline: 2px solid var(--ui-accent-color, var(--color-primary));
outline-offset: 2px; outline-offset: 2px;
} }
} }

View File

@ -15,13 +15,13 @@ type InputProps = React.DetailedHTMLProps<
>; >;
type OwnProps = { type OwnProps = {
checked: boolean; checked?: boolean;
disabled?: boolean; disabled?: boolean;
isRound?: boolean; isRound?: boolean;
indeterminate?: boolean; indeterminate?: boolean;
isInvalid?: boolean; isInvalid?: boolean;
className?: string; className?: string;
onChange: (checked: boolean) => void; onChange?: (checked: boolean) => void;
}; };
type Props = OwnProps & Omit<InputProps, keyof OwnProps | 'type'>; type Props = OwnProps & Omit<InputProps, keyof OwnProps | 'type'>;
@ -50,7 +50,7 @@ const Checkbox = ({
}, [indeterminate]); }, [indeterminate]);
const handleChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.currentTarget.checked); onChange?.(e.currentTarget.checked);
}); });
if (interactive?.isLoading) return undefined; if (interactive?.isLoading) return undefined;

View File

@ -7,18 +7,21 @@
width: 1.25rem; width: 1.25rem;
height: 1.25rem; height: 1.25rem;
margin: 0; 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%; border-radius: 50%;
appearance: none; 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 { &:checked {
border-color: var(--color-primary); border-color: var(--ui-accent-color, var(--color-primary));
background-color: var(--color-primary); background-size: 0.5rem 0.5rem;
box-shadow: inset 0 0 0 0.1875rem var(--color-background);
} }
&:disabled { &:disabled {
@ -27,7 +30,7 @@
} }
&:focus-visible { &:focus-visible {
outline: 2px solid var(--color-primary); outline: 2px solid var(--ui-accent-color, var(--color-primary));
outline-offset: 2px; outline-offset: 2px;
} }
} }

View File

@ -16,10 +16,10 @@ type InputProps = React.DetailedHTMLProps<
type OwnProps = { type OwnProps = {
value: string; value: string;
checked: boolean; checked?: boolean;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
onChange: (value: string) => void; onChange?: (value: string) => void;
}; };
type Props = OwnProps & Omit<InputProps, keyof OwnProps | 'type'>; type Props = OwnProps & Omit<InputProps, keyof OwnProps | 'type'>;
@ -40,7 +40,7 @@ const Radio = ({
const isDisabled = disabled || interactive?.isDisabled || interactive?.isLoading; const isDisabled = disabled || interactive?.isDisabled || interactive?.isLoading;
const handleChange = useLastCallback(() => { const handleChange = useLastCallback(() => {
onChange(value); onChange?.(value);
}); });
if (interactive?.isLoading) return undefined; if (interactive?.isLoading) return undefined;

View File

@ -12,7 +12,7 @@
border-radius: 0.625rem; border-radius: 0.625rem;
appearance: none; 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; transition: background-color 0.15s ease, border-color 0.15s ease;
@ -25,21 +25,21 @@
width: 1.25rem; width: 1.25rem;
height: 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%; 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; transition: transform 0.15s ease, border-color 0.15s ease;
} }
&:checked { &:checked {
border-color: var(--color-primary); border-color: var(--ui-accent-color, var(--color-primary));
background-color: var(--color-primary); background-color: var(--ui-accent-color, var(--color-primary));
&::before { &::before {
transform: translateX(0.75rem); transform: translateX(0.75rem);
border-color: var(--color-primary); border-color: var(--ui-accent-color, var(--color-primary));
} }
} }
@ -49,7 +49,7 @@
} }
&:focus-visible { &:focus-visible {
outline: 2px solid var(--color-primary); outline: 2px solid var(--ui-accent-color, var(--color-primary));
outline-offset: 2px; outline-offset: 2px;
} }
} }

View File

@ -12,7 +12,7 @@ import { getChatAvatarHash } from '../../../global/helpers';
import { selectTabState, selectUser, selectUserFullInfo } from '../../../global/selectors'; import { selectTabState, selectUser, selectUserFullInfo } from '../../../global/selectors';
import { selectCurrentLimit } from '../../../global/selectors/limits'; import { selectCurrentLimit } from '../../../global/selectors/limits';
import { formatDateToString } from '../../../util/dates/oldDateFormat'; 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 { throttle } from '../../../util/schedulers';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
@ -284,7 +284,7 @@ const SettingsEditProfile = ({
link: ( link: (
<Link isPrimary onClick={handleBirthdayPrivacyClick}> <Link isPrimary onClick={handleBirthdayPrivacyClick}>
{lang('BirthdayPrivacySuggestionLink', {lang('BirthdayPrivacySuggestionLink',
undefined, { withNodes: true, specialReplacement: getNextArrowReplacement() })} undefined, { withNodes: true, specialReplacement: NEXT_ARROW_REPLACEMENT })}
</Link> </Link>
), ),
}, { withNodes: true })} }, { withNodes: true })}

View File

@ -11,7 +11,7 @@ import { IS_WEBAUTHN_SUPPORTED } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { type LangFn } from '../../../util/localization'; import { type LangFn } from '../../../util/localization';
import { formatDateTime, getCalendarDayDiff, secondsToDate } from '../../../util/localization/dateFormat'; 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 { LOCAL_TGS_PREVIEW_URLS, LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
import { REM } from '../../common/helpers/mediaDimensions'; import { REM } from '../../common/helpers/mediaDimensions';
@ -158,7 +158,7 @@ const SettingsPasskeys = ({
link: ( link: (
<Link isPrimary onClick={handleOpenPasskeyModal}> <Link isPrimary onClick={handleOpenPasskeyModal}>
{lang('SettingsPasskeysFooterLink', undefined, {lang('SettingsPasskeysFooterLink', undefined,
{ withNodes: true, specialReplacement: getNextArrowReplacement() })} { withNodes: true, specialReplacement: NEXT_ARROW_REPLACEMENT })}
</Link> </Link>
), ),
}, { withNodes: true })} }, { withNodes: true })}

View File

@ -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'); const ghost = document.createElement('div');
ghost.classList.add('ghost'); ghost.classList.add('ghost');
@ -221,6 +224,8 @@ function createGhost(source: string | HTMLImageElement | HTMLVideoElement, origi
if (typeof source === 'string') { if (typeof source === 'string') {
img.src = source; img.src = source;
} else if (source instanceof HTMLCanvasElement) {
img.src = source.toDataURL();
} else if (source instanceof HTMLVideoElement) { } else if (source instanceof HTMLVideoElement) {
img.src = source.poster; img.src = source.poster;
} else { } else {
@ -305,6 +310,11 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage, index?: numbe
mediaSelector = 'img'; mediaSelector = 'img';
break; 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: case MediaViewerOrigin.SharedMedia:
containerSelector = `#shared-media${getMessageHtmlId(message!.id, index)}`; containerSelector = `#shared-media${getMessageHtmlId(message!.id, index)}`;
mediaSelector = 'img'; mediaSelector = 'img';
@ -343,14 +353,18 @@ function getNodes(origin: MediaViewerOrigin, message?: ApiMessage, index?: numbe
case MediaViewerOrigin.SponsoredMessage: case MediaViewerOrigin.SponsoredMessage:
containerSelector = '.Transition_slide-active > .MessageList .sponsored-media-preview'; 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; break;
case MediaViewerOrigin.ScheduledInline: case MediaViewerOrigin.ScheduledInline:
case MediaViewerOrigin.Inline: case MediaViewerOrigin.Inline:
default: default:
containerSelector = `.Transition_slide-active > .MessageList #${getMessageHtmlId(message!.id, index)}`; 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<HTMLElement>(containerSelector)!; const container = document.querySelector<HTMLElement>(containerSelector)!;
@ -371,6 +385,7 @@ function applyShape(ghost: HTMLDivElement, origin: MediaViewerOrigin) {
case MediaViewerOrigin.ScheduledInline: case MediaViewerOrigin.ScheduledInline:
case MediaViewerOrigin.StarsTransaction: case MediaViewerOrigin.StarsTransaction:
case MediaViewerOrigin.PreviewMedia: case MediaViewerOrigin.PreviewMedia:
case MediaViewerOrigin.PollPreview:
ghost.classList.add('rounded-corners'); ghost.classList.add('rounded-corners');
break; break;

View File

@ -84,14 +84,13 @@ const AttachmentModalItem = ({
); );
default: { default: {
const canEdit = SUPPORTED_PHOTO_CONTENT_TYPES.has(attachment.mimeType) && !isMobile; const canEdit = SUPPORTED_PHOTO_CONTENT_TYPES.has(attachment.mimeType) && !isMobile;
const isPhoto = SUPPORTED_PHOTO_CONTENT_TYPES.has(attachment.mimeType);
return ( return (
<> <>
<File <File
className={styles.file} className={styles.file}
name={attachment.filename} name={attachment.filename}
extension={getFileExtension(attachment.filename, attachment.mimeType)} extension={getFileExtension(attachment.filename, attachment.mimeType)}
previewData={isPhoto && attachment.blobUrl ? attachment.blobUrl : attachment.previewBlobUrl} previewAttachment={attachment}
size={attachment.size} size={attachment.size}
previewSize="large" previewSize="large"
onClick={canEdit ? handleEditClick : undefined} onClick={canEdit ? handleEditClick : undefined}

View File

@ -8,6 +8,7 @@ import type { ApiNewPoll } from '../../../api/types';
import { requestMeasure, requestNextMutation } from '../../../lib/fasterdom/fasterdom'; import { requestMeasure, requestNextMutation } from '../../../lib/fasterdom/fasterdom';
import captureEscKeyListener from '../../../util/captureEscKeyListener'; import captureEscKeyListener from '../../../util/captureEscKeyListener';
import { generateUniqueNumberId } from '../../../util/generateUniqueId';
import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText'; import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
@ -147,25 +148,26 @@ const PollModal = ({
const payload: ApiNewPoll = { const payload: ApiNewPoll = {
summary: { summary: {
id: generateUniqueNumberId().toString(),
hash: '0',
question: { question: {
text: questionTrimmed, text: questionTrimmed,
}, },
answers, answers,
...(!isAnonymous && { isPublic: true }), isPublic: !isAnonymous || undefined,
...(isMultipleAnswers && { multipleChoice: true }), isMultipleChoice: isMultipleAnswers || undefined,
...(isQuizMode && { quiz: true }), isQuiz: isQuizMode || undefined,
}, },
}; };
if (isQuizMode) { if (isQuizMode) {
const { text, entities } = (solution && parseHtmlAsFormattedText(solution.substring(0, MAX_SOLUTION_LENGTH))) const { text, entities } = (solution && parseHtmlAsFormattedText(solution.substring(0, MAX_SOLUTION_LENGTH)))
|| {}; || {};
const correctAnswerIndex = answers.findIndex((answer) => answer.option === String(correctOption!));
payload.quiz = { payload.correctAnswers = [correctAnswerIndex];
correctAnswers: [String(correctOption)], payload.solution = text;
...(text && { solution: text }), payload.solutionEntities = entities;
...(entities && { solutionEntities: entities }),
};
} }
onSend(payload); onSend(payload);

View File

@ -114,6 +114,8 @@ const SINGLE_LINE_ACTIONS = new Set<ApiMessageAction['type']>([
'chatDeletePhoto', 'chatDeletePhoto',
'todoCompletions', 'todoCompletions',
'todoAppendTasks', 'todoAppendTasks',
'pollAppendAnswer',
'pollDeleteAnswer',
'unsupported', 'unsupported',
]); ]);
const HIDDEN_TEXT_ACTIONS = new Set<ApiMessageAction['type']>(['giftCode', 'prizeStars', const HIDDEN_TEXT_ACTIONS = new Set<ApiMessageAction['type']>(['giftCode', 'prizeStars',

View File

@ -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 case 'phoneCall': // Rendered as a regular message, but considered an action for the summary
return lang(getCallMessageKey(action, isOutgoing)); return lang(getCallMessageKey(action, isOutgoing));

View File

@ -9,7 +9,7 @@ import type {
ApiChat, ApiChat,
ApiChatReactions, ApiChatReactions,
ApiMessage, ApiMessage,
ApiPoll, ApiMessagePoll,
ApiReaction, ApiReaction,
ApiStickerSet, ApiStickerSet,
ApiStickerSetInfo, ApiStickerSetInfo,
@ -108,7 +108,7 @@ export type OwnProps = {
type StateProps = { type StateProps = {
threadId?: ThreadId; threadId?: ThreadId;
poll?: ApiPoll; poll?: ApiMessagePoll;
webPage?: ApiWebPage; webPage?: ApiWebPage;
story?: ApiTypeStory; story?: ApiTypeStory;
chat?: ApiChat; chat?: ApiChat;

View File

@ -666,7 +666,7 @@
.message-content { .message-content {
&.has-replies:not(.custom-shape), &.has-replies:not(.custom-shape),
&.has-footer:not(.web-page) { &.has-footer:not(.web-page):not(.poll) {
.media-inner, .media-inner,
.Album { .Album {
--border-bottom-left-radius: 0; --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 { &.text.is-inverted-media {
.Album, .Album,
.media-inner { .media-inner {

View File

@ -17,8 +17,8 @@ import type {
ApiKeyboardButton, ApiKeyboardButton,
ApiMessage, ApiMessage,
ApiMessageOutgoingStatus, ApiMessageOutgoingStatus,
ApiMessagePoll,
ApiPeer, ApiPeer,
ApiPoll,
ApiReaction, ApiReaction,
ApiReactionKey, ApiReactionKey,
ApiSavedReactionTag, ApiSavedReactionTag,
@ -202,7 +202,7 @@ import MessageMeta from './MessageMeta';
import MessagePhoneCall from './MessagePhoneCall'; import MessagePhoneCall from './MessagePhoneCall';
import PaidMediaOverlay from './PaidMediaOverlay'; import PaidMediaOverlay from './PaidMediaOverlay';
import Photo from './Photo'; import Photo from './Photo';
import Poll from './Poll'; import Poll from './poll/Poll';
import Reactions from './reactions/Reactions'; import Reactions from './reactions/Reactions';
import RoundVideo from './RoundVideo'; import RoundVideo from './RoundVideo';
import Sticker from './Sticker'; import Sticker from './Sticker';
@ -323,7 +323,7 @@ type StateProps = {
canTranscribeVoice?: boolean; canTranscribeVoice?: boolean;
viaBusinessBot?: ApiUser; viaBusinessBot?: ApiUser;
effect?: ApiAvailableEffect; effect?: ApiAvailableEffect;
poll?: ApiPoll; poll?: ApiMessagePoll;
webPage?: ApiWebPage; webPage?: ApiWebPage;
maxTimestamp?: number; maxTimestamp?: number;
lastPlaybackTimestamp?: number; lastPlaybackTimestamp?: number;
@ -680,7 +680,6 @@ const Message = ({
handleOpenThread, handleOpenThread,
handleReadMedia, handleReadMedia,
handleCancelUpload, handleCancelUpload,
handleVoteSend,
handleGroupForward, handleGroupForward,
handleForward, handleForward,
handleFocus, handleFocus,
@ -1221,6 +1220,7 @@ const Message = ({
noUserColors={noUserColors} noUserColors={noUserColors}
isProtected={isProtected} isProtected={isProtected}
observeIntersectionForLoading={observeIntersectionForLoading} observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onClick={handleStoryClick} onClick={handleStoryClick}
/> />
)} )}
@ -1358,7 +1358,17 @@ const Message = ({
<Contact contact={contact} noUserColors={isOwn} /> <Contact contact={contact} noUserColors={isOwn} />
)} )}
{poll && ( {poll && (
<Poll message={message} poll={poll} onSendVote={handleVoteSend} /> <Poll
key={poll.summary.id}
chatId={chatId}
messageId={messageId}
poll={poll}
messageText={text}
theme={theme}
isInScheduled={isScheduled}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
/>
)} )}
{todo && ( {todo && (
<TodoList message={message} todoList={todo} /> <TodoList message={message} todoList={todo} />

View File

@ -9,8 +9,8 @@ import type {
ApiChat, ApiChat,
ApiChatReactions, ApiChatReactions,
ApiMessage, ApiMessage,
ApiMessagePoll,
ApiPeer, ApiPeer,
ApiPoll,
ApiReaction, ApiReaction,
ApiStickerSet, ApiStickerSet,
ApiThreadInfo, ApiThreadInfo,
@ -57,7 +57,7 @@ type OwnProps = {
anchor: IAnchorPosition; anchor: IAnchorPosition;
targetHref?: string; targetHref?: string;
message: ApiMessage; message: ApiMessage;
poll?: ApiPoll; poll?: ApiMessagePoll;
webPage?: ApiWebPage; webPage?: ApiWebPage;
story?: ApiTypeStory; story?: ApiTypeStory;
canSendNow?: boolean; canSendNow?: boolean;

View File

@ -37,7 +37,7 @@ import ProgressSpinner from '../../ui/ProgressSpinner';
export type OwnProps<T> = { export type OwnProps<T> = {
id?: string; id?: string;
photo: ApiPhoto | ApiMediaExtendedPreview; photo: ApiPhoto | ApiMediaExtendedPreview;
isInWebPage?: boolean; isNestedMedia?: boolean;
messageText?: string; messageText?: string;
isOwn?: boolean; isOwn?: boolean;
noAvatars?: boolean; noAvatars?: boolean;
@ -85,7 +85,7 @@ const Photo = <T,>({
isDownloading, isDownloading,
isProtected, isProtected,
theme, theme,
isInWebPage, isNestedMedia,
clickArg, clickArg,
className, className,
isMediaNsfw, isMediaNsfw,
@ -245,7 +245,7 @@ const Photo = <T,>({
noAvatars, noAvatars,
isMobile, isMobile,
messageText, messageText,
isInWebPage, isNestedMedia,
}); });
const componentClassName = buildClassName( const componentClassName = buildClassName(

View File

@ -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;
}
}

View File

@ -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<OwnProps> = ({
message,
poll,
observeIntersectionForLoading,
observeIntersectionForPlaying,
onSendVote,
}) => {
const {
loadMessage, openPollResults, requestConfetti, showNotification,
} = getActions();
const { id: messageId, chatId } = message;
const { summary, results } = poll;
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [chosenOptions, setChosenOptions] = useState<string[]>([]);
const [wasSubmitted, setWasSubmitted] = useState<boolean>(false);
const [closePeriod, setClosePeriod] = useState<number>(() => (
!summary.closed && summary.closeDate && summary.closeDate > 0
? Math.min(summary.closeDate - getServerTime(), summary.closePeriod!)
: 0
));
const countdownRef = useRef<HTMLDivElement>();
const timerCircleRef = useRef<SVGCircleElement>();
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 (
<PollOption
key={answer.option}
shouldAnimate={wasSubmitted || !canVote}
answer={answer}
voteResults={voteResults}
totalVoters={totalVoters}
maxVotersCount={maxVotersCount}
correctResults={correctResults}
/>
);
}
function renderRecentVoters() {
return (
recentVoters.length > 0 && (
<div className="poll-recent-voters">
<AvatarList
size="micro"
peers={recentVoters}
/>
</div>
)
);
}
return (
<div className="Poll" dir={lang.isRtl ? 'auto' : 'ltr'}>
<div className="poll-question">
{renderTextWithEntities({
text: summary.question.text,
entities: summary.question.entities,
observeIntersectionForLoading,
observeIntersectionForPlaying,
})}
</div>
<div className="poll-type">
{lang(getPollTypeString(summary))}
{renderRecentVoters()}
{closePeriod > 0 && canVote && (
<div ref={countdownRef} className="poll-countdown">
<span>{formatMediaDuration(closePeriod)}</span>
<svg width="16px" height="16px">
<circle
ref={timerCircleRef}
cx="8"
cy="8"
r={TIMER_RADIUS}
className="poll-countdown-progress"
transform="rotate(-90, 8, 8)"
stroke-dasharray={TIMER_CIRCUMFERENCE}
stroke-dashoffset="0"
/>
</svg>
</div>
)}
{summary.quiz && poll.results.solution && !canVote && (
<Button
round
size="tiny"
color="translucent"
className="poll-quiz-help"
onClick={showSolution}
ariaLabel="Show Solution"
iconName="lamp"
/>
)}
</div>
{canVote && (
<div
className="poll-answers"
onClick={stopPropagation}
>
{isMultiple
? (
<CheckboxGroup
options={answers}
selected={chosenOptions}
onChange={handleCheckboxChange}
disabled={message.isScheduled || isSubmitting}
loadingOptions={isSubmitting ? chosenOptions : undefined}
isRound
/>
)
: (
<RadioGroup
name={`poll-${messageId}`}
options={answers}
onChange={handleRadioChange}
disabled={message.isScheduled || isSubmitting}
loadingOption={isSubmitting ? chosenOptions[0] : undefined}
/>
)}
</div>
)}
{!canVote && (
<div className="poll-results">
{summary.answers.map(renderResultOption)}
</div>
)}
{!canViewResult && !isMultiple && (
<div className="poll-voters-count">{getReadableVotersCount(lang, summary.quiz, results.totalVoters)}</div>
)}
{isMultiple && (
<Button
isText
disabled={chosenOptions.length === 0}
size="tiny"
onClick={handleVoteClick}
>
{lang('PollSubmitVotes')}
</Button>
)}
{canViewResult && (
<Button
isText
size="tiny"
onClick={handleViewResultsClick}
>
{lang('PollViewResults')}
</Button>
)}
</div>
);
};
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<HTMLDivElement>) {
e.stopPropagation();
}
export default memo(Poll);

View File

@ -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;
}
}

View File

@ -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<OwnProps> = ({
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 (
<div className="PollOption" dir={lang.isRtl ? 'rtl' : undefined}>
<div className={`poll-option-share ${answerPercent === '100' ? 'limit-width' : ''}`}>
{answerPercent}
%
{showIcon && (
<span className={buildClassName(
'poll-option-chosen',
!correctAnswer && 'wrong',
shouldAnimate && 'animate',
)}
>
<Icon name={correctAnswer ? 'check' : 'close'} className="poll-option-icon" />
</span>
)}
</div>
<div className="poll-option-right">
<div className="poll-option-text" dir="auto">
{renderTextWithEntities({
text: answer.text.text,
entities: answer.text.entities,
})}
</div>
<div className={buildClassName('poll-option-answer', showIcon && !correctAnswer && 'wrong')}>
{shouldAnimate && (
<svg
className="poll-line"
style={!isAnimationDoesNotStart ? 'stroke-dasharray: 100% 200%; stroke-dashoffset: -44' : ''}
>
<path d="M4.47 5.33v13.6a9 9 0 009 9h13" />
</svg>
)}
<div
className="poll-option-line"
style={lineStyle}
/>
</div>
</div>
</div>
);
};
function getPercentage(value: number, total: number) {
return total > 0 ? ((value / total) * 100).toFixed() : 0;
}
export default PollOption;

View File

@ -38,7 +38,7 @@ export type OwnProps<T> = {
video: ApiVideo | ApiMediaExtendedPreview; video: ApiVideo | ApiMediaExtendedPreview;
lastPlaybackTimestamp?: number; lastPlaybackTimestamp?: number;
isOwn?: boolean; isOwn?: boolean;
isInWebPage?: boolean; isNestedMedia?: boolean;
noAvatars?: boolean; noAvatars?: boolean;
canAutoLoad?: boolean; canAutoLoad?: boolean;
canAutoPlay?: boolean; canAutoPlay?: boolean;
@ -65,7 +65,7 @@ const Video = <T,>({
id, id,
video, video,
isOwn, isOwn,
isInWebPage, isNestedMedia,
noAvatars, noAvatars,
canAutoLoad, canAutoLoad,
canAutoPlay, canAutoPlay,
@ -209,8 +209,8 @@ const Video = <T,>({
width, height, width, height,
} = dimensions || ( } = dimensions || (
isPaidPreview isPaidPreview
? calculateExtendedPreviewDimensions(video, Boolean(isOwn), asForwarded, isInWebPage, noAvatars, isMobile) ? calculateExtendedPreviewDimensions(video, Boolean(isOwn), asForwarded, isNestedMedia, noAvatars, isMobile)
: calculateVideoDimensions(video, Boolean(isOwn), asForwarded, isInWebPage, noAvatars, isMobile) : calculateVideoDimensions(video, Boolean(isOwn), asForwarded, isNestedMedia, noAvatars, isMobile)
); );
const handleClick = useLastCallback((e: React.MouseEvent<HTMLElement, MouseEvent>, isFromSpinner?: boolean) => { const handleClick = useLastCallback((e: React.MouseEvent<HTMLElement, MouseEvent>, isFromSpinner?: boolean) => {

View File

@ -247,7 +247,7 @@ const WebPage = ({
<Photo <Photo
photo={photo} photo={photo}
isOwn={message?.isOutgoing} isOwn={message?.isOutgoing}
isInWebPage isNestedMedia
observeIntersection={observeIntersectionForLoading} observeIntersection={observeIntersectionForLoading}
noAvatars={noAvatars} noAvatars={noAvatars}
canAutoLoad={canAutoLoad} canAutoLoad={canAutoLoad}
@ -265,7 +265,7 @@ const WebPage = ({
<Video <Video
video={video} video={video}
isOwn={message?.isOutgoing} isOwn={message?.isOutgoing}
isInWebPage isNestedMedia
observeIntersectionForLoading={observeIntersectionForLoading} observeIntersectionForLoading={observeIntersectionForLoading}
noAvatars={noAvatars} noAvatars={noAvatars}
canAutoLoad={canAutoLoad} canAutoLoad={canAutoLoad}

View File

@ -1,4 +1,4 @@
import type { ApiMessage, ApiPoll, ApiWebPage } from '../../../../api/types'; import type { ApiMessage, ApiMessagePoll, ApiWebPage } from '../../../../api/types';
import type { IAlbum } from '../../../../types'; import type { IAlbum } from '../../../../types';
import { EMOJI_SIZES, MESSAGE_CONTENT_CLASS_NAME } from '../../../../config'; import { EMOJI_SIZES, MESSAGE_CONTENT_CLASS_NAME } from '../../../../config';
@ -26,7 +26,7 @@ export function buildContentClassName(
peerColorClass, peerColorClass,
hasOutsideReactions, hasOutsideReactions,
}: { }: {
poll?: ApiPoll; poll?: ApiMessagePoll;
webPage?: ApiWebPage; webPage?: ApiWebPage;
hasSubheader?: boolean; hasSubheader?: boolean;
isCustomShape?: boolean | number; isCustomShape?: boolean | number;
@ -61,8 +61,10 @@ export function buildContentClassName(
const isInvertibleMedia = photo || (video && !isRoundVideo) || album || webPage; const isInvertibleMedia = photo || (video && !isRoundVideo) || album || webPage;
const classNames = [MESSAGE_CONTENT_CLASS_NAME]; const classNames = [MESSAGE_CONTENT_CLASS_NAME];
const isMedia = storyData || photo || video || location || invoice?.extendedMedia || paidMedia; const pollMedia = poll?.attachedMedia;
const hasText = text || location?.mediaType === 'venue' || isGeoLiveActive || hasFactCheck; const isMedia = storyData || photo || video || location || invoice?.extendedMedia || paidMedia
|| pollMedia?.photo || pollMedia?.video || pollMedia?.location;
const hasText = text || location?.mediaType === 'venue' || isGeoLiveActive || hasFactCheck || poll;
const isMediaWithNoText = isMedia && !hasText; const isMediaWithNoText = isMedia && !hasText;
const hasInlineKeyboard = Boolean(message.inlineButtons); const hasInlineKeyboard = Boolean(message.inlineButtons);
const isViaBot = Boolean(message.viaBotId); const isViaBot = Boolean(message.viaBotId);

View File

@ -26,7 +26,7 @@ export function calculateMediaDimensions({
media, media,
messageText, messageText,
isOwn, isOwn,
isInWebPage, isNestedMedia,
asForwarded, asForwarded,
noAvatars, noAvatars,
isMobile, isMobile,
@ -34,19 +34,19 @@ export function calculateMediaDimensions({
media: ApiPhoto | ApiVideo | ApiMediaExtendedPreview; media: ApiPhoto | ApiVideo | ApiMediaExtendedPreview;
messageText?: string; messageText?: string;
isOwn?: boolean; isOwn?: boolean;
isInWebPage?: boolean; isNestedMedia?: boolean;
asForwarded?: boolean; asForwarded?: boolean;
noAvatars?: boolean; noAvatars?: boolean;
isMobile: boolean; isMobile: boolean;
}) { }) {
const isPhoto = media.mediaType === 'photo'; const isPhoto = media.mediaType === 'photo';
const isVideo = media.mediaType === 'video'; const isVideo = media.mediaType === 'video';
const isWebPagePhoto = isPhoto && isInWebPage; const isWebPagePhoto = isPhoto && isNestedMedia;
const isWebPageVideo = isVideo && isInWebPage; const isWebPageVideo = isVideo && isNestedMedia;
const { width, height } = isPhoto const { width, height } = isPhoto
? calculateInlineImageDimensions(media, isOwn, asForwarded, isWebPagePhoto, noAvatars, isMobile) ? calculateInlineImageDimensions(media, isOwn, asForwarded, isWebPagePhoto, noAvatars, isMobile)
: isVideo ? calculateVideoDimensions(media, isOwn, asForwarded, isWebPageVideo, noAvatars, isMobile) : isVideo ? calculateVideoDimensions(media, isOwn, asForwarded, isWebPageVideo, noAvatars, isMobile)
: calculateExtendedPreviewDimensions(media, isOwn, asForwarded, isInWebPage, noAvatars, isMobile); : calculateExtendedPreviewDimensions(media, isOwn, asForwarded, isNestedMedia, noAvatars, isMobile);
const minMediaWidth = getMinMediaWidth(messageText, isMobile); const minMediaWidth = getMinMediaWidth(messageText, isMobile);

View File

@ -56,7 +56,7 @@ export default function useInnerHandlers({
}) { }) {
const { const {
openChat, openChatWithDraft, showNotification, focusMessage, openMediaViewer, openAudioPlayer, openChat, openChatWithDraft, showNotification, focusMessage, openMediaViewer, openAudioPlayer,
markMessagesRead, cancelUploadMedia, sendPollVote, openForwardMenu, markMessagesRead, cancelUploadMedia, openForwardMenu,
openChatLanguageModal, openThread, openStoryViewer, searchChatMediaMessages, openChatLanguageModal, openThread, openStoryViewer, searchChatMediaMessages,
} = getActions(); } = getActions();
@ -198,10 +198,6 @@ export default function useInnerHandlers({
cancelUploadMedia({ chatId, messageId }); cancelUploadMedia({ chatId, messageId });
}); });
const handleVoteSend = useLastCallback((options: string[]) => {
sendPollVote({ chatId, messageId, options });
});
const handleGroupForward = useLastCallback(() => { const handleGroupForward = useLastCallback(() => {
openForwardMenu({ fromChatId: chatId, groupedId }); openForwardMenu({ fromChatId: chatId, groupedId });
}); });
@ -305,7 +301,6 @@ export default function useInnerHandlers({
handleOpenThread, handleOpenThread,
handleReadMedia, handleReadMedia,
handleCancelUpload, handleCancelUpload,
handleVoteSend,
handleGroupForward, handleGroupForward,
handleForward, handleForward,
handleFocus, handleFocus,

View File

@ -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;
}

View File

@ -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<string[]>([]);
const [isExplanationOpen, setIsExplanationOpen] = useState(false);
const [isSendingVote, setIsSendingVote] = useState(false);
const [isViewingAuthorResults, setIsViewingAuthorResults] = useState(false);
const [answerOrder] = useState<string[]>(() => (
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<string, number>);
}, [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: (
<TextTimer
endsAt={activeCloseDate}
mode="countdown"
shouldShowZeroOnEnd
onEnd={handleCloseDateEnd}
/>
),
}, { withNodes: true })
: undefined;
const footerContent = useMemo(() => {
const renderFooterBody = (label: TeactNode) => (
<div className={styles.footerContent}>
<span>{label}</span>
{Boolean(footerSubtext) && (
<div
className={styles.footerSubtext}
dir="auto"
>
{footerSubtext}
</div>
)}
</div>
);
if (canVote && isMultipleChoice && selectedOptions.length) {
return (
<Button
className={styles.footerButton}
disabled={isSendingVote}
noForcedUpperCase
isText
inline
size="smaller"
color="adaptive"
onClick={handleSendVote}
>
{renderFooterBody(lang(summary.isQuiz ? 'PollSubmitAnswers' : 'PollSubmitVotes'))}
</Button>
);
}
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 (
<Button
className={styles.footerButton}
noForcedUpperCase
isText
inline
size="smaller"
color="adaptive"
onClick={handleToggleAuthorResults}
>
<Transition
activeKey={Number(isViewingAuthorResults)}
name={lang.isRtl ? 'slideRtl' : 'slide'}
shouldCleanup
slideClassName={styles.footerContentSlide}
>
{renderFooterBody(label)}
</Transition>
</Button>
);
}
if (canVote && isMultipleChoice) {
return (
<Button
className={styles.footerButton}
disabled={isSendingVote || !selectedOptions.length}
noForcedUpperCase
isText
inline
size="smaller"
color="adaptive"
onClick={handleSendVote}
>
{renderFooterBody(lang(summary.isQuiz ? 'PollSubmitAnswers' : 'PollSubmitVotes'))}
</Button>
);
}
if (canShowResultsPanel) {
return (
<Button
className={styles.footerButton}
noForcedUpperCase
isText
inline
size="smaller"
color="adaptive"
onClick={handleOpenResults}
>
{renderFooterBody(lang('PollViewResults'))}
</Button>
);
}
if (totalVoters) {
return (
<div className={styles.footerStatic}>
{renderFooterBody(lang(summary.isQuiz ? 'PollAnsweredCount' : 'VoteCount', {
count: totalVoters,
}, { pluralValue: totalVoters }))}
</div>
);
}
return (
<div className={styles.footerStatic}>
{renderFooterBody(lang(summary.isQuiz ? 'ChatQuizTotalVotesEmpty' : 'ChatPollTotalVotesResultEmpty'))}
</div>
);
}, [
canShowResultsPanel,
canToggleAuthorResults,
canVote,
isMultipleChoice,
isSendingVote,
isViewingAuthorResults,
lang,
selectedOptions.length,
summary.isQuiz,
footerSubtext,
totalVoters,
]);
return (
<>
{attachedMediaEl}
<div className={styles.root} dir={lang.isRtl ? 'rtl' : undefined}>
{isExplanationOpen && hasExplanation && (
<PeerColorWrapper className={styles.explanation} shouldReset>
<div className={styles.explanationHeader}>
<span className={styles.explanationTitle}>
{lang('PollsSolutionTitle')}
</span>
{explanationText && (
<div className={styles.explanationText} dir="auto">
{explanationText}
</div>
)}
</div>
<Button
round
size="tiny"
color="adaptive"
isText
iconName="close"
ariaLabel={lang('Close')}
onClick={handleToggleExplanation}
className={styles.explanationCloseButton}
/>
{explanationMedia && (
<div className={styles.explanationMedia}>
{explanationMedia}
</div>
)}
</PeerColorWrapper>
)}
{descriptionText && (
<div dir="auto">
{descriptionText}
</div>
)}
<div className={styles.questionContainer}>
<div className={styles.question} dir="auto">
{questionText}
</div>
{hasExplanation && !isExplanationOpen && (
<div className={styles.explanationToggleButton}>
<Button
round
size="smaller"
color="adaptive"
isText
iconName="lamp"
ariaLabel={lang('MediaPollSolutionAria')}
onClick={handleToggleExplanation}
/>
</div>
)}
<div className={styles.meta}>
<div className={styles.metaInfo}>
<span className={styles.metaLabel}>
{summary.isClosed
? lang('FinalResults')
: summary.isQuiz
? lang(summary.isPublic ? 'QuizPoll' : 'AnonymousQuizPoll')
: lang(summary.isPublic ? 'PublicPoll' : 'AnonymousPoll')}
</span>
{Boolean(pollRecentVoters?.length) && (
<AvatarList
size="micro"
peers={pollRecentVoters}
className={styles.metaVoters}
/>
)}
</div>
</div>
</div>
<div className={styles.options}>
{orderedAnswers.map((answer) => {
const optionResult = results.resultByOption?.[answer.option];
const previewIndex = previewIndexByKey[answer.option];
return (
<PollOption
key={answer.option}
answer={answer}
result={optionResult}
isQuiz={summary.isQuiz}
totalVotersCount={results.totalVoters}
hasResults={areInlineResultsVisible || hasMaskedResults}
hasMaskedResults={hasMaskedResults}
isSendingVote={isSendingVote}
isInScheduled={isInScheduled}
isSelected={selectedOptionsSet.has(answer.option)}
isMultipleChoice={isMultipleChoice}
recentVoters={hasMaskedResults ? undefined : resolvePeers(optionResult?.recentVoterIds)}
shouldReserveMediaColumn={hasOptionMedia}
mediaPreviewId={previewIndex !== undefined
? getPollPreviewId(messageId, previewIndex)
: undefined}
mediaPreviewIndex={previewIndex}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onSelect={handleSelectOption}
onOpenMedia={handleOpenPreview}
onOpenLocation={handleOpenLocation}
/>
);
})}
</div>
{footerContent && (
<div className={styles.footer}>
{footerContent}
</div>
)}
</div>
</>
);
};
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 (
<Photo
id={previewId}
photo={content.photo}
theme={theme}
className={className}
isNestedMedia={isNestedMedia}
canAutoLoad
observeIntersection={observeIntersectionForLoading}
clickArg={previewIndex}
onClick={previewIndex !== undefined ? onPreviewClick(onOpenPreview) : undefined}
/>
);
}
if (content.video) {
return (
<Video
id={previewId}
video={content.video}
className={className}
isNestedMedia={isNestedMedia}
canAutoLoad
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
clickArg={previewIndex}
onClick={previewIndex !== undefined ? onPreviewClick(onOpenPreview) : undefined}
/>
);
}
if (content.document) {
return (
<Document
id={previewId}
document={content.document}
className={className}
observeIntersection={observeIntersectionForLoading}
onMediaClick={previewIndex !== undefined ? () => onOpenPreview(previewIndex) : undefined}
/>
);
}
if (content.location) {
return (
<CompactMapPreview
className={buildClassName(className, 'media-inner')}
geo={content.location.geo}
width={locationWidth}
height={locationHeight}
zoom={ATTACHED_MAP_ZOOM}
shouldShowPin={false}
onClick={() => onOpenLocation(content.location!)}
/>
);
}
if (content.sticker) {
return (
<PollSticker
sticker={content.sticker}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
/>
);
}
return undefined;
}
function PollSticker({
sticker,
observeIntersectionForLoading,
observeIntersectionForPlaying,
}: {
sticker: ApiSticker;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
}) {
const ref = useRef<HTMLDivElement>();
return (
<div className={styles.stickerPreview} ref={ref}>
<StickerView
containerRef={ref}
sticker={sticker}
size={STICKER_PREVIEW_SIZE}
shouldLoop
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
/>
</div>
);
}
function hasPollOptionMedia(answer: ApiPoll['answers'][number]) {
return Boolean(answer.media?.photo || answer.media?.video || answer.media?.location || answer.media?.sticker);
}
function onPreviewClick(onOpenPreview: (previewIndex: number) => void) {
return (previewIndex: number, e: React.MouseEvent<HTMLElement>) => {
e.stopPropagation();
onOpenPreview(previewIndex);
};
}
export default memo(Poll);

View File

@ -0,0 +1,192 @@
.root {
--poll-option-media-width: 0;
--percent-column-width: 2rem;
--ui-border-color: var(--secondary-color);
--ui-accent-color: var(--accent-color);
display: grid;
grid-template-columns:
minmax(var(--percent-column-width), auto)
minmax(0, 1fr)
minmax(2.5rem, auto)
var(--poll-option-media-width);
grid-template-rows: auto auto;
column-gap: 0.375rem;
align-items: start;
:global(.theme-dark .Message.own) & {
--ui-check-color: var(--color-primary);
}
:global(body.is-macos) & {
--percent-column-width: 2.25rem;
}
}
.hasResults {
.selector {
justify-content: flex-end;
}
}
.hasMediaColumn {
--poll-option-media-width: 3rem;
}
.selector {
display: flex;
grid-column: 1;
grid-row: 1;
align-items: center;
justify-content: flex-start;
min-height: 1.5rem;
}
.selectorSlide {
display: flex;
align-items: center;
}
.spinner {
--spinner-size: 1.25rem;
}
.percentage {
margin-inline-start: auto;
font-size: 0.9375rem;
font-weight: var(--font-weight-semibold);
line-height: 1.5rem;
white-space: nowrap;
}
.fullPercent {
font-size: 0.8125rem;
:global(body.is-macos) & {
font-size: 0.75rem;
}
}
.answer {
grid-column: 2;
grid-row: 1;
align-self: center;
justify-self: flex-start;
min-width: 0;
overflow-wrap: anywhere;
}
.sideMeta {
display: flex;
grid-column: 3;
grid-row: 1;
gap: 0.25rem;
align-items: center;
align-self: center;
justify-content: flex-end;
min-height: 1.5rem;
}
.votersCount {
font-size: 0.75rem;
line-height: 1;
white-space: nowrap;
}
.avatarList {
flex-shrink: 0;
}
.media {
display: flex;
grid-column: 4;
grid-row: 1 / 3;
align-self: flex-start;
justify-self: end;
width: var(--poll-option-media-width);
}
.mediaPreview {
overflow: hidden;
border-radius: 0.5rem;
}
.stickerPreview {
position: relative;
width: 3rem;
height: 3rem;
}
.chosenMarker {
display: flex;
grid-column: 1;
grid-row: 2;
align-items: center;
align-self: center;
justify-content: flex-end;
min-height: 1rem;
}
.chosenMarkerIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
color: var(--ui-check-color, var(--color-white));
background-color: var(--ui-accent-color);
}
.square {
border-radius: 0.25rem;
}
.incorrect {
.chosenMarkerIcon, .progressFill {
background-color: var(--color-error);
}
}
.progress {
grid-column: 2 / 4;
grid-row: 2;
align-self: center;
min-height: 0.25rem;
border-radius: 0.125rem;
background-color: var(--accent-background-color);
}
.progressTrack {
overflow: hidden;
width: 100%;
height: 0.25rem;
border-radius: 0.125rem;
}
.progressFill {
transform-origin: left center;
transform: scaleX(var(--_progress));
width: 100%;
height: 100%;
border-radius: inherit;
background-color: var(--color-accent, var(--accent-color));
}
.animated {
transition: transform 500ms ease-out;
}

View File

@ -0,0 +1,356 @@
import { memo, useEffect, useMemo, useRef, useState } from '@teact';
import type {
ApiLocation,
ApiPeer,
ApiPollAnswer,
ApiPollResult,
} from '../../../../api/types';
import type { ObserveFn } from '../../../../hooks/useIntersectionObserver';
import buildClassName from '../../../../util/buildClassName';
import { formatPercent } from '../../../../util/textFormat';
import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities';
import useInterval from '../../../../hooks/schedulers/useInterval';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import AnimatedCounter from '../../../common/AnimatedCounter';
import AvatarList from '../../../common/AvatarList';
import CompactMapPreview from '../../../common/CompactMapPreview';
import CompactMediaPreview from '../../../common/CompactMediaPreview';
import Icon from '../../../common/icons/Icon';
import StickerView from '../../../common/StickerView';
import Spinner from '../../../ui/Spinner';
import Transition from '../../../ui/Transition';
import Checkbox from '@gili/primitives/Checkbox';
import Radio from '@gili/primitives/Radio';
import styles from './PollOption.module.scss';
type OwnProps = {
answer: ApiPollAnswer;
result?: ApiPollResult;
isSelected?: boolean;
isQuiz?: boolean;
totalVotersCount?: number;
isMultipleChoice?: boolean;
hasResults?: boolean;
hasMaskedResults?: boolean;
isSendingVote?: boolean;
recentVoters?: ApiPeer[];
shouldReserveMediaColumn?: boolean;
mediaPreviewId?: string;
mediaPreviewIndex?: number;
isInScheduled?: boolean;
observeIntersectionForLoading?: ObserveFn;
observeIntersectionForPlaying?: ObserveFn;
onSelect: (option: string) => void;
onOpenMedia: (previewIndex: number) => void;
onOpenLocation: (location: ApiLocation) => void;
};
const OPTION_MEDIA_SIZE = 48;
const MIN_PROGRESS = 5;
const PERCENT_STEP = 13;
const PERCENT_STEP_MS = 60;
const PollOption = ({
answer,
result,
isSelected,
isQuiz,
totalVotersCount,
isMultipleChoice,
hasResults,
hasMaskedResults,
isSendingVote,
recentVoters,
shouldReserveMediaColumn,
mediaPreviewId,
mediaPreviewIndex,
isInScheduled,
observeIntersectionForLoading,
observeIntersectionForPlaying,
onSelect,
onOpenMedia,
onOpenLocation,
}: OwnProps) => {
const lang = useLang();
const stickerRef = useRef<HTMLDivElement>();
const media = answer.media;
const shouldReserveMediaEndColumn = Boolean(shouldReserveMediaColumn);
const votersCount = result?.votersCount ?? 0;
const percentage = totalVotersCount
? Math.round((votersCount / totalVotersCount) * 100)
: 0;
const hasInitializedRef = useRef(false);
const previousResultStateRef = useRef({
percentage,
votersCount,
totalVotersCount,
});
const [displayedPercentage, setDisplayedPercentage] = useState(percentage);
const [progressAnimationKey, setProgressAnimationKey] = useState(0);
const isAnimatingPercentage = hasResults && !isSendingVote && displayedPercentage !== percentage;
const progressPercentage = totalVotersCount !== undefined
? Math.max(MIN_PROGRESS, percentage) : 0;
const selectorStateKey = isSendingVote ? 0
: hasResults && !hasMaskedResults ? 1
: hasMaskedResults ? 2 : 3;
const answerText = useMemo(() => renderTextWithEntities({
text: answer.text.text,
entities: answer.text.entities,
}), [answer.text.entities, answer.text.text]);
useEffect(() => {
const previousResultState = previousResultStateRef.current;
previousResultStateRef.current = {
percentage,
votersCount,
totalVotersCount,
};
if (!hasInitializedRef.current) {
hasInitializedRef.current = true;
setProgressAnimationKey((current) => current + 1);
return;
}
if (!hasResults) {
setDisplayedPercentage(percentage);
return;
}
if (
votersCount !== previousResultState.votersCount
|| totalVotersCount !== previousResultState.totalVotersCount
) {
setDisplayedPercentage(isSendingVote ? 0
: percentage !== previousResultState.percentage ? previousResultState.percentage : 0,
);
setProgressAnimationKey((current) => current + 1);
}
}, [hasResults, isSendingVote, percentage, totalVotersCount, votersCount]);
useInterval(() => {
setDisplayedPercentage((current) => {
if (current === percentage) {
return current;
}
if (current < percentage) {
return Math.min(percentage, current + PERCENT_STEP);
}
return Math.max(percentage, current - PERCENT_STEP);
});
}, isAnimatingPercentage ? PERCENT_STEP_MS : undefined, true);
const handleClick = useLastCallback(() => {
onSelect(answer.option);
});
const handleOpenPreview = useLastCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (mediaPreviewIndex === undefined) {
return;
}
e.stopPropagation();
onOpenMedia(mediaPreviewIndex);
});
const handleOpenMap = useLastCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!media?.location) {
return;
}
e.stopPropagation();
onOpenLocation(media.location);
});
function renderSelector() {
if (isSendingVote) {
return <Spinner className={styles.spinner} color="gray" />;
}
if (hasResults && !hasMaskedResults) {
return (
<AnimatedCounter
className={buildClassName(styles.percentage, percentage === 100 && styles.fullPercent)}
text={formatPercent(displayedPercentage, 0)}
/>
);
}
if (isMultipleChoice) {
return (
<Checkbox
className={styles.input}
checked={hasMaskedResults ? result?.isChosen : isSelected}
disabled={hasMaskedResults || isInScheduled}
/>
);
}
return (
<Radio
className={styles.input}
value={answer.option}
checked={hasMaskedResults ? result?.isChosen : isSelected}
disabled={hasMaskedResults || isInScheduled}
/>
);
}
function renderChosenMarker() {
if (!hasResults || !result) {
return undefined;
}
if (hasMaskedResults) {
if (!result.isChosen) return undefined;
} else if (!result.isChosen) {
if (!isQuiz || !result.isCorrect) return undefined;
}
return (
<div className={buildClassName(styles.chosenMarkerIcon, isMultipleChoice && styles.square)}>
<Icon name={!hasMaskedResults && isQuiz && !result.isCorrect ? 'close' : 'check'} />
</div>
);
}
function renderResultsMeta() {
if (!hasResults || hasMaskedResults || votersCount === 0) {
return undefined;
}
return (
<div className={styles.sideMeta}>
<span className={styles.votersCount}>{lang.number(votersCount)}</span>
{Boolean(recentVoters?.length) && (
<AvatarList
size="micro"
peers={recentVoters}
className={styles.avatarList}
limit={2}
/>
)}
</div>
);
}
function renderMedia() {
if (media?.photo) {
return (
<CompactMediaPreview
media={media}
id={mediaPreviewId}
className={styles.mediaPreview}
size={OPTION_MEDIA_SIZE}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onClick={handleOpenPreview}
/>
);
}
if (media?.video) {
return (
<CompactMediaPreview
media={media}
id={mediaPreviewId}
className={styles.mediaPreview}
size={OPTION_MEDIA_SIZE}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
onClick={handleOpenPreview}
/>
);
}
if (media?.location) {
return (
<CompactMapPreview
className={styles.mediaPreview}
geo={media.location.geo}
width={OPTION_MEDIA_SIZE}
height={OPTION_MEDIA_SIZE}
shouldShowPin={false}
onClick={handleOpenMap}
/>
);
}
if (media?.sticker) {
return (
<div className={styles.stickerPreview} ref={stickerRef} onClick={(e) => e.stopPropagation()}>
<StickerView
containerRef={stickerRef}
sticker={media.sticker}
size={OPTION_MEDIA_SIZE}
shouldLoop
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
/>
</div>
);
}
return undefined;
}
const mediaNode = renderMedia();
return (
<div
className={buildClassName(
styles.root,
hasResults && !hasMaskedResults && styles.hasResults,
shouldReserveMediaEndColumn && styles.hasMediaColumn,
hasResults && !hasMaskedResults && isQuiz && !result?.isCorrect && styles.incorrect,
)}
onClick={handleClick}
>
<Transition
name="fade"
activeKey={selectorStateKey}
className={styles.selector}
slideClassName={styles.selectorSlide}
shouldCleanup
direction={1}
>
{renderSelector()}
</Transition>
<div className={styles.answer} dir="auto">
{answerText}
</div>
{renderResultsMeta()}
{mediaNode && (
<div className={styles.media}>
{mediaNode}
</div>
)}
<div className={styles.chosenMarker}>
{renderChosenMarker()}
</div>
<div className={styles.progress}>
<div
className={styles.progressTrack}
style={`--_progress: ${hasResults && !hasMaskedResults ? progressPercentage / 100 : 0}`}
>
<div
className={buildClassName(styles.progressFill, progressAnimationKey > 0 && styles.animated)}
/>
</div>
</div>
</div>
);
};
export default memo(PollOption);

View File

@ -216,12 +216,6 @@
} }
} }
.pinnedThumbImage {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.pinnedMessage { .pinnedMessage {
flex-grow: 1; flex-grow: 1;

View File

@ -11,7 +11,6 @@ import {
getIsSavedDialog, getIsSavedDialog,
getMessageIsSpoiler, getMessageIsSpoiler,
getMessageSingleInlineButton, getMessageSingleInlineButton,
getMessageVideo,
} from '../../../global/helpers'; } from '../../../global/helpers';
import { getPeerTitle } from '../../../global/helpers/peers'; import { getPeerTitle } from '../../../global/helpers/peers';
import { import {
@ -25,12 +24,10 @@ import {
import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment'; import { IS_TOUCH_ENV } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import cycleRestrict from '../../../util/cycleRestrict'; import cycleRestrict from '../../../util/cycleRestrict';
import { getPictogramDimensions, REM } from '../../common/helpers/mediaDimensions'; import { REM } from '../../common/helpers/mediaDimensions';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
import renderKeyboardButtonText from '../composer/helpers/renderKeyboardButtonText'; import renderKeyboardButtonText from '../composer/helpers/renderKeyboardButtonText';
import useMessageMediaHash from '../../../hooks/media/useMessageMediaHash';
import useThumbnail from '../../../hooks/media/useThumbnail';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useDerivedState from '../../../hooks/useDerivedState'; import useDerivedState from '../../../hooks/useDerivedState';
import useEnsureMessage from '../../../hooks/useEnsureMessage'; import useEnsureMessage from '../../../hooks/useEnsureMessage';
@ -38,14 +35,13 @@ import { useFastClick } from '../../../hooks/useFastClick';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import useAsyncRendering from '../../right/hooks/useAsyncRendering'; import useAsyncRendering from '../../right/hooks/useAsyncRendering';
import useHeaderPane, { type PaneState } from '../hooks/useHeaderPane'; import useHeaderPane, { type PaneState } from '../hooks/useHeaderPane';
import AnimatedCounter from '../../common/AnimatedCounter'; import AnimatedCounter from '../../common/AnimatedCounter';
import CompactMediaPreview, { canRenderCompactMediaPreview } from '../../common/CompactMediaPreview';
import Icon from '../../common/icons/Icon'; import Icon from '../../common/icons/Icon';
import MediaSpoiler from '../../common/MediaSpoiler';
import MessageSummary from '../../common/MessageSummary'; import MessageSummary from '../../common/MessageSummary';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import ConfirmDialog from '../../ui/ConfirmDialog'; import ConfirmDialog from '../../ui/ConfirmDialog';
@ -114,21 +110,15 @@ const HeaderPinnedMessage = ({
const topMessageTitle = topMessageSender ? getPeerTitle(lang, topMessageSender) : undefined; const topMessageTitle = topMessageSender ? getPeerTitle(lang, topMessageSender) : undefined;
const video = pinnedMessage && getMessageVideo(pinnedMessage);
const gif = video?.isGif ? video : undefined;
const isVideoThumbnail = Boolean(gif && !gif.previewPhotoSizes?.length);
const mediaThumbnail = useThumbnail(pinnedMessage);
const mediaHash = useMessageMediaHash(pinnedMessage, isVideoThumbnail ? 'full' : 'pictogram');
const mediaBlobUrl = useMedia(mediaHash);
const hasPictogram = Boolean(mediaThumbnail || mediaBlobUrl);
const isSpoiler = pinnedMessage && getMessageIsSpoiler(pinnedMessage);
const isLoading = Boolean(useDerivedState(getLoadingPinnedId)); const isLoading = Boolean(useDerivedState(getLoadingPinnedId));
const canRenderLoader = useAsyncRendering([isLoading], SHOW_LOADER_DELAY); const canRenderLoader = useAsyncRendering([isLoading], SHOW_LOADER_DELAY);
const shouldShowLoader = canRenderLoader && isLoading; const shouldShowLoader = canRenderLoader && isLoading;
const renderingPinnedMessage = useCurrentOrPrev(pinnedMessage, true); const renderingPinnedMessage = useCurrentOrPrev(pinnedMessage, true);
const hasPictogram = Boolean(
renderingPinnedMessage && canRenderCompactMediaPreview(renderingPinnedMessage.content),
);
const isSpoiler = renderingPinnedMessage && getMessageIsSpoiler(renderingPinnedMessage);
useEffect(() => { useEffect(() => {
if (isSynced && (threadId === MAIN_THREAD_ID || chat?.isForum)) { if (isSynced && (threadId === MAIN_THREAD_ID || chat?.isForum)) {
@ -190,39 +180,6 @@ const HeaderPinnedMessage = ({
const { handleClick, handleMouseDown } = useFastClick(handleMessageClick); const { handleClick, handleMouseDown } = useFastClick(handleMessageClick);
function renderPictogram(thumbDataUri?: string, blobUrl?: string, isFullVideo?: boolean, asSpoiler?: boolean) {
const { width, height } = getPictogramDimensions();
const srcUrl = blobUrl || thumbDataUri;
const shouldRenderVideo = isFullVideo && blobUrl;
return (
<div className={styles.pinnedThumb}>
{thumbDataUri && !asSpoiler && !shouldRenderVideo && (
<img
className={styles.pinnedThumbImage}
src={srcUrl}
width={width}
height={height}
alt=""
draggable={false}
/>
)}
{shouldRenderVideo && !asSpoiler && (
<video
src={blobUrl}
width={width}
height={height}
playsInline
disablePictureInPicture
className={styles.pinnedThumbImage}
/>
)}
{thumbDataUri
&& <MediaSpoiler thumbDataUri={srcUrl} isVisible={Boolean(asSpoiler)} width={width} height={height} />}
</div>
);
}
if (!shouldRender || !renderingPinnedMessage) return undefined; if (!shouldRender || !renderingPinnedMessage) return undefined;
return ( return (
@ -284,12 +241,12 @@ const HeaderPinnedMessage = ({
index={currentPinnedIndex} index={currentPinnedIndex}
/> />
<Transition activeKey={renderingPinnedMessage.id} name="slideVertical" className={styles.pictogramTransition}> <Transition activeKey={renderingPinnedMessage.id} name="slideVertical" className={styles.pictogramTransition}>
{renderPictogram( <CompactMediaPreview
mediaThumbnail, media={renderingPinnedMessage.content}
mediaBlobUrl, className={styles.pinnedThumb}
isVideoThumbnail, isPictogram
isSpoiler, isSpoiler={isSpoiler}
)} />
</Transition> </Transition>
<div <div
className={buildClassName(styles.messageText, hasPictogram && styles.withMedia)} className={buildClassName(styles.messageText, hasPictogram && styles.withMedia)}
@ -315,7 +272,7 @@ const HeaderPinnedMessage = ({
<MessageSummary <MessageSummary
message={renderingPinnedMessage} message={renderingPinnedMessage}
truncateLength={MAX_LENGTH} truncateLength={MAX_LENGTH}
noEmoji={Boolean(mediaThumbnail)} noEmoji={hasPictogram}
emojiSize={EMOJI_SIZE} emojiSize={EMOJI_SIZE}
/> />
</p> </p>

View File

@ -5,7 +5,7 @@ import type { TabState } from '../../../global/types';
import { SettingsScreens } from '../../../types'; import { SettingsScreens } from '../../../types';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { getNextArrowReplacement } from '../../../util/localization/format'; import { NEXT_ARROW_REPLACEMENT } from '../../../util/localization/format';
import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
@ -210,7 +210,7 @@ const BirthdaySetupModal = ({ modal }: OwnProps) => {
link: ( link: (
<Link isPrimary onClick={handlePrivacyClick}> <Link isPrimary onClick={handlePrivacyClick}>
{lang('BirthdayPrivacySuggestionLink', undefined, {lang('BirthdayPrivacySuggestionLink', undefined,
{ withNodes: true, specialReplacement: getNextArrowReplacement() })} { withNodes: true, specialReplacement: NEXT_ARROW_REPLACEMENT })}
</Link> </Link>
), ),
}, { withNodes: true })} }, { withNodes: true })}

View File

@ -19,7 +19,7 @@ import buildStyle from '../../../util/buildStyle';
import { formatCountdown } from '../../../util/dates/oldDateFormat'; import { formatCountdown } from '../../../util/dates/oldDateFormat';
import { HOUR } from '../../../util/dates/units'; import { HOUR } from '../../../util/dates/units';
import { formatCurrency } from '../../../util/formatCurrency'; import { formatCurrency } from '../../../util/formatCurrency';
import { formatStarsAsIcon, getNextArrowReplacement } from '../../../util/localization/format'; import { formatStarsAsIcon, NEXT_ARROW_REPLACEMENT } from '../../../util/localization/format';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import useCustomBackground from '../../../hooks/useCustomBackground'; import useCustomBackground from '../../../hooks/useCustomBackground';
@ -293,7 +293,7 @@ function GiftComposer({
<Link isPrimary onClick={handleGetMoreStars}> <Link isPrimary onClick={handleGetMoreStars}>
{lang('GetMoreStarsLinkText', undefined, { {lang('GetMoreStarsLinkText', undefined, {
withNodes: true, withNodes: true,
specialReplacement: getNextArrowReplacement(), specialReplacement: NEXT_ARROW_REPLACEMENT,
})} })}
</Link> </Link>
), ),
@ -332,7 +332,7 @@ function GiftComposer({
link: ( link: (
<Link isPrimary onClick={handleOpenUpgradePreview}> <Link isPrimary onClick={handleOpenUpgradePreview}>
{lang('GiftMakeUniqueLink', undefined, { withNodes: true, {lang('GiftMakeUniqueLink', undefined, { withNodes: true,
specialReplacement: getNextArrowReplacement() })} specialReplacement: NEXT_ARROW_REPLACEMENT })}
</Link> </Link>
), ),
}, { }, {
@ -344,7 +344,7 @@ function GiftComposer({
<Link isPrimary onClick={handleOpenUpgradePreview}> <Link isPrimary onClick={handleOpenUpgradePreview}>
{lang('GiftMakeUniqueLink', undefined, { {lang('GiftMakeUniqueLink', undefined, {
withNodes: true, withNodes: true,
specialReplacement: getNextArrowReplacement() })} specialReplacement: NEXT_ARROW_REPLACEMENT })}
</Link> </Link>
), ),
}, { }, {

View File

@ -22,7 +22,7 @@ import { getUserFullName } from '../../../global/helpers';
import { getPeerTitle, isApiPeerChat, isApiPeerUser } from '../../../global/helpers/peers'; import { getPeerTitle, isApiPeerChat, isApiPeerUser } from '../../../global/helpers/peers';
import { selectPeer, selectTabState, selectUserFullInfo } from '../../../global/selectors'; import { selectPeer, selectTabState, selectUserFullInfo } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { getNextArrowReplacement } from '../../../util/localization/format'; import { NEXT_ARROW_REPLACEMENT } from '../../../util/localization/format';
import { throttle } from '../../../util/schedulers'; import { throttle } from '../../../util/schedulers';
import { REM } from '../../common/helpers/mediaDimensions'; import { REM } from '../../common/helpers/mediaDimensions';
@ -245,7 +245,7 @@ const GiftModal: FC<OwnProps & StateProps> = ({
> >
{lang('GiftPremiumDescriptionLinkCaption', undefined, { {lang('GiftPremiumDescriptionLinkCaption', undefined, {
withNodes: true, withNodes: true,
specialReplacement: getNextArrowReplacement(), specialReplacement: NEXT_ARROW_REPLACEMENT,
})} })}
</SafeLink> </SafeLink>
), ),

View File

@ -16,7 +16,7 @@ import type { TabState } from '../../../../global/types';
import { requestMeasure, requestMutation } from '../../../../lib/fasterdom/fasterdom'; import { requestMeasure, requestMutation } from '../../../../lib/fasterdom/fasterdom';
import { VTT_CRAFT_ATTRIBUTES } from '../../../../util/animations/viewTransitionTypes'; import { VTT_CRAFT_ATTRIBUTES } from '../../../../util/animations/viewTransitionTypes';
import buildClassName from '../../../../util/buildClassName'; import buildClassName from '../../../../util/buildClassName';
import { getNextArrowReplacement } from '../../../../util/localization/format'; import { NEXT_ARROW_REPLACEMENT } from '../../../../util/localization/format';
import { formatPercent } from '../../../../util/textFormat'; import { formatPercent } from '../../../../util/textFormat';
import { LOCAL_TGS_PREVIEW_URLS, LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets'; import { LOCAL_TGS_PREVIEW_URLS, LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets';
import { getGiftAttributes } from '../../../common/helpers/gifts'; import { getGiftAttributes } from '../../../common/helpers/gifts';
@ -1392,7 +1392,7 @@ const GiftCraftModal = ({ modal, craftAttributePermilles }: OwnProps & StateProp
<span className={styles.viewAllText}> <span className={styles.viewAllText}>
{lang('GiftCraftViewAll', undefined, { {lang('GiftCraftViewAll', undefined, {
withNodes: true, withNodes: true,
specialReplacement: getNextArrowReplacement(), specialReplacement: NEXT_ARROW_REPLACEMENT,
})} })}
</span> </span>
</Button> </Button>

View File

@ -19,7 +19,7 @@ import { formatDateTimeToString } from '../../../../util/dates/oldDateFormat';
import { formatCurrency, formatCurrencyAsString } from '../../../../util/formatCurrency'; import { formatCurrency, formatCurrencyAsString } from '../../../../util/formatCurrency';
import { import {
formatStarsAsIcon, formatStarsAsText, formatTonAsIcon, formatTonAsText, formatStarsAsIcon, formatStarsAsText, formatTonAsIcon, formatTonAsText,
getNextArrowReplacement, NEXT_ARROW_REPLACEMENT,
} from '../../../../util/localization/format'; } from '../../../../util/localization/format';
import { CUSTOM_PEER_HIDDEN } from '../../../../util/objects/customPeer'; import { CUSTOM_PEER_HIDDEN } from '../../../../util/objects/customPeer';
import { getServerTime } from '../../../../util/serverTime'; import { getServerTime } from '../../../../util/serverTime';
@ -786,7 +786,7 @@ const GiftInfoModal = ({
link: ( link: (
<SafeLink url={tonLink} shouldSkipModal text={lang('GiftInfoTonLinkText')}> <SafeLink url={tonLink} shouldSkipModal text={lang('GiftInfoTonLinkText')}>
{lang('GiftInfoTonLinkText', undefined, {lang('GiftInfoTonLinkText', undefined,
{ withNodes: true, specialReplacement: getNextArrowReplacement() })} { withNodes: true, specialReplacement: NEXT_ARROW_REPLACEMENT })}
</SafeLink> </SafeLink>
), ),
}, { withNodes: true })} }, { withNodes: true })}
@ -798,7 +798,7 @@ const GiftInfoModal = ({
link: ( link: (
<Link isPrimary onClick={handleTriggerVisibility}> <Link isPrimary onClick={handleTriggerVisibility}>
{lang(`GiftInfoSaved${isUnsaved ? 'Show' : 'Hide'}`, undefined, {lang(`GiftInfoSaved${isUnsaved ? 'Show' : 'Hide'}`, undefined,
{ withNodes: true, specialReplacement: getNextArrowReplacement() })} { withNodes: true, specialReplacement: NEXT_ARROW_REPLACEMENT })}
</Link> </Link>
), ),
}, { }, {

View File

@ -11,7 +11,7 @@ import type { AnimationLevel } from '../../../../types';
import { selectAnimationLevel } from '../../../../global/selectors/sharedState'; import { selectAnimationLevel } from '../../../../global/selectors/sharedState';
import buildClassName from '../../../../util/buildClassName'; import buildClassName from '../../../../util/buildClassName';
import { getNextArrowReplacement } from '../../../../util/localization/format'; import { NEXT_ARROW_REPLACEMENT } from '../../../../util/localization/format';
import { resolveTransitionName } from '../../../../util/resolveTransitionName'; import { resolveTransitionName } from '../../../../util/resolveTransitionName';
import { getGiftAttributes, getRandomGiftPreviewAttributes } from '../../../common/helpers/gifts'; import { getGiftAttributes, getRandomGiftPreviewAttributes } from '../../../common/helpers/gifts';
@ -359,7 +359,7 @@ const GiftPreviewModal = ({ modal, animationLevel }: OwnProps & StateProps) => {
{lang( {lang(
isCraftableModelsMode ? 'GiftPreviewToggleRegularModels' : 'GiftPreviewToggleCraftableModels', isCraftableModelsMode ? 'GiftPreviewToggleRegularModels' : 'GiftPreviewToggleCraftableModels',
undefined, undefined,
{ withNodes: true, specialReplacement: getNextArrowReplacement() }, { withNodes: true, specialReplacement: NEXT_ARROW_REPLACEMENT },
)} )}
</Link> </Link>
)} )}

View File

@ -8,7 +8,7 @@ import type { TabState } from '../../../../global/types';
import { getPeerTitle } from '../../../../global/helpers/peers'; import { getPeerTitle } from '../../../../global/helpers/peers';
import { selectPeer } from '../../../../global/selectors'; import { selectPeer } from '../../../../global/selectors';
import { getNextArrowReplacement } from '../../../../util/localization/format'; import { NEXT_ARROW_REPLACEMENT } from '../../../../util/localization/format';
import { import {
getRandomGiftPreviewAttributes, type GiftPreviewAttributes, getRandomGiftPreviewAttributes, type GiftPreviewAttributes,
preloadGiftAttributeStickers } from '../../../common/helpers/gifts'; preloadGiftAttributeStickers } from '../../../common/helpers/gifts';
@ -193,7 +193,7 @@ const GiftUpgradeModal = ({ modal, recipient }: OwnProps & StateProps) => {
/> />
))} ))}
<span className={styles.viewAllText}> <span className={styles.viewAllText}>
{lang('GiftUpgradeViewAll', undefined, { withNodes: true, specialReplacement: getNextArrowReplacement() })} {lang('GiftUpgradeViewAll', undefined, { withNodes: true, specialReplacement: NEXT_ARROW_REPLACEMENT })}
</span> </span>
</Button> </Button>
</div> </div>
@ -255,7 +255,7 @@ const GiftUpgradeModal = ({ modal, recipient }: OwnProps & StateProps) => {
onClick={handleOpenPriceInfo} onClick={handleOpenPriceInfo}
> >
{lang('StarGiftPriceDecreaseInfoLink', undefined, {lang('StarGiftPriceDecreaseInfoLink', undefined,
{ withNodes: true, specialReplacement: getNextArrowReplacement() })} { withNodes: true, specialReplacement: NEXT_ARROW_REPLACEMENT })}
</Link> </Link>
)} )}
</> </>

View File

@ -3,11 +3,10 @@ import { getActions } from '../../../global';
import type { TabState } from '../../../global/types'; import type { TabState } from '../../../global/types';
import { IS_IOS, IS_MAC_OS } from '../../../util/browser/windowEnvironment';
import { prepareMapUrl } from '../../../util/map'; import { prepareMapUrl } from '../../../util/map';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import useOldLang from '../../../hooks/useOldLang';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import Modal from '../../ui/Modal'; import Modal from '../../ui/Modal';
@ -23,7 +22,7 @@ const OpenMapModal = ({ modal }: OwnProps) => {
const { point: geoPoint, zoom } = modal || {}; const { point: geoPoint, zoom } = modal || {};
const lang = useOldLang(); const lang = useLang();
const isOpen = Boolean(geoPoint); const isOpen = Boolean(geoPoint);
@ -38,8 +37,8 @@ const OpenMapModal = ({ modal }: OwnProps) => {
const google = prepareMapUrl('google', geoPoint, zoom); const google = prepareMapUrl('google', geoPoint, zoom);
const bing = prepareMapUrl('bing', geoPoint, zoom); const bing = prepareMapUrl('bing', geoPoint, zoom);
const osm = prepareMapUrl('osm', geoPoint, zoom);
const apple = prepareMapUrl('apple', geoPoint, zoom); const apple = prepareMapUrl('apple', geoPoint, zoom);
const osm = prepareMapUrl('osm', geoPoint, zoom);
return [google, bing, apple, osm]; return [google, bing, apple, osm];
}, [geoPoint, zoom]); }, [geoPoint, zoom]);
@ -74,18 +73,16 @@ const OpenMapModal = ({ modal }: OwnProps) => {
isSlim isSlim
> >
<div className={styles.buttons}> <div className={styles.buttons}>
{(IS_IOS || IS_MAC_OS) && ( <Button noForcedUpperCase fluid size="smaller" onClick={handleGoogleClick}>
<Button fluid size="smaller" onClick={handleAppleClick}>
Apple Maps
</Button>
)}
<Button fluid size="smaller" onClick={handleGoogleClick}>
Google Maps Google Maps
</Button> </Button>
<Button fluid size="smaller" onClick={handleBingClick}> <Button noForcedUpperCase fluid size="smaller" onClick={handleAppleClick}>
Apple Maps
</Button>
<Button noForcedUpperCase fluid size="smaller" onClick={handleBingClick}>
Bing Maps Bing Maps
</Button> </Button>
<Button fluid size="smaller" onClick={handleOsmClick}> <Button noForcedUpperCase fluid size="smaller" onClick={handleOsmClick}>
OpenStreetMap OpenStreetMap
</Button> </Button>
</div> </div>

View File

@ -8,7 +8,7 @@ import { getPeerTitle } from '../../../global/helpers/peers';
import { selectUser, selectUserFullInfo } from '../../../global/selectors'; import { selectUser, selectUserFullInfo } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { formatShortDuration } from '../../../util/dates/oldDateFormat'; import { formatShortDuration } from '../../../util/dates/oldDateFormat';
import { getNextArrowReplacement } from '../../../util/localization/format'; import { NEXT_ARROW_REPLACEMENT } from '../../../util/localization/format';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
@ -132,7 +132,7 @@ const ProfileRatingModal = ({
link: ( link: (
<span className={styles.backLink} onClick={handleShowCurrent}> <span className={styles.backLink} onClick={handleShowCurrent}>
{lang('LinkDescriptionRatingBack', {lang('LinkDescriptionRatingBack',
undefined, { withNodes: true, specialReplacement: getNextArrowReplacement() })} undefined, { withNodes: true, specialReplacement: NEXT_ARROW_REPLACEMENT })}
</span> </span>
), ),
}, { }, {
@ -148,7 +148,7 @@ const ProfileRatingModal = ({
link: ( link: (
<span className={styles.previewLink} onClick={handleShowFuture}> <span className={styles.previewLink} onClick={handleShowFuture}>
{lang('LinkDescriptionRatingPreview', {lang('LinkDescriptionRatingPreview',
undefined, { withNodes: true, specialReplacement: getNextArrowReplacement() })} undefined, { withNodes: true, specialReplacement: NEXT_ARROW_REPLACEMENT })}
</span> </span>
), ),
}, { }, {

View File

@ -1,12 +1,11 @@
import { memo } from '../../lib/teact/teact'; import { memo } from '../../lib/teact/teact';
import { withGlobal } from '../../global'; import { withGlobal } from '../../global';
import type { ApiChat, ApiMessage, ApiPoll } from '../../api/types'; import type { ApiChat, ApiMessage, ApiMessagePoll } from '../../api/types';
import { import {
selectChat, selectChatMessage, selectPollFromMessage, selectTabState, selectChat, selectChatMessage, selectPollFromMessage, selectTabState,
} from '../../global/selectors'; } from '../../global/selectors';
import { buildCollectionByKey } from '../../util/iteratees';
import { renderTextWithEntities } from '../common/helpers/renderTextWithEntities'; import { renderTextWithEntities } from '../common/helpers/renderTextWithEntities';
import useHistoryBack from '../../hooks/useHistoryBack'; import useHistoryBack from '../../hooks/useHistoryBack';
@ -25,7 +24,7 @@ export type OwnProps = {
type StateProps = { type StateProps = {
chat?: ApiChat; chat?: ApiChat;
message?: ApiMessage; message?: ApiMessage;
poll?: ApiPoll; poll?: ApiMessagePoll;
}; };
const PollResults = ({ const PollResults = ({
@ -47,12 +46,10 @@ const PollResults = ({
} }
const { summary, results } = poll; const { summary, results } = poll;
if (!results.results) { if (!results.resultByOption) {
return undefined; return undefined;
} }
const resultsByOption = buildCollectionByKey(results.results, 'option');
return ( return (
<div className="PollResults" dir={lang.isRtl ? 'rtl' : undefined}> <div className="PollResults" dir={lang.isRtl ? 'rtl' : undefined}>
<h3 className="poll-question" dir="auto"> <h3 className="poll-question" dir="auto">
@ -64,11 +61,11 @@ const PollResults = ({
<div className="poll-results-list custom-scroll"> <div className="poll-results-list custom-scroll">
{summary.answers.map((answer) => ( {summary.answers.map((answer) => (
<PollAnswerResults <PollAnswerResults
key={`${poll.id}-${answer.option}`} key={`${poll.summary.id}-${answer.option}`}
chat={chat} chat={chat}
message={message} message={message}
answer={answer} answer={answer}
answerVote={resultsByOption[answer.option]} answerVote={results.resultByOption![answer.option]}
totalVoters={results.totalVoters!} totalVoters={results.totalVoters!}
/> />
))} ))}

View File

@ -150,39 +150,6 @@
} }
} }
&.text {
background-color: transparent;
&.primary {
color: var(--color-primary);
background-color: transparent;
@include active-styles() {
background-color: rgba(var(--color-primary-shade-rgb), 0.08);
}
@include no-ripple-styles() {
background-color: rgba(var(--color-primary-shade-rgb), 0.16);
}
}
&.secondary {
color: var(--color-text-secondary);
background-color: transparent;
}
&.danger {
@include active-styles() {
color: var(--color-error);
background-color: rgba(var(--color-error-rgb), 0.08);
}
@include no-ripple-styles() {
background-color: rgba(var(--color-error-rgb), 0.16);
}
}
}
&.faded { &.faded {
opacity: 0.8; opacity: 0.8;
@ -532,5 +499,38 @@
margin-inline-start: 0.25rem; margin-inline-start: 0.25rem;
} }
} }
&.text {
background-color: transparent;
&.primary {
color: var(--color-primary);
background-color: transparent;
@include active-styles() {
background-color: rgba(var(--color-primary-shade-rgb), 0.08);
}
@include no-ripple-styles() {
background-color: rgba(var(--color-primary-shade-rgb), 0.16);
}
}
&.secondary {
color: var(--color-text-secondary);
background-color: transparent;
}
&.danger {
@include active-styles() {
color: var(--color-error);
background-color: rgba(var(--color-error-rgb), 0.08);
}
@include no-ripple-styles() {
background-color: rgba(var(--color-error-rgb), 0.16);
}
}
}
} }
} }

View File

@ -6,7 +6,7 @@ import type { ApiStarsAmount, ApiTonAmount } from '../../api/types';
import { formatStarsAmount } from '../../global/helpers/payments'; import { formatStarsAmount } from '../../global/helpers/payments';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { convertTonFromNanos, convertTonToUsd, formatCurrencyAsString } from '../../util/formatCurrency'; import { convertTonFromNanos, convertTonToUsd, formatCurrencyAsString } from '../../util/formatCurrency';
import { formatStarsAsIcon, formatTonAsIcon, getNextArrowReplacement } from '../../util/localization/format'; import { formatStarsAsIcon, formatTonAsIcon, NEXT_ARROW_REPLACEMENT } from '../../util/localization/format';
import useIsTopmostBalanceBarModal from '../../hooks/element/useIsTopmostBalanceBarModal'; import useIsTopmostBalanceBarModal from '../../hooks/element/useIsTopmostBalanceBarModal';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
@ -101,7 +101,7 @@ function ModalStarBalanceBar({
<Link className={styles.getMoreStarsLink} isPrimary onClick={handleGetMoreStars}> <Link className={styles.getMoreStarsLink} isPrimary onClick={handleGetMoreStars}>
{lang('GetMoreStarsLinkText', undefined, { {lang('GetMoreStarsLinkText', undefined, {
withNodes: true, withNodes: true,
specialReplacement: getNextArrowReplacement(), specialReplacement: NEXT_ARROW_REPLACEMENT,
})} })}
</Link> </Link>
)} )}

View File

@ -1,28 +1,46 @@
import { useEffect } from '../../lib/teact/teact'; import { useEffect } from '../../lib/teact/teact';
import { formatMediaDuration } from '../../util/dates/oldDateFormat'; import { formatClockDuration, formatCountdownDateTime, secondsToDate } from '../../util/localization/dateFormat';
import { getServerTime } from '../../util/serverTime'; import { getServerTime } from '../../util/serverTime';
import useInterval from '../../hooks/schedulers/useInterval'; import useInterval from '../../hooks/schedulers/useInterval';
import useTimeout from '../../hooks/schedulers/useTimeout';
import useForceUpdate from '../../hooks/useForceUpdate'; import useForceUpdate from '../../hooks/useForceUpdate';
import useLang from '../../hooks/useLang';
import AnimatedCounter from '../common/AnimatedCounter'; import AnimatedCounter from '../common/AnimatedCounter';
type OwnProps = { type OwnProps = {
className?: string; className?: string;
endsAt: number; endsAt: number;
mode?: 'clock' | 'countdown';
shouldShowZeroOnEnd?: boolean; shouldShowZeroOnEnd?: boolean;
onEnd?: NoneToVoidFunction; onEnd?: NoneToVoidFunction;
}; };
const DAY_IN_SECONDS = 24 * 60 * 60;
const UPDATE_FREQUENCY = 500; // Sometimes second gets skipped if using 1000 const UPDATE_FREQUENCY = 500; // Sometimes second gets skipped if using 1000
const TextTimer = ({ className, endsAt, shouldShowZeroOnEnd, onEnd }: OwnProps) => { const TextTimer = ({
className,
endsAt,
mode = 'clock',
shouldShowZeroOnEnd,
onEnd,
}: OwnProps) => {
const forceUpdate = useForceUpdate(); const forceUpdate = useForceUpdate();
const lang = useLang();
const serverTime = getServerTime(); const serverTime = getServerTime();
const isActive = serverTime < endsAt; const isActive = serverTime < endsAt;
useInterval(forceUpdate, isActive ? UPDATE_FREQUENCY : undefined); const timeLeft = Math.max(0, endsAt - serverTime);
const shouldUseClock = mode === 'clock' || timeLeft < DAY_IN_SECONDS;
const switchToClockDelay = isActive && mode === 'countdown' && !shouldUseClock
? ((timeLeft - DAY_IN_SECONDS) * 1000) + UPDATE_FREQUENCY
: undefined;
useTimeout(forceUpdate, switchToClockDelay);
useInterval(forceUpdate, isActive && shouldUseClock ? UPDATE_FREQUENCY : undefined);
useEffect(() => { useEffect(() => {
if (!isActive) { if (!isActive) {
@ -32,18 +50,35 @@ const TextTimer = ({ className, endsAt, shouldShowZeroOnEnd, onEnd }: OwnProps)
if (!isActive && !shouldShowZeroOnEnd) return undefined; if (!isActive && !shouldShowZeroOnEnd) return undefined;
const timeLeft = Math.max(0, endsAt - serverTime); if (mode === 'countdown' && !shouldUseClock) {
const time = formatMediaDuration(timeLeft); return (
<span className={className}>
{formatCountdownDateTime(lang, secondsToDate(endsAt), {
anchorDate: secondsToDate(serverTime),
})}
</span>
);
}
const time = formatClockDuration(timeLeft);
const timeParts = time.split(':'); const timeParts = time.split(':');
const clockNode = (
<>
{timeParts.map((part, index) => (
<span key={index}>
{index > 0 && ':'}
<AnimatedCounter text={part} />
</span>
))}
</>
);
return ( return (
<span className={className} style="font-variant-numeric: tabular-nums;"> <span className={className} style="font-variant-numeric: tabular-nums;">
{timeParts.map((part, index) => ( {mode === 'countdown'
<> ? lang('TimeIn', { time: clockNode }, { withNodes: true })
{index > 0 && ':'} : clockNode}
<AnimatedCounter key={index} text={part} />
</>
))}
</span> </span>
); );
}; };

View File

@ -196,7 +196,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
}); });
if (poll) { if (poll) {
global = updatePoll(global, poll.id, poll); global = updatePoll(global, poll.summary.id, poll);
} }
if (webPage) { if (webPage) {
@ -320,7 +320,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
} }
if (poll) { if (poll) {
global = updatePoll(global, poll.id, poll); global = updatePoll(global, poll.summary.id, poll);
} }
if (webPage) { if (webPage) {
@ -368,7 +368,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
); );
} }
if (poll) { if (poll) {
global = updatePoll(global, poll.id, poll); global = updatePoll(global, poll.summary.id, poll);
} }
if (webPage) { if (webPage) {
@ -398,7 +398,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
} }
if (poll) { if (poll) {
global = updatePoll(global, poll.id, poll); global = updatePoll(global, poll.summary.id, poll);
} }
if (webPage) { if (webPage) {
@ -444,7 +444,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
global = updateQuickReplyMessage(global, id, message); global = updateQuickReplyMessage(global, id, message);
if (poll) { if (poll) {
global = updatePoll(global, poll.id, poll); global = updatePoll(global, poll.summary.id, poll);
} }
if (webPage) { if (webPage) {
@ -547,7 +547,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
}); });
if (poll) { if (poll) {
global = updatePoll(global, poll.id, poll); global = updatePoll(global, poll.summary.id, poll);
} }
global = { global = {
@ -624,7 +624,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
}); });
if (poll) { if (poll) {
global = updatePoll(global, poll.id, poll); global = updatePoll(global, poll.summary.id, poll);
} }
setGlobal(global); setGlobal(global);

View File

@ -46,7 +46,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
} }
if (polls) { if (polls) {
polls.forEach((poll) => { polls.forEach((poll) => {
global = updatePoll(global, poll.id, poll); global = updatePoll(global, poll.summary.id, poll);
}); });
} }
if (webPages) { if (webPages) {

View File

@ -10,8 +10,7 @@ import type {
ApiTypeStory, ApiTypeStory,
} from '../../api/types'; } from '../../api/types';
import type { import type {
ApiFormattedText, ApiFormattedText, ApiMessagePoll, ApiReplyInfo, ApiWebPage, MediaContainer, StatefulMediaContent,
ApiPoll, ApiReplyInfo, ApiWebPage, MediaContainer, StatefulMediaContent,
} from '../../api/types/messages'; } from '../../api/types/messages';
import type { ThreadId } from '../../types'; import type { ThreadId } from '../../types';
import type { LangFn } from '../../util/localization'; import type { LangFn } from '../../util/localization';
@ -74,6 +73,8 @@ export function hasMessageText(message: MediaContainer) {
webPage, contact, invoice, location, game, storyData, giveaway, giveawayResults, paidMedia, webPage, contact, invoice, location, game, storyData, giveaway, giveawayResults, paidMedia,
} = message.content; } = message.content;
if (pollId) return false;
return Boolean(text) || !( return Boolean(text) || !(
sticker || photo || video || audio || voice || document || contact || pollId || todo || webPage sticker || photo || video || audio || voice || document || contact || pollId || todo || webPage
|| invoice || location || game || storyData || giveaway || giveawayResults || dice || invoice || location || game || storyData || giveaway || giveawayResults || dice
@ -96,7 +97,7 @@ export function groupStatefulContent({
story, story,
webPage, webPage,
}: { }: {
poll?: ApiPoll; poll?: ApiMessagePoll;
story?: ApiTypeStory; story?: ApiTypeStory;
webPage?: ApiWebPage; webPage?: ApiWebPage;
}) { }) {

View File

@ -1,6 +1,6 @@
import type { import type {
ApiFormattedText, ApiFormattedText,
ApiMessage, ApiPoll, ApiPollResult, ApiQuickReply, ApiSponsoredMessage, ApiMessage, ApiMessagePoll, ApiPollResult, ApiPollResults, ApiQuickReply, ApiSponsoredMessage,
ApiWebPage, ApiWebPage,
ApiWebPageFull, ApiWebPageFull,
} from '../../api/types'; } from '../../api/types';
@ -871,34 +871,21 @@ export function replaceWebPage<T extends GlobalState>(
export function updatePoll<T extends GlobalState>( export function updatePoll<T extends GlobalState>(
global: T, global: T,
pollId: string, pollId: string,
pollUpdate: Partial<ApiPoll>, pollUpdate: Partial<ApiMessagePoll>,
) { ) {
const poll = selectPoll(global, pollId); const poll = selectPoll(global, pollId);
const results = mergePollResults(poll?.results, pollUpdate.results);
const oldResults = poll?.results; if (!results) {
let newResults = oldResults || pollUpdate.results; return global;
if (poll && pollUpdate.results?.results) {
if (!poll.results || !pollUpdate.results.isMin) {
newResults = pollUpdate.results;
} else if (oldResults.results) {
// Update voters counts, but keep local `isChosen` values
newResults = {
...pollUpdate.results,
results: pollUpdate.results.results.map((result) => ({
...result,
isChosen: oldResults.results!.find((r) => r.option === result.option)?.isChosen,
})),
isMin: undefined,
};
}
} }
const updatedPoll = { const updatedPoll = {
...poll, ...poll,
...pollUpdate, ...pollUpdate,
results: newResults, results,
} satisfies ApiPoll; } satisfies ApiMessagePoll;
if (!updatedPoll.id) { if (!updatedPoll.summary?.id) {
return global; return global;
} }
@ -914,6 +901,58 @@ export function updatePoll<T extends GlobalState>(
}; };
} }
function mergePollResults(
currentResults: ApiPollResults | undefined,
newResults: ApiPollResults | undefined,
) {
if (!newResults) {
return currentResults;
}
if (!currentResults) {
return newResults;
}
const mergedResults: ApiPollResults = {
...currentResults,
...newResults,
};
if (newResults.resultByOption) {
mergedResults.resultByOption = newResults.isMin
? mergeMinPollResultByOption(currentResults.resultByOption, newResults.resultByOption)
: newResults.resultByOption;
}
return mergedResults;
}
function mergeMinPollResultByOption(
currentResultByOption: Record<string, ApiPollResult> | undefined,
newResultByOption: Record<string, ApiPollResult>,
) {
const mergedResultByOption: Record<string, ApiPollResult> = {};
Object.keys(newResultByOption).forEach((option) => {
const currentResult = currentResultByOption?.[option];
const nextResult = newResultByOption[option];
if (!currentResult) {
mergedResultByOption[option] = nextResult;
return;
}
mergedResultByOption[option] = {
...currentResult,
...nextResult,
isChosen: currentResult.isChosen || nextResult.isChosen,
isCorrect: currentResult.isCorrect || nextResult.isCorrect,
};
});
return mergedResultByOption;
}
export function updatePollVote<T extends GlobalState>( export function updatePollVote<T extends GlobalState>(
global: T, global: T,
pollId: string, pollId: string,
@ -925,28 +964,26 @@ export function updatePollVote<T extends GlobalState>(
return global; return global;
} }
const { recentVoterIds, totalVoters, results } = poll.results; const { recentVoterIds, totalVoters, resultByOption } = poll.results;
const newRecentVoterIds = recentVoterIds ? [...recentVoterIds] : []; const newRecentVoterIds = recentVoterIds ? [...recentVoterIds] : [];
const newTotalVoters = totalVoters ? totalVoters + 1 : 1; const newTotalVoters = totalVoters ? totalVoters + 1 : 1;
const newResults = results ? [...results] : []; const newResultByOption = { ...resultByOption };
newRecentVoterIds.push(peerId); newRecentVoterIds.push(peerId);
options.forEach((option) => { options.forEach((option) => {
const targetOptionIndex = newResults.findIndex((result) => result.option === option); const targetOption = resultByOption?.[option];
const targetOption = newResults[targetOptionIndex];
const updatedOption: ApiPollResult = targetOption ? { ...targetOption } : { option, votersCount: 0 }; const updatedOption: ApiPollResult = targetOption ? { ...targetOption } : { option, votersCount: 0 };
const recentOptionVoterIds = updatedOption.recentVoterIds ? [...updatedOption.recentVoterIds] : [];
updatedOption.votersCount += 1; updatedOption.votersCount += 1;
recentOptionVoterIds.push(peerId);
updatedOption.recentVoterIds = recentOptionVoterIds;
if (peerId === global.currentUserId) { if (peerId === global.currentUserId) {
updatedOption.isChosen = true; updatedOption.isChosen = true;
} }
if (targetOptionIndex) { newResultByOption[option] = updatedOption;
newResults[targetOptionIndex] = updatedOption;
} else {
newResults.push(updatedOption);
}
}); });
return updatePoll(global, pollId, { return updatePoll(global, pollId, {
@ -954,7 +991,7 @@ export function updatePollVote<T extends GlobalState>(
...poll.results, ...poll.results,
recentVoterIds: newRecentVoterIds, recentVoterIds: newRecentVoterIds,
totalVoters: newTotalVoters, totalVoters: newTotalVoters,
results: newResults, resultByOption: newResultByOption,
}, },
}); });
} }

View File

@ -647,8 +647,12 @@ export function selectAllowedMessageActionsSlow<T extends GlobalState>(
const canSaveGif = message.content.video?.isGif; const canSaveGif = message.content.video?.isGif;
const poll = content.pollId ? selectPoll(global, content.pollId) : undefined; const poll = content.pollId ? selectPoll(global, content.pollId) : undefined;
const canRevote = !poll?.summary.closed && !poll?.summary.quiz && poll?.results.results?.some((r) => r.isChosen); const hasChosenPollAnswer = Boolean(
const canClosePoll = hasMessageEditRight && poll && !poll.summary.closed && !isForwarded; poll && Object.values(poll.results.resultByOption || {}).some((result) => result.isChosen),
);
const canRevote = poll && !poll.summary.isClosed && !poll.summary.isRevoteDisabled
&& hasChosenPollAnswer;
const canClosePoll = hasMessageEditRight && poll && !poll.summary.isClosed && !isForwarded;
const noOptions = [ const noOptions = [
canReply, canReply,

View File

@ -14,6 +14,7 @@ import type {
ApiEmojiStatusType, ApiEmojiStatusType,
ApiGroupCall, ApiGroupCall,
ApiMessage, ApiMessage,
ApiMessagePoll,
ApiNotifyPeerType, ApiNotifyPeerType,
ApiPaidReactionPrivacyType, ApiPaidReactionPrivacyType,
ApiPasskey, ApiPasskey,
@ -23,7 +24,6 @@ import type {
ApiPeerPhotos, ApiPeerPhotos,
ApiPeerStories, ApiPeerStories,
ApiPhoneCall, ApiPhoneCall,
ApiPoll,
ApiPrivacyKey, ApiPrivacyKey,
ApiPrivacySettings, ApiPrivacySettings,
ApiPromoData, ApiPromoData,
@ -253,7 +253,7 @@ export type GlobalState = {
byId: Record<number, number>; byId: Record<number, number>;
}>; }>;
sponsoredByChatId: Record<string, ApiSponsoredMessage>; sponsoredByChatId: Record<string, ApiSponsoredMessage>;
pollById: Record<string, ApiPoll>; pollById: Record<string, ApiMessagePoll>;
webPageById: Record<string, ApiWebPage>; webPageById: Record<string, ApiWebPage>;
}; };

File diff suppressed because it is too large Load Diff

View File

@ -16,337 +16,338 @@
} }
$icons-map: ( $icons-map: (
"zoom-out": "\f101", "active-sessions": "\f101",
"zoom-in": "\f102", "add-caption": "\f102",
"word-wrap": "\f103", "add-filled": "\f103",
"webapp": "\f104", "add-one-badge": "\f104",
"web": "\f105", "add-user-filled": "\f105",
"warning": "\f106", "add-user": "\f106",
"volume-3": "\f107", "add": "\f107",
"volume-2": "\f108", "admin": "\f108",
"volume-1": "\f109", "ai-edit": "\f109",
"voice-chat": "\f10a", "ai-fix": "\f10a",
"view-once": "\f10b", "ai": "\f10b",
"video": "\f10c", "allow-share": "\f10c",
"video-stop": "\f10d", "allow-speak": "\f10d",
"video-outlined": "\f10e", "animals": "\f10e",
"user": "\f10f", "animations": "\f10f",
"user-tag": "\f110", "archive-filled": "\f110",
"user-stars": "\f111", "archive-from-main": "\f111",
"user-online": "\f112", "archive-to-main": "\f112",
"user-filled": "\f113", "archive": "\f113",
"up": "\f114", "arrow-down-circle": "\f114",
"unread": "\f115", "arrow-down": "\f115",
"unpin": "\f116", "arrow-left": "\f116",
"unmute": "\f117", "arrow-right": "\f117",
"unlock": "\f118", "ask-support": "\f118",
"unlock-badge": "\f119", "attach": "\f119",
"unlist": "\f11a", "auction-drop": "\f11a",
"unlist-outline": "\f11b", "auction-filled": "\f11b",
"unique-profile": "\f11c", "auction-next-round": "\f11c",
"undo": "\f11d", "auction": "\f11d",
"understood": "\f11e", "author-hidden": "\f11e",
"underlined": "\f11f", "avatar-archived-chats": "\f11f",
"unarchive": "\f120", "avatar-deleted-account": "\f120",
"truck": "\f121", "avatar-saved-messages": "\f121",
"transcribe": "\f122", "bold": "\f122",
"trade": "\f123", "boost-craft-chance": "\f123",
"topic-new": "\f124", "boost-outline": "\f124",
"tools": "\f125", "boost": "\f125",
"toncoin": "\f126", "boostcircle": "\f126",
"timer": "\f127", "boosts": "\f127",
"tag": "\f128", "bot-command": "\f128",
"tag-name": "\f129", "bot-commands-filled": "\f129",
"tag-filter": "\f12a", "bots": "\f12a",
"tag-crossed": "\f12b", "brush": "\f12b",
"tag-add": "\f12c", "bug": "\f12c",
"strikethrough": "\f12d", "calendar-filter": "\f12d",
"story-reply": "\f12e", "calendar": "\f12e",
"story-priority": "\f12f", "camera-add": "\f12f",
"story-expired": "\f130", "camera": "\f130",
"story-caption": "\f131", "car": "\f131",
"stop": "\f132", "card": "\f132",
"stop-raising-hand": "\f133", "cash-circle": "\f133",
"stickers": "\f134", "channel-filled": "\f134",
"stealth-past": "\f135", "channel": "\f135",
"stealth-future": "\f136", "channelviews": "\f136",
"stats": "\f137", "chat-badge": "\f137",
"stars-refund": "\f138", "chats-badge": "\f138",
"stars-lock": "\f139", "check-bold": "\f139",
"star": "\f13a", "check": "\f13a",
"sport": "\f13b", "clock-edit": "\f13b",
"spoiler": "\f13c", "clock": "\f13c",
"spoiler-disable": "\f13d", "close-circle": "\f13d",
"speaker": "\f13e", "close-topic": "\f13e",
"speaker-story": "\f13f", "close": "\f13f",
"speaker-outline": "\f140", "closed-gift": "\f140",
"speaker-muted-story": "\f141", "cloud-download": "\f141",
"sort": "\f142", "collapse-modal": "\f142",
"sort-by-price": "\f143", "collapse": "\f143",
"sort-by-number": "\f144", "colorize": "\f144",
"sort-by-date": "\f145", "combine-craft": "\f145",
"smile": "\f146", "comments-sticker": "\f146",
"smallscreen": "\f147", "comments": "\f147",
"skip-previous": "\f148", "copy-media": "\f148",
"skip-next": "\f149", "copy": "\f149",
"sidebar": "\f14a", "craft": "\f14a",
"show-message": "\f14b", "crop": "\f14b",
"share-screen": "\f14c", "crown-take-off-outline": "\f14c",
"share-screen-stop": "\f14d", "crown-take-off": "\f14d",
"share-screen-outlined": "\f14e", "crown-wear-outline": "\f14e",
"share-filled": "\f14f", "crown-wear": "\f14f",
"settings": "\f150", "darkmode": "\f150",
"settings-filled": "\f151", "data": "\f151",
"send": "\f152", "delete-filled": "\f152",
"send-outline": "\f153", "delete-left": "\f153",
"sell": "\f154", "delete-user": "\f154",
"sell-outline": "\f155", "delete": "\f155",
"select": "\f156", "diamond": "\f156",
"select-filled": "\f157", "document": "\f157",
"search": "\f158", "double-badge": "\f158",
"sd-photo": "\f159", "down": "\f159",
"scheduled": "\f15a", "download": "\f15a",
"schedule": "\f15b", "dropdown-arrows": "\f15b",
"saved-messages": "\f15c", "eats": "\f15c",
"save-story": "\f15d", "edit": "\f15d",
"rotate": "\f15e", "email": "\f15e",
"revote": "\f15f", "enter": "\f15f",
"revenue-split": "\f160", "expand-modal": "\f160",
"reply": "\f161", "expand": "\f161",
"reply-filled": "\f162", "eye-crossed-outline": "\f162",
"replies": "\f163", "eye-crossed": "\f163",
"replace": "\f164", "eye-outline": "\f164",
"reorder-tabs": "\f165", "eye": "\f165",
"reopen-topic": "\f166", "favorite-filled": "\f166",
"remove": "\f167", "favorite": "\f167",
"remove-quote": "\f168", "file-badge": "\f168",
"reload": "\f169", "flag": "\f169",
"refund": "\f16a", "flip": "\f16a",
"redo": "\f16b", "folder-badge": "\f16b",
"recent": "\f16c", "folder-tabs-bot": "\f16c",
"readchats": "\f16d", "folder-tabs-channel": "\f16d",
"radial-badge": "\f16e", "folder-tabs-chat": "\f16e",
"quote": "\f16f", "folder-tabs-chats": "\f16f",
"quote-text": "\f170", "folder-tabs-folder": "\f170",
"proof-of-ownership": "\f171", "folder-tabs-group": "\f171",
"privacy-policy": "\f172", "folder-tabs-star": "\f172",
"previous": "\f173", "folder-tabs-user": "\f173",
"poll": "\f174", "folder": "\f174",
"play": "\f175", "fontsize": "\f175",
"play-story": "\f176", "forums": "\f176",
"pip": "\f177", "forward": "\f177",
"pinned-message": "\f178", "fragment": "\f178",
"pinned-chat": "\f179", "frozen-time": "\f179",
"pin": "\f17a", "fullscreen": "\f17a",
"pin-list": "\f17b", "gifs": "\f17b",
"pin-badge": "\f17c", "gift-transfer-inline": "\f17c",
"photo": "\f17d", "gift": "\f17d",
"phone": "\f17e", "group-filled": "\f17e",
"phone-discard": "\f17f", "group": "\f17f",
"phone-discard-outline": "\f180", "grouped-disable": "\f180",
"permissions": "\f181", "grouped": "\f181",
"pause": "\f182", "hand-stop-filled": "\f182",
"password-off": "\f183", "hand-stop": "\f183",
"open-in-new-tab": "\f184", "hashtag": "\f184",
"one-filled": "\f185", "hd-photo": "\f185",
"note": "\f186", "heart-outline": "\f186",
"non-contacts": "\f187", "heart": "\f187",
"noise-suppression": "\f188", "help": "\f188",
"nochannel": "\f189", "info-filled": "\f189",
"no-share": "\f18a", "info": "\f18a",
"no-download": "\f18b", "install": "\f18b",
"next": "\f18c", "italic": "\f18c",
"next-link": "\f18d", "key": "\f18d",
"new-send": "\f18e", "keyboard": "\f18e",
"new-chat-filled": "\f18f", "lamp": "\f18f",
"my-notes": "\f190", "language": "\f190",
"muted": "\f191", "large-pause": "\f191",
"mute": "\f192", "large-play": "\f192",
"move-caption-up": "\f193", "link-badge": "\f193",
"move-caption-down": "\f194", "link-broken": "\f194",
"more": "\f195", "link": "\f195",
"more-circle": "\f196", "location": "\f196",
"monospace": "\f197", "lock-badge": "\f197",
"microphone": "\f198", "lock": "\f198",
"microphone-alt": "\f199", "logout": "\f199",
"message": "\f19a", "loop": "\f19a",
"message-succeeded": "\f19b", "mention": "\f19b",
"message-read": "\f19c", "menu": "\f19c",
"message-pending": "\f19d", "message-failed": "\f19d",
"message-failed": "\f19e", "message-pending": "\f19e",
"menu": "\f19f", "message-read": "\f19f",
"mention": "\f1a0", "message-succeeded": "\f1a0",
"loop": "\f1a1", "message": "\f1a1",
"logout": "\f1a2", "microphone-alt": "\f1a2",
"lock": "\f1a3", "microphone": "\f1a3",
"lock-badge": "\f1a4", "monospace": "\f1a4",
"location": "\f1a5", "more-circle": "\f1a5",
"link": "\f1a6", "more": "\f1a6",
"link-broken": "\f1a7", "move-caption-down": "\f1a7",
"link-badge": "\f1a8", "move-caption-up": "\f1a8",
"large-play": "\f1a9", "mute": "\f1a9",
"large-pause": "\f1aa", "muted": "\f1aa",
"language": "\f1ab", "my-notes": "\f1ab",
"lamp": "\f1ac", "new-chat-filled": "\f1ac",
"keyboard": "\f1ad", "new-send": "\f1ad",
"key": "\f1ae", "next-link": "\f1ae",
"italic": "\f1af", "next": "\f1af",
"install": "\f1b0", "no-download": "\f1b0",
"info": "\f1b1", "no-share": "\f1b1",
"info-filled": "\f1b2", "nochannel": "\f1b2",
"help": "\f1b3", "noise-suppression": "\f1b3",
"heart": "\f1b4", "non-contacts": "\f1b4",
"heart-outline": "\f1b5", "note": "\f1b5",
"hd-photo": "\f1b6", "one-filled": "\f1b6",
"hashtag": "\f1b7", "open-in-new-tab": "\f1b7",
"hand-stop": "\f1b8", "password-off": "\f1b8",
"hand-stop-filled": "\f1b9", "pause": "\f1b9",
"grouped": "\f1ba", "permissions": "\f1ba",
"grouped-disable": "\f1bb", "phone-discard-outline": "\f1bb",
"group": "\f1bc", "phone-discard": "\f1bc",
"group-filled": "\f1bd", "phone": "\f1bd",
"gift": "\f1be", "photo": "\f1be",
"gift-transfer-inline": "\f1bf", "pin-badge": "\f1bf",
"gifs": "\f1c0", "pin-list": "\f1c0",
"fullscreen": "\f1c1", "pin": "\f1c1",
"frozen-time": "\f1c2", "pinned-chat": "\f1c2",
"fragment": "\f1c3", "pinned-message": "\f1c3",
"forward": "\f1c4", "pip": "\f1c4",
"forums": "\f1c5", "play-story": "\f1c5",
"fontsize": "\f1c6", "play": "\f1c6",
"folder": "\f1c7", "poll": "\f1c7",
"folder-badge": "\f1c8", "previous-link": "\f1c8",
"flip": "\f1c9", "previous": "\f1c9",
"flag": "\f1ca", "privacy-policy": "\f1ca",
"file-badge": "\f1cb", "proof-of-ownership": "\f1cb",
"favorite": "\f1cc", "quote-text": "\f1cc",
"favorite-filled": "\f1cd", "quote": "\f1cd",
"eye": "\f1ce", "radial-badge": "\f1ce",
"eye-outline": "\f1cf", "rating-icons-level1": "\f1cf",
"eye-crossed": "\f1d0", "rating-icons-level10": "\f1d0",
"eye-crossed-outline": "\f1d1", "rating-icons-level2": "\f1d1",
"expand": "\f1d2", "rating-icons-level20": "\f1d2",
"expand-modal": "\f1d3", "rating-icons-level3": "\f1d3",
"enter": "\f1d4", "rating-icons-level30": "\f1d4",
"email": "\f1d5", "rating-icons-level4": "\f1d5",
"edit": "\f1d6", "rating-icons-level40": "\f1d6",
"eats": "\f1d7", "rating-icons-level5": "\f1d7",
"dropdown-arrows": "\f1d8", "rating-icons-level50": "\f1d8",
"download": "\f1d9", "rating-icons-level6": "\f1d9",
"down": "\f1da", "rating-icons-level60": "\f1da",
"double-badge": "\f1db", "rating-icons-level7": "\f1db",
"document": "\f1dc", "rating-icons-level70": "\f1dc",
"diamond": "\f1dd", "rating-icons-level8": "\f1dd",
"delete": "\f1de", "rating-icons-level80": "\f1de",
"delete-user": "\f1df", "rating-icons-level9": "\f1df",
"delete-left": "\f1e0", "rating-icons-level90": "\f1e0",
"delete-filled": "\f1e1", "rating-icons-negative": "\f1e1",
"data": "\f1e2", "readchats": "\f1e2",
"darkmode": "\f1e3", "recent": "\f1e3",
"crown-wear": "\f1e4", "redo": "\f1e4",
"crown-wear-outline": "\f1e5", "refund": "\f1e5",
"crown-take-off": "\f1e6", "reload": "\f1e6",
"crown-take-off-outline": "\f1e7", "remove-quote": "\f1e7",
"crop": "\f1e8", "remove": "\f1e8",
"craft": "\f1e9", "reopen-topic": "\f1e9",
"copy": "\f1ea", "reorder-tabs": "\f1ea",
"copy-media": "\f1eb", "replace": "\f1eb",
"comments": "\f1ec", "replies": "\f1ec",
"comments-sticker": "\f1ed", "reply-filled": "\f1ed",
"combine-craft": "\f1ee", "reply": "\f1ee",
"colorize": "\f1ef", "revenue-split": "\f1ef",
"collapse": "\f1f0", "revote": "\f1f0",
"collapse-modal": "\f1f1", "rotate": "\f1f1",
"cloud-download": "\f1f2", "save-story": "\f1f2",
"closed-gift": "\f1f3", "saved-messages": "\f1f3",
"close": "\f1f4", "schedule": "\f1f4",
"close-topic": "\f1f5", "scheduled": "\f1f5",
"close-circle": "\f1f6", "sd-photo": "\f1f6",
"clock": "\f1f7", "search": "\f1f7",
"clock-edit": "\f1f8", "select-filled": "\f1f8",
"check": "\f1f9", "select": "\f1f9",
"check-bold": "\f1fa", "sell-outline": "\f1fa",
"chats-badge": "\f1fb", "sell": "\f1fb",
"chat-badge": "\f1fc", "send-outline": "\f1fc",
"channelviews": "\f1fd", "send": "\f1fd",
"channel": "\f1fe", "settings-filled": "\f1fe",
"channel-filled": "\f1ff", "settings": "\f1ff",
"cash-circle": "\f200", "share-filled": "\f200",
"card": "\f201", "share-screen-outlined": "\f201",
"car": "\f202", "share-screen-stop": "\f202",
"camera": "\f203", "share-screen": "\f203",
"camera-add": "\f204", "show-message": "\f204",
"calendar": "\f205", "sidebar": "\f205",
"calendar-filter": "\f206", "skip-next": "\f206",
"bug": "\f207", "skip-previous": "\f207",
"brush": "\f208", "smallscreen": "\f208",
"bots": "\f209", "smile": "\f209",
"bot-commands-filled": "\f20a", "sort-by-date": "\f20a",
"bot-command": "\f20b", "sort-by-number": "\f20b",
"boosts": "\f20c", "sort-by-price": "\f20c",
"boostcircle": "\f20d", "sort": "\f20d",
"boost": "\f20e", "speaker-muted-story": "\f20e",
"boost-outline": "\f20f", "speaker-outline": "\f20f",
"boost-craft-chance": "\f210", "speaker-story": "\f210",
"bold": "\f211", "speaker": "\f211",
"avatar-saved-messages": "\f212", "spoiler-disable": "\f212",
"avatar-deleted-account": "\f213", "spoiler": "\f213",
"avatar-archived-chats": "\f214", "sport": "\f214",
"author-hidden": "\f215", "star": "\f215",
"auction": "\f216", "stars-lock": "\f216",
"auction-next-round": "\f217", "stars-refund": "\f217",
"auction-filled": "\f218", "stats": "\f218",
"auction-drop": "\f219", "stealth-future": "\f219",
"attach": "\f21a", "stealth-past": "\f21a",
"ask-support": "\f21b", "stickers": "\f21b",
"arrow-right": "\f21c", "stop-raising-hand": "\f21c",
"arrow-left": "\f21d", "stop": "\f21d",
"arrow-down": "\f21e", "story-caption": "\f21e",
"arrow-down-circle": "\f21f", "story-expired": "\f21f",
"archive": "\f220", "story-priority": "\f220",
"archive-to-main": "\f221", "story-reply": "\f221",
"archive-from-main": "\f222", "strikethrough": "\f222",
"archive-filled": "\f223", "tag-add": "\f223",
"animations": "\f224", "tag-crossed": "\f224",
"animals": "\f225", "tag-filter": "\f225",
"allow-speak": "\f226", "tag-name": "\f226",
"allow-share": "\f227", "tag": "\f227",
"ai": "\f228", "timer": "\f228",
"ai-fix": "\f229", "toncoin": "\f229",
"ai-edit": "\f22a", "tools": "\f22a",
"admin": "\f22b", "topic-new": "\f22b",
"add": "\f22c", "trade": "\f22c",
"add-user": "\f22d", "transcribe": "\f22d",
"add-user-filled": "\f22e", "truck": "\f22e",
"add-one-badge": "\f22f", "unarchive": "\f22f",
"add-filled": "\f230", "underlined": "\f230",
"add-caption": "\f231", "understood": "\f231",
"active-sessions": "\f232", "undo": "\f232",
"rating-icons-negative": "\f233", "unique-profile": "\f233",
"rating-icons-level90": "\f234", "unlist-outline": "\f234",
"rating-icons-level9": "\f235", "unlist": "\f235",
"rating-icons-level80": "\f236", "unlock-badge": "\f236",
"rating-icons-level8": "\f237", "unlock": "\f237",
"rating-icons-level70": "\f238", "unmute": "\f238",
"rating-icons-level7": "\f239", "unpin": "\f239",
"rating-icons-level60": "\f23a", "unread": "\f23a",
"rating-icons-level6": "\f23b", "up": "\f23b",
"rating-icons-level50": "\f23c", "user-filled": "\f23c",
"rating-icons-level5": "\f23d", "user-online": "\f23d",
"rating-icons-level40": "\f23e", "user-stars": "\f23e",
"rating-icons-level4": "\f23f", "user-tag": "\f23f",
"rating-icons-level30": "\f240", "user": "\f240",
"rating-icons-level3": "\f241", "video-outlined": "\f241",
"rating-icons-level20": "\f242", "video-stop": "\f242",
"rating-icons-level2": "\f243", "video": "\f243",
"rating-icons-level10": "\f244", "view-once": "\f244",
"rating-icons-level1": "\f245", "voice-chat": "\f245",
"folder-tabs-user": "\f246", "volume-1": "\f246",
"folder-tabs-star": "\f247", "volume-2": "\f247",
"folder-tabs-group": "\f248", "volume-3": "\f248",
"folder-tabs-folder": "\f249", "warning": "\f249",
"folder-tabs-chats": "\f24a", "web": "\f24a",
"folder-tabs-chat": "\f24b", "webapp": "\f24b",
"folder-tabs-channel": "\f24c", "word-wrap": "\f24c",
"folder-tabs-bot": "\f24d", "zoom-in": "\f24d",
"zoom-out": "\f24e",
); );

Binary file not shown.

Binary file not shown.

View File

@ -352,11 +352,10 @@ body:not(.is-ios) {
vertical-align: bottom; vertical-align: bottom;
} }
.next-arrow-icon { .link-arrow-icon {
font-size: var(--next-arrow-size, 0.5625em); font-size: var(--next-arrow-size, 0.5625em);
font-weight: var(--next-arrow-weight, inherit); font-weight: var(--next-arrow-weight, inherit);
line-height: inherit; line-height: inherit;
vertical-align: middle;
} }
.shared-canvas-container { .shared-canvas-container {

View File

@ -1,334 +1,335 @@
export type FontIconName = export type FontIconName =
| 'zoom-out'
| 'zoom-in'
| 'word-wrap'
| 'webapp'
| 'web'
| 'warning'
| 'volume-3'
| 'volume-2'
| 'volume-1'
| 'voice-chat'
| 'view-once'
| 'video'
| 'video-stop'
| 'video-outlined'
| 'user'
| 'user-tag'
| 'user-stars'
| 'user-online'
| 'user-filled'
| 'up'
| 'unread'
| 'unpin'
| 'unmute'
| 'unlock'
| 'unlock-badge'
| 'unlist'
| 'unlist-outline'
| 'unique-profile'
| 'undo'
| 'understood'
| 'underlined'
| 'unarchive'
| 'truck'
| 'transcribe'
| 'trade'
| 'topic-new'
| 'tools'
| 'toncoin'
| 'timer'
| 'tag'
| 'tag-name'
| 'tag-filter'
| 'tag-crossed'
| 'tag-add'
| 'strikethrough'
| 'story-reply'
| 'story-priority'
| 'story-expired'
| 'story-caption'
| 'stop'
| 'stop-raising-hand'
| 'stickers'
| 'stealth-past'
| 'stealth-future'
| 'stats'
| 'stars-refund'
| 'stars-lock'
| 'star'
| 'sport'
| 'spoiler'
| 'spoiler-disable'
| 'speaker'
| 'speaker-story'
| 'speaker-outline'
| 'speaker-muted-story'
| 'sort'
| 'sort-by-price'
| 'sort-by-number'
| 'sort-by-date'
| 'smile'
| 'smallscreen'
| 'skip-previous'
| 'skip-next'
| 'sidebar'
| 'show-message'
| 'share-screen'
| 'share-screen-stop'
| 'share-screen-outlined'
| 'share-filled'
| 'settings'
| 'settings-filled'
| 'send'
| 'send-outline'
| 'sell'
| 'sell-outline'
| 'select'
| 'select-filled'
| 'search'
| 'sd-photo'
| 'scheduled'
| 'schedule'
| 'saved-messages'
| 'save-story'
| 'rotate'
| 'revote'
| 'revenue-split'
| 'reply'
| 'reply-filled'
| 'replies'
| 'replace'
| 'reorder-tabs'
| 'reopen-topic'
| 'remove'
| 'remove-quote'
| 'reload'
| 'refund'
| 'redo'
| 'recent'
| 'readchats'
| 'radial-badge'
| 'quote'
| 'quote-text'
| 'proof-of-ownership'
| 'privacy-policy'
| 'previous'
| 'poll'
| 'play'
| 'play-story'
| 'pip'
| 'pinned-message'
| 'pinned-chat'
| 'pin'
| 'pin-list'
| 'pin-badge'
| 'photo'
| 'phone'
| 'phone-discard'
| 'phone-discard-outline'
| 'permissions'
| 'pause'
| 'password-off'
| 'open-in-new-tab'
| 'one-filled'
| 'note'
| 'non-contacts'
| 'noise-suppression'
| 'nochannel'
| 'no-share'
| 'no-download'
| 'next'
| 'next-link'
| 'new-send'
| 'new-chat-filled'
| 'my-notes'
| 'muted'
| 'mute'
| 'move-caption-up'
| 'move-caption-down'
| 'more'
| 'more-circle'
| 'monospace'
| 'microphone'
| 'microphone-alt'
| 'message'
| 'message-succeeded'
| 'message-read'
| 'message-pending'
| 'message-failed'
| 'menu'
| 'mention'
| 'loop'
| 'logout'
| 'lock'
| 'lock-badge'
| 'location'
| 'link'
| 'link-broken'
| 'link-badge'
| 'large-play'
| 'large-pause'
| 'language'
| 'lamp'
| 'keyboard'
| 'key'
| 'italic'
| 'install'
| 'info'
| 'info-filled'
| 'help'
| 'heart'
| 'heart-outline'
| 'hd-photo'
| 'hashtag'
| 'hand-stop'
| 'hand-stop-filled'
| 'grouped'
| 'grouped-disable'
| 'group'
| 'group-filled'
| 'gift'
| 'gift-transfer-inline'
| 'gifs'
| 'fullscreen'
| 'frozen-time'
| 'fragment'
| 'forward'
| 'forums'
| 'fontsize'
| 'folder'
| 'folder-badge'
| 'flip'
| 'flag'
| 'file-badge'
| 'favorite'
| 'favorite-filled'
| 'eye'
| 'eye-outline'
| 'eye-crossed'
| 'eye-crossed-outline'
| 'expand'
| 'expand-modal'
| 'enter'
| 'email'
| 'edit'
| 'eats'
| 'dropdown-arrows'
| 'download'
| 'down'
| 'double-badge'
| 'document'
| 'diamond'
| 'delete'
| 'delete-user'
| 'delete-left'
| 'delete-filled'
| 'data'
| 'darkmode'
| 'crown-wear'
| 'crown-wear-outline'
| 'crown-take-off'
| 'crown-take-off-outline'
| 'crop'
| 'craft'
| 'copy'
| 'copy-media'
| 'comments'
| 'comments-sticker'
| 'combine-craft'
| 'colorize'
| 'collapse'
| 'collapse-modal'
| 'cloud-download'
| 'closed-gift'
| 'close'
| 'close-topic'
| 'close-circle'
| 'clock'
| 'clock-edit'
| 'check'
| 'check-bold'
| 'chats-badge'
| 'chat-badge'
| 'channelviews'
| 'channel'
| 'channel-filled'
| 'cash-circle'
| 'card'
| 'car'
| 'camera'
| 'camera-add'
| 'calendar'
| 'calendar-filter'
| 'bug'
| 'brush'
| 'bots'
| 'bot-commands-filled'
| 'bot-command'
| 'boosts'
| 'boostcircle'
| 'boost'
| 'boost-outline'
| 'boost-craft-chance'
| 'bold'
| 'avatar-saved-messages'
| 'avatar-deleted-account'
| 'avatar-archived-chats'
| 'author-hidden'
| 'auction'
| 'auction-next-round'
| 'auction-filled'
| 'auction-drop'
| 'attach'
| 'ask-support'
| 'arrow-right'
| 'arrow-left'
| 'arrow-down'
| 'arrow-down-circle'
| 'archive'
| 'archive-to-main'
| 'archive-from-main'
| 'archive-filled'
| 'animations'
| 'animals'
| 'allow-speak'
| 'allow-share'
| 'ai'
| 'ai-fix'
| 'ai-edit'
| 'admin'
| 'add'
| 'add-user'
| 'add-user-filled'
| 'add-one-badge'
| 'add-filled'
| 'add-caption'
| 'active-sessions' | 'active-sessions'
| 'rating-icons-negative' | 'add-caption'
| 'rating-icons-level90' | 'add-filled'
| 'rating-icons-level9' | 'add-one-badge'
| 'rating-icons-level80' | 'add-user-filled'
| 'rating-icons-level8' | 'add-user'
| 'rating-icons-level70' | 'add'
| 'rating-icons-level7' | 'admin'
| 'rating-icons-level60' | 'ai-edit'
| 'rating-icons-level6' | 'ai-fix'
| 'rating-icons-level50' | 'ai'
| 'rating-icons-level5' | 'allow-share'
| 'rating-icons-level40' | 'allow-speak'
| 'rating-icons-level4' | 'animals'
| 'rating-icons-level30' | 'animations'
| 'rating-icons-level3' | 'archive-filled'
| 'rating-icons-level20' | 'archive-from-main'
| 'rating-icons-level2' | 'archive-to-main'
| 'rating-icons-level10' | 'archive'
| 'rating-icons-level1' | 'arrow-down-circle'
| 'folder-tabs-user' | 'arrow-down'
| 'folder-tabs-star' | 'arrow-left'
| 'folder-tabs-group' | 'arrow-right'
| 'folder-tabs-folder' | 'ask-support'
| 'folder-tabs-chats' | 'attach'
| 'folder-tabs-chat' | 'auction-drop'
| 'auction-filled'
| 'auction-next-round'
| 'auction'
| 'author-hidden'
| 'avatar-archived-chats'
| 'avatar-deleted-account'
| 'avatar-saved-messages'
| 'bold'
| 'boost-craft-chance'
| 'boost-outline'
| 'boost'
| 'boostcircle'
| 'boosts'
| 'bot-command'
| 'bot-commands-filled'
| 'bots'
| 'brush'
| 'bug'
| 'calendar-filter'
| 'calendar'
| 'camera-add'
| 'camera'
| 'car'
| 'card'
| 'cash-circle'
| 'channel-filled'
| 'channel'
| 'channelviews'
| 'chat-badge'
| 'chats-badge'
| 'check-bold'
| 'check'
| 'clock-edit'
| 'clock'
| 'close-circle'
| 'close-topic'
| 'close'
| 'closed-gift'
| 'cloud-download'
| 'collapse-modal'
| 'collapse'
| 'colorize'
| 'combine-craft'
| 'comments-sticker'
| 'comments'
| 'copy-media'
| 'copy'
| 'craft'
| 'crop'
| 'crown-take-off-outline'
| 'crown-take-off'
| 'crown-wear-outline'
| 'crown-wear'
| 'darkmode'
| 'data'
| 'delete-filled'
| 'delete-left'
| 'delete-user'
| 'delete'
| 'diamond'
| 'document'
| 'double-badge'
| 'down'
| 'download'
| 'dropdown-arrows'
| 'eats'
| 'edit'
| 'email'
| 'enter'
| 'expand-modal'
| 'expand'
| 'eye-crossed-outline'
| 'eye-crossed'
| 'eye-outline'
| 'eye'
| 'favorite-filled'
| 'favorite'
| 'file-badge'
| 'flag'
| 'flip'
| 'folder-badge'
| 'folder-tabs-bot'
| 'folder-tabs-channel' | 'folder-tabs-channel'
| 'folder-tabs-bot'; | 'folder-tabs-chat'
| 'folder-tabs-chats'
| 'folder-tabs-folder'
| 'folder-tabs-group'
| 'folder-tabs-star'
| 'folder-tabs-user'
| 'folder'
| 'fontsize'
| 'forums'
| 'forward'
| 'fragment'
| 'frozen-time'
| 'fullscreen'
| 'gifs'
| 'gift-transfer-inline'
| 'gift'
| 'group-filled'
| 'group'
| 'grouped-disable'
| 'grouped'
| 'hand-stop-filled'
| 'hand-stop'
| 'hashtag'
| 'hd-photo'
| 'heart-outline'
| 'heart'
| 'help'
| 'info-filled'
| 'info'
| 'install'
| 'italic'
| 'key'
| 'keyboard'
| 'lamp'
| 'language'
| 'large-pause'
| 'large-play'
| 'link-badge'
| 'link-broken'
| 'link'
| 'location'
| 'lock-badge'
| 'lock'
| 'logout'
| 'loop'
| 'mention'
| 'menu'
| 'message-failed'
| 'message-pending'
| 'message-read'
| 'message-succeeded'
| 'message'
| 'microphone-alt'
| 'microphone'
| 'monospace'
| 'more-circle'
| 'more'
| 'move-caption-down'
| 'move-caption-up'
| 'mute'
| 'muted'
| 'my-notes'
| 'new-chat-filled'
| 'new-send'
| 'next-link'
| 'next'
| 'no-download'
| 'no-share'
| 'nochannel'
| 'noise-suppression'
| 'non-contacts'
| 'note'
| 'one-filled'
| 'open-in-new-tab'
| 'password-off'
| 'pause'
| 'permissions'
| 'phone-discard-outline'
| 'phone-discard'
| 'phone'
| 'photo'
| 'pin-badge'
| 'pin-list'
| 'pin'
| 'pinned-chat'
| 'pinned-message'
| 'pip'
| 'play-story'
| 'play'
| 'poll'
| 'previous-link'
| 'previous'
| 'privacy-policy'
| 'proof-of-ownership'
| 'quote-text'
| 'quote'
| 'radial-badge'
| 'rating-icons-level1'
| 'rating-icons-level10'
| 'rating-icons-level2'
| 'rating-icons-level20'
| 'rating-icons-level3'
| 'rating-icons-level30'
| 'rating-icons-level4'
| 'rating-icons-level40'
| 'rating-icons-level5'
| 'rating-icons-level50'
| 'rating-icons-level6'
| 'rating-icons-level60'
| 'rating-icons-level7'
| 'rating-icons-level70'
| 'rating-icons-level8'
| 'rating-icons-level80'
| 'rating-icons-level9'
| 'rating-icons-level90'
| 'rating-icons-negative'
| 'readchats'
| 'recent'
| 'redo'
| 'refund'
| 'reload'
| 'remove-quote'
| 'remove'
| 'reopen-topic'
| 'reorder-tabs'
| 'replace'
| 'replies'
| 'reply-filled'
| 'reply'
| 'revenue-split'
| 'revote'
| 'rotate'
| 'save-story'
| 'saved-messages'
| 'schedule'
| 'scheduled'
| 'sd-photo'
| 'search'
| 'select-filled'
| 'select'
| 'sell-outline'
| 'sell'
| 'send-outline'
| 'send'
| 'settings-filled'
| 'settings'
| 'share-filled'
| 'share-screen-outlined'
| 'share-screen-stop'
| 'share-screen'
| 'show-message'
| 'sidebar'
| 'skip-next'
| 'skip-previous'
| 'smallscreen'
| 'smile'
| 'sort-by-date'
| 'sort-by-number'
| 'sort-by-price'
| 'sort'
| 'speaker-muted-story'
| 'speaker-outline'
| 'speaker-story'
| 'speaker'
| 'spoiler-disable'
| 'spoiler'
| 'sport'
| 'star'
| 'stars-lock'
| 'stars-refund'
| 'stats'
| 'stealth-future'
| 'stealth-past'
| 'stickers'
| 'stop-raising-hand'
| 'stop'
| 'story-caption'
| 'story-expired'
| 'story-priority'
| 'story-reply'
| 'strikethrough'
| 'tag-add'
| 'tag-crossed'
| 'tag-filter'
| 'tag-name'
| 'tag'
| 'timer'
| 'toncoin'
| 'tools'
| 'topic-new'
| 'trade'
| 'transcribe'
| 'truck'
| 'unarchive'
| 'underlined'
| 'understood'
| 'undo'
| 'unique-profile'
| 'unlist-outline'
| 'unlist'
| 'unlock-badge'
| 'unlock'
| 'unmute'
| 'unpin'
| 'unread'
| 'up'
| 'user-filled'
| 'user-online'
| 'user-stars'
| 'user-tag'
| 'user'
| 'video-outlined'
| 'video-stop'
| 'video'
| 'view-once'
| 'voice-chat'
| 'volume-1'
| 'volume-2'
| 'volume-3'
| 'warning'
| 'web'
| 'webapp'
| 'word-wrap'
| 'zoom-in'
| 'zoom-out';

View File

@ -342,6 +342,7 @@ export enum MediaViewerOrigin {
StarsTransaction, StarsTransaction,
PreviewMedia, PreviewMedia,
SponsoredMessage, SponsoredMessage,
PollPreview,
} }
export enum StoryViewerOrigin { export enum StoryViewerOrigin {

View File

@ -713,7 +713,10 @@ export interface LangPair {
'CallAgain': undefined; 'CallAgain': undefined;
'CallBack': undefined; 'CallBack': undefined;
'PollSubmitVotes': undefined; 'PollSubmitVotes': undefined;
'PollSubmitAnswers': undefined;
'PollViewResults': undefined; 'PollViewResults': undefined;
'PollBackToVote': undefined;
'PollBackToAnswer': undefined;
'ChatQuizTotalVotesEmpty': undefined; 'ChatQuizTotalVotesEmpty': undefined;
'ChatPollTotalVotesResultEmpty': undefined; 'ChatPollTotalVotesResultEmpty': undefined;
'Vote': undefined; 'Vote': undefined;
@ -1403,6 +1406,7 @@ export interface LangPair {
'StarsSubscribeInfoLinkText': undefined; 'StarsSubscribeInfoLinkText': undefined;
'StarsSubscribeInfoLink': undefined; 'StarsSubscribeInfoLink': undefined;
'StarsBalance': undefined; 'StarsBalance': undefined;
'OpenMapWith': undefined;
'OpenApp': undefined; 'OpenApp': undefined;
'PopularApps': undefined; 'PopularApps': undefined;
'SearchApps': undefined; 'SearchApps': undefined;
@ -2284,6 +2288,15 @@ export interface LangPairWithVariables<V = LangVariable> {
'time': V; 'time': V;
'duration': V; 'duration': V;
}; };
'PollEndsTime': {
'time': V;
};
'PollResultsTime': {
'time': V;
};
'TimeIn': {
'time': V;
};
'MessageScheduledOn': { 'MessageScheduledOn': {
'date': V; 'date': V;
}; };
@ -3428,6 +3441,20 @@ export interface LangPairWithVariables<V = LangVariable> {
'tasks': V; 'tasks': V;
'list': V; 'list': V;
}; };
'MessageActionPollAppendAnswer': {
'peer': V;
'option': V;
};
'MessageActionPollAppendAnswerYou': {
'option': V;
};
'MessageActionPollDeleteAnswer': {
'peer': V;
'option': V;
};
'MessageActionPollDeleteAnswerYou': {
'option': V;
};
'GiftInfoCollectibleBy': { 'GiftInfoCollectibleBy': {
'number': V; 'number': V;
'owner': V; 'owner': V;
@ -3724,9 +3751,21 @@ export interface LangPairPluralWithVariables<V = LangVariable> {
'ConversationContextMenuSeen': { 'ConversationContextMenuSeen': {
'count': V; 'count': V;
}; };
'PollVoteCountButton': {
'count': V;
};
'PollAnswerCountButton': {
'count': V;
};
'Answer': { 'Answer': {
'count': V; 'count': V;
}; };
'PollAnsweredCount': {
'count': V;
};
'VoteCount': {
'count': V;
};
'VoiceOverChatMessagesSelected': { 'VoiceOverChatMessagesSelected': {
'count': V; 'count': V;
}; };

View File

@ -114,6 +114,17 @@ export function orderBy<T>(
}); });
} }
export function shuffle<T>(array: readonly T[]): T[] {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i -= 1) {
const randomIndex = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[randomIndex]] = [shuffled[randomIndex], shuffled[i]];
}
return shuffled;
}
export function unique<T>(array: T[]): T[] { export function unique<T>(array: T[]): T[] {
return Array.from(new Set(array)); return Array.from(new Set(array));
} }

View File

@ -153,6 +153,24 @@ export function formatClockDuration(duration: number) {
return string; return string;
} }
export function formatCountdownDateTime(
lang: LangFn,
targetDate: Date,
options: Pick<FormatDateTimeOptions, 'anchorDate'> = {},
) {
const anchorDate = options.anchorDate || new Date();
const diffInSeconds = Math.max(0, Math.trunc((targetDate.getTime() - anchorDate.getTime()) / 1000));
if (diffInSeconds < DAY_IN_SECONDS) {
return lang('TimeIn', { time: formatClockDuration(diffInSeconds) });
}
return formatDateTime(lang, targetDate, {
relative: 'auto',
anchorDate,
});
}
function buildAbsoluteFormatterOptions(lang: LangFn, options: FormatDateTimeOptions) { function buildAbsoluteFormatterOptions(lang: LangFn, options: FormatDateTimeOptions) {
const dateStyle = options.date ?? false; const dateStyle = options.date ?? false;
const timeStyle = options.time ?? false; const timeStyle = options.time ?? false;

View File

@ -8,11 +8,12 @@ import buildClassName from '../buildClassName';
import Icon from '../../components/common/icons/Icon'; import Icon from '../../components/common/icons/Icon';
import StarIcon from '../../components/common/icons/StarIcon'; import StarIcon from '../../components/common/icons/StarIcon';
export function getNextArrowReplacement() { export const NEXT_ARROW_REPLACEMENT = {
return { '>': <Icon name="next-link" className="link-arrow-icon" />,
'>': <Icon name="next-link" className="next-arrow-icon" />, };
}; export const PREVIOUS_ARROW_REPLACEMENT = {
} '<': <Icon name="previous-link" className="link-arrow-icon" />,
};
export function formatStarsAsText(lang: LangFn, amount: number) { export function formatStarsAsText(lang: LangFn, amount: number) {
return lang('StarsAmountText', { amount }, { pluralValue: amount }); return lang('StarsAmountText', { amount }, { pluralValue: amount });

View File

@ -24,7 +24,8 @@
"jsxImportSource": "@teact", "jsxImportSource": "@teact",
"paths": { "paths": {
"@teact": ["./src/lib/teact/teact.ts"], "@teact": ["./src/lib/teact/teact.ts"],
"@teact/*": ["./src/lib/teact/*"] "@teact/*": ["./src/lib/teact/*"],
"@gili/*": ["./src/components/gili/*"]
}, },
"types": [ "types": [
"dom-chromium-ai", "dom-chromium-ai",

View File

@ -183,6 +183,7 @@ export default function createConfig(
alias: { alias: {
'@teact$': path.resolve(__dirname, './src/lib/teact/teact.ts'), '@teact$': path.resolve(__dirname, './src/lib/teact/teact.ts'),
'@teact': path.resolve(__dirname, './src/lib/teact'), '@teact': path.resolve(__dirname, './src/lib/teact'),
'@gili': path.resolve(__dirname, './src/components/gili'),
}, },
fallback: { fallback: {
path: require.resolve('path-browserify'), path: require.resolve('path-browserify'),