From f3743bd5569c89e8bc45079d8db9d8200a5f6d49 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 6 Aug 2024 20:06:41 +0200 Subject: [PATCH] Reactions: Support limit in channels and groups (#4784) --- src/api/gramjs/apiBuilders/appConfig.ts | 3 +- src/api/gramjs/methods/chats.ts | 9 +- src/api/types/chats.ts | 1 + .../middle/message/ContextMenuContainer.tsx | 16 ++- .../middle/message/MessageContextMenu.tsx | 6 +- .../message/reactions/ReactionSelector.tsx | 10 +- .../right/management/ManageReactions.tsx | 111 ++++++++++++++++-- .../right/management/Management.scss | 4 + src/components/ui/RangeSlider.scss | 10 +- src/components/ui/RangeSlider.tsx | 36 +++++- src/config.ts | 1 + src/global/actions/api/chats.ts | 5 +- src/global/types.ts | 1 + 13 files changed, 181 insertions(+), 32 deletions(-) diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index a7b38b96d..041c06158 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -7,6 +7,7 @@ import type { ApiAppConfig } from '../../types'; import { DEFAULT_LIMITS, + MAX_UNIQUE_REACTIONS, SERVICE_NOTIFICATIONS_USER_ID, STORY_EXPIRE_PERIOD, STORY_VIEWERS_EXPIRE_PERIOD, @@ -116,7 +117,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp readDateExpiresAt: appConfig.pm_read_date_expire_period, autologinDomains: appConfig.autologin_domains || [], urlAuthDomains: appConfig.url_auth_domains || [], - maxUniqueReactions: appConfig.reactions_uniq_max, + maxUniqueReactions: appConfig.reactions_uniq_max ?? MAX_UNIQUE_REACTIONS, premiumBotUsername: appConfig.premium_bot_username, premiumInvoiceSlug: appConfig.premium_invoice_slug, premiumPromoOrder: appConfig.premium_promo_order as ApiPremiumSection[], diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index b0d756008..65fc4f79a 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -514,6 +514,7 @@ async function getFullChatInfo(chatId: string): Promise buildApiPeerId(userId, 'user')), isTranslationDisabled: translationsDisabled, @@ -588,6 +590,7 @@ async function getFullChannelInfo( call, botInfo, availableReactions, + reactionsLimit, defaultSendAs, requestsPending, recentRequesters, @@ -670,6 +673,7 @@ async function getFullChannelInfo( linkedChatId: linkedChatId ? buildApiPeerId(linkedChatId, 'channel') : undefined, botCommands, enabledReactions: buildApiChatReactions(availableReactions), + reactionsLimit, sendAsId: defaultSendAs ? getApiChatIdFromMtpPeer(defaultSendAs) : undefined, requestsPending, recentRequesterIds: recentRequesters?.map((userId) => buildApiPeerId(userId, 'user')), @@ -1568,13 +1572,14 @@ export async function importChatInvite({ hash }: { hash: string }) { } export function setChatEnabledReactions({ - chat, enabledReactions, + chat, enabledReactions, reactionsLimit, }: { - chat: ApiChat; enabledReactions?: ApiChatReactions; + chat: ApiChat; enabledReactions?: ApiChatReactions; reactionsLimit?: number; }) { return invokeRequest(new GramJs.messages.SetChatAvailableReactions({ peer: buildInputPeer(chat.id, chat.accessHash), availableReactions: buildInputChatReactions(enabledReactions), + reactionsLimit, }), { shouldReturnTrue: true, }); diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 771d80670..e001cc07c 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -123,6 +123,7 @@ export interface ApiChatFullInfo { linkedChatId?: string; botCommands?: ApiBotCommand[]; enabledReactions?: ApiChatReactions; + reactionsLimit?: number; sendAsId?: string; canViewStatistics?: boolean; recentRequesterIds?: string[]; diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index 90fb0d6c0..c81627549 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -124,7 +124,7 @@ type StateProps = { canShowSeenBy?: boolean; enabledReactions?: ApiChatReactions; canScheduleUntilOnline?: boolean; - maxUniqueReactions?: number; + reactionsLimit?: number; canPlayAnimatedEmojis?: boolean; isReactionPickerOpen?: boolean; isInSavedMessages?: boolean; @@ -161,7 +161,7 @@ const ContextMenuContainer: FC = ({ canShowReactionList, canEdit, enabledReactions, - maxUniqueReactions, + reactionsLimit, isPrivate, isCurrentUserPremium, canForward, @@ -576,7 +576,7 @@ const ContextMenuContainer: FC = ({ canBuyPremium={canBuyPremium} isOpen={isMenuOpen} enabledReactions={enabledReactions} - maxUniqueReactions={maxUniqueReactions} + reactionsLimit={reactionsLimit} anchor={anchor} targetHref={targetHref} canShowReactionsCount={canShowReactionsCount} @@ -674,9 +674,15 @@ export default memo(withGlobal( const activeDownloads = selectActiveDownloads(global); const chat = selectChat(global, message.chatId); + const isPrivate = chat && isUserId(chat.id); + const chatFullInfo = !isPrivate ? selectChatFullInfo(global, message.chatId) : undefined; + const { seenByExpiresAt, seenByMaxChatMembers, maxUniqueReactions, readDateExpiresAt, } = global.appConfig || {}; + + const reactionsLimit = chatFullInfo?.reactionsLimit || maxUniqueReactions; + const { noOptions, canReply, @@ -697,7 +703,6 @@ export default memo(withGlobal( canClosePoll, } = (threadId && selectAllowedMessageActions(global, message, threadId)) || {}; - const isPrivate = chat && isUserId(chat.id); const userStatus = isPrivate ? selectUserStatus(global, chat.id) : undefined; const isOwn = isOwnMessage(message); const isMessageUnread = selectIsMessageUnread(global, message); @@ -731,7 +736,6 @@ export default memo(withGlobal( && chat.membersCount <= seenByMaxChatMembers && message.date > Date.now() / 1000 - seenByExpiresAt); const isAction = isActionMessage(message); - const chatFullInfo = !isPrivate ? selectChatFullInfo(global, message.chatId) : undefined; const canShowReactionsCount = !isLocal && !isChannel && !isScheduled && !isAction && !isPrivate && message.reactions && !areReactionsEmpty(message.reactions) && message.reactions.canSeeList; const isProtected = selectIsMessageProtected(global, message); @@ -781,7 +785,7 @@ export default memo(withGlobal( canLoadReadDate, shouldRenderShowWhen, enabledReactions: chat?.isForbidden ? undefined : chatFullInfo?.enabledReactions, - maxUniqueReactions, + reactionsLimit, isPrivate, isCurrentUserPremium, hasFullInfo: Boolean(chatFullInfo), diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index 341cfd946..51aa199bf 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -52,7 +52,7 @@ type OwnProps = { message: ApiMessage | ApiSponsoredMessage; canSendNow?: boolean; enabledReactions?: ApiChatReactions; - maxUniqueReactions?: number; + reactionsLimit?: number; canReschedule?: boolean; canReply?: boolean; canQuote?: boolean; @@ -140,7 +140,7 @@ const MessageContextMenu: FC = ({ isPrivate, isCurrentUserPremium, enabledReactions, - maxUniqueReactions, + reactionsLimit, anchor, targetHref, canSendNow, @@ -364,7 +364,7 @@ const MessageContextMenu: FC = ({ allAvailableReactions={availableReactions} defaultTagReactions={defaultTagReactions} currentReactions={!isSponsoredMessage ? message.reactions?.results : undefined} - maxUniqueReactions={maxUniqueReactions} + reactionsLimit={reactionsLimit} onToggleReaction={onToggleReaction!} isPrivate={isPrivate} isReady={isReady} diff --git a/src/components/middle/message/reactions/ReactionSelector.tsx b/src/components/middle/message/reactions/ReactionSelector.tsx index 40580b723..476a7f41f 100644 --- a/src/components/middle/message/reactions/ReactionSelector.tsx +++ b/src/components/middle/message/reactions/ReactionSelector.tsx @@ -30,7 +30,7 @@ type OwnProps = { effectReactions?: ApiReaction[]; allAvailableReactions?: ApiAvailableReaction[]; currentReactions?: ApiReactionCount[]; - maxUniqueReactions?: number; + reactionsLimit?: number; isReady?: boolean; canBuyPremium?: boolean; isCurrentUserPremium?: boolean; @@ -54,7 +54,7 @@ const ReactionSelector: FC = ({ defaultTagReactions, enabledReactions, currentReactions, - maxUniqueReactions, + reactionsLimit, isPrivate, isReady, canPlayAnimatedEmojis, @@ -75,8 +75,8 @@ const ReactionSelector: FC = ({ const areReactionsLocked = isInSavedMessages && !isCurrentUserPremium && !isInStoryViewer; - const shouldUseCurrentReactions = Boolean(maxUniqueReactions - && currentReactions && currentReactions.length >= maxUniqueReactions); + const shouldUseCurrentReactions = Boolean(reactionsLimit + && currentReactions && currentReactions.length >= reactionsLimit); const availableReactions = useMemo(() => { const reactions = (() => { @@ -86,6 +86,7 @@ const ReactionSelector: FC = ({ if (enabledReactions?.type === 'some') return enabledReactions.allowed; return allAvailableReactions?.map((reaction) => reaction.reaction); })(); + const filteredReactions = reactions?.map((reaction) => { const isCustomReaction = 'documentId' in reaction; const availableReaction = allAvailableReactions?.find((r) => isSameReaction(r.reaction, reaction)); @@ -106,6 +107,7 @@ const ReactionSelector: FC = ({ }, [ allAvailableReactions, currentReactions, defaultTagReactions, enabledReactions, isInSavedMessages, isPrivate, topReactions, isForEffects, effectReactions, shouldUseCurrentReactions, + ]); const reactionsToRender = useMemo(() => { diff --git a/src/components/right/management/ManageReactions.tsx b/src/components/right/management/ManageReactions.tsx index 6edaaea8f..47bc5e24b 100644 --- a/src/components/right/management/ManageReactions.tsx +++ b/src/components/right/management/ManageReactions.tsx @@ -9,7 +9,10 @@ import type { ApiAvailableReaction, ApiChat, ApiChatReactions, ApiReaction, } from '../../../api/types'; -import { isSameReaction } from '../../../global/helpers'; +import { + MAX_UNIQUE_REACTIONS, +} from '../../../config'; +import { isChatChannel, isSameReaction } from '../../../global/helpers'; import { selectChat, selectChatFullInfo } from '../../../global/selectors'; import useHistoryBack from '../../../hooks/useHistoryBack'; @@ -19,6 +22,7 @@ import ReactionStaticEmoji from '../../common/ReactionStaticEmoji'; import Checkbox from '../../ui/Checkbox'; import FloatingActionButton from '../../ui/FloatingActionButton'; import RadioGroup from '../../ui/RadioGroup'; +import RangeSlider from '../../ui/RangeSlider'; import Spinner from '../../ui/Spinner'; type OwnProps = { @@ -31,6 +35,9 @@ type StateProps = { chat?: ApiChat; availableReactions?: ApiAvailableReaction[]; enabledReactions?: ApiChatReactions; + maxUniqueReactions: number; + reactionsLimit?: number; + isChannel?: boolean; }; const ManageReactions: FC = ({ @@ -39,6 +46,9 @@ const ManageReactions: FC = ({ chat, isActive, onClose, + maxUniqueReactions, + reactionsLimit, + isChannel, }) => { const { setChatEnabledReactions } = getActions(); @@ -47,6 +57,8 @@ const ManageReactions: FC = ({ const [isLoading, setIsLoading] = useState(false); const [localEnabledReactions, setLocalEnabledReactions] = useState(enabledReactions); + const [localReactionsLimit, setLocalReactionsLimit] = useState(reactionsLimit); + useHistoryBack({ isActive, onBack: onClose, @@ -70,33 +82,80 @@ const ManageReactions: FC = ({ setChatEnabledReactions({ chatId: chat.id, enabledReactions: localEnabledReactions, + reactionsLimit: localReactionsLimit, }); - }, [chat, localEnabledReactions, setChatEnabledReactions]); + }, [chat, localEnabledReactions, setChatEnabledReactions, localReactionsLimit]); useEffect(() => { setIsLoading(false); setIsTouched(false); setLocalEnabledReactions(enabledReactions); - }, [enabledReactions]); + setLocalReactionsLimit(reactionsLimit); + }, [enabledReactions, reactionsLimit]); const availableActiveReactions = useMemo( () => availableReactions?.filter(({ isInactive }) => !isInactive), [availableReactions], ); + useEffect(() => { + if (localReactionsLimit !== undefined && localReactionsLimit !== reactionsLimit) { + setIsTouched(true); + return; + } + + if (localEnabledReactions?.type === 'some') { + const isReactionsDisabled = enabledReactions?.type !== 'all' && enabledReactions?.type !== 'some'; + + if (isReactionsDisabled && localEnabledReactions.allowed.length === 0) { + setIsTouched(false); + return; + } + } + + if (localEnabledReactions?.type !== enabledReactions?.type) { + setIsTouched(true); + return; + } + + if (localEnabledReactions?.type === 'some' && enabledReactions?.type === 'some') { + const localAllowedReactions = localEnabledReactions.allowed; + const enabledAllowedReactions = enabledReactions?.allowed; + + if (localAllowedReactions.length !== enabledAllowedReactions.length + || localAllowedReactions.reverse().some( + (localReaction) => !enabledAllowedReactions.find( + (enabledReaction) => isSameReaction(localReaction, enabledReaction), + ), + )) { + setIsTouched(true); + return; + } + } + + setIsTouched(false); + }, [ + localReactionsLimit, + reactionsLimit, + localEnabledReactions, + enabledReactions, + ]); + const handleReactionsOptionChange = useCallback((value: string) => { if (value === 'all') { setLocalEnabledReactions({ type: 'all' }); + setLocalReactionsLimit(reactionsLimit); } else if (value === 'some') { setLocalEnabledReactions({ type: 'some', allowed: enabledReactions?.type === 'some' ? enabledReactions.allowed : [], }); + setLocalReactionsLimit(reactionsLimit); } else { setLocalEnabledReactions(undefined); + setLocalReactionsLimit(undefined); } - setIsTouched(true); - }, [enabledReactions]); + }, [enabledReactions, reactionsLimit]); const handleReactionChange = useCallback((e: React.ChangeEvent) => { if (!chat || !availableActiveReactions) return; @@ -116,12 +175,40 @@ const ManageReactions: FC = ({ }); } } - setIsTouched(true); }, [availableActiveReactions, chat, localEnabledReactions]); + const handleReactionsLimitChange = useCallback((value: number) => { + setLocalReactionsLimit(value); + }, []); + + const renderReactionsMaxCountValue = useCallback((value: number) => { + return lang('PeerInfo.AllowedReactions.MaxCountValue', value); + }, [lang]); + + const shouldShowReactionsLimit = isChannel + && (localEnabledReactions?.type === 'all' || localEnabledReactions?.type === 'some'); + return (
+ { localReactionsLimit && shouldShowReactionsLimit && ( +
+

+ {lang('MaximumReactionsHeader')} +

+ +

+ {lang('ChannelReactions.MaxCount.Info')} +

+
+ )}

{lang('AvailableReactions')} @@ -141,7 +228,7 @@ const ManageReactions: FC = ({ {localEnabledReactions?.type === 'some' && (

- {lang('AvailableReactions')} + {lang('OnlyAllowThisReactions')}

{availableActiveReactions?.map(({ reaction, title }) => (
@@ -181,11 +268,19 @@ const ManageReactions: FC = ({ export default memo(withGlobal( (global, { chatId }): StateProps => { const chat = selectChat(global, chatId)!; + const { maxUniqueReactions = MAX_UNIQUE_REACTIONS } = global.appConfig || {}; + + const chatFullInfo = selectChatFullInfo(global, chatId); + const reactionsLimit = chatFullInfo?.reactionsLimit || maxUniqueReactions; + const isChannel = isChatChannel(chat); return { - enabledReactions: selectChatFullInfo(global, chatId)?.enabledReactions, + enabledReactions: chatFullInfo?.enabledReactions, availableReactions: global.reactions.availableReactions, chat, + maxUniqueReactions, + reactionsLimit, + isChannel, }; }, (global, { chatId }) => { diff --git a/src/components/right/management/Management.scss b/src/components/right/management/Management.scss index 16b360f52..3ac92fa03 100644 --- a/src/components/right/management/Management.scss +++ b/src/components/right/management/Management.scss @@ -135,6 +135,10 @@ color: var(--color-text); } + .RangeSlider { + margin-top: 2rem; + } + .radio-group { margin-top: 2rem; diff --git a/src/components/ui/RangeSlider.scss b/src/components/ui/RangeSlider.scss index 8b3f93ce7..123ecc9d3 100644 --- a/src/components/ui/RangeSlider.scss +++ b/src/components/ui/RangeSlider.scss @@ -30,12 +30,19 @@ justify-content: space-between; margin-bottom: 0.625rem; + .value-min, + .value-max, .value { flex-shrink: 0; margin-left: 1rem; color: var(--color-text-secondary); } + .value-min, + .value-max { + margin-left: 0; + } + &[dir="rtl"] { .value { margin-left: 0; @@ -90,7 +97,8 @@ // Apply custom styles input[type="range"] { - // Note that while we're repeating code here, that's necessary as you can't comma-separate these type of selectors. + // Note that while we're repeating code here, that's + // necessary as you can't comma-separate these type of selectors. // Browsers will drop the entire selector if it doesn't understand a part of it. &::-webkit-slider-thumb { @include thumb-styles(); diff --git a/src/components/ui/RangeSlider.tsx b/src/components/ui/RangeSlider.tsx index b81902425..3cf0150f6 100644 --- a/src/components/ui/RangeSlider.tsx +++ b/src/components/ui/RangeSlider.tsx @@ -20,6 +20,7 @@ type OwnProps = { className?: string; renderValue?: (value: number) => string; onChange: (value: number) => void; + isCenteredLayout?: boolean; }; const RangeSlider: FC = ({ @@ -34,6 +35,7 @@ const RangeSlider: FC = ({ className, renderValue, onChange, + isCenteredLayout, }) => { const lang = useOldLang(); const handleChange = useCallback((event: ChangeEvent) => { @@ -56,16 +58,38 @@ const RangeSlider: FC = ({ } }, [options, value, max, min, step]); - return ( -
- {label && ( + function renderTopRow() { + if (isCenteredLayout) { + return (
- {label} {!options && ( - {renderValue ? renderValue(value) : value} + <> + {min} + {renderValue ? renderValue(value) : value} + {max} + )}
- )} + ); + } + + if (!label) { + return undefined; + } + + return ( +
+ {label} + {!options && ( + {renderValue ? renderValue(value) : value} + )} +
+ ); + } + + return ( +
+ {renderTopRow()}
=> { - const { chatId, enabledReactions, tabId = getCurrentTabId() } = payload; + const { + chatId, enabledReactions, reactionsLimit, tabId = getCurrentTabId(), + } = payload; const chat = selectChat(global, chatId); if (!chat) return; await callApi('setChatEnabledReactions', { chat, enabledReactions, + reactionsLimit, }); global = getGlobal(); diff --git a/src/global/types.ts b/src/global/types.ts index b042f40ea..010630438 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -2335,6 +2335,7 @@ export interface ActionPayloads { setChatEnabledReactions: { chatId: string; enabledReactions?: ApiChatReactions; + reactionsLimit?: number; } & WithTabId; startActiveReaction: {