diff --git a/src/api/gramjs/apiBuilders/messageContent.ts b/src/api/gramjs/apiBuilders/messageContent.ts index 211cb21cb..f6d9d5586 100644 --- a/src/api/gramjs/apiBuilders/messageContent.ts +++ b/src/api/gramjs/apiBuilders/messageContent.ts @@ -58,7 +58,7 @@ export function buildMessageContent( const hasUnsupportedMedia = mtpMessage.media instanceof GramJs.MessageMediaUnsupported; if (mtpMessage.message && !hasUnsupportedMedia - && !content.sticker && !content.poll && !content.contact && !content.video?.isRound) { + && !content.sticker && !content.pollId && !content.contact && !content.video?.isRound) { content = { ...content, text: buildMessageTextContent(mtpMessage.message, mtpMessage.entities), @@ -130,8 +130,8 @@ export function buildMessageMediaContent( const contact = buildContact(media); if (contact) return { contact }; - const poll = buildPollFromMedia(media); - if (poll) return { poll }; + const pollId = buildPollIdFromMedia(media); + if (pollId) return { pollId }; const webPage = buildWebPage(media); if (webPage) return { webPage }; @@ -465,7 +465,15 @@ function buildContact(media: GramJs.TypeMessageMedia): ApiContact | undefined { }; } -function buildPollFromMedia(media: GramJs.TypeMessageMedia): ApiPoll | undefined { +function buildPollIdFromMedia(media: GramJs.TypeMessageMedia): string | undefined { + if (!(media instanceof GramJs.MessageMediaPoll)) { + return undefined; + } + + return media.poll.id.toString(); +} + +export function buildPollFromMedia(media: GramJs.TypeMessageMedia): ApiPoll | undefined { if (!(media instanceof GramJs.MessageMediaPoll)) { return undefined; } diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index 997d8a84f..a48a38798 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -935,30 +935,30 @@ export function buildLocalMessage( story?: ApiStory | ApiStorySkipped, isInvertedMedia?: true, effectId?: string, -): ApiMessage { +) { const localId = getNextLocalMessageId(lastMessageId); const media = attachment && buildUploadingMedia(attachment); const isChannel = chat.type === 'chatTypeChannel'; const resultReplyInfo = replyInfo && buildReplyInfo(replyInfo, chat.isForum); + const localPoll = poll && buildNewPoll(poll, localId); + const message = { id: localId, chatId: chat.id, - content: { - ...(text && { - text: { - text, - entities, - }, - }), + content: omitUndefined({ + text: text ? { + text, + entities, + } : undefined, ...media, - ...(sticker && { sticker }), - ...(gif && { video: gif }), - ...(poll && { poll: buildNewPoll(poll, localId) }), - ...(contact && { contact }), - ...(story && { storyData: { mediaType: 'storyData', ...story } }), - }, + sticker, + video: gif || media?.video, + contact, + storyData: story && { mediaType: 'storyData', ...story }, + pollId: localPoll?.id, + }), date: scheduledAt || Math.round(Date.now() / 1000) + getServerTimeOffset(), isOutgoing: !isChannel, senderId: sendAs?.id || currentUserId, @@ -975,10 +975,15 @@ export function buildLocalMessage( const emojiOnlyCount = getEmojiOnlyCountForMessage(message.content, message.groupedId); - return { + const finalMessage = { ...message, ...(emojiOnlyCount && { emojiOnlyCount }), }; + + return { + message: finalMessage, + poll: localPoll, + }; } export function buildLocalForwardedMessage({ diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 16c12dbd7..c155cc2f0 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -293,7 +293,10 @@ export function sendMessage( }, onProgress?: ApiOnProgress, ) { - const localMessage = buildLocalMessage( + const { + message: localMessage, + poll: localPoll, + } = buildLocalMessage( chat, lastMessageId, text, @@ -317,6 +320,7 @@ export function sendMessage( id: localMessage.id, chatId: chat.id, message: localMessage, + poll: localPoll, wasDrafted, }); diff --git a/src/api/gramjs/updates/entityProcessor.ts b/src/api/gramjs/updates/entityProcessor.ts index 60c03fd29..550afa031 100644 --- a/src/api/gramjs/updates/entityProcessor.ts +++ b/src/api/gramjs/updates/entityProcessor.ts @@ -1,9 +1,12 @@ import { Api as GramJs } from '../../../lib/gramjs'; -import type { ApiChat, ApiThreadInfo, ApiUser } from '../../types'; +import type { + ApiChat, ApiPoll, ApiThreadInfo, ApiUser, +} from '../../types'; import { buildCollectionByKey } from '../../../util/iteratees'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; +import { buildPollFromMedia } from '../apiBuilders/messageContent'; import { buildApiThreadInfoFromMessage } from '../apiBuilders/messages'; import { buildApiUser } from '../apiBuilders/users'; import { addChatToLocalDb, addMessageToLocalDb, addUserToLocalDb } from '../helpers'; @@ -19,7 +22,8 @@ export function processAndUpdateEntities(response?: GramJs.AnyRequest['__respons let userById: Record | undefined; let chatById: Record | undefined; - let threadInfos: ApiThreadInfo[] | undefined; + const threadInfos: ApiThreadInfo[] | undefined = []; + const polls: ApiPoll[] | undefined = []; if ('users' in response && Array.isArray(response.users) && TYPE_USER.has(response.users[0]?.className)) { const users = response.users.map((user) => { @@ -42,19 +46,29 @@ export function processAndUpdateEntities(response?: GramJs.AnyRequest['__respons } if ('messages' in response && Array.isArray(response.messages) && TYPE_MESSAGE.has(response.messages[0]?.className)) { - threadInfos = response.messages.map((message) => { + response.messages.forEach((message) => { addMessageToLocalDb(message); - return buildApiThreadInfoFromMessage(message); - }).filter(Boolean); + + const threadInfo = buildApiThreadInfoFromMessage(message); + if (threadInfo) { + threadInfos.push(threadInfo); + } + + const poll = buildPollFromMedia(message.media); + if (poll) { + polls.push(poll); + } + }); } - if (!userById && !chatById && !threadInfos) return; + if (!userById && !chatById && !threadInfos?.length) return; sendImmediateApiUpdate({ '@type': 'updateEntities', users: userById, chats: chatById, - threadInfos, + threadInfos: threadInfos?.length ? threadInfos : undefined, + polls: polls?.length ? polls : undefined, }); } diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 1f4e89a96..288d6b967 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -9,7 +9,9 @@ import type { ApiPremiumGiftCodeOption, ApiStarGift, } from './payments'; -import type { ApiMessageStoryData, ApiWebPageStickerData, ApiWebPageStoryData } from './stories'; +import type { + ApiMessageStoryData, ApiStory, ApiWebPageStickerData, ApiWebPageStoryData, +} from './stories'; import type { ApiUser } from './users'; export interface ApiDimensions { @@ -667,7 +669,7 @@ export type MediaContent = { document?: ApiDocument; sticker?: ApiSticker; contact?: ApiContact; - poll?: ApiPoll; + pollId?: string; action?: ApiAction; webPage?: ApiWebPage; audio?: ApiAudio; @@ -687,6 +689,11 @@ export type MediaContainer = { content: MediaContent; }; +export type StatefulMediaContent = { + poll?: ApiPoll; + story?: ApiStory; +}; + export type BoughtPaidMedia = Pick; export interface ApiMessage { diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index ed80d0551..392a4e6e8 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -204,6 +204,7 @@ export type ApiUpdateNewScheduledMessage = { id: number; message: ApiMessage; wasDrafted?: boolean; + poll?: ApiPoll; }; export type ApiUpdateNewMessage = { @@ -213,6 +214,7 @@ export type ApiUpdateNewMessage = { message: Partial; shouldForceReply?: boolean; wasDrafted?: boolean; + poll?: ApiPoll; }; export type ApiUpdateMessage = { @@ -764,6 +766,7 @@ export type ApiUpdateEntities = { users?: Record; chats?: Record; threadInfos?: ApiThreadInfo[]; + polls?: ApiPoll[]; }; export type ApiUpdatePaidReactionPrivacy = { diff --git a/src/components/common/MessageSummary.tsx b/src/components/common/MessageSummary.tsx index eed2648d6..b25fdd9d3 100644 --- a/src/components/common/MessageSummary.tsx +++ b/src/components/common/MessageSummary.tsx @@ -1,12 +1,16 @@ import React, { memo } from '../../lib/teact/teact'; +import { withGlobal } from '../../global'; -import type { ApiFormattedText, ApiMessage } from '../../api/types'; +import type { + ApiFormattedText, ApiMessage, ApiPoll, ApiTypeStory, +} from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import { ApiMessageEntityTypes } from '../../api/types'; import { extractMessageText, - getMessagePoll, + getMessagePollId, + groupStatetefulContent, } from '../../global/helpers'; import { getMessageSummaryDescription, @@ -14,6 +18,7 @@ import { getMessageSummaryText, TRUNCATED_SUMMARY_LENGTH, } from '../../global/helpers/messageSummary'; +import { selectPeerStory, selectPollFromMessage } from '../../global/selectors'; import trimText from '../../util/trimText'; import renderText from './helpers/renderText'; @@ -21,7 +26,7 @@ import useOldLang from '../../hooks/useOldLang'; import MessageText from './MessageText'; -interface OwnProps { +type OwnProps = { message: ApiMessage; translatedText?: ApiFormattedText; noEmoji?: boolean; @@ -32,7 +37,12 @@ interface OwnProps { emojiSize?: number; observeIntersectionForLoading?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; -} +}; + +type StateProps = { + poll?: ApiPoll; + story?: ApiTypeStory; +}; function MessageSummary({ message, @@ -43,17 +53,22 @@ function MessageSummary({ withTranslucentThumbs = false, inChatList = false, emojiSize, + poll, + story, observeIntersectionForLoading, observeIntersectionForPlaying, -}: OwnProps) { +}: OwnProps & StateProps) { const lang = useOldLang(); const { text, entities } = extractMessageText(message, inChatList) || {}; const hasSpoilers = entities?.some((e) => e.type === ApiMessageEntityTypes.Spoiler); const hasCustomEmoji = entities?.some((e) => e.type === ApiMessageEntityTypes.CustomEmoji); - const hasPoll = Boolean(getMessagePoll(message)); + const hasPoll = Boolean(getMessagePollId(message)); + + const statefulContent = groupStatetefulContent({ poll, story }); if ((!text || (!hasSpoilers && !hasCustomEmoji)) && !hasPoll) { - const summaryText = translatedText?.text || getMessageSummaryText(lang, message, noEmoji, truncateLength); + const summaryText = translatedText?.text + || getMessageSummaryText(lang, message, statefulContent, noEmoji, truncateLength); const trimmedText = trimText(summaryText, truncateLength); return ( @@ -90,10 +105,21 @@ function MessageSummary({ <> {[ emoji ? renderText(`${emoji} `) : undefined, - getMessageSummaryDescription(lang, message, renderMessageText()), + getMessageSummaryDescription(lang, message, statefulContent, renderMessageText()), ].flat().filter(Boolean)} ); } -export default memo(MessageSummary); +export default memo(withGlobal( + (global, { message }): StateProps => { + const poll = selectPollFromMessage(global, message); + const storyData = message.content.storyData; + const story = storyData && selectPeerStory(global, storyData.peerId, storyData.id); + + return { + poll, + story, + }; + }, +)(MessageSummary)); diff --git a/src/components/common/Picker.tsx b/src/components/common/Picker.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/components/common/embedded/EmbeddedMessage.tsx b/src/components/common/embedded/EmbeddedMessage.tsx index a6dc6f794..a8348fb38 100644 --- a/src/components/common/embedded/EmbeddedMessage.tsx +++ b/src/components/common/embedded/EmbeddedMessage.tsx @@ -164,7 +164,7 @@ const EmbeddedMessage: FC = ({ function renderMediaContentType(media?: MediaContainer) { if (!media || media.content.text) return NBSP; - const description = getMediaContentTypeDescription(lang, media.content); + const description = getMediaContentTypeDescription(lang, media.content, {}); if (!description || description === CONTENT_NOT_SUPPORTED) return NBSP; return ( diff --git a/src/components/common/helpers/renderActionMessageText.tsx b/src/components/common/helpers/renderActionMessageText.tsx index 7a3a9093d..63a76c80c 100644 --- a/src/components/common/helpers/renderActionMessageText.tsx +++ b/src/components/common/helpers/renderActionMessageText.tsx @@ -278,7 +278,7 @@ function renderMessageContent( const { asPlainText, isEmbedded } = options; if (asPlainText) { - return getMessageSummaryText(lang, message, undefined, MAX_LENGTH); + return getMessageSummaryText(lang, message, undefined, undefined, MAX_LENGTH); } const messageSummary = ( diff --git a/src/components/common/helpers/renderMessageText.ts b/src/components/common/helpers/renderMessageText.ts index e40a1fac4..06edb8a4f 100644 --- a/src/components/common/helpers/renderMessageText.ts +++ b/src/components/common/helpers/renderMessageText.ts @@ -1,9 +1,12 @@ +import { getGlobal } from '../../../global'; + import type { ApiMessage, ApiSponsoredMessage } from '../../../api/types'; import type { LangFn } from '../../../hooks/useOldLang'; import type { TextPart } from '../../../types'; import { ApiMessageEntityTypes } from '../../../api/types'; import { + getMessageStatefulContent, getMessageText, } from '../../../global/helpers'; import { @@ -70,10 +73,13 @@ export function renderMessageSummary( ): TextPart[] { const { entities } = message.content.text || {}; + const global = getGlobal(); + const statefulContent = getMessageStatefulContent(global, message); + const hasSpoilers = entities?.some((e) => e.type === ApiMessageEntityTypes.Spoiler); const hasCustomEmoji = entities?.some((e) => e.type === ApiMessageEntityTypes.CustomEmoji); if (!hasSpoilers && !hasCustomEmoji) { - const text = trimText(getMessageSummaryText(lang, message, noEmoji), truncateLength); + const text = trimText(getMessageSummaryText(lang, message, statefulContent, noEmoji), truncateLength); if (highlight) { return renderText(text, ['emoji', 'highlight'], { highlight }); @@ -88,7 +94,7 @@ export function renderMessageSummary( const text = renderMessageText({ message, highlight, isSimple: true, truncateLength, }); - const description = getMessageSummaryDescription(lang, message, text); + const description = getMessageSummaryDescription(lang, message, statefulContent, text); return [ ...renderText(emojiWithSpace), diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index b41f0c25e..37cda8a2f 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -8,6 +8,7 @@ import type { ApiMessageOutgoingStatus, ApiPeer, ApiTopic, + ApiTypeStory, ApiTypingStatus, ApiUser, ApiUserStatus, @@ -21,6 +22,7 @@ import { StoryViewerOrigin } from '../../../types'; import { getMessageAction, getPrivateChatUserId, + groupStatetefulContent, isUserId, isUserOnline, selectIsChatMuted, @@ -40,6 +42,7 @@ import { selectNotifySettings, selectOutgoingStatus, selectPeer, + selectPeerStory, selectTabState, selectThreadParam, selectTopicFromMessage, @@ -92,6 +95,7 @@ type OwnProps = { type StateProps = { chat?: ApiChat; + lastMessageStory?: ApiTypeStory; listedTopicIds?: number[]; topics?: Record; isMuted?: boolean; @@ -127,6 +131,7 @@ const Chat: FC = ({ topics, observeIntersection, chat, + lastMessageStory, isMuted, user, userStatus, @@ -187,6 +192,7 @@ const Chat: FC = ({ lastMessage, typingStatus, draft, + statefulMediaContent: groupStatetefulContent({ story: lastMessageStory }), actionTargetMessage, actionTargetUserIds, actionTargetChatId, @@ -483,6 +489,9 @@ export default memo(withGlobal( const topicsInfo = selectTopicsInfo(global, chatId); + const storyData = lastMessage?.content.storyData; + const lastMessageStory = storyData && selectPeerStory(global, storyData.peerId, storyData.id); + return { chat, isMuted: selectIsChatMuted(chat, selectNotifySettings(global), selectNotifyExceptions(global)), @@ -510,6 +519,7 @@ export default memo(withGlobal( listedTopicIds: topicsInfo?.listedTopicIds, topics: topicsInfo?.topicsById, isSynced: global.isSynced, + lastMessageStory, }; }, )(Chat)); diff --git a/src/components/left/main/Topic.tsx b/src/components/left/main/Topic.tsx index 5143862da..47468e08d 100644 --- a/src/components/left/main/Topic.tsx +++ b/src/components/left/main/Topic.tsx @@ -4,13 +4,13 @@ import { getActions, withGlobal } from '../../../global'; import type { ApiChat, ApiMessage, ApiMessageOutgoingStatus, - ApiPeer, ApiTopic, ApiTypingStatus, + ApiPeer, ApiTopic, ApiTypeStory, ApiTypingStatus, } from '../../../api/types'; import type { ApiDraft } from '../../../global/types'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ChatAnimationTypes } from './hooks'; -import { getMessageAction } from '../../../global/helpers'; +import { getMessageAction, groupStatetefulContent } from '../../../global/helpers'; import { getMessageReplyInfo } from '../../../global/helpers/replies'; import { selectCanAnimateInterface, @@ -20,6 +20,7 @@ import { selectCurrentMessageList, selectDraft, selectOutgoingStatus, + selectPeerStory, selectThreadInfo, selectThreadParam, selectTopics, @@ -59,6 +60,7 @@ type StateProps = { chat: ApiChat; canDelete?: boolean; lastMessage?: ApiMessage; + lastMessageStory?: ApiTypeStory; lastMessageOutgoingStatus?: ApiMessageOutgoingStatus; actionTargetMessage?: ApiMessage; actionTargetUserIds?: string[]; @@ -79,6 +81,7 @@ const Topic: FC = ({ chat, style, lastMessage, + lastMessageStory, canScrollDown, lastMessageOutgoingStatus, observeIntersection, @@ -142,6 +145,7 @@ const Topic: FC = ({ isTopic: true, typingStatus, topics, + statefulMediaContent: groupStatetefulContent({ story: lastMessageStory }), animationType, withInterfaceAnimations, @@ -262,6 +266,9 @@ export default memo(withGlobal( const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global) || {}; + const storyData = lastMessage?.content.storyData; + const lastMessageStory = storyData && selectPeerStory(global, storyData.peerId, storyData.id); + return { chat, lastMessage, @@ -279,6 +286,7 @@ export default memo(withGlobal( canScrollDown: isSelected && chat?.id === currentChatId && currentThreadId === topic.id, wasTopicOpened, topics, + lastMessageStory, }; }, )(Topic)); diff --git a/src/components/left/main/hooks/useChatListEntry.tsx b/src/components/left/main/hooks/useChatListEntry.tsx index ba0d74bb3..7cd185db9 100644 --- a/src/components/left/main/hooks/useChatListEntry.tsx +++ b/src/components/left/main/hooks/useChatListEntry.tsx @@ -5,6 +5,7 @@ import { getGlobal } from '../../../../global'; import type { ApiChat, ApiMessage, ApiPeer, ApiTopic, ApiTypingStatus, ApiUser, + StatefulMediaContent, } from '../../../../api/types'; import type { ApiDraft } from '../../../../global/types'; import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; @@ -32,6 +33,7 @@ import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEn import { ChatAnimationTypes } from './useChatAnimationType'; import useEnsureMessage from '../../../../hooks/useEnsureMessage'; +import useEnsureStory from '../../../../hooks/useEnsureStory'; import useMedia from '../../../../hooks/useMedia'; import useOldLang from '../../../../hooks/useOldLang'; @@ -45,6 +47,7 @@ export default function useChatListEntry({ chat, topics, lastMessage, + statefulMediaContent, chatId, typingStatus, draft, @@ -64,6 +67,7 @@ export default function useChatListEntry({ chat?: ApiChat; topics?: Record; lastMessage?: ApiMessage; + statefulMediaContent: StatefulMediaContent | undefined; chatId: string; typingStatus?: ApiTypingStatus; draft?: ApiDraft; @@ -90,10 +94,16 @@ export default function useChatListEntry({ const replyToMessageId = lastMessage && getMessageReplyInfo(lastMessage)?.replyToMsgId; useEnsureMessage(chatId, isAction ? replyToMessageId : undefined, actionTargetMessage); - const mediaHasPreview = lastMessage && !getMessageSticker(lastMessage); + const storyData = lastMessage?.content.storyData; + const shouldTryLoadingStory = statefulMediaContent && !statefulMediaContent.story; - const mediaThumbnail = mediaHasPreview ? getMessageMediaThumbDataUri(lastMessage) : undefined; - const mediaBlobUrl = useMedia(mediaHasPreview ? getMessageMediaHash(lastMessage, 'micro') : undefined); + useEnsureStory(shouldTryLoadingStory ? storyData?.peerId : undefined, storyData?.id, statefulMediaContent?.story); + + const mediaContent = statefulMediaContent?.story || lastMessage; + const mediaHasPreview = mediaContent && !getMessageSticker(mediaContent); + + const mediaThumbnail = mediaHasPreview ? getMessageMediaThumbDataUri(mediaContent) : undefined; + const mediaBlobUrl = useMedia(mediaHasPreview ? getMessageMediaHash(mediaContent, 'micro') : undefined); const isRoundVideo = Boolean(lastMessage && getMessageRoundVideo(lastMessage)); const actionTargetUsers = useMemo(() => { diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index 261b23346..fe2cf63f2 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -8,10 +8,12 @@ import type { ApiAvailableReaction, ApiChatReactions, ApiMessage, + ApiPoll, ApiReaction, ApiStickerSet, ApiStickerSetInfo, ApiThreadInfo, + ApiTypeStory, } from '../../../api/types'; import type { ActiveDownloads, MessageListType } from '../../../global/types'; import type { IAlbum, IAnchorPosition, ThreadId } from '../../../types'; @@ -50,6 +52,8 @@ import { selectIsReactionPickerOpen, selectMessageCustomEmojiSets, selectMessageTranslations, + selectPeerStory, + selectPollFromMessage, selectRequestedChatTranslationLanguage, selectRequestedMessageTranslationLanguage, selectStickerSet, @@ -88,6 +92,8 @@ export type OwnProps = { type StateProps = { threadId?: ThreadId; + poll?: ApiPoll; + story?: ApiTypeStory; availableReactions?: ApiAvailableReaction[]; topReactions?: ApiReaction[]; defaultTagReactions?: ApiReaction[]; @@ -150,6 +156,8 @@ const ContextMenuContainer: FC = ({ customEmojiSetsInfo, customEmojiSets, album, + poll, + story, anchor, targetHref, noOptions, @@ -642,6 +650,8 @@ const ContextMenuContainer: FC = ({ seenByRecentPeers={seenByRecentPeers} isInSavedMessages={isInSavedMessages} noReplies={noReplies} + poll={poll} + story={story} onOpenThread={handleOpenThread} onReply={handleReply} onEdit={handleEdit} @@ -795,6 +805,10 @@ export default memo(withGlobal( const isInSavedMessages = selectIsChatWithSelf(global, message.chatId); + const poll = selectPollFromMessage(global, message); + const storyData = message.content.storyData; + const story = storyData ? selectPeerStory(global, storyData.peerId, storyData.id) : undefined; + return { threadId, availableReactions, @@ -845,6 +859,8 @@ export default memo(withGlobal( isChannel, canReplyInChat, isWithPaidReaction: chatFullInfo?.isPaidReactionAvailable, + poll, + story, }; }, )(ContextMenuContainer)); diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index f31ac1106..14bcf6c2e 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -19,6 +19,7 @@ import type { ApiMessage, ApiMessageOutgoingStatus, ApiPeer, + ApiPoll, ApiReaction, ApiReactionKey, ApiSavedReactionTag, @@ -90,6 +91,7 @@ import { selectPeer, selectPeerStory, selectPerformanceSettingsValue, + selectPollFromMessage, selectRequestedChatTranslationLanguage, selectRequestedMessageTranslationLanguage, selectSender, @@ -295,6 +297,7 @@ type StateProps = { viaBusinessBot?: ApiUser; effect?: ApiAvailableEffect; availableStars?: number; + poll?: ApiPoll; }; type MetaPosition = @@ -416,6 +419,7 @@ const Message: FC = ({ viaBusinessBot, effect, availableStars, + poll, onIntersectPinnedMessage, }) => { const { @@ -505,7 +509,7 @@ const Message: FC = ({ const { photo = paidMediaPhoto, video = paidMediaVideo, audio, voice, document, sticker, contact, - poll, webPage, invoice, location, + webPage, invoice, location, action, game, storyData, giveaway, giveawayResults, } = getMessageContent(message); @@ -741,6 +745,7 @@ const Message: FC = ({ && (isCustomShape || ((photo || video || storyData || (location?.mediaType === 'geo')) && !hasText)); const contentClassName = buildContentClassName(message, album, { + poll, hasSubheader, isCustomShape, isLastInGroup, @@ -1818,6 +1823,7 @@ export default memo(withGlobal( const effect = effectId ? global.availableEffectById[effectId] : undefined; const { balance: availableStars } = global.stars || {}; + const poll = selectPollFromMessage(global, message); return { theme: selectTheme(global), @@ -1906,6 +1912,7 @@ export default memo(withGlobal( viaBusinessBot, effect, availableStars, + poll, }; }, )(Message)); diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index 7cf582918..0e5bda322 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -10,15 +10,17 @@ import type { ApiChatReactions, ApiMessage, ApiPeer, + ApiPoll, ApiReaction, ApiSponsoredMessage, ApiStickerSet, ApiThreadInfo, + ApiTypeStory, ApiUser, } from '../../../api/types'; import type { IAnchorPosition } from '../../../types'; -import { getUserFullName, isUserId } from '../../../global/helpers'; +import { getUserFullName, groupStatetefulContent, isUserId } from '../../../global/helpers'; import buildClassName from '../../../util/buildClassName'; import { disableScrolling } from '../../../util/scrollLock'; import { REM } from '../../common/helpers/mediaDimensions'; @@ -49,6 +51,8 @@ type OwnProps = { anchor: IAnchorPosition; targetHref?: string; message: ApiMessage | ApiSponsoredMessage; + poll?: ApiPoll; + story?: ApiTypeStory; canSendNow?: boolean; enabledReactions?: ApiChatReactions; isWithPaidReaction?: boolean; @@ -138,6 +142,8 @@ const MessageContextMenu: FC = ({ defaultTagReactions, isOpen, message, + poll, + story, isPrivate, isCurrentUserPremium, enabledReactions, @@ -282,6 +288,7 @@ const MessageContextMenu: FC = ({ ? [] : getMessageCopyOptions( message, + groupStatetefulContent({ poll, story }), targetHref, canCopy, handleAfterCopy, diff --git a/src/components/middle/message/Poll.scss b/src/components/middle/message/Poll.scss index 8429793f1..ca9033c5c 100644 --- a/src/components/middle/message/Poll.scss +++ b/src/components/middle/message/Poll.scss @@ -39,9 +39,14 @@ .Checkbox, .Radio { padding-left: 2.25rem; + padding-bottom: 1rem; &:last-child { - margin-bottom: 0.75rem; + margin-bottom: 0; + } + + &:first-child { + margin-top: 0; } &.disabled { diff --git a/src/components/middle/message/Poll.tsx b/src/components/middle/message/Poll.tsx index e0774c08b..1fb47079f 100644 --- a/src/components/middle/message/Poll.tsx +++ b/src/components/middle/message/Poll.tsx @@ -7,7 +7,7 @@ import React, { useRef, useState, } from '../../../lib/teact/teact'; -import { getActions, getGlobal, withGlobal } from '../../../global'; +import { getActions, getGlobal } from '../../../global'; import type { ApiMessage, ApiPeer, ApiPoll, ApiPollAnswer, @@ -15,6 +15,7 @@ import type { import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { LangFn } from '../../../hooks/useOldLang'; +import { selectPeer } from '../../../global/selectors'; import { formatMediaDuration } from '../../../util/dates/dateFormat'; import { getServerTime } from '../../../util/serverTime'; import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities'; @@ -39,10 +40,6 @@ type OwnProps = { onSendVote: (options: string[]) => void; }; -type StateProps = { - recentVoterIds?: number[]; -}; - const SOLUTION_CONTAINER_ID = '#middle-column-portals'; const SOLUTION_DURATION = 5000; const TIMER_RADIUS = 6; @@ -50,10 +47,9 @@ const TIMER_CIRCUMFERENCE = TIMER_RADIUS * 2 * Math.PI; const TIMER_UPDATE_INTERVAL = 1000; const NBSP = '\u00A0'; -const Poll: FC = ({ +const Poll: FC = ({ message, poll, - recentVoterIds, observeIntersectionForLoading, observeIntersectionForPlaying, onSendVote, @@ -80,6 +76,7 @@ const Poll: FC = ({ 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) || []; @@ -147,15 +144,11 @@ const Poll: FC = ({ const recentVoters = useMemo(() => { // No need for expensive global updates on chats or users, so we avoid them - const chatsById = getGlobal().chats.byId; - const usersById = getGlobal().users.byId; + const global = getGlobal(); return recentVoterIds ? recentVoterIds.reduce((result: ApiPeer[], id) => { - const chat = chatsById[id]; - const user = usersById[id]; - if (user) { - result.push(user); - } else if (chat) { - result.push(chat); + const peer = selectPeer(global, id); + if (peer) { + result.push(peer); } return result; @@ -372,17 +365,4 @@ function stopPropagation(e: React.MouseEvent) { e.stopPropagation(); } -export default memo(withGlobal( - (global, { poll }) => { - const { recentVoterIds } = poll.results; - const { users: { byId: usersById } } = global; - if (!recentVoterIds || recentVoterIds.length === 0) { - return {}; - } - - return { - recentVoterIds, - usersById, - }; - }, -)(Poll)); +export default memo(Poll); diff --git a/src/components/middle/message/PollOption.scss b/src/components/middle/message/PollOption.scss index 8358c7cf4..ca189cc7a 100644 --- a/src/components/middle/message/PollOption.scss +++ b/src/components/middle/message/PollOption.scss @@ -44,8 +44,8 @@ background: var(--color-error); } - .is-forwarded & > .icon { - margin-left: 1px; + .poll-option-icon { + line-height: 1rem; } &.animate { diff --git a/src/components/middle/message/PollOption.tsx b/src/components/middle/message/PollOption.tsx index 93fbf00ed..4437ee6fc 100644 --- a/src/components/middle/message/PollOption.tsx +++ b/src/components/middle/message/PollOption.tsx @@ -9,6 +9,8 @@ import type { ApiPollAnswer, ApiPollResult } from '../../../api/types'; import buildClassName from '../../../util/buildClassName'; import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities'; +import Icon from '../../common/icons/Icon'; + import './PollOption.scss'; type OwnProps = { @@ -59,7 +61,7 @@ const PollOption: FC = ({ shouldAnimate && 'animate', )} > - + )} diff --git a/src/components/middle/message/helpers/buildContentClassName.ts b/src/components/middle/message/helpers/buildContentClassName.ts index 613026aca..e56115d70 100644 --- a/src/components/middle/message/helpers/buildContentClassName.ts +++ b/src/components/middle/message/helpers/buildContentClassName.ts @@ -1,4 +1,4 @@ -import type { ApiMessage } from '../../../../api/types'; +import type { ApiMessage, ApiPoll } from '../../../../api/types'; import type { IAlbum } from '../../../../types'; import { EMOJI_SIZES, MESSAGE_CONTENT_CLASS_NAME } from '../../../../config'; @@ -9,6 +9,7 @@ export function buildContentClassName( message: ApiMessage, album?: IAlbum, { + poll, hasSubheader, isCustomShape, isLastInGroup, @@ -23,6 +24,7 @@ export function buildContentClassName( peerColorClass, hasOutsideReactions, }: { + poll?: ApiPoll; hasSubheader?: boolean; isCustomShape?: boolean | number; isLastInGroup?: boolean; @@ -44,7 +46,7 @@ export function buildContentClassName( const content = getMessageContent(message); const { photo = paidMediaPhoto, video = paidMediaVideo, - audio, voice, document, poll, webPage, contact, location, invoice, storyData, + audio, voice, document, webPage, contact, location, invoice, storyData, giveaway, giveawayResults, } = content; const text = album?.hasMultipleCaptions ? undefined : getMessageContent(album?.captionMessage || message).text; diff --git a/src/components/middle/message/helpers/copyOptions.ts b/src/components/middle/message/helpers/copyOptions.ts index 768364ebb..d2949e558 100644 --- a/src/components/middle/message/helpers/copyOptions.ts +++ b/src/components/middle/message/helpers/copyOptions.ts @@ -1,4 +1,4 @@ -import type { ApiMessage } from '../../../../api/types'; +import type { ApiMessage, StatefulMediaContent } from '../../../../api/types'; import type { IconName } from '../../../../types/icons'; import { ApiMediaFormat } from '../../../../api/types'; @@ -32,6 +32,7 @@ type ICopyOptions = { export function getMessageCopyOptions( message: ApiMessage, + statefulContent: StatefulMediaContent | undefined, href?: string, canCopy?: boolean, afterEffect?: () => void, @@ -94,7 +95,12 @@ export function getMessageCopyOptions( const clipboardText = renderMessageText( { message, shouldRenderAsHtml: true }, ); - if (clipboardText) copyHtmlToClipboard(clipboardText.join(''), getMessageTextWithSpoilers(message)!); + if (clipboardText) { + copyHtmlToClipboard( + clipboardText.join(''), + getMessageTextWithSpoilers(message, statefulContent)!, + ); + } } afterEffect?.(); diff --git a/src/components/right/PollResults.tsx b/src/components/right/PollResults.tsx index 3f9aff5e3..03958c30a 100644 --- a/src/components/right/PollResults.tsx +++ b/src/components/right/PollResults.tsx @@ -2,10 +2,11 @@ import type { FC } from '../../lib/teact/teact'; import React, { memo } from '../../lib/teact/teact'; import { withGlobal } from '../../global'; -import type { ApiChat, ApiMessage } from '../../api/types'; +import type { ApiChat, ApiMessage, ApiPoll } from '../../api/types'; -import { getMessagePoll } from '../../global/helpers'; -import { selectChat, selectChatMessage, selectTabState } from '../../global/selectors'; +import { + selectChat, selectChatMessage, selectPollFromMessage, selectTabState, +} from '../../global/selectors'; import { buildCollectionByKey } from '../../util/iteratees'; import { renderTextWithEntities } from '../common/helpers/renderTextWithEntities'; @@ -25,12 +26,14 @@ type OwnProps = { type StateProps = { chat?: ApiChat; message?: ApiMessage; + poll?: ApiPoll; }; const PollResults: FC = ({ isActive, chat, message, + poll, onClose, }) => { const lang = useOldLang(); @@ -40,11 +43,11 @@ const PollResults: FC = ({ onBack: onClose, }); - if (!message || !chat) { + if (!message || !poll || !chat) { return ; } - const { summary, results } = getMessagePoll(message)!; + const { summary, results } = poll; if (!results.results) { return undefined; } @@ -62,7 +65,7 @@ const PollResults: FC = ({
{summary.answers.map((answer) => ( { - const { chatId, messageId, options } = payload!; + const { chatId, messageId, options } = payload; const chat = selectChat(global, chatId); if (chat) { @@ -986,7 +987,7 @@ addActionHandler('sendPollVote', (global, actions, payload): ActionReturnType => }); addActionHandler('cancelPollVote', (global, actions, payload): ActionReturnType => { - const { chatId, messageId } = payload!; + const { chatId, messageId } = payload; const chat = selectChat(global, chatId); if (chat) { @@ -997,7 +998,8 @@ addActionHandler('cancelPollVote', (global, actions, payload): ActionReturnType addActionHandler('closePoll', (global, actions, payload): ActionReturnType => { const { chatId, messageId } = payload; const chat = selectChat(global, chatId); - const poll = selectChatMessage(global, chatId, messageId)?.content.poll; + const message = selectChatMessage(global, chatId, messageId); + const poll = message && selectPollFromMessage(global, message); if (chat && poll) { void callApi('closePoll', { chat, messageId, poll }); } @@ -1006,7 +1008,7 @@ addActionHandler('closePoll', (global, actions, payload): ActionReturnType => { addActionHandler('loadPollOptionResults', async (global, actions, payload): Promise => { const { chat, messageId, option, offset, limit, shouldResetVoters, tabId = getCurrentTabId(), - } = payload!; + } = payload; const result = await callApi('loadPollOptionResults', { chat, messageId, option, offset, limit, @@ -1103,7 +1105,7 @@ addActionHandler('forwardMessages', (global, actions, payload): ActionReturnType serviceMessages .forEach((message) => { const { text, entities } = message.content.text || {}; - const { sticker, poll } = message.content; + const { sticker } = message.content; const replyInfo = selectMessageReplyInfo(global, toChat.id, toThreadId); @@ -1113,7 +1115,6 @@ addActionHandler('forwardMessages', (global, actions, payload): ActionReturnType text, entities, sticker, - poll, isSilent, scheduledAt, sendAs, diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index 25d0a156c..d0e8777ba 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -1,5 +1,5 @@ import type { - ApiChat, ApiMediaExtendedPreview, ApiMessage, ApiPollResult, ApiReactions, + ApiChat, ApiMediaExtendedPreview, ApiMessage, ApiReactions, MediaContent, } from '../../../api/types'; import type { ThreadId } from '../../../types'; @@ -43,6 +43,8 @@ import { updateChatMessage, updateListedIds, updateMessageTranslations, + updatePoll, + updatePollVote, updateQuickReplies, updateQuickReplyMessage, updateScheduledMessage, @@ -57,7 +59,6 @@ import { selectChat, selectChatLastMessageId, selectChatMessage, - selectChatMessageByPollId, selectChatMessages, selectChatScheduledMessages, selectCommonBoxChatId, @@ -74,7 +75,6 @@ import { selectSavedDialogIdFromMessage, selectScheduledIds, selectScheduledMessage, - selectSendAs, selectTabState, selectThreadByMessage, selectThreadIdFromMessage, @@ -90,7 +90,7 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { switch (update['@type']) { case 'newMessage': { const { - chatId, id, message, shouldForceReply, wasDrafted, + chatId, id, message, shouldForceReply, wasDrafted, poll, } = update; global = updateWithLocalMedia(global, chatId, id, message); global = updateListedAndViewportIds(global, actions, message as ApiMessage); @@ -154,6 +154,10 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { } }); + if (poll) { + global = updatePoll(global, poll.id, poll); + } + setGlobal(global); // Reload dialogs if chat is not present in the list @@ -208,7 +212,9 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { } case 'newScheduledMessage': { - const { chatId, id, message } = update; + const { + chatId, id, message, poll, + } = update; global = updateWithLocalMedia(global, chatId, id, message, true); @@ -221,6 +227,10 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { global = replaceThreadParam(global, chatId, threadId, 'scheduledIds', unique([...threadScheduledIds, id])); } + if (poll) { + global = updatePoll(global, poll.id, poll); + } + setGlobal(global); break; @@ -560,97 +570,15 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { case 'updateMessagePoll': { const { pollId, pollUpdate } = update; - const message = selectChatMessageByPollId(global, pollId); + global = updatePoll(global, pollId, pollUpdate); - if (message?.content.poll) { - const oldResults = message.content.poll.results; - let newResults = oldResults; - if (pollUpdate.results?.results) { - if (!oldResults.results || !pollUpdate.results.isMin) { - newResults = pollUpdate.results; - } else if (oldResults.results) { - newResults = { - ...pollUpdate.results, - results: pollUpdate.results.results.map((result) => ({ - ...result, - isChosen: oldResults.results!.find((r) => r.option === result.option)?.isChosen, - })), - isMin: undefined, - }; - } - } - const updatedPoll = { ...message.content.poll, ...pollUpdate, results: newResults }; - - global = updateChatMessage( - global, - message.chatId, - message.id, - { - content: { - ...message.content, - poll: updatedPoll, - }, - }, - ); - setGlobal(global); - } + setGlobal(global); break; } case 'updateMessagePollVote': { const { pollId, peerId, options } = update; - const message = selectChatMessageByPollId(global, pollId); - if (!message || !message.content.poll || !message.content.poll.results) { - break; - } - - const { poll } = message.content; - - const currentSendAs = selectSendAs(global, message.chatId); - - const { recentVoterIds, totalVoters, results } = poll.results; - const newRecentVoterIds = recentVoterIds ? [...recentVoterIds] : []; - const newTotalVoters = totalVoters ? totalVoters + 1 : 1; - const newResults = results ? [...results] : []; - - newRecentVoterIds.push(peerId); - - options.forEach((option) => { - const targetOptionIndex = newResults.findIndex((result) => result.option === option); - const targetOption = newResults[targetOptionIndex]; - const updatedOption: ApiPollResult = targetOption ? { ...targetOption } : { option, votersCount: 0 }; - - updatedOption.votersCount += 1; - if (currentSendAs?.id === peerId || peerId === global.currentUserId) { - updatedOption.isChosen = true; - } - - if (targetOptionIndex) { - newResults[targetOptionIndex] = updatedOption; - } else { - newResults.push(updatedOption); - } - }); - - global = updateChatMessage( - global, - message.chatId, - message.id, - { - content: { - ...message.content, - poll: { - ...poll, - results: { - ...poll.results, - recentVoterIds: newRecentVoterIds, - totalVoters: newTotalVoters, - results: newResults, - }, - }, - }, - }, - ); + global = updatePollVote(global, pollId, peerId, options); setGlobal(global); break; diff --git a/src/global/actions/apiUpdaters/misc.ts b/src/global/actions/apiUpdaters/misc.ts index b70cf7419..b73e48c0c 100644 --- a/src/global/actions/apiUpdaters/misc.ts +++ b/src/global/actions/apiUpdaters/misc.ts @@ -14,6 +14,7 @@ import { updateLastReadStoryForPeer, updatePeerStory, updatePeersWithStories, + updatePoll, updateStealthMode, updateThreadInfos, } from '../../reducers'; @@ -22,10 +23,17 @@ import { selectPeerStories, selectPeerStory } from '../../selectors'; addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { switch (update['@type']) { case 'updateEntities': { - const { users, chats, threadInfos } = update; + const { + users, chats, threadInfos, polls, + } = update; if (users) global = addUsers(global, users); if (chats) global = addChats(global, chats); if (threadInfos) global = updateThreadInfos(global, threadInfos); + if (polls) { + polls.forEach((poll) => { + global = updatePoll(global, poll.id, poll); + }); + } setGlobal(global); break; } diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index f849f1499..23e5c84b2 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -29,6 +29,7 @@ import { getMediaFormat, getMediaHash, getMessageDownloadableMedia, + getMessageStatefulContent, getSenderTitle, isChatChannel, isJoinedChannelMessage, @@ -985,12 +986,13 @@ function copyTextForMessages(global: GlobalState, chatId: string, messageIds: nu messages.forEach((message) => { const sender = isChatChannel(chat) ? chat : selectSender(global, message); const senderTitle = `> ${sender ? getSenderTitle(lang, sender) : message.forwardInfo?.hiddenUserName || ''}:`; + const statefulContent = getMessageStatefulContent(global, message); resultHtml.push(senderTitle); resultHtml.push(`${renderMessageSummaryHtml(lang, message)}\n`); resultText.push(senderTitle); - resultText.push(`${getMessageSummaryText(lang, message, false, 0, true)}\n`); + resultText.push(`${getMessageSummaryText(lang, message, statefulContent, false, 0, true)}\n`); }); copyHtmlToClipboard(resultHtml.join('\n'), resultText.join('\n')); diff --git a/src/global/cache.ts b/src/global/cache.ts index ae06f5ecf..479ffc9c9 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -257,6 +257,10 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) { cached.chats.topicsInfoById = initialState.chats.topicsInfoById; } + if (!cached.messages.pollById) { + cached.messages.pollById = initialState.messages.pollById; + } + if (!cached.stickers.starGifts) { cached.stickers.starGifts = initialState.stickers.starGifts; cached.users.giftsById = initialState.users.giftsById; @@ -495,6 +499,8 @@ function reduceMessages(global: T): GlobalState['messages return acc; }, {} as Record>); + const pollIdsToSave: string[] = []; + chatIdsToSave.forEach((chatId) => { const current = global.messages.byChatId[chatId]; if (!current) { @@ -537,6 +543,11 @@ function reduceMessages(global: T): GlobalState['messages let cleanedMessage = omitLocalMedia(message); cleanedMessage = omitLocalPaidReactions(cleanedMessage); acc[message.id] = cleanedMessage; + + if (message.content.pollId) { + pollIdsToSave.push(message.content.pollId); + } + return acc; }, {} as Record); @@ -548,6 +559,7 @@ function reduceMessages(global: T): GlobalState['messages return { byChatId, + pollById: pickTruthy(global.messages.pollById, pollIdsToSave), sponsoredByChatId: {}, }; } diff --git a/src/global/helpers/messageMedia.ts b/src/global/helpers/messageMedia.ts index 5589b8ae7..2992fa5be 100644 --- a/src/global/helpers/messageMedia.ts +++ b/src/global/helpers/messageMedia.ts @@ -50,7 +50,7 @@ export function hasMessageMedia(message: MediaContainer) { || getMessageDocument(message) || getMessageSticker(message) || getMessageContact(message) - || getMessagePoll(message) + || getMessagePollId(message) || getMessageAction(message) || getMessageAudio(message) || getMessageVoice(message) @@ -128,8 +128,8 @@ export function getMessageContact(message: MediaContainer) { return message.content.contact; } -export function getMessagePoll(message: MediaContainer) { - return message.content.poll; +export function getMessagePollId(message: MediaContainer) { + return message.content.pollId; } export function getMessageInvoice(message: MediaContainer) { diff --git a/src/global/helpers/messageSummary.ts b/src/global/helpers/messageSummary.ts index 70e4eaaab..d08f52817 100644 --- a/src/global/helpers/messageSummary.ts +++ b/src/global/helpers/messageSummary.ts @@ -1,17 +1,17 @@ import type { TeactNode } from '../../lib/teact/teact'; -import type { ApiMediaExtendedPreview, ApiMessage, MediaContent } from '../../api/types'; +import type { + ApiMediaExtendedPreview, ApiMessage, MediaContent, StatefulMediaContent, +} from '../../api/types'; import type { LangFn } from '../../hooks/useOldLang'; import { ApiMessageEntityTypes } from '../../api/types'; import { CONTENT_NOT_SUPPORTED } from '../../config'; import trimText from '../../util/trimText'; import { renderTextWithEntities } from '../../components/common/helpers/renderTextWithEntities'; -import { getGlobal } from '../index'; import { getExpiredMessageContentDescription, getMessageText, getMessageTranscription, isExpiredMessageContent, } from './messages'; -import { getUserFirstOrLastName } from './users'; const SPOILER_CHARS = ['⠺', '⠵', '⠞', '⠟']; export const TRUNCATED_SUMMARY_LENGTH = 80; @@ -19,22 +19,23 @@ export const TRUNCATED_SUMMARY_LENGTH = 80; export function getMessageSummaryText( lang: LangFn, message: ApiMessage, + statefulContent: StatefulMediaContent | undefined, noEmoji = false, truncateLength = TRUNCATED_SUMMARY_LENGTH, isExtended = false, ) { const emoji = !noEmoji && getMessageSummaryEmoji(message); const emojiWithSpace = emoji ? `${emoji} ` : ''; - const text = trimText(getMessageTextWithSpoilers(message), truncateLength); - const description = getMessageSummaryDescription(lang, message, text, isExtended); + const text = trimText(getMessageTextWithSpoilers(message, statefulContent), truncateLength); + const description = getMessageSummaryDescription(lang, message, statefulContent, text, isExtended); return `${emojiWithSpace}${description}`; } -export function getMessageTextWithSpoilers(message: ApiMessage) { +export function getMessageTextWithSpoilers(message: ApiMessage, statefulContent: StatefulMediaContent | undefined) { const transcription = getMessageTranscription(message); - const textWithoutTranscription = getMessageText(message); + const textWithoutTranscription = getMessageText(statefulContent?.story || message); if (!textWithoutTranscription) { return transcription; } @@ -69,7 +70,7 @@ export function getMessageSummaryEmoji(message: ApiMessage) { voice, document, sticker, - poll, + pollId, paidMedia, } = message.content; @@ -97,27 +98,31 @@ export function getMessageSummaryEmoji(message: ApiMessage) { return '📎'; } - if (poll) { + if (pollId) { return '📊'; } return undefined; } -export function getMediaContentTypeDescription(lang: LangFn, content: MediaContent) { - return getSummaryDescription(lang, content); +export function getMediaContentTypeDescription( + lang: LangFn, content: MediaContent, statefulContent: StatefulMediaContent | undefined, +) { + return getSummaryDescription(lang, content, statefulContent); } export function getMessageSummaryDescription( lang: LangFn, message: ApiMessage, + statefulContent: StatefulMediaContent | undefined, truncatedText?: string | TeactNode, isExtended = false, ) { - return getSummaryDescription(lang, message.content, message, truncatedText, isExtended); + return getSummaryDescription(lang, message.content, statefulContent, message, truncatedText, isExtended); } function getSummaryDescription( lang: LangFn, mediaContent: MediaContent, + statefulContent: StatefulMediaContent | undefined, message?: ApiMessage, truncatedText?: string | TeactNode, isExtended = false, @@ -131,7 +136,6 @@ function getSummaryDescription( document, sticker, contact, - poll, invoice, location, game, @@ -140,6 +144,7 @@ function getSummaryDescription( giveawayResults, paidMedia, } = mediaContent; + const { poll } = statefulContent || {}; let hasUsedTruncatedText = false; let summary: string | TeactNode | undefined; @@ -231,16 +236,7 @@ function getSummaryDescription( } if (storyData) { - if (message && storyData.isMention) { - // eslint-disable-next-line eslint-multitab-tt/no-immediate-global - const global = getGlobal(); - const firstName = getUserFirstOrLastName(global.users.byId[message.chatId]); - summary = message.isOutgoing - ? lang('Chat.Service.StoryMentioned.You', firstName) - : lang('Chat.Service.StoryMentioned', firstName); - } else { - summary = message ? lang('ForwardedStory') : lang('Chat.ReplyStory'); - } + summary = truncatedText || (message ? lang('ForwardedStory') : lang('Chat.ReplyStory')); } if (isExpiredMessageContent(mediaContent)) { diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index a90b7f5e4..79d1e23fb 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -3,13 +3,15 @@ import type { ApiMessage, ApiMessageEntityTextUrl, ApiPeer, - ApiSponsoredMessage, ApiStory, - MediaContainer, + ApiTypeStory, } from '../../api/types'; -import type { MediaContent } from '../../api/types/messages'; +import type { + ApiPoll, MediaContainer, MediaContent, StatefulMediaContent, +} from '../../api/types/messages'; import type { LangFn } from '../../hooks/useOldLang'; import type { ThreadId } from '../../types'; +import type { GlobalState } from '../types'; import { ApiMessageEntityTypes, MAIN_THREAD_ID } from '../../api/types'; import { @@ -52,26 +54,48 @@ export function getMessageTranscription(message: ApiMessage) { return transcriptionId && global.transcriptions[transcriptionId]?.text; } -export function hasMessageText(message: ApiMessage | ApiStory | ApiSponsoredMessage | MediaContainer) { +export function hasMessageText(message: MediaContainer) { const { - text, sticker, photo, video, audio, voice, document, poll, webPage, contact, invoice, location, + text, sticker, photo, video, audio, voice, document, pollId, webPage, contact, invoice, location, game, action, storyData, giveaway, giveawayResults, isExpiredVoice, paidMedia, } = message.content; return Boolean(text) || !( - sticker || photo || video || audio || voice || document || contact || poll || webPage || invoice || location + sticker || photo || video || audio || voice || document || contact || pollId || webPage || invoice || location || game || action?.phoneCall || storyData || giveaway || giveawayResults || isExpiredVoice || paidMedia ); } -export function getMessageText(message: ApiMessage | ApiStory | ApiSponsoredMessage | MediaContainer) { +export function getMessageStatefulContent(global: GlobalState, message: ApiMessage): StatefulMediaContent { + const poll = message.content.pollId ? global.messages.pollById[message.content.pollId] : undefined; + + const { peerId: storyPeerId, id: storyId } = message.content.storyData || {}; + const story = storyId && storyPeerId ? global.stories.byPeerId[storyPeerId]?.byId[storyId] : undefined; + + return groupStatetefulContent({ poll, story }); +} + +export function groupStatetefulContent({ + poll, + story, +} : { + poll?: ApiPoll; + story?: ApiTypeStory; +}) { + return { + poll, + story: story && 'content' in story ? story : undefined, + }; +} + +export function getMessageText(message: MediaContainer) { return hasMessageText(message) ? message.content.text?.text || CONTENT_NOT_SUPPORTED : undefined; } export function getMessageCustomShape(message: ApiMessage): boolean { const { text, sticker, photo, video, audio, voice, - document, poll, webPage, contact, action, + document, pollId, webPage, contact, action, game, invoice, location, storyData, } = message.content; @@ -79,7 +103,7 @@ export function getMessageCustomShape(message: ApiMessage): boolean { return true; } - if (!text || photo || video || audio || voice || document || poll || webPage || contact || action || game || invoice + if (!text || photo || video || audio || voice || document || pollId || webPage || contact || action || game || invoice || location || storyData) { return false; } diff --git a/src/global/helpers/renderMessageSummaryHtml.ts b/src/global/helpers/renderMessageSummaryHtml.ts index 05501893a..394a2c77d 100644 --- a/src/global/helpers/renderMessageSummaryHtml.ts +++ b/src/global/helpers/renderMessageSummaryHtml.ts @@ -2,18 +2,24 @@ import type { ApiMessage } from '../../api/types'; import type { LangFn } from '../../hooks/useOldLang'; import { renderMessageText } from '../../components/common/helpers/renderMessageText'; +import { getGlobal } from '..'; +import { getMessageStatefulContent } from './messages'; import { getMessageSummaryDescription, getMessageSummaryEmoji } from './messageSummary'; export function renderMessageSummaryHtml( lang: LangFn, message: ApiMessage, ) { + const global = getGlobal(); const emoji = getMessageSummaryEmoji(message); const emojiWithSpace = emoji ? `${emoji} ` : ''; const text = renderMessageText( { message, shouldRenderAsHtml: true }, )?.join(''); - const description = getMessageSummaryDescription(lang, message, text, true); + + const statefulContent = getMessageStatefulContent(global, message); + + const description = getMessageSummaryDescription(lang, message, statefulContent, text, true); return `${emojiWithSpace}${description}`; } diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 14c80ba0c..e217f2679 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -122,6 +122,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { messages: { byChatId: {}, sponsoredByChatId: {}, + pollById: {}, }, stories: { diff --git a/src/global/reducers/messages.ts b/src/global/reducers/messages.ts index 9432ac4ab..04d6130fc 100644 --- a/src/global/reducers/messages.ts +++ b/src/global/reducers/messages.ts @@ -1,5 +1,5 @@ import type { - ApiMessage, ApiQuickReply, ApiSponsoredMessage, ApiThreadInfo, + ApiMessage, ApiPoll, ApiPollResult, ApiQuickReply, ApiSponsoredMessage, ApiThreadInfo, } from '../../api/types'; import type { FocusDirection, ScrollTargetPosition, ThreadId } from '../../types'; import type { @@ -29,6 +29,7 @@ import { selectMessageIdsByGroupId, selectOutlyingLists, selectPinnedIds, + selectPoll, selectQuickReplyMessage, selectScheduledIds, selectScheduledMessage, @@ -934,3 +935,94 @@ export function deleteQuickReply( }, }; } + +export function updatePoll( + global: T, + pollId: string, + pollUpdate: Partial, +) { + const poll = selectPoll(global, pollId); + + const oldResults = poll?.results; + let newResults = oldResults || pollUpdate.results; + 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 = { + ...poll, + ...pollUpdate, + results: newResults, + } satisfies ApiPoll; + if (!updatedPoll.id) { + return global; + } + + return { + ...global, + messages: { + ...global.messages, + pollById: { + ...global.messages.pollById, + [pollId]: updatedPoll, + }, + }, + }; +} + +export function updatePollVote( + global: T, + pollId: string, + peerId: string, + options: string[], +) { + const poll = selectPoll(global, pollId); + if (!poll) { + return global; + } + + const { recentVoterIds, totalVoters, results } = poll.results; + const newRecentVoterIds = recentVoterIds ? [...recentVoterIds] : []; + const newTotalVoters = totalVoters ? totalVoters + 1 : 1; + const newResults = results ? [...results] : []; + + newRecentVoterIds.push(peerId); + + options.forEach((option) => { + const targetOptionIndex = newResults.findIndex((result) => result.option === option); + const targetOption = newResults[targetOptionIndex]; + const updatedOption: ApiPollResult = targetOption ? { ...targetOption } : { option, votersCount: 0 }; + + updatedOption.votersCount += 1; + if (peerId === global.currentUserId) { + updatedOption.isChosen = true; + } + + if (targetOptionIndex) { + newResults[targetOptionIndex] = updatedOption; + } else { + newResults.push(updatedOption); + } + }); + + return updatePoll(global, pollId, { + results: { + ...poll.results, + recentVoterIds: newRecentVoterIds, + totalVoters: newTotalVoters, + results: newResults, + }, + }); +} diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index c612e56ab..06edaf852 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -357,23 +357,6 @@ export function selectEditingMessage( } } -export function selectChatMessageByPollId(global: T, pollId: string) { - let messageWithPoll: ApiMessage | undefined; - - // eslint-disable-next-line no-restricted-syntax - for (const chatMessages of Object.values(global.messages.byChatId)) { - const { byId } = chatMessages; - messageWithPoll = Object.values(byId).find((message) => { - return message.content.poll && message.content.poll.id === pollId; - }); - if (messageWithPoll) { - break; - } - } - - return messageWithPoll; -} - export function selectFocusedMessageId( global: T, chatId: string, ...[tabId = getCurrentTabId()]: TabArgs ) { @@ -484,6 +467,15 @@ export function selectForwardedSender( return undefined; } +export function selectPoll(global: T, pollId: string) { + return global.messages.pollById[pollId]; +} + +export function selectPollFromMessage(global: T, message: ApiMessage) { + if (!message.content.pollId) return undefined; + return selectPoll(global, message.content.pollId); +} + export function selectTopicFromMessage(global: T, message: ApiMessage) { const { chatId } = message; const chat = selectChat(global, chatId); @@ -647,7 +639,7 @@ export function selectAllowedMessageActionsSlow( canEditMessagesIndefinitely || getServerTime() - message.date < MESSAGE_EDIT_ALLOWED_TIME ) && !( - content.sticker || content.contact || content.poll || content.action + content.sticker || content.contact || content.pollId || content.action || (content.video?.isRound) || content.location || content.invoice || content.giveaway || content.giveawayResults || isDocumentSticker ) @@ -724,7 +716,7 @@ export function selectAllowedMessageActionsSlow( const canSaveGif = message.content.video?.isGif; - const poll = content.poll; + 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 canClosePoll = hasMessageEditRight && poll && !poll.summary.closed && !isForwarded; diff --git a/src/global/types.ts b/src/global/types.ts index e42aefbc1..67e96cf50 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -50,6 +50,7 @@ import type { ApiPeerStories, ApiPhoneCall, ApiPhoto, + ApiPoll, ApiPostStatistics, ApiPremiumGiftCodeOption, ApiPremiumPromo, @@ -1048,6 +1049,7 @@ export type GlobalState = { threadsById: Record; }>; sponsoredByChatId: Record; + pollById: Record; }; stories: { diff --git a/src/util/notifications.ts b/src/util/notifications.ts index c4f2d2b82..01983e30f 100644 --- a/src/util/notifications.ts +++ b/src/util/notifications.ts @@ -13,6 +13,7 @@ import { getMessageAction, getMessageRecentReaction, getMessageSenderName, + getMessageStatefulContent, getPrivateChatUserId, getUserFullName, isActionMessage, @@ -366,7 +367,8 @@ function getNotificationContent(chat: ApiChat, message: ApiMessage, reaction?: A } else { // TODO[forums] Support ApiChat const senderName = getMessageSenderName(oldTranslate, chat.id, isChat ? messageSenderChat : messageSenderUser); - let summary = getMessageSummaryText(oldTranslate, message, hasReaction, 60); + const statefulContent = getMessageStatefulContent(global, message); + let summary = getMessageSummaryText(oldTranslate, message, statefulContent, hasReaction, 60); if (hasReaction) { const emoji = getReactionEmoji(reaction);