Feature: Implement mass moderation for groups and channels (#4730)

Co-authored-by: zubiden <19638254+zubiden@users.noreply.github.com>
Co-authored-by: Anton <anton@korenskoy.ru>
This commit is contained in:
Alexander Zinchuk 2024-08-06 20:05:49 +02:00
parent ff9a0e3fdf
commit 7bfb334d63
19 changed files with 1580 additions and 577 deletions

View File

@ -164,7 +164,6 @@ import Button from '../ui/Button';
import ResponsiveHoverButton from '../ui/ResponsiveHoverButton';
import Spinner from '../ui/Spinner';
import Avatar from './Avatar';
import DeleteMessageModal from './DeleteMessageModal.async';
import Icon from './icons/Icon';
import ReactionAnimatedEmoji from './reactions/ReactionAnimatedEmoji';
@ -586,7 +585,6 @@ const Composer: FC<OwnProps & StateProps> = ({
const [isBotCommandMenuOpen, openBotCommandMenu, closeBotCommandMenu] = useFlag();
const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag();
const [isSendAsMenuOpen, openSendAsMenu, closeSendAsMenu] = useFlag();
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag();
const [isHoverDisabled, disableHover, enableHover] = useFlag();
const {
@ -756,7 +754,6 @@ const Composer: FC<OwnProps & StateProps> = ({
setHtml,
editingMessage,
resetComposer,
openDeleteModal,
chatId,
threadId,
messageListType,
@ -1405,7 +1402,7 @@ const Composer: FC<OwnProps & StateProps> = ({
}, [isInStoryViewer, slowMode?.nextSendDate, stealthMode?.activeUntil]);
const isComposerHasFocus = isBotKeyboardOpen || isSymbolMenuOpen || isEmojiTooltipOpen || isSendAsMenuOpen
|| isMentionTooltipOpen || isInlineBotTooltipOpen || isDeleteModalOpen || isBotCommandMenuOpen || isAttachMenuOpen
|| isMentionTooltipOpen || isInlineBotTooltipOpen || isBotCommandMenuOpen || isAttachMenuOpen
|| isStickerTooltipOpen || isChatCommandTooltipOpen || isCustomEmojiTooltipOpen || isBotMenuButtonOpen
|| isCustomSendMenuOpen || Boolean(activeVoiceRecording) || attachments.length > 0 || isInputHasFocus;
const isReactionSelectorOpen = isComposerHasFocus && !isReactionPickerOpen && isInStoryViewer && !isAttachMenuOpen
@ -1473,9 +1470,6 @@ const Composer: FC<OwnProps & StateProps> = ({
}
});
const prevEditedMessage = usePrevious(editingMessage, true);
const renderedEditedMessage = editingMessage || prevEditedMessage;
const scheduledDefaultDate = new Date();
scheduledDefaultDate.setSeconds(0);
scheduledDefaultDate.setMilliseconds(0);
@ -1663,14 +1657,6 @@ const Composer: FC<OwnProps & StateProps> = ({
onClear={closePollModal}
onSend={handlePollSend}
/>
{renderedEditedMessage && (
<DeleteMessageModal
isOpen={isDeleteModalOpen}
isSchedule={messageListType === 'scheduled'}
onClose={closeDeleteModal}
message={renderedEditedMessage}
/>
)}
<SendAsMenu
isOpen={isSendAsMenuOpen}
onClose={closeSendAsMenu}

View File

@ -0,0 +1,67 @@
.mainContainer {
max-height: 90vh;
}
.container {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.title {
margin: 0;
}
.actionTitle {
margin-top: 1.5rem;
margin-left: 0.5rem;
color: var(--color-links);
font-size: 1rem;
font-weight: bold;
}
.restrictionTitle {
margin-bottom: 1.5rem;
margin-top: 0;
}
.listItemButton {
margin-top: 0.1875rem;
margin-left: 0.4375rem;
}
.button {
color: var(--color-links) !important;
padding: 0 !important;
&:hover {
background: none !important;
}
}
.dropdownList {
padding-left: 3.1875rem;
}
.dialogButtons {
padding-bottom: 1rem;
}
.restrictionContainer,
.dropdownList {
overflow: hidden;
max-height: 0;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: max-height 0.3s ease-out;
}
.restrictionContainerOpen,
.dropdownListOpen {
max-height: 100vh;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: max-height 0.3s ease-in;
}
:global(.Checkbox) {
padding-left: 3.875rem;
}

View File

@ -1,49 +1,86 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo, useCallback } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import React, {
memo, useEffect,
useMemo,
useState,
} from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global';
import type { ApiMessage } from '../../api/types';
import type {
ApiChat, ApiChatMember, ApiMessage, ApiPeer,
} from '../../api/types';
import type { IAlbum } from '../../types';
import type { IRadioOption } from '../ui/CheckboxGroup';
import { REPLIES_USER_ID } from '../../config';
import {
getHasAdminRight,
getPrivateChatUserId,
getUserFirstOrLastName,
isChatBasicGroup,
isChatSuperGroup,
getUserFirstOrLastName, getUserFullName,
isChatBasicGroup, isChatChannel,
isChatSuperGroup, isOwnMessage,
isUserId,
} from '../../global/helpers';
import {
selectAllowedMessageActions,
selectBot,
selectChat,
selectCurrentMessageList,
selectChat, selectChatFullInfo, selectCurrentMessageIds,
selectCurrentMessageList, selectSenderFromMessage, selectTabState,
selectUser,
} from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import renderText from './helpers/renderText';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import usePrevious from '../../hooks/usePrevious';
import useManagePermissions from '../right/hooks/useManagePermissions';
import PermissionCheckboxList from '../main/PermissionCheckboxList';
import Button from '../ui/Button';
import CheckboxGroup from '../ui/CheckboxGroup';
import ListItem from '../ui/ListItem';
import Modal from '../ui/Modal';
import Avatar from './Avatar';
import Icon from './icons/Icon';
import styles from './DeleteMessageModal.module.scss';
export type OwnProps = {
isOpen: boolean;
isSchedule: boolean;
message: ApiMessage;
album?: IAlbum;
onClose: NoneToVoidFunction;
onConfirm?: NoneToVoidFunction;
isOpen?: boolean;
};
type StateProps = {
chat?: ApiChat;
isChannel?: boolean;
isGroup?: boolean;
isSuperGroup?: boolean;
sender: ApiPeer | undefined;
currentUserId?: string;
canDeleteForAll?: boolean;
contactName?: string;
willDeleteForCurrentUserOnly?: boolean;
willDeleteForAll?: boolean;
messageIdList: number[] | undefined;
adminMembersById?: Record<string, ApiChatMember>;
chatBot?: boolean;
isSchedule?: boolean;
message?: ApiMessage;
album?: IAlbum;
onConfirm?: NoneToVoidFunction;
isOwn?: boolean;
canBanUsers?: boolean;
};
const DeleteMessageModal: FC<OwnProps & StateProps> = ({
isOpen,
chat,
isChannel,
isGroup,
isSuperGroup,
sender,
currentUserId,
messageIdList,
isSchedule,
message,
album,
@ -52,89 +89,392 @@ const DeleteMessageModal: FC<OwnProps & StateProps> = ({
willDeleteForCurrentUserOnly,
willDeleteForAll,
onConfirm,
onClose,
adminMembersById,
chatBot,
isOwn,
canBanUsers,
}) => {
const {
deleteMessages,
deleteScheduledMessages,
reportMessages,
deleteChatMember,
updateChatMemberBannedRights,
closeDeleteMessageModal,
} = getActions();
const handleDeleteMessageForAll = useCallback(() => {
onConfirm?.();
const messageIds = album?.messages
? album.messages.map(({ id }) => id)
: [message.id];
deleteMessages({ messageIds, shouldDeleteForAll: true });
onClose();
}, [onConfirm, album, message.id, deleteMessages, onClose]);
const prevIsOpen = usePrevious(isOpen);
const handleDeleteMessageForSelf = useCallback(() => {
const lang = useOldLang();
const {
permissions, havePermissionChanged, handlePermissionChange, resetPermissions,
} = useManagePermissions(chat?.defaultBannedRights);
const [chosenDeleteOption, setChosenDeleteOption] = useState<string[] | undefined>(undefined);
const [chosenBanOption, setChosenBanOptions] = useState<string[] | undefined>(undefined);
const [chosenSpanOption, setChosenSpanOptions] = useState<string[] | undefined>(undefined);
const [isMediaDropdownOpen, setIsMediaDropdownOpen] = useState(false);
const [isAdditionalOptionsVisible, setIsAdditionalOptionsVisible] = useState(false);
const isSenderOwner = useMemo(() => {
return sender && adminMembersById && adminMembersById[sender.id] && adminMembersById[sender.id].isOwner;
}, [sender, adminMembersById]);
const user = useMemo(() => {
const usersById = getGlobal().users.byId;
if (!sender || isSchedule) return undefined;
return usersById[sender.id];
}, [isSchedule, sender]);
const shouldShowAdditionalOptions = useMemo(() => {
return user && user.id !== currentUserId;
}, [user, currentUserId]);
const userName = useMemo(() => {
const usersById = getGlobal().users.byId;
if (!sender || isSchedule) return '';
return getUserFullName(usersById[sender.id]);
}, [isSchedule, sender]);
const ACTION_SPAM_OPTION: IRadioOption[] = useMemo(() => {
if (!user) {
return [];
}
return [
{
value: user.id,
label: lang('ReportSpamTitle'),
},
];
}, [lang, user]);
const ACTION_DELETE_OPTION: IRadioOption[] = useMemo(() => {
if (!user) {
return [];
}
return [
{
value: user.id,
label: lang('DeleteAllFrom', userName),
},
];
}, [lang, user, userName]);
const ACTION_BAN_OPTION: IRadioOption[] = useMemo(() => {
if (!user) {
return [];
}
return [
{
value: user.id,
label: (message && isAdditionalOptionsVisible ? lang('KickFromSupergroup') : lang('DeleteBan', userName)),
},
];
}, [isAdditionalOptionsVisible, lang, message, user, userName]);
const toggleAdditionalOptions = useLastCallback(() => {
setIsAdditionalOptionsVisible(!isAdditionalOptionsVisible);
});
const filterMessageIdByUserId = useLastCallback((userIds: string[], selectedMessageIdList: number[]) => {
return selectedMessageIdList.filter((msgId) => {
const senderPeer = selectSenderFromMessage(getGlobal(), chat, msgId);
return senderPeer && userIds.includes(senderPeer.id);
});
});
const handleDeleteMessages = useLastCallback((filteredMessageIdList: number[]) => {
if (filteredMessageIdList?.length) {
deleteMessages({ messageIds: filteredMessageIdList, shouldDeleteForAll: true });
}
});
const handleDeleteMember = useLastCallback((filteredUserIdList: string[]) => {
filteredUserIdList.forEach((userId) => {
deleteChatMember({ chatId: chat!.id, userId });
});
});
const handleUpdateChatMemberBannedRights = useLastCallback((filteredUserIdList: string[]) => {
filteredUserIdList.forEach((userId) => {
updateChatMemberBannedRights({
chatId: chat!.id,
userId,
bannedRights: permissions,
});
});
});
const handleDeleteMessageForAll = useLastCallback(() => {
onConfirm?.();
const messageIds = album?.messages
? album.messages.map(({ id }) => id)
: [message.id];
: [message!.id];
deleteMessages({ messageIds, shouldDeleteForAll: true });
closeDeleteMessageModal();
});
const handleDeleteMessageForSelf = useLastCallback(() => {
onConfirm?.();
const messageIds = album?.messages
? album.messages.map(({ id }) => id)
: [message!.id];
if (isSchedule) {
deleteScheduledMessages({ messageIds });
} else if (!isOwn && (isChannel || isGroup || isSuperGroup)) {
if (chosenSpanOption) {
const filteredMessageIdList = filterMessageIdByUserId(chosenSpanOption, messageIdList!);
if (filteredMessageIdList && filteredMessageIdList.length) {
reportMessages({ messageIds: filteredMessageIdList, reason: 'spam', description: '' });
}
}
if (chosenDeleteOption) {
const filteredMessageIdList = filterMessageIdByUserId(chosenDeleteOption, messageIdList!);
handleDeleteMessages(filteredMessageIdList);
}
if (chosenBanOption && !havePermissionChanged && message) {
const filteredUserIdList = chosenBanOption.filter((userId) => messageIds?.some((msgId) => {
const senderPeer = selectSenderFromMessage(getGlobal(), chat, msgId);
return senderPeer && senderPeer.id === userId;
}));
handleDeleteMember(filteredUserIdList);
deleteMessages({
messageIds: [message.id],
shouldDeleteForAll: false,
});
}
if (chosenBanOption && havePermissionChanged) {
const filteredUserIdList = chosenBanOption.filter((userId) => messageIds?.some((msgId) => {
const senderPeer = selectSenderFromMessage(getGlobal(), chat, msgId);
return senderPeer && senderPeer.id === userId;
}));
handleUpdateChatMemberBannedRights(filteredUserIdList);
}
} else {
deleteMessages({
messageIds,
shouldDeleteForAll: false,
});
}
onClose();
}, [onConfirm, album, message.id, isSchedule, onClose, deleteScheduledMessages, deleteMessages]);
closeDeleteMessageModal();
});
const lang = useOldLang();
const handleDeleteOptionChange = useLastCallback((options: string[]) => {
setChosenDeleteOption(options);
});
const handleBanOptionChange = useLastCallback((options: string[]) => {
setChosenBanOptions(options);
});
const handleSpanOptionChange = useLastCallback((options: string[]) => {
setChosenSpanOptions(options);
});
const handleClose = useLastCallback(() => {
closeDeleteMessageModal();
});
useEffect(() => {
if (!isOpen && prevIsOpen) {
setChosenSpanOptions(undefined);
setChosenDeleteOption(undefined);
setChosenBanOptions(undefined);
setIsMediaDropdownOpen(false);
setIsAdditionalOptionsVisible(false);
resetPermissions();
}
}, [isOpen, prevIsOpen, resetPermissions]);
function renderHeader() {
return (
<div className={styles.container} dir={lang.isRtl ? 'rtl' : undefined}>
{(shouldShowAdditionalOptions && !canDeleteForAll && !isSchedule) && (
<Avatar
size="small"
peer={user!}
/>
)}
<h3 className={styles.title}>{lang('DeleteSingleMessagesTitle')}
</h3>
</div>
);
}
function renderAdditionalActionOptions() {
return (
<div className={styles.options}>
<CheckboxGroup
options={ACTION_SPAM_OPTION}
onChange={handleSpanOptionChange}
selected={chosenSpanOption}
/>
<CheckboxGroup
options={ACTION_DELETE_OPTION}
onChange={handleDeleteOptionChange}
selected={chosenDeleteOption}
/>
{!isSenderOwner && canBanUsers && (
<CheckboxGroup
options={ACTION_BAN_OPTION}
onChange={handleBanOptionChange}
selected={chosenBanOption}
/>
)}
</div>
);
}
function renderPartiallyRestrictedUser() {
return (
<div className={buildClassName(styles.restrictionContainer,
isAdditionalOptionsVisible && styles.restrictionContainerOpen)}
>
<h3 className={buildClassName(styles.actionTitle, styles.restrictionTitle)}>
{lang('UserRestrictionsCanDoUsers', 1)}
</h3>
<PermissionCheckboxList
withCheckbox
permissionGroup
chatId={chat?.id}
isMediaDropdownOpen={isMediaDropdownOpen}
setIsMediaDropdownOpen={setIsMediaDropdownOpen}
handlePermissionChange={handlePermissionChange}
permissions={permissions}
className={buildClassName(
styles.dropdownList,
isMediaDropdownOpen && styles.dropdownListOpen,
)}
/>
</div>
);
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
onEnter={isOpen && !canDeleteForAll ? handleDeleteMessageForSelf : undefined}
onClose={handleClose}
onEnter={canDeleteForAll ? undefined : handleDeleteMessageForSelf}
className="delete"
title={lang('DeleteSingleMessagesTitle')}
>
<p>{lang('AreYouSureDeleteSingleMessage')}</p>
{willDeleteForCurrentUserOnly && (
<p>{lang('lng_delete_for_me_chat_hint', 1, 'i')}</p>
)}
{willDeleteForAll && (
<p>{lang('lng_delete_for_everyone_hint', 1, 'i')}</p>
)}
<div className={canDeleteForAll ? 'dialog-buttons-column' : 'dialog-buttons'}>
{canDeleteForAll && (
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForAll}>
{contactName && renderText(lang('Conversation.DeleteMessagesFor', contactName))}
{!contactName && lang('Conversation.DeleteMessagesForEveryone')}
</Button>
<div className={buildClassName(styles.mainContainer, 'custom-scroll')}>
{renderHeader()}
{(shouldShowAdditionalOptions && !canDeleteForAll && !isSchedule && (isChannel || isGroup || isSuperGroup)) && (
<>
<p className={styles.actionTitle}>{lang('DeleteAdditionalActions')}</p>
{renderAdditionalActionOptions()}
{renderPartiallyRestrictedUser()}
{
chosenBanOption && canBanUsers && chosenBanOption?.length ? (
<ListItem
narrow
className={styles.listItemButton}
buttonClassName={styles.button}
onClick={toggleAdditionalOptions}
>
{lang(isAdditionalOptionsVisible ? 'DeleteToggleBanUsers' : 'DeleteToggleRestrictUsers')}
<Icon
name={isAdditionalOptionsVisible ? 'up' : 'down'}
className={buildClassName(styles.button, 'ml-2')}
/>
</ListItem>
) : setIsAdditionalOptionsVisible(false)
}
</>
)}
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForSelf}>
{lang(canDeleteForAll ? 'ChatList.DeleteForCurrentUser' : 'Delete')}
</Button>
<Button className="confirm-dialog-button" isText onClick={onClose}>{lang('Cancel')}</Button>
{(chatBot || !shouldShowAdditionalOptions) && (
<>
<p>{lang('AreYouSureDeleteSingleMessage')}</p>
{willDeleteForCurrentUserOnly && (
<p>{lang('lng_delete_for_me_chat_hint', 1, 'i')}</p>
)}
{willDeleteForAll && (
<p>{lang('lng_delete_for_everyone_hint', 1, 'i')}</p>
)}
</>
)}
<div className={canDeleteForAll ? 'dialog-buttons-column'
: buildClassName('dialog-buttons', isAdditionalOptionsVisible && styles.dialogButtons)}
>
{canDeleteForAll && (
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForAll}>
{contactName && renderText(lang('Conversation.DeleteMessagesFor', contactName))}
{!contactName && lang('Conversation.DeleteMessagesForEveryone')}
</Button>
)}
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForSelf}>
{lang(canDeleteForAll ? 'ChatList.DeleteForCurrentUser' : 'Delete')}
</Button>
<Button
className="confirm-dialog-button"
isText
onClick={handleClose}
>{lang('Cancel')}
</Button>
</div>
</div>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global, { message, isSchedule }): StateProps => {
const { threadId } = selectCurrentMessageList(global) || {};
const { canDeleteForAll } = (threadId && selectAllowedMessageActions(global, message, threadId)) || {};
const chat = selectChat(global, message.chatId);
(global): StateProps => {
const {
deleteMessageModal,
} = selectTabState(global);
const chatId = deleteMessageModal && deleteMessageModal.message?.chatId;
const chat = chatId ? selectChat(global, chatId) : undefined;
const chatFullInfo = chat && selectChatFullInfo(global, chat.id);
const { threadId, type } = selectCurrentMessageList(global) || {};
const { canDeleteForAll } = (deleteMessageModal && deleteMessageModal.message && threadId
&& selectAllowedMessageActions(global, deleteMessageModal.message, threadId)) || {};
const adminMembersById = chatFullInfo && chatFullInfo?.adminMembersById;
const messageIdList = chat && selectCurrentMessageIds(global, chat.id, threadId!, type!);
const isChannel = Boolean(chat) && isChatChannel(chat);
const isGroup = Boolean(chat) && isChatBasicGroup(chat);
const isSuperGroup = Boolean(chat) && isChatSuperGroup(chat);
const sender = deleteMessageModal && chat && deleteMessageModal.message
&& selectSenderFromMessage(global, chat, deleteMessageModal.message.id);
const contactName = chat && isUserId(chat.id)
? getUserFirstOrLastName(selectUser(global, getPrivateChatUserId(chat)!))
: undefined;
const isChatWithBot = Boolean(selectBot(global, message.chatId));
const isChatWithBot = Boolean(deleteMessageModal && deleteMessageModal.message
&& selectBot(global, deleteMessageModal.message.chatId));
const chatBot = Boolean(chat && chat.id !== REPLIES_USER_ID && selectBot(global, chat.id));
const canBanUsers = chat && (chat.isCreator || getHasAdminRight(chat, 'banUsers'));
const isOwn = deleteMessageModal && deleteMessageModal.message && isOwnMessage(deleteMessageModal.message);
const willDeleteForCurrentUserOnly = (chat && isChatBasicGroup(chat) && !canDeleteForAll) || isChatWithBot;
const willDeleteForAll = chat && isChatSuperGroup(chat);
return {
canDeleteForAll: !isSchedule && canDeleteForAll,
chat,
isChannel,
isGroup,
isSuperGroup,
currentUserId: global.currentUserId,
sender,
messageIdList,
canDeleteForAll: deleteMessageModal && !deleteMessageModal.isSchedule && canDeleteForAll,
contactName,
willDeleteForCurrentUserOnly,
willDeleteForAll,
adminMembersById,
chatBot,
isSchedule: deleteMessageModal && deleteMessageModal.isSchedule,
message: deleteMessageModal && deleteMessageModal.message,
album: deleteMessageModal && deleteMessageModal.album,
onConfirm: deleteMessageModal && deleteMessageModal.onConfirm,
isOwn,
canBanUsers,
};
},
)(DeleteMessageModal));

View File

@ -64,6 +64,7 @@ import GroupCall from '../calls/group/GroupCall.async';
import PhoneCall from '../calls/phone/PhoneCall.async';
import RatePhoneCallModal from '../calls/phone/RatePhoneCallModal.async';
import CustomEmojiSetsModal from '../common/CustomEmojiSetsModal.async';
import DeleteMessageModal from '../common/DeleteMessageModal.async';
import StickerSetModal from '../common/StickerSetModal.async';
import UnreadCount from '../common/UnreadCounter';
import LeftColumn from '../left/LeftColumn';
@ -143,6 +144,7 @@ type StateProps = {
isReactionPickerOpen: boolean;
isAppendModalOpen?: boolean;
isGiveawayModalOpen?: boolean;
isDeleteMessageModalOpen?: boolean;
isPremiumGiftingModalOpen?: boolean;
isCurrentUserPremium?: boolean;
noRightColumnAnimation?: boolean;
@ -192,6 +194,7 @@ const Main: FC<OwnProps & StateProps> = ({
requestedDraft,
isPremiumModalOpen,
isGiveawayModalOpen,
isDeleteMessageModalOpen,
isPremiumGiftingModalOpen,
isPaymentModalOpen,
isReceiptModalOpen,
@ -577,6 +580,7 @@ const Main: FC<OwnProps & StateProps> = ({
<ReceiptModal isOpen={isReceiptModalOpen} onClose={clearReceipt} />
<DeleteFolderDialog folder={deleteFolderDialog} />
<ReactionPicker isOpen={isReactionPickerOpen} />
<DeleteMessageModal isOpen={isDeleteMessageModalOpen} />
</div>
);
};
@ -610,6 +614,7 @@ export default memo(withGlobal<OwnProps>(
ratingPhoneCall,
premiumModal,
giveawayModal,
deleteMessageModal,
giftingModal,
isMasterTab,
payment,
@ -665,6 +670,7 @@ export default memo(withGlobal<OwnProps>(
isCurrentUserPremium: selectIsCurrentUserPremium(global),
isPremiumModalOpen: premiumModal?.isOpen,
isGiveawayModalOpen: giveawayModal?.isOpen,
isDeleteMessageModalOpen: Boolean(deleteMessageModal),
isPremiumGiftingModalOpen: giftingModal?.isOpen,
limitReached: limitReachedModal?.limit,
isPaymentModalOpen: payment.isPaymentModalOpen,

View File

@ -0,0 +1,297 @@
import type { FC } from '../../lib/teact/teact';
import React, {
memo, useMemo,
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { ApiChat, ApiChatBannedRights } from '../../api/types';
import { isChatPublic } from '../../global/helpers';
import { selectChat, selectChatFullInfo } from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import stopEvent from '../../util/stopEvent';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import Checkbox from '../ui/Checkbox';
export type OwnProps = {
chatId?: string;
handlePermissionChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
permissions: ApiChatBannedRights;
isMediaDropdownOpen: boolean;
setIsMediaDropdownOpen: (open: boolean) => void;
className?: string;
shiftedClassName?: string;
dropdownClassName?: string;
withCheckbox?: boolean;
permissionGroup?: boolean;
getControlIsDisabled?: (key: Exclude<keyof ApiChatBannedRights, 'untilDate'>) => boolean | undefined;
};
type StateProps = {
chat?: ApiChat;
hasLinkedChat?: boolean;
};
const permissionKeyList: (keyof ApiChatBannedRights)[] = [
'sendPhotos', 'sendVideos', 'sendStickers',
'sendAudios', 'sendDocs', 'sendVoices', 'sendRoundvideos', 'embedLinks', 'sendPolls',
];
const PermissionCheckboxList: FC<OwnProps & StateProps> = ({
chat,
isMediaDropdownOpen,
setIsMediaDropdownOpen,
hasLinkedChat,
permissions,
handlePermissionChange,
className,
shiftedClassName,
dropdownClassName,
withCheckbox,
getControlIsDisabled,
permissionGroup,
}) => {
const {
showNotification,
} = getActions();
const { isForum } = chat || {};
const lang = useOldLang();
const isPublic = useMemo(() => chat && isChatPublic(chat), [chat]);
const shouldDisablePermissionForPublicGroup = hasLinkedChat || isPublic;
const countCheckedPermissions = useMemo(() => {
return permissionKeyList.reduce((count, key) => {
if (!permissions[key]) {
count += 1;
}
return count;
}, 0);
}, [permissions]);
const handleOpenMediaDropdown = useLastCallback((e: React.MouseEvent) => {
stopEvent(e);
setIsMediaDropdownOpen(!isMediaDropdownOpen);
});
const handleDisabledClick = useLastCallback(() => {
showNotification({ message: lang('lng_rights_permission_unavailable') });
});
return (
<>
<div className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}>
<Checkbox
name="sendPlain"
checked={!permissions.sendPlain}
label={lang('UserRestrictionsSend')}
blocking
permissionGroup={permissionGroup}
onChange={handlePermissionChange}
disabled={getControlIsDisabled && getControlIsDisabled('sendPlain')}
/>
</div>
<div className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}>
<Checkbox
name="sendMedia"
checked={!permissions.sendMedia}
label={lang('UserRestrictionsSendMedia')}
labelText={`${countCheckedPermissions}/${permissionKeyList.length}`}
blocking
permissionGroup={permissionGroup}
rightIcon={isMediaDropdownOpen ? 'up' : 'down'}
onChange={handlePermissionChange}
onClickLabel={handleOpenMediaDropdown}
disabled={getControlIsDisabled && getControlIsDisabled('sendMedia')}
/>
</div>
<div className={dropdownClassName}>
<div
className={className}
>
<div className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}>
<Checkbox
name="sendPhotos"
checked={!permissions.sendPhotos}
label={lang('UserRestrictionsSendPhotos')}
blocking
permissionGroup={permissionGroup}
onChange={handlePermissionChange}
disabled={getControlIsDisabled && getControlIsDisabled('sendPhotos')}
/>
</div>
<div className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}>
<Checkbox
name="sendVideos"
checked={!permissions.sendVideos}
label={lang('UserRestrictionsSendVideos')}
blocking
permissionGroup={permissionGroup}
onChange={handlePermissionChange}
disabled={getControlIsDisabled && getControlIsDisabled('sendVideos')}
/>
</div>
<div className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}>
<Checkbox
name="sendStickers"
checked={!permissions.sendStickers && !permissions.sendGifs}
label={lang('UserRestrictionsSendStickers')}
blocking
permissionGroup={permissionGroup}
onChange={handlePermissionChange}
disabled={getControlIsDisabled && getControlIsDisabled('sendStickers')}
/>
</div>
<div className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}>
<Checkbox
name="sendAudios"
checked={!permissions.sendAudios}
label={lang('UserRestrictionsSendMusic')}
blocking
permissionGroup={permissionGroup}
onChange={handlePermissionChange}
disabled={getControlIsDisabled && getControlIsDisabled('sendAudios')}
/>
</div>
<div className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}>
<Checkbox
name="sendDocs"
checked={!permissions.sendDocs}
label={lang('UserRestrictionsSendFiles')}
blocking
permissionGroup={permissionGroup}
onChange={handlePermissionChange}
disabled={getControlIsDisabled && getControlIsDisabled('sendDocs')}
/>
</div>
<div className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}>
<Checkbox
name="sendVoices"
checked={!permissions.sendVoices}
label={lang('UserRestrictionsSendVoices')}
blocking
permissionGroup={permissionGroup}
onChange={handlePermissionChange}
disabled={getControlIsDisabled && getControlIsDisabled('sendVoices')}
/>
</div>
<div className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}>
<Checkbox
name="sendRoundvideos"
checked={!permissions.sendRoundvideos}
label={lang('UserRestrictionsSendRound')}
blocking
permissionGroup={permissionGroup}
onChange={handlePermissionChange}
disabled={getControlIsDisabled && getControlIsDisabled('sendRoundvideos')}
/>
</div>
<div className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}>
<Checkbox
name="embedLinks"
checked={!permissions.embedLinks}
label={lang('UserRestrictionsEmbedLinks')}
blocking
permissionGroup={permissionGroup}
onChange={handlePermissionChange}
disabled={getControlIsDisabled && getControlIsDisabled('embedLinks')}
/>
</div>
<div className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}>
<Checkbox
name="sendPolls"
checked={!permissions.sendPolls}
label={lang('UserRestrictionsSendPolls')}
blocking
permissionGroup={permissionGroup}
onChange={handlePermissionChange}
disabled={getControlIsDisabled && getControlIsDisabled('sendPolls')}
/>
</div>
</div>
</div>
<div className={shiftedClassName}>
<div className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}>
<Checkbox
name="inviteUsers"
checked={!permissions.inviteUsers}
label={lang('UserRestrictionsInviteUsers')}
blocking
permissionGroup={permissionGroup}
onChange={handlePermissionChange}
disabled={getControlIsDisabled && getControlIsDisabled('inviteUsers')}
/>
</div>
<div
className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}
onClick={shouldDisablePermissionForPublicGroup ? handleDisabledClick : undefined}
>
<Checkbox
name="pinMessages"
checked={!permissions.pinMessages}
label={lang('UserRestrictionsPinMessages')}
disabled={getControlIsDisabled ? getControlIsDisabled('pinMessages')
: shouldDisablePermissionForPublicGroup}
blocking
permissionGroup={permissionGroup}
onChange={handlePermissionChange}
/>
</div>
<div
className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}
onClick={shouldDisablePermissionForPublicGroup ? handleDisabledClick : undefined}
>
<Checkbox
name="changeInfo"
checked={!permissions.changeInfo}
label={lang('UserRestrictionsChangeInfo')}
blocking
permissionGroup={permissionGroup}
disabled={getControlIsDisabled ? getControlIsDisabled('changeInfo')
: shouldDisablePermissionForPublicGroup}
onChange={handlePermissionChange}
/>
</div>
{isForum && (
<div className={buildClassName('ListItem', withCheckbox && 'with-checkbox')}>
<Checkbox
name="manageTopics"
checked={!permissions.manageTopics}
label={lang('CreateTopicsPermission')}
blocking
permissionGroup={permissionGroup}
onChange={handlePermissionChange}
disabled={getControlIsDisabled && getControlIsDisabled('manageTopics')}
/>
</div>
)}
</div>
</>
);
};
export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const chat = chatId ? selectChat(global, chatId) : undefined;
const fullInfo = chat && selectChatFullInfo(global, chat.id);
const hasLinkedChat = Boolean(fullInfo?.linkedChatId);
return {
chat,
hasLinkedChat,
};
},
)(PermissionCheckboxList));

View File

@ -31,7 +31,6 @@ import useMediaWithLoadProgress from '../../hooks/useMediaWithLoadProgress';
import useOldLang from '../../hooks/useOldLang';
import useZoomChange from './hooks/useZoomChangeSignal';
import DeleteMessageModal from '../common/DeleteMessageModal';
import DeleteProfilePhotoModal from '../common/DeleteProfilePhotoModal';
import Button from '../ui/Button';
import DropdownMenu from '../ui/DropdownMenu';
@ -90,6 +89,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
updateProfilePhoto,
updateChatPhoto,
openMediaViewer,
openDeleteMessageModal,
} = getActions();
const isMessage = item?.type === 'message';
@ -163,31 +163,16 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
);
}, []);
function renderDeleteModals() {
if (item?.type === 'message') {
return (
<DeleteMessageModal
isOpen={isDeleteModalOpen}
isSchedule={messageListType === 'scheduled'}
onClose={closeDeleteModal}
onConfirm={onBeforeDelete}
message={item.message}
/>
);
}
if (item?.type === 'avatar') {
return (
<DeleteProfilePhotoModal
isOpen={isDeleteModalOpen}
onClose={closeDeleteModal}
onConfirm={onBeforeDelete}
profileId={item.avatarOwner.id}
photo={item.avatarOwner.photos![item.mediaIndex!]}
/>
);
}
return undefined;
function renderDeleteModal() {
return (item?.type === 'avatar') ? (
<DeleteProfilePhotoModal
isOpen={isDeleteModalOpen}
onClose={closeDeleteModal}
onConfirm={onBeforeDelete}
profileId={item.avatarOwner.id}
photo={item.avatarOwner.photos![item.mediaIndex!]}
/>
) : undefined;
}
function renderDownloadButton() {
@ -223,6 +208,17 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
);
}
const openDeleteModalHandler = useLastCallback(() => {
if (item?.type === 'message') {
openDeleteMessageModal({
isSchedule: messageListType === 'scheduled',
message: item.message, onConfirm: onBeforeDelete,
});
} else {
openDeleteModal();
}
});
if (isMobile) {
const menuItems: MenuItemProps[] = [];
if (isMessage && item.message.isForwardingAllowed && !item.message.content.action && !isChatProtected) {
@ -268,7 +264,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
if (canDelete) {
menuItems.push({
icon: 'delete',
onClick: openDeleteModal,
onClick: openDeleteModalHandler,
children: lang('Delete'),
destructive: true,
});
@ -300,7 +296,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
))}
</DropdownMenu>
{isDownloading && <ProgressSpinner progress={downloadProgress} size="s" noCross />}
{canDelete && renderDeleteModals()}
{canDelete && renderDeleteModal()}
</div>
);
}
@ -365,7 +361,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
size="smaller"
color="translucent-white"
ariaLabel={lang('Delete')}
onClick={openDeleteModal}
onClick={openDeleteModalHandler}
>
<i className="icon icon-delete" />
</Button>
@ -379,7 +375,7 @@ const MediaViewerActions: FC<OwnProps & StateProps> = ({
>
<i className="icon icon-close" />
</Button>
{canDelete && renderDeleteModals()}
{canDelete && renderDeleteModal()}
</div>
);
};

View File

@ -0,0 +1,63 @@
.main {
max-height: 90vh;
}
.container {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.title {
margin: 0;
}
.actionTitle {
margin-top: 1.5rem;
margin-left: 0.5rem;
color: var(--color-links);
font-size: 1rem;
font-weight: 500;
}
.restrictionTitle {
margin-bottom: 1.5rem;
margin-top: 0;
}
.listItemButton {
margin-top: 0.1875rem;
margin-left: 0.4375rem;
}
.button {
color: var(--color-links) !important;
padding: 0 !important;
&:hover {
background: none !important;
}
}
.dropdownList {
padding-left: 3.1875rem;
}
.dialogButtons {
padding-bottom: 1rem;
}
.restrictionContainer,
.dropdownList {
overflow: hidden;
max-height: 0;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: max-height 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.restrictionContainerOpen,
.dropdownListOpen {
max-height: 100vh;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: max-height 0.6s ease-in-out;
}

View File

@ -1,26 +1,57 @@
import type { FC } from '../../lib/teact/teact';
import React, { memo, useEffect } from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import React, {
memo,
useEffect,
useMemo,
useState,
} from '../../lib/teact/teact';
import { getActions, getGlobal, withGlobal } from '../../global';
import type { ApiChat, ApiChatMember } from '../../api/types';
import type { IRadioOption } from '../ui/CheckboxGroup';
import {
getHasAdminRight,
getPrivateChatUserId,
getUserFirstOrLastName,
getUserFullName,
isChatBasicGroup,
isChatChannel,
isChatSuperGroup,
isUserId,
} from '../../global/helpers';
import {
selectCanDeleteSelectedMessages, selectCurrentChat, selectTabState, selectUser,
selectCanDeleteSelectedMessages,
selectChatFullInfo,
selectCurrentChat,
selectCurrentMessageIds,
selectCurrentMessageList,
selectSenderFromMessage,
selectSendersFromSelectedMessages,
selectTabState,
selectUser,
} from '../../global/selectors';
import buildClassName from '../../util/buildClassName';
import { buildCollectionByCallback } from '../../util/iteratees';
import renderText from '../common/helpers/renderText';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import usePrevious from '../../hooks/usePrevious';
import useManagePermissions from '../right/hooks/useManagePermissions';
import Avatar from '../common/Avatar';
import AvatarList from '../common/AvatarList';
import Icon from '../common/icons/Icon';
import PermissionCheckboxList from '../main/PermissionCheckboxList';
import Button from '../ui/Button';
import CheckboxGroup from '../ui/CheckboxGroup';
import ListItem from '../ui/ListItem';
import Modal from '../ui/Modal';
import styles from './DeleteSelectedMessageModal.module.scss';
export type OwnProps = {
isOpen: boolean;
isSchedule: boolean;
@ -28,39 +59,226 @@ export type OwnProps = {
};
type StateProps = {
chat?: ApiChat;
isChannel?: boolean;
isGroup?: boolean;
isSuperGroup?: boolean;
selectedMessageIds?: number[];
canDeleteForAll?: boolean;
contactName?: string;
currentUserId?: string;
willDeleteForCurrentUserOnly?: boolean;
willDeleteForAll?: boolean;
messageIds: number[] | undefined;
adminMembersById?: Record<string, ApiChatMember>;
canBanUsers?: boolean;
};
const DeleteSelectedMessageModal: FC<OwnProps & StateProps> = ({
chat,
isChannel,
isGroup,
isSuperGroup,
isOpen,
isSchedule,
currentUserId,
selectedMessageIds,
canDeleteForAll,
contactName,
willDeleteForCurrentUserOnly,
willDeleteForAll,
messageIds,
onClose,
adminMembersById,
canBanUsers,
}) => {
const {
deleteMessages,
reportMessages,
deleteChatMember,
deleteScheduledMessages,
exitMessageSelectMode,
updateChatMemberBannedRights,
} = getActions();
const prevIsOpen = usePrevious(isOpen);
const oldLang = useOldLang();
const lang = useLang();
const {
permissions, havePermissionChanged, handlePermissionChange, resetPermissions,
} = useManagePermissions(chat?.defaultBannedRights);
const [chosenDeleteOption, setChosenDeleteOption] = useState<string[] | undefined>(undefined);
const [chosenBanOption, setChosenBanOptions] = useState<string[] | undefined>(undefined);
const [chosenSpanOption, setChosenSpanOptions] = useState<string[] | undefined>(undefined);
const [isMediaDropdownOpen, setIsMediaDropdownOpen] = useState(false);
const [isAdditionalOptionsVisible, setIsAdditionalOptionsVisible] = useState(false);
const senderList = useMemo(() => {
return selectSendersFromSelectedMessages(getGlobal(), chat);
// eslint-disable-next-line react-hooks-static-deps/exhaustive-deps
}, [chat, isOpen]);
const isSenderOwner = useMemo(() => {
if (!senderList) {
return undefined;
}
return senderList.some((sender) => sender
&& adminMembersById
&& adminMembersById[sender.id] && adminMembersById[sender.id].isOwner);
}, [senderList, adminMembersById]);
const userList = useMemo(() => {
const usersById = getGlobal().users.byId;
if (!senderList || isSchedule) return [];
const uniqueUserIds = new Set(senderList.map((user) => user!.id));
return Array.from(uniqueUserIds)
.map((id) => usersById[id])
.filter(Boolean);
}, [isSchedule, senderList]);
const nestedOptionsWithAvatarList = useLastCallback(() => {
return userList.map((user) => ({
value: `${user.id}`,
label: getUserFullName(user) || '',
leftElement: <Avatar size="small" peer={user} />,
}));
});
const showAdditionalOptions = useMemo(() => {
return !userList.some((user) => user.id === currentUserId);
}, [userList, currentUserId]);
const userNames = useMemo(() => {
const usersById = getGlobal().users.byId;
if (!senderList || isSchedule) return {};
const uniqueUserIds = new Set(senderList.map((user) => user!.id));
const userIds = Array.from(uniqueUserIds).map((userId) => usersById[userId]);
return buildCollectionByCallback(userIds, (user) => [user.id, getUserFullName(user)]);
}, [isSchedule, senderList]);
const ACTION_SPAM_OPTION: IRadioOption[] = useMemo(() => {
return [
{
value: selectedMessageIds && userList.length >= 2 ? 'spam' : userList?.[0]?.id,
label: oldLang('ReportSpamTitle'),
nestedOptions: selectedMessageIds && userList.length >= 2 ? [
...nestedOptionsWithAvatarList(),
] : undefined,
},
];
}, [oldLang, selectedMessageIds, userList]);
const ACTION_DELETE_OPTION: IRadioOption[] = useMemo(() => {
return [
{
value: selectedMessageIds && userList.length >= 2 ? 'delete_all' : userList?.[0]?.id,
label: selectedMessageIds && userList.length >= 2
? oldLang('DeleteAllFromUsers')
: oldLang('DeleteAllFrom', Object.values(userNames)[0]),
nestedOptions: selectedMessageIds && userList.length >= 2 ? [
...nestedOptionsWithAvatarList(),
] : undefined,
},
];
}, [oldLang, selectedMessageIds, userList, userNames]);
const ACTION_BAN_OPTION: IRadioOption[] = useMemo(() => {
return [
{
value: selectedMessageIds && userList.length >= 2 ? 'ban' : userList?.[0]?.id,
label: selectedMessageIds && userList.length >= 2
? (isAdditionalOptionsVisible ? oldLang('DeleteRestrictUsers') : oldLang('DeleteBanUsers'))
: (isAdditionalOptionsVisible ? oldLang('KickFromSupergroup')
: oldLang('DeleteBan', Object.values(userNames)[0])),
nestedOptions: selectedMessageIds && userList.length >= 2 ? [
...nestedOptionsWithAvatarList(),
] : undefined,
},
];
}, [isAdditionalOptionsVisible, oldLang, selectedMessageIds, userList, userNames]);
const toggleAdditionalOptions = useLastCallback(() => {
setIsAdditionalOptionsVisible((prev) => !prev);
});
const handleDeleteMessageForAll = useLastCallback(() => {
onClose();
deleteMessages({ messageIds: selectedMessageIds!, shouldDeleteForAll: true });
});
const filterMessageIdByUserId = useLastCallback((userIds: string[], selectedMessageIdList: number[]) => {
return selectedMessageIdList.filter((msgId) => {
const sender = selectSenderFromMessage(getGlobal(), chat, msgId);
return sender && userIds.includes(sender.id);
});
});
const handleDeleteMessages = useLastCallback((filteredMessageIdList: number[]) => {
if (filteredMessageIdList && filteredMessageIdList.length) {
deleteMessages({ messageIds: filteredMessageIdList, shouldDeleteForAll: true });
}
});
const handleDeleteMember = useLastCallback((filteredUserIdList: string[]) => {
filteredUserIdList.forEach((userId) => {
deleteChatMember({ chatId: chat!.id, userId });
});
});
const handleUpdateChatMemberBannedRights = useLastCallback((filteredUserIdList: string[]) => {
filteredUserIdList.forEach((userId) => {
updateChatMemberBannedRights({
chatId: chat!.id,
userId,
bannedRights: permissions,
});
});
});
const handleDeleteMessageForSelf = useLastCallback(() => {
if (isSchedule) {
deleteScheduledMessages({ messageIds: selectedMessageIds! });
} else if (!isSenderOwner && (isChannel || isSuperGroup)) {
if (chosenSpanOption) {
const userIdList = chosenSpanOption.filter((option) => !Number.isNaN(Number(option)));
const filteredMessageIdList = filterMessageIdByUserId(userIdList, selectedMessageIds!);
if (filteredMessageIdList && filteredMessageIdList.length) {
reportMessages({ messageIds: filteredMessageIdList, reason: 'spam', description: '' });
}
}
if (chosenDeleteOption) {
const userIdList = chosenDeleteOption.filter((option) => !Number.isNaN(Number(option)));
const filteredMessageIdList = filterMessageIdByUserId(userIdList, messageIds!);
handleDeleteMessages(filteredMessageIdList);
}
if (chosenBanOption && !havePermissionChanged) {
const userIdList = chosenBanOption.filter((option) => !Number.isNaN(Number(option)));
const filteredUserIdList = userIdList.filter((userId) => selectedMessageIds?.some((msgId) => {
const sender = selectSenderFromMessage(getGlobal(), chat, msgId);
return sender && sender.id === userId;
}));
handleDeleteMember(filteredUserIdList);
const filteredMessageIdList = filterMessageIdByUserId(userIdList, selectedMessageIds!);
handleDeleteMessages(filteredMessageIdList);
}
if (chosenBanOption && havePermissionChanged) {
const userIdList = chosenBanOption.filter((option) => !Number.isNaN(Number(option)));
const filteredUserIdList = userIdList.filter((userId) => selectedMessageIds?.some((msgId) => {
const sender = selectSenderFromMessage(getGlobal(), chat, msgId);
return sender && sender.id === userId;
}));
handleUpdateChatMemberBannedRights(filteredUserIdList);
}
} else {
deleteMessages({ messageIds: selectedMessageIds!, shouldDeleteForAll: false });
}
@ -68,14 +286,97 @@ const DeleteSelectedMessageModal: FC<OwnProps & StateProps> = ({
onClose();
});
const lang = useOldLang();
const onCloseHandler = useLastCallback(() => {
onClose();
});
const handleDeleteOptionChange = useLastCallback((options: string[]) => {
setChosenDeleteOption(options);
});
const handleBanOptionChange = useLastCallback((options: string[]) => {
setChosenBanOptions(options);
});
const handleSpanOptionChange = useLastCallback((options: string[]) => {
setChosenSpanOptions(options);
});
// Returning `undefined` from FC instead of `<Modal>` doesn't trigger useHistoryBack
useEffect(() => {
if (!isOpen && prevIsOpen) {
exitMessageSelectMode();
setChosenSpanOptions(undefined);
setChosenDeleteOption(undefined);
setChosenBanOptions(undefined);
setIsMediaDropdownOpen(false);
setIsAdditionalOptionsVisible(false);
resetPermissions();
}
}, [exitMessageSelectMode, isOpen, prevIsOpen]);
}, [exitMessageSelectMode, isOpen, prevIsOpen, resetPermissions]);
function renderHeader() {
return (
<div className={styles.container} dir={oldLang.isRtl ? 'rtl' : undefined}>
{(showAdditionalOptions && !canDeleteForAll && !isSchedule) && (
<AvatarList
size="small"
peers={userList}
/>
)}
<h3 className={styles.title}>{oldLang('Chat.DeleteMessagesConfirmation', selectedMessageIds?.length)}
</h3>
</div>
);
}
function renderAdditionalActionOptions() {
return (
<div className={styles.options}>
<CheckboxGroup
options={ACTION_SPAM_OPTION}
onChange={handleSpanOptionChange}
selected={chosenSpanOption}
nestedCheckbox={selectedMessageIds && userList.length >= 2}
/>
<CheckboxGroup
options={ACTION_DELETE_OPTION}
onChange={handleDeleteOptionChange}
selected={chosenDeleteOption}
nestedCheckbox={selectedMessageIds && userList.length >= 2}
/>
{!isSenderOwner && canBanUsers && (
<CheckboxGroup
options={ACTION_BAN_OPTION}
onChange={handleBanOptionChange}
selected={chosenBanOption}
nestedCheckbox={selectedMessageIds && userList.length >= 2}
/>
)}
</div>
);
}
function renderPartiallyRestrictedUser() {
return (
<div className={buildClassName(styles.restrictionContainer,
isAdditionalOptionsVisible && styles.restrictionContainerOpen)}
>
<h3 className={buildClassName(styles.actionTitle, styles.restrictionTitle)}>
{oldLang('UserRestrictionsCanDoUsers', userList.length)}
</h3>
<PermissionCheckboxList
withCheckbox
permissionGroup
chatId={chat?.id}
isMediaDropdownOpen={isMediaDropdownOpen}
setIsMediaDropdownOpen={setIsMediaDropdownOpen}
handlePermissionChange={handlePermissionChange}
permissions={permissions}
className={buildClassName(styles.dropdownList, isMediaDropdownOpen && styles.dropdownListOpen)}
/>
</div>
);
}
if (!selectedMessageIds) {
return undefined;
@ -87,27 +388,50 @@ const DeleteSelectedMessageModal: FC<OwnProps & StateProps> = ({
onClose={onClose}
onEnter={canDeleteForAll ? undefined : handleDeleteMessageForSelf}
className="delete"
title={lang('Conversation.DeleteManyMessages')}
>
<p>{lang('AreYouSureDeleteFewMessages')}</p>
{willDeleteForCurrentUserOnly && (
<p>This will delete them just for you, not for other participants in the chat.</p>
)}
{willDeleteForAll && (
<p>This will delete them for everyone in this chat.</p>
)}
<div className={canDeleteForAll ? 'dialog-buttons-column' : 'dialog-buttons'}>
{canDeleteForAll && (
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForAll}>
{contactName
? renderText(lang('ChatList.DeleteForEveryone', contactName))
: lang('Conversation.DeleteMessagesForEveryone')}
</Button>
<div className={styles.main}>
{renderHeader()}
{!showAdditionalOptions && <p>{lang('AreYouSureDeleteFewMessages')}</p>}
{(showAdditionalOptions && !canDeleteForAll && !isSchedule && (isChannel || isGroup || isSuperGroup)) && (
<>
<p className={styles.actionTitle}>{oldLang('DeleteAdditionalActions')}</p>
{renderAdditionalActionOptions()}
{renderPartiallyRestrictedUser()}
{
chosenBanOption && canBanUsers && chosenBanOption?.length ? (
<ListItem
narrow
className={styles.listItemButton}
buttonClassName={styles.button}
onClick={toggleAdditionalOptions}
>
{oldLang(isAdditionalOptionsVisible ? 'DeleteToggleBanUsers' : 'DeleteToggleRestrictUsers')}
<Icon
name={isAdditionalOptionsVisible ? 'up' : 'down'}
className={buildClassName(styles.button, 'ml-2')}
/>
</ListItem>
) : setIsAdditionalOptionsVisible(false)
}
</>
)}
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForSelf}>
{lang(canDeleteForAll ? 'ChatList.DeleteForCurrentUser' : 'Delete')}
</Button>
<Button className="confirm-dialog-button" isText onClick={onClose}>{lang('Cancel')}</Button>
{willDeleteForCurrentUserOnly && lang('DeleteForMeDescription')}
{(willDeleteForAll && !showAdditionalOptions) && lang('DeleteForEveryoneDescription')}
<div className={canDeleteForAll ? 'dialog-buttons-column'
: buildClassName('dialog-buttons', isAdditionalOptionsVisible && styles.dialogButtons)}
>
{canDeleteForAll && (
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForAll}>
{contactName
? renderText(oldLang('ChatList.DeleteForEveryone', contactName))
: oldLang('Conversation.DeleteMessagesForEveryone')}
</Button>
)}
<Button color="danger" className="confirm-dialog-button" isText onClick={handleDeleteMessageForSelf}>
{oldLang(canDeleteForAll ? 'ChatList.DeleteForCurrentUser' : 'Delete')}
</Button>
<Button className="confirm-dialog-button" isText onClick={onCloseHandler}>{oldLang('Cancel')}</Button>
</div>
</div>
</Modal>
);
@ -118,19 +442,34 @@ export default memo(withGlobal<OwnProps>(
const { messageIds: selectedMessageIds } = selectTabState(global).selectedMessages || {};
const { canDeleteForAll } = selectCanDeleteSelectedMessages(global);
const chat = selectCurrentChat(global);
const chatFullInfo = chat && selectChatFullInfo(global, chat.id);
const { threadId, type } = selectCurrentMessageList(global) || {};
const messageIds = chat && selectCurrentMessageIds(global, chat.id, threadId!, type!);
const isChannel = Boolean(chat) && isChatChannel(chat);
const isGroup = Boolean(chat) && isChatBasicGroup(chat);
const isSuperGroup = Boolean(chat) && isChatSuperGroup(chat);
const contactName = chat && isUserId(chat.id)
? getUserFirstOrLastName(selectUser(global, getPrivateChatUserId(chat)!))
: undefined;
const adminMembersById = chatFullInfo?.adminMembersById;
const canBanUsers = chat && (chat.isCreator || getHasAdminRight(chat, 'banUsers'));
const willDeleteForCurrentUserOnly = chat && isChatBasicGroup(chat) && !canDeleteForAll;
const willDeleteForAll = chat && isChatSuperGroup(chat);
return {
chat,
isChannel,
isGroup,
isSuperGroup,
selectedMessageIds,
currentUserId: global.currentUserId,
canDeleteForAll: !isSchedule && canDeleteForAll,
contactName,
willDeleteForCurrentUserOnly,
willDeleteForAll,
messageIds,
adminMembersById,
canBanUsers,
};
},
)(DeleteSelectedMessageModal));

View File

@ -29,14 +29,15 @@ const useEditing = (
setHtml: (html: string) => void,
editedMessage: ApiMessage | undefined,
resetComposer: (shouldPreserveInput?: boolean) => void,
openDeleteModal: () => void,
chatId: string,
threadId: ThreadId,
type: MessageListType,
draft?: ApiDraft,
editingDraft?: ApiFormattedText,
): [VoidFunction, VoidFunction, boolean] => {
const { editMessage, setEditingDraft, toggleMessageWebPage } = getActions();
const {
editMessage, setEditingDraft, toggleMessageWebPage, openDeleteMessageModal,
} = getActions();
const [shouldForceShowEditing, setShouldForceShowEditing] = useState(false);
const replyingToId = draft?.replyInfo?.replyToMsgId;
@ -152,7 +153,7 @@ const useEditing = (
}
if (!text && !hasMessageMedia(editedMessage)) {
openDeleteModal();
openDeleteMessageModal({ isSchedule: type === 'scheduled', message: editedMessage });
return;
}

View File

@ -64,7 +64,6 @@ import useOldLang from '../../../hooks/useOldLang';
import useSchedule from '../../../hooks/useSchedule';
import useShowTransition from '../../../hooks/useShowTransition';
import DeleteMessageModal from '../../common/DeleteMessageModal';
import PinMessageModal from '../../common/PinMessageModal.async';
import ReportModal from '../../common/ReportModal';
import ConfirmDialog from '../../ui/ConfirmDialog';
@ -222,12 +221,12 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
openPremiumModal,
loadOutboxReadDate,
copyMessageLink,
openDeleteMessageModal,
} = getActions();
const lang = useOldLang();
const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false);
const [isMenuOpen, setIsMenuOpen] = useState(true);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [isPinModalOpen, setIsPinModalOpen] = useState(false);
const [isClosePollDialogOpen, openClosePollDialog, closeClosePollDialog] = useFlag();
@ -333,24 +332,20 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
isMessageTranslated, message.content.text,
]);
const handleDelete = useLastCallback(() => {
setIsMenuOpen(false);
setIsDeleteModalOpen(true);
});
const handleReport = useLastCallback(() => {
setIsMenuOpen(false);
setIsReportModalOpen(true);
});
const closeMenu = useLastCallback(() => {
setIsMenuOpen(false);
onClose();
});
const closeDeleteModal = useLastCallback(() => {
setIsDeleteModalOpen(false);
onClose();
const handleDelete = useLastCallback(() => {
setIsMenuOpen(false);
closeMenu();
openDeleteMessageModal({ isSchedule: messageListType === 'scheduled', album, message });
});
const handleReport = useLastCallback(() => {
setIsMenuOpen(false);
setIsReportModalOpen(true);
});
const closeReportModal = useLastCallback(() => {
@ -639,13 +634,6 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
onShowOriginal={handleShowOriginal}
onSelectLanguage={handleSelectLanguage}
/>
<DeleteMessageModal
isOpen={isDeleteModalOpen}
isSchedule={messageListType === 'scheduled'}
onClose={closeDeleteModal}
album={album}
message={message}
/>
<ReportModal
isOpen={isReportModalOpen}
onClose={closeReportModal}

View File

@ -109,11 +109,17 @@ export default function useManagePermissions(defaultPermissions: ApiChatBannedRi
}));
}, [defaultPermissions, permissions]);
const resetPermissions = useCallback(() => {
setPermissions(defaultPermissions || {});
setHavePermissionChanged(false);
}, [defaultPermissions]);
return {
permissions,
isLoading,
havePermissionChanged,
handlePermissionChange,
setIsLoading,
resetPermissions,
};
}

View File

@ -7,17 +7,15 @@ import { getActions, withGlobal } from '../../../global';
import type { ApiChat, ApiChatBannedRights, ApiChatMember } from '../../../api/types';
import { ManagementScreens } from '../../../types';
import { isChatPublic } from '../../../global/helpers';
import { selectChat, selectChatFullInfo } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import stopEvent from '../../../util/stopEvent';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useOldLang from '../../../hooks/useOldLang';
import useManagePermissions from '../hooks/useManagePermissions';
import PrivateChatInfo from '../../common/PrivateChatInfo';
import Checkbox from '../../ui/Checkbox';
import PermissionCheckboxList from '../../main/PermissionCheckboxList';
import FloatingActionButton from '../../ui/FloatingActionButton';
import ListItem from '../../ui/ListItem';
import Spinner from '../../ui/Spinner';
@ -33,7 +31,6 @@ type OwnProps = {
type StateProps = {
chat?: ApiChat;
currentUserId?: string;
hasLinkedChat?: boolean;
removedUsersCount: number;
members?: ApiChatMember[];
};
@ -86,21 +83,17 @@ const ManageGroupPermissions: FC<OwnProps & StateProps> = ({
onChatMemberSelect,
chat,
currentUserId,
hasLinkedChat,
removedUsersCount,
members,
onClose,
isActive,
}) => {
const { updateChatDefaultBannedRights, showNotification } = getActions();
const { updateChatDefaultBannedRights } = getActions();
const {
permissions, havePermissionChanged, isLoading, handlePermissionChange, setIsLoading,
} = useManagePermissions(chat?.defaultBannedRights);
const lang = useOldLang();
const { isForum } = chat || {};
const isPublic = useMemo(() => chat && isChatPublic(chat), [chat]);
const shouldDisablePermissionForPublicGroup = hasLinkedChat || isPublic;
useHistoryBack({
isActive,
@ -121,14 +114,6 @@ const ManageGroupPermissions: FC<OwnProps & StateProps> = ({
}, [currentUserId, onChatMemberSelect, onScreenSelect]);
const [isMediaDropdownOpen, setIsMediaDropdownOpen] = useState(false);
const handleOpenMediaDropdown = useCallback((e: React.MouseEvent) => {
stopEvent(e);
setIsMediaDropdownOpen(!isMediaDropdownOpen);
}, [isMediaDropdownOpen]);
const handleDisabledClick = useCallback(() => {
showNotification({ message: lang('lng_rights_permission_unavailable') });
}, [lang, showNotification]);
const handleSavePermissions = useCallback(() => {
if (!chat) {
@ -186,174 +171,19 @@ const ManageGroupPermissions: FC<OwnProps & StateProps> = ({
<div className="custom-scroll">
<div className="section without-bottom-shadow">
<h3 className="section-heading" dir="auto">{lang('ChannelPermissionsHeader')}</h3>
<div className="ListItem with-checkbox">
<Checkbox
name="sendPlain"
checked={!permissions.sendPlain}
label={lang('UserRestrictionsSend')}
blocking
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem with-checkbox">
<Checkbox
name="sendMedia"
checked={!permissions.sendMedia}
label={lang('UserRestrictionsSendMedia')}
blocking
rightIcon={isMediaDropdownOpen ? 'up' : 'down'}
onChange={handlePermissionChange}
onClickLabel={handleOpenMediaDropdown}
/>
</div>
<div className="DropdownListTrap">
<div
className={buildClassName(
'DropdownList',
isMediaDropdownOpen && 'DropdownList--open',
)}
>
<div className="ListItem with-checkbox">
<Checkbox
name="sendPhotos"
checked={!permissions.sendPhotos}
label={lang('UserRestrictionsSendPhotos')}
blocking
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem with-checkbox">
<Checkbox
name="sendVideos"
checked={!permissions.sendVideos}
label={lang('UserRestrictionsSendVideos')}
blocking
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem with-checkbox">
<Checkbox
name="sendStickers"
checked={!permissions.sendStickers && !permissions.sendGifs}
label={lang('UserRestrictionsSendStickers')}
blocking
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem with-checkbox">
<Checkbox
name="sendAudios"
checked={!permissions.sendAudios}
label={lang('UserRestrictionsSendMusic')}
blocking
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem with-checkbox">
<Checkbox
name="sendDocs"
checked={!permissions.sendDocs}
label={lang('UserRestrictionsSendFiles')}
blocking
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem with-checkbox">
<Checkbox
name="sendVoices"
checked={!permissions.sendVoices}
label={lang('UserRestrictionsSendVoices')}
blocking
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem with-checkbox">
<Checkbox
name="sendRoundvideos"
checked={!permissions.sendRoundvideos}
label={lang('UserRestrictionsSendRound')}
blocking
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem with-checkbox">
<Checkbox
name="embedLinks"
checked={!permissions.embedLinks}
label={lang('UserRestrictionsEmbedLinks')}
blocking
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem with-checkbox">
<Checkbox
name="sendPolls"
checked={!permissions.sendPolls}
label={lang('UserRestrictionsSendPolls')}
blocking
onChange={handlePermissionChange}
/>
</div>
</div>
</div>
<div className={buildClassName('part', isMediaDropdownOpen && 'shifted')}>
<div className="ListItem with-checkbox">
<Checkbox
name="inviteUsers"
checked={!permissions.inviteUsers}
label={lang('UserRestrictionsInviteUsers')}
blocking
onChange={handlePermissionChange}
/>
</div>
<div
className="ListItem with-checkbox"
onClick={shouldDisablePermissionForPublicGroup ? handleDisabledClick : undefined}
>
<Checkbox
name="pinMessages"
checked={!permissions.pinMessages}
label={lang('UserRestrictionsPinMessages')}
disabled={shouldDisablePermissionForPublicGroup}
blocking
onChange={handlePermissionChange}
/>
</div>
<div
className="ListItem with-checkbox"
onClick={shouldDisablePermissionForPublicGroup ? handleDisabledClick : undefined}
>
<Checkbox
name="changeInfo"
checked={!permissions.changeInfo}
label={lang('UserRestrictionsChangeInfo')}
blocking
disabled={shouldDisablePermissionForPublicGroup}
onChange={handlePermissionChange}
/>
</div>
{isForum && (
<div className="ListItem with-checkbox">
<Checkbox
name="manageTopics"
checked={!permissions.manageTopics}
label={lang('CreateTopicsPermission')}
blocking
onChange={handlePermissionChange}
/>
</div>
<PermissionCheckboxList
chatId={chat?.id}
isMediaDropdownOpen={isMediaDropdownOpen}
setIsMediaDropdownOpen={setIsMediaDropdownOpen}
handlePermissionChange={handlePermissionChange}
permissions={permissions}
dropdownClassName="DropdownListTrap"
className={buildClassName(
'DropdownList',
isMediaDropdownOpen && 'DropdownList--open',
)}
</div>
shiftedClassName={buildClassName('part', isMediaDropdownOpen && 'shifted')}
/>
</div>
<div
@ -425,12 +255,10 @@ export default memo(withGlobal<OwnProps>(
(global, { chatId }): StateProps => {
const chat = selectChat(global, chatId);
const fullInfo = selectChatFullInfo(global, chatId);
const hasLinkedChat = Boolean(fullInfo?.linkedChatId);
return {
chat,
currentUserId: global.currentUserId,
hasLinkedChat,
removedUsersCount: fullInfo?.kickedMembers?.length || 0,
members: fullInfo?.members,
};

View File

@ -9,15 +9,15 @@ import { ManagementScreens } from '../../../types';
import { selectChat, selectChatFullInfo } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import stopEvent from '../../../util/stopEvent';
import useFlag from '../../../hooks/useFlag';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useLang from '../../../hooks/useLang';
import useOldLang from '../../../hooks/useOldLang';
import useManagePermissions from '../hooks/useManagePermissions';
import PrivateChatInfo from '../../common/PrivateChatInfo';
import Checkbox from '../../ui/Checkbox';
import PermissionCheckboxList from '../../main/PermissionCheckboxList';
import ConfirmDialog from '../../ui/ConfirmDialog';
import FloatingActionButton from '../../ui/FloatingActionButton';
import ListItem from '../../ui/ListItem';
@ -69,9 +69,8 @@ const ManageGroupUserPermissions: FC<OwnProps & StateProps> = ({
permissions, havePermissionChanged, isLoading, handlePermissionChange, setIsLoading,
} = useManagePermissions(selectedChatMember?.bannedRights || chat?.defaultBannedRights);
const [isBanConfirmationDialogOpen, openBanConfirmationDialog, closeBanConfirmationDialog] = useFlag();
const lang = useOldLang();
const { isForum } = chat || {};
const lang = useLang();
const oldLang = useOldLang();
useHistoryBack({
isActive,
@ -124,10 +123,6 @@ const ManageGroupUserPermissions: FC<OwnProps & StateProps> = ({
}, [chat, isFormFullyDisabled]);
const [isMediaDropdownOpen, setIsMediaDropdownOpen] = useState(false);
const handleOpenMediaDropdown = useCallback((e: React.MouseEvent) => {
stopEvent(e);
setIsMediaDropdownOpen(!isMediaDropdownOpen);
}, [isMediaDropdownOpen]);
if (!selectedChatMember) {
return undefined;
@ -145,185 +140,21 @@ const ManageGroupUserPermissions: FC<OwnProps & StateProps> = ({
<PrivateChatInfo userId={selectedChatMember.userId} forceShowSelf />
</ListItem>
<h3 className="section-heading mt-4" dir="auto">{lang('UserRestrictionsCanDo')}</h3>
<div className="ListItem">
<Checkbox
name="sendPlain"
checked={!permissions.sendPlain}
label={lang('UserRestrictionsSend')}
blocking
disabled={getControlIsDisabled('sendPlain')}
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem">
<Checkbox
name="sendMedia"
checked={!permissions.sendMedia}
label={lang('UserRestrictionsSendMedia')}
blocking
rightIcon={isMediaDropdownOpen ? 'up' : 'down'}
disabled={getControlIsDisabled('sendMedia')}
onChange={handlePermissionChange}
onClickLabel={handleOpenMediaDropdown}
/>
</div>
<div className="DropdownListTrap">
<div
className={buildClassName(
'DropdownList',
isMediaDropdownOpen && 'DropdownList--open',
)}
>
<div className="ListItem">
<Checkbox
name="sendPhotos"
checked={!permissions.sendPhotos}
label={lang('UserRestrictionsSendPhotos')}
blocking
disabled={getControlIsDisabled('sendPhotos')}
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem">
<Checkbox
name="sendVideos"
checked={!permissions.sendVideos}
label={lang('UserRestrictionsSendVideos')}
blocking
disabled={getControlIsDisabled('sendVideos')}
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem">
<Checkbox
name="sendStickers"
checked={!permissions.sendStickers && !permissions.sendGifs}
label={lang('UserRestrictionsSendStickers')}
blocking
disabled={getControlIsDisabled('sendStickers')}
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem">
<Checkbox
name="sendAudios"
checked={!permissions.sendAudios}
label={lang('UserRestrictionsSendMusic')}
blocking
disabled={getControlIsDisabled('sendAudios')}
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem">
<Checkbox
name="sendDocs"
checked={!permissions.sendDocs}
label={lang('UserRestrictionsSendFiles')}
blocking
disabled={getControlIsDisabled('sendDocs')}
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem">
<Checkbox
name="sendVoices"
checked={!permissions.sendVoices}
label={lang('UserRestrictionsSendVoices')}
blocking
disabled={getControlIsDisabled('sendVoices')}
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem">
<Checkbox
name="sendRoundvideos"
checked={!permissions.sendRoundvideos}
label={lang('UserRestrictionsSendRound')}
blocking
disabled={getControlIsDisabled('sendRoundvideos')}
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem">
<Checkbox
name="embedLinks"
checked={!permissions.embedLinks}
label={lang('UserRestrictionsEmbedLinks')}
blocking
disabled={getControlIsDisabled('embedLinks')}
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem">
<Checkbox
name="sendPolls"
checked={!permissions.sendPolls}
label={lang('UserRestrictionsSendPolls')}
blocking
disabled={getControlIsDisabled('sendPolls')}
onChange={handlePermissionChange}
/>
</div>
</div>
</div>
<div className={buildClassName('part', isMediaDropdownOpen && 'shifted')}>
<div className="ListItem">
<Checkbox
name="inviteUsers"
checked={!permissions.inviteUsers}
label={lang('UserRestrictionsInviteUsers')}
blocking
disabled={getControlIsDisabled('inviteUsers')}
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem">
<Checkbox
name="pinMessages"
checked={!permissions.pinMessages}
label={lang('UserRestrictionsPinMessages')}
blocking
disabled={getControlIsDisabled('pinMessages')}
onChange={handlePermissionChange}
/>
</div>
<div className="ListItem">
<Checkbox
name="changeInfo"
checked={!permissions.changeInfo}
label={lang('UserRestrictionsChangeInfo')}
blocking
disabled={getControlIsDisabled('changeInfo')}
onChange={handlePermissionChange}
/>
</div>
{isForum && (
<div className="ListItem">
<Checkbox
name="manageTopics"
checked={!permissions.manageTopics}
label={lang('CreateTopicsPermission')}
blocking
disabled={getControlIsDisabled('manageTopics')}
onChange={handlePermissionChange}
/>
</div>
<h3 className="section-heading mt-4" dir="auto">{oldLang('UserRestrictionsCanDo')}</h3>
<PermissionCheckboxList
chatId={chat?.id}
isMediaDropdownOpen={isMediaDropdownOpen}
setIsMediaDropdownOpen={setIsMediaDropdownOpen}
handlePermissionChange={handlePermissionChange}
permissions={permissions}
className={buildClassName(
'DropdownList',
isMediaDropdownOpen && 'DropdownList--open',
)}
</div>
dropdownClassName="DropdownListTrap"
shiftedClassName={buildClassName('part', isMediaDropdownOpen && 'shifted')}
getControlIsDisabled={getControlIsDisabled}
/>
</div>
{!isFormFullyDisabled && (
@ -334,7 +165,7 @@ const ManageGroupUserPermissions: FC<OwnProps & StateProps> = ({
)}
>
<ListItem icon="delete-user" ripple destructive onClick={openBanConfirmationDialog}>
{lang('UserRestrictionsBlock')}
{oldLang('UserRestrictionsBlock')}
</ListItem>
</div>
)}
@ -343,7 +174,7 @@ const ManageGroupUserPermissions: FC<OwnProps & StateProps> = ({
<FloatingActionButton
isShown={havePermissionChanged}
onClick={handleSavePermissions}
ariaLabel={lang('Save')}
ariaLabel={oldLang('Save')}
disabled={isLoading}
>
{isLoading ? (
@ -356,7 +187,7 @@ const ManageGroupUserPermissions: FC<OwnProps & StateProps> = ({
<ConfirmDialog
isOpen={isBanConfirmationDialogOpen}
onClose={closeBanConfirmationDialog}
text="Are you sure you want to ban and remove this user from the group?"
text={lang('GroupManagementBanUserConfirm')}
confirmLabel="Remove"
confirmHandler={handleBanFromGroup}
confirmIsDestructive

View File

@ -23,7 +23,7 @@
var(--color-primary)
url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTIiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PHBhdGggZD0iTTEzLjkuOEw1LjggOC45IDIuMSA1LjJjLS40LS40LTEuMS0uNC0xLjYgMC0uNC40LS40IDEuMSAwIDEuNkw1IDExLjJjLjQuNCAxLjEuNCAxLjYgMGw4LjktOC45Yy40LS40LjQtMS4xIDAtMS42LS41LS40LTEuMi0uNC0xLjYuMXoiIGZpbGw9IiNGRkYiLz48L3N2Zz4=)
no-repeat 50% 50%;
background-size: 12px;
background-size: 0.75rem;
border-radius: 50%;
}
}
@ -75,14 +75,14 @@
content: "";
display: block;
position: absolute;
left: 1.1875rem;
left: 0.6875rem;
top: 0.1875rem;
width: 1.125rem;
height: 1.125rem;
}
&::before {
border: 2px solid var(--color-borders-input);
border: 0.125rem solid var(--color-borders-input);
border-radius: 0.25rem;
background-color: var(--color-background);
transition: border-color 0.1s ease, background-color 0.1s ease;
@ -120,6 +120,24 @@
}
}
.Nested-avatar-list {
&::before,
&::after {
pointer-events: none;
content: "";
display: block;
position: absolute;
left: 4.3125rem;
top: 0.625rem;
width: 1.125rem;
height: 1.125rem;
}
.label {
column-gap: 0.6875rem;
}
}
input:checked ~ .Checkbox-main {
&::before {
border-color: var(--color-primary);
@ -155,4 +173,64 @@
}
}
}
&.nested {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.3125rem;
margin-bottom: 0.5625rem;
padding-left: 4.1875rem;
.Checkbox-main {
&::before,
&::after {
top: 0.875rem;
left: 0.75rem;
}
}
}
&.permission-group {
padding-left: 4rem;
.Checkbox-main {
&::before,
&::after {
left: 0.75rem;
}
}
}
&.avatar {
padding-left: 6.625rem;
margin-bottom: 1.125rem;
}
.button {
color: var(--color-text);
display: flex;
gap: 0.1875rem;
}
.group-icon {
font-size: 0.75rem;
}
&.nested-checkbox-group &.Checkbox-main::before,
&.nested-checkbox-group &.Checkbox-main::after {
top: 1.875rem;
left: 2.875rem;
}
}
.nested-checkbox-group {
overflow: hidden;
max-height: 0;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: max-height 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.nested-checkbox-group-open {
max-height: 100vh;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: max-height 0.6s ease-in-out;
}

View File

@ -1,14 +1,22 @@
import type { ChangeEvent } from 'react';
import type { FC, TeactNode } from '../../lib/teact/teact';
import React, { memo, useCallback, useRef } from '../../lib/teact/teact';
import React, {
memo,
useRef,
useState,
} from '../../lib/teact/teact';
import type { IconName } from '../../types/icons';
import type { IRadioOption } from './CheckboxGroup';
import buildClassName from '../../util/buildClassName';
import renderText from '../common/helpers/renderText';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang';
import Icon from '../common/icons/Icon';
import Button from './Button';
import Spinner from './Spinner';
import './Checkbox.scss';
@ -18,6 +26,7 @@ type OwnProps = {
name?: string;
value?: string;
label: TeactNode;
labelText?: TeactNode;
subLabel?: string;
checked?: boolean;
rightIcon?: IconName;
@ -25,12 +34,18 @@ type OwnProps = {
tabIndex?: number;
round?: boolean;
blocking?: boolean;
permissionGroup?: boolean;
isLoading?: boolean;
withCheckedCallback?: boolean;
className?: string;
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
onChange?: (e: ChangeEvent<HTMLInputElement>, nestedOptionList?: IRadioOption) => void;
onCheck?: (isChecked: boolean) => void;
onClickLabel?: (e: React.MouseEvent, value?: string) => void;
nestedCheckbox?: boolean;
nestedCheckboxCount?: number | undefined;
nestedOptionList?: IRadioOption;
leftElement?: TeactNode;
values?: string[];
};
const Checkbox: FC<OwnProps> = ({
@ -38,37 +53,48 @@ const Checkbox: FC<OwnProps> = ({
name,
value,
label,
labelText,
subLabel,
checked,
tabIndex,
disabled,
round,
blocking,
permissionGroup,
isLoading,
className,
rightIcon,
onChange,
onCheck,
onClickLabel,
nestedCheckbox,
nestedCheckboxCount,
nestedOptionList,
leftElement,
values = [],
}) => {
const lang = useOldLang();
// eslint-disable-next-line no-null/no-null
const labelRef = useRef<HTMLLabelElement>(null);
const [showNested, setShowNested] = useState(false);
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const handleChange = useLastCallback((event: ChangeEvent<HTMLInputElement>) => {
if (disabled) {
return;
}
if (onChange) {
onChange(event);
onChange(event, nestedOptionList);
}
if (onCheck) {
onCheck(event.currentTarget.checked);
}
}, [disabled, onChange, onCheck]);
});
const toggleNested = useLastCallback(() => {
setShowNested(!showNested);
});
function handleClick(event: React.MouseEvent) {
if (event.target !== labelRef.current) {
@ -86,37 +112,70 @@ const Checkbox: FC<OwnProps> = ({
round && 'round',
isLoading && 'loading',
blocking && 'blocking',
nestedCheckbox && 'nested',
permissionGroup && 'permission-group',
Boolean(leftElement) && 'avatar',
className,
);
return (
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
<label
className={labelClassName}
dir={lang.isRtl ? 'rtl' : undefined}
onClick={onClickLabel ? handleClick : undefined}
ref={labelRef}
>
<input
type="checkbox"
id={id}
name={name}
value={value}
checked={checked}
disabled={disabled}
tabIndex={tabIndex}
onChange={handleChange}
onClick={onClickLabel ? handleInputClick : undefined}
/>
<div className="Checkbox-main">
<span className="label" dir="auto">
{typeof label === 'string' ? renderText(label) : label}
{rightIcon && <i className={`icon icon-${rightIcon} right-icon`} />}
</span>
{subLabel && <span className="subLabel" dir="auto">{renderText(subLabel)}</span>}
</div>
{isLoading && <Spinner />}
</label>
<>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
<label
className={labelClassName}
dir={lang.isRtl ? 'rtl' : undefined}
onClick={onClickLabel ? handleClick : undefined}
ref={labelRef}
>
<input
type="checkbox"
id={id}
name={name}
value={value}
checked={checked}
disabled={disabled}
tabIndex={tabIndex}
onChange={handleChange}
onClick={onClickLabel ? handleInputClick : undefined}
/>
<div className={buildClassName('Checkbox-main', Boolean(leftElement) && 'Nested-avatar-list')}>
<span className="label" dir="auto">
{leftElement}
{typeof label === 'string' ? renderText(label) : label}
{labelText && <span className="ml-1">{renderText(labelText)}</span>}
{rightIcon && <i className={`icon icon-${rightIcon} right-icon`} />}
</span>
{subLabel && <span className="subLabel" dir="auto">{renderText(subLabel)}</span>}
</div>
{nestedCheckbox && (
<span className="nestedButton" dir="auto">
<Button className="button" color="translucent" size="smaller" onClick={toggleNested}>
<Icon name="group-filled" className="group-icon" />
{nestedCheckboxCount}
<Icon name={showNested ? 'up' : 'down'} />
</Button>
</span>
)}
{isLoading && <Spinner />}
</label>
{nestedCheckbox && (
<div
className={buildClassName('nested-checkbox-group', showNested && 'nested-checkbox-group-open')}
>
{nestedOptionList?.nestedOptions?.map((nestedOption) => (
<Checkbox
key={nestedOption.value}
leftElement={leftElement}
onChange={handleChange}
checked={values.indexOf(nestedOption.value) !== -1}
values={values}
/* eslint-disable-next-line react/jsx-props-no-spreading */
{...nestedOption}
/>
))}
</div>
)}
</>
);
};

View File

@ -1,6 +1,10 @@
import type { ChangeEvent } from 'react';
import type { FC, TeactNode } from '../../lib/teact/teact';
import React, { memo, useCallback, useState } from '../../lib/teact/teact';
import React, { memo, useState } from '../../lib/teact/teact';
import type { ApiUser } from '../../api/types';
import useLastCallback from '../../hooks/useLastCallback';
import Checkbox from './Checkbox';
@ -9,6 +13,8 @@ export type IRadioOption = {
subLabel?: string;
disabled?: boolean;
value: string;
nestedOptions?: IRadioOption[];
user?: ApiUser;
};
type OwnProps = {
@ -17,6 +23,7 @@ type OwnProps = {
selected?: string[];
disabled?: boolean;
round?: boolean;
nestedCheckbox?: boolean;
loadingOptions?: string[];
onChange: (value: string[]) => void;
};
@ -27,38 +34,71 @@ const CheckboxGroup: FC<OwnProps> = ({
selected = [],
disabled,
round,
nestedCheckbox,
loadingOptions,
onChange,
}) => {
const [values, setValues] = useState<string[]>(selected || []);
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
const handleChange = useLastCallback((event: ChangeEvent<HTMLInputElement>, nestedOptionList?: IRadioOption) => {
const { value, checked } = event.currentTarget;
let newValues: string[];
if (checked) {
newValues = [...values, value];
if (nestedOptionList && value) {
newValues.push(nestedOptionList.value);
}
if (nestedOptionList && value === nestedOptionList.value) {
nestedOptionList.nestedOptions?.forEach((nestedOption) => {
if (!newValues.includes(nestedOption.value)) {
newValues.push(nestedOption.value);
}
});
}
} else {
newValues = values.filter((v) => v !== value);
if (nestedOptionList && value === nestedOptionList.value) {
nestedOptionList.nestedOptions?.forEach((nestedOption) => {
newValues = newValues.filter((v) => v !== nestedOption.value);
});
} else if (nestedOptionList) {
const nestedOptionValues = nestedOptionList.nestedOptions?.map((nestedOption) => nestedOption.value) || [];
const hasOtherNestedValuesChecked = nestedOptionValues.some((nestedValue) => newValues.includes(nestedValue));
if (!hasOtherNestedValuesChecked) {
newValues = newValues.filter((v) => v !== nestedOptionList.value);
}
}
}
setValues(newValues);
onChange(newValues);
}, [onChange, values]);
});
const getCheckedNestedCount = useLastCallback((nestedOptions: IRadioOption[]) => {
const checkedCount = nestedOptions?.filter((nestedOption) => values.includes(nestedOption.value)).length;
return checkedCount > 0 ? checkedCount : undefined;
});
return (
<div id={id} className="radio-group">
{options.map((option) => (
<Checkbox
label={option.label}
subLabel={option.subLabel}
value={option.value}
checked={selected.indexOf(option.value) !== -1}
disabled={option.disabled || disabled}
round={round}
isLoading={loadingOptions ? loadingOptions.indexOf(option.value) !== -1 : undefined}
onChange={handleChange}
/>
))}
{options.map((option) => {
return (
<Checkbox
label={option.label}
subLabel={option.subLabel}
value={option.value}
checked={selected.indexOf(option.value) !== -1}
disabled={option.disabled || disabled}
round={round}
isLoading={loadingOptions ? loadingOptions.indexOf(option.value) !== -1 : undefined}
onChange={handleChange}
nestedCheckbox={nestedCheckbox}
nestedCheckboxCount={getCheckedNestedCount(option.nestedOptions ?? [])}
nestedOptionList={option}
values={values}
/>
);
})}
</div>
);
};

View File

@ -958,3 +958,29 @@ function copyTextForMessages(global: GlobalState, chatId: string, messageIds: nu
copyHtmlToClipboard(resultHtml.join('\n'), resultText.join('\n'));
}
addActionHandler('openDeleteMessageModal', (global, actions, payload): ActionReturnType => {
const {
message, isSchedule, album,
tabId = getCurrentTabId(),
} = payload;
global = getGlobal();
global = updateTabState(global, {
deleteMessageModal: {
isSchedule,
album,
message,
},
}, tabId);
setGlobal(global);
});
addActionHandler('closeDeleteMessageModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
deleteMessageModal: undefined,
}, tabId);
});

View File

@ -423,6 +423,42 @@ export function selectSender<T extends GlobalState>(global: T, message: ApiMessa
return selectPeer(global, senderId);
}
export function selectSendersFromSelectedMessages<T extends GlobalState>(
global: T,
chat: ApiChat | undefined,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
const { messageIds: selectedMessageIds } = selectTabState(global, tabId).selectedMessages || {};
if (!chat?.id || !selectedMessageIds) {
return undefined;
}
return selectedMessageIds.map((id) => {
const message = selectChatMessage(global, chat.id, id);
if (!message?.senderId) {
return undefined;
}
return selectSender(global, message);
});
}
export function selectSenderFromMessage<T extends GlobalState>(
global: T,
chat: ApiChat | undefined,
selectedMessageId: number | undefined,
): ApiPeer | undefined {
if (!chat?.id || !selectedMessageId) {
return undefined;
}
const message = selectChatMessage(global, chat.id, selectedMessageId);
if (!message?.senderId) {
return undefined;
}
return selectPeer(global, message.senderId);
}
export function selectReplySender<T extends GlobalState>(
global: T, message: ApiMessage,
) {

View File

@ -100,6 +100,7 @@ import type {
EmojiKeywords,
FocusDirection,
GlobalSearchContent,
IAlbum,
IAnchorPosition,
InlineBotSettings,
ISettings,
@ -691,6 +692,13 @@ export type TabState = {
prepaidGiveaway?: ApiPrepaidGiveaway;
};
deleteMessageModal?: {
message?: ApiMessage;
isSchedule?: boolean;
album?: IAlbum;
onConfirm?: NoneToVoidFunction;
};
giftingModal?: {
isOpen?: boolean;
};
@ -3125,6 +3133,14 @@ export interface ActionPayloads {
openPremiumGiftingModal: WithTabId | undefined;
closePremiumGiftingModal: WithTabId | undefined;
openDeleteMessageModal: ({
message?: ApiMessage;
isSchedule?: boolean;
album?: IAlbum;
onConfirm?: NoneToVoidFunction;
} & WithTabId);
closeDeleteMessageModal: WithTabId | undefined;
transcribeAudio: {
chatId: string;
messageId: number;