Channel Admin: Add leave channel modal (#6656)

Co-authored-by: Alexander Zinchuk <alx.zinchuk@gmail.com>
This commit is contained in:
Alexander Zinchuk 2026-02-22 23:43:38 +01:00
parent c22d035308
commit 6c3f009388
33 changed files with 1092 additions and 42 deletions

View File

@ -116,6 +116,7 @@ Convesion from and to Api* objects is done by `apiBuilders` (function name start
const result = await callApi('methodName', { /* params */ });
```
* Always check for `undefined` before proceeding.
* **IMPORTANT: Do not pass `accessHash` directly to API methods.** Methods that accept separate `id` and `accessHash` parameters are outdated. Instead, pass the full `ApiPeer`, `ApiChat`, or `ApiUser` object. The `buildInput*` functions in `gramjsBuilders` will extract the necessary fields.
## 4. Example

View File

@ -556,6 +556,22 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess
price: buildApiCurrencyAmount(price),
};
}
if (action instanceof GramJs.MessageActionNewCreatorPending) {
const { newCreatorId } = action;
return {
mediaType: 'action',
type: 'newCreatorPending',
newCreatorId: buildApiPeerId(newCreatorId, 'user'),
};
}
if (action instanceof GramJs.MessageActionChangeCreator) {
const { newCreatorId } = action;
return {
mediaType: 'action',
type: 'changeCreator',
newCreatorId: buildApiPeerId(newCreatorId, 'user'),
};
}
return UNSUPPORTED_ACTION;
}

View File

@ -117,6 +117,11 @@ export function wrapError<T extends Error>(error: T): WrappedError<T> {
key: 'ErrorPasswordFresh',
variables: { time: formatWait(error.seconds) },
};
} else if (error instanceof errors.SessionFreshError) {
messageKey = {
key: 'ErrorSessionFresh',
variables: { time: formatWait(error.seconds) },
};
} else if (error instanceof errors.RPCError) {
messageKey = {
key: ERROR_KEYS[error.errorMessage],

View File

@ -1,5 +1,5 @@
import { Api as GramJs } from '../../../lib/gramjs';
import { RPCError } from '../../../lib/gramjs/errors';
import { PasswordFreshError, RPCError, SessionFreshError } from '../../../lib/gramjs/errors';
import type { ChatListType, ThreadReadState } from '../../../types';
import {
@ -82,13 +82,14 @@ import {
import {
addPhotoToLocalDb,
} from '../helpers/localDb';
import { isChatFolder } from '../helpers/misc';
import { checkErrorType, isChatFolder, wrapError } from '../helpers/misc';
import { scheduleMutedChatUpdate } from '../scheduleUnmute';
import { sendApiUpdate } from '../updates/apiUpdateEmitter';
import {
applyState, updateChannelState,
} from '../updates/updateManager';
import { handleGramJsUpdate, invokeRequest, uploadFile } from './client';
import { getPassword } from './twoFaSettings';
type FullChatData = {
fullInfo: ApiChatFullInfo;
@ -960,18 +961,88 @@ export function deleteChat({
});
}
export function leaveChannel({
channelId, accessHash,
}: {
channelId: string; accessHash: string;
}) {
export function leaveChannel({ chat }: { chat: ApiChat }) {
return invokeRequest(new GramJs.channels.LeaveChannel({
channel: buildInputChannel(channelId, accessHash),
channel: buildInputChannel(chat.id, chat.accessHash),
}), {
shouldReturnTrue: true,
});
}
export async function fetchFutureCreatorAfterLeave({ chat }: { chat: ApiChat }) {
const result = await invokeRequest(new GramJs.channels.GetFutureCreatorAfterLeave({
channel: buildInputChannel(chat.id, chat.accessHash),
}));
if (!result) {
return undefined;
}
return buildApiUser(result);
}
export async function verifyTransferOwnership({
chat, user,
}: {
chat: ApiChat;
user: ApiUser;
}) {
try {
await invokeRequest(new GramJs.channels.EditCreator({
channel: buildInputChannel(chat.id, chat.accessHash),
userId: buildInputUser(user.id, user.accessHash),
password: new GramJs.InputCheckPasswordEmpty(),
}), {
shouldReturnTrue: true,
shouldThrow: true,
});
return { canTransfer: true };
} catch (err: any) {
if (!checkErrorType(err)) return undefined;
if (err instanceof RPCError && err.errorMessage === 'PASSWORD_HASH_INVALID') return { canTransfer: true };
if (err instanceof PasswordFreshError) return { errorMessage: 'PASSWORD_TOO_FRESH' };
if (err instanceof SessionFreshError) return { errorMessage: 'SESSION_TOO_FRESH' };
if (err instanceof RPCError && err.errorMessage === 'PASSWORD_MISSING') return { errorMessage: 'PASSWORD_MISSING' };
return wrapError(err);
}
}
export async function editChannelCreator({
chat, user, password,
}: {
chat: ApiChat;
user: ApiUser;
password: string;
}) {
try {
const passwordCheck = await getPassword(password);
if (!passwordCheck) {
return undefined;
}
if ('error' in passwordCheck) {
return passwordCheck;
}
return invokeRequest(new GramJs.channels.EditCreator({
channel: buildInputChannel(chat.id, chat.accessHash),
userId: buildInputUser(user.id, user.accessHash),
password: passwordCheck,
}), {
shouldReturnTrue: true,
shouldThrow: true,
});
} catch (err) {
if (!checkErrorType(err)) return undefined;
return wrapError(err);
}
}
export function deleteChannel({
channelId, accessHash,
}: {

View File

@ -346,6 +346,16 @@ export interface ApiMessageActionStarGiftPurchaseOfferDeclined extends ActionMed
price: ApiTypeCurrencyAmount;
}
export interface ApiMessageActionNewCreatorPending extends ActionMediaType {
type: 'newCreatorPending';
newCreatorId: string;
}
export interface ApiMessageActionChangeCreator extends ActionMediaType {
type: 'changeCreator';
newCreatorId: string;
}
export interface ApiMessageActionUnsupported extends ActionMediaType {
type: 'unsupported';
}
@ -366,4 +376,5 @@ export type ApiMessageAction = ApiMessageActionUnsupported | ApiMessageActionCha
| ApiMessageActionPaidMessagesRefunded | ApiMessageActionPaidMessagesPrice | ApiMessageActionSuggestedPostApproval
| ApiMessageActionSuggestedPostSuccess | ApiMessageActionSuggestedPostRefund | ApiMessageActionTodoCompletions
| ApiMessageActionTodoAppendTasks | ApiMessageActionStarGiftPurchaseOffer
| ApiMessageActionStarGiftPurchaseOfferDeclined;
| ApiMessageActionStarGiftPurchaseOfferDeclined | ApiMessageActionNewCreatorPending
| ApiMessageActionChangeCreator;

View File

@ -707,6 +707,7 @@
"ErrorPhoneBanned" = "This phone number is banned";
"ErrorFloodTime" = "Too many attempts, please try again in {time}";
"ErrorPasswordFresh" = "The password was modified recently, try again in {time}";
"ErrorSessionFresh" = "For security reasons, please wait {time} or use an older session";
"ErrorUnexpected" = "Unexpected error";
"ErrorUnexpectedMessage" = "Unexpected error: {error}";
"ErrorEmailUnconfirmed" = "Email not confirmed";
@ -966,6 +967,12 @@
"ChannelEditAdminCannotEdit" = "You can't edit the rights of this admin.";
"EditAdminRank" = "Custom title";
"EditAdminRemoveAdmin" = "Dismiss Admin";
"EditAdminTransferChannelOwnership" = "Transfer Channel Ownership";
"EditAdminTransferGroupOwnership" = "Transfer Group Ownership";
"EditAdminTransferOwnershipText" = "This will transfer **full owner rights** for **{chat}** to **{user}**. The new owner will be free to remove any of your admin privileges or even ban you.";
"EditAdminTransferChangeOwner" = "Change Owner";
"EditAdminTransferChannelOwnershipSuccess" = "{user} is now the owner of the channel.";
"EditAdminTransferGroupOwnershipSuccess" = "{user} is now the owner of the group.";
"ChannelAdminDismiss" = "Dismiss Admin";
"ChannelPermissionsHeader" = "What can members of this group do?";
"UserRestrictionsSend" = "Send Messages";
@ -2388,6 +2395,8 @@
"StarGiftAuctionBidRefundedTransaction" = "Refunded Auction Bid";
"ActionStarGiftPrepaidUpgraded" = "{user} turned the gift into a unique collectible";
"ActionStarGiftPrepaidUpgradedYou" = "You turned the gift into a unique collectible";
"ActionNewCreatorPending" = "{user} will become the new owner in 7 days if {from} does not return.";
"ActionChangeCreator" = "{from} has transferred ownership of the group to {user}.";
"UserNoteTitle" = "Notes";
"UserNoteHint" = "only visible to you";
"EditUserNoteHint" = "Notes are only visible to you.";
@ -2609,6 +2618,21 @@
"SettingsDataClearMediaCache" = "Clear Media Cache";
"SettingsDataClearMediaCacheDescription" = "Deletes locally cached media for this account";
"SettingsDataClearMediaDone" = "Media cache cleared";
"LeaveGroupTitle" = "Leave {group}?";
"LeaveGroupDescription" = "If you leave, **{nextOwner}** will become the new owner of **{group}** in 1 week.";
"LeaveGroupAppointOwner" = "Appoint Another Owner";
"LeaveGroupAdmins" = "GROUP ADMINS";
"LeaveGroupMembers" = "GROUP MEMBERS";
"LeaveGroupJoinedDate" = "joined {date}";
"SecurityCheck" = "Security Check";
"SecurityCheckInfo" = "You can only confirm this action if:";
"SecurityCheckTwoStepEnabled" = "2-Step verification is active and has been enabled for your account for more than **7 days**.";
"SecurityCheckTwoStepNotChanged" = "Your 2-Step Verification password has not been changed the last **7 days**.";
"SecurityCheckLoggedIn" = "You have been logged in on this device for more than **24 hours**.";
"SecurityCheckEnableTwoStep" = "Enable 2-Step Verification";
"EnterPassword" = "Enter Password";
"EnterPasswordDescription" = "Please enter your Two-Step Verification password to complete this action.";
"Transfer" = "Transfer";
"TranslateMenuCocoon" = "Translations are powered by 🥚 **Cocoon**. {link}";
"TranslateMenuCocoonLinkText" = "How does it work?";
"CocoonTitle" = "Cocoon";

View File

@ -107,5 +107,7 @@ export { default as FrozenAccountModal } from '../components/modals/frozenAccoun
export { default as ProfileRatingModal } from '../components/modals/profileRating/ProfileRatingModal';
export { default as QuickPreviewModal } from '../components/modals/quickPreview/QuickPreviewModal';
export { default as StealthModeModal } from '../components/modals/storyStealthMode/StealthModeModal';
export { default as LeaveGroupModal } from '../components/modals/leaveGroup/LeaveGroupModal';
export { default as TwoFaCheckModal } from '../components/modals/twoFaCheck/TwoFaCheckModal';
export { default as QuickChatPickerModal } from '../components/modals/quickChatPicker/QuickChatPickerModal';
export { default as CocoonModal } from '../components/modals/cocoon/CocoonModal';

View File

@ -1,4 +1,3 @@
import type { FC } from '../../lib/teact/teact';
import { memo } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
@ -46,7 +45,7 @@ type StateProps = {
contactName?: string;
};
const DeleteChatModal: FC<OwnProps & StateProps> = ({
const DeleteChatModal = ({
isOpen,
chat,
isSavedDialog,
@ -61,7 +60,7 @@ const DeleteChatModal: FC<OwnProps & StateProps> = ({
contactName,
onClose,
onCloseAnimationEnd,
}) => {
}: OwnProps & StateProps) => {
const {
leaveChannel,
deleteHistory,

View File

@ -0,0 +1,4 @@
.description {
margin-bottom: 1rem;
color: var(--color-text-secondary);
}

View File

@ -0,0 +1,102 @@
import { memo, useState } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { GlobalState } from '../../global/types';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import ConfirmDialog from '../ui/ConfirmDialog';
import PasswordForm from './PasswordForm';
import PasswordMonkey from './PasswordMonkey';
import styles from './PasswordConfirmModal.module.scss';
type OwnProps = {
isOpen: boolean;
title?: string;
confirmLabel?: string;
description?: string;
onClose: NoneToVoidFunction;
onSubmit: (password: string) => void;
};
type StateProps = {
error?: GlobalState['twoFaSettings']['errorKey'];
hint?: string;
isLoading?: boolean;
};
const PasswordConfirmModal = ({
isOpen,
title,
confirmLabel,
description,
error,
hint,
isLoading,
onClose,
onSubmit,
}: OwnProps & StateProps) => {
const { checkPassword, clearTwoFaError } = getActions();
const lang = useLang();
const [shouldShowPassword, setShouldShowPassword] = useState(false);
const [password, setPassword] = useState('');
const handlePasswordChange = useLastCallback((value: string) => {
setPassword(value);
});
const handleSubmit = useLastCallback(() => {
checkPassword({
currentPassword: password,
onSuccess: () => {
onSubmit(password);
setPassword('');
setShouldShowPassword(false);
},
});
});
const handleClose = useLastCallback(() => {
onClose();
clearTwoFaError();
setPassword('');
setShouldShowPassword(false);
});
return (
<ConfirmDialog
isOpen={isOpen}
title={title || lang('EnterPassword')}
confirmLabel={confirmLabel || lang('AutoDeleteConfirm')}
confirmHandler={handleSubmit}
confirmIsDestructive
onClose={handleClose}
>
<PasswordMonkey isBig isPasswordVisible={shouldShowPassword} />
{description && <p className={styles.description}>{description}</p>}
<PasswordForm
error={error && lang.withRegular(error)}
hint={hint}
isLoading={isLoading}
isPasswordVisible={shouldShowPassword}
onChangePasswordVisibility={setShouldShowPassword}
onClearError={clearTwoFaError}
onInputChange={handlePasswordChange}
/>
</ConfirmDialog>
);
};
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
const { errorKey, hint, isLoading } = global.twoFaSettings;
return {
error: errorKey,
hint,
isLoading,
};
},
)(PasswordConfirmModal));

View File

@ -0,0 +1,14 @@
.root {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
margin-top: 0.5rem;
margin-bottom: 1rem;
}
.arrow {
font-size: 2rem;
color: var(--color-text-secondary);
}

View File

@ -0,0 +1,30 @@
import { memo } from '../../lib/teact/teact';
import type { ApiPeer } from '../../api/types';
import { REM } from './helpers/mediaDimensions';
import Avatar from './Avatar';
import Icon from './icons/Icon';
import styles from './TransferBetweenPeers.module.scss';
type OwnProps = {
fromPeer?: ApiPeer;
toPeer?: ApiPeer;
avatarSize?: number;
};
const DEFAULT_AVATAR_SIZE = 4 * REM;
const TransferBetweenPeers = ({ fromPeer, toPeer, avatarSize = DEFAULT_AVATAR_SIZE }: OwnProps) => {
return (
<div className={styles.root}>
<Avatar peer={fromPeer} size={avatarSize} />
<Icon name="next" className={styles.arrow} />
<Avatar peer={toPeer} size={avatarSize} />
</div>
);
};
export default memo(TransferBetweenPeers);

View File

@ -1,4 +1,4 @@
import type React from '../../../lib/teact/teact';
import type { TeactNode } from '../../../lib/teact/teact';
import {
memo, useCallback, useEffect, useMemo, useRef,
} from '../../../lib/teact/teact';
@ -58,10 +58,25 @@ type MultipleModeProps<CategoryType extends string> = {
onSelectedIdsChange?: (Ids: string[]) => void;
};
export type PeerPickerSection = {
key: string;
title: string;
ids: string[];
};
type ItemIdsProps = {
itemIds: string[];
sections?: undefined;
};
type SectionsProps = {
sections: PeerPickerSection[];
itemIds?: undefined;
};
type OwnProps<CategoryType extends string> = {
className?: string;
categories?: UniqueCustomPeer<CategoryType>[];
itemIds: string[];
lockedUnselectedSubtitle?: string;
filterValue?: string;
filterPlaceholder?: string;
@ -81,7 +96,7 @@ type OwnProps<CategoryType extends string> = {
onFilterChange?: (value: string) => void;
onDisabledClick?: (id: string, isSelected: boolean) => void;
onLoadMore?: () => void;
} & (SingleModeProps<CategoryType> | MultipleModeProps<CategoryType>);
} & (ItemIdsProps | SectionsProps) & (SingleModeProps<CategoryType> | MultipleModeProps<CategoryType>);
const MAX_FULL_ITEMS = 10;
const ALWAYS_FULL_ITEMS_COUNT = 5;
@ -91,7 +106,8 @@ const ITEM_CLASS_NAME = 'PeerPickerItem';
const PeerPicker = <CategoryType extends string = CustomPeerType>({
className,
categories,
itemIds,
itemIds: itemIdsProp,
sections,
categoryPlaceholderKey,
filterValue,
filterPlaceholder,
@ -117,6 +133,11 @@ const PeerPicker = <CategoryType extends string = CustomPeerType>({
const oldLang = useOldLang();
const lang = useLang();
const itemIds = useMemo(() => {
if (itemIdsProp) return itemIdsProp;
return sections.flatMap((section) => section.ids);
}, [itemIdsProp, sections]);
const allowMultiple = optionalProps.allowMultiple;
const lockedSelectedIds = allowMultiple ? optionalProps.lockedSelectedIds : undefined;
const lockedUnselectedIds = allowMultiple ? optionalProps.lockedUnselectedIds : undefined;
@ -344,6 +365,28 @@ const PeerPicker = <CategoryType extends string = CustomPeerType>({
);
}, [categories, categoryPlaceholderKey, oldLang, renderItem]);
const renderItems = useCallback(() => {
if (!sections) {
return viewportIds?.map((id) => renderItem(id));
}
const result: TeactNode[] = [];
sections.forEach((section) => {
if (section.ids.length === 0) return;
result.push(
<div key={section.key} className={styles.sectionHeader}>{section.title}</div>,
);
section.ids.forEach((id) => {
result.push(renderItem(id));
});
});
return result;
}, [sections, viewportIds, renderItem]);
const hasContent = sections
? sections.some((s) => s.ids.length > 0)
: Boolean(viewportIds?.length);
return (
<div className={buildClassName(styles.container, className)}>
{isSearchable && (
@ -389,7 +432,7 @@ const PeerPicker = <CategoryType extends string = CustomPeerType>({
</div>
)}
{viewportIds?.length ? (
{hasContent ? (
<InfiniteScroll
className={buildClassName(styles.pickerList, withDefaultPadding && styles.padded, 'custom-scroll')}
items={viewportIds}
@ -398,7 +441,7 @@ const PeerPicker = <CategoryType extends string = CustomPeerType>({
onLoadMore={getMore}
noScrollRestore={noScrollRestore}
>
{viewportIds.map((id) => renderItem(id))}
{renderItems()}
</InfiniteScroll>
) : !isLoading && viewportIds && !viewportIds.length ? (
<p className={styles.noResults}>{notFoundText || 'Sorry, nothing found.'}</p>

View File

@ -45,6 +45,15 @@
}
}
.sectionHeader {
margin: 0;
padding: 0.75rem 0.25rem 0.5rem;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.peerChip {
max-width: calc(50% - 0.5rem);
margin-bottom: 0.5rem;

View File

@ -1045,6 +1045,22 @@ const ActionMessageText = ({
case 'phoneCall': // Rendered as a regular message, but considered an action for the summary
return lang(getCallMessageKey(action, isOutgoing));
case 'newCreatorPending': {
const { newCreatorId } = action;
const newCreator = selectPeer(global, newCreatorId);
const newCreatorTitle = (newCreator && getPeerTitle(lang, newCreator)) || userFallbackText;
const newCreatorLink = renderPeerLink(newCreator?.id, newCreatorTitle, asPreview);
return lang('ActionNewCreatorPending', { user: newCreatorLink, from: senderLink }, { withNodes: true });
}
case 'changeCreator': {
const { newCreatorId } = action;
const newCreator = selectPeer(global, newCreatorId);
const newCreatorTitle = (newCreator && getPeerTitle(lang, newCreator)) || userFallbackText;
const newCreatorLink = renderPeerLink(newCreator?.id, newCreatorTitle, asPreview);
return lang('ActionChangeCreator', { user: newCreatorLink, from: senderLink }, { withNodes: true });
}
case 'starGiftPurchaseOffer': {
const { gift, price } = action;

View File

@ -43,6 +43,7 @@ import GiftInfoValueModal from './gift/value/GiftInfoValueModal.async';
import GiftWithdrawModal from './gift/withdraw/GiftWithdrawModal.async';
import GiftCodeModal from './giftcode/GiftCodeModal.async';
import InviteViaLinkModal from './inviteViaLink/InviteViaLinkModal.async';
import LeaveGroupModal from './leaveGroup/LeaveGroupModal.async';
import LocationAccessModal from './locationAccess/LocationAccessModal.async';
import MapModal from './map/MapModal.async';
import OneTimeMediaModal from './oneTimeMedia/OneTimeMediaModal.async';
@ -66,6 +67,7 @@ import StealthModeModal from './storyStealthMode/StealthModeModal.async';
import SuggestedPostApprovalModal from './suggestedPostApproval/SuggestedPostApprovalModal.async';
import SuggestedStatusModal from './suggestedStatus/SuggestedStatusModal.async';
import SuggestMessageModal from './suggestMessage/SuggestMessageModal.async';
import TwoFaCheckModal from './twoFaCheck/TwoFaCheckModal.async';
import UrlAuthModal from './urlAuth/UrlAuthModal.async';
import WebAppModal from './webApp/WebAppModal.async';
@ -131,6 +133,8 @@ type ModalKey = keyof Pick<TabState,
'storyStealthModal' |
'isPasskeyModalOpen' |
'birthdaySetupModal' |
'leaveGroupModal' |
'isTwoFaCheckModalOpen' |
'isQuickChatPickerOpen' |
'isCocoonModalOpen'
>;
@ -208,6 +212,8 @@ const MODALS: ModalRegistry = {
storyStealthModal: StealthModeModal,
isPasskeyModalOpen: PasskeyModal,
birthdaySetupModal: BirthdaySetupModal,
leaveGroupModal: LeaveGroupModal,
isTwoFaCheckModalOpen: TwoFaCheckModal,
isQuickChatPickerOpen: QuickChatPickerModal,
isCocoonModalOpen: CocoonModal,
};

View File

@ -6,18 +6,14 @@ import type { TabState } from '../../../../global/types';
import { getPeerTitle } from '../../../../global/helpers/peers';
import { selectPeer } from '../../../../global/selectors';
import { REM } from '../../../common/helpers/mediaDimensions';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import Avatar from '../../../common/Avatar';
import Icon from '../../../common/icons/Icon';
import TransferBetweenPeers from '../../../common/TransferBetweenPeers';
import ConfirmDialog from '../../../ui/ConfirmDialog';
import styles from './GiftAuctionChangeRecipientModal.module.scss';
export type OwnProps = {
modal: TabState['giftAuctionChangeRecipientModal'];
};
@ -27,8 +23,6 @@ type StateProps = {
newPeer?: ApiPeer;
};
const AVATAR_SIZE = 4 * REM;
const GiftAuctionChangeRecipientModal = ({ modal, oldPeer, newPeer }: OwnProps & StateProps) => {
const { closeGiftAuctionChangeRecipientModal, openGiftAuctionBidModal } = getActions();
const lang = useLang();
@ -60,11 +54,7 @@ const GiftAuctionChangeRecipientModal = ({ modal, oldPeer, newPeer }: OwnProps &
confirmLabel={lang('Continue')}
confirmHandler={handleConfirm}
>
<div className={styles.preview}>
<Avatar peer={renderingOldPeer} size={AVATAR_SIZE} />
<Icon name="next" className={styles.arrow} />
<Avatar peer={renderingNewPeer} size={AVATAR_SIZE} />
</div>
<TransferBetweenPeers fromPeer={renderingOldPeer} toPeer={renderingNewPeer} />
<p>
{lang('GiftAuctionChangeRecipientDescription', {
oldPeer: getPeerTitle(lang, renderingOldPeer),

View File

@ -0,0 +1,14 @@
import type { OwnProps } from './LeaveGroupModal';
import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader';
const LeaveGroupModalAsync = (props: OwnProps) => {
const { modal } = props;
const LeaveGroupModal = useModuleLoader(Bundles.Extra, 'LeaveGroupModal', !modal);
return LeaveGroupModal ? <LeaveGroupModal key={modal?.chatId} {...props} /> : undefined;
};
export default LeaveGroupModalAsync;

View File

@ -0,0 +1,8 @@
.dialog {
max-width: 22rem !important;
}
.passwordDescription {
margin-bottom: 1rem;
color: var(--color-text-secondary);
}

View File

@ -0,0 +1,317 @@
import {
memo, useCallback, useEffect, useMemo, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiChat, ApiChatFullInfo, ApiPeer } from '../../../api/types';
import type { GlobalState, TabState } from '../../../global/types';
import { getPeerTitle } from '../../../global/helpers/peers';
import { selectChat, selectChatFullInfo, selectPeer } from '../../../global/selectors';
import useSelector from '../../../hooks/data/useSelector';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import usePeerSearch, { prepareChatMemberSearch } from '../../../hooks/usePeerSearch';
import PasswordConfirmModal from '../../common/PasswordConfirmModal';
import PeerPicker, { type PeerPickerSection } from '../../common/pickers/PeerPicker';
import PickerModal from '../../common/pickers/PickerModal';
import TransferBetweenPeers from '../../common/TransferBetweenPeers';
import Button from '../../ui/Button';
import Modal from '../../ui/Modal';
import styles from './LeaveGroupModal.module.scss';
export type OwnProps = {
modal: TabState['leaveGroupModal'];
};
type StateProps = {
chat?: ApiChat;
currentUser?: ApiPeer;
currentUserId?: string;
nextOwner?: ApiPeer;
chatFullInfo?: ApiChatFullInfo;
};
const LeaveGroupModal = ({
modal,
chat,
currentUser,
currentUserId,
nextOwner,
chatFullInfo,
}: OwnProps & StateProps) => {
const {
closeLeaveGroupModal, leaveChannel, loadMoreMembers, loadFullChat,
transferChannelOwnership, verifyTransferOwnership, openTwoFaCheckModal,
} = getActions();
const lang = useLang();
const [isPickerOpen, openPicker, closePicker] = useFlag();
const [isPasswordModalOpen, openPasswordModal, closePasswordModal] = useFlag();
const [newOwnerId, setNewOwnerId] = useState<string | undefined>(modal?.nextOwnerId);
const [search, setSearch] = useState('');
const isOpen = Boolean(modal);
const renderingChat = useCurrentOrPrev(chat);
const renderingCurrentUser = useCurrentOrPrev(currentUser);
useEffect(() => {
if (chat && !chatFullInfo) {
loadFullChat({ chatId: chat.id });
}
}, [chat, chatFullInfo]);
const newOwner = (!newOwnerId || newOwnerId === modal?.nextOwnerId) ? nextOwner : undefined;
const renderingNewOwner = useCurrentOrPrev(newOwner);
const selectNewOwnerPeer = useCallback((global: GlobalState) => {
return newOwnerId ? selectPeer(global, newOwnerId) : undefined;
}, [newOwnerId]);
const selectedNewOwnerPeer = useSelector(selectNewOwnerPeer);
const newOwnerPeer = selectedNewOwnerPeer || renderingNewOwner;
const { adminIds, memberIds, allIds } = useMemo(() => {
if (!currentUserId) {
return { adminIds: [], memberIds: [], allIds: [] };
}
const adminMembersById = chatFullInfo?.adminMembersById;
const allMembers = chatFullInfo?.members;
const adminUserIds = adminMembersById
? Object.values(adminMembersById)
.filter((member) => member.userId !== currentUserId && !member.isOwner)
.map((member) => member.userId)
: [];
const memberUserIds = (allMembers || [])
.filter((member) => {
return member.userId !== currentUserId
&& !member.isOwner
&& !member.isAdmin;
})
.map((member) => member.userId);
return {
adminIds: adminUserIds,
memberIds: memberUserIds,
allIds: [...adminUserIds, ...memberUserIds],
};
}, [currentUserId, chatFullInfo]);
const hasAdmins = adminIds.length > 0;
const isOwnerChanged = Boolean(newOwnerId && newOwnerId !== modal?.nextOwnerId);
const memberSearchFn = useMemo(() => {
return chat ? prepareChatMemberSearch(chat) : undefined;
}, [chat]);
const { result: searchResults, isLoading: isSearchLoading } = usePeerSearch({
query: search,
queryFn: memberSearchFn,
defaultValue: allIds,
isDisabled: !chat,
});
const { sections, filteredIds } = useMemo(() => {
if (search) {
const filtered = (searchResults || []).filter((id) => id !== currentUserId && allIds.includes(id));
return { sections: undefined, filteredIds: filtered };
}
if (!hasAdmins) {
return { sections: undefined, filteredIds: allIds };
}
const hasMembers = memberIds.length > 0;
if (!hasMembers) {
return {
sections: undefined,
filteredIds: adminIds,
};
}
return {
sections: [
{ key: 'admins', title: lang('LeaveGroupAdmins'), ids: adminIds },
{ key: 'members', title: lang('LeaveGroupMembers'), ids: memberIds },
] as PeerPickerSection[],
filteredIds: allIds,
};
}, [search, searchResults, currentUserId, allIds, hasAdmins, adminIds, memberIds, lang]);
const pickerTitle = lang('LeaveGroupAppointOwner');
const handleLeave = useLastCallback(() => {
const chatId = modal?.chatId;
if (!chatId) return;
if (isOwnerChanged && newOwnerId) {
verifyTransferOwnership({
chatId,
userId: newOwnerId,
onSuccess: openPasswordModal,
onPasswordMissing: openTwoFaCheckModal,
onPasswordTooFresh: openTwoFaCheckModal,
onSessionTooFresh: openTwoFaCheckModal,
});
} else {
openPasswordModal();
}
});
const handleLeaveAndTransfer = useLastCallback((password: string) => {
const chatId = modal?.chatId;
if (!chatId) return;
if (isOwnerChanged && newOwnerId) {
transferChannelOwnership({
chatId,
userId: newOwnerId,
password,
onSuccess: () => leaveChannel({ chatId, shouldSkipOwnershipCheck: true }),
});
} else {
leaveChannel({ chatId, shouldSkipOwnershipCheck: true });
}
closeLeaveGroupModal();
});
const handleAppointOwner = useLastCallback(() => {
openPicker();
});
const handleSelectNewOwner = useLastCallback((userId: string) => {
setNewOwnerId(userId);
closePicker();
});
const handleClosePicker = useLastCallback(() => {
closePicker();
});
const handleLoadMore = useLastCallback(() => {
if (modal?.chatId) {
loadMoreMembers({ chatId: modal.chatId });
}
});
if (!renderingChat || !renderingCurrentUser) return undefined;
const chatTitle = getPeerTitle(lang, renderingChat);
const newOwnerName = newOwnerPeer ? getPeerTitle(lang, newOwnerPeer) : undefined;
return (
<>
<Modal
isOpen={isOpen && !isPickerOpen && !isPasswordModalOpen}
dialogClassName={styles.dialog}
onClose={closeLeaveGroupModal}
>
{newOwnerPeer && (
<TransferBetweenPeers fromPeer={renderingCurrentUser} toPeer={newOwnerPeer} />
)}
<h3>{lang('LeaveGroupTitle', { group: chatTitle })}</h3>
<p>
{lang('LeaveGroupDescription', {
nextOwner: newOwnerName,
group: chatTitle,
}, {
withNodes: true,
withMarkdown: true,
})}
</p>
<div className="dialog-buttons-column">
<Button
color="danger"
className="confirm-dialog-button"
isText
onClick={handleLeave}
>
{lang('GroupLeaveGroup')}
</Button>
{allIds.length > 0 && (
<Button
className="confirm-dialog-button"
isText
onClick={handleAppointOwner}
>
{lang('LeaveGroupAppointOwner')}
</Button>
)}
<Button className="confirm-dialog-button" isText onClick={() => closeLeaveGroupModal()}>
{lang('Cancel')}
</Button>
</div>
</Modal>
<PickerModal
isOpen={isPickerOpen}
title={pickerTitle}
hasCloseButton
shouldAdaptToSearch
withFixedHeight
onClose={handleClosePicker}
>
{sections ? (
<PeerPicker
sections={sections}
filterValue={search}
filterPlaceholder={lang('Search')}
isSearchable
isLoading={isSearchLoading}
withStatus
withDefaultPadding
onFilterChange={setSearch}
onSelectedIdChange={handleSelectNewOwner}
onLoadMore={handleLoadMore}
/>
) : (
<PeerPicker
itemIds={filteredIds}
filterValue={search}
filterPlaceholder={lang('Search')}
isSearchable
isLoading={isSearchLoading}
withStatus
withDefaultPadding
onFilterChange={setSearch}
onSelectedIdChange={handleSelectNewOwner}
onLoadMore={handleLoadMore}
/>
)}
</PickerModal>
<PasswordConfirmModal
isOpen={isPasswordModalOpen}
title={lang('EnterPassword')}
confirmLabel={lang('Transfer')}
description={lang('EnterPasswordDescription')}
onClose={closePasswordModal}
onSubmit={handleLeaveAndTransfer}
/>
</>
);
};
export default memo(withGlobal<OwnProps>(
(global, { modal }): Complete<StateProps> => {
const chat = modal?.chatId ? selectChat(global, modal.chatId) : undefined;
const currentUser = global.currentUserId ? selectPeer(global, global.currentUserId) : undefined;
const nextOwner = modal?.nextOwnerId ? selectPeer(global, modal.nextOwnerId) : undefined;
const fullInfo = modal?.chatId ? selectChatFullInfo(global, modal.chatId) : undefined;
return {
chat,
currentUser,
currentUserId: global.currentUserId,
nextOwner,
chatFullInfo: fullInfo,
};
},
)(LeaveGroupModal));

View File

@ -0,0 +1,14 @@
import type { OwnProps } from './TwoFaCheckModal';
import { Bundles } from '../../../util/moduleLoader';
import useModuleLoader from '../../../hooks/useModuleLoader';
const TwoFaCheckModalAsync = (props: OwnProps) => {
const { modal } = props;
const TwoFaCheckModal = useModuleLoader(Bundles.Extra, 'TwoFaCheckModal', !modal);
return TwoFaCheckModal ? <TwoFaCheckModal {...props} /> : undefined;
};
export default TwoFaCheckModalAsync;

View File

@ -0,0 +1,18 @@
.dialog :global(.modal-dialog) {
max-width: 22rem !important;
}
.list {
margin-block: 1.5rem;
padding-left: 1rem;
text-align: left;
li {
margin-bottom: 1rem;
color: var(--color-text-secondary);
&:last-child {
margin-bottom: 0;
}
}
}

View File

@ -0,0 +1,62 @@
import { memo } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { TabState } from '../../../global/types';
import { SettingsScreens } from '../../../types';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import ConfirmDialog from '../../ui/ConfirmDialog';
import styles from './TwoFaCheckModal.module.scss';
export type OwnProps = {
modal: TabState['isTwoFaCheckModalOpen'];
};
type StateProps = {
hasPassword?: boolean;
};
const TwoFaCheckModal = ({ modal, hasPassword }: OwnProps & StateProps) => {
const {
closeTwoFaCheckModal, openSettingsScreen,
} = getActions();
const lang = useLang();
const isOpen = Boolean(modal);
const handleEnableTwoFa = useLastCallback(() => {
openSettingsScreen({ screen: SettingsScreens.TwoFaDisabled });
closeTwoFaCheckModal();
});
return (
<ConfirmDialog
isOpen={isOpen}
title={lang('SecurityCheck')}
className={styles.dialog}
confirmLabel={hasPassword ? lang('OK') : lang('SecurityCheckEnableTwoStep')}
confirmHandler={hasPassword ? closeTwoFaCheckModal : handleEnableTwoFa}
isOnlyConfirm={hasPassword}
areButtonsInColumn={!hasPassword}
onClose={closeTwoFaCheckModal}
>
<p>{lang('SecurityCheckInfo')}</p>
<ul className={styles.list}>
<li>{lang('SecurityCheckTwoStepEnabled', undefined, { withNodes: true, withMarkdown: true })}</li>
<li>{lang('SecurityCheckTwoStepNotChanged', undefined, { withNodes: true, withMarkdown: true })}</li>
<li>{lang('SecurityCheckLoggedIn', undefined, { withNodes: true, withMarkdown: true })}</li>
</ul>
</ConfirmDialog>
);
};
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
return {
hasPassword: global.settings.byKey.hasPassword,
};
},
)(TwoFaCheckModal));

View File

@ -8,13 +8,15 @@ import type {
} from '../../../api/types';
import { ManagementScreens } from '../../../types';
import { getUserFullName, isChatBasicGroup, isChatChannel } from '../../../global/helpers';
import { getUserFullName, isChatBasicGroup, isChatChannel, isUserBot } from '../../../global/helpers';
import { selectChat, selectChatFullInfo } from '../../../global/selectors';
import useFlag from '../../../hooks/useFlag';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import PasswordConfirmModal from '../../common/PasswordConfirmModal';
import PrivateChatInfo from '../../common/PrivateChatInfo';
import Checkbox from '../../ui/Checkbox';
import ConfirmDialog from '../../ui/ConfirmDialog';
@ -58,12 +60,17 @@ const ManageGroupAdminRights = ({
onClose,
onScreenSelect,
}: OwnProps & StateProps) => {
const { updateChatAdmin } = getActions();
const {
updateChatAdmin, transferChannelOwnership, showNotification,
openTwoFaCheckModal, verifyTransferOwnership,
} = getActions();
const [permissions, setPermissions] = useState<ApiChatAdminRights>({});
const [isTouched, setIsTouched] = useState(Boolean(isNewAdmin));
const [isLoading, setIsLoading] = useState(false);
const [isDismissConfirmationDialogOpen, openDismissConfirmationDialog, closeDismissConfirmationDialog] = useFlag();
const [isTransferDialogOpen, openTransferDialog, closeTransferDialog] = useFlag();
const [isPasswordModalOpen, openPasswordModal, closePasswordModal] = useFlag();
const [customTitle, setCustomTitle] = useState('');
const lang = useLang();
@ -196,6 +203,52 @@ const ManageGroupAdminRights = ({
setIsTouched(true);
}, []);
const handleStartTransfer = useLastCallback(() => {
if (!selectedUserId) return;
verifyTransferOwnership({
chatId: chat.id,
userId: selectedUserId,
onSuccess: openTransferDialog,
onPasswordMissing: openTwoFaCheckModal,
onPasswordTooFresh: openTwoFaCheckModal,
onSessionTooFresh: openTwoFaCheckModal,
});
});
const handleConfirmTransfer = useLastCallback(() => {
closeTransferDialog();
openPasswordModal();
});
const handleTransferOwnership = useLastCallback((password: string) => {
if (!selectedUserId) return;
const user = usersById[selectedUserId];
const userName = user ? getUserFullName(user) : '';
transferChannelOwnership({
chatId: chat.id,
userId: selectedUserId,
password,
onSuccess: () => {
showNotification({
message: lang(
isChannel ? 'EditAdminTransferChannelOwnershipSuccess' : 'EditAdminTransferGroupOwnershipSuccess',
{ user: userName },
),
});
},
});
closePasswordModal();
});
const selectedUser = selectedUserId ? usersById[selectedUserId] : undefined;
const canTransferOwnership = Boolean(
chat.isCreator && selectedUser && !isUserBot(selectedUser) && selectedUserId !== currentUserId,
);
if (!selectedChatMember) {
return undefined;
}
@ -395,6 +448,11 @@ const ManageGroupAdminRights = ({
/>
)}
{canTransferOwnership && currentUserId !== selectedUserId && !isFormFullyDisabled && !isNewAdmin && (
<ListItem icon="key" ripple onClick={handleStartTransfer}>
{lang(isChannel ? 'EditAdminTransferChannelOwnership' : 'EditAdminTransferGroupOwnership')}
</ListItem>
)}
{currentUserId !== selectedUserId && !isFormFullyDisabled && !isNewAdmin && (
<ListItem icon="delete" ripple destructive onClick={openDismissConfirmationDialog}>
{lang('EditAdminRemoveAdmin')}
@ -422,6 +480,24 @@ const ManageGroupAdminRights = ({
confirmIsDestructive
/>
)}
<ConfirmDialog
isOpen={isTransferDialogOpen}
onClose={closeTransferDialog}
title={lang(isChannel ? 'EditAdminTransferChannelOwnership' : 'EditAdminTransferGroupOwnership')}
textParts={lang('EditAdminTransferOwnershipText', {
chat: chat.title,
user: selectedUserId ? getUserFullName(usersById[selectedUserId]) : '',
}, { withNodes: true, withMarkdown: true })}
confirmLabel={lang('EditAdminTransferChangeOwner')}
confirmHandler={handleConfirmTransfer}
/>
<PasswordConfirmModal
isOpen={isPasswordModalOpen}
title={lang(isChannel ? 'EditAdminTransferChannelOwnership' : 'EditAdminTransferGroupOwnership')}
confirmLabel={lang('EditAdminTransferChangeOwner')}
onClose={closePasswordModal}
onSubmit={handleTransferOwnership}
/>
</div>
);
};

View File

@ -889,12 +889,28 @@ addActionHandler('deleteChat', (global, actions, payload): ActionReturnType => {
});
addActionHandler('leaveChannel', async (global, actions, payload): Promise<void> => {
const { chatId, tabId = getCurrentTabId() } = payload;
const { chatId, shouldSkipOwnershipCheck, tabId = getCurrentTabId() } = payload;
const chat = selectChat(global, chatId);
if (!chat) {
return;
}
if (!shouldSkipOwnershipCheck && chat.isCreator && chat.accessHash) {
const futureCreator = await callApi('fetchFutureCreatorAfterLeave', { chat });
if (futureCreator) {
global = getGlobal();
const hasPassword = global.settings.byKey.hasPassword;
if (!hasPassword) {
actions.openTwoFaCheckModal({ tabId });
return;
}
actions.openLeaveGroupModal({ chatId, nextOwnerId: futureCreator.id, tabId });
return;
}
}
global = getGlobal();
global = leaveChat(global, chatId);
setGlobal(global);
@ -902,15 +918,76 @@ addActionHandler('leaveChannel', async (global, actions, payload): Promise<void>
actions.openChat({ id: undefined, tabId });
}
const { id: channelId, accessHash } = chat;
if (channelId && accessHash) {
await callApi('leaveChannel', { channelId, accessHash });
global = getGlobal();
const chatMessages = selectChatMessages(global, chatId);
const localMessageIds = Object.keys(chatMessages).map(Number).filter(isLocalMessageId);
global = deleteChatMessages(global, chatId, localMessageIds);
setGlobal(global);
await callApi('leaveChannel', { chat });
global = getGlobal();
const chatMessages = selectChatMessages(global, chatId);
const localMessageIds = Object.keys(chatMessages).map(Number).filter(isLocalMessageId);
global = deleteChatMessages(global, chatId, localMessageIds);
setGlobal(global);
});
addActionHandler('verifyTransferOwnership', async (global, actions, payload): Promise<void> => {
const {
chatId, userId, onSuccess, onPasswordMissing, onPasswordTooFresh, onSessionTooFresh,
} = payload;
const chat = selectChat(global, chatId);
const user = selectUser(global, userId);
if (!chat || !user) {
return;
}
const result = await callApi('verifyTransferOwnership', {
chat,
user,
});
if (!result) {
return;
}
if ('canTransfer' in result) {
onSuccess?.();
return;
}
switch (result.errorMessage) {
case 'PASSWORD_MISSING':
onPasswordMissing?.();
break;
case 'PASSWORD_TOO_FRESH':
onPasswordTooFresh?.();
break;
case 'SESSION_TOO_FRESH':
onSessionTooFresh?.();
break;
default:
break;
}
});
addActionHandler('transferChannelOwnership', async (global, actions, payload): Promise<void> => {
const {
chatId, userId, password, onSuccess,
} = payload;
const chat = selectChat(global, chatId);
const user = selectUser(global, userId);
if (!chat?.accessHash || !user?.accessHash) {
return;
}
const result = await callApi('editChannelCreator', {
chat,
user,
password,
});
if (result !== true) {
return;
}
onSuccess?.();
});
addActionHandler('deleteChannel', (global, actions, payload): ActionReturnType => {

View File

@ -952,6 +952,29 @@ addCallback((global: GlobalState) => {
prevBlurredTabsCount = blurredTabsCount;
});
addActionHandler('openLeaveGroupModal', (global, actions, payload): ActionReturnType => {
const { chatId, nextOwnerId, tabId = getCurrentTabId() } = payload;
return updateTabState(global, {
leaveGroupModal: {
chatId,
nextOwnerId,
},
}, tabId);
});
addTabStateResetterAction('closeLeaveGroupModal', 'leaveGroupModal');
addActionHandler('openTwoFaCheckModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
isTwoFaCheckModalOpen: true,
}, tabId);
});
addTabStateResetterAction('closeTwoFaCheckModal', 'isTwoFaCheckModalOpen');
addActionHandler('openQuickChatPicker', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};

View File

@ -401,7 +401,10 @@ export interface ActionPayloads {
joinChannel: {
chatId: string;
} & WithTabId;
leaveChannel: { chatId: string } & WithTabId;
leaveChannel: {
chatId: string;
shouldSkipOwnershipCheck?: boolean;
} & WithTabId;
deleteChannel: { chatId: string } & WithTabId;
toggleChatPinned: {
id: string;
@ -2556,6 +2559,29 @@ export interface ActionPayloads {
openGiftRecipientPicker: WithTabId | undefined;
closeGiftRecipientPicker: WithTabId | undefined;
openLeaveGroupModal: {
chatId: string;
nextOwnerId?: string;
} & WithTabId;
closeLeaveGroupModal: WithTabId | undefined;
openTwoFaCheckModal: WithTabId | undefined;
closeTwoFaCheckModal: WithTabId | undefined;
verifyTransferOwnership: {
chatId: string;
userId: string;
onSuccess?: VoidFunction;
onPasswordMissing?: VoidFunction;
onPasswordTooFresh?: VoidFunction;
onSessionTooFresh?: VoidFunction;
};
transferChannelOwnership: {
chatId: string;
userId: string;
password: string;
onSuccess?: VoidFunction;
} & WithTabId;
openQuickChatPicker: WithTabId | undefined;
closeQuickChatPicker: WithTabId | undefined;

View File

@ -971,6 +971,13 @@ export type TabState = {
isPasskeyModalOpen?: boolean;
leaveGroupModal?: {
chatId: string;
nextOwnerId?: string;
};
isTwoFaCheckModalOpen?: true;
isWaitingForStarGiftUpgrade?: true;
isWaitingForStarGiftTransfer?: true;
insertingPeerIdMention?: string;

View File

@ -148,6 +148,18 @@ export class PasswordFreshError extends BadRequestError {
}
}
export class SessionFreshError extends BadRequestError {
public seconds: number;
constructor(args: any) {
const seconds = Number(args.capture || 0);
super(`Session is fresh, please try again in ${seconds} seconds.`, args.request);
this.message = `Session is fresh, please try again in ${seconds} seconds.`;
this.seconds = seconds;
}
}
export class PasskeyLoginRequestedError extends Error {
public credentialJson: PublicKeyCredentialJSON;
@ -185,6 +197,7 @@ export const rpcErrorRe = new Map<RegExp, any>([
[/NETWORK_MIGRATE_(\d+)/, NetworkMigrateError],
[/EMAIL_UNCONFIRMED_(\d+)/, EmailUnconfirmedError],
[/PASSWORD_TOO_FRESH_(\d+)/, PasswordFreshError],
[/SESSION_TOO_FRESH_(\d+)/, SessionFreshError],
[/^Timeout$/, TimedOutError],
[/PASSKEY_CREDENTIAL_NOT_FOUND/, PasskeyCredentialNotFoundError],
]);

View File

@ -1836,6 +1836,7 @@ channels.readMessageContents#eab5dc38 channel:InputChannel id:Vector<int> = Bool
channels.togglePreHistoryHidden#eabbb94c channel:InputChannel enabled:Bool = Updates;
channels.getGroupsForDiscussion#f5dad378 = messages.Chats;
channels.setDiscussionGroup#40582bb2 broadcast:InputChannel group:InputChannel = Bool;
channels.editCreator#8f38cd1f channel:InputChannel user_id:InputUser password:InputCheckPasswordSRP = Updates;
channels.getSendAs#e785a43f flags:# for_paid_reactions:flags.0?true for_live_stories:flags.1?true peer:InputPeer = channels.SendAsPeers;
channels.deleteParticipantHistory#367544db channel:InputChannel participant:InputPeer = messages.AffectedHistory;
channels.toggleJoinToSend#e4cb9580 channel:InputChannel enabled:Bool = Updates;
@ -1852,6 +1853,7 @@ channels.updatePaidMessagesPrice#4b12327b flags:# broadcast_messages_allowed:fla
channels.toggleAutotranslation#167fc0a1 channel:InputChannel enabled:Bool = Updates;
channels.checkSearchPostsFlood#22567115 flags:# query:flags.0?string = SearchPostsFlood;
channels.setMainProfileTab#3583fcb1 channel:InputChannel tab:ProfileTab = Bool;
channels.getFutureCreatorAfterLeave#a00918af channel:InputChannel = User;
bots.setBotInfo#10cf3123 flags:# bot:flags.2?InputUser lang_code:string name:flags.3?string about:flags.0?string description:flags.1?string = Bool;
bots.canSendMessage#1359f4e6 bot:InputUser = Bool;
bots.allowSendMessage#f132e3ef bot:InputUser = Updates;

View File

@ -295,6 +295,7 @@
"channels.togglePreHistoryHidden",
"channels.getGroupsForDiscussion",
"channels.setDiscussionGroup",
"channels.editCreator",
"channels.getSendAs",
"channels.toggleJoinToSend",
"channels.toggleJoinRequest",
@ -396,6 +397,7 @@
"channels.toggleForum",
"channels.toggleParticipantsHidden",
"channels.toggleViewForumAsMessages",
"channels.getFutureCreatorAfterLeave",
"photos.uploadContactProfilePhoto",
"messages.getMessagesViews",
"chatlists.exportChatlistInvite",

View File

@ -844,6 +844,9 @@ export interface LangPair {
'ChannelEditAdminCannotEdit': undefined;
'EditAdminRank': undefined;
'EditAdminRemoveAdmin': undefined;
'EditAdminTransferChannelOwnership': undefined;
'EditAdminTransferGroupOwnership': undefined;
'EditAdminTransferChangeOwner': undefined;
'ChannelAdminDismiss': undefined;
'ChannelPermissionsHeader': undefined;
'UserRestrictionsSend': undefined;
@ -1917,6 +1920,18 @@ export interface LangPair {
'SettingsDataClearMediaCache': undefined;
'SettingsDataClearMediaCacheDescription': undefined;
'SettingsDataClearMediaDone': undefined;
'LeaveGroupAppointOwner': undefined;
'LeaveGroupAdmins': undefined;
'LeaveGroupMembers': undefined;
'SecurityCheck': undefined;
'SecurityCheckInfo': undefined;
'SecurityCheckTwoStepEnabled': undefined;
'SecurityCheckTwoStepNotChanged': undefined;
'SecurityCheckLoggedIn': undefined;
'SecurityCheckEnableTwoStep': undefined;
'EnterPassword': undefined;
'EnterPasswordDescription': undefined;
'Transfer': undefined;
'TranslateMenuCocoonLinkText': undefined;
'CocoonTitle': undefined;
'CocoonDescription': undefined;
@ -2101,6 +2116,9 @@ export interface LangPairWithVariables<V = LangVariable> {
'ErrorPasswordFresh': {
'time': V;
};
'ErrorSessionFresh': {
'time': V;
};
'ErrorUnexpectedMessage': {
'error': V;
};
@ -2150,6 +2168,16 @@ export interface LangPairWithVariables<V = LangVariable> {
'AreYouSureDeleteThisChatWithGroup': {
'chat': V;
};
'EditAdminTransferOwnershipText': {
'chat': V;
'user': V;
};
'EditAdminTransferChannelOwnershipSuccess': {
'user': V;
};
'EditAdminTransferGroupOwnershipSuccess': {
'user': V;
};
'LinkExpiresIn': {
'time': V;
};
@ -3274,6 +3302,14 @@ export interface LangPairWithVariables<V = LangVariable> {
'ActionStarGiftPrepaidUpgraded': {
'user': V;
};
'ActionNewCreatorPending': {
'user': V;
'from': V;
};
'ActionChangeCreator': {
'from': V;
'user': V;
};
'FileTransferProgress': {
'currentSize': V;
'totalSize': V;
@ -3375,6 +3411,16 @@ export interface LangPairWithVariables<V = LangVariable> {
'status': V;
'onlineCount': V;
};
'LeaveGroupTitle': {
'group': V;
};
'LeaveGroupDescription': {
'nextOwner': V;
'group': V;
};
'LeaveGroupJoinedDate': {
'date': V;
};
'TranslateMenuCocoon': {
'link': V;
};

View File

@ -71,6 +71,8 @@ const READABLE_ERROR_MESSAGES: Record<string, string> = {
ADMIN_RANK_EMOJI_NOT_ALLOWED: 'An admin rank cannot contain emojis',
ADMIN_RANK_INVALID: 'The specified admin rank is invalid',
FRESH_CHANGE_ADMINS_FORBIDDEN: 'You were just elected admin, you can\'t add or modify other admins yet',
SESSION_TOO_FRESH: 'Session is fresh, please try again later',
SESSION_IS_FRESH: 'Session is fresh, please try again later',
INPUT_USER_DEACTIVATED: 'Can\'t do this action to a deleted account',
BOT_PRECHECKOUT_TIMEOUT: 'The request for payment has expired',
PROVIDER_ACCOUNT_TIMEOUT: 'Request to the payment provider has expired',