From 97891c02a55e43b57e0e0d5b2905a0d3b65207dd Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 28 Jan 2022 02:12:00 +0100 Subject: [PATCH] Management: Manage Join Requests and revoked invites (#1667) --- src/api/gramjs/apiBuilders/chats.ts | 20 +- src/api/gramjs/methods/chats.ts | 91 +------ src/api/gramjs/methods/index.ts | 3 +- src/api/gramjs/methods/management.ts | 161 ++++++++++- src/api/gramjs/updater.ts | 14 + src/api/types/chats.ts | 4 + src/api/types/misc.ts | 8 + src/api/types/updates.ts | 10 +- src/components/middle/HeaderActions.tsx | 25 +- src/components/middle/MiddleHeader.scss | 21 ++ src/components/right/RightColumn.tsx | 19 +- src/components/right/RightHeader.tsx | 61 ++++- .../right/management/JoinRequest.scss | 55 ++++ .../right/management/JoinRequest.tsx | 101 +++++++ .../right/management/ManageChannel.tsx | 25 +- .../right/management/ManageGroup.tsx | 25 +- .../right/management/ManageInvite.tsx | 6 +- .../right/management/ManageInviteInfo.tsx | 152 +++++++++++ .../right/management/ManageInvites.tsx | 158 +++++++++-- .../right/management/ManageJoinRequests.tsx | 119 ++++++++ .../right/management/Management.scss | 19 ++ .../right/management/Management.tsx | 18 ++ src/global/types.ts | 2 + src/lib/gramjs/tl/apiTl.js | 4 + src/lib/gramjs/tl/static/api.json | 4 + src/modules/actions/api/chats.ts | 77 ------ src/modules/actions/api/management.ts | 253 +++++++++++++++++- src/modules/actions/apiUpdaters/chats.ts | 16 ++ src/modules/actions/ui/misc.ts | 23 ++ src/types/index.ts | 9 + src/util/dateFormat.ts | 4 + 31 files changed, 1313 insertions(+), 194 deletions(-) create mode 100644 src/components/right/management/JoinRequest.scss create mode 100644 src/components/right/management/JoinRequest.tsx create mode 100644 src/components/right/management/ManageInviteInfo.tsx create mode 100644 src/components/right/management/ManageJoinRequests.tsx diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index 25f1d614..71afaee7 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -9,6 +9,7 @@ import { ApiChatMember, ApiRestrictionReason, ApiExportedInvite, + ApiChatInviteImporter, } from '../../types'; import { pick, pickTruthy } from '../../../util/iteratees'; import { @@ -388,7 +389,7 @@ export function buildApiChatBotCommands(botInfos: GramJs.BotInfo[]) { }, [] as ApiBotCommand[]); } -export function buildApiExportedInvite(invite: GramJs.ChatInviteExported) : ApiExportedInvite { +export function buildApiExportedInvite(invite: GramJs.ChatInviteExported): ApiExportedInvite { const { revoked, date, @@ -401,6 +402,7 @@ export function buildApiExportedInvite(invite: GramJs.ChatInviteExported) : ApiE requested, requestNeeded, title, + adminId, } = invite; return { isRevoked: revoked, @@ -414,5 +416,21 @@ export function buildApiExportedInvite(invite: GramJs.ChatInviteExported) : ApiE isRequestNeeded: requestNeeded, requested, title, + adminId: buildApiPeerId(adminId, 'user'), + }; +} + +export function buildChatInviteImporter(importer: GramJs.ChatInviteImporter): ApiChatInviteImporter { + const { + userId, + date, + about, + requested, + } = importer; + return { + userId: buildApiPeerId(userId, 'user'), + date, + about, + isRequested: requested, }; } diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index 054e8e22..4e361c91 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -26,7 +26,6 @@ import { buildApiChatFolder, buildApiChatFolderFromSuggested, buildApiChatBotCommands, - buildApiExportedInvite, } from '../apiBuilders/chats'; import { buildApiMessage, buildMessageDraft } from '../apiBuilders/messages'; import { buildApiUser, buildApiUsersAndStatuses } from '../apiBuilders/users'; @@ -343,6 +342,8 @@ async function getFullChatInfo(chatId: string): Promise<{ botInfo, call, availableReactions, + recentRequesters, + requestsPending, } = result.fullChat; const members = buildChatMembers(participants); @@ -361,6 +362,8 @@ async function getFullChatInfo(chatId: string): Promise<{ }), groupCallId: call?.id.toString(), enabledReactions: availableReactions, + requestsPending, + recentRequesterIds: recentRequesters?.map((userId) => buildApiPeerId(userId, 'user')), }, users: result.users.map(buildApiUser).filter(Boolean as any), groupCall: call ? { @@ -408,6 +411,8 @@ async function getFullChannelInfo( botInfo, availableReactions, defaultSendAs, + requestsPending, + recentRequesters, } = result.fullChat; const inviteLink = exportedInvite instanceof GramJs.ChatInviteExported @@ -460,6 +465,8 @@ async function getFullChannelInfo( botCommands, enabledReactions: availableReactions, sendAsId: defaultSendAs ? getApiChatIdFromMtpPeer(defaultSendAs) : undefined, + requestsPending, + recentRequesterIds: recentRequesters?.map((userId) => buildApiPeerId(userId, 'user')), }, users: [...(users || []), ...(bannedUsers || []), ...(adminUsers || [])], groupCall: call ? { @@ -1012,7 +1019,7 @@ export async function openChatByInvite(hash: string) { if (result instanceof GramJs.ChatInvite) { const { - photo, participantsCount, title, channel, requestNeeded, about, + photo, participantsCount, title, channel, requestNeeded, about, megagroup, } = result; if (photo instanceof GramJs.Photo) { @@ -1026,7 +1033,7 @@ export async function openChatByInvite(hash: string) { about, hash, participantsCount, - isChannel: channel, + isChannel: channel && !megagroup, isRequestNeeded: requestNeeded, ...(photo instanceof GramJs.Photo && { photo: buildApiPhoto(photo) }), }, @@ -1139,84 +1146,6 @@ function updateLocalDb(result: ( } } -export async function fetchExportedChatInvites({ - peer, admin, limit = 0, isRevoked, -}: { peer: ApiChat; admin: ApiUser; limit: number; isRevoked?: boolean }) { - const exportedInvites = await invokeRequest(new GramJs.messages.GetExportedChatInvites({ - peer: buildInputPeer(peer.id, peer.accessHash), - adminId: buildInputEntity(admin.id, admin.accessHash) as GramJs.InputUser, - limit, - revoked: isRevoked || undefined, - })); - - if (!exportedInvites) return undefined; - - return exportedInvites.invites.map(buildApiExportedInvite); -} - -export async function editExportedChatInvite({ - peer, isRevoked, link, expireDate, usageLimit, isRequestNeeded, title, -}: { - peer: ApiChat; - isRevoked?: boolean; - link: string; - expireDate?: number; - usageLimit?: number; - isRequestNeeded?: boolean; - title?: string; -}) { - const invite = await invokeRequest(new GramJs.messages.EditExportedChatInvite({ - link, - peer: buildInputPeer(peer.id, peer.accessHash), - expireDate, - usageLimit: !isRequestNeeded ? usageLimit : undefined, - requestNeeded: isRequestNeeded, - title, - revoked: isRevoked || undefined, - })); - - if (!invite) return undefined; - - if (invite instanceof GramJs.messages.ExportedChatInvite) { - const replaceInvite = buildApiExportedInvite(invite.invite); - return { - oldInvite: replaceInvite, - newInvite: replaceInvite, - }; - } - - if (invite instanceof GramJs.messages.ExportedChatInviteReplaced) { - const oldInvite = buildApiExportedInvite(invite.invite); - const newInvite = buildApiExportedInvite(invite.newInvite); - return { - oldInvite, - newInvite, - }; - } - return undefined; -} - -export async function exportChatInvite({ - peer, expireDate, usageLimit, isRequestNeeded, title, -}: { - peer: ApiChat; - expireDate?: number; - usageLimit?: number; - isRequestNeeded?: boolean; - title?: string; -}) { - const invite = await invokeRequest(new GramJs.messages.ExportChatInvite({ - peer: buildInputPeer(peer.id, peer.accessHash), - expireDate, - usageLimit: !isRequestNeeded ? usageLimit : undefined, - requestNeeded: isRequestNeeded || undefined, - title, - })); - - if (!invite) return undefined; - return buildApiExportedInvite(invite); -} - export async function importChatInvite({ hash }: { hash: string }) { const updates = await invokeRequest(new GramJs.messages.ImportChatInvite({ hash })); if (!(updates instanceof GramJs.Updates) || !updates.chats.length) { diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 33f0b556..1eb173a7 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -15,7 +15,6 @@ export { getChatByUsername, togglePreHistoryHidden, updateChatDefaultBannedRights, updateChatMemberBannedRights, updateChatTitle, updateChatAbout, toggleSignatures, updateChatAdmin, fetchGroupsForDiscussion, setDiscussionGroup, migrateChat, openChatByInvite, fetchMembers, importChatInvite, addChatMembers, deleteChatMember, toggleIsProtected, - fetchExportedChatInvites, editExportedChatInvite, exportChatInvite, } from './chats'; export { @@ -40,6 +39,8 @@ export { export { checkChatUsername, setChatUsername, updatePrivateLink, + fetchExportedChatInvites, editExportedChatInvite, exportChatInvite, deleteExportedChatInvite, + deleteRevokedExportedChatInvites, fetchChatInviteImporters, hideChatJoinRequest, hideAllChatJoinRequests, } from './management'; export { diff --git a/src/api/gramjs/methods/management.ts b/src/api/gramjs/methods/management.ts index bb555809..c8ca6e2d 100644 --- a/src/api/gramjs/methods/management.ts +++ b/src/api/gramjs/methods/management.ts @@ -2,7 +2,9 @@ import { Api as GramJs } from '../../../lib/gramjs'; import { invokeRequest } from './client'; import { buildInputEntity, buildInputPeer } from '../gramjsBuilders'; -import { ApiChat, OnApiUpdate } from '../../types'; +import { ApiChat, ApiUser, OnApiUpdate } from '../../types'; +import { addEntitiesWithPhotosToLocalDb } from '../helpers'; +import { buildApiExportedInvite, buildChatInviteImporter } from '../apiBuilders/chats'; let onUpdate: OnApiUpdate; @@ -59,3 +61,160 @@ export async function updatePrivateLink({ return result.link; } + +export async function fetchExportedChatInvites({ + peer, admin, limit = 0, isRevoked, +}: { peer: ApiChat; admin: ApiUser; limit: number; isRevoked?: boolean }) { + const exportedInvites = await invokeRequest(new GramJs.messages.GetExportedChatInvites({ + peer: buildInputPeer(peer.id, peer.accessHash), + adminId: buildInputEntity(admin.id, admin.accessHash) as GramJs.InputUser, + limit, + revoked: isRevoked || undefined, + })); + + if (!exportedInvites) return undefined; + addEntitiesWithPhotosToLocalDb(exportedInvites.users); + return exportedInvites.invites.map(buildApiExportedInvite); +} + +export async function editExportedChatInvite({ + peer, isRevoked, link, expireDate, usageLimit, isRequestNeeded, title, +}: { + peer: ApiChat; + isRevoked?: boolean; + link: string; + expireDate?: number; + usageLimit?: number; + isRequestNeeded?: boolean; + title?: string; +}) { + const invite = await invokeRequest(new GramJs.messages.EditExportedChatInvite({ + link, + peer: buildInputPeer(peer.id, peer.accessHash), + expireDate, + usageLimit: !isRequestNeeded ? usageLimit : undefined, + requestNeeded: isRequestNeeded, + title, + revoked: isRevoked || undefined, + })); + + if (!invite) return undefined; + + addEntitiesWithPhotosToLocalDb(invite.users); + if (invite instanceof GramJs.messages.ExportedChatInvite) { + const replaceInvite = buildApiExportedInvite(invite.invite); + return { + oldInvite: replaceInvite, + newInvite: replaceInvite, + }; + } + + if (invite instanceof GramJs.messages.ExportedChatInviteReplaced) { + const oldInvite = buildApiExportedInvite(invite.invite); + const newInvite = buildApiExportedInvite(invite.newInvite); + return { + oldInvite, + newInvite, + }; + } + return undefined; +} + +export async function exportChatInvite({ + peer, expireDate, usageLimit, isRequestNeeded, title, +}: { + peer: ApiChat; + expireDate?: number; + usageLimit?: number; + isRequestNeeded?: boolean; + title?: string; +}) { + const invite = await invokeRequest(new GramJs.messages.ExportChatInvite({ + peer: buildInputPeer(peer.id, peer.accessHash), + expireDate, + usageLimit: !isRequestNeeded ? usageLimit : undefined, + requestNeeded: isRequestNeeded || undefined, + title, + })); + + if (!invite) return undefined; + return buildApiExportedInvite(invite); +} + +export async function deleteExportedChatInvite({ + peer, link, +}: { + peer: ApiChat; link: string; +}) { + const result = await invokeRequest(new GramJs.messages.DeleteExportedChatInvite({ + peer: buildInputPeer(peer.id, peer.accessHash), + link, + })); + + return result; +} + +export async function deleteRevokedExportedChatInvites({ + peer, admin, +}: { + peer: ApiChat; admin: ApiUser; +}) { + const result = await invokeRequest(new GramJs.messages.DeleteRevokedExportedChatInvites({ + peer: buildInputPeer(peer.id, peer.accessHash), + adminId: buildInputEntity(admin.id, admin.accessHash) as GramJs.InputUser, + })); + + return result; +} + +export async function fetchChatInviteImporters({ + peer, link, offsetDate = 0, offsetUser, limit = 0, isRequested, +}: { + peer: ApiChat; link?: string; offsetDate: number; offsetUser?: ApiUser; limit: number; isRequested?: boolean; +}) { + const result = await invokeRequest(new GramJs.messages.GetChatInviteImporters({ + peer: buildInputPeer(peer.id, peer.accessHash), + link, + offsetDate, + offsetUser: offsetUser + ? buildInputEntity(offsetUser.id, offsetUser.accessHash) as GramJs.InputUser : new GramJs.InputUserEmpty(), + limit, + requested: isRequested || undefined, + })); + + if (!result) return undefined; + addEntitiesWithPhotosToLocalDb(result.users); + return result.importers.map((importer) => buildChatInviteImporter(importer)); +} + +export function hideChatJoinRequest({ + peer, + user, + isApproved, +}: { + peer: ApiChat; + user: ApiUser; + isApproved: boolean; +}) { + return invokeRequest(new GramJs.messages.HideChatJoinRequest({ + peer: buildInputPeer(peer.id, peer.accessHash), + userId: buildInputEntity(user.id, user.accessHash) as GramJs.InputUser, + approved: isApproved || undefined, + }), true); +} + +export function hideAllChatJoinRequests({ + peer, + isApproved, + link, +}: { + peer: ApiChat; + isApproved: boolean; + link?: string; +}) { + return invokeRequest(new GramJs.messages.HideAllChatJoinRequests({ + peer: buildInputPeer(peer.id, peer.accessHash), + approved: isApproved || undefined, + link, + }), true); +} diff --git a/src/api/gramjs/updater.ts b/src/api/gramjs/updater.ts index f20a35e8..cbc16fba 100644 --- a/src/api/gramjs/updater.ts +++ b/src/api/gramjs/updater.ts @@ -879,6 +879,20 @@ export function updater(update: Update, originRequest?: GramJs.AnyRequest) { groupCallId: getGroupCallId(update.call), participants: update.participants.map(buildApiGroupCallParticipant), }); + } else if (update instanceof GramJs.UpdatePendingJoinRequests) { + // eslint-disable-next-line no-underscore-dangle + const entities = update._entities; + if (entities) { + addEntitiesWithPhotosToLocalDb(entities); + dispatchUserAndChatUpdates(entities); + } + + onUpdate({ + '@type': 'updatePendingJoinRequests', + chatId: getApiChatIdFromMtpPeer(update.peer), + recentRequesterIds: update.recentRequesters.map((id) => buildApiPeerId(id, 'user')), + requestsPending: update.requestsPending, + }); } else if (DEBUG) { const params = typeof update === 'object' && 'className' in update ? update.className : update; // eslint-disable-next-line no-console diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 436620fc..f7dbc073 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -1,5 +1,6 @@ import { ApiMessage, ApiPhoto } from './messages'; import { ApiBotCommand } from './bots'; +import { ApiChatInviteImporter } from './misc'; type ApiChatType = ( 'chatTypePrivate' | 'chatTypeSecret' | @@ -57,6 +58,7 @@ export interface ApiChat { // Obtained with UpdateUserTyping or UpdateChatUserTyping updates typingStatus?: ApiTypingStatus; + joinRequests?: ApiChatInviteImporter[]; sendAsIds?: string[]; } @@ -89,6 +91,8 @@ export interface ApiChatFullInfo { botCommands?: ApiBotCommand[]; enabledReactions?: string[]; sendAsId?: string; + recentRequesterIds?: string[]; + requestsPending?: number; } export interface ApiChatMember { diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index a62f4b6b..9d5f7ef6 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -109,6 +109,14 @@ export type ApiExportedInvite = { isRequestNeeded?: boolean; requested?: number; title?: string; + adminId: string; +}; + +export type ApiChatInviteImporter = { + userId: string; + date: number; + isRequested?: boolean; + about?: string; }; export interface ApiCountry { diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 9ae2a169..1c425d29 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -420,6 +420,13 @@ export type ApiUpdateGroupCallParticipants = { nextOffset?: string; }; +export type ApiUpdatePendingJoinRequests = { + '@type': 'updatePendingJoinRequests'; + chatId: string; + recentRequesterIds: string[]; + requestsPending: number; +}; + export type ApiUpdateGroupCallConnection = { '@type': 'updateGroupCallConnection'; data: GroupCallConnectionData; @@ -459,7 +466,8 @@ export type ApiUpdate = ( ApiUpdateNotifySettings | ApiUpdateNotifyExceptions | ApiUpdatePeerBlocked | ApiUpdatePrivacy | ApiUpdateServerTimeOffset | ApiUpdateShowInvite | ApiUpdateMessageReactions | ApiUpdateGroupCallParticipants | ApiUpdateGroupCallConnection | ApiUpdateGroupCall | ApiUpdateGroupCallStreams | - ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId + ApiUpdateGroupCallConnectionState | ApiUpdateGroupCallLeavePresentation | ApiUpdateGroupCallChatId | + ApiUpdatePendingJoinRequests ); export type OnApiUpdate = (update: ApiUpdate) => void; diff --git a/src/components/middle/HeaderActions.tsx b/src/components/middle/HeaderActions.tsx index e73720be..f96a8963 100644 --- a/src/components/middle/HeaderActions.tsx +++ b/src/components/middle/HeaderActions.tsx @@ -10,7 +10,7 @@ import { getDispatch, withGlobal } from '../../lib/teact/teactn'; import { MessageListType } from '../../global/types'; import { MAIN_THREAD_ID } from '../../api/types'; -import { IAnchorPosition } from '../../types'; +import { IAnchorPosition, ManagementScreens } from '../../types'; import { ARE_CALLS_SUPPORTED, IS_MAC_OS, IS_PWA, IS_SINGLE_COLUMN_LAYOUT, @@ -53,6 +53,7 @@ interface StateProps { canLeave?: boolean; canEnterVoiceChat?: boolean; canCreateVoiceChat?: boolean; + pendingJoinRequests?: number; } // Chrome breaks layout when focusing input during transition @@ -72,6 +73,7 @@ const HeaderActions: FC = ({ canLeave, canEnterVoiceChat, canCreateVoiceChat, + pendingJoinRequests, isRightColumnShown, canExpandActions, }) => { @@ -81,6 +83,7 @@ const HeaderActions: FC = ({ openLocalTextSearch, restartBot, openCallFallbackConfirm, + requestNextManagementScreen, } = getDispatch(); // eslint-disable-next-line no-null/no-null @@ -114,6 +117,10 @@ const HeaderActions: FC = ({ restartBot({ chatId }); }, [chatId, restartBot]); + const handleJoinRequestsClick = useCallback(() => { + requestNextManagementScreen({ screen: ManagementScreens.JoinRequests }); + }, [requestNextManagementScreen]); + const handleSearchClick = useCallback(() => { openLocalTextSearch(); @@ -213,6 +220,20 @@ const HeaderActions: FC = ({ )} )} + {Boolean(pendingJoinRequests) && ( + + )} + )} + {currentInviteInfo && currentInviteInfo.isRevoked && ( + + )} + + + ); + case HeaderContent.ManageJoinRequests: + return

{isChannel ? lang('SubscribeRequests') : lang('MemberRequests')}

; case HeaderContent.ManageGroupAddAdmins: return

{lang('Channel.Management.AddModerator')}

; case HeaderContent.StickerSearch: @@ -381,6 +438,7 @@ export default memo(withGlobal( && (isUserId(chat.id) || ((isChatAdmin(chat) || chat.isCreator) && !chat.isNotJoined)), ); const isEditingInvite = Boolean(chatId && global.management.byChatId[chatId]?.editingInvite); + const currentInviteInfo = chatId ? global.management.byChatId[chatId]?.inviteInfo?.invite : undefined; return { canManage, @@ -391,6 +449,7 @@ export default memo(withGlobal( stickerSearchQuery, gifSearchQuery, isEditingInvite, + currentInviteInfo, }; }, )(RightHeader)); diff --git a/src/components/right/management/JoinRequest.scss b/src/components/right/management/JoinRequest.scss new file mode 100644 index 00000000..39045557 --- /dev/null +++ b/src/components/right/management/JoinRequest.scss @@ -0,0 +1,55 @@ +.JoinRequest { + display: flex; + flex-direction: column; + padding: 1rem 0; + + &:not(:last-child) { + border-bottom: 1px solid var(--color-borders); + } + + &__top { + display: flex; + cursor: pointer; + padding: 0.5rem; + border-radius: 0.5rem; + + &:hover { + background-color: var(--color-chat-hover); + } + } + + &__user { + display: flex; + flex-grow: 1; + } + + &__user-info { + display: flex; + flex-direction: column; + justify-content: center; + margin-left: 1rem; + } + + &__user-subtitle { + color: var(--color-text-secondary); + } + + &__date { + color: var(--color-text-secondary); + font-size: 0.875rem; + margin-left: 1rem; + white-space: nowrap; + } + + &__buttons { + display: flex; + justify-content: space-evenly; + margin-top: 1rem; + gap: 0.5rem; + } + + &__button { + width: auto; + height: auto; + } +} diff --git a/src/components/right/management/JoinRequest.tsx b/src/components/right/management/JoinRequest.tsx new file mode 100644 index 00000000..b033770b --- /dev/null +++ b/src/components/right/management/JoinRequest.tsx @@ -0,0 +1,101 @@ +import React, { FC, memo, useCallback } from '../../../lib/teact/teact'; +import { getDispatch, withGlobal } from '../../../lib/teact/teactn'; + +import { ApiUser } from '../../../api/types'; + +import useLang from '../../../hooks/useLang'; +import { getUserFullName } from '../../../modules/helpers'; +import { selectUser } from '../../../modules/selectors'; +import { formatHumanDate, formatTime, isToday } from '../../../util/dateFormat'; +import { getServerTime } from '../../../util/serverTime'; +import { createClassNameBuilder } from '../../../util/buildClassName'; + +import Avatar from '../../common/Avatar'; +import Button from '../../ui/Button'; + +import './JoinRequest.scss'; + +type OwnProps = { + userId: string; + about?: string; + isChannel?: boolean; + date: number; + chatId: string; +}; + +type StateProps = { + user?: ApiUser; + isSavedMessages?: boolean; + serverTimeOffset: number; +}; + +const JoinRequest: FC = ({ + userId, + about, + date, + isChannel, + user, + serverTimeOffset, + chatId, +}) => { + const { openUserInfo, hideChatJoinRequest } = getDispatch(); + + const buildClassName = createClassNameBuilder('JoinRequest'); + const lang = useLang(); + + const fullName = getUserFullName(user); + const fixedDate = (date - getServerTime(serverTimeOffset)) * 1000 + Date.now(); + + const dateString = isToday(new Date(fixedDate)) + ? formatTime(lang, fixedDate) : formatHumanDate(lang, fixedDate, true, false, true); + + const handleUserClick = () => { + openUserInfo({ userId }); + }; + + const handleAcceptRequest = useCallback(() => { + hideChatJoinRequest({ chatId, userId, isApproved: true }); + }, [chatId, hideChatJoinRequest, userId]); + + const handleRejectRequest = useCallback(() => { + hideChatJoinRequest({ chatId, userId, isApproved: false }); + }, [chatId, hideChatJoinRequest, userId]); + + return ( +
+
+
+ +
+
{fullName}
+
{about}
+
+
+
{dateString}
+
+
+ + +
+
+ ); +}; + +export default memo(withGlobal( + (global, { userId }): StateProps => { + const user = selectUser(global, userId); + + return { + user, + serverTimeOffset: global.serverTimeOffset, + }; + }, +)(JoinRequest)); diff --git a/src/components/right/management/ManageChannel.tsx b/src/components/right/management/ManageChannel.tsx index 6a3a23b0..818a95d6 100644 --- a/src/components/right/management/ManageChannel.tsx +++ b/src/components/right/management/ManageChannel.tsx @@ -67,6 +67,7 @@ const ManageChannel: FC = ({ deleteChannel, openChat, loadExportedChatInvites, + loadChatJoinRequests, } = getDispatch(); const currentTitle = chat ? (chat.title || '') : ''; @@ -88,8 +89,10 @@ const ManageChannel: FC = ({ useEffect(() => { if (lastSyncTime) { loadExportedChatInvites({ chatId }); + loadExportedChatInvites({ chatId, isRevoked: true }); + loadChatJoinRequests({ chatId }); } - }, [chatId, loadExportedChatInvites, lastSyncTime]); + }, [chatId, loadExportedChatInvites, lastSyncTime, loadChatJoinRequests]); useEffect(() => { if (progress === ManagementProgress.Complete) { @@ -116,9 +119,13 @@ const ManageChannel: FC = ({ onScreenSelect(ManagementScreens.ChatAdministrators); }, [onScreenSelect]); - const handleClickInvites = useCallback(() => { + const handleClickInvites = () => { onScreenSelect(ManagementScreens.Invites); - }, [onScreenSelect]); + }; + + const handleClickRequests = () => { + onScreenSelect(ManagementScreens.JoinRequests); + }; const handleSetPhoto = useCallback((file: File) => { setPhoto(file); @@ -241,6 +248,18 @@ const ManageChannel: FC = ({ )} + {Boolean(chat.joinRequests?.length) && ( + + {lang('SubscribeRequests')} + + {formatInteger(chat.joinRequests!.length)} + + + )} = ({ closeManagement, openChat, loadExportedChatInvites, + loadChatJoinRequests, } = getDispatch(); const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag(); @@ -99,8 +100,10 @@ const ManageGroup: FC = ({ useEffect(() => { if (lastSyncTime && canInvite) { loadExportedChatInvites({ chatId }); + loadExportedChatInvites({ chatId, isRevoked: true }); + loadChatJoinRequests({ chatId }); } - }, [chatId, loadExportedChatInvites, lastSyncTime, canInvite]); + }, [chatId, loadExportedChatInvites, lastSyncTime, canInvite, loadChatJoinRequests]); useEffect(() => { if (progress === ManagementProgress.Complete) { @@ -129,9 +132,13 @@ const ManageGroup: FC = ({ onScreenSelect(ManagementScreens.ChatAdministrators); }, [onScreenSelect]); - const handleClickInvites = useCallback(() => { + const handleClickInvites = () => { onScreenSelect(ManagementScreens.Invites); - }, [onScreenSelect]); + }; + + const handleClickRequests = () => { + onScreenSelect(ManagementScreens.JoinRequests); + }; const handleSetPhoto = useCallback((file: File) => { setPhoto(file); @@ -317,6 +324,18 @@ const ManageGroup: FC = ({ )} + {Boolean(chat.joinRequests?.length) && ( + + {lang('MemberRequests')} + + {formatInteger(chat.joinRequests!.length)} + + + )}
diff --git a/src/components/right/management/ManageInvite.tsx b/src/components/right/management/ManageInvite.tsx index 7ef33306..756e3553 100644 --- a/src/components/right/management/ManageInvite.tsx +++ b/src/components/right/management/ManageInvite.tsx @@ -1,6 +1,6 @@ import { ChangeEvent } from 'react'; import React, { - FC, memo, useCallback, useEffect, useState, + FC, memo, useCallback, useState, } from '../../../lib/teact/teact'; import { getDispatch, withGlobal } from '../../../lib/teact/teactn'; @@ -18,6 +18,7 @@ import InputText from '../../ui/InputText'; import RadioGroup from '../../ui/RadioGroup'; import Button from '../../ui/Button'; import FloatingActionButton from '../../ui/FloatingActionButton'; +import useOnChange from '../../../hooks/useOnChange'; import CalendarModal from '../../common/CalendarModal'; const DEFAULT_USAGE_LIMITS = [1, 10, 100]; @@ -61,7 +62,8 @@ const ManageInvite: FC = ({ useHistoryBack(isActive, onClose); - useEffect(() => { + useOnChange(([oldEditingInvite]) => { + if (oldEditingInvite === editingInvite) return; if (!editingInvite) { setTitle(''); setSelectedExpireOption('unlimited'); diff --git a/src/components/right/management/ManageInviteInfo.tsx b/src/components/right/management/ManageInviteInfo.tsx new file mode 100644 index 00000000..ba0d753a --- /dev/null +++ b/src/components/right/management/ManageInviteInfo.tsx @@ -0,0 +1,152 @@ +import React, { + FC, memo, useCallback, useEffect, +} from '../../../lib/teact/teact'; +import { getDispatch, withGlobal } from '../../../lib/teact/teactn'; + +import { ApiChatInviteImporter, ApiExportedInvite, ApiUser } from '../../../api/types'; + +import useHistoryBack from '../../../hooks/useHistoryBack'; +import useLang from '../../../hooks/useLang'; +import { copyTextToClipboard } from '../../../util/clipboard'; +import { getServerTime } from '../../../util/serverTime'; +import { formatFullDate, formatMediaDateTime, formatTime } from '../../../util/dateFormat'; + +import PrivateChatInfo from '../../common/PrivateChatInfo'; +import ListItem from '../../ui/ListItem'; +import Button from '../../ui/Button'; + +type OwnProps = { + chatId: string; + onClose: NoneToVoidFunction; + isActive: boolean; +}; + +type StateProps = { + invite?: ApiExportedInvite; + importers?: ApiChatInviteImporter[]; + admin?: ApiUser; + serverTimeOffset: number; +}; + +const ManageInviteInfo: FC = ({ + chatId, + invite, + importers, + isActive, + serverTimeOffset, + onClose, +}) => { + const { + showNotification, + loadChatInviteImporters, + openUserInfo, + } = getDispatch(); + + const lang = useLang(); + const { + usage = 0, usageLimit, link, adminId, + } = invite || {}; + const expireDate = invite?.expireDate && (invite.expireDate - getServerTime(serverTimeOffset)) * 1000 + Date.now(); + const isExpired = ((invite?.expireDate || 0) - getServerTime(serverTimeOffset)) < 0; + + useEffect(() => { + if (link) loadChatInviteImporters({ chatId, link }); + }, [chatId, link, loadChatInviteImporters]); + + const handleCopyClicked = useCallback(() => { + copyTextToClipboard(invite!.link); + showNotification({ + message: lang('LinkCopied'), + }); + }, [invite, lang, showNotification]); + + useHistoryBack(isActive, onClose); + + const renderImporters = () => { + if (invite?.isRevoked) return undefined; + if (!importers) return

{lang('Loading')}

; + return ( +
+

{importers.length ? lang('PeopleJoined', usage) : lang('NoOneJoined')}

+

+ {!importers.length && ( + usageLimit ? lang('PeopleCanJoinViaLinkCount', usageLimit - usage) : lang('NoOneJoinedYet') + )} + {importers.map((importer) => ( + openUserInfo({ id: importer.userId })} + > + + + ))} +

+
+ ); + }; + + return ( +
+
+ {!invite && ( +

{lang('Loading')}

+ )} + {invite && ( + <> +
+

{invite.title || invite.link}

+ + + {expireDate && ( +

+ {isExpired + ? lang('ExpiredLink') + : lang('LinkExpiresIn', `${formatFullDate(lang, expireDate)} ${formatTime(lang, expireDate)}`)} +

+ )} +
+ {adminId && ( +
+

{lang('LinkCreatedeBy')}

+ openUserInfo({ id: adminId })} + > + + +
+ )} + {renderImporters()} + + )} +
+
+ ); +}; + +export default memo(withGlobal( + (global, { chatId }): StateProps => { + const { inviteInfo } = global.management.byChatId[chatId]; + const invite = inviteInfo?.invite; + const importers = inviteInfo?.importers; + + return { + invite, + importers, + serverTimeOffset: global.serverTimeOffset, + }; + }, +)(ManageInviteInfo)); diff --git a/src/components/right/management/ManageInvites.tsx b/src/components/right/management/ManageInvites.tsx index deaed2b5..34377d69 100644 --- a/src/components/right/management/ManageInvites.tsx +++ b/src/components/right/management/ManageInvites.tsx @@ -1,5 +1,5 @@ import React, { - FC, memo, useCallback, useMemo, + FC, memo, useCallback, useMemo, useState, } from '../../../lib/teact/teact'; import { getDispatch, withGlobal } from '../../../lib/teact/teactn'; @@ -15,12 +15,14 @@ import { selectChat } from '../../../modules/selectors'; import { copyTextToClipboard } from '../../../util/clipboard'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../../util/environment'; import { getServerTime } from '../../../util/serverTime'; +import useFlag from '../../../hooks/useFlag'; import ListItem from '../../ui/ListItem'; import NothingFound from '../../common/NothingFound'; import Button from '../../ui/Button'; import DropdownMenu from '../../ui/DropdownMenu'; import MenuItem from '../../ui/MenuItem'; +import ConfirmDialog from '../../ui/ConfirmDialog'; type OwnProps = { chatId: string; @@ -32,6 +34,7 @@ type OwnProps = { type StateProps = { chat?: ApiChat; exportedInvites?: ApiExportedInvite[]; + revokedExportedInvites?: ApiExportedInvite[]; serverTimeOffset: number; }; @@ -49,12 +52,26 @@ const ManageInvites: FC = ({ chatId, chat, exportedInvites, + revokedExportedInvites, isActive, serverTimeOffset, onClose, onScreenSelect, }) => { - const { setEditingExportedInvite, showNotification, editExportedChatInvite } = getDispatch(); + const { + setEditingExportedInvite, + showNotification, + editExportedChatInvite, + deleteExportedChatInvite, + deleteRevokedExportedChatInvites, + setOpenedInviteInfo, + } = getDispatch(); + const [isDeleteRevokeAllDialogOpen, openDeleteRevokeAllDialog, closeDeleteRevokeAllDialog] = useFlag(); + const [isRevokeDialogOpen, openRevokeDialog, closeRevokeDialog] = useFlag(); + const [revokingInvite, setRevokingInvite] = useState(); + const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag(); + const [deletingInvite, setDeletingInvite] = useState(); + useHistoryBack(isActive, onClose); const lang = useLang(); @@ -74,8 +91,7 @@ const ManageInvites: FC = ({ const primaryInviteLink = chat?.username ? `t.me/${chat.username}` : primaryInvite?.link; const temporalInvites = useMemo(() => { const invites = chat?.username ? exportedInvites : exportedInvites?.filter(({ isPermanent }) => !isPermanent); - return invites?.filter(({ isRevoked }) => !isRevoked) - .sort(inviteComparator); + return invites?.sort(inviteComparator); }, [chat?.username, exportedInvites]); const editInvite = (invite: ApiExportedInvite) => { @@ -98,15 +114,53 @@ const ManageInvites: FC = ({ }); }, [chatId, editExportedChatInvite]); + const askToRevoke = useCallback((invite: ApiExportedInvite) => { + setRevokingInvite(invite); + openRevokeDialog(); + }, [openRevokeDialog]); + + const handleRevoke = useCallback(() => { + if (!revokingInvite) return; + revokeInvite(revokingInvite); + setRevokingInvite(undefined); + closeRevokeDialog(); + }, [closeRevokeDialog, revokeInvite, revokingInvite]); + const handleCreateNewClick = useCallback(() => { onScreenSelect(ManagementScreens.EditInvite); }, [onScreenSelect]); const handlePrimaryRevoke = useCallback(() => { if (primaryInvite) { - revokeInvite(primaryInvite); + askToRevoke(primaryInvite); } - }, [primaryInvite, revokeInvite]); + }, [askToRevoke, primaryInvite]); + + const handleDeleteAllRevoked = useCallback(() => { + deleteRevokedExportedChatInvites({ chatId }); + closeDeleteRevokeAllDialog(); + }, [chatId, closeDeleteRevokeAllDialog, deleteRevokedExportedChatInvites]); + + const showInviteInfo = useCallback((invite: ApiExportedInvite) => { + setOpenedInviteInfo({ chatId, invite }); + onScreenSelect(ManagementScreens.InviteInfo); + }, [chatId, onScreenSelect, setOpenedInviteInfo]); + + const deleteInvite = useCallback((invite: ApiExportedInvite) => { + deleteExportedChatInvite({ chatId, link: invite.link }); + }, [chatId, deleteExportedChatInvite]); + + const askToDelete = useCallback((invite: ApiExportedInvite) => { + setDeletingInvite(invite); + openDeleteDialog(); + }, [openDeleteDialog]); + + const handleDelete = useCallback(() => { + if (!deletingInvite) return; + deleteInvite(deletingInvite); + setDeletingInvite(undefined); + closeDeleteDialog(); + }, [closeDeleteDialog, deleteInvite, deletingInvite]); const copyLink = useCallback((link: string) => { copyTextToClipboard(link); @@ -121,10 +175,10 @@ const ManageInvites: FC = ({ const prepareUsageText = (invite: ApiExportedInvite) => { const { - usage = 0, usageLimit, expireDate, isPermanent, requested, + usage = 0, usageLimit, expireDate, isPermanent, requested, isRevoked, } = invite; let text = ''; - if (usageLimit && usage < usageLimit) { + if (!isRevoked && usageLimit && usage < usageLimit) { text = lang('CanJoin', usageLimit - usage); } else if (usage) { text = lang('PeopleJoined', usage); @@ -132,6 +186,11 @@ const ManageInvites: FC = ({ text = lang('NoOneJoined'); } + if (isRevoked) { + text += ` ${BULLET} ${lang('Revoked')}`; + return text; + } + if (requested) { text += ` ${BULLET} ${lang('JoinRequests', requested)}`; } @@ -160,19 +219,30 @@ const ManageInvites: FC = ({ icon: 'copy', handler: () => copyLink(invite.link), }); - if (!invite.isPermanent) { + + if (!invite.isPermanent && !invite.isRevoked) { actions.push({ title: lang('Edit'), - icon: lang('edit'), + icon: 'edit', handler: () => editInvite(invite), }); } - actions.push({ - title: lang('RevokeButton'), - icon: lang('delete'), - handler: () => revokeInvite(invite), - destructive: true, - }); + + if (!invite.isRevoked) { + actions.push({ + title: lang('RevokeButton'), + icon: 'delete', + handler: () => askToRevoke(invite), + destructive: true, + }); + } else { + actions.push({ + title: lang('DeleteLink'), + icon: 'delete', + handler: () => askToDelete(invite), + destructive: true, + }); + } return actions; }; @@ -225,13 +295,13 @@ const ManageInvites: FC = ({ - {!temporalInvites && } + {(!temporalInvites || !temporalInvites.length) && } {temporalInvites?.map((invite) => ( copyLink(invite.link)} + onClick={() => showInviteInfo(invite)} contextActions={prepareContextActions(invite)} key={invite.link} > @@ -243,18 +313,68 @@ const ManageInvites: FC = ({ ))}

{lang('ManageLinksInfoHelp')}

+ {revokedExportedInvites && Boolean(revokedExportedInvites.length) && ( +
+

{lang('RevokedLinks')}

+ + {lang('DeleteAllRevokedLinks')} + + {revokedExportedInvites?.map((invite) => ( + showInviteInfo(invite)} + contextActions={prepareContextActions(invite)} + key={invite.link} + > + {invite.title || invite.link} + + {prepareUsageText(invite)} + + + ))} +
+ )} + + + ); }; export default memo(withGlobal( (global, { chatId }): StateProps => { - const { invites } = global.management.byChatId[chatId]; + const { invites, revokedInvites } = global.management.byChatId[chatId]; const chat = selectChat(global, chatId); return { exportedInvites: invites, + revokedExportedInvites: revokedInvites, chat, serverTimeOffset: global.serverTimeOffset, }; diff --git a/src/components/right/management/ManageJoinRequests.tsx b/src/components/right/management/ManageJoinRequests.tsx new file mode 100644 index 00000000..ac3f1ea9 --- /dev/null +++ b/src/components/right/management/ManageJoinRequests.tsx @@ -0,0 +1,119 @@ +import React, { + FC, memo, useCallback, useEffect, +} from '../../../lib/teact/teact'; +import { getDispatch, withGlobal } from '../../../lib/teact/teactn'; + +import { ApiChat } from '../../../api/types'; + +import useHistoryBack from '../../../hooks/useHistoryBack'; +import { selectChat } from '../../../modules/selectors'; +import { isChatChannel } from '../../../modules/helpers'; +import useLang from '../../../hooks/useLang'; +import useFlag from '../../../hooks/useFlag'; + +import JoinRequest from './JoinRequest'; +import Button from '../../ui/Button'; +import ConfirmDialog from '../../ui/ConfirmDialog'; + +type OwnProps = { + chatId: string; + onClose: NoneToVoidFunction; + isActive: boolean; +}; + +type StateProps = { + chat?: ApiChat; + isChannel?: boolean; + serverTimeOffset: number; +}; + +const ManageJoinRequests: FC = ({ + chat, + chatId, + isActive, + isChannel, + onClose, +}) => { + const { hideAllChatJoinRequests, loadChatJoinRequests } = getDispatch(); + const [isAcceptAllDialogOpen, openAcceptAllDialog, closeAcceptAllDialog] = useFlag(); + const [isRejectAllDialogOpen, openRejectAllDialog, closeRejectAllDialog] = useFlag(); + + const lang = useLang(); + + useHistoryBack(isActive, onClose); + + useEffect(() => { + if (!chat?.joinRequests) { + loadChatJoinRequests({ chatId }); + } + }, [chat, chatId, loadChatJoinRequests]); + + const handleAcceptAllRequests = useCallback(() => { + hideAllChatJoinRequests({ chatId, isApproved: true }); + closeAcceptAllDialog(); + }, [hideAllChatJoinRequests, chatId, closeAcceptAllDialog]); + + const handleRejectAllRequests = useCallback(() => { + hideAllChatJoinRequests({ chatId, isApproved: false }); + closeRejectAllDialog(); + }, [hideAllChatJoinRequests, chatId, closeRejectAllDialog]); + + return ( +
+ {Boolean(chat?.joinRequests?.length) && ( +
+ + +
+ )} +
+
+

+ {chat?.joinRequests?.length ? lang('JoinRequests', chat?.joinRequests?.length) : lang('NoMemberRequests')} +

+ {chat?.joinRequests?.length === 0 && ( +

+ {isChannel ? lang('NoSubscribeRequestsDescription') : lang('NoMemberRequestsDescription')} +

+ )} + {chat?.joinRequests?.map(({ userId, about, date }) => ( + + ))} +
+
+ + +
+ ); +}; + +export default memo(withGlobal( + (global, { chatId }): StateProps => { + const chat = selectChat(global, chatId); + + return { + chat, + serverTimeOffset: global.serverTimeOffset, + isChannel: chat && isChatChannel(chat), + }; + }, +)(ManageJoinRequests)); diff --git a/src/components/right/management/Management.scss b/src/components/right/management/Management.scss index ae462198..7478fd70 100644 --- a/src/components/right/management/Management.scss +++ b/src/components/right/management/Management.scss @@ -207,6 +207,25 @@ } } +.ManageInviteInfo { + .copy-link { + margin-top: 1rem; + margin-bottom: 1rem; + } +} + +.ManageJoinRequests { + .bulk-actions { + display: flex; + justify-content: space-around; + } + + .bulk-action-button { + width: auto; + height: auto; + } +} + .ManageInvite, .ManageInvites { .hint { font-size: 0.875rem; diff --git a/src/components/right/management/Management.tsx b/src/components/right/management/Management.tsx index 7174c941..5a4a4933 100644 --- a/src/components/right/management/Management.tsx +++ b/src/components/right/management/Management.tsx @@ -21,6 +21,8 @@ import ManageGroupUserPermissionsCreate from './ManageGroupUserPermissionsCreate import ManageInvites from './ManageInvites'; import ManageInvite from './ManageInvite'; import ManageReactions from './ManageReactions'; +import ManageInviteInfo from './ManageInviteInfo'; +import ManageJoinRequests from './ManageJoinRequests'; export type OwnProps = { chatId: string; @@ -268,6 +270,22 @@ const Management: FC = ({ onClose={onClose} /> ); + case ManagementScreens.InviteInfo: + return ( + + ); + case ManagementScreens.JoinRequests: + return ( + + ); } return undefined; // Never reached diff --git a/src/global/types.ts b/src/global/types.ts index 70977823..c4aafa0e 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -556,6 +556,8 @@ export type ActionTypes = ( // management 'toggleManagement' | 'closeManagement' | 'checkPublicLink' | 'updatePublicLink' | 'updatePrivateLink' | 'setEditingExportedInvite' | 'loadExportedChatInvites' | 'editExportedChatInvite' | 'exportChatInvite' | + 'deleteExportedChatInvite' | 'deleteRevokedExportedChatInvites' | 'setOpenedInviteInfo' | 'loadChatInviteImporters' | + 'loadChatJoinRequests' | 'hideChatJoinRequest' | 'hideAllChatJoinRequests' | 'requestNextManagementScreen' | // groups 'togglePreHistoryHidden' | 'updateChatDefaultBannedRights' | 'updateChatMemberBannedRights' | 'updateChatAdmin' | 'acceptInviteConfirmation' | diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 43a9c5dc..045e6ffb 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1084,8 +1084,12 @@ messages.unpinAllMessages#f025bc8b peer:InputPeer = messages.AffectedHistory; messages.deleteChat#5bd0ee50 chat_id:long = Bool; messages.getExportedChatInvites#a2b5a3f6 flags:# revoked:flags.3?true peer:InputPeer admin_id:InputUser offset_date:flags.2?int offset_link:flags.2?string limit:int = messages.ExportedChatInvites; messages.editExportedChatInvite#bdca2f75 flags:# revoked:flags.2?true peer:InputPeer link:string expire_date:flags.0?int usage_limit:flags.1?int request_needed:flags.3?Bool title:flags.4?string = messages.ExportedChatInvite; +messages.deleteRevokedExportedChatInvites#56987bd5 peer:InputPeer admin_id:InputUser = Bool; messages.deleteExportedChatInvite#d464a42b peer:InputPeer link:string = Bool; +messages.getChatInviteImporters#df04dd4e flags:# requested:flags.0?true peer:InputPeer link:flags.1?string q:flags.2?string offset_date:int offset_user:InputUser limit:int = messages.ChatInviteImporters; messages.getMessageReadParticipants#2c6f97b7 peer:InputPeer msg_id:int = Vector; +messages.hideChatJoinRequest#7fe7e815 flags:# approved:flags.0?true peer:InputPeer user_id:InputUser = Updates; +messages.hideAllChatJoinRequests#e085f4ea flags:# approved:flags.0?true peer:InputPeer link:flags.1?string = Updates; messages.toggleNoForwards#b11eafa2 peer:InputPeer enabled:Bool = Updates; messages.saveDefaultSendAs#ccfddf96 peer:InputPeer send_as:InputPeer = Bool; messages.sendReaction#25690ce4 flags:# peer:InputPeer msg_id:int reaction:flags.0?string = Updates; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 7df9ca9e..915097b4 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -77,7 +77,9 @@ "messages.createChat", "messages.getExportedChatInvites", "messages.editExportedChatInvite", + "messages.deleteRevokedExportedChatInvites", "messages.deleteExportedChatInvite", + "messages.getChatInviteImporters", "messages.getDhConfig", "messages.readMessageContents", "messages.getStickers", @@ -132,6 +134,8 @@ "messages.unpinAllMessages", "messages.deleteChat", "messages.getMessageReadParticipants", + "messages.hideChatJoinRequest", + "messages.hideAllChatJoinRequests", "messages.toggleNoForwards", "messages.saveDefaultSendAs", "updates.getState", diff --git a/src/modules/actions/api/chats.ts b/src/modules/actions/api/chats.ts index 29ef4417..6910bb09 100644 --- a/src/modules/actions/api/chats.ts +++ b/src/modules/actions/api/chats.ts @@ -30,7 +30,6 @@ import { updateChatListSecondaryInfo, updateManagementProgress, leaveChat, - updateManagement, } from '../../reducers'; import { selectChat, @@ -611,82 +610,6 @@ addReducer('acceptInviteConfirmation', (global, actions, payload) => { })(); }); -addReducer('loadExportedChatInvites', (global, actions, payload) => { - const { - chatId, adminId, isRevoked, limit, - } = payload!; - const peer = selectChat(global, chatId); - const admin = selectUser(global, adminId || global.currentUserId); - if (!peer || !admin) return; - - (async () => { - const result = await callApi('fetchExportedChatInvites', { - peer, admin, isRevoked, limit, - }); - if (!result) { - return; - } - - setGlobal(updateManagement(getGlobal(), chatId, { invites: result })); - })(); -}); - -addReducer('editExportedChatInvite', (global, actions, payload) => { - const { - chatId, link, isRevoked, expireDate, usageLimit, isRequestNeeded, title, - } = payload!; - const peer = selectChat(global, chatId); - if (!peer) return; - - (async () => { - const result = await callApi('editExportedChatInvite', { - peer, - link, - isRevoked, - expireDate, - usageLimit, - isRequestNeeded, - title, - }); - if (!result) { - return; - } - global = getGlobal(); - let invites = global.management.byChatId[chatId].invites || []; - const { oldInvite, newInvite } = result; - invites = invites.filter((current) => current.link !== oldInvite.link); - setGlobal(updateManagement(global, chatId, { - invites: [...invites, newInvite], - })); - })(); -}); - -addReducer('exportChatInvite', (global, actions, payload) => { - const { - chatId, expireDate, usageLimit, isRequestNeeded, title, - } = payload!; - const peer = selectChat(global, chatId); - if (!peer) return; - - (async () => { - const result = await callApi('exportChatInvite', { - peer, - expireDate, - usageLimit, - isRequestNeeded, - title, - }); - if (!result) { - return; - } - global = getGlobal(); - const invites = global.management.byChatId[chatId].invites || []; - setGlobal(updateManagement(global, chatId, { - invites: [...invites, result], - })); - })(); -}); - addReducer('openChatByUsername', (global, actions, payload) => { const { username, messageId, commentId, startParam, diff --git a/src/modules/actions/api/management.ts b/src/modules/actions/api/management.ts index 96c16868..2ac97628 100644 --- a/src/modules/actions/api/management.ts +++ b/src/modules/actions/api/management.ts @@ -2,8 +2,8 @@ import { addReducer, getGlobal, setGlobal } from '../../../lib/teact/teactn'; import { ManagementProgress } from '../../../types'; import { callApi } from '../../../api/gramjs'; -import { updateManagement, updateManagementProgress } from '../../reducers'; -import { selectChat, selectCurrentMessageList } from '../../selectors'; +import { updateChat, updateManagement, updateManagementProgress } from '../../reducers'; +import { selectChat, selectCurrentMessageList, selectUser } from '../../selectors'; import { isChatBasicGroup } from '../../helpers'; addReducer('checkPublicLink', (global, actions, payload) => { @@ -82,3 +82,252 @@ addReducer('setEditingExportedInvite', (global, actions, payload) => { setGlobal(updateManagement(global, chatId, { editingInvite: invite })); }); + +addReducer('setOpenedInviteInfo', (global, actions, payload) => { + const { chatId, invite } = payload; + + const update = invite ? { inviteInfo: { invite } } : { inviteInfo: undefined }; + + setGlobal(updateManagement(global, chatId, update)); +}); + +addReducer('loadExportedChatInvites', (global, actions, payload) => { + const { + chatId, adminId, isRevoked, limit, + } = payload!; + const peer = selectChat(global, chatId); + const admin = selectUser(global, adminId || global.currentUserId); + if (!peer || !admin) return; + + (async () => { + const result = await callApi('fetchExportedChatInvites', { + peer, admin, isRevoked, limit, + }); + if (!result) { + return; + } + const update = isRevoked ? { revokedInvites: result } : { invites: result }; + + setGlobal(updateManagement(getGlobal(), chatId, update)); + })(); +}); + +addReducer('editExportedChatInvite', (global, actions, payload) => { + const { + chatId, link, isRevoked, expireDate, usageLimit, isRequestNeeded, title, + } = payload!; + const peer = selectChat(global, chatId); + if (!peer) return; + + (async () => { + const result = await callApi('editExportedChatInvite', { + peer, + link, + isRevoked, + expireDate, + usageLimit, + isRequestNeeded, + title, + }); + if (!result) { + return; + } + global = getGlobal(); + let invites = global.management.byChatId[chatId].invites || []; + const revokedInvites = global.management.byChatId[chatId].revokedInvites || []; + const { oldInvite, newInvite } = result; + invites = invites.filter((current) => current.link !== oldInvite.link); + if (newInvite.isRevoked) { + revokedInvites.unshift(newInvite); + } else { + invites.push(newInvite); + } + setGlobal(updateManagement(global, chatId, { + invites, + revokedInvites, + })); + })(); +}); + +addReducer('exportChatInvite', (global, actions, payload) => { + const { + chatId, expireDate, usageLimit, isRequestNeeded, title, + } = payload!; + const peer = selectChat(global, chatId); + if (!peer) return; + + (async () => { + const result = await callApi('exportChatInvite', { + peer, + expireDate, + usageLimit, + isRequestNeeded, + title, + }); + if (!result) { + return; + } + global = getGlobal(); + const invites = global.management.byChatId[chatId].invites || []; + setGlobal(updateManagement(global, chatId, { + invites: [...invites, result], + })); + })(); +}); + +addReducer('deleteExportedChatInvite', (global, actions, payload) => { + const { + chatId, link, + } = payload!; + const peer = selectChat(global, chatId); + if (!peer) return; + + (async () => { + const result = await callApi('deleteExportedChatInvite', { + peer, + link, + }); + if (!result) { + return; + } + global = getGlobal(); + const managementState = global.management.byChatId[chatId]; + setGlobal(updateManagement(global, chatId, { + invites: managementState?.invites?.filter((invite) => invite.link !== link), + revokedInvites: managementState?.revokedInvites?.filter((invite) => invite.link !== link), + })); + })(); +}); + +addReducer('deleteRevokedExportedChatInvites', (global, actions, payload) => { + const { + chatId, adminId, + } = payload!; + const peer = selectChat(global, chatId); + const admin = selectUser(global, adminId || global.currentUserId); + if (!peer || !admin) return; + + (async () => { + const result = await callApi('deleteRevokedExportedChatInvites', { + peer, + admin, + }); + if (!result) { + return; + } + global = getGlobal(); + setGlobal(updateManagement(global, chatId, { + revokedInvites: [], + })); + })(); +}); + +addReducer('loadChatInviteImporters', (global, actions, payload) => { + const { + chatId, link, offsetDate, offsetUserId, limit, + } = payload!; + const peer = selectChat(global, chatId); + const offsetUser = selectUser(global, offsetUserId); + if (!peer || (offsetUserId && !offsetUser)) return; + + (async () => { + const result = await callApi('fetchChatInviteImporters', { + peer, + link, + offsetDate, + offsetUser, + limit, + }); + if (!result) { + return; + } + global = getGlobal(); + const currentInviteInfo = global.management.byChatId[chatId]?.inviteInfo; + if (!currentInviteInfo?.invite) return; + setGlobal(updateManagement(global, chatId, { + inviteInfo: { + ...currentInviteInfo, + importers: result, + }, + })); + })(); +}); + +addReducer('loadChatJoinRequests', (global, actions, payload) => { + const { + chatId, offsetDate, offsetUserId, limit, + } = payload!; + const peer = selectChat(global, chatId); + const offsetUser = selectUser(global, offsetUserId); + if (!peer || (offsetUserId && !offsetUser)) return; + + (async () => { + const result = await callApi('fetchChatInviteImporters', { + peer, + offsetDate, + offsetUser, + limit, + isRequested: true, + }); + if (!result) { + return; + } + global = getGlobal(); + setGlobal(updateChat(global, chatId, { joinRequests: result })); + })(); +}); + +addReducer('hideChatJoinRequest', (global, actions, payload) => { + const { + chatId, userId, isApproved, + } = payload!; + const peer = selectChat(global, chatId); + const user = selectUser(global, userId); + if (!peer || !user) return; + + (async () => { + const result = await callApi('hideChatJoinRequest', { + peer, + user, + isApproved, + }); + + if (!result) return; + global = getGlobal(); + const targetChat = selectChat(global, chatId); + if (!targetChat) return; + setGlobal(updateChat(global, chatId, { + joinRequests: targetChat.joinRequests?.filter((importer) => importer.userId !== userId), + })); + })(); +}); + +addReducer('hideAllChatJoinRequests', (global, actions, payload) => { + const { + chatId, isApproved, link, + } = payload!; + const peer = selectChat(global, chatId); + if (!peer) return; + + (async () => { + const result = await callApi('hideAllChatJoinRequests', { + peer, + isApproved, + link, + }); + + if (!result) return; + global = getGlobal(); + const targetChat = selectChat(global, chatId); + if (!targetChat) return; + + setGlobal(updateChat(global, chatId, { + joinRequests: [], + fullInfo: { + ...targetChat.fullInfo, + recentRequesterIds: [], + requestsPending: 0, + }, + })); + })(); +}); diff --git a/src/modules/actions/apiUpdaters/chats.ts b/src/modules/actions/apiUpdaters/chats.ts index ea9b3fe9..0558e118 100644 --- a/src/modules/actions/apiUpdaters/chats.ts +++ b/src/modules/actions/apiUpdaters/chats.ts @@ -382,5 +382,21 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { actions.showDialog({ data }); break; } + + case 'updatePendingJoinRequests': { + const { chatId, requestsPending, recentRequesterIds } = update; + const chat = global.chats.byId[chatId]; + if (chat) { + global = updateChat(global, chatId, { + fullInfo: { + ...chat.fullInfo, + requestsPending, + recentRequesterIds, + }, + }); + setGlobal(global); + actions.loadChatJoinRequests({ chatId }); + } + } } }); diff --git a/src/modules/actions/ui/misc.ts b/src/modules/actions/ui/misc.ts index 02271135..6183bf8d 100644 --- a/src/modules/actions/ui/misc.ts +++ b/src/modules/actions/ui/misc.ts @@ -54,6 +54,29 @@ addReducer('toggleManagement', (global): GlobalState | undefined => { }; }); +addReducer('requestNextManagementScreen', (global, actions, payload): GlobalState | undefined => { + const { screen } = payload || {}; + const { chatId } = selectCurrentMessageList(global) || {}; + + if (!chatId) { + return undefined; + } + + return { + ...global, + management: { + byChatId: { + ...global.management.byChatId, + [chatId]: { + ...global.management.byChatId[chatId], + isActive: true, + nextScreen: screen, + }, + }, + }, + }; +}); + addReducer('closeManagement', (global): GlobalState | undefined => { const { chatId } = selectCurrentMessageList(global) || {}; diff --git a/src/types/index.ts b/src/types/index.ts index 1e94d8aa..540a4d35 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,6 @@ import { ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm, + ApiChatInviteImporter, ApiExportedInvite, ApiLanguage, ApiMessage, ApiShippingAddress, ApiStickerSet, } from '../api/types'; @@ -283,10 +284,16 @@ export enum ManagementProgress { export interface ManagementState { isActive: boolean; + nextScreen?: ManagementScreens; isUsernameAvailable?: boolean; error?: string; invites?: ApiExportedInvite[]; + revokedInvites?: ApiExportedInvite[]; editingInvite?: ApiExportedInvite; + inviteInfo?: { + invite: ApiExportedInvite; + importers?: ApiChatInviteImporter[]; + }; } export enum NewChatMembersProgress { @@ -334,6 +341,8 @@ export enum ManagementScreens { Invites, EditInvite, Reactions, + InviteInfo, + JoinRequests, } export type ManagementType = 'user' | 'group' | 'channel'; diff --git a/src/util/dateFormat.ts b/src/util/dateFormat.ts index 8f8afa0d..2d93b6ac 100644 --- a/src/util/dateFormat.ts +++ b/src/util/dateFormat.ts @@ -12,6 +12,10 @@ const MAX_DAY_IN_MONTH = 31; const MAX_MONTH_IN_YEAR = 12; export const MILLISECONDS_IN_DAY = 24 * 60 * 60 * 1000; +export function isToday(date: Date) { + return getDayStart(new Date()) === getDayStart(date); +} + export function getDayStart(datetime: number | Date) { const date = new Date(datetime); date.setHours(0, 0, 0, 0);