From c4736edbb4a1d842a2faf0f361be207a0b5ecd18 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 23 Feb 2024 14:06:06 +0100 Subject: [PATCH] Introduce Saved tags (#4244) --- src/api/gramjs/apiBuilders/reactions.ts | 16 +- src/api/gramjs/methods/index.ts | 5 +- src/api/gramjs/methods/messages.ts | 6 +- src/api/gramjs/methods/reactions.ts | 52 ++++- src/api/gramjs/updates/updater.ts | 2 + src/api/types/messages.ts | 9 + src/api/types/updates.ts | 6 +- src/assets/font-icons/tag-add.svg | 1 + src/assets/font-icons/tag-crossed.svg | 1 + src/assets/font-icons/tag-filter.svg | 1 + src/assets/font-icons/tag-name.svg | 1 + src/assets/font-icons/tag.svg | 1 + src/assets/premium/PremiumTags.svg | 3 + src/bundles/extra.ts | 2 +- src/components/common/Composer.scss | 9 +- src/components/common/Composer.tsx | 8 +- src/components/common/CustomEmojiPicker.tsx | 37 +++- src/components/common/StickerSet.tsx | 4 +- .../reactions/ReactionAnimatedEmoji.tsx | 4 +- .../left/settings/SettingsQuickReaction.tsx | 4 +- .../left/settings/SettingsStickers.tsx | 2 +- src/components/main/Main.tsx | 6 +- .../main/premium/PremiumFeatureModal.tsx | 4 + .../main/premium/PremiumMainModal.tsx | 2 + src/components/middle/MobileSearch.scss | 26 ++- src/components/middle/MobileSearch.tsx | 95 +++++++- src/components/middle/ReactorListModal.tsx | 8 +- .../middle/message/ContextMenuContainer.tsx | 31 ++- src/components/middle/message/Message.scss | 23 +- src/components/middle/message/Message.tsx | 13 +- .../middle/message/MessageContextMenu.scss | 8 +- .../middle/message/MessageContextMenu.tsx | 14 +- .../middle/message/ReactionButton.tsx | 86 -------- .../middle/message/ReactionSelector.tsx | 151 ------------- .../message/ReactionSelectorReaction.scss | 62 ------ src/components/middle/message/Reactions.scss | 138 ------------ src/components/middle/message/Reactions.tsx | 61 ------ .../reactions/ReactionButton.module.scss | 118 ++++++++++ .../message/reactions/ReactionButton.tsx | 76 +++++++ .../{ => reactions}/ReactionPicker.async.tsx | 8 +- .../ReactionPicker.module.scss | 0 .../{ => reactions}/ReactionPicker.tsx | 39 ++-- .../ReactionPickerLimited.module.scss | 0 .../{ => reactions}/ReactionPickerLimited.tsx | 26 +-- .../{ => reactions}/ReactionSelector.scss | 41 ++-- .../message/reactions/ReactionSelector.tsx | 207 ++++++++++++++++++ .../ReactionSelectorCustomReaction.tsx | 34 +-- .../ReactionSelectorReaction.module.scss | 73 ++++++ .../ReactionSelectorReaction.tsx | 30 +-- .../middle/message/reactions/Reactions.scss | 31 +++ .../middle/message/reactions/Reactions.tsx | 172 +++++++++++++++ .../message/reactions/SavedTagButton.tsx | 193 ++++++++++++++++ .../modals/prompt/PromptDialog.module.scss | 3 + src/components/modals/prompt/PromptDialog.tsx | 79 +++++++ src/components/right/RightSearch.scss | 15 ++ src/components/right/RightSearch.tsx | 115 ++++++++-- .../right/management/ManageChannel.tsx | 2 +- .../right/management/ManageGroup.tsx | 2 +- .../right/management/ManageReactions.tsx | 2 +- src/components/story/StoryView.tsx | 2 +- src/components/story/StoryViewModal.tsx | 2 +- .../story/hooks/useStoryPreloader.ts | 2 + src/components/ui/Button.tsx | 2 +- src/components/ui/Menu.scss | 1 + src/global/actions/api/localSearch.ts | 11 +- src/global/actions/api/reactions.ts | 126 ++++++++++- src/global/actions/apiUpdaters/misc.ts | 4 + src/global/actions/ui/localSearch.ts | 30 ++- src/global/cache.ts | 32 +-- src/global/helpers/reactions.ts | 27 +-- src/global/initialState.ts | 9 +- src/global/reducers/localSearch.ts | 25 ++- src/global/reducers/reactions.ts | 5 +- src/global/selectors/localSearch.ts | 2 +- src/global/types.ts | 34 ++- src/hooks/useContextMenuHandlers.ts | 2 + src/hooks/useMenuPosition.ts | 22 +- src/lib/gramjs/tl/AllTLObjects.js | 2 +- src/lib/gramjs/tl/api.d.ts | 8 + src/lib/gramjs/tl/apiTl.js | 5 +- src/lib/gramjs/tl/static/api.json | 3 + src/lib/gramjs/tl/static/api.tl | 4 +- src/styles/icons.scss | 74 ++++--- src/styles/icons.woff | Bin 28388 -> 29268 bytes src/styles/icons.woff2 | Bin 23848 -> 24508 bytes src/styles/index.scss | 2 +- src/styles/themes.json | 2 +- src/types/icons/font.ts | 5 + 88 files changed, 1836 insertions(+), 775 deletions(-) create mode 100644 src/assets/font-icons/tag-add.svg create mode 100644 src/assets/font-icons/tag-crossed.svg create mode 100644 src/assets/font-icons/tag-filter.svg create mode 100644 src/assets/font-icons/tag-name.svg create mode 100644 src/assets/font-icons/tag.svg create mode 100644 src/assets/premium/PremiumTags.svg delete mode 100644 src/components/middle/message/ReactionButton.tsx delete mode 100644 src/components/middle/message/ReactionSelector.tsx delete mode 100644 src/components/middle/message/ReactionSelectorReaction.scss delete mode 100644 src/components/middle/message/Reactions.scss delete mode 100644 src/components/middle/message/Reactions.tsx create mode 100644 src/components/middle/message/reactions/ReactionButton.module.scss create mode 100644 src/components/middle/message/reactions/ReactionButton.tsx rename src/components/middle/message/{ => reactions}/ReactionPicker.async.tsx (63%) rename src/components/middle/message/{ => reactions}/ReactionPicker.module.scss (100%) rename src/components/middle/message/{ => reactions}/ReactionPicker.tsx (87%) rename src/components/middle/message/{ => reactions}/ReactionPickerLimited.module.scss (100%) rename src/components/middle/message/{ => reactions}/ReactionPickerLimited.tsx (82%) rename src/components/middle/message/{ => reactions}/ReactionSelector.scss (77%) create mode 100644 src/components/middle/message/reactions/ReactionSelector.tsx rename src/components/middle/message/{ => reactions}/ReactionSelectorCustomReaction.tsx (50%) create mode 100644 src/components/middle/message/reactions/ReactionSelectorReaction.module.scss rename src/components/middle/message/{ => reactions}/ReactionSelectorReaction.tsx (72%) create mode 100644 src/components/middle/message/reactions/Reactions.scss create mode 100644 src/components/middle/message/reactions/Reactions.tsx create mode 100644 src/components/middle/message/reactions/SavedTagButton.tsx create mode 100644 src/components/modals/prompt/PromptDialog.module.scss create mode 100644 src/components/modals/prompt/PromptDialog.tsx diff --git a/src/api/gramjs/apiBuilders/reactions.ts b/src/api/gramjs/apiBuilders/reactions.ts index 73d2449b4..744900e09 100644 --- a/src/api/gramjs/apiBuilders/reactions.ts +++ b/src/api/gramjs/apiBuilders/reactions.ts @@ -7,6 +7,7 @@ import type { ApiReactionCount, ApiReactionEmoji, ApiReactions, + ApiSavedReactionTag, } from '../../types'; import { buildApiDocument } from './messageContent'; @@ -14,10 +15,11 @@ import { getApiChatIdFromMtpPeer } from './peers'; export function buildMessageReactions(reactions: GramJs.MessageReactions): ApiReactions { const { - recentReactions, results, canSeeList, + recentReactions, results, canSeeList, reactionsAsTags, } = reactions; return { + areTags: reactionsAsTags, canSeeList, results: results.map(buildReactionCount).filter(Boolean).sort(reactionCountComparator), recentReactions: recentReactions?.map(buildMessagePeerReaction).filter(Boolean), @@ -82,6 +84,18 @@ export function buildApiReaction(reaction: GramJs.TypeReaction): ApiReaction | u return undefined; } +export function buildApiSavedReactionTag(tag: GramJs.SavedReactionTag): ApiSavedReactionTag | undefined { + const { reaction, title, count } = tag; + const apiReaction = buildApiReaction(reaction); + if (!apiReaction) return undefined; + + return { + reaction: apiReaction, + title, + count, + }; +} + export function buildApiAvailableReaction(availableReaction: GramJs.AvailableReaction): ApiAvailableReaction { const { selectAnimation, staticIcon, reaction, title, appearAnimation, diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index f3584a061..6710e8034 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -91,10 +91,7 @@ export { requestCall, getDhConfig, confirmCall, sendSignalingData, acceptCall, discardCall, setCallRating, receivedCall, } from './calls'; -export { - getAvailableReactions, sendReaction, sendEmojiInteraction, fetchMessageReactionsList, clearRecentReactions, - setDefaultReaction, fetchMessageReactions, sendWatchingEmojiInteraction, fetchRecentReactions, fetchTopReactions, -} from './reactions'; +export * from './reactions'; export { fetchChannelStatistics, fetchGroupStatistics, fetchMessageStatistics, diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index a55eded23..890443a7a 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -15,6 +15,7 @@ import type { ApiOnProgress, ApiPeer, ApiPoll, + ApiReaction, ApiReportReason, ApiSendMessageAction, ApiSticker, @@ -63,6 +64,7 @@ import { buildInputPeer, buildInputPoll, buildInputPollFromExisting, + buildInputReaction, buildInputReplyTo, buildInputReportReason, buildInputStory, @@ -1091,10 +1093,11 @@ export async function fetchDiscussionMessage({ } export async function searchMessagesLocal({ - chat, isSavedDialog, type, query, threadId, minDate, maxDate, ...pagination + chat, isSavedDialog, savedTag, type, query, threadId, minDate, maxDate, ...pagination }: { chat: ApiChat; isSavedDialog?: boolean; + savedTag?: ApiReaction; type?: ApiMessageSearchType | ApiGlobalMessageSearchType; query?: string; threadId?: ThreadId; @@ -1135,6 +1138,7 @@ export async function searchMessagesLocal({ const result = await invokeRequest(new GramJs.messages.Search({ peer: isSavedDialog ? new GramJs.InputPeerSelf() : peer, savedPeerId: isSavedDialog ? peer : undefined, + savedReaction: savedTag && [buildInputReaction(savedTag)], topMsgId: threadId !== MAIN_THREAD_ID && !isSavedDialog ? Number(threadId) : undefined, filter, q: query || '', diff --git a/src/api/gramjs/methods/reactions.ts b/src/api/gramjs/methods/reactions.ts index feb2453bc..961bf454a 100644 --- a/src/api/gramjs/methods/reactions.ts +++ b/src/api/gramjs/methods/reactions.ts @@ -11,7 +11,12 @@ import { } from '../../../config'; import { split } from '../../../util/iteratees'; import { buildApiChatFromPreview } from '../apiBuilders/chats'; -import { buildApiAvailableReaction, buildApiReaction, buildMessagePeerReaction } from '../apiBuilders/reactions'; +import { + buildApiAvailableReaction, + buildApiReaction, + buildApiSavedReactionTag, + buildMessagePeerReaction, +} from '../apiBuilders/reactions'; import { buildApiUser } from '../apiBuilders/users'; import { buildInputPeer, buildInputReaction } from '../gramjsBuilders'; import { addEntitiesToLocalDb } from '../helpers'; @@ -62,7 +67,7 @@ export function sendEmojiInteraction({ }); } -export async function getAvailableReactions() { +export async function fetchAvailableReactions() { const result = await invokeRequest(new GramJs.messages.GetAvailableReactions({})); if (!result || result instanceof GramJs.messages.AvailableReactionsNotModified) { @@ -202,3 +207,46 @@ export async function fetchRecentReactions({ hash = '0' }: { hash?: string }) { export function clearRecentReactions() { return invokeRequest(new GramJs.messages.ClearRecentReactions()); } + +export async function fetchDefaultTagReactions({ hash = '0' }: { hash?: string }) { + const result = await invokeRequest(new GramJs.messages.GetDefaultTagReactions({ + hash: BigInt(hash), + })); + + if (!result || result instanceof GramJs.messages.ReactionsNotModified) { + return undefined; + } + + return { + hash: String(result.hash), + reactions: result.reactions.map(buildApiReaction).filter(Boolean), + }; +} + +export async function fetchSavedReactionTags({ hash = '0' }: { hash?: string }) { + const result = await invokeRequest(new GramJs.messages.GetSavedReactionTags({ hash: BigInt(hash) })); + + if (!result || result instanceof GramJs.messages.SavedReactionTagsNotModified) { + return undefined; + } + + return { + hash: String(result.hash), + tags: result.tags.map(buildApiSavedReactionTag).filter(Boolean), + }; +} + +export function updateSavedReactionTag({ + reaction, + title, +}: { + reaction: ApiReaction; + title?: string; +}) { + return invokeRequest(new GramJs.messages.UpdateSavedReactionTag({ + reaction: buildInputReaction(reaction), + title, + }), { + shouldReturnTrue: true, + }); +} diff --git a/src/api/gramjs/updates/updater.ts b/src/api/gramjs/updates/updater.ts index 7cfd75da0..28f2e1485 100644 --- a/src/api/gramjs/updates/updater.ts +++ b/src/api/gramjs/updates/updater.ts @@ -947,6 +947,8 @@ export function updater(update: Update) { onUpdate({ '@type': 'updateRecentStickers' }); } else if (update instanceof GramJs.UpdateRecentReactions) { onUpdate({ '@type': 'updateRecentReactions' }); + } else if (update instanceof GramJs.UpdateSavedReactionTags) { + onUpdate({ '@type': 'updateSavedReactionTags' }); } else if (update instanceof GramJs.UpdateMoveStickerSetToTop) { if (!update.masks) { onUpdate({ diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index fac30b43c..da523d379 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -543,6 +543,7 @@ export interface ApiMessage { export interface ApiReactions { canSeeList?: boolean; + areTags?: boolean; results: ApiReactionCount[]; recentReactions?: ApiPeerReaction[]; } @@ -598,6 +599,14 @@ export type ApiReactionCustomEmoji = { export type ApiReaction = ApiReactionEmoji | ApiReactionCustomEmoji; +export type ApiReactionKey = `${string}-${string}`; + +export type ApiSavedReactionTag = { + reaction: ApiReaction; + title?: string; + count: number; +}; + interface ApiBaseThreadInfo { chatId: string; messagesCount: number; diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 65428aa18..2d704b9f4 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -706,6 +706,10 @@ export type ApiUpdateGroupInvitePrivacyForbidden = { userId: string; }; +export type ApiUpdateSavedReactionTags = { + '@type': 'updateSavedReactionTags'; +}; + export type ApiUpdate = ( ApiUpdateReady | ApiUpdateSession | ApiUpdateWebAuthTokenFailed | ApiUpdateRequestUserUpdate | ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser | @@ -724,7 +728,7 @@ export type ApiUpdate = ( ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages | ApiUpdateMessageTranslations | ApiUpdateTwoFaError | ApiUpdateTwoFaStateWaitCode | ApiUpdateWebViewResultSent | ApiUpdateNotifySettings | ApiUpdateNotifyExceptions | ApiUpdatePeerBlocked | ApiUpdatePrivacy | - ApiUpdateServerTimeOffset | ApiUpdateShowInvite | ApiUpdateMessageReactions | + ApiUpdateServerTimeOffset | ApiUpdateShowInvite | ApiUpdateMessageReactions | ApiUpdateSavedReactionTags | ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams | ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId | ApiUpdatePendingJoinRequests | ApiUpdatePaymentVerificationNeeded | ApiUpdatePaymentStateCompleted | diff --git a/src/assets/font-icons/tag-add.svg b/src/assets/font-icons/tag-add.svg new file mode 100644 index 000000000..1f3e9b977 --- /dev/null +++ b/src/assets/font-icons/tag-add.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/tag-crossed.svg b/src/assets/font-icons/tag-crossed.svg new file mode 100644 index 000000000..9b72f6210 --- /dev/null +++ b/src/assets/font-icons/tag-crossed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/tag-filter.svg b/src/assets/font-icons/tag-filter.svg new file mode 100644 index 000000000..8590c7726 --- /dev/null +++ b/src/assets/font-icons/tag-filter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/tag-name.svg b/src/assets/font-icons/tag-name.svg new file mode 100644 index 000000000..1dd25730c --- /dev/null +++ b/src/assets/font-icons/tag-name.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/tag.svg b/src/assets/font-icons/tag.svg new file mode 100644 index 000000000..7889f9a53 --- /dev/null +++ b/src/assets/font-icons/tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/premium/PremiumTags.svg b/src/assets/premium/PremiumTags.svg new file mode 100644 index 000000000..4fe80d9f1 --- /dev/null +++ b/src/assets/premium/PremiumTags.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 32682d19b..3b717b834 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -52,7 +52,7 @@ export { default as StickerSetModal } from '../components/common/StickerSetModal export { default as CustomEmojiSetsModal } from '../components/common/CustomEmojiSetsModal'; export { default as HeaderMenuContainer } from '../components/middle/HeaderMenuContainer'; export { default as MobileSearch } from '../components/middle/MobileSearch'; -export { default as ReactionPicker } from '../components/middle/message/ReactionPicker'; +export { default as ReactionPicker } from '../components/middle/message/reactions/ReactionPicker'; export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal'; export { default as PollModal } from '../components/middle/composer/PollModal'; diff --git a/src/components/common/Composer.scss b/src/components/common/Composer.scss index 4d46bb9c0..b6be92ff2 100644 --- a/src/components/common/Composer.scss +++ b/src/components/common/Composer.scss @@ -240,10 +240,11 @@ --color-interactive-element-hover: rgba(255, 255, 255, 0.1); --color-text: #fff; + position: absolute; left: 50%; right: auto; - top: -3.875rem; - transform: translateX(-50%); + top: -0.75rem; + transform: translate(-50%, -100%); z-index: 1; @media (max-width: 600px) { @@ -259,6 +260,10 @@ transform: scaleY(-1); color: #fff; } + + .ReactionSelector__hint { + color: #fff; + } } } diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index e812c0043..e7678d724 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -153,7 +153,7 @@ import SendAsMenu from '../middle/composer/SendAsMenu.async'; import StickerTooltip from '../middle/composer/StickerTooltip.async'; import SymbolMenuButton from '../middle/composer/SymbolMenuButton'; import WebPagePreview from '../middle/composer/WebPagePreview'; -import ReactionSelector from '../middle/message/ReactionSelector'; +import ReactionSelector from '../middle/message/reactions/ReactionSelector'; import Button from '../ui/Button'; import ResponsiveHoverButton from '../ui/ResponsiveHoverButton'; import Spinner from '../ui/Spinner'; @@ -1521,6 +1521,8 @@ const Composer: FC = ({ isReady={isReady} canBuyPremium={canBuyPremium} isCurrentUserPremium={isCurrentUserPremium} + isInSavedMessages={isChatWithSelf} + isInStoryViewer={isInStoryViewer} canPlayAnimatedEmojis={canPlayAnimatedEmojis} onShowMore={handleReactionPickerOpen} className={reactionSelectorTransitonClassNames} @@ -2001,8 +2003,8 @@ export default memo(withGlobal( const isInScheduledList = messageListType === 'scheduled'; return { - availableReactions: type === 'story' ? global.availableReactions : undefined, - topReactions: type === 'story' ? global.topReactions : undefined, + availableReactions: type === 'story' ? global.reactions.availableReactions : undefined, + topReactions: type === 'story' ? global.reactions.topReactions : undefined, isOnActiveTab: !tabState.isBlurred, editingMessage: selectEditingMessage(global, chatId, threadId, messageListType), draft, diff --git a/src/components/common/CustomEmojiPicker.tsx b/src/components/common/CustomEmojiPicker.tsx index 175bad600..63e8033e4 100644 --- a/src/components/common/CustomEmojiPicker.tsx +++ b/src/components/common/CustomEmojiPicker.tsx @@ -30,7 +30,6 @@ import { import animateHorizontalScroll from '../../util/animateHorizontalScroll'; import buildClassName from '../../util/buildClassName'; import { pickTruthy, unique } from '../../util/iteratees'; -import { MEMO_EMPTY_ARRAY } from '../../util/memo'; import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; import { REM } from './helpers/mediaDimensions'; @@ -76,6 +75,7 @@ type StateProps = { recentStatusEmojis?: ApiSticker[]; topReactions?: ApiReaction[]; recentReactions?: ApiReaction[]; + defaultTagReactions?: ApiReaction[]; stickerSetsById: Record; availableReactions?: ApiAvailableReaction[]; addedCustomEmojiIds?: string[]; @@ -126,6 +126,7 @@ const CustomEmojiPicker: FC = ({ withDefaultTopicIcons, defaultTopicIconsId, defaultStatusIconsId, + defaultTagReactions, onCustomEmojiSelect, onReactionSelect, onContextMenuOpen, @@ -168,13 +169,22 @@ const CustomEmojiPicker: FC = ({ const areAddedLoaded = Boolean(addedCustomEmojiIds); const allSets = useMemo(() => { - if (!addedCustomEmojiIds) { - return MEMO_EMPTY_ARRAY; - } - const defaultSets: StickerSetOrReactionsSetOrRecent[] = []; - if (isReactionPicker) { + if (isReactionPicker && isSavedMessages) { + if (defaultTagReactions?.length) { + defaultSets.push({ + id: TOP_SYMBOL_SET_ID, + accessHash: '', + title: lang('PremiumPreviewTags'), + reactions: defaultTagReactions, + count: defaultTagReactions.length, + isEmoji: true, + }); + } + } + + if (isReactionPicker && !isSavedMessages) { const topReactionsSlice = topReactions?.slice(0, TOP_REACTIONS_COUNT) || []; if (topReactionsSlice?.length) { defaultSets.push({ @@ -240,7 +250,7 @@ const CustomEmojiPicker: FC = ({ }); } - const setIdsToDisplay = unique(addedCustomEmojiIds.concat(customEmojiFeaturedIds || [])); + const setIdsToDisplay = unique((addedCustomEmojiIds || []).concat(customEmojiFeaturedIds || [])); const setsToDisplay = Object.values(pickTruthy(stickerSetsById, setIdsToDisplay)); @@ -251,7 +261,7 @@ const CustomEmojiPicker: FC = ({ }, [ addedCustomEmojiIds, isReactionPicker, isStatusPicker, withDefaultTopicIcons, recentCustomEmojis, customEmojiFeaturedIds, stickerSetsById, topReactions, availableReactions, lang, recentReactions, - defaultStatusIconsId, defaultTopicIconsId, + defaultStatusIconsId, defaultTopicIconsId, isSavedMessages, defaultTagReactions, ]); const noPopulatedSets = useMemo(() => ( @@ -447,8 +457,12 @@ export default memo(withGlobal( }, }, recentCustomEmojis: recentCustomEmojiIds, - recentReactions, - topReactions, + reactions: { + availableReactions, + recentReactions, + topReactions, + defaultTags, + }, } = global; const isSavedMessages = Boolean(chatId && selectIsChatWithSelf(global, chatId)); @@ -467,7 +481,8 @@ export default memo(withGlobal( defaultStatusIconsId: global.defaultStatusIconsId, topReactions: isReactionPicker ? topReactions : undefined, recentReactions: isReactionPicker ? recentReactions : undefined, - availableReactions: isReactionPicker ? global.availableReactions : undefined, + availableReactions: isReactionPicker ? availableReactions : undefined, + defaultTagReactions: isReactionPicker ? defaultTags : undefined, }; }, )(CustomEmojiPicker)); diff --git a/src/components/common/StickerSet.tsx b/src/components/common/StickerSet.tsx index e3841ada4..ea3eb6ee0 100644 --- a/src/components/common/StickerSet.tsx +++ b/src/components/common/StickerSet.tsx @@ -17,7 +17,7 @@ import { RECENT_SYMBOL_SET_ID, STICKER_SIZE_PICKER, } from '../../config'; -import { getReactionUniqueKey } from '../../global/helpers'; +import { getReactionKey } from '../../global/helpers'; import { selectIsAlwaysHighPriorityEmoji, selectIsSetPremium } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; @@ -319,7 +319,7 @@ const StickerSet: FC = ({ )} {shouldRender && stickerSet.reactions?.map((reaction) => { - const reactionId = getReactionUniqueKey(reaction); + const reactionId = getReactionKey(reaction); const isSelected = reactionId ? selectedReactionIds?.includes(reactionId) : undefined; return ( diff --git a/src/components/common/reactions/ReactionAnimatedEmoji.tsx b/src/components/common/reactions/ReactionAnimatedEmoji.tsx index 29658e174..d320e75f4 100644 --- a/src/components/common/reactions/ReactionAnimatedEmoji.tsx +++ b/src/components/common/reactions/ReactionAnimatedEmoji.tsx @@ -209,14 +209,14 @@ const ReactionAnimatedEmoji = ({ export default memo(withGlobal( (global, { containerId }) => { - const { availableReactions, genericEmojiEffects } = global; + const { genericEmojiEffects, reactions } = global; const { activeReactions } = selectTabState(global); const withEffects = selectPerformanceSettingsValue(global, 'reactionEffects'); return { activeReactions: activeReactions?.[containerId], - availableReactions, + availableReactions: reactions.availableReactions, genericEffects: genericEmojiEffects, withEffects, }; diff --git a/src/components/left/settings/SettingsQuickReaction.tsx b/src/components/left/settings/SettingsQuickReaction.tsx index ae134a522..2d4a57e99 100644 --- a/src/components/left/settings/SettingsQuickReaction.tsx +++ b/src/components/left/settings/SettingsQuickReaction.tsx @@ -65,10 +65,10 @@ const SettingsQuickReaction: FC = ({ export default memo(withGlobal( (global) => { - const { availableReactions, config } = global; + const { config, reactions } = global; return { - availableReactions, + availableReactions: reactions.availableReactions, selectedReaction: config?.defaultReaction, }; }, diff --git a/src/components/left/settings/SettingsStickers.tsx b/src/components/left/settings/SettingsStickers.tsx index d83d2c929..1c0b5319a 100644 --- a/src/components/left/settings/SettingsStickers.tsx +++ b/src/components/left/settings/SettingsStickers.tsx @@ -175,7 +175,7 @@ export default memo(withGlobal( customEmojiSetIds: global.customEmojis.added.setIds, stickerSetsById: global.stickers.setsById, defaultReaction: global.config?.defaultReaction, - availableReactions: global.availableReactions, + availableReactions: global.reactions.availableReactions, canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global), }; }, diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 2a0169966..936622f37 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -72,7 +72,7 @@ import UnreadCount from '../common/UnreadCounter'; import LeftColumn from '../left/LeftColumn'; import MediaViewer from '../mediaViewer/MediaViewer.async'; import AudioPlayer from '../middle/AudioPlayer'; -import ReactionPicker from '../middle/message/ReactionPicker.async'; +import ReactionPicker from '../middle/message/reactions/ReactionPicker.async'; import MessageListHistoryHandler from '../middle/MessageListHistoryHandler'; import MiddleColumn from '../middle/MiddleColumn'; import AttachBotInstallModal from '../modals/attachBotInstall/AttachBotInstallModal.async'; @@ -265,11 +265,13 @@ const Main: FC = ({ updatePageTitle, loadTopReactions, loadRecentReactions, + loadDefaultTagReactions, loadFeaturedEmojiStickers, setIsElectronUpdateAvailable, loadPremiumSetStickers, loadAuthorizations, loadPeerColors, + loadSavedReactionTags, } = getActions(); if (DEBUG && !DEBUG_isLogged) { @@ -343,8 +345,10 @@ const Main: FC = ({ checkAppVersion(); loadTopReactions(); loadRecentReactions(); + loadDefaultTagReactions(); loadFeaturedEmojiStickers(); loadAuthorizations(); + loadSavedReactionTags(); } }, [isMasterTab, isSynced]); diff --git a/src/components/main/premium/PremiumFeatureModal.tsx b/src/components/main/premium/PremiumFeatureModal.tsx index b1d43b3ca..a8fb8f08b 100644 --- a/src/components/main/premium/PremiumFeatureModal.tsx +++ b/src/components/main/premium/PremiumFeatureModal.tsx @@ -39,6 +39,7 @@ export const PREMIUM_FEATURE_TITLES: Record = { emoji_status: 'PremiumPreviewEmojiStatus', translations: 'PremiumPreviewTranslations', stories: 'PremiumPreviewStories', + saved_tags: 'PremiumPreviewTags2', }; export const PREMIUM_FEATURE_DESCRIPTIONS: Record = { @@ -56,6 +57,7 @@ export const PREMIUM_FEATURE_DESCRIPTIONS: Record = { emoji_status: 'PremiumPreviewEmojiStatusDescription', translations: 'PremiumPreviewTranslationsDescription', stories: 'PremiumPreviewStoriesDescription', + saved_tags: 'PremiumPreviewTagsDescription2', }; export const PREMIUM_FEATURE_SECTIONS = [ @@ -73,6 +75,7 @@ export const PREMIUM_FEATURE_SECTIONS = [ 'animated_userpics', 'emoji_status', 'translations', + 'saved_tags', ]; const PREMIUM_BOTTOM_VIDEOS: string[] = [ @@ -84,6 +87,7 @@ const PREMIUM_BOTTOM_VIDEOS: string[] = [ 'animated_userpics', 'emoji_status', 'translations', + 'saved_tags', ]; type ApiLimitTypeWithoutUpload = Exclude = { animated_userpics: PremiumVideo, emoji_status: PremiumStatus, translations: PremiumTranslate, + saved_tags: PremiumTags, }; export type OwnProps = { diff --git a/src/components/middle/MobileSearch.scss b/src/components/middle/MobileSearch.scss index 68866bedc..18b22e956 100644 --- a/src/components/middle/MobileSearch.scss +++ b/src/components/middle/MobileSearch.scss @@ -17,6 +17,30 @@ } } +#MobileSearch > .tags-subheader { + --color-reaction: var(--color-background-secondary); + --hover-color-reaction: var(--color-background-secondary-accent); + --text-color-reaction: var(--color-text-secondary); + --color-reaction-chosen: var(--color-primary); + --text-color-reaction-chosen: #FFFFFF; + --hover-color-reaction-chosen: var(--color-primary-shade); + + position: absolute; + top: 3.5rem; + left: 0; + z-index: var(--z-mobile-search); + width: 100%; + height: 3rem; + background: var(--color-background); + display: flex; + align-items: center; + gap: 0.375rem; + padding-left: max(0.25rem, env(safe-area-inset-left)); + padding-right: max(0.5rem, env(safe-area-inset-right)); + + overflow-x: scroll; +} + #MobileSearch > .footer { position: absolute; bottom: 0; @@ -49,7 +73,7 @@ } #MobileSearch:not(.active) { - .header, .footer { + .header, .tags-subheader, .footer { // `display: none` will prevent synchronous focus on iOS transform: translateX(-999rem); } diff --git a/src/components/middle/MobileSearch.tsx b/src/components/middle/MobileSearch.tsx index c70fba170..f000f7ab4 100644 --- a/src/components/middle/MobileSearch.tsx +++ b/src/components/middle/MobileSearch.tsx @@ -1,28 +1,36 @@ import type { FC } from '../../lib/teact/teact'; import React, { memo, useEffect, useLayoutEffect, + useMemo, useRef, useState, } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; -import type { ApiChat } from '../../api/types'; +import type { + ApiChat, ApiReaction, ApiReactionKey, ApiSavedReactionTag, +} from '../../api/types'; import type { ThreadId } from '../../types'; import { requestMutation } from '../../lib/fasterdom/fasterdom'; +import { getIsSavedDialog, getReactionKey, isSameReaction } from '../../global/helpers'; import { - selectCurrentChat, + selectChat, selectCurrentMessageList, selectCurrentTextSearch, + selectIsChatWithSelf, + selectIsCurrentUserPremium, selectTabState, } from '../../global/selectors'; import { getDayStartAt } from '../../util/dateFormat'; import { debounce } from '../../util/schedulers'; import { IS_IOS } from '../../util/windowEnvironment'; +import useHorizontalScroll from '../../hooks/useHorizontalScroll'; import useLastCallback from '../../hooks/useLastCallback'; import Button from '../ui/Button'; import SearchInput from '../ui/SearchInput'; +import SavedTagButton from './message/reactions/SavedTagButton'; import './MobileSearch.scss'; @@ -35,9 +43,12 @@ type StateProps = { chat?: ApiChat; threadId?: ThreadId; query?: string; + savedTags?: Record; + searchTag?: ApiReaction; totalCount?: number; foundIds?: number[]; isHistoryCalendarOpen?: boolean; + isCurrentUserPremium?: boolean; }; const runDebouncedForSearch = debounce((cb) => cb(), 200, false); @@ -47,22 +58,33 @@ const MobileSearchFooter: FC = ({ chat, threadId, query, + savedTags, + searchTag, totalCount, foundIds, isHistoryCalendarOpen, + isCurrentUserPremium, }) => { const { setLocalTextSearchQuery, + setLocalTextSearchTag, searchTextMessagesLocal, focusMessage, closeLocalTextSearch, openHistoryCalendar, + openPremiumModal, + loadSavedReactionTags, } = getActions(); // eslint-disable-next-line no-null/no-null const inputRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const tagsRef = useRef(null); + const [focusedIndex, setFocusedIndex] = useState(0); + const hasQueryData = Boolean(query || searchTag); + // Fix for iOS keyboard useEffect(() => { const { visualViewport } = window as any; @@ -127,14 +149,41 @@ const MobileSearchFooter: FC = ({ searchInput.blur(); }, [isHistoryCalendarOpen]); + const tags = useMemo(() => { + if (!savedTags) return undefined; + return Object.values(savedTags); + }, [savedTags]); + + const hasTags = Boolean(tags?.length); + const areTagsDisabled = hasTags && !isCurrentUserPremium; + + useHorizontalScroll(tagsRef, !hasTags); + + useEffect(() => { + if (isActive) loadSavedReactionTags(); + }, [hasTags, isActive]); + const handleMessageSearchQueryChange = useLastCallback((newQuery: string) => { setLocalTextSearchQuery({ query: newQuery }); - if (newQuery.length) { + if (hasQueryData) { runDebouncedForSearch(searchTextMessagesLocal); } }); + const handleTagClick = useLastCallback((tag: ApiReaction) => { + if (areTagsDisabled) { + openPremiumModal({ + initialSection: 'saved_tags', + }); + return; + } + + setLocalTextSearchTag({ tag }); + + runDebouncedForSearch(searchTextMessagesLocal); + }); + const handleUp = useLastCallback(() => { if (chat && foundIds) { const newFocusIndex = focusedIndex + 1; @@ -172,9 +221,28 @@ const MobileSearchFooter: FC = ({ onChange={handleMessageSearchQueryChange} /> + {hasTags && ( +
+ {tags.map((tag) => ( + + ))} +
+ )}
- {query ? ( + {hasQueryData ? ( foundIds?.length ? ( `${focusedIndex + 1} of ${totalCount}` ) : foundIds && !foundIds.length ? ( @@ -220,15 +288,25 @@ const MobileSearchFooter: FC = ({ export default memo(withGlobal( (global): StateProps => { - const chat = selectCurrentChat(global); + const currentMessageList = selectCurrentMessageList(global); + if (!currentMessageList) { + return {}; + } + const { chatId, threadId } = currentMessageList; + + const chat = selectChat(global, chatId); if (!chat) { return {}; } - const { query, results } = selectCurrentTextSearch(global) || {}; - const { threadId } = selectCurrentMessageList(global) || {}; + const { query, savedTag, results } = selectCurrentTextSearch(global) || {}; const { totalCount, foundIds } = results || {}; + const isSavedMessages = selectIsChatWithSelf(global, chatId); + const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); + + const savedTags = isSavedMessages && !isSavedDialog ? global.savedReactionTags?.byKey : undefined; + return { chat, query, @@ -236,6 +314,9 @@ export default memo(withGlobal( threadId, foundIds, isHistoryCalendarOpen: Boolean(selectTabState(global).historyCalendarSelectedAt), + savedTags, + searchTag: savedTag, + isCurrentUserPremium: selectIsCurrentUserPremium(global), }; }, )(MobileSearchFooter)); diff --git a/src/components/middle/ReactorListModal.tsx b/src/components/middle/ReactorListModal.tsx index 7bdfd24d8..d2bb7dd42 100644 --- a/src/components/middle/ReactorListModal.tsx +++ b/src/components/middle/ReactorListModal.tsx @@ -8,7 +8,7 @@ import { getActions, getGlobal, withGlobal } from '../../global'; import type { ApiAvailableReaction, ApiMessage, ApiReaction } from '../../api/types'; import { LoadMoreDirection } from '../../types'; -import { getReactionUniqueKey, isSameReaction } from '../../global/helpers'; +import { getReactionKey, isSameReaction } from '../../global/helpers'; import { selectChatMessage, selectTabState, @@ -162,7 +162,7 @@ const ReactorListModal: FC = ({ .find((reactionsCount) => isSameReaction(reactionsCount.reaction, reaction))?.count; return (
@@ -1611,7 +1619,7 @@ export default memo(withGlobal( autoLoadFileMaxSizeMb: global.settings.byKey.autoLoadFileMaxSizeMb, shouldLoopStickers: selectShouldLoopStickers(global), repliesThreadInfo, - availableReactions: global.availableReactions, + availableReactions: global.reactions.availableReactions, defaultReaction: isMessageLocal(message) || messageListType === 'scheduled' ? undefined : selectDefaultReaction(global, chatId), hasActiveReactions, @@ -1644,6 +1652,7 @@ export default memo(withGlobal( isResizingContainer, focusedQuote, }), + tags: global.savedReactionTags?.byKey, }; }, )(Message)); diff --git a/src/components/middle/message/MessageContextMenu.scss b/src/components/middle/message/MessageContextMenu.scss index be0764739..60d9b4c7e 100644 --- a/src/components/middle/message/MessageContextMenu.scss +++ b/src/components/middle/message/MessageContextMenu.scss @@ -21,13 +21,15 @@ .bubble { overflow: initial; padding: 0 !important; + display: flex; + flex-direction: column; + align-items: stretch; } &.with-reactions .bubble { background: none !important; backdrop-filter: none !important; box-shadow: none; - padding: 3.5rem 0 0 !important; } &.with-reactions &_items { @@ -37,6 +39,10 @@ border-radius: var(--border-radius-default); padding: 0.25rem 0; + @media (min-width: 600px) { + margin-inline-end: 2.75rem; + } + body.no-menu-blur & { background: var(--color-background); backdrop-filter: none; diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index 89022872c..29e60d1db 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -36,7 +36,7 @@ import Menu from '../../ui/Menu'; import MenuItem from '../../ui/MenuItem'; import MenuSeparator from '../../ui/MenuSeparator'; import Skeleton from '../../ui/placeholder/Skeleton'; -import ReactionSelector from './ReactionSelector'; +import ReactionSelector from './reactions/ReactionSelector'; import ReadTimeMenuItem from './ReadTimeMenuItem'; import './MessageContextMenu.scss'; @@ -45,6 +45,7 @@ type OwnProps = { isReactionPickerOpen?: boolean; availableReactions?: ApiAvailableReaction[]; topReactions?: ApiReaction[]; + defaultTagReactions?: ApiReaction[]; isOpen: boolean; anchor: IAnchorPosition; targetHref?: string; @@ -87,6 +88,7 @@ type OwnProps = { customEmojiSets?: ApiStickerSet[]; canPlayAnimatedEmojis?: boolean; noTransition?: boolean; + isInSavedMessages?: boolean; shouldRenderShowWhen?: boolean; canLoadReadDate?: boolean; onReply?: NoneToVoidFunction; @@ -124,14 +126,15 @@ type OwnProps = { }; const SCROLLBAR_WIDTH = 10; -const REACTION_BUBBLE_EXTRA_WIDTH = 32; const REACTION_SELECTOR_WIDTH_REM = 19.25; +const REACTION_SELECTOR_HEIGHT_REM = 3; const ANIMATION_DURATION = 200; const MessageContextMenu: FC = ({ isReactionPickerOpen, availableReactions, topReactions, + defaultTagReactions, isOpen, message, isPrivate, @@ -174,6 +177,7 @@ const MessageContextMenu: FC = ({ customEmojiSets, canPlayAnimatedEmojis, noTransition, + isInSavedMessages, shouldRenderShowWhen, canLoadReadDate, onReply, @@ -288,8 +292,8 @@ const MessageContextMenu: FC = ({ return { extraPaddingX: SCROLLBAR_WIDTH, extraTopPadding: (document.querySelector('.MiddleHeader')!).offsetHeight, - marginSides: withReactions ? REACTION_BUBBLE_EXTRA_WIDTH : undefined, extraMarginTop: extraHeightPinned + extraHeightAudioPlayer, + topShiftY: withReactions && !isMobile ? -REACTION_SELECTOR_HEIGHT_REM * REM : 0, shouldAvoidNegativePosition: !isDesktop, menuElMinWidth: withReactions && isMobile ? REACTION_SELECTOR_WIDTH_REM * REM : undefined, }; @@ -343,6 +347,7 @@ const MessageContextMenu: FC = ({ enabledReactions={enabledReactions} topReactions={topReactions} allAvailableReactions={availableReactions} + defaultTagReactions={defaultTagReactions} currentReactions={!isSponsoredMessage ? message.reactions?.results : undefined} maxUniqueReactions={maxUniqueReactions} onToggleReaction={onToggleReaction!} @@ -350,8 +355,10 @@ const MessageContextMenu: FC = ({ isReady={isReady} canBuyPremium={canBuyPremium} isCurrentUserPremium={isCurrentUserPremium} + isInSavedMessages={isInSavedMessages} canPlayAnimatedEmojis={canPlayAnimatedEmojis} onShowMore={handleOpenMessageReactionPicker} + onClose={onClose} className={buildClassName(areItemsHidden && 'ReactionSelector-hidden')} /> )} @@ -362,6 +369,7 @@ const MessageContextMenu: FC = ({ areItemsHidden && 'MessageContextMenu_items-hidden', )} style={menuStyle} + dir={lang.isRtl ? 'rtl' : undefined} ref={scrollableRef} > {canSendNow && {lang('MessageScheduleSend')}} diff --git a/src/components/middle/message/ReactionButton.tsx b/src/components/middle/message/ReactionButton.tsx deleted file mode 100644 index 0faa758ac..000000000 --- a/src/components/middle/message/ReactionButton.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { FC } from '../../../lib/teact/teact'; -import React, { memo, useMemo } from '../../../lib/teact/teact'; -import { getActions, getGlobal } from '../../../global'; - -import type { - ApiMessage, ApiPeer, ApiReactionCount, -} from '../../../api/types'; -import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; - -import { isReactionChosen, isSameReaction } from '../../../global/helpers'; -import buildClassName from '../../../util/buildClassName'; -import { getMessageKey } from '../../../util/messageKey'; -import { formatIntegerCompact } from '../../../util/textFormat'; -import { REM } from '../../common/helpers/mediaDimensions'; - -import useLastCallback from '../../../hooks/useLastCallback'; - -import AnimatedCounter from '../../common/AnimatedCounter'; -import AvatarList from '../../common/AvatarList'; -import ReactionAnimatedEmoji from '../../common/reactions/ReactionAnimatedEmoji'; -import Button from '../../ui/Button'; - -import './Reactions.scss'; - -const REACTION_SIZE = 1.25 * REM; - -const ReactionButton: FC<{ - reaction: ApiReactionCount; - message: ApiMessage; - withRecentReactors?: boolean; - observeIntersection?: ObserveFn; -}> = ({ - reaction, - message, - withRecentReactors, - observeIntersection, -}) => { - const { toggleReaction } = getActions(); - const { recentReactions } = message.reactions!; - - const recentReactors = useMemo(() => { - if (!withRecentReactors || !recentReactions) { - return undefined; - } - - // No need for expensive global updates on chats or users, so we avoid them - const chatsById = getGlobal().chats.byId; - const usersById = getGlobal().users.byId; - - return recentReactions - .filter((recentReaction) => isSameReaction(recentReaction.reaction, reaction.reaction)) - .map((recentReaction) => usersById[recentReaction.peerId] || chatsById[recentReaction.peerId]) - .filter(Boolean) as ApiPeer[]; - }, [reaction.reaction, recentReactions, withRecentReactors]); - - const handleClick = useLastCallback(() => { - toggleReaction({ - reaction: reaction.reaction, - chatId: message.chatId, - messageId: message.id, - }); - }); - - return ( - - ); -}; - -export default memo(ReactionButton); diff --git a/src/components/middle/message/ReactionSelector.tsx b/src/components/middle/message/ReactionSelector.tsx deleted file mode 100644 index 622c39999..000000000 --- a/src/components/middle/message/ReactionSelector.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import type { FC } from '../../../lib/teact/teact'; -import React, { memo, useMemo, useRef } from '../../../lib/teact/teact'; - -import type { - ApiAvailableReaction, ApiChatReactions, ApiReaction, ApiReactionCount, -} from '../../../api/types'; -import type { IAnchorPosition } from '../../../types'; - -import { - canSendReaction, getReactionUniqueKey, isSameReaction, sortReactions, -} from '../../../global/helpers'; -import buildClassName, { createClassNameBuilder } from '../../../util/buildClassName'; - -import useLang from '../../../hooks/useLang'; -import useLastCallback from '../../../hooks/useLastCallback'; - -import Button from '../../ui/Button'; -import ReactionSelectorCustomReaction from './ReactionSelectorCustomReaction'; -import ReactionSelectorReaction from './ReactionSelectorReaction'; - -import './ReactionSelector.scss'; - -type OwnProps = { - enabledReactions?: ApiChatReactions; - isPrivate?: boolean; - topReactions?: ApiReaction[]; - allAvailableReactions?: ApiAvailableReaction[]; - currentReactions?: ApiReactionCount[]; - maxUniqueReactions?: number; - isReady?: boolean; - canBuyPremium?: boolean; - isCurrentUserPremium?: boolean; - canPlayAnimatedEmojis?: boolean; - className?: string; - onToggleReaction: (reaction: ApiReaction) => void; - onShowMore: (position: IAnchorPosition) => void; -}; - -const cn = createClassNameBuilder('ReactionSelector'); -const REACTIONS_AMOUNT = 6; -const FADE_IN_DELAY = 20; - -const ReactionSelector: FC = ({ - allAvailableReactions, - topReactions, - enabledReactions, - currentReactions, - maxUniqueReactions, - isPrivate, - isReady, - canPlayAnimatedEmojis, - className, - onToggleReaction, - onShowMore, -}) => { - // eslint-disable-next-line no-null/no-null - const ref = useRef(null); - const lang = useLang(); - - const availableReactions = useMemo(() => { - const reactions = (enabledReactions?.type === 'some' && enabledReactions.allowed) - || allAvailableReactions?.map((reaction) => reaction.reaction); - const filteredReactions = reactions?.map((reaction) => { - const isCustomReaction = 'documentId' in reaction; - const availableReaction = allAvailableReactions?.find((r) => isSameReaction(r.reaction, reaction)); - if ((!isCustomReaction && !availableReaction) || availableReaction?.isInactive) return undefined; - - if (!isPrivate && (!enabledReactions || !canSendReaction(reaction, enabledReactions))) { - return undefined; - } - - if (maxUniqueReactions && currentReactions && currentReactions.length >= maxUniqueReactions - && !currentReactions.some(({ reaction: currentReaction }) => isSameReaction(reaction, currentReaction))) { - return undefined; - } - - return isCustomReaction ? reaction : availableReaction; - }).filter(Boolean) || []; - - return sortReactions(filteredReactions, topReactions); - }, [allAvailableReactions, currentReactions, enabledReactions, isPrivate, maxUniqueReactions, topReactions]); - - const reactionsToRender = useMemo(() => { - // Component can fit one more if we do not need show more button - return availableReactions.length === REACTIONS_AMOUNT + 1 - ? availableReactions - : availableReactions.slice(0, REACTIONS_AMOUNT); - }, [availableReactions]); - const withMoreButton = reactionsToRender.length < availableReactions.length; - - const userReactionIndexes = useMemo(() => { - const chosenReactions = currentReactions?.filter(({ chosenOrder }) => chosenOrder !== undefined) || []; - return new Set(chosenReactions.map(({ reaction }) => ( - reactionsToRender.findIndex((r) => r && isSameReaction('reaction' in r ? r.reaction : r, reaction)) - ))); - }, [currentReactions, reactionsToRender]); - - const handleShowMoreClick = useLastCallback(() => { - const bound = ref.current?.getBoundingClientRect() || { x: 0, y: 0 }; - onShowMore({ - x: bound.x, - y: bound.y, - }); - }); - - if (!reactionsToRender.length) return undefined; - - return ( -
-
-
-
-
- {reactionsToRender.map((reaction, i) => ( - 'reaction' in reaction ? ( - - ) : ( - - ) - ))} - {withMoreButton && ( - - )} -
-
-
- ); -}; - -export default memo(ReactionSelector); diff --git a/src/components/middle/message/ReactionSelectorReaction.scss b/src/components/middle/message/ReactionSelectorReaction.scss deleted file mode 100644 index 36c4406a2..000000000 --- a/src/components/middle/message/ReactionSelectorReaction.scss +++ /dev/null @@ -1,62 +0,0 @@ -.ReactionSelectorReaction { - --custom-emoji-size: 2rem; - - margin-inline-start: 0.25rem; - position: relative; - min-width: 2rem; - min-height: 2rem; - - &:first-child { - margin-inline-start: 0; - } - - &__static-icon { - position: absolute; - top: 5%; - left: 5%; - width: 90%; - height: 90%; - } - - .AnimatedSticker { - position: absolute; - top: 0; - left: 0; - } - - &--chosen::before { - content: ''; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 2.25rem; - height: 2.25rem; - border-radius: 50%; - background-color: var(--color-background-compact-menu-hover); - } - - &--custom { - opacity: 0; - } - - &--visible { - opacity: 1; - } - - &--custom-animated { - animation: ReactionSelectorReaction--fade-in 0.2s ease-in-out forwards; - animation-delay: var(--_animation-delay); - } - - @keyframes ReactionSelectorReaction--fade-in { - 0% { - transform: scale(0.5); - opacity: 0; - } - 100% { - transform: scale(1); - opacity: 1; - } - } -} diff --git a/src/components/middle/message/Reactions.scss b/src/components/middle/message/Reactions.scss deleted file mode 100644 index 9e2e04681..000000000 --- a/src/components/middle/message/Reactions.scss +++ /dev/null @@ -1,138 +0,0 @@ -.Reactions { - display: flex; - flex-direction: row; - width: 100%; - flex-wrap: wrap; - gap: 0.25rem; - margin-top: 0.375rem; - overflow: visible; - max-width: calc(var(--max-width) + 2.25rem); - - .Button { - --custom-emoji-size: 1.25rem; - --reaction-background: var(--color-reaction); - --reaction-background-hover: var(--hover-color-reaction); - --reaction-text-color: var(--text-color-reaction); - - .theme-dark & { - --reaction-background: rgb(255, 255, 255, 0.1); - --reaction-background-hover: rgb(255, 255, 255, 0.2); - --reaction-text-color: var(--color-text); - } - - display: flex; - flex-direction: row; - height: 1.875rem; - white-space: nowrap; - width: auto; - padding: 0 0.375rem 0 0.25rem; - background-color: var(--reaction-background) !important; - border-radius: 1.75rem; - font-weight: 500; - font-variant-numeric: tabular-nums; - text-transform: none; - color: var(--reaction-text-color); - overflow: visible; - line-height: 1.75rem; - - transition: background-color 150ms, color 150ms, backdrop-filter 150ms, filter 150ms; - - .avatars { - display: flex; - - .Avatar { - margin: 0; - margin-inline-start: -0.25rem; - border: 0.0625rem solid var(--reaction-background); - width: 1.5rem; - height: 1.5rem; - - &:first-child { - margin: 0; - } - } - } - - &.message-reaction { - gap: 0.125rem; - } - - .reaction-animated-emoji { - margin: 0.25rem; - } - - &.chosen { - --reaction-background: var(--color-reaction-chosen); - --reaction-background-hover: var(--hover-color-reaction-chosen); - --reaction-text-color: var(--text-color-reaction-chosen); - position: relative; - z-index: 1; - - .theme-dark & { - --reaction-background: #3390ec; - --reaction-background-hover: #4096ec; - } - - .theme-dark .own & { - --reaction-background: rgb(255, 255, 255, 0.75); - --reaction-background-hover: rgb(255, 255, 255, 0.85); - --reaction-text-color: rgb(62 62 62); - } - } - - .counter { - margin-inline-end: 0.125rem; - } - - &:hover { - --reaction-background: var(--reaction-background-hover) !important; - - backdrop-filter: var(--reaction-background-hover-filter); - - @supports not (backdrop-filter: var(--reaction-background-hover-filter)) { - filter: var(--reaction-background-hover-filter); - } - } - - &:first-of-type { - margin-inline-start: 0; - } - - &:last-of-type { - margin-inline-end: 0; - } - } - - &.is-outside { - margin-top: 0.125rem; - } - - .own &.is-outside { - flex-direction: row-reverse; - - .Button { - &:first-of-type { - margin-inline-start: 0.125rem; - margin-inline-end: 0; - } - - &:last-of-type { - margin-inline-end: 0.125rem; - margin-inline-start: 0; - } - } - } - - .theme-light &.is-outside .Button { - --reaction-background: var(--pattern-color); - --reaction-background-hover: var(--pattern-color); - --reaction-background-hover-filter: brightness(115%); - --reaction-text-color: white; - - &.chosen { - --reaction-background: rgb(255, 255, 255, 0.6); - --reaction-background-hover: rgb(255, 255, 255, 0.75); - --reaction-text-color: rgb(62 62 62); - } - } -} diff --git a/src/components/middle/message/Reactions.tsx b/src/components/middle/message/Reactions.tsx deleted file mode 100644 index 08ffd28cd..000000000 --- a/src/components/middle/message/Reactions.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import type { FC } from '../../../lib/teact/teact'; -import React, { memo, useMemo } from '../../../lib/teact/teact'; - -import type { ApiMessage } from '../../../api/types'; -import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; - -import { getReactionUniqueKey } from '../../../global/helpers'; -import buildClassName from '../../../util/buildClassName'; - -import useLang from '../../../hooks/useLang'; - -import ReactionButton from './ReactionButton'; - -import './Reactions.scss'; - -type OwnProps = { - message: ApiMessage; - isOutside?: boolean; - maxWidth?: number; - metaChildren?: React.ReactNode; - observeIntersection?: ObserveFn; - noRecentReactors?: boolean; -}; - -const MAX_RECENT_AVATARS = 3; - -const Reactions: FC = ({ - message, - isOutside, - maxWidth, - metaChildren, - observeIntersection, - noRecentReactors, -}) => { - const lang = useLang(); - - const totalCount = useMemo(() => ( - message.reactions!.results.reduce((acc, reaction) => acc + reaction.count, 0) - ), [message]); - - return ( -
- {message.reactions!.results.map((reaction) => ( - - ))} - {metaChildren} -
- ); -}; - -export default memo(Reactions); diff --git a/src/components/middle/message/reactions/ReactionButton.module.scss b/src/components/middle/message/reactions/ReactionButton.module.scss new file mode 100644 index 000000000..a10cd7e4d --- /dev/null +++ b/src/components/middle/message/reactions/ReactionButton.module.scss @@ -0,0 +1,118 @@ +// Hack: Increase selector specificity to override Button styles +.root.root { + --custom-emoji-size: 1.25rem; + --reaction-background: var(--color-reaction); + --reaction-background-hover: var(--hover-color-reaction); + --reaction-text-color: var(--text-color-reaction); + + &.chosen { + --reaction-background: var(--color-reaction-chosen); + --reaction-background-hover: var(--hover-color-reaction-chosen); + --reaction-text-color: var(--text-color-reaction-chosen); + + position: relative; + z-index: 1; + } + + display: flex; + flex-direction: row; + height: 1.875rem; + white-space: nowrap; + width: auto; + padding: 0 0.375rem 0 0.25rem; + background-color: var(--reaction-background) !important; + border-radius: 1.75rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + text-transform: none; + color: var(--reaction-text-color); + overflow: visible; + line-height: 1.75rem; + + gap: 0.125rem; + + transition: background-color 150ms, color 150ms, backdrop-filter 150ms, filter 150ms !important; + + &:hover { + --reaction-background: var(--reaction-background-hover) !important; + + backdrop-filter: var(--reaction-background-hover-filter); + + @supports not (backdrop-filter: var(--reaction-background-hover-filter)) { + filter: var(--reaction-background-hover-filter); + } + } +} + + +.animated-emoji { + margin: 0.25rem; +} + +.tag.tag { + position: relative; + margin-right: 1rem; + padding-inline: 0; + justify-content: start; + + border-radius: 0.375rem; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + + // SVG has problems with backdrop-filter + &:hover { + backdrop-filter: unset; + filter: var(--reaction-background-hover-filter); + } + + &::after { + content: ''; + position: absolute; + right: -0.5rem; + top: 50%; + transform: translateY(-50%); + + width: 0.375rem; + height: 0.375rem; + border-radius: 50%; + background-color: var(--text-color-reaction-chosen); + opacity: 0.4; + } + + .animated-emoji { + margin: 0.25rem 0 0.25rem 0.25rem; + } +} + +.tail { + height: 100%; + position: absolute; + right: -0.9375rem; + z-index: -1; + + .is-safari & { + // Safari subpixel rendering be damned. May cause slight overlap, but it's better than a gap. + /* stylelint-disable-next-line plugin/whole-pixel */ + right: -14.8px; + } +} + +.tail-fill { + fill: var(--reaction-background); + transition: fill 150ms; +} + +.tag-text { + display: flex; + gap: 0.25rem; + font-size: 1rem; + margin-inline-end: 0.375rem; +} + +.counter { + margin-inline-end: 0.125rem; +} + +.disabled { + opacity: 0.5; +} diff --git a/src/components/middle/message/reactions/ReactionButton.tsx b/src/components/middle/message/reactions/ReactionButton.tsx new file mode 100644 index 000000000..912a71172 --- /dev/null +++ b/src/components/middle/message/reactions/ReactionButton.tsx @@ -0,0 +1,76 @@ +import type { FC } from '../../../../lib/teact/teact'; +import React, { memo } from '../../../../lib/teact/teact'; + +import type { + ApiPeer, ApiReaction, ApiReactionCount, +} from '../../../../api/types'; +import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; + +import { isReactionChosen } from '../../../../global/helpers'; +import buildClassName from '../../../../util/buildClassName'; +import { formatIntegerCompact } from '../../../../util/textFormat'; +import { REM } from '../../../common/helpers/mediaDimensions'; + +import useLastCallback from '../../../../hooks/useLastCallback'; + +import AnimatedCounter from '../../../common/AnimatedCounter'; +import AvatarList from '../../../common/AvatarList'; +import ReactionAnimatedEmoji from '../../../common/reactions/ReactionAnimatedEmoji'; +import Button from '../../../ui/Button'; + +import styles from './ReactionButton.module.scss'; + +const REACTION_SIZE = 1.25 * REM; + +const ReactionButton: FC<{ + reaction: ApiReactionCount; + containerId: string; + isOwnMessage?: boolean; + recentReactors?: ApiPeer[]; + className?: string; + chosenClassName?: string; + observeIntersection?: ObserveFn; + onClick?: (reaction: ApiReaction) => void; +}> = ({ + reaction, + containerId, + isOwnMessage, + recentReactors, + className, + chosenClassName, + observeIntersection, + onClick, +}) => { + const handleClick = useLastCallback(() => { + onClick?.(reaction.reaction); + }); + + return ( + + ); +}; + +export default memo(ReactionButton); diff --git a/src/components/middle/message/ReactionPicker.async.tsx b/src/components/middle/message/reactions/ReactionPicker.async.tsx similarity index 63% rename from src/components/middle/message/ReactionPicker.async.tsx rename to src/components/middle/message/reactions/ReactionPicker.async.tsx index a0851f7a6..9cc92737e 100644 --- a/src/components/middle/message/ReactionPicker.async.tsx +++ b/src/components/middle/message/reactions/ReactionPicker.async.tsx @@ -1,11 +1,11 @@ -import type { FC } from '../../../lib/teact/teact'; -import React from '../../../lib/teact/teact'; +import type { FC } from '../../../../lib/teact/teact'; +import React from '../../../../lib/teact/teact'; import type { OwnProps } from './ReactionPicker'; -import { Bundles } from '../../../util/moduleLoader'; +import { Bundles } from '../../../../util/moduleLoader'; -import useModuleLoader from '../../../hooks/useModuleLoader'; +import useModuleLoader from '../../../../hooks/useModuleLoader'; const ReactionPickerAsync: FC = (props) => { const { isOpen } = props; diff --git a/src/components/middle/message/ReactionPicker.module.scss b/src/components/middle/message/reactions/ReactionPicker.module.scss similarity index 100% rename from src/components/middle/message/ReactionPicker.module.scss rename to src/components/middle/message/reactions/ReactionPicker.module.scss diff --git a/src/components/middle/message/ReactionPicker.tsx b/src/components/middle/message/reactions/ReactionPicker.tsx similarity index 87% rename from src/components/middle/message/ReactionPicker.tsx rename to src/components/middle/message/reactions/ReactionPicker.tsx index ac4a9378b..54c0fcd53 100644 --- a/src/components/middle/message/ReactionPicker.tsx +++ b/src/components/middle/message/reactions/ReactionPicker.tsx @@ -1,31 +1,31 @@ -import type { FC } from '../../../lib/teact/teact'; -import React, { memo, useMemo, useRef } from '../../../lib/teact/teact'; -import { getActions, getGlobal, withGlobal } from '../../../global'; +import type { FC } from '../../../../lib/teact/teact'; +import React, { memo, useMemo, useRef } from '../../../../lib/teact/teact'; +import { getActions, getGlobal, withGlobal } from '../../../../global'; import type { ApiMessage, ApiMessageEntity, ApiReaction, ApiReactionCustomEmoji, ApiSticker, ApiStory, ApiStorySkipped, -} from '../../../api/types'; -import type { IAnchorPosition } from '../../../types'; +} from '../../../../api/types'; +import type { IAnchorPosition } from '../../../../types'; -import { getStoryKey, isUserId } from '../../../global/helpers'; +import { getReactionKey, getStoryKey, isUserId } from '../../../../global/helpers'; import { selectChat, selectChatFullInfo, selectChatMessage, selectIsContextMenuTranslucent, selectIsCurrentUserPremium, selectPeerStory, selectTabState, -} from '../../../global/selectors'; -import buildClassName from '../../../util/buildClassName'; -import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText'; -import { REM } from '../../common/helpers/mediaDimensions'; -import { buildCustomEmojiHtml } from '../composer/helpers/customEmoji'; +} from '../../../../global/selectors'; +import buildClassName from '../../../../util/buildClassName'; +import parseHtmlAsFormattedText from '../../../../util/parseHtmlAsFormattedText'; +import { REM } from '../../../common/helpers/mediaDimensions'; +import { buildCustomEmojiHtml } from '../../composer/helpers/customEmoji'; -import { getIsMobile } from '../../../hooks/useAppLayout'; -import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; -import useLang from '../../../hooks/useLang'; -import useLastCallback from '../../../hooks/useLastCallback'; -import useMenuPosition from '../../../hooks/useMenuPosition'; +import { getIsMobile } from '../../../../hooks/useAppLayout'; +import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev'; +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; +import useMenuPosition from '../../../../hooks/useMenuPosition'; -import CustomEmojiPicker from '../../common/CustomEmojiPicker'; -import Menu from '../../ui/Menu'; +import CustomEmojiPicker from '../../../common/CustomEmojiPicker'; +import Menu from '../../../ui/Menu'; import ReactionPickerLimited from './ReactionPickerLimited'; import styles from './ReactionPicker.module.scss'; @@ -174,7 +174,7 @@ const ReactionPicker: FC = ({ const selectedReactionIds = useMemo(() => { return (message?.reactions?.results || []).reduce((acc, { chosenOrder, reaction }) => { if (chosenOrder !== undefined) { - acc.push('emoticon' in reaction ? reaction.emoticon : reaction.documentId); + acc.push(getReactionKey(reaction)); } return acc; @@ -202,6 +202,7 @@ const ReactionPicker: FC = ({ onClose={closeReactionPicker} > = ({ {allAvailableReactions.map((reaction) => { - const reactionId = getReactionUniqueKey(reaction); + const reactionId = getReactionKey(reaction); const isSelected = reactionId ? selectedReactionIds?.includes(reactionId) : undefined; return ( @@ -111,7 +111,7 @@ const ReactionPickerLimited: FC = ({ export default memo(withGlobal( (global, { chatId }): StateProps => { - const { availableReactions, topReactions } = global; + const { availableReactions, topReactions } = global.reactions; const { enabledReactions } = selectChatFullInfo(global, chatId) || {}; return { diff --git a/src/components/middle/message/ReactionSelector.scss b/src/components/middle/message/reactions/ReactionSelector.scss similarity index 77% rename from src/components/middle/message/ReactionSelector.scss rename to src/components/middle/message/reactions/ReactionSelector.scss index d24d37aab..58f8ba1f9 100644 --- a/src/components/middle/message/ReactionSelector.scss +++ b/src/components/middle/message/reactions/ReactionSelector.scss @@ -1,16 +1,12 @@ .ReactionSelector { - position: absolute; - height: 2.5rem; + position: relative; min-width: 3rem; - max-width: calc(100% + 4rem); - z-index: 100; - border-radius: 3rem; - right: -2rem; - top: 0.5rem; + max-width: fit-content; + right: 0; + top: -0.5rem; &--isRtl { - right: auto; - left: -3rem; + left: 0; } @media (max-width: 600px) { @@ -24,17 +20,12 @@ &__bubble-small, &__items-wrapper { background: var(--color-background); - filter: drop-shadow(0 0.25rem 0.125rem var(--color-default-shadow)); + box-shadow: 0 0.25rem 0.125rem var(--color-default-shadow); body:not(.no-menu-blur) & { background: var(--color-background-compact-menu); backdrop-filter: blur(25px); } - - body.is-safari & { - filter: none; - box-shadow: 0 0.25rem 0.125rem var(--color-default-shadow); - } } &__bubble-big { @@ -84,7 +75,7 @@ &__items-wrapper { width: 100%; height: 100%; - border-radius: 3rem; + border-radius: 1.25rem; @media (max-width: 600px) { width: fit-content; @@ -92,13 +83,25 @@ } &__items { + display: flex; + flex-direction: column; + align-items: center; + } + + &__hint { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + color: var(--color-text-secondary); + text-align: center; + text-wrap: balance; + } + + &__reactions { padding: 0 0.5rem; - width: 100%; - height: 100%; + height: 2.5rem; display: flex; cursor: var(--custom-cursor, pointer); align-items: center; - border-radius: 3rem; } &__show-more { diff --git a/src/components/middle/message/reactions/ReactionSelector.tsx b/src/components/middle/message/reactions/ReactionSelector.tsx new file mode 100644 index 000000000..da4a55029 --- /dev/null +++ b/src/components/middle/message/reactions/ReactionSelector.tsx @@ -0,0 +1,207 @@ +import type { FC } from '../../../../lib/teact/teact'; +import React, { memo, useMemo, useRef } from '../../../../lib/teact/teact'; +import { getActions } from '../../../../global'; + +import type { + ApiAvailableReaction, ApiChatReactions, ApiReaction, ApiReactionCount, +} from '../../../../api/types'; +import type { IAnchorPosition } from '../../../../types'; + +import { + canSendReaction, getReactionKey, isSameReaction, sortReactions, +} from '../../../../global/helpers'; +import buildClassName, { createClassNameBuilder } from '../../../../util/buildClassName'; + +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; + +import Button from '../../../ui/Button'; +import Link from '../../../ui/Link'; +import ReactionSelectorCustomReaction from './ReactionSelectorCustomReaction'; +import ReactionSelectorReaction from './ReactionSelectorReaction'; + +import './ReactionSelector.scss'; + +type OwnProps = { + enabledReactions?: ApiChatReactions; + isPrivate?: boolean; + topReactions?: ApiReaction[]; + defaultTagReactions?: ApiReaction[]; + allAvailableReactions?: ApiAvailableReaction[]; + currentReactions?: ApiReactionCount[]; + maxUniqueReactions?: number; + isReady?: boolean; + canBuyPremium?: boolean; + isCurrentUserPremium?: boolean; + canPlayAnimatedEmojis?: boolean; + className?: string; + isInSavedMessages?: boolean; + isInStoryViewer?: boolean; + onClose?: NoneToVoidFunction; + onToggleReaction: (reaction: ApiReaction) => void; + onShowMore: (position: IAnchorPosition) => void; +}; + +const cn = createClassNameBuilder('ReactionSelector'); +const REACTIONS_AMOUNT = 7; +const FADE_IN_DELAY = 20; + +const ReactionSelector: FC = ({ + allAvailableReactions, + topReactions, + defaultTagReactions, + enabledReactions, + currentReactions, + maxUniqueReactions, + isPrivate, + isReady, + canPlayAnimatedEmojis, + className, + isCurrentUserPremium, + isInSavedMessages, + isInStoryViewer, + onClose, + onToggleReaction, + onShowMore, +}) => { + const { openPremiumModal } = getActions(); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + const lang = useLang(); + + const areReactionsLocked = isInSavedMessages && !isCurrentUserPremium && !isInStoryViewer; + + const availableReactions = useMemo(() => { + const reactions = isInSavedMessages ? defaultTagReactions + : (enabledReactions?.type === 'some' ? enabledReactions.allowed + : allAvailableReactions?.map((reaction) => reaction.reaction)); + const filteredReactions = reactions?.map((reaction) => { + const isCustomReaction = 'documentId' in reaction; + const availableReaction = allAvailableReactions?.find((r) => isSameReaction(r.reaction, reaction)); + if ((!isCustomReaction && !availableReaction) || availableReaction?.isInactive) return undefined; + + if (!isPrivate && (!enabledReactions || !canSendReaction(reaction, enabledReactions))) { + return undefined; + } + + if (maxUniqueReactions && currentReactions && currentReactions.length >= maxUniqueReactions + && !currentReactions.some(({ reaction: currentReaction }) => isSameReaction(reaction, currentReaction))) { + return undefined; + } + + return isCustomReaction ? reaction : availableReaction; + }).filter(Boolean) || []; + + return sortReactions(filteredReactions, topReactions); + }, [ + allAvailableReactions, currentReactions, defaultTagReactions, enabledReactions, isInSavedMessages, isPrivate, + maxUniqueReactions, topReactions, + ]); + + const reactionsToRender = useMemo(() => { + // Component can fit one more if we do not need show more button + return availableReactions.length === REACTIONS_AMOUNT + 1 + ? availableReactions + : availableReactions.slice(0, REACTIONS_AMOUNT); + }, [availableReactions]); + const withMoreButton = reactionsToRender.length < availableReactions.length; + + const userReactionIndexes = useMemo(() => { + const chosenReactions = currentReactions?.filter(({ chosenOrder }) => chosenOrder !== undefined) || []; + return new Set(chosenReactions.map(({ reaction }) => ( + reactionsToRender.findIndex((r) => r && isSameReaction('reaction' in r ? r.reaction : r, reaction)) + ))); + }, [currentReactions, reactionsToRender]); + + const handleShowMoreClick = useLastCallback(() => { + const bound = ref.current?.getBoundingClientRect() || { x: 0, y: 0 }; + onShowMore({ + x: bound.x, + y: bound.y, + }); + }); + + const handleOpenPremiumModal = useLastCallback(() => { + onClose?.(); + openPremiumModal({ + initialSection: 'saved_tags', + }); + }); + + const hintText = useMemo(() => { + if (isInSavedMessages) { + if (!isCurrentUserPremium) { + const text = lang('lng_subscribe_tag_about'); + const parts = text.split('{link}'); + return ( + + {parts[0]} + + {lang('lng_subscribe_tag_link')} + + {parts[1]} + + ); + } + + return lang('SavedTagReactionsHint2'); + } + + if (isInStoryViewer) { + return lang('StoryReactionsHint'); + } + + return undefined; + }, [isCurrentUserPremium, isInSavedMessages, isInStoryViewer, lang]); + + if (!reactionsToRender.length) return undefined; + + return ( +
+
+
+
+
+ {hintText &&
{hintText}
} +
+ {reactionsToRender.map((reaction, i) => ( + 'reaction' in reaction ? ( + + ) : ( + + ) + ))} + {withMoreButton && ( + + )} +
+
+
+
+ ); +}; + +export default memo(ReactionSelector); diff --git a/src/components/middle/message/ReactionSelectorCustomReaction.tsx b/src/components/middle/message/reactions/ReactionSelectorCustomReaction.tsx similarity index 50% rename from src/components/middle/message/ReactionSelectorCustomReaction.tsx rename to src/components/middle/message/reactions/ReactionSelectorCustomReaction.tsx index 95fc7bc22..447bf8b7a 100644 --- a/src/components/middle/message/ReactionSelectorCustomReaction.tsx +++ b/src/components/middle/message/reactions/ReactionSelectorCustomReaction.tsx @@ -1,14 +1,15 @@ -import type { FC } from '../../../lib/teact/teact'; -import React, { memo } from '../../../lib/teact/teact'; +import type { FC } from '../../../../lib/teact/teact'; +import React, { memo } from '../../../../lib/teact/teact'; -import type { ApiReaction, ApiReactionCustomEmoji } from '../../../api/types'; +import type { ApiReaction, ApiReactionCustomEmoji } from '../../../../api/types'; -import { createClassNameBuilder } from '../../../util/buildClassName'; -import { REM } from '../../common/helpers/mediaDimensions'; +import buildClassName from '../../../../util/buildClassName'; +import { REM } from '../../../common/helpers/mediaDimensions'; -import CustomEmoji from '../../common/CustomEmoji'; +import CustomEmoji from '../../../common/CustomEmoji'; +import Icon from '../../../common/Icon'; -import './ReactionSelectorReaction.scss'; +import styles from './ReactionSelectorReaction.module.scss'; const REACTION_SIZE = 2 * REM; @@ -18,17 +19,17 @@ type OwnProps = { isReady?: boolean; noAppearAnimation?: boolean; style?: string; + isLocked?: boolean; onToggleReaction: (reaction: ApiReaction) => void; }; -const cn = createClassNameBuilder('ReactionSelectorReaction'); - const ReactionSelectorCustomReaction: FC = ({ reaction, chosen, isReady, noAppearAnimation, style, + isLocked, onToggleReaction, }) => { function handleClick() { @@ -37,12 +38,12 @@ const ReactionSelectorCustomReaction: FC = ({ return (
= ({ documentId={reaction.documentId} size={REACTION_SIZE} /> + {isLocked && ( + + )}
); }; diff --git a/src/components/middle/message/reactions/ReactionSelectorReaction.module.scss b/src/components/middle/message/reactions/ReactionSelectorReaction.module.scss new file mode 100644 index 000000000..abbe5bab0 --- /dev/null +++ b/src/components/middle/message/reactions/ReactionSelectorReaction.module.scss @@ -0,0 +1,73 @@ +.root { + --custom-emoji-size: 2rem; + + margin-inline-start: 0.25rem; + position: relative; + min-width: 2rem; + min-height: 2rem; + + &:first-child { + margin-inline-start: 0; + } + + :global(.AnimatedSticker) { + position: absolute; + top: 0; + left: 0; + } +} + +.custom { + opacity: 0; +} + +.visible { + opacity: 1; +} + +.custom-animated { + animation: custom-fade-in 0.2s ease-in-out forwards; + animation-delay: var(--_animation-delay); +} + +.chosen::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 2.25rem; + height: 2.25rem; + border-radius: 50%; + background-color: var(--color-background-compact-menu-hover); +} + +.static-icon { + position: absolute; + top: 5%; + left: 5%; + width: 90%; + height: 90%; +} + +.lock { + font-size: 0.875rem; + padding: 0.125rem; + + position: absolute; + right: 0; + bottom: 0; + background-color: var(--color-background-compact-menu); + border-radius: 50%; +} + +@keyframes custom-fade-in { + 0% { + transform: scale(0.5); + opacity: 0; + } + 100% { + transform: scale(1); + opacity: 1; + } +} diff --git a/src/components/middle/message/ReactionSelectorReaction.tsx b/src/components/middle/message/reactions/ReactionSelectorReaction.tsx similarity index 72% rename from src/components/middle/message/ReactionSelectorReaction.tsx rename to src/components/middle/message/reactions/ReactionSelectorReaction.tsx index 493129f37..9c6419a95 100644 --- a/src/components/middle/message/ReactionSelectorReaction.tsx +++ b/src/components/middle/message/reactions/ReactionSelectorReaction.tsx @@ -1,17 +1,18 @@ -import type { FC } from '../../../lib/teact/teact'; -import React, { memo } from '../../../lib/teact/teact'; +import type { FC } from '../../../../lib/teact/teact'; +import React, { memo } from '../../../../lib/teact/teact'; -import type { ApiAvailableReaction, ApiReaction } from '../../../api/types'; +import type { ApiAvailableReaction, ApiReaction } from '../../../../api/types'; -import { createClassNameBuilder } from '../../../util/buildClassName'; -import { REM } from '../../common/helpers/mediaDimensions'; +import buildClassName from '../../../../util/buildClassName'; +import { REM } from '../../../common/helpers/mediaDimensions'; -import useFlag from '../../../hooks/useFlag'; -import useMedia from '../../../hooks/useMedia'; +import useFlag from '../../../../hooks/useFlag'; +import useMedia from '../../../../hooks/useMedia'; -import AnimatedSticker from '../../common/AnimatedSticker'; +import AnimatedSticker from '../../../common/AnimatedSticker'; +import Icon from '../../../common/Icon'; -import './ReactionSelectorReaction.scss'; +import styles from './ReactionSelectorReaction.module.scss'; const REACTION_SIZE = 2 * REM; @@ -20,16 +21,16 @@ type OwnProps = { isReady?: boolean; chosen?: boolean; noAppearAnimation?: boolean; + isLocked?: boolean; onToggleReaction: (reaction: ApiReaction) => void; }; -const cn = createClassNameBuilder('ReactionSelectorReaction'); - const ReactionSelectorReaction: FC = ({ reaction, isReady, noAppearAnimation, chosen, + isLocked, onToggleReaction, }) => { const mediaAppearData = useMedia(`sticker${reaction.appearAnimation?.id}`, !isReady || noAppearAnimation); @@ -46,13 +47,13 @@ const ReactionSelectorReaction: FC = ({ return (
{noAppearAnimation && ( {reaction.reaction.emoticon} = ({ forceAlways /> )} + {isLocked && ( + + )}
); }; diff --git a/src/components/middle/message/reactions/Reactions.scss b/src/components/middle/message/reactions/Reactions.scss new file mode 100644 index 000000000..8e2f6d172 --- /dev/null +++ b/src/components/middle/message/reactions/Reactions.scss @@ -0,0 +1,31 @@ +.Reactions { + display: flex; + flex-direction: row; + width: 100%; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.375rem; + overflow: visible; + max-width: calc(var(--max-width) + 2.25rem); + + &.is-outside { + margin-top: 0.125rem; + } + + .own &.is-outside { + flex-direction: row-reverse; + } + + .theme-light &.is-outside .message-reaction { + --reaction-background: var(--pattern-color); + --reaction-background-hover: var(--pattern-color); + --reaction-background-hover-filter: brightness(115%); + --reaction-text-color: white; + + &.chosen { + --reaction-background: rgb(255, 255, 255, 0.6); + --reaction-background-hover: rgb(255, 255, 255, 0.75); + --reaction-text-color: rgb(62 62 62); + } + } +} diff --git a/src/components/middle/message/reactions/Reactions.tsx b/src/components/middle/message/reactions/Reactions.tsx new file mode 100644 index 000000000..d383ace47 --- /dev/null +++ b/src/components/middle/message/reactions/Reactions.tsx @@ -0,0 +1,172 @@ +import type { FC } from '../../../../lib/teact/teact'; +import React, { memo, useMemo } from '../../../../lib/teact/teact'; +import { getActions, getGlobal } from '../../../../global'; + +import type { + ApiMessage, + ApiPeer, + ApiReaction, + ApiReactionKey, + ApiSavedReactionTag, +} from '../../../../api/types'; +import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; + +import { getReactionKey, isReactionChosen } from '../../../../global/helpers'; +import { selectPeer } from '../../../../global/selectors'; +import buildClassName from '../../../../util/buildClassName'; +import { getMessageKey } from '../../../../util/messageKey'; + +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; + +import ReactionButton from './ReactionButton'; +import SavedTagButton from './SavedTagButton'; + +import './Reactions.scss'; + +type OwnProps = { + message: ApiMessage; + isOutside?: boolean; + maxWidth?: number; + metaChildren?: React.ReactNode; + tags?: Record; + isCurrentUserPremium?: boolean; + observeIntersection?: ObserveFn; + noRecentReactors?: boolean; +}; + +const MAX_RECENT_AVATARS = 3; + +const Reactions: FC = ({ + message, + isOutside, + maxWidth, + metaChildren, + observeIntersection, + noRecentReactors, + isCurrentUserPremium, + tags, +}) => { + const { + toggleReaction, + setLocalTextSearchTag, + searchTextMessagesLocal, + openPremiumModal, + } = getActions(); + const lang = useLang(); + + const { results, areTags, recentReactions } = message.reactions!; + + const totalCount = useMemo(() => ( + results.reduce((acc, reaction) => acc + reaction.count, 0) + ), [results]); + + const recentReactorsByReactionKey = useMemo(() => { + const global = getGlobal(); + + return recentReactions?.reduce((acc, recentReaction) => { + const { reaction, peerId } = recentReaction; + const key = getReactionKey(reaction); + const peer = selectPeer(global, peerId); + + if (!peer) return acc; + + const peers = acc[key] || []; + peers.push(peer); + acc[key] = peers; + return acc; + }, {} as Record); + }, [recentReactions]); + + const props = useMemo(() => { + const messageKey = getMessageKey(message); + return results.map((reaction) => { + const reactionKey = getReactionKey(reaction.reaction); + const recentReactors = recentReactorsByReactionKey?.[reactionKey]; + const shouldHideRecentReactors = totalCount > MAX_RECENT_AVATARS || noRecentReactors; + const tag = areTags ? tags?.[reactionKey] : undefined; + + return { + reaction, + reactionKey, + messageKey, + recentReactors: !shouldHideRecentReactors ? recentReactors : undefined, + isChosen: isReactionChosen(reaction), + tag, + }; + }); + }, [message, noRecentReactors, recentReactorsByReactionKey, results, areTags, tags, totalCount]); + + const handleClick = useLastCallback((reaction: ApiReaction) => { + if (areTags) { + if (!isCurrentUserPremium) { + openPremiumModal({ + initialSection: 'saved_tags', + }); + return; + } + + setLocalTextSearchTag({ tag: reaction }); + searchTextMessagesLocal(); + return; + } + + toggleReaction({ + chatId: message.chatId, + messageId: message.id, + reaction, + }); + }); + + const handleRemoveReaction = useLastCallback((reaction: ApiReaction) => { + toggleReaction({ + chatId: message.chatId, + messageId: message.id, + reaction, + }); + }); + + return ( +
+ {props.map(({ + reaction, recentReactors, messageKey, reactionKey, isChosen, tag, + }) => ( + areTags ? ( + + ) : ( + + ) + ))} + {metaChildren} +
+ ); +}; + +export default memo(Reactions); diff --git a/src/components/middle/message/reactions/SavedTagButton.tsx b/src/components/middle/message/reactions/SavedTagButton.tsx new file mode 100644 index 000000000..b9641610e --- /dev/null +++ b/src/components/middle/message/reactions/SavedTagButton.tsx @@ -0,0 +1,193 @@ +import type { FC } from '../../../../lib/teact/teact'; +import React, { memo, useRef } from '../../../../lib/teact/teact'; +import { getActions } from '../../../../global'; + +import type { + ApiReaction, ApiSavedReactionTag, +} from '../../../../api/types'; +import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; + +import buildClassName from '../../../../util/buildClassName'; +import { REM } from '../../../common/helpers/mediaDimensions'; + +import useContextMenuHandlers from '../../../../hooks/useContextMenuHandlers'; +import useFlag from '../../../../hooks/useFlag'; +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; +import useMenuPosition from '../../../../hooks/useMenuPosition'; + +import ReactionAnimatedEmoji from '../../../common/reactions/ReactionAnimatedEmoji'; +import PromptDialog from '../../../modals/prompt/PromptDialog'; +import Button from '../../../ui/Button'; +import Menu from '../../../ui/Menu'; +import MenuItem from '../../../ui/MenuItem'; + +import styles from './ReactionButton.module.scss'; + +const REACTION_SIZE = 1.25 * REM; +const TITLE_MAX_LENGTH = 15; + +const SavedTagButton: FC<{ + reaction: ApiReaction; + tag?: ApiSavedReactionTag; + containerId: string; + isChosen?: boolean; + isOwnMessage?: boolean; + withCount?: boolean; + className?: string; + chosenClassName?: string; + isDisabled?: boolean; + withContextMenu?: boolean; + observeIntersection?: ObserveFn; + onClick?: (reaction: ApiReaction) => void; + onRemove?: (reaction: ApiReaction) => void; +}> = ({ + reaction, + tag, + containerId, + isChosen, + isOwnMessage, + className, + chosenClassName, + withCount, + isDisabled, + withContextMenu, + observeIntersection, + onClick, + onRemove, +}) => { + const { editSavedReactionTag } = getActions(); + // eslint-disable-next-line no-null/no-null + const ref = useRef(null); + // eslint-disable-next-line no-null/no-null + const menuRef = useRef(null); + + const lang = useLang(); + const [isRenamePromptOpen, openRenamePrompt, closeRenamePrompt] = useFlag(); + + const { title, count } = tag || {}; + const hasText = Boolean(title || (withCount && count)); + + const handleClick = useLastCallback(() => { + onClick?.(reaction); + }); + + const handleRemoveClick = useLastCallback(() => { + onRemove?.(reaction); + }); + + const handleRenameTag = useLastCallback((newTitle: string) => { + editSavedReactionTag({ + reaction, + title: newTitle, + }); + closeRenamePrompt(); + }); + + const { + isContextMenuOpen, + contextMenuPosition, + handleBeforeContextMenu, + handleContextMenu, + handleContextMenuClose, + handleContextMenuHide, + } = useContextMenuHandlers(ref, !withContextMenu); + + const getTriggerElement = useLastCallback(() => ref.current); + const getRootElement = useLastCallback(() => document.body); + const getMenuElement = useLastCallback(() => menuRef.current); + + const getLayout = () => ({ withPortal: true, shouldAvoidNegativePosition: true }); + + const { + positionX, positionY, transformOriginX, transformOriginY, style: menuStyle, + } = useMenuPosition( + contextMenuPosition, + getTriggerElement, + getRootElement, + getMenuElement, + getLayout, + ); + + if (withCount && count === 0) { + return undefined; + } + + return ( + + ); +}; + +export default memo(SavedTagButton); diff --git a/src/components/modals/prompt/PromptDialog.module.scss b/src/components/modals/prompt/PromptDialog.module.scss new file mode 100644 index 000000000..acce79357 --- /dev/null +++ b/src/components/modals/prompt/PromptDialog.module.scss @@ -0,0 +1,3 @@ +.subtitle { + margin-bottom: 1rem; +} diff --git a/src/components/modals/prompt/PromptDialog.tsx b/src/components/modals/prompt/PromptDialog.tsx new file mode 100644 index 000000000..83639d597 --- /dev/null +++ b/src/components/modals/prompt/PromptDialog.tsx @@ -0,0 +1,79 @@ +import React, { memo, useState } from '../../../lib/teact/teact'; + +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import Button from '../../ui/Button'; +import InputText from '../../ui/InputText'; +import Modal from '../../ui/Modal'; + +import styles from './PromptDialog.module.scss'; + +export type OwnProps = { + isOpen: boolean; + title: string; + subtitle?: React.ReactNode; + placeholder: string; + submitText?: string; + maxLength?: number; + initialValue?: string; + onClose: NoneToVoidFunction; + onSubmit: (text: string) => void; +}; + +const PromptDialog = ({ + isOpen, + title, + subtitle, + placeholder, + submitText, + maxLength, + initialValue = '', + onClose, + onSubmit, +}: OwnProps) => { + const lang = useLang(); + + const [text, setText] = useState(initialValue); + + const handleTextChange = useLastCallback((e: React.ChangeEvent) => { + setText(e.target.value); + }); + + const handleSubmit = useLastCallback(() => { + onSubmit(text); + }); + + return ( + + {Boolean(subtitle) && ( +
+ {subtitle} +
+ )} + +
+ + +
+
+ ); +}; + +export default memo(PromptDialog); diff --git a/src/components/right/RightSearch.scss b/src/components/right/RightSearch.scss index df0b507dc..a3616a2a4 100644 --- a/src/components/right/RightSearch.scss +++ b/src/components/right/RightSearch.scss @@ -11,4 +11,19 @@ unicode-bidi: plaintext; text-align: initial; } + + .search-tags { + --color-reaction: var(--color-background-secondary); + --hover-color-reaction: var(--color-background-secondary-accent); + --text-color-reaction: var(--color-text-secondary); + --color-reaction-chosen: var(--color-primary); + --text-color-reaction-chosen: #FFFFFF; + --hover-color-reaction-chosen: var(--color-primary-shade); + + display: flex; + overflow-x: scroll; + + margin-top: 0.25rem; + gap: 0.375rem; + } } diff --git a/src/components/right/RightSearch.tsx b/src/components/right/RightSearch.tsx index cb9d4b14c..4fb0b22a5 100644 --- a/src/components/right/RightSearch.tsx +++ b/src/components/right/RightSearch.tsx @@ -1,33 +1,40 @@ import type { FC } from '../../lib/teact/teact'; import React, { - memo, useCallback, - useEffect, useMemo, useRef, + memo, useEffect, useMemo, useRef, } from '../../lib/teact/teact'; import { getActions, getGlobal, withGlobal } from '../../global'; -import type { ApiMessage, ApiPeer } from '../../api/types'; +import type { + ApiMessage, ApiPeer, ApiReaction, ApiReactionKey, ApiSavedReactionTag, +} from '../../api/types'; import type { ThreadId } from '../../types'; -import { REPLIES_USER_ID } from '../../config'; +import { ANONYMOUS_USER_ID, REPLIES_USER_ID } from '../../config'; +import { getIsSavedDialog, getReactionKey, isSameReaction } from '../../global/helpers'; import { selectChatMessages, selectCurrentTextSearch, selectForwardedSender, selectIsChatWithSelf, + selectIsCurrentUserPremium, selectSender, } from '../../global/selectors'; import { disableDirectTextInput, enableDirectTextInput } from '../../util/directInputManager'; import { MEMO_EMPTY_ARRAY } from '../../util/memo'; +import { debounce } from '../../util/schedulers'; import { renderMessageSummary } from '../common/helpers/renderMessageText'; import useHistoryBack from '../../hooks/useHistoryBack'; +import useHorizontalScroll from '../../hooks/useHorizontalScroll'; import useInfiniteScroll from '../../hooks/useInfiniteScroll'; import useKeyboardListNavigation from '../../hooks/useKeyboardListNavigation'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import Avatar from '../common/Avatar'; import FullNameTitle from '../common/FullNameTitle'; import LastMessageMeta from '../common/LastMessageMeta'; +import SavedTagButton from '../middle/message/reactions/SavedTagButton'; import InfiniteScroll from '../ui/InfiniteScroll'; import ListItem from '../ui/ListItem'; @@ -43,11 +50,16 @@ export type OwnProps = { type StateProps = { messagesById?: Record; query?: string; + savedTags?: Record; + searchTag?: ApiReaction; totalCount?: number; foundIds?: number[]; isSavedMessages?: boolean; + isCurrentUserPremium?: boolean; }; +const runDebouncedForSearch = debounce((cb) => cb(), 200, false); + const RightSearch: FC = ({ chatId, threadId, @@ -56,16 +68,24 @@ const RightSearch: FC = ({ query, totalCount, foundIds, + savedTags, + searchTag, isSavedMessages, + isCurrentUserPremium, onClose, }) => { const { searchTextMessagesLocal, + setLocalTextSearchTag, focusMessage, + openPremiumModal, + loadSavedReactionTags, } = getActions(); // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const tagsRef = useRef(null); const lang = useLang(); useHistoryBack({ @@ -83,14 +103,45 @@ const RightSearch: FC = ({ return enableDirectTextInput; }, [isActive]); - const handleSearchTextMessagesLocal = useCallback(() => { - searchTextMessagesLocal(); - }, [searchTextMessagesLocal]); + const tags = useMemo(() => { + if (!savedTags) return undefined; + return Object.values(savedTags); + }, [savedTags]); + + const hasTags = Boolean(tags?.length); + const areTagsDisabled = hasTags && !isCurrentUserPremium; + + useHorizontalScroll(tagsRef, !hasTags); + + useEffect(() => { + if (isActive) loadSavedReactionTags(); + }, [hasTags, isActive]); + + const handleSearchTextMessagesLocal = useLastCallback(() => { + runDebouncedForSearch(searchTextMessagesLocal); + }); + + const handleTagClick = useLastCallback((tag: ApiReaction) => { + if (areTagsDisabled) { + openPremiumModal({ + initialSection: 'saved_tags', + }); + return; + } + + if (isSameReaction(tag, searchTag)) { + setLocalTextSearchTag({ tag: undefined }); + return; + } + + setLocalTextSearchTag({ tag }); + handleSearchTextMessagesLocal(); + }); const [viewportIds, getMore] = useInfiniteScroll(handleSearchTextMessagesLocal, foundIds); const viewportResults = useMemo(() => { - if (!query || !viewportIds?.length || !messagesById) { + if ((!query && !searchTag) || !viewportIds?.length || !messagesById) { return MEMO_EMPTY_ARRAY; } @@ -102,23 +153,22 @@ const RightSearch: FC = ({ const global = getGlobal(); - const originalSender = (isSavedMessages || chatId === REPLIES_USER_ID) + const originalSender = (isSavedMessages || chatId === REPLIES_USER_ID || chatId === ANONYMOUS_USER_ID) ? selectForwardedSender(global, message) : undefined; const messageSender = selectSender(global, message); const senderPeer = originalSender || messageSender; - if (!senderPeer) { - return undefined; - } + const hiddenForwardTitle = message.forwardInfo?.hiddenUserName; return { message, senderPeer, + hiddenForwardTitle, onClick: () => focusMessage({ chatId, threadId, messageId: id }), }; }).filter(Boolean); - }, [query, viewportIds, messagesById, isSavedMessages, chatId, threadId]); + }, [query, searchTag, viewportIds, messagesById, isSavedMessages, chatId, threadId]); const handleKeyDown = useKeyboardListNavigation(containerRef, true, (index) => { const foundResult = viewportResults?.[index === -1 ? 0 : index]; @@ -128,10 +178,11 @@ const RightSearch: FC = ({ }, '.ListItem-button', true); const renderSearchResult = ({ - message, senderPeer, onClick, + message, senderPeer, hiddenForwardTitle, onClick, }: { message: ApiMessage; - senderPeer: ApiPeer; + senderPeer?: ApiPeer; + hiddenForwardTitle?: string; onClick: NoneToVoidFunction; }) => { const text = renderMessageSummary(lang, message, undefined, query); @@ -145,10 +196,12 @@ const RightSearch: FC = ({ >
- + {senderPeer && } + {!senderPeer && hiddenForwardTitle}
@@ -170,6 +223,26 @@ const RightSearch: FC = ({ onLoadMore={getMore} onKeyDown={handleKeyDown} > + {hasTags && ( +
+ {tags.map((tag) => ( + + ))} +
+ )} {isOnTop && (

{!query ? ( @@ -189,16 +262,19 @@ const RightSearch: FC = ({ }; export default memo(withGlobal( - (global, { chatId }): StateProps => { + (global, { chatId, threadId }): StateProps => { const messagesById = selectChatMessages(global, chatId); if (!messagesById) { return {}; } - const { query, results } = selectCurrentTextSearch(global) || {}; + const { query, savedTag, results } = selectCurrentTextSearch(global) || {}; const { totalCount, foundIds } = results || {}; const isSavedMessages = selectIsChatWithSelf(global, chatId); + const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); + + const savedTags = isSavedMessages && !isSavedDialog ? global.savedReactionTags?.byKey : undefined; return { messagesById, @@ -206,6 +282,9 @@ export default memo(withGlobal( totalCount, foundIds, isSavedMessages, + savedTags, + searchTag: savedTag, + isCurrentUserPremium: selectIsCurrentUserPremium(global), }; }, )(RightSearch)); diff --git a/src/components/right/management/ManageChannel.tsx b/src/components/right/management/ManageChannel.tsx index 0d4b8c695..ecaac702e 100644 --- a/src/components/right/management/ManageChannel.tsx +++ b/src/components/right/management/ManageChannel.tsx @@ -376,7 +376,7 @@ export default memo(withGlobal( canChangeInfo: getHasAdminRight(chat, 'changeInfo'), canInvite: getHasAdminRight(chat, 'inviteUsers'), exportedInvites: invites, - availableReactions: global.availableReactions, + availableReactions: global.reactions.availableReactions, }; }, )(ManageChannel)); diff --git a/src/components/right/management/ManageGroup.tsx b/src/components/right/management/ManageGroup.tsx index 001a0581e..da7f72d8b 100644 --- a/src/components/right/management/ManageGroup.tsx +++ b/src/components/right/management/ManageGroup.tsx @@ -509,7 +509,7 @@ export default memo(withGlobal( canInvite: chat.isCreator || getHasAdminRight(chat, 'inviteUsers'), exportedInvites: invites, isChannelsPremiumLimitReached: limitReachedModal?.limit === 'channels', - availableReactions: global.availableReactions, + availableReactions: global.reactions.availableReactions, canEditForum, }; }, diff --git a/src/components/right/management/ManageReactions.tsx b/src/components/right/management/ManageReactions.tsx index 8a951375f..c660bbf2b 100644 --- a/src/components/right/management/ManageReactions.tsx +++ b/src/components/right/management/ManageReactions.tsx @@ -184,7 +184,7 @@ export default memo(withGlobal( return { enabledReactions: selectChatFullInfo(global, chatId)?.enabledReactions, - availableReactions: global.availableReactions, + availableReactions: global.reactions.availableReactions, chat, }; }, diff --git a/src/components/story/StoryView.tsx b/src/components/story/StoryView.tsx index 859298881..d42124569 100644 --- a/src/components/story/StoryView.tsx +++ b/src/components/story/StoryView.tsx @@ -202,6 +202,6 @@ export default memo(withGlobal((global, { storyView }) => { return { peer, - availableReactions: global.availableReactions, + availableReactions: global.reactions.availableReactions, }; })(StoryView)); diff --git a/src/components/story/StoryViewModal.tsx b/src/components/story/StoryViewModal.tsx index c2e86bd06..092fee947 100644 --- a/src/components/story/StoryViewModal.tsx +++ b/src/components/story/StoryViewModal.tsx @@ -287,7 +287,7 @@ export default memo(withGlobal((global) => { story: story && 'content' in story ? story : undefined, nextOffset, isLoading, - availableReactions: global.availableReactions, + availableReactions: global.reactions.availableReactions, isCurrentUserPremium: selectIsCurrentUserPremium(global), }; })(StoryViewModal)); diff --git a/src/components/story/hooks/useStoryPreloader.ts b/src/components/story/hooks/useStoryPreloader.ts index b2d3d9a57..cb078d5d5 100644 --- a/src/components/story/hooks/useStoryPreloader.ts +++ b/src/components/story/hooks/useStoryPreloader.ts @@ -42,6 +42,8 @@ function useStoryPreloader(peerId?: string | string[], aroundStoryId?: number) { format, ) .then((result) => { + if (!result) return; + if (format === ApiMediaFormat.Progressive) { preloadProgressive(result); } diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx index 87c929bfb..908b109ba 100644 --- a/src/components/ui/Button.tsx +++ b/src/components/ui/Button.tsx @@ -205,7 +205,7 @@ const Button: FC = ({ title={ariaLabel} tabIndex={tabIndex} dir={isRtl ? 'rtl' : undefined} - style={buildStyle(style, backgroundImage && `background-image: url(${backgroundImage})`)} + style={buildStyle(style, backgroundImage && `background-image: url(${backgroundImage})`) || undefined} > {isLoading ? (

diff --git a/src/components/ui/Menu.scss b/src/components/ui/Menu.scss index 2ffdb91f2..44731c9d5 100644 --- a/src/components/ui/Menu.scss +++ b/src/components/ui/Menu.scss @@ -112,5 +112,6 @@ &.in-portal { z-index: var(--z-portal-menu); + position: absolute; } } diff --git a/src/global/actions/api/localSearch.ts b/src/global/actions/api/localSearch.ts index 4a0ae58a6..4e8490474 100644 --- a/src/global/actions/api/localSearch.ts +++ b/src/global/actions/api/localSearch.ts @@ -6,7 +6,7 @@ import { MESSAGE_SEARCH_SLICE, SHARED_MEDIA_SLICE } from '../../../config'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { buildCollectionByKey } from '../../../util/iteratees'; import { callApi } from '../../../api/gramjs'; -import { getIsSavedDialog } from '../../helpers'; +import { getIsSavedDialog, isSameReaction } from '../../helpers'; import { addActionHandler, getGlobal, setGlobal, } from '../../index'; @@ -36,14 +36,14 @@ addActionHandler('searchTextMessagesLocal', async (global, actions, payload): Pr const chat = realChatId ? selectChat(global, realChatId) : undefined; let currentSearch = selectCurrentTextSearch(global, tabId); - if (!chat || !currentSearch || !threadId) { + if (!chat || !threadId || !currentSearch) { return; } - const { query, results } = currentSearch; + const { query, results, savedTag } = currentSearch; const offsetId = results?.nextOffsetId; - if (!query) { + if (!query && !savedTag) { return; } @@ -55,6 +55,7 @@ addActionHandler('searchTextMessagesLocal', async (global, actions, payload): Pr limit: MESSAGE_SEARCH_SLICE, offsetId, isSavedDialog, + savedTag, }); if (!result) { @@ -71,7 +72,7 @@ addActionHandler('searchTextMessagesLocal', async (global, actions, payload): Pr global = getGlobal(); currentSearch = selectCurrentTextSearch(global, tabId); - if (!currentSearch || query !== currentSearch.query) { + if (!currentSearch || query !== currentSearch.query || !isSameReaction(savedTag, currentSearch.savedTag)) { return; } diff --git a/src/global/actions/api/reactions.ts b/src/global/actions/api/reactions.ts index 05be89a8e..793acf65f 100644 --- a/src/global/actions/api/reactions.ts +++ b/src/global/actions/api/reactions.ts @@ -3,13 +3,14 @@ import { ApiMediaFormat } from '../../../api/types'; import { GENERAL_REFETCH_INTERVAL } from '../../../config'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; -import { buildCollectionByKey, omit } from '../../../util/iteratees'; +import { buildCollectionByCallback, buildCollectionByKey, omit } from '../../../util/iteratees'; import * as mediaLoader from '../../../util/mediaLoader'; import { getMessageKey } from '../../../util/messageKey'; import requestActionTimeout from '../../../util/requestActionTimeout'; import { callApi } from '../../../api/gramjs'; import { getDocumentMediaHash, + getReactionKey, getUserReactions, isMessageLocal, isSameReaction, @@ -37,7 +38,7 @@ const INTERACTION_RANDOM_OFFSET = 40; let interactionLocalId = 0; addActionHandler('loadAvailableReactions', async (global): Promise => { - const result = await callApi('getAvailableReactions'); + const result = await callApi('fetchAvailableReactions'); if (!result) { return; } @@ -61,7 +62,10 @@ addActionHandler('loadAvailableReactions', async (global): Promise => { global = getGlobal(); global = { ...global, - availableReactions: result, + reactions: { + ...global.reactions, + availableReactions: result, + }, }; setGlobal(global); @@ -144,6 +148,8 @@ addActionHandler('toggleReaction', async (global, actions, payload): Promise => { - const result = await callApi('fetchTopReactions', {}); + const result = await callApi('fetchTopReactions', { + hash: global.reactions.hash.topReactions, + }); if (!result) { return; } @@ -454,13 +466,22 @@ addActionHandler('loadTopReactions', async (global): Promise => { global = getGlobal(); global = { ...global, - topReactions: result.reactions, + reactions: { + ...global.reactions, + topReactions: result.reactions, + hash: { + ...global.reactions.hash, + topReactions: result.hash, + }, + }, }; setGlobal(global); }); addActionHandler('loadRecentReactions', async (global): Promise => { - const result = await callApi('fetchRecentReactions', {}); + const result = await callApi('fetchRecentReactions', { + hash: global.reactions.hash.recentReactions, + }); if (!result) { return; } @@ -468,7 +489,14 @@ addActionHandler('loadRecentReactions', async (global): Promise => { global = getGlobal(); global = { ...global, - recentReactions: result.reactions, + reactions: { + ...global.reactions, + recentReactions: result.reactions, + hash: { + ...global.reactions.hash, + recentReactions: result.hash, + }, + }, }; setGlobal(global); }); @@ -482,7 +510,89 @@ addActionHandler('clearRecentReactions', async (global): Promise => { global = getGlobal(); global = { ...global, - recentReactions: [], + reactions: { + ...global.reactions, + recentReactions: [], + }, + }; + setGlobal(global); +}); + +addActionHandler('loadDefaultTagReactions', async (global): Promise => { + const result = await callApi('fetchDefaultTagReactions', { + hash: global.reactions.hash.defaultTags, + }); + if (!result) { + return; + } + + global = getGlobal(); + global = { + ...global, + reactions: { + ...global.reactions, + defaultTags: result.reactions, + hash: { + ...global.reactions.hash, + defaultTags: result.hash, + }, + }, + }; + setGlobal(global); +}); + +addActionHandler('loadSavedReactionTags', async (global): Promise => { + const { hash } = global.savedReactionTags || {}; + + const result = await callApi('fetchSavedReactionTags', { hash }); + if (!result) { + return; + } + + global = getGlobal(); + + const tagsByKey = buildCollectionByCallback(result.tags, (tag) => ([getReactionKey(tag.reaction), tag])); + + global = { + ...global, + savedReactionTags: { + hash: result.hash, + byKey: tagsByKey, + }, + }; + setGlobal(global); +}); + +addActionHandler('editSavedReactionTag', async (global, actions, payload): Promise => { + const { reaction, title } = payload; + + const result = await callApi('updateSavedReactionTag', { reaction, title }); + + if (!result) { + return; + } + + global = getGlobal(); + const tagsByKey = global.savedReactionTags?.byKey; + if (!tagsByKey) return; + + const key = getReactionKey(reaction); + const tag = tagsByKey[key]; + + const newTag = { + ...tag, + title, + }; + + global = { + ...global, + savedReactionTags: { + ...global.savedReactionTags!, + byKey: { + ...tagsByKey, + [key]: newTag, + }, + }, }; setGlobal(global); }); diff --git a/src/global/actions/apiUpdaters/misc.ts b/src/global/actions/apiUpdaters/misc.ts index 426f95c1c..c7a6597ac 100644 --- a/src/global/actions/apiUpdaters/misc.ts +++ b/src/global/actions/apiUpdaters/misc.ts @@ -63,6 +63,10 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => { actions.loadRecentEmojiStatuses(); break; + case 'updateSavedReactionTags': + actions.loadSavedReactionTags(); + break; + case 'updateMoveStickerSetToTop': { const oldOrder = update.isCustomEmoji ? global.customEmojis.added.setIds : global.stickers.added.setIds; if (!oldOrder) return global; diff --git a/src/global/actions/ui/localSearch.ts b/src/global/actions/ui/localSearch.ts index d687f4ef9..92dc1fd16 100644 --- a/src/global/actions/ui/localSearch.ts +++ b/src/global/actions/ui/localSearch.ts @@ -2,12 +2,13 @@ import type { ActionReturnType, GlobalState, TabArgs } from '../../types'; import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; -import { buildChatThreadKey } from '../../helpers'; +import { buildChatThreadKey, isSameReaction } from '../../helpers'; import { addActionHandler } from '../../index'; import { replaceLocalTextSearchResults, updateLocalMediaSearchType, updateLocalTextSearch, + updateLocalTextSearchTag, } from '../../reducers'; import { selectCurrentMessageList, selectTabState } from '../../selectors'; @@ -18,7 +19,7 @@ addActionHandler('openLocalTextSearch', (global, actions, payload): ActionReturn return undefined; } - return updateLocalTextSearch(global, chatId, threadId, true, undefined, tabId); + return updateLocalTextSearch(global, chatId, threadId, '', tabId); }); addActionHandler('closeLocalTextSearch', (global, actions, payload): ActionReturnType => { @@ -41,7 +42,27 @@ addActionHandler('setLocalTextSearchQuery', (global, actions, payload): ActionRe global = replaceLocalTextSearchResults(global, chatId, threadId, MEMO_EMPTY_ARRAY, undefined, undefined, tabId); } - global = updateLocalTextSearch(global, chatId, threadId, true, query, tabId); + global = updateLocalTextSearch(global, chatId, threadId, query, tabId); + + return global; +}); + +addActionHandler('setLocalTextSearchTag', (global, actions, payload): ActionReturnType => { + const { tag, tabId = getCurrentTabId() } = payload!; + + const { chatId, threadId } = selectCurrentMessageList(global, tabId) || {}; + if (!chatId || !threadId) { + return undefined; + } + + const chatThreadKey = buildChatThreadKey(chatId, threadId); + const { savedTag } = selectTabState(global, tabId).localTextSearch.byChatThreadKey[chatThreadKey] || {}; + + if (!isSameReaction(tag, savedTag)) { + global = replaceLocalTextSearchResults(global, chatId, threadId, MEMO_EMPTY_ARRAY, undefined, undefined, tabId); + } + + global = updateLocalTextSearchTag(global, chatId, threadId, tag, tabId); return global; }); @@ -65,7 +86,8 @@ export function closeLocalTextSearch( return global; } - global = updateLocalTextSearch(global, chatId, threadId, false, undefined, tabId); + global = updateLocalTextSearchTag(global, chatId, threadId, undefined, tabId); + global = updateLocalTextSearch(global, chatId, threadId, undefined, tabId); global = replaceLocalTextSearchResults(global, chatId, threadId, undefined, undefined, undefined, tabId); return global; } diff --git a/src/global/cache.ts b/src/global/cache.ts index eb4ee2f0a..dd78f5245 100644 --- a/src/global/cache.ts +++ b/src/global/cache.ts @@ -1,7 +1,7 @@ /* eslint-disable eslint-multitab-tt/no-immediate-global */ import { addCallback, removeCallback } from '../lib/teact/teactn'; -import type { ApiMessage } from '../api/types'; +import type { ApiAvailableReaction, ApiMessage } from '../api/types'; import type { ActionReturnType, GlobalState, MessageList } from './types'; import { MAIN_THREAD_ID } from '../api/types'; @@ -181,15 +181,6 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) { cached.appConfig.limits = DEFAULT_LIMITS; } - if (typeof cached.config?.defaultReaction === 'string') { - cached.config.defaultReaction = { emoticon: cached.config.defaultReaction }; - } - - if (typeof cached.availableReactions?.[0].reaction === 'string') { - cached.availableReactions = cached.availableReactions - .map((r) => ({ ...r, reaction: { emoticon: r.reaction as unknown as string } })); - } - if (!cached.archiveSettings) { cached.archiveSettings = initialState.archiveSettings; } @@ -224,6 +215,10 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) { if (!cached.fileUploads.byMessageKey) { cached.fileUploads.byMessageKey = {}; } + + if (!cached.reactions) { + cached.reactions = initialState.reactions; + } } function updateCache() { @@ -269,8 +264,6 @@ export function serializeGlobal(global: T) { 'topInlineBots', 'recentEmojis', 'recentCustomEmojis', - 'topReactions', - 'recentReactions', 'push', 'serviceNotifications', 'attachmentSettings', @@ -282,6 +275,7 @@ export function serializeGlobal(global: T) { 'trustedBotIds', 'recentlyFoundChatIds', 'peerColors', + 'savedReactionTags', ]), lastIsChatInfoShown: !getIsMobile() ? global.lastIsChatInfoShown : undefined, customEmojis: reduceCustomEmojis(global), @@ -291,7 +285,15 @@ export function serializeGlobal(global: T) { settings: reduceSettings(global), chatFolders: reduceChatFolders(global), groupCalls: reduceGroupCalls(global), - availableReactions: reduceAvailableReactions(global), + reactions: { + ...pick(global.reactions, [ + 'defaultTags', + 'recentReactions', + 'topReactions', + 'hash', + ]), + availableReactions: reduceAvailableReactions(global.reactions.availableReactions), + }, passcode: pick(global.passcode, [ 'isScreenLocked', 'hasPasscode', @@ -536,7 +538,7 @@ function reduceGroupCalls(global: T): GlobalState['groupC }; } -function reduceAvailableReactions(global: GlobalState): GlobalState['availableReactions'] { - return global.availableReactions +function reduceAvailableReactions(availableReactions?: ApiAvailableReaction[]): ApiAvailableReaction[] | undefined { + return availableReactions ?.map((r) => pick(r, ['reaction', 'staticIcon', 'title', 'isInactive'])); } diff --git a/src/global/helpers/reactions.ts b/src/global/helpers/reactions.ts index 173d0251f..f4c31152d 100644 --- a/src/global/helpers/reactions.ts +++ b/src/global/helpers/reactions.ts @@ -4,6 +4,7 @@ import type { ApiMessage, ApiReaction, ApiReactionCount, + ApiReactionKey, ApiReactions, } from '../../api/types'; import type { GlobalState } from '../types'; @@ -22,20 +23,20 @@ export function areReactionsEmpty(reactions: ApiReactions) { return !reactions.results.some(({ count }) => count > 0); } +export function getReactionKey(reaction: ApiReaction): ApiReactionKey { + if ('emoticon' in reaction) { + return `emoji-${reaction.emoticon}`; + } + + return `document-${reaction.documentId}`; +} + export function isSameReaction(first?: ApiReaction, second?: ApiReaction) { if (!first || !second) { return false; } - if ('emoticon' in first && 'emoticon' in second) { - return first.emoticon === second.emoticon; - } - - if ('documentId' in first && 'documentId' in second) { - return first.documentId === second.documentId; - } - - return false; + return getReactionKey(first) === getReactionKey(second); } export function canSendReaction(reaction: ApiReaction, chatReactions: ApiChatReactions) { @@ -71,14 +72,6 @@ export function getUserReactions(message: ApiMessage): ApiReaction[] { .map((r) => r.reaction) || []; } -export function getReactionUniqueKey(reaction: ApiReaction) { - if ('emoticon' in reaction) { - return reaction.emoticon; - } - - return reaction.documentId; -} - export function isReactionChosen(reaction: ApiReactionCount) { return reaction.chosenOrder !== undefined; } diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 50b26b39f..760659b0d 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -149,8 +149,13 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { recentEmojis: ['grinning', 'kissing_heart', 'christmas_tree', 'brain', 'trophy', 'duck', 'cherries'], recentCustomEmojis: ['5377305978079288312'], - topReactions: [], - recentReactions: [], + + reactions: { + defaultTags: [], + topReactions: [], + recentReactions: [], + hash: {}, + }, stickers: { setsById: {}, diff --git a/src/global/reducers/localSearch.ts b/src/global/reducers/localSearch.ts index 713bc7548..3d37e7783 100644 --- a/src/global/reducers/localSearch.ts +++ b/src/global/reducers/localSearch.ts @@ -1,4 +1,4 @@ -import type { ApiMessageSearchType } from '../../api/types'; +import type { ApiMessageSearchType, ApiReaction } from '../../api/types'; import type { SharedMediaType, ThreadId } from '../../types'; import type { GlobalState, TabArgs } from '../types'; @@ -9,8 +9,8 @@ import { selectTabState } from '../selectors'; import { updateTabState } from './tabs'; interface TextSearchParams { - isActive: boolean; query?: string; + savedTag?: ApiReaction; results?: { totalCount?: number; nextOffsetId?: number; @@ -47,7 +47,6 @@ export function updateLocalTextSearch( global: T, chatId: string, threadId: ThreadId, - isActive: boolean, query?: string, ...[tabId = getCurrentTabId()]: TabArgs ): T { @@ -55,11 +54,29 @@ export function updateLocalTextSearch( return replaceLocalTextSearch(global, chatThreadKey, { ...selectTabState(global, tabId).localTextSearch.byChatThreadKey[chatThreadKey], - isActive, query, }, tabId); } +export function updateLocalTextSearchTag( + global: T, + chatId: string, + threadId: ThreadId, + tag?: ApiReaction, + ...[tabId = getCurrentTabId()]: TabArgs +): T { + const chatThreadKey = buildChatThreadKey(chatId, threadId); + + const currentSearch = selectTabState(global, tabId).localTextSearch.byChatThreadKey[chatThreadKey]; + const query = currentSearch?.query || ''; + + return replaceLocalTextSearch(global, chatThreadKey, { + ...currentSearch, + query, + savedTag: tag, + }, tabId); +} + export function replaceLocalTextSearchResults( global: T, chatId: string, diff --git a/src/global/reducers/reactions.ts b/src/global/reducers/reactions.ts index 1f8f002ef..b2c11245e 100644 --- a/src/global/reducers/reactions.ts +++ b/src/global/reducers/reactions.ts @@ -8,7 +8,7 @@ import { SIDE_COLUMN_MAX_WIDTH, } from '../../components/middle/helpers/calculateMiddleFooterTransforms'; import { updateReactionCount } from '../helpers'; -import { selectSendAs, selectTabState } from '../selectors'; +import { selectIsChatWithSelf, selectSendAs, selectTabState } from '../selectors'; import { updateChat } from './chats'; import { updateChatMessage } from './messages'; @@ -42,7 +42,8 @@ export function subtractXForEmojiInteraction(global: GlobalState, x: number) { export function addMessageReaction( global: T, message: ApiMessage, userReactions: ApiReaction[], ): T { - const currentReactions = message.reactions || { results: [] }; + const isInSavedMessages = selectIsChatWithSelf(global, message.chatId); + const currentReactions = message.reactions || { results: [], areTags: isInSavedMessages }; const currentSendAs = selectSendAs(global, message.chatId); // Update UI without waiting for server response diff --git a/src/global/selectors/localSearch.ts b/src/global/selectors/localSearch.ts index d94920747..22cf452f4 100644 --- a/src/global/selectors/localSearch.ts +++ b/src/global/selectors/localSearch.ts @@ -16,7 +16,7 @@ export function selectCurrentTextSearch( const chatThreadKey = buildChatThreadKey(chatId, threadId); const currentSearch = selectTabState(global, tabId).localTextSearch.byChatThreadKey[chatThreadKey]; - if (!currentSearch || !currentSearch.isActive) { + if (!currentSearch || currentSearch.query === undefined) { return undefined; } diff --git a/src/global/types.ts b/src/global/types.ts index a6944b403..881264885 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -46,8 +46,10 @@ import type { ApiPostStatistics, ApiPremiumPromo, ApiReaction, + ApiReactionKey, ApiReceipt, ApiReportReason, + ApiSavedReactionTag, ApiSendMessageAction, ApiSession, ApiSessionData, @@ -345,8 +347,8 @@ export type TabState = { localTextSearch: { byChatThreadKey: Record; @@ -945,8 +957,6 @@ export type GlobalState = { }; }; - availableReactions?: ApiAvailableReaction[]; - topPeers: { userIds?: string[]; lastRequestedAt?: number; @@ -997,6 +1007,11 @@ export type GlobalState = { translations: { byChatId: Record; }; + + savedReactionTags?: { + byKey: Record; + hash: string; + }; }; export type CallSound = ( @@ -1197,6 +1212,9 @@ export interface ActionPayloads { setLocalTextSearchQuery: { query?: string; } & WithTabId; + setLocalTextSearchTag: { + tag: ApiReaction | undefined; + } & WithTabId; setLocalMediaSearchType: { mediaType: SharedMediaType; } & WithTabId; @@ -2080,7 +2098,13 @@ export interface ActionPayloads { loadTopReactions: undefined; loadRecentReactions: undefined; loadAvailableReactions: undefined; + loadDefaultTagReactions: undefined; clearRecentReactions: undefined; + loadSavedReactionTags: undefined; + editSavedReactionTag: { + reaction: ApiReaction; + title?: string; + }; loadMessageReactions: { chatId: string; diff --git a/src/hooks/useContextMenuHandlers.ts b/src/hooks/useContextMenuHandlers.ts index ef368e6d1..7a61fd5f5 100644 --- a/src/hooks/useContextMenuHandlers.ts +++ b/src/hooks/useContextMenuHandlers.ts @@ -49,6 +49,7 @@ const useContextMenuHandlers = ( return; } e.preventDefault(); + e.stopPropagation(); if (contextMenuPosition) { return; @@ -135,6 +136,7 @@ const useContextMenuHandlers = ( if (isMenuDisabled) { return; } + e.stopPropagation(); clearLongPressTimer(); timer = window.setTimeout(() => emulateContextMenuEvent(e), LONG_TAP_DURATION_MS); diff --git a/src/hooks/useMenuPosition.ts b/src/hooks/useMenuPosition.ts index 74571685f..90779ad8d 100644 --- a/src/hooks/useMenuPosition.ts +++ b/src/hooks/useMenuPosition.ts @@ -5,10 +5,10 @@ import type { IAnchorPosition } from '../types'; interface Layout { extraPaddingX?: number; extraTopPadding?: number; - marginSides?: number; extraMarginTop?: number; menuElMinWidth?: number; deltaX?: number; + topShiftY?: number; shouldAvoidNegativePosition?: boolean; withPortal?: boolean; isDense?: boolean; // Allows you to place the menu as close to the edges of the area as possible @@ -51,8 +51,8 @@ export default function useMenuPosition( const { extraPaddingX = 0, extraTopPadding = 0, - marginSides = 0, extraMarginTop = 0, + topShiftY = 0, menuElMinWidth = 0, deltaX = 0, shouldAvoidNegativePosition = false, @@ -83,22 +83,13 @@ export default function useMenuPosition( } setPositionX(horizontalPosition); - if (marginSides - && horizontalPosition === 'right' && (x + extraPaddingX + marginSides >= rootRect.width + rootRect.left)) { - x -= marginSides; - } - - if (marginSides && horizontalPosition === 'left') { - if (x + extraPaddingX + marginSides + menuRect.width >= rootRect.width + rootRect.left) { - x -= marginSides; - } else if (x - marginSides <= 0) { - x += marginSides; - } - } x += deltaX; - if (isDense || (y + menuRect.height < rootRect.height + rootRect.top)) { + const yWithTopShift = y + topShiftY; + + if (isDense || (yWithTopShift + menuRect.height < rootRect.height + rootRect.top)) { verticalPosition = 'top'; + y = yWithTopShift; } else { verticalPosition = 'bottom'; @@ -106,6 +97,7 @@ export default function useMenuPosition( y = rootRect.top + rootRect.height; } } + setPositionY(verticalPosition); const triggerRect = triggerEl.getBoundingClientRect(); diff --git a/src/lib/gramjs/tl/AllTLObjects.js b/src/lib/gramjs/tl/AllTLObjects.js index 95c3c96e6..fbb01803d 100644 --- a/src/lib/gramjs/tl/AllTLObjects.js +++ b/src/lib/gramjs/tl/AllTLObjects.js @@ -1,6 +1,6 @@ const api = require('./api'); -const LAYER = 172; +const LAYER = 173; const tlobjects = {}; for (const tl of Object.values(api)) { diff --git a/src/lib/gramjs/tl/api.d.ts b/src/lib/gramjs/tl/api.d.ts index f663d770d..7c21e80eb 100644 --- a/src/lib/gramjs/tl/api.d.ts +++ b/src/lib/gramjs/tl/api.d.ts @@ -11848,11 +11848,15 @@ namespace Api { settings: Api.TypeCodeSettings; }; export class SignUp extends Request, auth.TypeAuthorization> { + // flags: undefined; + noJoinedNotifications?: true; phoneNumber: string; phoneCodeHash: string; firstName: string; @@ -14706,8 +14710,12 @@ namespace Api { order: Api.TypeInputDialogPeer[]; }; export class GetSavedReactionTags extends Request, messages.TypeSavedReactionTags> { + // flags: undefined; + peer?: Api.TypeInputPeer; hash: long; }; export class UpdateSavedReactionTag extends Request = Bool; -messages.getSavedReactionTags#761ddacf hash:long = messages.SavedReactionTags; +messages.getSavedReactionTags#3637e05b flags:# peer:flags.0?InputPeer hash:long = messages.SavedReactionTags; messages.updateSavedReactionTag#60297dec flags:# reaction:Reaction title:flags.0?string = Bool; messages.getDefaultTagReactions#bdf93428 hash:long = messages.Reactions; messages.getOutboxReadDate#8c4bfe5d peer:InputPeer msg_id:int = OutboxReadDate; diff --git a/src/styles/icons.scss b/src/styles/icons.scss index e5b128566..2571103a1 100644 --- a/src/styles/icons.scss +++ b/src/styles/icons.scss @@ -227,33 +227,38 @@ $icons-map: ( "story-priority": "\f1c4", "story-reply": "\f1c5", "strikethrough": "\f1c6", - "timer": "\f1c7", - "transcribe": "\f1c8", - "truck": "\f1c9", - "unarchive": "\f1ca", - "underlined": "\f1cb", - "unlock-badge": "\f1cc", - "unlock": "\f1cd", - "unmute": "\f1ce", - "unpin": "\f1cf", - "unread": "\f1d0", - "up": "\f1d1", - "user-filled": "\f1d2", - "user-online": "\f1d3", - "user": "\f1d4", - "video-outlined": "\f1d5", - "video-stop": "\f1d6", - "video": "\f1d7", - "view-once": "\f1d8", - "voice-chat": "\f1d9", - "volume-1": "\f1da", - "volume-2": "\f1db", - "volume-3": "\f1dc", - "web": "\f1dd", - "webapp": "\f1de", - "word-wrap": "\f1df", - "zoom-in": "\f1e0", - "zoom-out": "\f1e1", + "tag-add": "\f1c7", + "tag-crossed": "\f1c8", + "tag-filter": "\f1c9", + "tag-name": "\f1ca", + "tag": "\f1cb", + "timer": "\f1cc", + "transcribe": "\f1cd", + "truck": "\f1ce", + "unarchive": "\f1cf", + "underlined": "\f1d0", + "unlock-badge": "\f1d1", + "unlock": "\f1d2", + "unmute": "\f1d3", + "unpin": "\f1d4", + "unread": "\f1d5", + "up": "\f1d6", + "user-filled": "\f1d7", + "user-online": "\f1d8", + "user": "\f1d9", + "video-outlined": "\f1da", + "video-stop": "\f1db", + "video": "\f1dc", + "view-once": "\f1dd", + "voice-chat": "\f1de", + "volume-1": "\f1df", + "volume-2": "\f1e0", + "volume-3": "\f1e1", + "web": "\f1e2", + "webapp": "\f1e3", + "word-wrap": "\f1e4", + "zoom-in": "\f1e5", + "zoom-out": "\f1e6", ); .icon-active-sessions::before { @@ -850,6 +855,21 @@ $icons-map: ( .icon-strikethrough::before { content: map.get($icons-map, "strikethrough"); } +.icon-tag-add::before { + content: map.get($icons-map, "tag-add"); +} +.icon-tag-crossed::before { + content: map.get($icons-map, "tag-crossed"); +} +.icon-tag-filter::before { + content: map.get($icons-map, "tag-filter"); +} +.icon-tag-name::before { + content: map.get($icons-map, "tag-name"); +} +.icon-tag::before { + content: map.get($icons-map, "tag"); +} .icon-timer::before { content: map.get($icons-map, "timer"); } diff --git a/src/styles/icons.woff b/src/styles/icons.woff index f1dbca30b551a16a5e7b7b56086ae2b9cd4dad33..b342279b01a4d6cc8eeb6f76702cf9a69b4bbfbf 100644 GIT binary patch delta 8182 zcmXY0cQhPM7e$0%)u_9A?`@UU#YS(7=+S#wy+zw-s}o%iu^}wcdymyc4J$%)5mBRt zi1Ouk&Ueq;Gv~c??|bvd{4?{uui#?8<5CCftEu7O;{1c(GaQot_QikMY_xk^vS@CdjHoffNPB=L3|B0CX@s#>o|CNuoe*g}S$1x5L!M`FTl(eoUD_M_l z-4Q7Ugrhmm0WP-qvnjku`8cE2L{vC9Aw`E+2Y*fvM&yaWm}ii5VN2turB?X2|F4?r zH>Nw2;n52IJBln@I`h9eI^XW?7FWH#$4Pvd@QR%+YQ|2`4#m;$pweYW^q2A}*8ySw zon||C$*t|p+DDY6j>k{=SM|vFS_EnA5xu5;<|~QwUdN|dMbu@SG?kiAi`k4~fFmbT z$B~=Fd`wMnaqNknUq+$)cP^Gce9BLV4It+?@2E>5T)dimT1Lpm>{59ev<4TyQC!ut zQdvo~rjWZ)d?&V4kS8Y&Rqslg*V7grllijY(IsVNdV z7vFncs`P+2e9wV{5+P)ijm$r`lyM8f z0~zDf^G7xnZOPNMk8%kk{Np}6-;$MX9~1KOkMCOEQX~%<}v(EGJYLaCWYU9j#|M6YR)1lj7+PbwaE*<4|lv+MFAHC@0 zQ_&2!Pth63g0HYFdUr!;s<^I==xdZ(p`w*&2dZ2Yn~f~^1Tz95+<{PC5A-fTvIpfX zicLWR9nh}E!mU=rp)D%pklA&MBy)F`%UiWdg?T zAuR@A9tfbj5VQu44g|E+qP~E!*T|(l7zpUj0fl>_bpb7vC@C=Z2Xd(&W(jl`fWqNu zIH08l)e6S`MlNAsazJ+;=%FXt3efTa6$Hi}A(sYVVL*2g=ph^(0cfd1{RU(2kg|O+ zLf{@Nl*tnf1q7l|WMJ$DQnnwa4n*wnLYd%b2S8voDh-T1LCRuaJit9p=o?S85g@P% zr3c3DB4r0)F2FrO=o>iN9}rlJngV10AUpeDbih4!sFf#L8xUBD5(ZcYQfkGWG4nD4cy~~u6Uv?0f8S-USRAYvU30y4BSHqLs#JFFhF1(>IjUz zMTYjl$boxoPz6u4I^Y70Vg_TkkfHrBec&D+Q~{250bEq0iow`3WGDtE0^H+*_IRRA z0T)#$YcO^n89D&-2JQ(#d*J9`z(p-;6^#9hyzYas0QWec5uRv$z(plW5sckIUiZUn zfO`V+&IGvjkw6zT3*e#@6)BFLMqUrWJ_7fop|@UWCBQ`m>P{THhP)nu zZKYlza2um6J~pL2l+#BvJ>M~@IGPWvJ9^!#a~W_sao{;Ubv|*f7u2*mxddwLN^6K= z0uAYj?rZ0MwJ3dgmdVT6uO-Ni%f>B2sgcml>PXF(z=Qun`4~x|@syH07luzIf~&1e zMR|8$RMmShwIpWmQr^1%#W--U=-%({{=WBJ-8*{-Upe4QZ(}ne-T7=8ucNN-w3pR9 z9aDGUnIq@qBBE6g-TuzCwSunV(7vL>?!=IoC?+d{$f~`9XC*T_^3n#d z^+a)4fl%|j+%FIsGj+^{&-_+fQG8$l-hoebfyUny$|>$XL^B@mxu@~a+`u?V&w#1` zp1W+)GahZyrWMb($+J`-Ztp-cVLiF+pbs1PDO#>H7dO#W-%W3Ctk{!|D-d^U9rymi zKR);$ldaV?tv}ORisRo_u?r-19-n#Z-zyl0Bg;EuKK31PQu8phg6XjJ#>YRcnkW~( z`Ic@LIURqVPb7Xy3oFWlery3PDit{RXo;N}@s%Gh-WAyLco@rfbIMhDU8iim2s2+h z4A(+<35v#fwU7E?=P~g%^Nj~v>bv?J1YXH2$O@zkHjsJpQs4#>XiQ0O>M;gO z`BJJoEs7wCf+il1&63E{7=Z4MtrY>b`s ziZxF}#&B*yiA1#NHxKp=4f_*o_?tfs@@oY;6Ibd@p+O zDXY>p2d^0FMZDci9=+zn(mX7O%%<@C)e?D`L)iB0K;UZ*5#NNMaaMP{tCB!+Q{Vbu zuEDd6;KS$EU6fUZ^2L}E+#15o>HJsry?fH_2Gc*XKaH@>q*pJxao|w|1ssbhWL(h|ECW%; zQZ7~P-esGOxO}2nbRa=v-&%Y|)iL`J{uf3u_-ZWxgya_#g8J)2Ko?9Bp_H1hi zlc#z!zzv3Gwd)qfkhrV`bqR+ncps>yupbxVAz}>fZHD_G1WMZm$F@rFaLkp^)@2Gp zNvik#j-_OpS3EW0$nHZw3Hq1`pgu2JP?W1&T~$hTFQL0?b}di+fdZ+eH*qDu^1FLeVkGb<>F<}T^AmQP(T!wKIrwf7^P$a}e=-;yu%3^IXX{veQ6y$q*Om9=q4I*BF8>&5YZ?wN9uw(- z&hhje`<3EomvYb^`CGOGSCY-*S%Te{g(MNn&dlc|;^yXI<#T`ZmnNj2@>{r34^%Q3 zvQzXbjAv0W`qPPttZ%YSq2^ed1dB3jr4phNUr@x!x5!;IaciJg*?- zpc-Vok)dCFgD8ga!@om=`}C<4?5Mt}XxNeq_jHQ~&_p7ONuF~Em3kk5@>11B1^97S zuy!SIV?#_V+ur!IE!}kWxTzVk1a4LV3TuOh6UIwD*bZXZbX=<->ifSw=U1+=49@&( zCfiFthCqLUq&a=P@nt`&n7#`{cu_c&_HaXf;u%ru_4xtTPXs}j;a#G(K8~ExIIYCp z=#gFgWdh>^^NBl0B~o2Sun>W5ViRBfy!|KYd$Z+lODopom0KGmqv~0Ha0Hlbuh90U zCVY}GE`dGpf|cTQ$JBeBODSE{e2C4Lh(GTLMP5&6m^>-d5*f=N1+GE-$@VIm=LHk@ z8|c1#ogY{#QTZu(n9r6J*H#$v;F;#9a@{X;E!A-Qy{!VK%+dR+(;%JQ55e7Jgnvlo z;911ak!V4Y@$|7z%zS%?1g0S)9>bTZ`@`xf`sncXJ2`_lK^ipdhE1kbMcuWwri?j~ z9~o}|Wl~Jn<lG#c5||}B-f!Zr<$i%@!lF9e0P66RRHDXF^A{YdbPi1f$Pv^c+7at)SR0Jj5GtaxpHu}@~ zJIsl63|3TVe#Mysj{F)(CK*s2>J%m68&_cGc#}W=xqWJ1oA=b}rL008-B~^NjEu7|s)Sgo_>-S`swp843vS6-i$XzK&Imq~JsJWP_ao>XvJr(65 z)t>@)C>36@9aHf3pjwqsxc6jg&o-PIW5I^(lKzqiFVH?E_-(acoL; z6h;IZTyl!HoK4RFO+~JUncsTfD8ShSi|B=9moy%MiV%MWiJDkP`=>2fJ16mV(!T12 zXZC5?uC+hm(^Zz@!s0dHA%*BO#P^=)Cx?+HVvTDx&XgG{I}VM{sDG*?JW$t|BeYDA zGa6hj4}RXQcLma;#{`ZJ;w=uo_*ul`)5052P{f%V%)HtH{W%yzayoP)A7J!qGd)UA z{Y(0MLpZ{nL?qoDb~g97hf@eI=}w-h7oEy2I2aan%2*&0BwE5jO4e4}4@=c5VJec}MLq~+ADfTDkl;aoo#!4>JNa5lQruOB zWqR3=2>$9*cdV@`r$^w(jdY&>_>a>>*;cJRACEJl0D76XwkE{eMjVuJd^b#ZdEcu0+yCcK#1-xy_h0&)US3*nJ6z|1Pe-yE4 zq|^c8?lyAVu62xY+SH%0W~_4yvVN8;x8q~LeoPZA?ebRY$=O0(6LfU-hQF5o&kMW~vx-r5-gRZ77$lNJ_q4 zDKhwxr2bi>IGQB$ZBJm|BZa{$?6rf|T7=^pgf_nKXMC|YWZhPluE=~svRF}avF`aV z$7FD%Zx272`wfNsI`>y4;`iQC5W^f@qwx3Y^QW#U$L^9g7*A|oOsSg*78D5ypCi=} z*msg{vR+*lnnsull=bhw_zi;goGuWe&Sce?j(w8ggR67AJKJkeoQ0T)@K)B^I`wCzYsbv z4#)q_5KZ5?Wf7I_t+89kyespifG+akz+N4pB4l*|dP!FNC+Tr9h~KeGO8kcG4|p(% zFIBKHHfp^}@V+X+bTI<>KHfesTw$RC0Z(beYTsD3+%SgZ?t>2LcQVfO>?X%jS0#of z(JQPQq35DN74rC*Uo8J_htWNm!l8Kc*GG>Pbp)G7N`fHz1uDNitD_8+{23k@9>e&6 zS5Fp>KnBteL~yP7-bZF4GyMcajCS+I0M>$sc3#fW1kYETF|gkSWe<`^vskkc;e?zo z^B}s>u=5Me5WU#w)7sR2>2Y-7w3Ez_EpyS~imFfnfpX$S6?-b$DUM11C$rU&XQK-` zG$;6v4l(p>&&pI3-h!F+X$s~tVIRzAyuzd{3)j_2$IA+B(+D(#bBJzfRpf8`vv`Nv z>F)jb&4jSF7Z|gABC}!VQ(Jok`#2SncKcFLI?+lJsmFIDinIo1!b0akJ2V0~HxIa&8Sr@xO7iZ|opYQ}0* z^(1u+BFhy?VGq6bsot>Zp4(VmbFa*?`gY@nI`PHm}y^F z%bK`zR#0UaSAxa?4prBP%6F`Djin|tE*hxq6ROfrO^#Fd#-GzFtG^|an20IeInLAM zy%_XD0Bh<6c_p8dON=N09DX)EVn77uEQjfyC`^c4mzylpWlTC%z$b185q++k)%LFz zem?tyIS@YDR#J!ydo3|GdYmHl$@u+3OIluzW!zy2@Kid>i!oRo(S9{=a_XWvo>nm6 z549KYHy%ZwLTCYD*2s4GE7KQ!9I0SkkoD$3d^S|rPKJ9>2T9t%s*u) z1N1PP@qDe}PsAQj%v*LEf%mrY=n0iM_A2zoR2zBIJ zV%NxWAT8)R^C+Os{b1N+S%^Z?cPp<5eWT1EGtpwx)Wb7!C|GYf<}p_A*zxdJloG*I z7Rv}Z`~cqft4(1%P$*qmzRp+7Md`Uk^Ivsnntune!@I;r(OMM zyXCpK5jo5~Amh&9iv_OLbXapVv#ENz2>Miuf$)jo_=^gJ>(svWG zAzB-QE}NQ>*r>6nT)NzH?~krAND>dpsKUJS;j`Q^DaBUIgx=^T^B2-nihB!RG$`z!`nL;24&->r7HLtmF;^~spxv1vd1#OZw6 zsIK9N_Ye0Tj3uPXNWVR4Ef|w_)^;o-S$h|mG*2fVgIGuwgTKgmf#5ee?}? znW@@-B&B&@chwq-b7wc#_Gy0L=31w6*0>`?Nji-1;?0?R4mv{GRl&dmQ!B0v{L+MC!^aqRJyc($`V;Ekv0NeMZ_0*&QYalCYi zJQD(eb$P=YMn31st{Du!$&|u7-SCyVZ~Tiy9ti#_EvX|fSP-p%+eSgp8f7d+^*R?f>C-@GylHCz_S(KjaP(SpS`NL`8 zTGi2ZkyjD0G^OLXdfI(_LWMdO_-1&n^^^OR!+ zsxw2WO>?QF^72{jspQtcuzG4bXqw@zc;v%|XYm}W+N%o^W|i;q`OBAj`5D+Y!f@Eqe)qm83$SaWpI&w+WB&7T2?|0NnEU&gW8xMOtzseh=Z3 zNy6YMy=8}8CiC083bRs5c(AtOM%yMri8t&(=pa`^8y|GLsP@UcNa7J+!MS`3Jd`;R z$dJr2Jg0t$bn=5364({Kd`;QB$?2>h&zFR;7Ei@>z!UL#L7iEz88Pps{bE(x8_hLa+kKsPbt2~)NGe+TvZdWGxNMBT_K_)KajmV2 z)91<`n)fa9m@(8%O{lPqpyp2x`wvh1bN3rJBNMhC$`5lZ77b-zVt4NgzP9}PPn>wU z`1$8m)8w0?_&l1(@thk+ku>hZD#6Al>oLkx^eO6g^y-NI>+zPAm~-5RHojc!ppmB3 zAMdb@*?9g1v^$jgX2ENhM_p^dh=%OJW8pTpN)O$@@4HOiULREyz;|?K)WcmNMGm%( zwzfE%cpREFA(4f3faMffWlAJ)o|IJdT8Tn*${G96Pli5U`5g{TJ&D-=^;kO`ykVL9 ze;9!jMrGjA$P?m(i4bW2n+iDC{sR_w+<-)E#CL0co!aA#C|KoT`@?`_aI#J z~$C0wq;S2Kxa1-+X)IoX^WSUwYF4%A%+bTflbi+BlCb z*>>UD`28({b~eP+pm{r`S4!b}c{_twn&o7q`eAt*1)i*we0GN|U zHcmqDiW7J?#zKgTlPaR@$kOoeNCX?A2j?2p5{Zd{k_h4Gs5od0X)7d&DQ$xxgu6J- zWy@ZkaC+$MIJ_H2WH9;Iz8ha;XxxWLc*rS4MtDTvSgV`3ZrH=;p>^`Y#zn}3)dcB{ z$q~O;Ek0K^4h_$6UA;8mOjE0ws3kDjQWXKIOE$so)f-s;9HE#iA*@N zO&{F}7b2X&24i|68P2rUCw(Fm4qC$mpD2X0DD{z_aNN)5fFCj5 zCvrE;h(4ARo*M=P#^yx&hUvaf=|toPbdNE<`n8(k%OlO+)^Aanv*Mzf;lXt(BamzV z!`vZmjIL2oHRC*7nbld9%Xs*cp|`&!Nh`A$f=j?;Nn&E^m$VbI?gsaefN-Spv$3^U zrE|~VZl5|+MGo?}(7DO>bD(w7d2CtCd$acHI5lC@*hASiT`P|*ECK!0r+W- zATcVfp45Nh`wD~VlnF+} zgv^5R&@Q!ce0nv=gR^b|jqZDzsl4uDUZ)&84@HIfAS2=9#pdve0jsV-MO0KPdvtsAb;HGXtk^I^r4LR$3VA=;4cjntz>^4fvpfe z3GbWoEEVaqMs3WfN6vYbYP4YTPYF7bPr2gd+s#oBi>x|*I=RNrpr?Gx?S}jr#S~7= zL7TW6i_!4W_N^r@1^vX!coD7EB0=8GXLwJu_t1x&8$58q0RO|+h6?-g1*yD|4P4)sSanFlhrHkP=scy*&rs- zTu6uO8(C=Cd_La3b{qNf9p5ZOR7qB$)@^@%da%^cLYno}=h99x$wQwV`cSJ1MO#IK z3E@-Gs!C^dM=h>Ge$)$&K!=K!4A5|}OixaDo;CKBlArpI5!|#0e3dqOAcikQ;*WS~ zF#u|^(D?mvF zip*Qh?^kaieGMP+cOMAPrsh76?bMX&ECX>VbrJ5IuPQl1 V?8a2ivL4>Q5TO@CNB<`3{{x7yb%X!_ delta 7288 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

N;Fxs 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 literal 23848 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 z

lZql5sM&fV=s9zH)ie%8@}v*B8i0bezv2zCA%$>zvTMhdAlYavn~Jv6HJyiKU- z@r%x)ilr`p*Y%!^a+C?LW~g?TLX1YFR>c*7>DoD!BNsz7q5>*f5te9kRGq`p=bxqe zED={XUU&f0NX)Re*b`(piLzYT3u&00lrX*&3|Q{iF0U&b414jt10EEb0(<0kvoX1S zic@X~(FATLBwNk$j884HQ$wNTfq+m++Q5^sW-BQ0I&I=ut@i=zdadkn4Ke~%E4IdC zt=0Mr>!zb8&pP>?Ub@3Yj=zn?XrxNZQ?|!xUE7=x3JRe zrGNm`-nQzp?s(fuD}V;3GD=Yc7oC(W3@Cqi1~Vh!WB7aEma&y?C6dY|zm(!KFE8&d z?<;d$_@d|WtM=#$vy~723(7ApSpG&6d+AG|_E*e}%C^n+VU)qsss!1gbZ1G7L7)gC7n{tE3OoKN89cwk! zFY;VLKCnity0^>Is|S00qq={9qc8s5eX1siMh{EV)gX@~bSJHddx!IZ$)6#Ntz(Ht z)RAhF6nmm98%hoZAom1Oif87hlPajI(27cdc^YNYqfW{)Ldb2qg~hD=%xNt)Y9TMg zl*QDN+}3vAMBa=5Xvq8_%IEkXE7`n)g8jas}6j zMu~TLI>eJG;!#Bog3K06a}xkYn4l(mGV*}gV%@UebcRMRT6Xo{&*rkJpmL5XV+^yJ z_t*a;j`6W<%aS8{d-{`~zGz|cKDRl&U!4E^XKhDKFagn-bu=CHijr^&Xpx;$w$tx+ z>Ci;H%4^9pn7V>TX}~%9FbEM9SrZw2z(g?9k|Ds>7RK#osv!fpe8_H<1_KuV>jxaE z->*9}6S?uajaNg=K2iSQGHRGHLt~uhS9Fq28E~!vdC!Xsh6Vu9p8i$mh4*a(%E&#u z;)pkviO%InSZ({B-v@XtDb7A2vXP`Yl92`lW}9LVfctc+24thtC(0TBh=uP1$!%cT z+R$8wY_RHcnEpX{Xy(ypyy47j>pwBQ2R&#rC+}C4f?-`E`}2=@)&v7)c(l^q;Cqf- zRn-wK8UOgXf^tfV|2FYU>_Nx#%UljK{T=7%Vbg8|g4q}PMY<;Z;z27( z8EK{u+iqS3Fr^TKOcZVJMl2viyCYH(xttCb6$_do=nPL>70)AC=muOTKieQheTzRF zA#Spq?4&ZdKB+WfNKs^uLzMo( zK$LXk3Sd=Kw|Qp#FuUyMmQ)N^|Zrq+57>=x}43rkFz#SvsUyg<5un1G5xVQlIaf;@Y>q=R4Rz&urBN_uo zi_xZDy3AB=0+EdDJ4~i|c);>azQM`XQLiC_>B3@JaBT|J%lIn2D4%E1`$H`~w9d@$ z6RCZv=>oI)yj;!4ak9{5BkH{yDtgaIxu^}!f10ItdU&m7B^9UHfKPlg##T`a~l=YXl|&bID&u2#omJ6^e|uF>69G7bX;6aMy@1WI)i@*jY-n zayWKGw@sV#cCQ|mwY!&3KS%U`pK$*1$q)W?S~udv^8*!L;iAJ9M|KoWt^v8fWu3h? z+4u!Z07C$%Epz?o(jfhH;am^SZYy2i534_v&X<4m&Q0>wpP;~nx22Upq6TbZW?U2X zETJ$J;o!v}{r6iwuXy+V^#4zC520H#=}ky}>R{_6dt7ASMZ({Ok*`Lg3cyO=Raft( z6Y;=H7a@FV$^=a>vEB%ynFzS#h@yIYB1)Ti^s4t>J?;FB``IH@w&dB>*@nJ8e~ly?AI?eN?b4RE901midN)C2^-j+O zK;9mYF!G8GPQd9^q6_b+;GWaG$6&5Aa9Sa(NB}r+mKJ`8k6$C#?;GQ!7#URS=vR}q z9aWkKAuxu_yyKz-TsvK^9{5*aXYM~--#h-040v>RrBy_N7S|7@I=Jl7gMuhyb3{I7 z0|4zO+ou4zE!i_Bs?1G&l0QffR!5?|bb=1(FRz@bE!Mju_RFW2tv|oo){5S~m2I=t z$oHkC$=B5MiGuSjA=*aE$vD|8a;fWTX+U&K{8g&pcom^qO9h)>EySpC?)dq!qDMy zpihxg(BbX2Py#Ynbn}*kR08~QN9r&W8Bo7}s#_}Acix`Tz*La>&3}_dFMcoMOpgmc zk^O!aXXIXlvicF?`Ln!&5)(%%DokN2EpO@tTwv3W$r>ER0fcnrLJ+}X=1M&k+kBy} zKX&EW-y&)nz!dQk(NNRHr;7)~SlGJedZuwzY3*jn3N*bd8&??W>Tf}a@_F@AO0~oX z2gP-kP*6jZXKg2so6?TV+0rcK^={c-NqCCbqT0R-;P&|)o_vSBHIT62B{*VMZ$!-A zL?(1ZPgX>V#!1s+!7j@Zb3{TzA4Jx!F0&W)H%4`fV1Nit?Frf2bS~v%xja65ct7u{ zioBmR-ip5W`mmNKmRw>G6IrO-r}UxAf5r&+p?v{C$a#|WjJQ<43⪙hV}NsSPr)H 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

vX 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