Reactions: Add maximum unique reactions limit, add optimistic UI for first reaction (#2205)

This commit is contained in:
Alexander Zinchuk 2022-12-15 19:19:27 +01:00
parent 6657e23889
commit 2a0ad055f1
8 changed files with 50 additions and 50 deletions

View File

@ -73,6 +73,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue): ApiAppConfig {
autologinDomains: appConfig.autologin_domains || [], autologinDomains: appConfig.autologin_domains || [],
autologinToken: appConfig.autologin_token || '', autologinToken: appConfig.autologin_token || '',
urlAuthDomains: appConfig.url_auth_domains || [], urlAuthDomains: appConfig.url_auth_domains || [],
maxUniqueReactions: appConfig.reactions_uniq_max,
premiumBotUsername: appConfig.premium_bot_username, premiumBotUsername: appConfig.premium_bot_username,
premiumInvoiceSlug: appConfig.premium_invoice_slug, premiumInvoiceSlug: appConfig.premium_invoice_slug,
premiumPromoOrder: appConfig.premium_promo_order, premiumPromoOrder: appConfig.premium_promo_order,

View File

@ -174,6 +174,7 @@ export interface ApiAppConfig {
isPremiumPurchaseBlocked: boolean; isPremiumPurchaseBlocked: boolean;
premiumPromoOrder: string[]; premiumPromoOrder: string[];
defaultEmojiStatusesStickerSetId: string; defaultEmojiStatusesStickerSetId: string;
maxUniqueReactions: number;
limits: Record<ApiLimitType, readonly [number, number]>; limits: Record<ApiLimitType, readonly [number, number]>;
} }

View File

@ -65,6 +65,10 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
const chatIdRef = useRef<string>(); const chatIdRef = useRef<string>();
useEffect(() => { useEffect(() => {
if (isOpen && !isClosing) {
chatIdRef.current = undefined;
}
if (isClosing && !isOpen) { if (isClosing && !isOpen) {
stopClosing(); stopClosing();
setChosenTab(undefined); setChosenTab(undefined);
@ -96,14 +100,14 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
const allReactions = useMemo(() => { const allReactions = useMemo(() => {
return reactors?.reactions ? unique(reactors.reactions.map((l) => l.reaction)) : []; return reactors?.reactions ? unique(reactors.reactions.map((l) => l.reaction)) : [];
}, [reactors?.reactions]); }, [reactors]);
const userIds = useMemo(() => { const userIds = useMemo(() => {
if (chosenTab) { if (chosenTab) {
return reactors?.reactions.filter((l) => l.reaction === chosenTab).map((l) => l.userId); return reactors?.reactions.filter((l) => l.reaction === chosenTab).map((l) => l.userId);
} }
return unique(reactors?.reactions.map((l) => l.userId).concat(seenByUserIds || []) || []); return unique(reactors?.reactions.map((l) => l.userId).concat(seenByUserIds || []) || []);
}, [chosenTab, reactors?.reactions, seenByUserIds]); }, [chosenTab, reactors, seenByUserIds]);
const [viewportIds, getMore] = useInfiniteScroll( const [viewportIds, getMore] = useInfiniteScroll(
handleLoadMore, userIds, reactors && reactors.nextOffset === undefined, handleLoadMore, userIds, reactors && reactors.nextOffset === undefined,
@ -137,6 +141,7 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
const count = reactions?.results.find((l) => l.reaction === reaction)?.count; const count = reactions?.results.find((l) => l.reaction === reaction)?.count;
return ( return (
<Button <Button
key={reaction}
className={buildClassName(chosenTab === reaction && 'chosen')} className={buildClassName(chosenTab === reaction && 'chosen')}
size="tiny" size="tiny"
ripple ripple
@ -186,7 +191,7 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
<Button <Button
className="confirm-dialog-button" className="confirm-dialog-button"
isText isText
onClick={closeReactorListModal} onClick={handleClose}
> >
{lang('Close')} {lang('Close')}
</Button> </Button>

View File

@ -89,6 +89,7 @@ type StateProps = {
canShowSeenBy?: boolean; canShowSeenBy?: boolean;
enabledReactions?: string[]; enabledReactions?: string[];
canScheduleUntilOnline?: boolean; canScheduleUntilOnline?: boolean;
maxUniqueReactions?: number;
}; };
const ContextMenuContainer: FC<OwnProps & StateProps> = ({ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
@ -117,6 +118,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canRemoveReaction, canRemoveReaction,
canEdit, canEdit,
enabledReactions, enabledReactions,
maxUniqueReactions,
isPrivate, isPrivate,
isCurrentUserPremium, isCurrentUserPremium,
canForward, canForward,
@ -402,6 +404,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canBuyPremium={canBuyPremium} canBuyPremium={canBuyPremium}
isOpen={isMenuOpen} isOpen={isMenuOpen}
enabledReactions={enabledReactions} enabledReactions={enabledReactions}
maxUniqueReactions={maxUniqueReactions}
anchor={anchor} anchor={anchor}
canShowReactionsCount={canShowReactionsCount} canShowReactionsCount={canShowReactionsCount}
canShowReactionList={canShowReactionList} canShowReactionList={canShowReactionList}
@ -488,7 +491,7 @@ export default memo(withGlobal<OwnProps>(
const { threadId } = selectCurrentMessageList(global) || {}; const { threadId } = selectCurrentMessageList(global) || {};
const activeDownloads = selectActiveDownloadIds(global, message.chatId); const activeDownloads = selectActiveDownloadIds(global, message.chatId);
const chat = selectChat(global, message.chatId); const chat = selectChat(global, message.chatId);
const { seenByExpiresAt, seenByMaxChatMembers } = global.appConfig || {}; const { seenByExpiresAt, seenByMaxChatMembers, maxUniqueReactions } = global.appConfig || {};
const { const {
noOptions, noOptions,
canReply, canReply,
@ -560,6 +563,7 @@ export default memo(withGlobal<OwnProps>(
activeDownloads, activeDownloads,
canShowSeenBy, canShowSeenBy,
enabledReactions: chat?.isForbidden ? undefined : chat?.fullInfo?.enabledReactions, enabledReactions: chat?.isForbidden ? undefined : chat?.fullInfo?.enabledReactions,
maxUniqueReactions,
isPrivate, isPrivate,
isCurrentUserPremium, isCurrentUserPremium,
hasFullInfo: Boolean(chat?.fullInfo), hasFullInfo: Boolean(chat?.fullInfo),

View File

@ -1,9 +1,9 @@
import type { FC } from '../../../lib/teact/teact';
import React, { import React, {
memo, useCallback, useEffect, useRef, memo, useMemo, useCallback, useEffect, useRef,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions } from '../../../global'; import { getActions } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { import type {
ApiAvailableReaction, ApiMessage, ApiSponsoredMessage, ApiStickerSet, ApiUser, ApiAvailableReaction, ApiMessage, ApiSponsoredMessage, ApiStickerSet, ApiUser,
} from '../../../api/types'; } from '../../../api/types';
@ -36,6 +36,7 @@ type OwnProps = {
message: ApiMessage | ApiSponsoredMessage; message: ApiMessage | ApiSponsoredMessage;
canSendNow?: boolean; canSendNow?: boolean;
enabledReactions?: string[]; enabledReactions?: string[];
maxUniqueReactions?: number;
canReschedule?: boolean; canReschedule?: boolean;
canReply?: boolean; canReply?: boolean;
canPin?: boolean; canPin?: boolean;
@ -103,6 +104,7 @@ const MessageContextMenu: FC<OwnProps> = ({
isPrivate, isPrivate,
isCurrentUserPremium, isCurrentUserPremium,
enabledReactions, enabledReactions,
maxUniqueReactions,
anchor, anchor,
canSendNow, canSendNow,
canReschedule, canReschedule,
@ -171,6 +173,11 @@ const MessageContextMenu: FC<OwnProps> = ({
const [isReady, markIsReady, unmarkIsReady] = useFlag(); const [isReady, markIsReady, unmarkIsReady] = useFlag();
const currentReactions = useMemo(() => {
if (isSponsoredMessage) return undefined;
return message.reactions?.results.map((reaction) => reaction.reaction);
}, [isSponsoredMessage, message]);
const handleAfterCopy = useCallback(() => { const handleAfterCopy = useCallback(() => {
showNotification({ showNotification({
message: lang('Share.Link.Copied'), message: lang('Share.Link.Copied'),
@ -276,6 +283,8 @@ const MessageContextMenu: FC<OwnProps> = ({
{canShowReactionList && ( {canShowReactionList && (
<ReactionSelector <ReactionSelector
enabledReactions={enabledReactions} enabledReactions={enabledReactions}
currentReactions={currentReactions}
maxUniqueReactions={maxUniqueReactions}
onSendReaction={onSendReaction!} onSendReaction={onSendReaction!}
isPrivate={isPrivate} isPrivate={isPrivate}
availableReactions={availableReactions} availableReactions={availableReactions}

View File

@ -1,6 +1,8 @@
import type { FC } from '../../../lib/teact/teact'; import React, {
import React, { memo, useLayoutEffect, useRef } from '../../../lib/teact/teact'; memo, useLayoutEffect, useMemo, useRef,
} from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import type { ApiAvailableReaction } from '../../../api/types'; import type { ApiAvailableReaction } from '../../../api/types';
import useHorizontalScroll from '../../../hooks/useHorizontalScroll'; import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
@ -8,10 +10,8 @@ import useFlag from '../../../hooks/useFlag';
import { getTouchY } from '../../../util/scrollLock'; import { getTouchY } from '../../../util/scrollLock';
import { createClassNameBuilder } from '../../../util/buildClassName'; import { createClassNameBuilder } from '../../../util/buildClassName';
import { IS_COMPACT_MENU } from '../../../util/environment'; import { IS_COMPACT_MENU } from '../../../util/environment';
import { getActions } from '../../../global';
import ReactionSelectorReaction from './ReactionSelectorReaction'; import ReactionSelectorReaction from './ReactionSelectorReaction';
import Button from '../../ui/Button';
import './ReactionSelector.scss'; import './ReactionSelector.scss';
@ -20,6 +20,8 @@ type OwnProps = {
onSendReaction: (reaction: string, x: number, y: number) => void; onSendReaction: (reaction: string, x: number, y: number) => void;
isPrivate?: boolean; isPrivate?: boolean;
availableReactions?: ApiAvailableReaction[]; availableReactions?: ApiAvailableReaction[];
currentReactions?: string[];
maxUniqueReactions?: number;
isReady?: boolean; isReady?: boolean;
canBuyPremium?: boolean; canBuyPremium?: boolean;
isCurrentUserPremium?: boolean; isCurrentUserPremium?: boolean;
@ -30,13 +32,12 @@ const cn = createClassNameBuilder('ReactionSelector');
const ReactionSelector: FC<OwnProps> = ({ const ReactionSelector: FC<OwnProps> = ({
availableReactions, availableReactions,
enabledReactions, enabledReactions,
onSendReaction, currentReactions,
maxUniqueReactions,
isPrivate, isPrivate,
isReady, isReady,
canBuyPremium, onSendReaction,
isCurrentUserPremium,
}) => { }) => {
const { openPremiumModal } = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const itemsScrollRef = useRef<HTMLDivElement>(null); const itemsScrollRef = useRef<HTMLDivElement>(null);
const [isHorizontalScrollEnabled, enableHorizontalScroll] = useFlag(false); const [isHorizontalScrollEnabled, enableHorizontalScroll] = useFlag(false);
@ -55,7 +56,17 @@ const ReactionSelector: FC<OwnProps> = ({
} }
}; };
if ((!isPrivate && !enabledReactions?.length) || !availableReactions) return undefined; const reactionsToRender = useMemo(() => {
return availableReactions?.map((reaction) => {
if (reaction.isInactive) return undefined;
if (!isPrivate && (!enabledReactions || !enabledReactions.includes(reaction.reaction))) return undefined;
if (maxUniqueReactions && currentReactions && currentReactions.length >= maxUniqueReactions
&& !currentReactions.includes(reaction.reaction)) return undefined;
return reaction;
}) || [];
}, [availableReactions, currentReactions, enabledReactions, isPrivate, maxUniqueReactions]);
if (!reactionsToRender.length) return undefined;
return ( return (
<div className={cn('&', IS_COMPACT_MENU && 'compact')} onWheelCapture={handleWheel} onTouchMove={handleWheel}> <div className={cn('&', IS_COMPACT_MENU && 'compact')} onWheelCapture={handleWheel} onTouchMove={handleWheel}>
@ -63,9 +74,8 @@ const ReactionSelector: FC<OwnProps> = ({
<div className={cn('bubble-small')} /> <div className={cn('bubble-small')} />
<div className={cn('items-wrapper')}> <div className={cn('items-wrapper')}>
<div className={cn('items', ['no-scrollbar'])} ref={itemsScrollRef}> <div className={cn('items', ['no-scrollbar'])} ref={itemsScrollRef}>
{availableReactions?.map((reaction, i) => { {reactionsToRender.map((reaction, i) => {
if (reaction.isInactive || (reaction.isPremium && !isCurrentUserPremium) if (!reaction) return undefined;
|| (!isPrivate && (!enabledReactions || !enabledReactions.includes(reaction.reaction)))) return undefined;
return ( return (
<ReactionSelectorReaction <ReactionSelectorReaction
key={reaction.reaction} key={reaction.reaction}
@ -76,23 +86,6 @@ const ReactionSelector: FC<OwnProps> = ({
/> />
); );
})} })}
{canBuyPremium && Boolean(
availableReactions
.filter((r) => r.isPremium && (!enabledReactions || enabledReactions.includes(r.reaction)))
.length,
) && (
<Button
round
color="translucent"
className={cn('blocked-button')}
// eslint-disable-next-line react/jsx-no-bind
onClick={() => openPremiumModal({
initialSection: 'unique_reactions',
})}
>
<i className="icon-lock-badge" />
</Button>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -224,17 +224,8 @@ addActionHandler('loadReactors', async (global, actions, payload) => {
global = addUsers(global, buildCollectionByKey(result.users, 'id')); global = addUsers(global, buildCollectionByKey(result.users, 'id'));
} }
const { nextOffset, count, reactions } = result;
setGlobal(updateChatMessage(global, chatId, messageId, { setGlobal(updateChatMessage(global, chatId, messageId, {
reactors: { reactors: result,
nextOffset,
count,
reactions: [
...(message.reactors?.reactions || []),
...reactions,
],
},
})); }));
}); });

View File

@ -36,11 +36,7 @@ export function subtractXForEmojiInteraction(global: GlobalState, x: number) {
} }
export function addMessageReaction(global: GlobalState, chatId: string, messageId: number, reaction: string) { export function addMessageReaction(global: GlobalState, chatId: string, messageId: number, reaction: string) {
const { reactions } = selectChatMessage(global, chatId, messageId) || {}; const reactions = selectChatMessage(global, chatId, messageId)?.reactions || { results: [] };
if (!reactions) {
return global;
}
// Update UI without waiting for server response // Update UI without waiting for server response
let results = reactions.results.map((l) => (l.reaction === reaction let results = reactions.results.map((l) => (l.reaction === reaction