2024-03-01 14:02:47 -05:00

521 lines
16 KiB
TypeScript

import type { ChangeEvent } from 'react';
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type {
ApiAvailableReaction, ApiChat, ApiChatBannedRights, ApiChatFullInfo, ApiExportedInvite,
} from '../../../api/types';
import { ApiMediaFormat } from '../../../api/types';
import { ManagementProgress, ManagementScreens } from '../../../types';
import {
getChatAvatarHash,
getHasAdminRight,
isChatBasicGroup,
isChatPublic,
} from '../../../global/helpers';
import { selectChat, selectChatFullInfo, selectTabState } from '../../../global/selectors';
import { debounce } from '../../../util/schedulers';
import { formatInteger } from '../../../util/textFormat';
import renderText from '../../common/helpers/renderText';
import useFlag from '../../../hooks/useFlag';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import useMedia from '../../../hooks/useMedia';
import AvatarEditable from '../../ui/AvatarEditable';
import Checkbox from '../../ui/Checkbox';
import ConfirmDialog from '../../ui/ConfirmDialog';
import FloatingActionButton from '../../ui/FloatingActionButton';
import InputText from '../../ui/InputText';
import ListItem from '../../ui/ListItem';
import Spinner from '../../ui/Spinner';
import Switcher from '../../ui/Switcher';
import TextArea from '../../ui/TextArea';
import './Management.scss';
type OwnProps = {
chatId: string;
onScreenSelect: (screen: ManagementScreens) => void;
onClose: NoneToVoidFunction;
isActive: boolean;
};
type StateProps = {
chat: ApiChat;
chatFullInfo?: ApiChatFullInfo;
progress?: ManagementProgress;
isBasicGroup: boolean;
hasLinkedChannel: boolean;
canChangeInfo?: boolean;
canBanUsers?: boolean;
canInvite?: boolean;
canEditForum?: boolean;
exportedInvites?: ApiExportedInvite[];
isChannelsPremiumLimitReached: boolean;
availableReactions?: ApiAvailableReaction[];
};
const GROUP_TITLE_EMPTY = 'Group title can\'t be empty';
const GROUP_MAX_DESCRIPTION = 255;
const ALL_PERMISSIONS: Array<keyof ApiChatBannedRights> = [
'sendMessages',
'embedLinks',
'sendPolls',
'changeInfo',
'inviteUsers',
'pinMessages',
'manageTopics',
'sendPhotos',
'sendVideos',
'sendRoundvideos',
'sendVoices',
'sendAudios',
'sendDocs',
];
// Some checkboxes control multiple rights, and some rights are not controlled from Permissions screen,
// so we need to define the amount manually
const TOTAL_PERMISSIONS_COUNT = ALL_PERMISSIONS.length + 1;
const runDebounced = debounce((cb) => cb(), 500, false);
const ManageGroup: FC<OwnProps & StateProps> = ({
chatId,
chat,
chatFullInfo,
progress,
isBasicGroup,
hasLinkedChannel,
canChangeInfo,
canBanUsers,
canInvite,
canEditForum,
isActive,
exportedInvites,
isChannelsPremiumLimitReached,
availableReactions,
onScreenSelect,
onClose,
}) => {
const {
togglePreHistoryHidden,
updateChat,
deleteChat,
leaveChannel,
deleteChannel,
closeManagement,
openChat,
loadExportedChatInvites,
loadChatJoinRequests,
toggleForum,
} = getActions();
const [isDeleteDialogOpen, openDeleteDialog, closeDeleteDialog] = useFlag();
const currentTitle = chat.title;
const currentAbout = chatFullInfo?.about || '';
const [isProfileFieldsTouched, setIsProfileFieldsTouched] = useState(false);
const [title, setTitle] = useState(currentTitle);
const [about, setAbout] = useState(currentAbout);
const [photo, setPhoto] = useState<File | undefined>();
const [error, setError] = useState<string | undefined>();
const [isForumEnabled, setIsForumEnabled] = useState(chat.isForum);
const imageHash = getChatAvatarHash(chat);
const currentAvatarBlobUrl = useMedia(imageHash, false, ApiMediaFormat.BlobUrl);
const isPublicGroup = useMemo(() => isChatPublic(chat), [chat]);
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const isPreHistoryHiddenCheckboxRef = useRef<HTMLDivElement>(null);
useHistoryBack({
isActive,
onBack: onClose,
});
useEffect(() => {
if (canInvite) {
loadExportedChatInvites({ chatId });
loadExportedChatInvites({ chatId, isRevoked: true });
loadChatJoinRequests({ chatId });
}
}, [chatId, canInvite]);
// Resetting `isForum` switch on flood wait error
useEffect(() => {
setIsForumEnabled(Boolean(chat.isForum));
}, [chat.isForum]);
useEffect(() => {
if (progress === ManagementProgress.Complete) {
setIsProfileFieldsTouched(false);
setError(undefined);
}
}, [progress]);
const handleClickEditType = useLastCallback(() => {
onScreenSelect(ManagementScreens.ChatPrivacyType);
});
const handleClickDiscussion = useLastCallback(() => {
onScreenSelect(ManagementScreens.Discussion);
});
const handleClickReactions = useLastCallback(() => {
onScreenSelect(ManagementScreens.Reactions);
});
const handleClickPermissions = useLastCallback(() => {
onScreenSelect(ManagementScreens.GroupPermissions);
});
const handleClickAdministrators = useLastCallback(() => {
onScreenSelect(ManagementScreens.ChatAdministrators);
});
const handleClickInvites = useLastCallback(() => {
onScreenSelect(ManagementScreens.Invites);
});
const handleClickRequests = useLastCallback(() => {
onScreenSelect(ManagementScreens.JoinRequests);
});
const handleSetPhoto = useLastCallback((file: File) => {
setPhoto(file);
setIsProfileFieldsTouched(true);
});
const handleTitleChange = useLastCallback((e: ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
setIsProfileFieldsTouched(true);
});
const handleAboutChange = useLastCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
setAbout(e.target.value);
setIsProfileFieldsTouched(true);
});
const handleUpdateGroup = useLastCallback(() => {
const trimmedTitle = title.trim();
const trimmedAbout = about.trim();
if (!trimmedTitle.length) {
setError(GROUP_TITLE_EMPTY);
return;
}
updateChat({
chatId,
title: trimmedTitle,
about: trimmedAbout,
photo,
});
});
const handleClickMembers = useLastCallback(() => {
onScreenSelect(ManagementScreens.GroupMembers);
});
const handleTogglePreHistory = useLastCallback(() => {
if (!chatFullInfo) {
return;
}
const { isPreHistoryHidden } = chatFullInfo;
togglePreHistoryHidden({ chatId: chat.id, isEnabled: !isPreHistoryHidden });
});
const handleForumToggle = useLastCallback(() => {
setIsForumEnabled((current) => {
const newIsForumEnabled = !current;
runDebounced(() => {
toggleForum({ chatId, isEnabled: newIsForumEnabled });
});
return newIsForumEnabled;
});
});
useEffect(() => {
if (!isChannelsPremiumLimitReached) {
return;
}
// Teact does not have full support of controlled form components, we need to "disable" input value change manually
// TODO Teact support added, this can now be removed
const checkbox = isPreHistoryHiddenCheckboxRef.current?.querySelector('input') as HTMLInputElement;
checkbox.checked = !chatFullInfo?.isPreHistoryHidden;
}, [isChannelsPremiumLimitReached, chatFullInfo?.isPreHistoryHidden]);
const chatReactionsDescription = useMemo(() => {
if (!chatFullInfo?.enabledReactions) {
return lang('ReactionsOff');
}
if (chatFullInfo.enabledReactions.type === 'all') {
return lang('ReactionsAll');
}
const enabledLength = chatFullInfo.enabledReactions.allowed.length;
const totalLength = availableReactions?.filter((reaction) => !reaction.isInactive).length || 0;
return totalLength
? `${enabledLength} / ${totalLength}`
: `${enabledLength}`;
}, [availableReactions, chatFullInfo?.enabledReactions, lang]);
const enabledPermissionsCount = useMemo(() => {
if (!chat.defaultBannedRights) {
return 0;
}
let totalCount = ALL_PERMISSIONS.filter(
(key) => {
if (key === 'manageTopics' && !isForumEnabled) return false;
return !chat.defaultBannedRights![key as keyof ApiChatBannedRights];
},
).length;
const { sendStickers, sendGifs } = chat.defaultBannedRights;
// These two rights are controlled with a single checkbox
if (!sendStickers && !sendGifs) {
totalCount += 1;
}
return totalCount;
}, [chat.defaultBannedRights, isForumEnabled]);
const adminsCount = useMemo(() => {
return Object.keys(chatFullInfo?.adminMembersById || {}).length;
}, [chatFullInfo?.adminMembersById]);
const handleDeleteGroup = useLastCallback(() => {
if (isBasicGroup) {
deleteChat({ chatId: chat.id });
} else if (!chat.isCreator) {
leaveChannel({ chatId: chat.id });
} else {
deleteChannel({ chatId: chat.id });
}
closeDeleteDialog();
closeManagement();
openChat({ id: undefined });
});
if (chat.isRestricted || chat.isForbidden) {
return undefined;
}
const isLoading = progress === ManagementProgress.InProgress;
return (
<div className="Management">
<div className="custom-scroll">
<div className="section">
<AvatarEditable
isForForum={isForumEnabled}
currentAvatarBlobUrl={currentAvatarBlobUrl}
onChange={handleSetPhoto}
disabled={!canChangeInfo}
/>
<InputText
id="group-title"
label={lang('GroupName')}
onChange={handleTitleChange}
value={title}
error={error === GROUP_TITLE_EMPTY ? error : undefined}
disabled={!canChangeInfo}
/>
<TextArea
id="group-about"
className="mb-2"
label={lang('DescriptionPlaceholder')}
maxLength={GROUP_MAX_DESCRIPTION}
maxLengthIndicator={(GROUP_MAX_DESCRIPTION - about.length).toString()}
onChange={handleAboutChange}
value={about}
disabled={!canChangeInfo}
noReplaceNewlines
/>
{chat.isCreator && (
<ListItem icon="lock" multiline onClick={handleClickEditType}>
<span className="title">{lang('GroupType')}</span>
<span className="subtitle">{isPublicGroup ? lang('TypePublic') : lang('TypePrivate')}</span>
</ListItem>
)}
{hasLinkedChannel && (
<ListItem
icon="message"
multiline
onClick={handleClickDiscussion}
>
<span className="title">{lang('LinkedChannel')}</span>
<span className="subtitle">{lang('DiscussionUnlink')}</span>
</ListItem>
)}
<ListItem
icon="permissions"
multiline
onClick={handleClickPermissions}
disabled={!canBanUsers}
>
<span className="title">{lang('ChannelPermissions')}</span>
<span className="subtitle" dir="auto">
{enabledPermissionsCount}/{TOTAL_PERMISSIONS_COUNT - (isForumEnabled ? 0 : 1)}
</span>
</ListItem>
<ListItem
icon="heart-outline"
multiline
onClick={handleClickReactions}
disabled={!canChangeInfo}
>
<span className="title">{lang('Reactions')}</span>
<span className="subtitle" dir="auto">
{chatReactionsDescription}
</span>
</ListItem>
<ListItem
icon="admin"
multiline
onClick={handleClickAdministrators}
>
<span className="title">{lang('ChannelAdministrators')}</span>
<span className="subtitle">{formatInteger(adminsCount)}</span>
</ListItem>
{canInvite && (
<ListItem
icon="link"
onClick={handleClickInvites}
multiline
disabled={!exportedInvites}
>
<span className="title">{lang('GroupInfo.InviteLinks')}</span>
<span className="subtitle">
{exportedInvites ? formatInteger(exportedInvites.length) : lang('Loading')}
</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>
)}
{canEditForum && (
<>
<ListItem icon="forums" ripple onClick={handleForumToggle}>
<span>{lang('ChannelTopics')}</span>
<Switcher
id="group-notifications"
label={lang('ChannelTopics')}
checked={isForumEnabled}
inactive
/>
</ListItem>
<div className="section-info section-info_push">{lang('ForumToggleDescription')}</div>
</>
)}
</div>
<div className="section">
<ListItem icon="group" multiline onClick={handleClickMembers}>
<span className="title">{lang('GroupMembers')}</span>
<span className="subtitle">{formatInteger(chat.membersCount ?? 0)}</span>
</ListItem>
{!isPublicGroup && !hasLinkedChannel && Boolean(chatFullInfo) && (
<div className="ListItem narrow" ref={isPreHistoryHiddenCheckboxRef}>
<Checkbox
checked={!chatFullInfo.isPreHistoryHidden}
label={lang('ChatHistory')}
onChange={handleTogglePreHistory}
disabled={!canBanUsers}
/>
</div>
)}
</div>
<div className="section">
<ListItem icon="delete" ripple destructive onClick={openDeleteDialog}>
{lang('DeleteMega')}
</ListItem>
</div>
</div>
<FloatingActionButton
isShown={isProfileFieldsTouched}
onClick={handleUpdateGroup}
disabled={isLoading}
ariaLabel={lang('Save')}
>
{isLoading ? (
<Spinner color="white" />
) : (
<i className="icon icon-check" />
)}
</FloatingActionButton>
<ConfirmDialog
isOpen={isDeleteDialogOpen}
onClose={closeDeleteDialog}
textParts={renderText(
isBasicGroup || !chat.isCreator
? lang('AreYouSureDeleteAndExit')
: lang('AreYouSureDeleteThisChatWithGroup', chat.title),
['br', 'simple_markdown'],
)}
confirmLabel={isBasicGroup || !chat.isCreator ? lang('DeleteMega') : lang('DeleteGroupForAll')}
confirmHandler={handleDeleteGroup}
confirmIsDestructive
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const chat = selectChat(global, chatId)!;
const chatFullInfo = selectChatFullInfo(global, chatId);
const { management, limitReachedModal } = selectTabState(global);
const { progress } = management;
const hasLinkedChannel = Boolean(chatFullInfo?.linkedChatId);
const isBasicGroup = isChatBasicGroup(chat);
const { invites } = management.byChatId[chatId] || {};
const canEditForum = !hasLinkedChannel && (getHasAdminRight(chat, 'changeInfo') || chat.isCreator);
const canChangeInfo = chat.isCreator || getHasAdminRight(chat, 'changeInfo');
const canBanUsers = chat.isCreator || getHasAdminRight(chat, 'banUsers');
const canInvite = chat.isCreator || getHasAdminRight(chat, 'inviteUsers');
return {
chat,
chatFullInfo,
progress,
isBasicGroup,
hasLinkedChannel,
canChangeInfo,
canBanUsers,
canInvite,
exportedInvites: invites,
isChannelsPremiumLimitReached: limitReachedModal?.limit === 'channels',
availableReactions: global.reactions.availableReactions,
canEditForum,
};
},
(global, { chatId }) => {
return Boolean(selectChat(global, chatId));
},
)(ManageGroup));