diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index ec7cdaacc..505d82f76 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -70,7 +70,7 @@ import { } from '../helpers'; import localDb from '../localDb'; import { scheduleMutedChatUpdate } from '../scheduleUnmute'; -import { applyState, updateChannelState } from '../updateManager'; +import { applyState, processUpdate, updateChannelState } from '../updateManager'; import { invokeRequest, uploadFile } from './client'; type FullChatData = { @@ -626,7 +626,7 @@ export async function createChannel({ title, about = '', users, }: { title: string; about?: string; users?: ApiUser[]; -}, noErrorUpdate = false): Promise { +}) { const result = await invokeRequest(new GramJs.channels.CreateChannel({ broadcast: true, title, @@ -657,20 +657,36 @@ export async function createChannel({ const channel = buildApiChatFromPreview(newChannel)!; + let restrictedUserIds: string[] | undefined; + if (users?.length) { try { - await invokeRequest(new GramJs.channels.InviteToChannel({ + const updates = await invokeRequest(new GramJs.channels.InviteToChannel({ channel: buildInputEntity(channel.id, channel.accessHash) as GramJs.InputChannel, users: users.map(({ id, accessHash }) => buildInputEntity(id, accessHash)) as GramJs.InputUser[], }), { - shouldThrow: noErrorUpdate, + shouldIgnoreUpdates: true, + shouldThrow: true, }); + if (updates) { + processUpdate(updates); + restrictedUserIds = handleUserPrivacyRestrictedUpdates(updates); + } } catch (err) { - // `noErrorUpdate` will cause an exception which we don't want either + if ((err as Error).message === 'USER_PRIVACY_RESTRICTED') { + restrictedUserIds = users.map(({ id }) => id); + } else { + onUpdate({ + '@type': 'error', + error: { + message: (err as Error).message, + }, + }); + } } } - return channel; + return { channel, restrictedUserIds }; } export function joinChannel({ @@ -740,11 +756,12 @@ export async function createGroupChat({ title, users, }: { title: string; users: ApiUser[]; -}): Promise { +}) { const result = await invokeRequest(new GramJs.messages.CreateChat({ title, users: users.map(({ id, accessHash }) => buildInputEntity(id, accessHash)) as GramJs.InputUser[], }), { + shouldIgnoreUpdates: true, shouldThrow: true, }); @@ -758,6 +775,8 @@ export async function createGroupChat({ } return undefined; } + processUpdate(result); + const restrictedUserIds = handleUserPrivacyRestrictedUpdates(result); const newChat = result.chats[0]; if (!newChat || !(newChat instanceof GramJs.Chat)) { @@ -768,7 +787,7 @@ export async function createGroupChat({ return undefined; } - return buildApiChatFromPreview(newChat); + return { chat: buildApiChatFromPreview(newChat), restrictedUserIds }; } export async function editChatPhoto({ @@ -1265,31 +1284,64 @@ export async function openChatByInvite(hash: string) { return { chatId: chat.id }; } -export async function addChatMembers(chat: ApiChat, users: ApiUser[], noErrorUpdate = false) { +export async function addChatMembers(chat: ApiChat, users: ApiUser[]) { try { if (chat.type === 'chatTypeChannel' || chat.type === 'chatTypeSuperGroup') { - return await invokeRequest(new GramJs.channels.InviteToChannel({ - channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel, - users: users.map((user) => buildInputEntity(user.id, user.accessHash)) as GramJs.InputUser[], - }), { - shouldReturnTrue: true, - shouldThrow: noErrorUpdate, - }); + try { + const updates = await invokeRequest(new GramJs.channels.InviteToChannel({ + channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel, + users: users.map((user) => buildInputEntity(user.id, user.accessHash)) as GramJs.InputUser[], + }), { + shouldIgnoreUpdates: true, + shouldThrow: true, + }); + if (updates) { + processUpdate(updates); + return handleUserPrivacyRestrictedUpdates(updates); + } + } catch (err) { + if ((err as Error).message === 'USER_PRIVACY_RESTRICTED') { + return users.map(({ id }) => id); + } + throw err; + } } - return await Promise.all(users.map((user) => { - return invokeRequest(new GramJs.messages.AddChatUser({ - chatId: buildInputEntity(chat.id) as BigInt.BigInteger, - userId: buildInputEntity(user.id, user.accessHash) as GramJs.InputUser, - }), { - shouldReturnTrue: true, - shouldThrow: noErrorUpdate, - }); - })); + const addChatUsersResult = await Promise.all( + users.map(async (user) => { + try { + const updates = await invokeRequest(new GramJs.messages.AddChatUser({ + chatId: buildInputEntity(chat.id) as BigInt.BigInteger, + userId: buildInputEntity(user.id, user.accessHash) as GramJs.InputUser, + }), { + shouldIgnoreUpdates: true, + shouldThrow: true, + }); + if (updates) { + processUpdate(updates); + return handleUserPrivacyRestrictedUpdates(updates); + } + return undefined; + } catch (err) { + if ((err as Error).message === 'USER_PRIVACY_RESTRICTED') { + return [user.id]; + } + throw err; + } + }), + ); + if (addChatUsersResult) { + return addChatUsersResult.flat().filter(Boolean); + } } catch (err) { - // `noErrorUpdate` will cause an exception which we don't want either - return undefined; + onUpdate({ + '@type': 'error', + error: { + message: (err as Error).message, + }, + }); } + return undefined; } export function deleteChatMember(chat: ApiChat, user: ApiUser) { @@ -1819,3 +1871,24 @@ export function togglePeerTranslations({ peer: buildInputPeer(chat.id, chat.accessHash), })); } + +function handleUserPrivacyRestrictedUpdates(updates: GramJs.TypeUpdates) { + if (!(updates instanceof GramJs.Updates) && !(updates instanceof GramJs.UpdatesCombined)) { + return undefined; + } + + const eligibleUpdates = updates + .updates + .filter( + (u): u is GramJs.UpdateGroupInvitePrivacyForbidden => { + return u instanceof GramJs.UpdateGroupInvitePrivacyForbidden; + }, + ); + + if (eligibleUpdates.length === 0) { + return undefined; + } + + return eligibleUpdates + .map((u) => buildApiPeerId(u.userId, 'user')); +} diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 4d2e23962..6a29af4a3 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -674,6 +674,11 @@ export type ApiUpdateNewAuthorization = { location?: string; }; +export type ApiUpdateGroupInvitePrivacyForbidden = { + '@type': 'updateGroupInvitePrivacyForbidden'; + userId: string; +}; + export type ApiUpdate = ( ApiUpdateReady | ApiUpdateSession | ApiUpdateWebAuthTokenFailed | ApiUpdateRequestUserUpdate | ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser | @@ -702,7 +707,7 @@ export type ApiUpdate = ( ApiUpdatePinnedTopicsOrder | ApiUpdateTopic | ApiUpdateTopics | ApiUpdateRecentEmojiStatuses | ApiUpdateRecentReactions | ApiUpdateStory | ApiUpdateReadStories | ApiUpdateDeleteStory | ApiUpdateSentStoryReaction | ApiRequestReconnectApi | ApiRequestSync | ApiUpdateFetchingDifference | ApiUpdateChannelMessages | - ApiUpdateStealthMode | ApiUpdateAttachMenuBots | ApiUpdateNewAuthorization + ApiUpdateStealthMode | ApiUpdateAttachMenuBots | ApiUpdateNewAuthorization | ApiUpdateGroupInvitePrivacyForbidden ); export type OnApiUpdate = (update: ApiUpdate) => void; diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 27de33ca7..5b6890c56 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -81,3 +81,4 @@ export { default as Management } from '../components/right/management/Management export { default as PaymentModal } from '../components/payment/PaymentModal'; export { default as ReceiptModal } from '../components/payment/ReceiptModal'; +export { default as InviteViaLinkModal } from '../components/main/InviteViaLinkModal'; diff --git a/src/components/main/InviteViaLinkModal.async.tsx b/src/components/main/InviteViaLinkModal.async.tsx new file mode 100644 index 000000000..e14526b9a --- /dev/null +++ b/src/components/main/InviteViaLinkModal.async.tsx @@ -0,0 +1,18 @@ +import type { FC } from '../../lib/teact/teact'; +import React from '../../lib/teact/teact'; + +import type { OwnProps } from './InviteViaLinkModal'; + +import { Bundles } from '../../util/moduleLoader'; + +import useModuleLoader from '../../hooks/useModuleLoader'; + +const InviteViaLinkModalAsync: FC = (props) => { + const { userIds, chatId } = props; + const InviteViaLinkModal = useModuleLoader(Bundles.Extra, 'InviteViaLinkModal', !(userIds && chatId)); + + // eslint-disable-next-line react/jsx-props-no-spreading + return InviteViaLinkModal ? : undefined; +}; + +export default InviteViaLinkModalAsync; diff --git a/src/components/main/InviteViaLinkModal.module.scss b/src/components/main/InviteViaLinkModal.module.scss new file mode 100644 index 000000000..744ebf3c0 --- /dev/null +++ b/src/components/main/InviteViaLinkModal.module.scss @@ -0,0 +1,3 @@ +.contentText { + color: var(--color-text-secondary); +} diff --git a/src/components/main/InviteViaLinkModal.tsx b/src/components/main/InviteViaLinkModal.tsx new file mode 100644 index 000000000..64ed00ba8 --- /dev/null +++ b/src/components/main/InviteViaLinkModal.tsx @@ -0,0 +1,89 @@ +import type { FC } from '../../lib/teact/teact'; +import React, { + memo, useCallback, + useEffect, + useMemo, useState, +} from '../../lib/teact/teact'; +import { getActions, getGlobal } from '../../global'; + +import { getUserFullName } from '../../global/helpers'; +import renderText from '../common/helpers/renderText'; + +import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; + +import Picker from '../common/Picker'; +import Button from '../ui/Button'; +import Modal from '../ui/Modal'; + +import styles from './InviteViaLinkModal.module.scss'; + +export type OwnProps = { + chatId?: string; + userIds?: string[]; +}; + +const InviteViaLinkModal: FC = ({ + chatId, userIds, +}) => { + const { sendInviteMessages, closeInviteViaLinkModal } = getActions(); + + const lang = useLang(); + const [selectedMemberIds, setSelectedMemberIds] = useState([]); + + useEffect(() => { + if (userIds) { + setSelectedMemberIds(userIds); + } + }, [userIds]); + + const handleClose = useLastCallback(() => closeInviteViaLinkModal()); + const handleClickSkip = useLastCallback(() => closeInviteViaLinkModal()); + + const handleClickSendInviteLink = useCallback(() => { + sendInviteMessages({ chatId: chatId!, userIds: selectedMemberIds! }); + closeInviteViaLinkModal(); + }, [selectedMemberIds, chatId]); + + const userNames = useMemo(() => { + const usersById = getGlobal().users.byId; + return userIds?.map((userId) => getUserFullName(usersById[userId])).join(', '); + }, [userIds]); + + return ( + +

+ {renderText(lang('SendInviteLink.TextAvailableSingleUser', userNames), ['simple_markdown'])} +

+ +
+ + +
+
+ ); +}; + +export default memo(InviteViaLinkModal); diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 1089c1b9c..770979b9b 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -96,6 +96,7 @@ import DraftRecipientPicker from './DraftRecipientPicker.async'; import ForwardRecipientPicker from './ForwardRecipientPicker.async'; import GameModal from './GameModal'; import HistoryCalendar from './HistoryCalendar.async'; +import InviteViaLinkModal from './InviteViaLinkModal.async'; import NewContactModal from './NewContactModal.async'; import Notifications from './Notifications.async'; import PremiumLimitReachedModal from './premium/common/PremiumLimitReachedModal.async'; @@ -161,6 +162,7 @@ type StateProps = { noRightColumnAnimation?: boolean; withInterfaceAnimations?: boolean; isSynced?: boolean; + inviteViaLinkModal?: TabState['inviteViaLinkModal']; }; const APP_OUTDATED_TIMEOUT_MS = 5 * 60 * 1000; // 5 min @@ -222,6 +224,7 @@ const Main: FC = ({ boostModal, noRightColumnAnimation, isSynced, + inviteViaLinkModal, }) => { const { initMain, @@ -586,6 +589,7 @@ const Main: FC = ({ + ); }; @@ -629,6 +633,7 @@ export default memo(withGlobal( chatlistModal, boostModal, giftCodeModal, + inviteViaLinkModal, } = selectTabState(global); const { chatId: audioChatId, messageId: audioMessageId } = audioPlayer; @@ -696,6 +701,7 @@ export default memo(withGlobal( giftCodeModal, noRightColumnAnimation, isSynced: global.isSynced, + inviteViaLinkModal, }; }, )(Main)); diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index c39c5c34d..15b3d819f 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -53,6 +53,7 @@ import { addMessages, addUsers, addUserStatuses, + addUsersToRestrictedInviteList, deleteTopic, leaveChat, replaceChatFullInfo, @@ -436,9 +437,11 @@ addActionHandler('createChannel', async (global, actions, payload): Promise id), createdChatId!, tabId); + setGlobal(global); } } }); @@ -1645,7 +1665,12 @@ addActionHandler('addChatMembers', async (global, actions, payload): Promise return undefined; }); +addActionHandler('sendInviteMessages', async (global, actions, payload): Promise => { + const { chatId, userIds, tabId = getCurrentTabId() } = payload; + const chatFullInfo = selectChatFullInfo(global, chatId); + if (!chatFullInfo?.inviteLink) { + return undefined; + } + const userFullNames: string[] = []; + await Promise.all(userIds.map((userId) => { + const chat = selectChat(global, userId); + if (!chat) { + return undefined; + } + const userFullName = getUserFullName(selectUser(global, userId)); + if (userFullName) { + userFullNames.push(userFullName); + } + return sendMessage(global, { + chat, + text: chatFullInfo.inviteLink, + }); + })); + return actions.showNotification({ + message: translate('Conversation.ShareLinkTooltip.Chat.One', userFullNames.join(', ')), + tabId, + }); +}); + addActionHandler('editMessage', (global, actions, payload): ActionReturnType => { const { messageList, text, entities, tabId = getCurrentTabId(), diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 311193d3e..2891d0e53 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -723,6 +723,13 @@ addActionHandler('updatePageTitle', (global, actions, payload): ActionReturnType setPageTitleInstant(IS_ELECTRON ? '' : PAGE_TITLE); }); +addActionHandler('closeInviteViaLinkModal', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload ?? {}; + return updateTabState(global, { + inviteViaLinkModal: undefined, + }, tabId); +}); + let prevIsScreenLocked: boolean | undefined; let prevBlurredTabsCount: number = 0; let onlineTimeout: number | undefined; diff --git a/src/global/reducers/users.ts b/src/global/reducers/users.ts index e96149bdb..0079977c1 100644 --- a/src/global/reducers/users.ts +++ b/src/global/reducers/users.ts @@ -3,7 +3,7 @@ import type { GlobalState, TabArgs, TabState } from '../types'; import { areDeepEqual } from '../../util/areDeepEqual'; import { getCurrentTabId } from '../../util/establishMultitabRole'; -import { omit, pick } from '../../util/iteratees'; +import { omit, pick, unique } from '../../util/iteratees'; import { MEMO_EMPTY_ARRAY } from '../../util/memo'; import { selectTabState } from '../selectors'; import { updateChat } from './chats'; @@ -263,3 +263,19 @@ export function closeNewContactDialog( newContact: undefined, }, tabId); } + +export function addUsersToRestrictedInviteList( + global: T, + userIds: string[], + chatId: string, + ...[tabId = getCurrentTabId()]: TabArgs +): T { + const { inviteViaLinkModal } = selectTabState(global, tabId); + return updateTabState(global, { + inviteViaLinkModal: { + ...inviteViaLinkModal, + restrictedUserIds: unique([...inviteViaLinkModal?.restrictedUserIds ?? [], ...userIds]), + chatId, + }, + }, tabId); +} diff --git a/src/global/types.ts b/src/global/types.ts index ac93a72c0..ae10b2cd7 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -641,6 +641,11 @@ export type TabState = { slug: string; info: ApiCheckedGiftCode; }; + + inviteViaLinkModal?: { + restrictedUserIds: string[]; + chatId: string; + }; }; export type GlobalState = { @@ -1278,6 +1283,10 @@ export interface ActionPayloads { messageList?: MessageList; isReaction?: true; // Reaction to the story are sent in the form of a message } & WithTabId; + sendInviteMessages: { + chatId: string; + userIds: string[]; + } & WithTabId; cancelSendingMessage: { chatId: string; messageId: number; @@ -2578,6 +2587,7 @@ export interface ActionPayloads { dismissNotification: { localId: string } & WithTabId; updatePageTitle: WithTabId | undefined; + closeInviteViaLinkModal: WithTabId | undefined; // Calls joinGroupCall: {