Verification: Support bot verification (#5396)

Co-authored-by: Alexander Zinchuk <alx.zinchuk@gmail.com>
This commit is contained in:
Alexander Zinchuk 2025-01-05 20:18:49 +01:00
parent 683384074a
commit 1e68ce4da7
20 changed files with 218 additions and 16 deletions

View File

@ -27,7 +27,7 @@ import { pick, pickTruthy } from '../../../util/iteratees';
import { getServerTime, getServerTimeOffset } from '../../../util/serverTime';
import { addPhotoToLocalDb, addUserToLocalDb, serializeBytes } from '../helpers';
import {
buildApiFormattedText, buildApiPhoto, buildApiUsernames, buildAvatarPhotoId,
buildApiBotVerification, buildApiFormattedText, buildApiPhoto, buildApiUsernames, buildAvatarPhotoId,
} from './common';
import { omitVirtualClassFields } from './helpers';
import {
@ -65,6 +65,8 @@ function buildApiChatFieldsFromPeerEntity(
const isForum = Boolean('forum' in peerEntity && peerEntity.forum);
const areStoriesHidden = Boolean('storiesHidden' in peerEntity && peerEntity.storiesHidden);
const maxStoryId = 'storiesMaxId' in peerEntity ? peerEntity.storiesMaxId : undefined;
const botVerificationIconId = 'botVerificationIconId' in peerEntity
? peerEntity.botVerificationIconId?.toString() : undefined;
const storiesUnavailable = Boolean('storiesUnavailable' in peerEntity && peerEntity.storiesUnavailable);
const color = ('color' in peerEntity && peerEntity.color) ? buildApiPeerColor(peerEntity.color) : undefined;
const emojiStatus = ('emojiStatus' in peerEntity && peerEntity.emojiStatus)
@ -105,6 +107,7 @@ function buildApiChatFieldsFromPeerEntity(
hasStories: Boolean(maxStoryId) && !storiesUnavailable,
emojiStatus,
boostLevel,
botVerificationIconId,
subscriptionUntil,
};
}
@ -685,7 +688,7 @@ export function buildApiSponsoredMessageReportResult(
export function buildApiChatInviteInfo(invite: GramJs.ChatInvite): ApiChatInviteInfo {
const {
color, participants, participantsCount, photo, title, about, scam, fake, verified, megagroup, channel, broadcast,
requestNeeded, subscriptionFormId, subscriptionPricing, canRefulfillSubscription,
requestNeeded, subscriptionFormId, subscriptionPricing, canRefulfillSubscription, botVerification,
} = invite;
let apiPhoto;
@ -714,6 +717,7 @@ export function buildApiChatInviteInfo(invite: GramJs.ChatInvite): ApiChatInvite
subscriptionPricing: subscriptionPricing && buildApiStarsSubscriptionPricing(subscriptionPricing),
canRefulfillSubscription,
participantIds: participants?.map((participant) => buildApiPeerId(participant.id, 'user')).filter(Boolean),
botVerification: botVerification && buildApiBotVerification(botVerification),
};
}

View File

@ -2,6 +2,7 @@ import { Api as GramJs } from '../../../lib/gramjs';
import { strippedPhotoToJpg } from '../../../lib/gramjs/Utils';
import type {
ApiBotVerification,
ApiFormattedText,
ApiMessageEntity,
ApiMessageEntityDefault,
@ -299,3 +300,11 @@ export function buildAvatarPhotoId(photo: GramJs.TypeUserProfilePhoto | GramJs.T
return undefined;
}
export function buildApiBotVerification(botVerification: GramJs.BotVerification): ApiBotVerification {
return {
botId: buildApiPeerId(botVerification.botId, 'user'),
iconId: botVerification.icon.toString(),
description: botVerification.description,
};
}

View File

@ -11,7 +11,9 @@ import type {
import { buildApiBotInfo } from './bots';
import { buildApiBusinessIntro, buildApiBusinessLocation, buildApiBusinessWorkHours } from './business';
import { buildApiPhoto, buildApiUsernames, buildAvatarPhotoId } from './common';
import {
buildApiBotVerification, buildApiPhoto, buildApiUsernames, buildAvatarPhotoId,
} from './common';
import { omitVirtualClassFields } from './helpers';
import { buildApiEmojiStatus, buildApiPeerColor, buildApiPeerId } from './peers';
@ -22,7 +24,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse
profilePhoto, voiceMessagesForbidden, premiumGifts, hasScheduled,
fallbackPhoto, personalPhoto, translationsDisabled, storiesPinnedAvailable,
contactRequirePremium, businessWorkHours, businessLocation, businessIntro,
birthday, personalChannelId, personalChannelMessage, sponsoredEnabled, stargiftsCount,
birthday, personalChannelId, personalChannelMessage, sponsoredEnabled, stargiftsCount, botVerification,
},
users,
} = mtpUserFull;
@ -49,6 +51,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse
businessIntro: businessIntro && buildApiBusinessIntro(businessIntro),
personalChannelId: personalChannelId && buildApiPeerId(personalChannelId, 'channel'),
personalChannelMessageId: personalChannelMessage,
botVerification: botVerification && buildApiBotVerification(botVerification),
areAdsEnabled: sponsoredEnabled,
starGiftCount: stargiftsCount,
hasScheduledMessages: hasScheduled,
@ -62,7 +65,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
const {
id, firstName, lastName, fake, scam, support, closeFriend, storiesUnavailable, storiesMaxId,
bot, botActiveUsers, botInlinePlaceholder, botAttachMenu, botCanEdit,
bot, botActiveUsers, botVerificationIcon, botInlinePlaceholder, botAttachMenu, botCanEdit,
} = mtpUser;
const hasVideoAvatar = mtpUser.photo instanceof GramJs.UserProfilePhoto ? Boolean(mtpUser.photo.hasVideo) : undefined;
const avatarPhotoId = mtpUser.photo && buildAvatarPhotoId(mtpUser.photo);
@ -99,6 +102,7 @@ export function buildApiUser(mtpUser: GramJs.TypeUser): ApiUser | undefined {
...(bot && botInlinePlaceholder && { botPlaceholder: botInlinePlaceholder }),
...(bot && botAttachMenu && { isAttachBot: botAttachMenu }),
botActiveUsers,
botVerificationIconId: botVerificationIcon?.toString(),
color: mtpUser.color && buildApiPeerColor(mtpUser.color),
};
}

View File

@ -49,7 +49,7 @@ import {
buildChatMembers,
getPeerKey,
} from '../apiBuilders/chats';
import { buildApiPhoto } from '../apiBuilders/common';
import { buildApiBotVerification, buildApiPhoto } from '../apiBuilders/common';
import { buildApiMessage, buildMessageDraft } from '../apiBuilders/messages';
import { buildApiPeerId, getApiChatIdFromMtpPeer } from '../apiBuilders/peers';
import { buildStickerSet } from '../apiBuilders/symbols';
@ -603,6 +603,7 @@ async function getFullChannelInfo(
emojiset,
boostsApplied,
boostsUnrestrict,
botVerification,
canViewRevenue: canViewMonetization,
paidReactionsAvailable,
hasScheduled,
@ -695,6 +696,7 @@ async function getFullChannelInfo(
hasPinnedStories: Boolean(storiesPinnedAvailable),
boostsApplied,
boostsToUnrestrict: boostsUnrestrict,
botVerification: botVerification && buildApiBotVerification(botVerification),
isPaidReactionAvailable: paidReactionsAvailable,
hasScheduledMessages: hasScheduled,
},

View File

@ -2,7 +2,7 @@ import type { ApiBotCommand } from './bots';
import type {
ApiChatReactions, ApiFormattedText, ApiInputMessageReplyInfo, ApiPhoto, ApiStickerSet,
} from './messages';
import type { ApiChatInviteImporter } from './misc';
import type { ApiBotVerification, ApiChatInviteImporter } from './misc';
import type {
ApiEmojiStatus, ApiFakeType, ApiUser, ApiUsername,
} from './users';
@ -48,6 +48,7 @@ export interface ApiChat {
isForum?: boolean;
isForumAsMessages?: true;
boostLevel?: number;
botVerificationIconId?: string;
// Calls
isCallActive?: boolean;
@ -143,6 +144,7 @@ export interface ApiChatFullInfo {
boostsApplied?: number;
boostsToUnrestrict?: number;
botVerification?: ApiBotVerification;
}
export interface ApiChatMember {

View File

@ -175,6 +175,7 @@ export type ApiChatInviteInfo = {
subscriptionFormId?: string;
canRefulfillSubscription?: boolean;
subscriptionPricing?: ApiStarsSubscriptionPricing;
botVerification?: ApiBotVerification;
};
export type ApiChatInviteImporter = {
@ -321,6 +322,12 @@ export interface ApiPeerPhotos {
isLoading?: boolean;
}
export interface ApiBotVerification {
botId: string;
iconId: string;
description: string;
}
export type ApiLimitType =
| 'uploadMaxFileparts'
| 'stickersFaved'

View File

@ -3,6 +3,7 @@ import type { ApiBotInfo } from './bots';
import type { ApiBusinessIntro, ApiBusinessLocation, ApiBusinessWorkHours } from './business';
import type { ApiPeerColor } from './chats';
import type { ApiDocument, ApiPhoto } from './messages';
import type { ApiBotVerification } from './misc';
import type { ApiUserStarGift } from './payments';
export interface ApiUser {
@ -36,6 +37,7 @@ export interface ApiUser {
canEditBot?: boolean;
hasMainMiniApp?: boolean;
botActiveUsers?: number;
botVerificationIconId?: string;
}
export interface ApiUserFullInfo {
@ -61,6 +63,7 @@ export interface ApiUserFullInfo {
businessIntro?: ApiBusinessIntro;
starGiftCount?: number;
hasScheduledMessages?: boolean;
botVerification?: ApiBotVerification;
}
export type ApiFakeType = 'fake' | 'scam';

View File

@ -107,9 +107,18 @@ const FullNameTitle: FC<OwnProps> = ({
return undefined;
}, [customPeer, isSavedDialog, isSavedMessages, lang, realPeer]);
const botVerificationIconId = realPeer?.botVerificationIconId;
return (
<div className={buildClassName('title', styles.root, className)}>
{botVerificationIconId && (
<CustomEmoji
documentId={botVerificationIconId}
size={emojiStatusSize}
loopLimit={!noLoopLimit ? EMOJI_STATUS_LOOP_LIMIT : undefined}
observeIntersectionForLoading={observeIntersection}
/>
)}
<h3
dir="auto"
role="button"

View File

@ -24,11 +24,21 @@
margin-bottom: 0;
}
.botVerificationSection,
.sectionInfo {
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.botVerificationSection {
padding-inline: 1.25rem;
}
.botVerificationIcon {
--custom-emoji-size: 1rem;
padding-inline-end: 0.125rem;
}
.personalChannelSubscribers {
grid-column: 2;
grid-row: 1;

View File

@ -5,6 +5,7 @@ import React, {
import { getActions, withGlobal } from '../../../global';
import type {
ApiBotVerification,
ApiChat, ApiCountryCode, ApiUser, ApiUserFullInfo, ApiUsername,
} from '../../../api/types';
import { MAIN_THREAD_ID } from '../../../api/types';
@ -49,6 +50,7 @@ import Button from '../../ui/Button';
import ListItem from '../../ui/ListItem';
import Skeleton from '../../ui/placeholder/Skeleton';
import Switcher from '../../ui/Switcher';
import CustomEmoji from '../CustomEmoji';
import SafeLink from '../SafeLink';
import BusinessHours from './BusinessHours';
import UserBirthday from './UserBirthday';
@ -75,6 +77,7 @@ type StateProps = {
hasSavedMessages?: boolean;
personalChannel?: ApiChat;
hasMainMiniApp?: boolean;
botVerification?: ApiBotVerification;
};
const DEFAULT_MAP_CONFIG = {
@ -84,6 +87,7 @@ const DEFAULT_MAP_CONFIG = {
};
const runDebounced = debounce((cb) => cb(), 500, false);
const BOT_VERIFICATION_ICON_SIZE = 16;
const ChatExtra: FC<OwnProps & StateProps> = ({
chatOrUserId,
@ -101,6 +105,7 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
hasSavedMessages,
personalChannel,
hasMainMiniApp,
botVerification,
}) => {
const {
showNotification,
@ -437,6 +442,16 @@ const ChatExtra: FC<OwnProps & StateProps> = ({
<span>{oldLang('SavedMessagesTab')}</span>
</ListItem>
)}
{botVerification && (
<div className={styles.botVerificationSection}>
<CustomEmoji
className={styles.botVerificationIcon}
documentId={botVerification.iconId}
size={BOT_VERIFICATION_ICON_SIZE}
/>
{botVerification.description}
</div>
)}
</div>
);
};
@ -455,6 +470,8 @@ export default memo(withGlobal<OwnProps>(
const chatFullInfo = chat && selectChatFullInfo(global, chat.id);
const userFullInfo = user && selectUserFullInfo(global, user.id);
const botVerification = userFullInfo?.botVerification || chatFullInfo?.botVerification;
const chatInviteLink = chatFullInfo?.inviteLink;
const description = userFullInfo?.bio || chatFullInfo?.about;
@ -488,6 +505,7 @@ export default memo(withGlobal<OwnProps>(
hasSavedMessages,
personalChannel,
hasMainMiniApp,
botVerification,
};
},
)(ChatExtra));

View File

@ -22,6 +22,7 @@ import { applyAnimationState, type PaneState } from './hooks/useHeaderPane';
import GroupCallTopPane from '../calls/group/GroupCallTopPane';
import AudioPlayer from './panes/AudioPlayer';
import BotAdPane from './panes/BotAdPane';
import BotVerificationPane from './panes/BotVerificationPane';
import ChatReportPane from './panes/ChatReportPane';
import HeaderPinnedMessage from './panes/HeaderPinnedMessage';
@ -65,6 +66,7 @@ const MiddleHeaderPanes = ({
const [getGroupCallState, setGroupCallState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const [getChatReportState, setChatReportState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const [getBotAdState, setBotAdState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const [getBotVerificationState, setBotVerificationState] = useSignal<PaneState>(FALLBACK_PANE_STATE);
const isPinnedMessagesFullWidth = isAudioPlayerRendered || !isDesktop;
@ -84,13 +86,15 @@ const MiddleHeaderPanes = ({
useSignalEffect(() => {
const audioPlayerState = getAudioPlayerState();
const botVerificationState = getBotVerificationState();
const pinnedState = getPinnedState();
const groupCallState = getGroupCallState();
const chatReportState = getChatReportState();
const botAdState = getBotAdState();
// Keep in sync with the order of the panes in the DOM
const stateArray = [audioPlayerState, groupCallState, chatReportState, pinnedState, botAdState];
const stateArray = [audioPlayerState, groupCallState,
chatReportState, botVerificationState, pinnedState, botAdState];
const isFirstRender = isFirstRenderRef.current;
const totalHeight = stateArray.reduce((acc, state) => acc + state.height, 0);
@ -103,7 +107,8 @@ const MiddleHeaderPanes = ({
setExtraStyles(middleColumn, {
'--middle-header-panes-height': `${totalHeight}px`,
});
}, [getAudioPlayerState, getGroupCallState, getPinnedState, getChatReportState, getBotAdState]);
}, [getAudioPlayerState, getGroupCallState, getPinnedState,
getChatReportState, getBotAdState, getBotVerificationState]);
if (!shouldRender) return undefined;
@ -128,6 +133,10 @@ const MiddleHeaderPanes = ({
isAutoArchived={settings?.isAutoArchived}
onPaneStateChange={setChatReportState}
/>
<BotVerificationPane
peerId={chatId}
onPaneStateChange={setBotVerificationState}
/>
<HeaderPinnedMessage
chatId={chatId}
threadId={threadId}

View File

@ -0,0 +1,18 @@
@use "../../../styles/mixins";
.root {
@include mixins.header-pane;
display: flex;
height: auto;
justify-content: center;
padding-inline: 1rem;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.icon {
--custom-emoji-size: 1rem;
padding-inline-end: 0.125rem;
}

View File

@ -0,0 +1,80 @@
import type { FC } from '../../../lib/teact/teact';
import React, { memo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiBotVerification } from '../../../api/types';
import {
selectPeerFullInfo,
} from '../../../global/selectors';
import useTimeout from '../../../hooks/schedulers/useTimeout';
import useLastCallback from '../../../hooks/useLastCallback';
import useHeaderPane, { type PaneState } from '../hooks/useHeaderPane';
import CustomEmoji from '../../common/CustomEmoji';
import styles from './BotVerificationPane.module.scss';
type OwnProps = {
peerId: string;
onPaneStateChange?: (state: PaneState) => void;
};
type StateProps = {
wasShown: boolean;
botVerification?: ApiBotVerification;
};
const BOT_VERIFICATION_ICON_SIZE = 16;
const DISPLAY_DURATION_MS = 5000; // 5 sec
const BotVerificationPane: FC<OwnProps & StateProps> = ({
peerId,
wasShown,
botVerification,
onPaneStateChange,
}) => {
const isOpen = Boolean(!wasShown && botVerification);
const {
markBotVerificationInfoShown,
} = getActions();
const { ref, shouldRender } = useHeaderPane({
isOpen,
onStateChange: onPaneStateChange,
});
const markAsShowed = useLastCallback(() => {
markBotVerificationInfoShown({ peerId });
});
useTimeout(markAsShowed, !wasShown ? DISPLAY_DURATION_MS : undefined);
if (!shouldRender || !botVerification) return undefined;
return (
<div ref={ref} className={styles.root}>
<span className={styles.icon}>
<CustomEmoji
documentId={botVerification.iconId}
size={BOT_VERIFICATION_ICON_SIZE}
/>
</span>
{botVerification.description}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { peerId }): StateProps => {
const peerFullInfo = selectPeerFullInfo(global, peerId);
const botVerification = peerFullInfo?.botVerification;
const wasShown = global.settings.botVerificationShownPeerIds.includes(peerId);
return {
botVerification,
wasShown,
};
},
)(BotVerificationPane));

View File

@ -21,6 +21,7 @@ export const DEBUG = process.env.APP_ENV !== 'production';
export const DEBUG_MORE = false;
export const DEBUG_LOG_FILENAME = 'tt-log.json';
export const STRICTERDOM_ENABLED = DEBUG;
export const BOT_VERIFICATION_PEERS_LIMIT = 20;
export const BETA_CHANGELOG_URL = 'https://telegra.ph/WebA-Beta-03-20';
export const ELECTRON_HOST_URL = process.env.ELECTRON_HOST_URL!;

View File

@ -2,6 +2,7 @@ import type { ApiUser } from '../../../api/types';
import type { ActionReturnType } from '../../types';
import { ManagementProgress } from '../../../types';
import { BOT_VERIFICATION_PEERS_LIMIT } from '../../../config';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { buildCollectionByKey, unique } from '../../../util/iteratees';
import * as langProvider from '../../../util/oldLangProvider';
@ -9,11 +10,7 @@ import { throttle } from '../../../util/schedulers';
import { getServerTime } from '../../../util/serverTime';
import { callApi } from '../../../api/gramjs';
import { isUserBot, isUserId } from '../../helpers';
import {
addActionHandler,
getGlobal,
setGlobal,
} from '../../index';
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import {
addUserStatuses,
closeNewContactDialog,
@ -508,3 +505,20 @@ addActionHandler('openSuggestedStatusModal', async (global, actions, payload): P
}, tabId);
setGlobal(global);
});
addActionHandler('markBotVerificationInfoShown', (global, actions, payload): ActionReturnType => {
const { peerId } = payload;
const currentPeerIds = global.settings.botVerificationShownPeerIds;
const newPeerIds = unique([peerId, ...currentPeerIds]).slice(0, BOT_VERIFICATION_PEERS_LIMIT);
global = {
...global,
settings: {
...global.settings,
botVerificationShownPeerIds: newPeerIds,
},
};
setGlobal(global);
});

View File

@ -260,6 +260,9 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) {
if (!cached.messages.pollById) {
cached.messages.pollById = initialState.messages.pollById;
}
if (!cached.settings.botVerificationShownPeerIds) {
cached.settings.botVerificationShownPeerIds = initialState.settings.botVerificationShownPeerIds;
}
}
function updateCache(force?: boolean) {
@ -610,7 +613,9 @@ function omitLocalMedia(message: ApiMessage): ApiMessage {
}
function reduceSettings<T extends GlobalState>(global: T): GlobalState['settings'] {
const { byKey, themes, performance } = global.settings;
const {
byKey, themes, performance, botVerificationShownPeerIds,
} = global.settings;
return {
byKey,
@ -618,6 +623,7 @@ function reduceSettings<T extends GlobalState>(global: T): GlobalState['settings
performance,
privacy: {},
notifyExceptions: {},
botVerificationShownPeerIds,
};
}

View File

@ -296,6 +296,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
performance: INITIAL_PERFORMANCE_STATE_MAX,
privacy: {},
notifyExceptions: {},
botVerificationShownPeerIds: [],
},
serviceNotifications: [],

View File

@ -269,6 +269,9 @@ export interface ActionPayloads {
saveCloseFriends: {
userIds: string[];
};
markBotVerificationInfoShown: {
peerId: string;
};
// Message search
openMiddleSearch: WithTabId | undefined;

View File

@ -401,6 +401,7 @@ export type GlobalState = {
lastPremiumBandwithNotificationDate?: number;
paidReactionPrivacy?: boolean;
languages?: ApiLanguage[];
botVerificationShownPeerIds: string[];
};
push?: {

View File

@ -10,7 +10,8 @@ import useResizeObserver from '../useResizeObserver';
const TRANSITION_PROPERTY = 'color';
const TRANSITION_STYLE = `50ms ${TRANSITION_PROPERTY} linear`;
export default function useDynamicColorListener(ref: React.RefObject<HTMLElement>, isDisabled?: boolean) {
export default function useDynamicColorListener(ref: React.RefObject<HTMLElement>,
isDisabled?: boolean) {
const [hexColor, setHexColor] = useState<string | undefined>();
const updateColor = useLastCallback(() => {