Reactions: Add maximum unique reactions limit, add optimistic UI for first reaction (#2205)
This commit is contained in:
parent
6657e23889
commit
2a0ad055f1
@ -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,
|
||||||
|
|||||||
@ -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]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user