Invite restricted users via link (#3982)

This commit is contained in:
Alexander Zinchuk 2023-12-04 14:38:38 +01:00
parent 0e5660abd7
commit 4d70ae306d
12 changed files with 317 additions and 36 deletions

View File

@ -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<ApiChat | undefined> {
}) {
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<ApiChat | undefined> {
}) {
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'));
}

View File

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

View File

@ -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';

View File

@ -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<OwnProps> = (props) => {
const { userIds, chatId } = props;
const InviteViaLinkModal = useModuleLoader(Bundles.Extra, 'InviteViaLinkModal', !(userIds && chatId));
// eslint-disable-next-line react/jsx-props-no-spreading
return InviteViaLinkModal ? <InviteViaLinkModal {...props} /> : undefined;
};
export default InviteViaLinkModalAsync;

View File

@ -0,0 +1,3 @@
.contentText {
color: var(--color-text-secondary);
}

View File

@ -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<OwnProps> = ({
chatId, userIds,
}) => {
const { sendInviteMessages, closeInviteViaLinkModal } = getActions();
const lang = useLang();
const [selectedMemberIds, setSelectedMemberIds] = useState<string[]>([]);
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 (
<Modal
isOpen={Boolean(userIds && chatId)}
title={lang('SendInviteLink.InviteTitle')}
onClose={handleClose}
isSlim
>
<p className={styles.contentText}>
{renderText(lang('SendInviteLink.TextAvailableSingleUser', userNames), ['simple_markdown'])}
</p>
<Picker
itemIds={userIds!}
selectedIds={selectedMemberIds ?? []}
onSelectedIdsChange={setSelectedMemberIds}
/>
<div className="dialog-buttons">
<Button
className="confirm-dialog-button"
isText
onClick={handleClickSendInviteLink}
disabled={!selectedMemberIds?.length}
>
{lang('SendInviteLink.ActionInvite')}
</Button>
<Button
className="confirm-dialog-button"
isText
onClick={handleClickSkip}
>
{lang('SendInviteLink.ActionSkip')}
</Button>
</div>
</Modal>
);
};
export default memo(InviteViaLinkModal);

View File

@ -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<OwnProps & StateProps> = ({
boostModal,
noRightColumnAnimation,
isSynced,
inviteViaLinkModal,
}) => {
const {
initMain,
@ -586,6 +589,7 @@ const Main: FC<OwnProps & StateProps> = ({
<ReceiptModal isOpen={isReceiptModalOpen} onClose={clearReceipt} />
<DeleteFolderDialog folder={deleteFolderDialog} />
<ReactionPicker isOpen={isReactionPickerOpen} />
<InviteViaLinkModal userIds={inviteViaLinkModal?.restrictedUserIds} chatId={inviteViaLinkModal?.chatId} />
</div>
);
};
@ -629,6 +633,7 @@ export default memo(withGlobal<OwnProps>(
chatlistModal,
boostModal,
giftCodeModal,
inviteViaLinkModal,
} = selectTabState(global);
const { chatId: audioChatId, messageId: audioMessageId } = audioPlayer;
@ -696,6 +701,7 @@ export default memo(withGlobal<OwnProps>(
giftCodeModal,
noRightColumnAnimation,
isSynced: global.isSynced,
inviteViaLinkModal,
};
},
)(Main));

View File

@ -53,6 +53,7 @@ import {
addMessages,
addUsers,
addUserStatuses,
addUsersToRestrictedInviteList,
deleteTopic,
leaveChat,
replaceChatFullInfo,
@ -436,9 +437,11 @@ addActionHandler('createChannel', async (global, actions, payload): Promise<void
setGlobal(global);
let createdChannel: ApiChat | undefined;
let restrictedUserIds: string[] | undefined;
try {
createdChannel = await callApi('createChannel', { title, about, users });
const result = await callApi('createChannel', { title, about, users });
createdChannel = result?.channel;
restrictedUserIds = result?.restrictedUserIds;
} catch (error) {
global = getGlobal();
@ -474,6 +477,12 @@ addActionHandler('createChannel', async (global, actions, payload): Promise<void
setGlobal(global);
actions.openChat({ id: channelId, shouldReplaceHistory: true, tabId });
if (restrictedUserIds) {
global = getGlobal();
global = addUsersToRestrictedInviteList(global, restrictedUserIds, channelId, tabId);
setGlobal(global);
}
if (channelId && accessHash && photo) {
await callApi('editChatPhoto', { chatId: channelId, accessHash, photo });
}
@ -593,17 +602,19 @@ addActionHandler('createGroupChat', async (global, actions, payload): Promise<vo
}, tabId);
setGlobal(global);
let createdChatId: string | undefined;
try {
const createdChat = await callApi('createGroupChat', {
const { chat: createdChat, restrictedUserIds } = await callApi('createGroupChat', {
title,
users,
});
}) ?? {};
if (!createdChat) {
return;
}
const { id: chatId } = createdChat;
createdChatId = chatId;
global = getGlobal();
global = updateChat(global, chatId, createdChat);
@ -619,6 +630,11 @@ addActionHandler('createGroupChat', async (global, actions, payload): Promise<vo
shouldReplaceHistory: true,
tabId,
});
if (restrictedUserIds) {
global = getGlobal();
global = addUsersToRestrictedInviteList(global, restrictedUserIds, chatId, tabId);
setGlobal(global);
}
if (chatId && photo) {
await callApi('editChatPhoto', {
@ -626,8 +642,8 @@ addActionHandler('createGroupChat', async (global, actions, payload): Promise<vo
photo,
});
}
} catch (e: any) {
if (e.message === 'USERS_TOO_FEW') {
} catch (err) {
if ((err as ApiError).message === 'USERS_TOO_FEW') {
global = getGlobal();
global = updateTabState(global, {
chatCreation: {
@ -637,6 +653,10 @@ addActionHandler('createGroupChat', async (global, actions, payload): Promise<vo
},
}, tabId);
setGlobal(global);
} else if ((err as ApiError).message === 'USER_PRIVACY_RESTRICTED') {
global = getGlobal();
global = addUsersToRestrictedInviteList(global, users.map(({ id }) => id), createdChatId!, tabId);
setGlobal(global);
}
}
});
@ -1645,7 +1665,12 @@ addActionHandler('addChatMembers', async (global, actions, payload): Promise<voi
}
actions.setNewChatMembersDialogState({ newChatMembersProgress: NewChatMembersProgress.Loading, tabId });
await callApi('addChatMembers', chat, users);
const restrictedUserIds = await callApi('addChatMembers', chat, users);
if (restrictedUserIds) {
global = getGlobal();
global = addUsersToRestrictedInviteList(global, restrictedUserIds, chat.id, tabId);
setGlobal(global);
}
actions.setNewChatMembersDialogState({ newChatMembersProgress: NewChatMembersProgress.Closed, tabId });
global = getGlobal();
loadFullChat(global, actions, chat, tabId);

View File

@ -79,6 +79,7 @@ import {
import { updateTabState } from '../../reducers/tabs';
import {
selectChat,
selectChatFullInfo,
selectChatMessage,
selectCurrentChat,
selectCurrentMessageList,
@ -360,6 +361,33 @@ addActionHandler('sendMessage', (global, actions, payload): ActionReturnType =>
return undefined;
});
addActionHandler('sendInviteMessages', async (global, actions, payload): Promise<void> => {
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(),

View File

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

View File

@ -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<T extends GlobalState>(
newContact: undefined,
}, tabId);
}
export function addUsersToRestrictedInviteList<T extends GlobalState>(
global: T,
userIds: string[],
chatId: string,
...[tabId = getCurrentTabId()]: TabArgs<T>
): T {
const { inviteViaLinkModal } = selectTabState(global, tabId);
return updateTabState(global, {
inviteViaLinkModal: {
...inviteViaLinkModal,
restrictedUserIds: unique([...inviteViaLinkModal?.restrictedUserIds ?? [], ...userIds]),
chatId,
},
}, tabId);
}

View File

@ -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: {