Reactions: Support limit in channels and groups (#4784)

This commit is contained in:
Alexander Zinchuk 2024-08-06 20:06:41 +02:00
parent 16c442b9cf
commit f3743bd556
13 changed files with 181 additions and 32 deletions

View File

@ -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[],

View File

@ -514,6 +514,7 @@ async function getFullChatInfo(chatId: string): Promise<FullChatData | undefined
requestsPending,
chatPhoto,
translationsDisabled,
reactionsLimit,
} = result.fullChat;
if (chatPhoto) {
@ -538,6 +539,7 @@ async function getFullChatInfo(chatId: string): Promise<FullChatData | undefined
inviteLink,
groupCallId: call?.id.toString(),
enabledReactions: buildApiChatReactions(availableReactions),
reactionsLimit,
requestsPending,
recentRequesterIds: recentRequesters?.map((userId) => 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,
});

View File

@ -123,6 +123,7 @@ export interface ApiChatFullInfo {
linkedChatId?: string;
botCommands?: ApiBotCommand[];
enabledReactions?: ApiChatReactions;
reactionsLimit?: number;
sendAsId?: string;
canViewStatistics?: boolean;
recentRequesterIds?: string[];

View File

@ -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<OwnProps & StateProps> = ({
canShowReactionList,
canEdit,
enabledReactions,
maxUniqueReactions,
reactionsLimit,
isPrivate,
isCurrentUserPremium,
canForward,
@ -576,7 +576,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canBuyPremium={canBuyPremium}
isOpen={isMenuOpen}
enabledReactions={enabledReactions}
maxUniqueReactions={maxUniqueReactions}
reactionsLimit={reactionsLimit}
anchor={anchor}
targetHref={targetHref}
canShowReactionsCount={canShowReactionsCount}
@ -674,9 +674,15 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
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<OwnProps>(
&& 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<OwnProps>(
canLoadReadDate,
shouldRenderShowWhen,
enabledReactions: chat?.isForbidden ? undefined : chatFullInfo?.enabledReactions,
maxUniqueReactions,
reactionsLimit,
isPrivate,
isCurrentUserPremium,
hasFullInfo: Boolean(chatFullInfo),

View File

@ -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<OwnProps> = ({
isPrivate,
isCurrentUserPremium,
enabledReactions,
maxUniqueReactions,
reactionsLimit,
anchor,
targetHref,
canSendNow,
@ -364,7 +364,7 @@ const MessageContextMenu: FC<OwnProps> = ({
allAvailableReactions={availableReactions}
defaultTagReactions={defaultTagReactions}
currentReactions={!isSponsoredMessage ? message.reactions?.results : undefined}
maxUniqueReactions={maxUniqueReactions}
reactionsLimit={reactionsLimit}
onToggleReaction={onToggleReaction!}
isPrivate={isPrivate}
isReady={isReady}

View File

@ -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<OwnProps> = ({
defaultTagReactions,
enabledReactions,
currentReactions,
maxUniqueReactions,
reactionsLimit,
isPrivate,
isReady,
canPlayAnimatedEmojis,
@ -75,8 +75,8 @@ const ReactionSelector: FC<OwnProps> = ({
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<OwnProps> = ({
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<OwnProps> = ({
}, [
allAvailableReactions, currentReactions, defaultTagReactions, enabledReactions, isInSavedMessages, isPrivate,
topReactions, isForEffects, effectReactions, shouldUseCurrentReactions,
]);
const reactionsToRender = useMemo(() => {

View File

@ -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<OwnProps & StateProps> = ({
@ -39,6 +46,9 @@ const ManageReactions: FC<OwnProps & StateProps> = ({
chat,
isActive,
onClose,
maxUniqueReactions,
reactionsLimit,
isChannel,
}) => {
const { setChatEnabledReactions } = getActions();
@ -47,6 +57,8 @@ const ManageReactions: FC<OwnProps & StateProps> = ({
const [isLoading, setIsLoading] = useState(false);
const [localEnabledReactions, setLocalEnabledReactions] = useState<ApiChatReactions | undefined>(enabledReactions);
const [localReactionsLimit, setLocalReactionsLimit] = useState(reactionsLimit);
useHistoryBack({
isActive,
onBack: onClose,
@ -70,33 +82,80 @@ const ManageReactions: FC<OwnProps & StateProps> = ({
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<ApiAvailableReaction[] | undefined>(
() => 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<HTMLInputElement>) => {
if (!chat || !availableActiveReactions) return;
@ -116,12 +175,40 @@ const ManageReactions: FC<OwnProps & StateProps> = ({
});
}
}
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 (
<div className="Management">
<div className="custom-scroll">
{ localReactionsLimit && shouldShowReactionsLimit && (
<div className="section">
<h3 className="section-heading">
{lang('MaximumReactionsHeader')}
</h3>
<RangeSlider
min={1}
max={maxUniqueReactions}
value={localReactionsLimit}
onChange={handleReactionsLimitChange}
renderValue={renderReactionsMaxCountValue}
isCenteredLayout
/>
<p className="section-info mt-4">
{lang('ChannelReactions.MaxCount.Info')}
</p>
</div>
)}
<div className="section">
<h3 className="section-heading">
{lang('AvailableReactions')}
@ -141,7 +228,7 @@ const ManageReactions: FC<OwnProps & StateProps> = ({
{localEnabledReactions?.type === 'some' && (
<div className="section">
<h3 className="section-heading">
{lang('AvailableReactions')}
{lang('OnlyAllowThisReactions')}
</h3>
{availableActiveReactions?.map(({ reaction, title }) => (
<div className="ListItem">
@ -181,11 +268,19 @@ const ManageReactions: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(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 }) => {

View File

@ -135,6 +135,10 @@
color: var(--color-text);
}
.RangeSlider {
margin-top: 2rem;
}
.radio-group {
margin-top: 2rem;

View File

@ -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();

View File

@ -20,6 +20,7 @@ type OwnProps = {
className?: string;
renderValue?: (value: number) => string;
onChange: (value: number) => void;
isCenteredLayout?: boolean;
};
const RangeSlider: FC<OwnProps> = ({
@ -34,6 +35,7 @@ const RangeSlider: FC<OwnProps> = ({
className,
renderValue,
onChange,
isCenteredLayout,
}) => {
const lang = useOldLang();
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
@ -56,16 +58,38 @@ const RangeSlider: FC<OwnProps> = ({
}
}, [options, value, max, min, step]);
return (
<div className={mainClassName}>
{label && (
function renderTopRow() {
if (isCenteredLayout) {
return (
<div className="slider-top-row" dir={lang.isRtl ? 'rtl' : undefined}>
<span className="label" dir="auto">{label}</span>
{!options && (
<span className="value" dir="auto">{renderValue ? renderValue(value) : value}</span>
<>
<span className="value-min" dir="auto">{min}</span>
<span className="label" dir="auto">{renderValue ? renderValue(value) : value}</span>
<span className="value-max" dir="auto">{max}</span>
</>
)}
</div>
)}
);
}
if (!label) {
return undefined;
}
return (
<div className="slider-top-row" dir={lang.isRtl ? 'rtl' : undefined}>
<span className="label" dir="auto">{label}</span>
{!options && (
<span className="value" dir="auto">{renderValue ? renderValue(value) : value}</span>
)}
</div>
);
}
return (
<div className={mainClassName}>
{renderTopRow()}
<div className="slider-main">
<div
className="slider-fill-track"

View File

@ -339,6 +339,7 @@ export const PEER_COLOR_BG_OPACITY = '1a';
export const PEER_COLOR_BG_ACTIVE_OPACITY = '2b';
export const PEER_COLOR_GRADIENT_STEP = 5; // px
export const MAX_UPLOAD_FILEPART_SIZE = 524288;
export const MAX_UNIQUE_REACTIONS = 11;
// Group calls
export const GROUP_CALL_VOLUME_MULTIPLIER = 100;

View File

@ -1953,13 +1953,16 @@ addActionHandler('toggleIsProtected', (global, actions, payload): ActionReturnTy
});
addActionHandler('setChatEnabledReactions', async (global, actions, payload): Promise<void> => {
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();

View File

@ -2335,6 +2335,7 @@ export interface ActionPayloads {
setChatEnabledReactions: {
chatId: string;
enabledReactions?: ApiChatReactions;
reactionsLimit?: number;
} & WithTabId;
startActiveReaction: {