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 || [],
autologinToken: appConfig.autologin_token || '',
urlAuthDomains: appConfig.url_auth_domains || [],
maxUniqueReactions: appConfig.reactions_uniq_max,
premiumBotUsername: appConfig.premium_bot_username,
premiumInvoiceSlug: appConfig.premium_invoice_slug,
premiumPromoOrder: appConfig.premium_promo_order,

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo, useLayoutEffect, useRef } from '../../../lib/teact/teact';
import React, {
memo, useLayoutEffect, useMemo, useRef,
} from '../../../lib/teact/teact';
import type { FC } from '../../../lib/teact/teact';
import type { ApiAvailableReaction } from '../../../api/types';
import useHorizontalScroll from '../../../hooks/useHorizontalScroll';
@ -8,10 +10,8 @@ import useFlag from '../../../hooks/useFlag';
import { getTouchY } from '../../../util/scrollLock';
import { createClassNameBuilder } from '../../../util/buildClassName';
import { IS_COMPACT_MENU } from '../../../util/environment';
import { getActions } from '../../../global';
import ReactionSelectorReaction from './ReactionSelectorReaction';
import Button from '../../ui/Button';
import './ReactionSelector.scss';
@ -20,6 +20,8 @@ type OwnProps = {
onSendReaction: (reaction: string, x: number, y: number) => void;
isPrivate?: boolean;
availableReactions?: ApiAvailableReaction[];
currentReactions?: string[];
maxUniqueReactions?: number;
isReady?: boolean;
canBuyPremium?: boolean;
isCurrentUserPremium?: boolean;
@ -30,13 +32,12 @@ const cn = createClassNameBuilder('ReactionSelector');
const ReactionSelector: FC<OwnProps> = ({
availableReactions,
enabledReactions,
onSendReaction,
currentReactions,
maxUniqueReactions,
isPrivate,
isReady,
canBuyPremium,
isCurrentUserPremium,
onSendReaction,
}) => {
const { openPremiumModal } = getActions();
// eslint-disable-next-line no-null/no-null
const itemsScrollRef = useRef<HTMLDivElement>(null);
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 (
<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('items-wrapper')}>
<div className={cn('items', ['no-scrollbar'])} ref={itemsScrollRef}>
{availableReactions?.map((reaction, i) => {
if (reaction.isInactive || (reaction.isPremium && !isCurrentUserPremium)
|| (!isPrivate && (!enabledReactions || !enabledReactions.includes(reaction.reaction)))) return undefined;
{reactionsToRender.map((reaction, i) => {
if (!reaction) return undefined;
return (
<ReactionSelectorReaction
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>

View File

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

View File

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