diff --git a/CLAUDE.md b/CLAUDE.md index 08fe02ee1..4fa21bf29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/src/api/gramjs/apiBuilders/messageActions.ts b/src/api/gramjs/apiBuilders/messageActions.ts index 224da0362..f0842410e 100644 --- a/src/api/gramjs/apiBuilders/messageActions.ts +++ b/src/api/gramjs/apiBuilders/messageActions.ts @@ -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; } diff --git a/src/api/gramjs/helpers/misc.ts b/src/api/gramjs/helpers/misc.ts index f4cb5db3e..943081196 100644 --- a/src/api/gramjs/helpers/misc.ts +++ b/src/api/gramjs/helpers/misc.ts @@ -117,6 +117,11 @@ export function wrapError(error: T): WrappedError { 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], diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 7cf721e94..c4933f4a2 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -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, }: { diff --git a/src/api/types/messageActions.ts b/src/api/types/messageActions.ts index 47374e61d..787c01d62 100644 --- a/src/api/types/messageActions.ts +++ b/src/api/types/messageActions.ts @@ -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; diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 74a8aa032..815fac3c8 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -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"; diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 05e91a88e..4c608d2f5 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -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'; diff --git a/src/components/common/DeleteChatModal.tsx b/src/components/common/DeleteChatModal.tsx index 6fdb41a3a..08cdfee6a 100644 --- a/src/components/common/DeleteChatModal.tsx +++ b/src/components/common/DeleteChatModal.tsx @@ -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 = ({ +const DeleteChatModal = ({ isOpen, chat, isSavedDialog, @@ -61,7 +60,7 @@ const DeleteChatModal: FC = ({ contactName, onClose, onCloseAnimationEnd, -}) => { +}: OwnProps & StateProps) => { const { leaveChannel, deleteHistory, diff --git a/src/components/common/PasswordConfirmModal.module.scss b/src/components/common/PasswordConfirmModal.module.scss new file mode 100644 index 000000000..3d744d22b --- /dev/null +++ b/src/components/common/PasswordConfirmModal.module.scss @@ -0,0 +1,4 @@ +.description { + margin-bottom: 1rem; + color: var(--color-text-secondary); +} diff --git a/src/components/common/PasswordConfirmModal.tsx b/src/components/common/PasswordConfirmModal.tsx new file mode 100644 index 000000000..c7f5847dc --- /dev/null +++ b/src/components/common/PasswordConfirmModal.tsx @@ -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 ( + + + {description &&

{description}

} + +
+ ); +}; + +export default memo(withGlobal( + (global): Complete => { + const { errorKey, hint, isLoading } = global.twoFaSettings; + return { + error: errorKey, + hint, + isLoading, + }; + }, +)(PasswordConfirmModal)); diff --git a/src/components/common/TransferBetweenPeers.module.scss b/src/components/common/TransferBetweenPeers.module.scss new file mode 100644 index 000000000..0895752c5 --- /dev/null +++ b/src/components/common/TransferBetweenPeers.module.scss @@ -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); +} diff --git a/src/components/common/TransferBetweenPeers.tsx b/src/components/common/TransferBetweenPeers.tsx new file mode 100644 index 000000000..9a4c25b73 --- /dev/null +++ b/src/components/common/TransferBetweenPeers.tsx @@ -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 ( +
+ + + +
+ ); +}; + +export default memo(TransferBetweenPeers); diff --git a/src/components/common/pickers/PeerPicker.tsx b/src/components/common/pickers/PeerPicker.tsx index ed2f6e67c..9a879c287 100644 --- a/src/components/common/pickers/PeerPicker.tsx +++ b/src/components/common/pickers/PeerPicker.tsx @@ -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 = { 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 = { className?: string; categories?: UniqueCustomPeer[]; - itemIds: string[]; lockedUnselectedSubtitle?: string; filterValue?: string; filterPlaceholder?: string; @@ -81,7 +96,7 @@ type OwnProps = { onFilterChange?: (value: string) => void; onDisabledClick?: (id: string, isSelected: boolean) => void; onLoadMore?: () => void; -} & (SingleModeProps | MultipleModeProps); +} & (ItemIdsProps | SectionsProps) & (SingleModeProps | MultipleModeProps); const MAX_FULL_ITEMS = 10; const ALWAYS_FULL_ITEMS_COUNT = 5; @@ -91,7 +106,8 @@ const ITEM_CLASS_NAME = 'PeerPickerItem'; const PeerPicker = ({ className, categories, - itemIds, + itemIds: itemIdsProp, + sections, categoryPlaceholderKey, filterValue, filterPlaceholder, @@ -117,6 +133,11 @@ const PeerPicker = ({ 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 = ({ ); }, [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( +
{section.title}
, + ); + 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 (
{isSearchable && ( @@ -389,7 +432,7 @@ const PeerPicker = ({
)} - {viewportIds?.length ? ( + {hasContent ? ( ({ onLoadMore={getMore} noScrollRestore={noScrollRestore} > - {viewportIds.map((id) => renderItem(id))} + {renderItems()} ) : !isLoading && viewportIds && !viewportIds.length ? (

{notFoundText || 'Sorry, nothing found.'}

diff --git a/src/components/common/pickers/PickerStyles.module.scss b/src/components/common/pickers/PickerStyles.module.scss index b8981fb02..4029bba8c 100644 --- a/src/components/common/pickers/PickerStyles.module.scss +++ b/src/components/common/pickers/PickerStyles.module.scss @@ -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; diff --git a/src/components/middle/message/ActionMessageText.tsx b/src/components/middle/message/ActionMessageText.tsx index 5ed5d9df0..eb5e30dc4 100644 --- a/src/components/middle/message/ActionMessageText.tsx +++ b/src/components/middle/message/ActionMessageText.tsx @@ -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; diff --git a/src/components/modals/ModalContainer.tsx b/src/components/modals/ModalContainer.tsx index 2c784e4b8..3d1c74380 100644 --- a/src/components/modals/ModalContainer.tsx +++ b/src/components/modals/ModalContainer.tsx @@ -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; @@ -208,6 +212,8 @@ const MODALS: ModalRegistry = { storyStealthModal: StealthModeModal, isPasskeyModalOpen: PasskeyModal, birthdaySetupModal: BirthdaySetupModal, + leaveGroupModal: LeaveGroupModal, + isTwoFaCheckModalOpen: TwoFaCheckModal, isQuickChatPickerOpen: QuickChatPickerModal, isCocoonModalOpen: CocoonModal, }; diff --git a/src/components/modals/gift/auction/GiftAuctionChangeRecipientModal.tsx b/src/components/modals/gift/auction/GiftAuctionChangeRecipientModal.tsx index 37a3c7cb1..c1c520ccb 100644 --- a/src/components/modals/gift/auction/GiftAuctionChangeRecipientModal.tsx +++ b/src/components/modals/gift/auction/GiftAuctionChangeRecipientModal.tsx @@ -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} > -
- - - -
+

{lang('GiftAuctionChangeRecipientDescription', { oldPeer: getPeerTitle(lang, renderingOldPeer), diff --git a/src/components/modals/leaveGroup/LeaveGroupModal.async.tsx b/src/components/modals/leaveGroup/LeaveGroupModal.async.tsx new file mode 100644 index 000000000..107d848a0 --- /dev/null +++ b/src/components/modals/leaveGroup/LeaveGroupModal.async.tsx @@ -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 ? : undefined; +}; + +export default LeaveGroupModalAsync; diff --git a/src/components/modals/leaveGroup/LeaveGroupModal.module.scss b/src/components/modals/leaveGroup/LeaveGroupModal.module.scss new file mode 100644 index 000000000..9b2bff4ab --- /dev/null +++ b/src/components/modals/leaveGroup/LeaveGroupModal.module.scss @@ -0,0 +1,8 @@ +.dialog { + max-width: 22rem !important; +} + +.passwordDescription { + margin-bottom: 1rem; + color: var(--color-text-secondary); +} diff --git a/src/components/modals/leaveGroup/LeaveGroupModal.tsx b/src/components/modals/leaveGroup/LeaveGroupModal.tsx new file mode 100644 index 000000000..732b32332 --- /dev/null +++ b/src/components/modals/leaveGroup/LeaveGroupModal.tsx @@ -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(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 ( + <> + + {newOwnerPeer && ( + + )} +

{lang('LeaveGroupTitle', { group: chatTitle })}

+

+ {lang('LeaveGroupDescription', { + nextOwner: newOwnerName, + group: chatTitle, + }, { + withNodes: true, + withMarkdown: true, + })} +

+
+ + {allIds.length > 0 && ( + + )} + +
+ + + {sections ? ( + + ) : ( + + )} + + + + ); +}; + +export default memo(withGlobal( + (global, { modal }): Complete => { + 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)); diff --git a/src/components/modals/twoFaCheck/TwoFaCheckModal.async.tsx b/src/components/modals/twoFaCheck/TwoFaCheckModal.async.tsx new file mode 100644 index 000000000..cc7aeeee9 --- /dev/null +++ b/src/components/modals/twoFaCheck/TwoFaCheckModal.async.tsx @@ -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 ? : undefined; +}; + +export default TwoFaCheckModalAsync; diff --git a/src/components/modals/twoFaCheck/TwoFaCheckModal.module.scss b/src/components/modals/twoFaCheck/TwoFaCheckModal.module.scss new file mode 100644 index 000000000..9584ae511 --- /dev/null +++ b/src/components/modals/twoFaCheck/TwoFaCheckModal.module.scss @@ -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; + } + } +} diff --git a/src/components/modals/twoFaCheck/TwoFaCheckModal.tsx b/src/components/modals/twoFaCheck/TwoFaCheckModal.tsx new file mode 100644 index 000000000..bfcf49106 --- /dev/null +++ b/src/components/modals/twoFaCheck/TwoFaCheckModal.tsx @@ -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 ( + +

{lang('SecurityCheckInfo')}

+
    +
  • {lang('SecurityCheckTwoStepEnabled', undefined, { withNodes: true, withMarkdown: true })}
  • +
  • {lang('SecurityCheckTwoStepNotChanged', undefined, { withNodes: true, withMarkdown: true })}
  • +
  • {lang('SecurityCheckLoggedIn', undefined, { withNodes: true, withMarkdown: true })}
  • +
+
+ ); +}; + +export default memo(withGlobal( + (global): Complete => { + return { + hasPassword: global.settings.byKey.hasPassword, + }; + }, +)(TwoFaCheckModal)); diff --git a/src/components/right/management/ManageGroupAdminRights.tsx b/src/components/right/management/ManageGroupAdminRights.tsx index db1bdaacf..bd015017e 100644 --- a/src/components/right/management/ManageGroupAdminRights.tsx +++ b/src/components/right/management/ManageGroupAdminRights.tsx @@ -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({}); 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 && ( + + {lang(isChannel ? 'EditAdminTransferChannelOwnership' : 'EditAdminTransferGroupOwnership')} + + )} {currentUserId !== selectedUserId && !isFormFullyDisabled && !isNewAdmin && ( {lang('EditAdminRemoveAdmin')} @@ -422,6 +480,24 @@ const ManageGroupAdminRights = ({ confirmIsDestructive /> )} + + ); }; diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index 99a553ef9..526e32ad0 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -889,12 +889,28 @@ addActionHandler('deleteChat', (global, actions, payload): ActionReturnType => { }); addActionHandler('leaveChannel', async (global, actions, payload): Promise => { - 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 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 => { + 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 => { + 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 => { diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 34977c173..64ac3d2c9 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -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 || {}; diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index c1c76364f..cadd74ff6 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -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; diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index ca74ef599..bd599745f 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -971,6 +971,13 @@ export type TabState = { isPasskeyModalOpen?: boolean; + leaveGroupModal?: { + chatId: string; + nextOwnerId?: string; + }; + + isTwoFaCheckModalOpen?: true; + isWaitingForStarGiftUpgrade?: true; isWaitingForStarGiftTransfer?: true; insertingPeerIdMention?: string; diff --git a/src/lib/gramjs/errors/RPCErrorList.ts b/src/lib/gramjs/errors/RPCErrorList.ts index 6743a4575..1b06ebf53 100644 --- a/src/lib/gramjs/errors/RPCErrorList.ts +++ b/src/lib/gramjs/errors/RPCErrorList.ts @@ -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([ [/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], ]); diff --git a/src/lib/gramjs/tl/apiTl.ts b/src/lib/gramjs/tl/apiTl.ts index 5356fa604..bbd945306 100644 --- a/src/lib/gramjs/tl/apiTl.ts +++ b/src/lib/gramjs/tl/apiTl.ts @@ -1836,6 +1836,7 @@ channels.readMessageContents#eab5dc38 channel:InputChannel id:Vector = 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; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index d598c6599..66b8701ff 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -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", diff --git a/src/types/language.d.ts b/src/types/language.d.ts index 6d92d1430..537cf28e4 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -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 { 'ErrorPasswordFresh': { 'time': V; }; + 'ErrorSessionFresh': { + 'time': V; + }; 'ErrorUnexpectedMessage': { 'error': V; }; @@ -2150,6 +2168,16 @@ export interface LangPairWithVariables { 'AreYouSureDeleteThisChatWithGroup': { 'chat': V; }; + 'EditAdminTransferOwnershipText': { + 'chat': V; + 'user': V; + }; + 'EditAdminTransferChannelOwnershipSuccess': { + 'user': V; + }; + 'EditAdminTransferGroupOwnershipSuccess': { + 'user': V; + }; 'LinkExpiresIn': { 'time': V; }; @@ -3274,6 +3302,14 @@ export interface LangPairWithVariables { '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 { 'status': V; 'onlineCount': V; }; + 'LeaveGroupTitle': { + 'group': V; + }; + 'LeaveGroupDescription': { + 'nextOwner': V; + 'group': V; + }; + 'LeaveGroupJoinedDate': { + 'date': V; + }; 'TranslateMenuCocoon': { 'link': V; }; diff --git a/src/util/getReadableErrorText.ts b/src/util/getReadableErrorText.ts index 4b5e2876f..d664203c0 100644 --- a/src/util/getReadableErrorText.ts +++ b/src/util/getReadableErrorText.ts @@ -71,6 +71,8 @@ const READABLE_ERROR_MESSAGES: Record = { 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',