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 f1dbca30b..b342279b0 100644 Binary files a/src/styles/icons.woff and b/src/styles/icons.woff differ diff --git a/src/styles/icons.woff2 b/src/styles/icons.woff2 index 6de97315d..871d36114 100644 Binary files a/src/styles/icons.woff2 and b/src/styles/icons.woff2 differ diff --git a/src/styles/index.scss b/src/styles/index.scss index 4488b4032..4da163622 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -326,7 +326,7 @@ body:not(.is-ios) { --color-background-compact-menu-hover: rgb(0, 0, 0, 0.4); --color-background-menu-separator: rgba(255, 255, 255, 0.102); --color-background-secondary: rgb(15, 15, 15); - --color-background-secondary-accent: rgb(16, 15, 16); + --color-background-secondary-accent: rgb(25, 25, 25); --color-background-own: rgb(118, 106, 200); --color-background-own-apple: rgb(118, 106, 200); --color-background-selected: rgb(44, 44, 44); diff --git a/src/styles/themes.json b/src/styles/themes.json index 1278b2a59..cc06e0c43 100644 --- a/src/styles/themes.json +++ b/src/styles/themes.json @@ -9,7 +9,7 @@ "--color-background-compact-menu-reactions": ["#FFFFFFEB", "#212121DD"], "--color-background-compact-menu-hover": ["#00000011", "#00000066"], "--color-background-secondary": ["#f4f4f5", "#0F0F0F"], - "--color-background-secondary-accent": ["#E4E4E5", "#100f10"], + "--color-background-secondary-accent": ["#E4E4E5", "#191919"], "--color-background-own": ["#EEFFDE", "#766AC8"], "--color-background-own-apple": ["#DCF8C5", "#766AC8"], "--color-background-selected": ["#F4F4F5", "#2C2C2C"], diff --git a/src/types/icons/font.ts b/src/types/icons/font.ts index ad85f4562..8d91a7400 100644 --- a/src/types/icons/font.ts +++ b/src/types/icons/font.ts @@ -197,6 +197,11 @@ export type FontIconName = | 'story-priority' | 'story-reply' | 'strikethrough' + | 'tag-add' + | 'tag-crossed' + | 'tag-filter' + | 'tag-name' + | 'tag' | 'timer' | 'transcribe' | 'truck'