Management: Manage Join Requests and revoked invites (#1667)

This commit is contained in:
Alexander Zinchuk 2022-01-28 02:12:00 +01:00
parent 871d83b951
commit 97891c02a5
31 changed files with 1313 additions and 194 deletions

View File

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

View File

@ -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<ApiUser>(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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<OwnProps & StateProps> = ({
canLeave,
canEnterVoiceChat,
canCreateVoiceChat,
pendingJoinRequests,
isRightColumnShown,
canExpandActions,
}) => {
@ -81,6 +83,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
openLocalTextSearch,
restartBot,
openCallFallbackConfirm,
requestNextManagementScreen,
} = getDispatch();
// eslint-disable-next-line no-null/no-null
@ -114,6 +117,10 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
restartBot({ chatId });
}, [chatId, restartBot]);
const handleJoinRequestsClick = useCallback(() => {
requestNextManagementScreen({ screen: ManagementScreens.JoinRequests });
}, [requestNextManagementScreen]);
const handleSearchClick = useCallback(() => {
openLocalTextSearch();
@ -213,6 +220,20 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
)}
</>
)}
{Boolean(pendingJoinRequests) && (
<Button
round
className="badge-button"
ripple={isRightColumnShown}
color="translucent"
size="smaller"
onClick={handleJoinRequestsClick}
ariaLabel={isChannel ? lang('SubscribeRequests') : lang('MemberRequests')}
>
<i className="icon-user" />
<div className="badge">{pendingJoinRequests}</div>
</Button>
)}
<Button
ref={menuButtonRef}
className={isMenuOpen ? 'active' : ''}
@ -282,6 +303,7 @@ export default memo(withGlobal<OwnProps>(
const canEnterVoiceChat = ARE_CALLS_SUPPORTED && chat.isCallActive;
const canCreateVoiceChat = ARE_CALLS_SUPPORTED && !chat.isCallActive
&& (chat.adminRights?.manageCall || (chat.isCreator && isChatBasicGroup(chat)));
const pendingJoinRequests = chat.fullInfo?.requestsPending;
return {
noMenu: false,
@ -296,6 +318,7 @@ export default memo(withGlobal<OwnProps>(
canLeave,
canEnterVoiceChat,
canCreateVoiceChat,
pendingJoinRequests,
};
},
)(HeaderActions));

View File

@ -540,6 +540,27 @@
opacity: 1;
}
}
.badge-button {
position: relative;
overflow: visible;
}
.badge {
position: absolute;
min-width: 1rem;
height: 1rem;
background: var(--color-primary);
border-radius: 0.5rem;
color: white;
font-size: 0.75rem;
line-height: 1rem;
font-weight: 500;
text-align: center;
flex-shrink: 0;
right: 0;
top: 0;
}
}
.HeaderPinnedMessage-wrapper, .HeaderActions {

View File

@ -38,6 +38,7 @@ type StateProps = {
currentProfileUserId?: string;
isChatSelected: boolean;
shouldSkipHistoryAnimations?: boolean;
nextManagementScreen?: ManagementScreens;
};
const COLUMN_CLOSE_DELAY_MS = 300;
@ -58,6 +59,7 @@ const RightColumn: FC<StateProps> = ({
currentProfileUserId,
isChatSelected,
shouldSkipHistoryAnimations,
nextManagementScreen,
}) => {
const {
toggleChatInfo,
@ -70,6 +72,8 @@ const RightColumn: FC<StateProps> = ({
addChatMembers,
setNewChatMembersDialogState,
setEditingExportedInvite,
setOpenedInviteInfo,
requestNextManagementScreen,
} = getDispatch();
const { width: windowWidth } = useWindowSize();
@ -126,6 +130,7 @@ const RightColumn: FC<StateProps> = ({
case ManagementScreens.GroupMembers:
case ManagementScreens.Invites:
case ManagementScreens.Reactions:
case ManagementScreens.JoinRequests:
setManagementScreen(ManagementScreens.Initial);
break;
case ManagementScreens.GroupUserPermissionsCreate:
@ -142,7 +147,9 @@ const RightColumn: FC<StateProps> = ({
setManagementScreen(ManagementScreens.ChatAdministrators);
break;
case ManagementScreens.EditInvite:
case ManagementScreens.InviteInfo:
setManagementScreen(ManagementScreens.Invites);
setOpenedInviteInfo({ invite: undefined });
setEditingExportedInvite({ chatId, invite: undefined });
break;
}
@ -170,7 +177,7 @@ const RightColumn: FC<StateProps> = ({
}, [
contentKey, isScrolledDown, toggleChatInfo, openUserInfo, closePollResults, setNewChatMembersDialogState,
managementScreen, toggleManagement, closeLocalTextSearch, setStickerSearchQuery, setGifSearchQuery,
setEditingExportedInvite, chatId,
setEditingExportedInvite, chatId, setOpenedInviteInfo,
]);
const handleSelectChatMember = useCallback((memberId, isPromoted) => {
@ -190,6 +197,13 @@ const RightColumn: FC<StateProps> = ({
}, COLUMN_CLOSE_DELAY_MS);
}, [isOpen]);
useEffect(() => {
if (nextManagementScreen) {
setManagementScreen(nextManagementScreen);
requestNextManagementScreen(undefined);
}
}, [nextManagementScreen, requestNextManagementScreen]);
// Close Right Column when it transforms into overlayed state on screen resize
useEffect(() => {
if (isOpen && isOverlaying) {
@ -290,6 +304,7 @@ const RightColumn: FC<StateProps> = ({
managementScreen={managementScreen}
onClose={close}
shouldSkipAnimation={shouldSkipTransition || shouldSkipHistoryAnimations}
onScreenSelect={setManagementScreen}
/>
<Transition
name={(shouldSkipTransition || shouldSkipHistoryAnimations) ? 'none' : 'zoom-fade'}
@ -309,6 +324,7 @@ export default memo(withGlobal(
(global): StateProps => {
const { chatId, threadId } = selectCurrentMessageList(global) || {};
const areActiveChatsLoaded = selectAreActiveChatsLoaded(global);
const nextManagementScreen = chatId ? global.management.byChatId[chatId]?.nextScreen : undefined;
return {
contentKey: selectRightColumnContentKey(global),
@ -317,6 +333,7 @@ export default memo(withGlobal(
currentProfileUserId: global.users.selectedId,
isChatSelected: Boolean(chatId && areActiveChatsLoaded),
shouldSkipHistoryAnimations: global.shouldSkipHistoryAnimations,
nextManagementScreen,
};
},
)(RightColumn));

View File

@ -4,6 +4,7 @@ import React, {
import { getDispatch, withGlobal } from '../../lib/teact/teactn';
import { ManagementScreens, ProfileState } from '../../types';
import { ApiExportedInvite } from '../../api/types';
import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment';
import { debounce } from '../../util/schedulers';
@ -42,6 +43,7 @@ type OwnProps = {
profileState?: ProfileState;
managementScreen?: ManagementScreens;
onClose: () => void;
onScreenSelect: (screen: ManagementScreens) => void;
};
type StateProps = {
@ -53,6 +55,7 @@ type StateProps = {
stickerSearchQuery?: string;
gifSearchQuery?: string;
isEditingInvite?: boolean;
currentInviteInfo?: ApiExportedInvite;
};
const COLUMN_CLOSE_DELAY_MS = 300;
@ -85,9 +88,12 @@ enum HeaderContent {
ManageInvites,
ManageEditInvite,
ManageReactions,
ManageInviteInfo,
ManageJoinRequests,
}
const RightHeader: FC<OwnProps & StateProps> = ({
chatId,
isColumnOpen,
isProfile,
isSearch,
@ -103,11 +109,13 @@ const RightHeader: FC<OwnProps & StateProps> = ({
canManage,
isChannel,
onClose,
onScreenSelect,
messageSearchQuery,
stickerSearchQuery,
gifSearchQuery,
shouldSkipAnimation,
isEditingInvite,
currentInviteInfo,
}) => {
const {
setLocalTextSearchQuery,
@ -115,12 +123,25 @@ const RightHeader: FC<OwnProps & StateProps> = ({
setGifSearchQuery,
searchTextMessagesLocal,
toggleManagement,
openHistoryCalendar, addContact,
openHistoryCalendar,
addContact,
setEditingExportedInvite,
deleteExportedChatInvite,
} = getDispatch();
// eslint-disable-next-line no-null/no-null
const backButtonRef = useRef<HTMLDivElement>(null);
const handleEditInviteClick = useCallback(() => {
setEditingExportedInvite({ chatId: chatId!, invite: currentInviteInfo! });
onScreenSelect(ManagementScreens.EditInvite);
}, [chatId, currentInviteInfo, onScreenSelect, setEditingExportedInvite]);
const handleDeleteInviteClick = useCallback(() => {
deleteExportedChatInvite({ chatId: chatId!, link: currentInviteInfo!.link });
onScreenSelect(ManagementScreens.Invites);
}, [chatId, currentInviteInfo, deleteExportedChatInvite, onScreenSelect]);
const handleMessageSearchQueryChange = useCallback((query: string) => {
setLocalTextSearchQuery({ query });
@ -203,6 +224,10 @@ const RightHeader: FC<OwnProps & StateProps> = ({
HeaderContent.ManageGroupAddAdmins
) : managementScreen === ManagementScreens.Reactions ? (
HeaderContent.ManageReactions
) : managementScreen === ManagementScreens.InviteInfo ? (
HeaderContent.ManageInviteInfo
) : managementScreen === ManagementScreens.JoinRequests ? (
HeaderContent.ManageJoinRequests
) : undefined // Never reached
) : undefined; // When column is closed
@ -263,6 +288,38 @@ const RightHeader: FC<OwnProps & StateProps> = ({
return <h3>{lang('lng_group_invite_title')}</h3>;
case HeaderContent.ManageEditInvite:
return <h3>{isEditingInvite ? lang('EditLink') : lang('NewLink')}</h3>;
case HeaderContent.ManageInviteInfo:
return (
<>
<h3>{lang('InviteLink')}</h3>
<section className="tools">
{currentInviteInfo && !currentInviteInfo.isRevoked && (
<Button
round
color="translucent"
size="smaller"
ariaLabel={lang('Edit')}
onClick={handleEditInviteClick}
>
<i className="icon-edit" />
</Button>
)}
{currentInviteInfo && currentInviteInfo.isRevoked && (
<Button
round
color="danger"
size="smaller"
ariaLabel={lang('Delete')}
onClick={handleDeleteInviteClick}
>
<i className="icon-delete" />
</Button>
)}
</section>
</>
);
case HeaderContent.ManageJoinRequests:
return <h3>{isChannel ? lang('SubscribeRequests') : lang('MemberRequests')}</h3>;
case HeaderContent.ManageGroupAddAdmins:
return <h3>{lang('Channel.Management.AddModerator')}</h3>;
case HeaderContent.StickerSearch:
@ -381,6 +438,7 @@ export default memo(withGlobal<OwnProps>(
&& (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<OwnProps>(
stickerSearchQuery,
gifSearchQuery,
isEditingInvite,
currentInviteInfo,
};
},
)(RightHeader));

View File

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

View File

@ -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<OwnProps & StateProps> = ({
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 (
<div className={buildClassName('&')}>
<div className={buildClassName('top')}>
<div className={buildClassName('user')} onClick={handleUserClick}>
<Avatar
key={userId}
size="medium"
user={user}
/>
<div className={buildClassName('user-info')}>
<div className={buildClassName('user-name')}>{fullName}</div>
<div className={buildClassName('user-subtitle')}>{about}</div>
</div>
</div>
<div className={buildClassName('date')}>{dateString}</div>
</div>
<div className={buildClassName('buttons')}>
<Button className={buildClassName('button')} onClick={handleAcceptRequest}>
{isChannel ? lang('ChannelAddToChannel') : lang('ChannelAddToGroup')}
</Button>
<Button className={buildClassName('button')} isText onClick={handleRejectRequest}>
{lang('DismissRequest')}
</Button>
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { userId }): StateProps => {
const user = selectUser(global, userId);
return {
user,
serverTimeOffset: global.serverTimeOffset,
};
},
)(JoinRequest));

View File

@ -67,6 +67,7 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
deleteChannel,
openChat,
loadExportedChatInvites,
loadChatJoinRequests,
} = getDispatch();
const currentTitle = chat ? (chat.title || '') : '';
@ -88,8 +89,10 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
</span>
</ListItem>
)}
{Boolean(chat.joinRequests?.length) && (
<ListItem
icon="add-user-filled"
onClick={handleClickRequests}
multiline
>
<span className="title">{lang('SubscribeRequests')}</span>
<span className="subtitle">
{formatInteger(chat.joinRequests!.length)}
</span>
</ListItem>
)}
<ListItem
icon="reactions"
multiline

View File

@ -79,6 +79,7 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
closeManagement,
openChat,
loadExportedChatInvites,
loadChatJoinRequests,
} = getDispatch();
const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag();
@ -99,8 +100,10 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
</span>
</ListItem>
)}
{Boolean(chat.joinRequests?.length) && (
<ListItem
icon="add-user-filled"
onClick={handleClickRequests}
multiline
>
<span className="title">{lang('MemberRequests')}</span>
<span className="subtitle">
{formatInteger(chat.joinRequests!.length)}
</span>
</ListItem>
)}
</div>
<div className="section">
<ListItem icon="group" multiline onClick={handleClickMembers}>

View File

@ -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<OwnProps & StateProps> = ({
useHistoryBack(isActive, onClose);
useEffect(() => {
useOnChange(([oldEditingInvite]) => {
if (oldEditingInvite === editingInvite) return;
if (!editingInvite) {
setTitle('');
setSelectedExpireOption('unlimited');

View File

@ -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<OwnProps & StateProps> = ({
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 <p className="text-muted">{lang('Loading')}</p>;
return (
<div className="section">
<p>{importers.length ? lang('PeopleJoined', usage) : lang('NoOneJoined')}</p>
<p className="text-muted">
{!importers.length && (
usageLimit ? lang('PeopleCanJoinViaLinkCount', usageLimit - usage) : lang('NoOneJoinedYet')
)}
{importers.map((importer) => (
<ListItem
className="chat-item-clickable scroll-item small-icon"
onClick={() => openUserInfo({ id: importer.userId })}
>
<PrivateChatInfo
userId={importer.userId}
status={formatMediaDateTime(lang, importer.date * 1000, true)}
forceShowSelf
/>
</ListItem>
))}
</p>
</div>
);
};
return (
<div className="Management ManageInviteInfo">
<div className="custom-scroll">
{!invite && (
<p className="text-muted">{lang('Loading')}</p>
)}
{invite && (
<>
<div className="section">
<h3>{invite.title || invite.link}</h3>
<input
className="form-control"
value={invite.link}
readOnly
onClick={handleCopyClicked}
/>
<Button className="copy-link" onClick={handleCopyClicked}>{lang('CopyLink')}</Button>
{expireDate && (
<p className="text-muted">
{isExpired
? lang('ExpiredLink')
: lang('LinkExpiresIn', `${formatFullDate(lang, expireDate)} ${formatTime(lang, expireDate)}`)}
</p>
)}
</div>
{adminId && (
<div className="section">
<p>{lang('LinkCreatedeBy')}</p>
<ListItem
className="chat-item-clickable scroll-item small-icon"
onClick={() => openUserInfo({ id: adminId })}
>
<PrivateChatInfo
userId={adminId}
status={formatMediaDateTime(lang, invite.date * 1000, true)}
forceShowSelf
/>
</ListItem>
</div>
)}
{renderImporters()}
</>
)}
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const { inviteInfo } = global.management.byChatId[chatId];
const invite = inviteInfo?.invite;
const importers = inviteInfo?.importers;
return {
invite,
importers,
serverTimeOffset: global.serverTimeOffset,
};
},
)(ManageInviteInfo));

View File

@ -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<OwnProps & StateProps> = ({
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<ApiExportedInvite | undefined>();
const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag();
const [deletingInvite, setDeletingInvite] = useState<ApiExportedInvite | undefined>();
useHistoryBack(isActive, onClose);
const lang = useLang();
@ -74,8 +91,7 @@ const ManageInvites: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
});
}, [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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
<Button isText key="create" className="create-link" onClick={handleCreateNewClick}>
{lang('CreateNewLink')}
</Button>
{!temporalInvites && <NothingFound text="No links found" key="nothing" />}
{(!temporalInvites || !temporalInvites.length) && <NothingFound text="No links found" key="nothing" />}
{temporalInvites?.map((invite) => (
<ListItem
icon="link"
secondaryIcon="more"
multiline
onClick={() => copyLink(invite.link)}
onClick={() => showInviteInfo(invite)}
contextActions={prepareContextActions(invite)}
key={invite.link}
>
@ -243,18 +313,68 @@ const ManageInvites: FC<OwnProps & StateProps> = ({
))}
<p className="text-muted hint" key="links-hint">{lang('ManageLinksInfoHelp')}</p>
</div>
{revokedExportedInvites && Boolean(revokedExportedInvites.length) && (
<div className="section" teactFastList>
<p className="text-muted" key="title">{lang('RevokedLinks')}</p>
<ListItem
icon="delete"
destructive
key="delete"
onClick={openDeleteRevokeAllDialog}
>
<span className="title">{lang('DeleteAllRevokedLinks')}</span>
</ListItem>
{revokedExportedInvites?.map((invite) => (
<ListItem
icon="link"
secondaryIcon="more"
multiline
onClick={() => showInviteInfo(invite)}
contextActions={prepareContextActions(invite)}
key={invite.link}
>
<span className="title">{invite.title || invite.link}</span>
<span className="subtitle" dir="auto">
{prepareUsageText(invite)}
</span>
</ListItem>
))}
</div>
)}
</div>
<ConfirmDialog
isOpen={isDeleteRevokeAllDialogOpen}
onClose={closeDeleteRevokeAllDialog}
title={lang('DeleteAllRevokedLinks')}
text={lang('DeleteAllRevokedLinkHelp')}
confirmHandler={handleDeleteAllRevoked}
/>
<ConfirmDialog
isOpen={isRevokeDialogOpen}
onClose={closeRevokeDialog}
title={lang('RevokeLink')}
text={lang('RevokeAlert')}
confirmHandler={handleRevoke}
/>
<ConfirmDialog
isOpen={isDeleteDialogOpen}
onClose={closeDeleteDialog}
title={lang('DeleteLink')}
text={lang('DeleteLinkHelp')}
confirmHandler={handleDelete}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(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,
};

View File

@ -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<OwnProps & StateProps> = ({
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 (
<div className="Management ManageJoinRequests">
{Boolean(chat?.joinRequests?.length) && (
<div className="section bulk-actions">
<Button className="bulk-action-button" onClick={openAcceptAllDialog}>Accept all</Button>
<Button className="bulk-action-button" onClick={openRejectAllDialog} isText>Dismiss all</Button>
</div>
)}
<div className="section">
<div className="custom-scroll" teactFastList>
<p key="title">
{chat?.joinRequests?.length ? lang('JoinRequests', chat?.joinRequests?.length) : lang('NoMemberRequests')}
</p>
{chat?.joinRequests?.length === 0 && (
<p className="text-muted" key="empty">
{isChannel ? lang('NoSubscribeRequestsDescription') : lang('NoMemberRequestsDescription')}
</p>
)}
{chat?.joinRequests?.map(({ userId, about, date }) => (
<JoinRequest
userId={userId}
about={about}
date={date}
isChannel={isChannel}
chatId={chatId}
key={userId}
/>
))}
</div>
</div>
<ConfirmDialog
isOpen={isAcceptAllDialogOpen}
onClose={closeAcceptAllDialog}
title="Accept all requests?"
text="Are you sure you want to accept all requests?"
confirmHandler={handleAcceptAllRequests}
/>
<ConfirmDialog
isOpen={isRejectAllDialogOpen}
onClose={closeRejectAllDialog}
title="Reject all requests?"
text="Are you sure you want to reject all requests?"
confirmHandler={handleRejectAllRequests}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const chat = selectChat(global, chatId);
return {
chat,
serverTimeOffset: global.serverTimeOffset,
isChannel: chat && isChatChannel(chat),
};
},
)(ManageJoinRequests));

View File

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

View File

@ -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<OwnProps & StateProps> = ({
onClose={onClose}
/>
);
case ManagementScreens.InviteInfo:
return (
<ManageInviteInfo
chatId={chatId}
isActive={isActive}
onClose={onClose}
/>
);
case ManagementScreens.JoinRequests:
return (
<ManageJoinRequests
chatId={chatId}
isActive={isActive}
onClose={onClose}
/>
);
}
return undefined; // Never reached

View File

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

View File

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

View File

@ -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",

View File

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

View File

@ -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,
},
}));
})();
});

View File

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

View File

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

View File

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

View File

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