From 3cc27156cbcefe648a6a2901659cc64be5ccdfb8 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 28 Feb 2023 18:43:18 +0100 Subject: [PATCH] Introduce Granular Media Permissions (#2576) --- src/api/gramjs/methods/chats.ts | 8 + src/api/gramjs/methods/messages.ts | 42 ++- src/api/types/chats.ts | 9 +- src/api/types/updates.ts | 4 +- .../common/MessageOutgoingStatus.scss | 16 + .../common/MessageOutgoingStatus.tsx | 6 +- src/components/middle/composer/AttachMenu.tsx | 44 ++- .../middle/composer/AttachmentModal.tsx | 24 +- src/components/middle/composer/Composer.scss | 9 + src/components/middle/composer/Composer.tsx | 84 +++-- src/components/middle/composer/GifPicker.tsx | 4 +- .../middle/composer/MessageInput.tsx | 29 +- .../middle/composer/StickerPicker.tsx | 2 +- src/components/middle/composer/SymbolMenu.tsx | 14 +- .../middle/composer/SymbolMenuButton.tsx | 3 + .../middle/composer/SymbolMenuFooter.tsx | 6 +- .../composer/hooks/useAttachmentModal.ts | 65 +++- .../right/hooks/useManagePermissions.ts | 119 +++++++ .../management/ManageChatRemovedUsers.tsx | 1 + .../right/management/ManageGroup.tsx | 30 +- .../management/ManageGroupPermissions.tsx | 289 +++++++++++------ .../management/ManageGroupUserPermissions.tsx | 300 +++++++++++------- .../right/management/Management.scss | 58 ++++ src/components/ui/Checkbox.scss | 10 +- src/components/ui/Checkbox.tsx | 34 +- src/components/ui/FloatingActionButton.scss | 2 +- src/global/actions/api/messages.ts | 7 +- src/global/actions/apiUpdaters/messages.ts | 13 + src/global/actions/ui/misc.ts | 45 ++- src/global/helpers/chats.ts | 21 ++ src/global/selectors/messages.ts | 41 ++- src/global/types.ts | 3 + 32 files changed, 1035 insertions(+), 307 deletions(-) create mode 100644 src/components/right/hooks/useManagePermissions.ts diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index e7916069b..77ec379b9 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -1238,6 +1238,14 @@ export function deleteChatMember(chat: ApiChat, user: ApiUser) { changeInfo: true, inviteUsers: true, pinMessages: true, + manageTopics: true, + sendPhotos: true, + sendVideos: true, + sendRoundvideos: true, + sendAudios: true, + sendVoices: true, + sendDocs: true, + sendPlain: true, }, untilDate: MAX_INT_32, }); diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index d4d270db0..b3e3eb0fc 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -263,7 +263,7 @@ export function sendMessage( // This is expected to arrive after `updateMessageSendSucceeded` which replaces the local ID, // so in most cases this will be simply ignored - setTimeout(() => { + const timeout = setTimeout(() => { onUpdate({ '@type': localMessage.isScheduled ? 'updateScheduledMessage' : 'updateMessage', id: localMessage.id, @@ -326,21 +326,31 @@ export function sendMessage( const RequestClass = media ? GramJs.messages.SendMedia : GramJs.messages.SendMessage; - await invokeRequest(new RequestClass({ - clearDraft: true, - message: text || '', - entities: entities ? entities.map(buildMtpMessageEntity) : undefined, - peer: buildInputPeer(chat.id, chat.accessHash), - randomId, - ...(isSilent && { silent: isSilent }), - ...(scheduledAt && { scheduleDate: scheduledAt }), - ...(replyingTo && { replyToMsgId: replyingTo }), - ...(replyingToTopId && { topMsgId: replyingToTopId }), - ...(media && { media }), - ...(noWebPage && { noWebpage: noWebPage }), - ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), - ...(shouldUpdateStickerSetsOrder && { updateStickersetsOrder: shouldUpdateStickerSetsOrder }), - }), true); + try { + await invokeRequest(new RequestClass({ + clearDraft: true, + message: text || '', + entities: entities ? entities.map(buildMtpMessageEntity) : undefined, + peer: buildInputPeer(chat.id, chat.accessHash), + randomId, + ...(isSilent && { silent: isSilent }), + ...(scheduledAt && { scheduleDate: scheduledAt }), + ...(replyingTo && { replyToMsgId: replyingTo }), + ...(replyingToTopId && { topMsgId: replyingToTopId }), + ...(media && { media }), + ...(noWebPage && { noWebpage: noWebPage }), + ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), + ...(shouldUpdateStickerSetsOrder && { updateStickersetsOrder: shouldUpdateStickerSetsOrder }), + }), true, true); + } catch (error: any) { + onUpdate({ + '@type': 'updateMessageSendFailed', + chatId: chat.id, + localId: localMessage.id, + error: error.message, + }); + clearTimeout(timeout); + } })(); return queue; diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index bcd26ae78..3a69b57f9 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -157,8 +157,15 @@ export interface ApiChatBannedRights { changeInfo?: true; inviteUsers?: true; pinMessages?: true; - untilDate?: number; manageTopics?: true; + sendPhotos?: true; + sendVideos?: true; + sendRoundvideos?: true; + sendAudios?: true; + sendVoices?: true; + sendDocs?: true; + sendPlain?: true; + untilDate?: number; } export interface ApiRestrictionReason { diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index c42edb96b..ca7738752 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -243,9 +243,7 @@ export type ApiUpdateMessageSendFailed = { '@type': 'updateMessageSendFailed'; chatId: string; localId: number; - sendingState: { - '@type': 'messageSendingStateFailed'; - }; + error: string; }; export type ApiUpdateCommonBoxMessages = { diff --git a/src/components/common/MessageOutgoingStatus.scss b/src/components/common/MessageOutgoingStatus.scss index 93b8d14b4..feb25d05d 100644 --- a/src/components/common/MessageOutgoingStatus.scss +++ b/src/components/common/MessageOutgoingStatus.scss @@ -18,4 +18,20 @@ width: 100%; height: 100%; } + + .MessageOutgoingStatus--failed::before { + content: ""; + display: block; + position: absolute; + inset: 0.25rem; + border-radius: 50%; + background: white; + } + + .icon-message-failed { + background: none; + color: var(--color-error); + z-index: 1; + position: relative; + } } diff --git a/src/components/common/MessageOutgoingStatus.tsx b/src/components/common/MessageOutgoingStatus.tsx index e17a9a2e6..8d8d71d04 100644 --- a/src/components/common/MessageOutgoingStatus.tsx +++ b/src/components/common/MessageOutgoingStatus.tsx @@ -19,7 +19,11 @@ const MessageOutgoingStatus: FC = ({ status }) => { return (
- + {status === 'failed' ? ( +
+ +
+ ) : }
); diff --git a/src/components/middle/composer/AttachMenu.tsx b/src/components/middle/composer/AttachMenu.tsx index 745e46686..5a582c4a4 100644 --- a/src/components/middle/composer/AttachMenu.tsx +++ b/src/components/middle/composer/AttachMenu.tsx @@ -7,7 +7,11 @@ import type { GlobalState } from '../../../global/types'; import type { ApiAttachMenuPeerType } from '../../../api/types'; import type { ISettings } from '../../../types'; -import { CONTENT_TYPES_WITH_PREVIEW } from '../../../config'; +import { + CONTENT_TYPES_WITH_PREVIEW, SUPPORTED_AUDIO_CONTENT_TYPES, + SUPPORTED_IMAGE_CONTENT_TYPES, + SUPPORTED_VIDEO_CONTENT_TYPES, +} from '../../../config'; import { IS_TOUCH_ENV } from '../../../util/environment'; import { openSystemFilesDialog } from '../../../util/systemFilesDialog'; import { validateFiles } from '../../../util/files'; @@ -29,6 +33,10 @@ export type OwnProps = { isButtonVisible: boolean; canAttachMedia: boolean; canAttachPolls: boolean; + canSendPhotos: boolean; + canSendVideos: boolean; + canSendDocuments: boolean; + canSendAudios: boolean; isScheduled?: boolean; attachBots: GlobalState['attachMenu']['bots']; peerType?: ApiAttachMenuPeerType; @@ -43,6 +51,10 @@ const AttachMenu: FC = ({ isButtonVisible, canAttachMedia, canAttachPolls, + canSendPhotos, + canSendVideos, + canSendDocuments, + canSendAudios, attachBots, peerType, isScheduled, @@ -53,6 +65,9 @@ const AttachMenu: FC = ({ const [isAttachMenuOpen, openAttachMenu, closeAttachMenu] = useFlag(); const [handleMouseEnter, handleMouseLeave, markMouseInside] = useMouseInside(isAttachMenuOpen, closeAttachMenu); + const canSendVideoAndPhoto = canSendPhotos && canSendVideos; + const canSendVideoOrPhoto = canSendPhotos || canSendVideos; + const [isAttachmentBotMenuOpen, markAttachmentBotMenuOpen, unmarkAttachmentBotMenuOpen] = useFlag(); useEffect(() => { if (isAttachMenuOpen) { @@ -79,14 +94,19 @@ const AttachMenu: FC = ({ const handleQuickSelect = useCallback(() => { openSystemFilesDialog( - Array.from(CONTENT_TYPES_WITH_PREVIEW).join(','), + Array.from(canSendVideoAndPhoto ? CONTENT_TYPES_WITH_PREVIEW : ( + canSendPhotos ? SUPPORTED_IMAGE_CONTENT_TYPES : SUPPORTED_VIDEO_CONTENT_TYPES + )).join(','), (e) => handleFileSelect(e, true), ); - }, [handleFileSelect]); + }, [canSendPhotos, canSendVideoAndPhoto, handleFileSelect]); const handleDocumentSelect = useCallback(() => { - openSystemFilesDialog('*', (e) => handleFileSelect(e, false)); - }, [handleFileSelect]); + openSystemFilesDialog(!canSendDocuments && canSendAudios + ? Array.from(SUPPORTED_AUDIO_CONTENT_TYPES).join(',') : ( + '*' + ), (e) => handleFileSelect(e, false)); + }, [canSendAudios, canSendDocuments, handleFileSelect]); const bots = useMemo(() => { return Object.values(attachBots).filter((bot) => { @@ -141,8 +161,18 @@ const AttachMenu: FC = ({ )} {canAttachMedia && ( <> - {lang('AttachmentMenu.PhotoOrVideo')} - {lang('AttachDocument')} + {canSendVideoOrPhoto && ( + + {lang(canSendVideoAndPhoto ? 'AttachmentMenu.PhotoOrVideo' + : (canSendPhotos ? 'InputAttach.Popover.Photo' : 'InputAttach.Popover.Video'))} + + )} + {(canSendDocuments || canSendAudios) + && ( + + {lang(!canSendDocuments && canSendAudios ? 'InputAttach.Popover.Music' : 'AttachDocument')} + + )} )} {canAttachPolls && ( diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 9f3135946..b08a464f0 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -61,6 +61,8 @@ export type OwnProps = { isReady?: boolean; shouldSchedule?: boolean; shouldSuggestCompression?: boolean; + shouldForceCompression?: boolean; + shouldForceAsFile?: boolean; isForCurrentMessageList?: boolean; onCaptionUpdate: (html: string) => void; onSend: (sendCompressed: boolean, sendGrouped: boolean) => void; @@ -109,6 +111,8 @@ const AttachmentModal: FC = ({ customEmojiForEmoji, attachmentSettings, shouldSuggestCompression, + shouldForceCompression, + shouldForceAsFile, isForCurrentMessageList, onAttachmentsUpdate, onCaptionUpdate, @@ -140,6 +144,7 @@ const AttachmentModal: FC = ({ const [shouldSendCompressed, setShouldSendCompressed] = useState( shouldSuggestCompression ?? attachmentSettings.shouldCompress, ); + const isSendingCompressed = Boolean((shouldSendCompressed || shouldForceCompression) && !shouldForceAsFile); const [shouldSendGrouped, setShouldSendGrouped] = useState(attachmentSettings.shouldSendGrouped); const { @@ -241,15 +246,15 @@ const AttachmentModal: FC = ({ if (isOpen) { const send = (shouldSchedule || shouldSendScheduled) ? onSendScheduled : isSilent ? onSendSilent : onSend; - send(shouldSendCompressed, shouldSendGrouped); + send(isSendingCompressed, shouldSendGrouped); updateAttachmentSettings({ - shouldCompress: shouldSendCompressed, + shouldCompress: isSendingCompressed, shouldSendGrouped, }); } }, [ - isOpen, shouldSchedule, onSendScheduled, onSend, updateAttachmentSettings, shouldSendCompressed, shouldSendGrouped, - onSendSilent, + isOpen, shouldSchedule, onSendScheduled, onSendSilent, onSend, isSendingCompressed, shouldSendGrouped, + updateAttachmentSettings, ]); const handleSendSilent = useCallback(() => { @@ -366,7 +371,7 @@ const AttachmentModal: FC = ({ return leftCharsBeforeLimit <= MAX_LEFT_CHARS_TO_SHOW ? leftCharsBeforeLimit : undefined; }, [captionLimit, getHtml, renderingIsOpen]); - const isQuickGallery = shouldSendCompressed && hasOnlyMedia; + const isQuickGallery = isSendingCompressed && hasOnlyMedia; const [areAllPhotos, areAllVideos, areAllAudios] = useMemo(() => { if (!isQuickGallery || !renderingAttachments) return [false, false, false]; @@ -413,7 +418,7 @@ const AttachmentModal: FC = ({ {hasMedia && ( <> { - shouldSendCompressed ? ( + !shouldForceAsFile && !shouldForceCompression && (isSendingCompressed ? ( // eslint-disable-next-line react/jsx-no-bind setShouldSendCompressed(false)}> {lang(isMultiple ? 'Attachment.SendAsFiles' : 'Attachment.SendAsFile')} @@ -423,9 +428,9 @@ const AttachmentModal: FC = ({ setShouldSendCompressed(true)}> {isMultiple ? 'Send All as Media' : 'Send as Media'} - ) + )) } - {shouldSendCompressed && ( + {isSendingCompressed && ( hasSpoiler ? ( {lang('Attachment.DisableSpoiler')} @@ -496,7 +501,7 @@ const AttachmentModal: FC = ({ {renderingAttachments.map((attachment, i) => ( = ({ onRemoveSymbol={onRemoveSymbol} onEmojiSelect={onEmojiSelect} isAttachmentModal + canSendPlainText className="attachment-modal-symbol-menu with-menu-transitions" /> = ({ callAttachBot, addRecentCustomEmoji, showNotification, + showAllowedMessageTypesNotification, } = getActions(); const lang = useLang(); @@ -355,8 +356,15 @@ const Composer: FC = ({ const [attachments, setAttachments] = useState([]); const hasAttachments = Boolean(attachments.length); + const { + canSendStickers, canSendGifs, canAttachMedia, canAttachPolls, canAttachEmbedLinks, + canSendVoices, canSendPlainText, canSendAudios, canSendVideos, canSendPhotos, canSendDocuments, + } = useMemo(() => getAllowedAttachmentOptions(chat, isChatWithBot), [chat, isChatWithBot]); + const { shouldSuggestCompression, + shouldForceCompression, + shouldForceAsFile, handleAppendFiles, handleFileSelect, onCaptionUpdate, @@ -367,6 +375,11 @@ const Composer: FC = ({ setHtml, setAttachments, fileSizeLimit, + chatId, + canSendAudios, + canSendVideos, + canSendPhotos, + canSendDocuments, }); const [isBotKeyboardOpen, openBotKeyboard, closeBotKeyboard] = useFlag(); @@ -403,10 +416,6 @@ const Composer: FC = ({ } }, [getHtml, isEditingRef, sendMessageAction]); - const { - canSendStickers, canSendGifs, canAttachMedia, canAttachPolls, canAttachEmbedLinks, - } = useMemo(() => getAllowedAttachmentOptions(chat, isChatWithBot), [chat, isChatWithBot]); - const isAdmin = chat && isChatAdmin(chat); const slowMode = getChatSlowModeOptions(chat); @@ -492,6 +501,7 @@ const Composer: FC = ({ ); const insertHtmlAndUpdateCursor = useCallback((newHtml: string, inputId: string = EDITABLE_INPUT_ID) => { + if (inputId === EDITABLE_INPUT_ID && !canSendPlainText) return; const selection = window.getSelection()!; let messageInput: HTMLDivElement; if (inputId === EDITABLE_INPUT_ID) { @@ -515,7 +525,7 @@ const Composer: FC = ({ requestAnimationFrame(() => { focusEditableElement(messageInput); }); - }, [getHtml, setHtml]); + }, [canSendPlainText, getHtml, setHtml]); const insertFormattedTextAndUpdateCursor = useCallback(( text: ApiFormattedText, inputId: string = EDITABLE_INPUT_ID, @@ -1060,6 +1070,12 @@ const Composer: FC = ({ insertHtmlAndUpdateCursor(newHtml, inputId); }, [insertHtmlAndUpdateCursor]); + useEffect(() => { + if (canSendPlainText) return; + + setHtml(''); + }, [canSendPlainText, setHtml, attachments]); + const insertTextAndUpdateCursorAttachmentModal = useCallback((text: string) => { insertTextAndUpdateCursor(text, EDITABLE_INPUT_MODAL_ID); }, [insertTextAndUpdateCursor]); @@ -1107,7 +1123,7 @@ const Composer: FC = ({ }, [isSelectModeActive, enableHover, disableHover, isReady]); const areVoiceMessagesNotAllowed = mainButtonState === MainButtonState.Record - && (!canAttachMedia || !canSendVoiceByPrivacy); + && (!canAttachMedia || !canSendVoiceByPrivacy || !canSendVoices); const mainButtonHandler = useCallback(() => { switch (mainButtonState) { @@ -1120,6 +1136,8 @@ const Composer: FC = ({ showNotification({ message: lang('VoiceMessagesRestrictedByPrivacy', chat?.title), }); + } else if (!canSendVoices) { + showAllowedMessageTypesNotification({ chatId }); } } else { startRecordingVoice(); @@ -1143,7 +1161,7 @@ const Composer: FC = ({ }, [ mainButtonState, handleSend, handleEditComplete, activeVoiceRecording, requestCalendar, areVoiceMessagesNotAllowed, canSendVoiceByPrivacy, showNotification, lang, chat?.title, startRecordingVoice, pauseRecordingVoice, - handleMessageSchedule, + handleMessageSchedule, chatId, showAllowedMessageTypesNotification, canSendVoices, ]); const prevEditedMessage = usePrevious(editingMessage, true); @@ -1224,6 +1242,8 @@ const Composer: FC = ({ getHtml={getHtml} isReady={isReady} shouldSuggestCompression={shouldSuggestCompression} + shouldForceCompression={shouldForceCompression} + shouldForceAsFile={shouldForceAsFile} isForCurrentMessageList={isForCurrentMessageList} onCaptionUpdate={onCaptionUpdate} onSendSilent={handleSendSilentAttachments} @@ -1335,37 +1355,43 @@ const Composer: FC = ({ /> )} - + {(canSendPlainText || canSendGifs || canSendStickers) && ( + + )} = ({ isButtonVisible={!activeVoiceRecording && !editingMessage} canAttachMedia={canAttachMedia} canAttachPolls={canAttachPolls} + canSendPhotos={canSendPhotos} + canSendVideos={canSendVideos} + canSendDocuments={canSendDocuments} + canSendAudios={canSendAudios} onFileSelect={handleFileSelect} onPollCreate={openPollModal} isScheduled={shouldSchedule} diff --git a/src/components/middle/composer/GifPicker.tsx b/src/components/middle/composer/GifPicker.tsx index 9ce380b4d..f60341667 100644 --- a/src/components/middle/composer/GifPicker.tsx +++ b/src/components/middle/composer/GifPicker.tsx @@ -22,8 +22,8 @@ import './GifPicker.scss'; type OwnProps = { className: string; loadAndPlay: boolean; - canSendGifs: boolean; - onGifSelect: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void; + canSendGifs?: boolean; + onGifSelect?: (gif: ApiVideo, isSilent?: boolean, shouldSchedule?: boolean) => void; }; type StateProps = { diff --git a/src/components/middle/composer/MessageInput.tsx b/src/components/middle/composer/MessageInput.tsx index 78d1e4c50..1309a267c 100644 --- a/src/components/middle/composer/MessageInput.tsx +++ b/src/components/middle/composer/MessageInput.tsx @@ -54,6 +54,7 @@ type OwnProps = { canAutoFocus: boolean; shouldSuppressFocus?: boolean; shouldSuppressTextFormatter?: boolean; + canSendPlainText?: boolean; onUpdate: (html: string) => void; onSuppressedFocus?: () => void; onSend: () => void; @@ -102,6 +103,7 @@ const MessageInput: FC = ({ getHtml, placeholder, forcedPlaceholder, + canSendPlainText, canAutoFocus, noFocusInterception, shouldSuppressFocus, @@ -117,6 +119,7 @@ const MessageInput: FC = ({ const { editLastMessage, replyToNextMessage, + showAllowedMessageTypesNotification, } = getActions(); // eslint-disable-next-line no-null/no-null @@ -405,6 +408,11 @@ const MessageInput: FC = ({ } } + function handleClick() { + if (isAttachmentModalInput || canSendPlainText) return; + showAllowedMessageTypesNotification({ chatId }); + } + useEffect(() => { if (IS_TOUCH_ENV) { return; @@ -506,13 +514,17 @@ const MessageInput: FC = ({ return (
-
+
= ({ onTouchCancel={IS_ANDROID ? processSelectionWithTimeout : undefined} aria-label={placeholder} /> - {!forcedPlaceholder && {placeholder}} + {!forcedPlaceholder && ( + + {!isAttachmentModalInput && !canSendPlainText && } + {placeholder} + + )}
diff --git a/src/components/middle/composer/StickerPicker.tsx b/src/components/middle/composer/StickerPicker.tsx index 4d24af089..791e04d6e 100644 --- a/src/components/middle/composer/StickerPicker.tsx +++ b/src/components/middle/composer/StickerPicker.tsx @@ -44,7 +44,7 @@ type OwnProps = { threadId?: number; className: string; loadAndPlay: boolean; - canSendStickers: boolean; + canSendStickers?: boolean; onStickerSelect: ( sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean, shouldUpdateStickerSetsOrder?: boolean, ) => void; diff --git a/src/components/middle/composer/SymbolMenu.tsx b/src/components/middle/composer/SymbolMenu.tsx index 35f4a57dc..cbc8f08b8 100644 --- a/src/components/middle/composer/SymbolMenu.tsx +++ b/src/components/middle/composer/SymbolMenu.tsx @@ -30,6 +30,7 @@ import Portal from '../../ui/Portal'; import './SymbolMenu.scss'; const ANIMATION_DURATION = 350; +const STICKERS_TAB_INDEX = 2; export type OwnProps = { chatId: string; @@ -55,6 +56,7 @@ export type OwnProps = { addRecentCustomEmoji: GlobalActions['addRecentCustomEmoji']; className?: string; isAttachmentModal?: boolean; + canSendPlainText?: boolean; positionX?: 'left' | 'right'; positionY?: 'top' | 'bottom'; transformOriginX?: number; @@ -83,6 +85,7 @@ const SymbolMenu: FC = ({ onClose, onEmojiSelect, isAttachmentModal, + canSendPlainText, onCustomEmojiSelect, onStickerSelect, className, @@ -114,6 +117,12 @@ const SymbolMenu: FC = ({ onLoad(); }, [onLoad]); + // If we can't send plain text, we should always show the stickers tab + useEffect(() => { + if (canSendPlainText) return; + setActiveTab(STICKERS_TAB_INDEX); + }, [canSendPlainText]); + useEffect(() => { if (!lastSyncTime) return; if (isCurrentUserPremium) { @@ -218,8 +227,6 @@ const SymbolMenu: FC = ({ /> ); case SymbolMenuTabs.Stickers: - if (!canSendStickers) return undefined; - return ( = ({ /> ); case SymbolMenuTabs.GIFs: - if (!canSendGifs || !onGifSelect) return undefined; - return ( = ({ onRemoveSymbol={onRemoveSymbol} onSearchOpen={handleSearch} isAttachmentModal={isAttachmentModal} + canSendPlainText={canSendPlainText} /> ); diff --git a/src/components/middle/composer/SymbolMenuButton.tsx b/src/components/middle/composer/SymbolMenuButton.tsx index 4c564878a..aa4b0c350 100644 --- a/src/components/middle/composer/SymbolMenuButton.tsx +++ b/src/components/middle/composer/SymbolMenuButton.tsx @@ -44,6 +44,7 @@ type OwnProps = { closeSendAsMenu?: VoidFunction; isSymbolMenuForced?: boolean; isAttachmentModal?: boolean; + canSendPlainText?: boolean; className?: string; }; @@ -61,6 +62,7 @@ const SymbolMenuButton: FC = ({ onStickerSelect, onGifSelect, isAttachmentModal, + canSendPlainText, onRemoveSymbol, onEmojiSelect, closeBotCommandMenu, @@ -196,6 +198,7 @@ const SymbolMenuButton: FC = ({ addRecentEmoji={addRecentEmoji} addRecentCustomEmoji={addRecentCustomEmoji} isAttachmentModal={isAttachmentModal} + canSendPlainText={canSendPlainText} className={className} positionX={isAttachmentModal ? positionX : undefined} positionY={isAttachmentModal ? positionY : undefined} diff --git a/src/components/middle/composer/SymbolMenuFooter.tsx b/src/components/middle/composer/SymbolMenuFooter.tsx index 393084b93..fee3afe73 100644 --- a/src/components/middle/composer/SymbolMenuFooter.tsx +++ b/src/components/middle/composer/SymbolMenuFooter.tsx @@ -11,6 +11,7 @@ type OwnProps = { onRemoveSymbol: () => void; onSearchOpen: (type: 'stickers' | 'gifs') => void; isAttachmentModal?: boolean; + canSendPlainText?: boolean; }; export enum SymbolMenuTabs { @@ -36,6 +37,7 @@ const SYMBOL_MENU_TAB_ICONS = { const SymbolMenuFooter: FC = ({ activeTab, onSwitchTab, onRemoveSymbol, onSearchOpen, isAttachmentModal, + canSendPlainText, }) => { const lang = useLang(); @@ -78,8 +80,8 @@ const SymbolMenuFooter: FC = ({ )} - {renderTabButton(SymbolMenuTabs.Emoji)} - {renderTabButton(SymbolMenuTabs.CustomEmoji)} + {canSendPlainText && renderTabButton(SymbolMenuTabs.Emoji)} + {canSendPlainText && renderTabButton(SymbolMenuTabs.CustomEmoji)} {!isAttachmentModal && renderTabButton(SymbolMenuTabs.Stickers)} {!isAttachmentModal && renderTabButton(SymbolMenuTabs.GIFs)} diff --git a/src/components/middle/composer/hooks/useAttachmentModal.ts b/src/components/middle/composer/hooks/useAttachmentModal.ts index 1430184e3..6af8bfece 100644 --- a/src/components/middle/composer/hooks/useAttachmentModal.ts +++ b/src/components/middle/composer/hooks/useAttachmentModal.ts @@ -5,19 +5,36 @@ import type { ApiAttachment } from '../../../../api/types'; import buildAttachment from '../helpers/buildAttachment'; import { MEMO_EMPTY_ARRAY } from '../../../../util/memo'; +import { + SUPPORTED_AUDIO_CONTENT_TYPES, + SUPPORTED_IMAGE_CONTENT_TYPES, + SUPPORTED_VIDEO_CONTENT_TYPES, +} from '../../../../config'; export default function useAttachmentModal({ attachments, fileSizeLimit, setHtml, setAttachments, + chatId, + canSendAudios, + canSendVideos, + canSendPhotos, + canSendDocuments, }: { attachments: ApiAttachment[]; fileSizeLimit: number; setHtml: (html: string) => void; setAttachments: (attachments: ApiAttachment[]) => void; + chatId: string; + canSendAudios?: boolean; + canSendVideos?: boolean; + canSendPhotos?: boolean; + canSendDocuments?: boolean; }) { - const { openLimitReachedModal } = getActions(); + const { openLimitReachedModal, showAllowedMessageTypesNotification } = getActions(); + const [shouldForceAsFile, setShouldForceAsFile] = useState(false); + const [shouldForceCompression, setShouldForceCompression] = useState(false); const [shouldSuggestCompression, setShouldSuggestCompression] = useState(undefined); const handleClearAttachments = useCallback(() => { @@ -28,18 +45,40 @@ export default function useAttachmentModal({ (newValue: ApiAttachment[] | ((current: ApiAttachment[]) => ApiAttachment[])) => { const newAttachments = typeof newValue === 'function' ? newValue(attachments) : newValue; if (!newAttachments.length) { - setAttachments(MEMO_EMPTY_ARRAY); + handleClearAttachments(); return; } - if (newAttachments.some(({ size }) => size > fileSizeLimit)) { + if (newAttachments.some((attachment) => { + const type = getAttachmentType(attachment); + + return (type === 'audio' && !canSendAudios && !canSendDocuments) + || (type === 'video' && !canSendVideos && !canSendDocuments) + || (type === 'image' && !canSendPhotos && !canSendDocuments) + || (type === 'file' && !canSendDocuments); + })) { + showAllowedMessageTypesNotification({ chatId }); + } else if (newAttachments.some(({ size }) => size > fileSizeLimit)) { openLimitReachedModal({ limit: 'uploadMaxFileparts', }); } else { setAttachments(newAttachments); + const shouldForce = newAttachments.some((attachment) => { + const type = getAttachmentType(attachment); + + return (type === 'audio' && !canSendAudios) + || (type === 'video' && !canSendVideos) + || (type === 'image' && !canSendPhotos); + }); + + setShouldForceAsFile(Boolean(shouldForce && canSendDocuments)); + setShouldForceCompression(!canSendDocuments); } - }, [attachments, fileSizeLimit, openLimitReachedModal, setAttachments], + }, [ + attachments, canSendAudios, canSendDocuments, canSendPhotos, canSendVideos, chatId, fileSizeLimit, + handleClearAttachments, openLimitReachedModal, setAttachments, showAllowedMessageTypesNotification, + ], ); const handleAppendFiles = useCallback(async (files: File[], isSpoiler?: boolean) => { @@ -63,5 +102,23 @@ export default function useAttachmentModal({ onCaptionUpdate: setHtml, handleClearAttachments, handleSetAttachments, + shouldForceCompression, + shouldForceAsFile, }; } + +function getAttachmentType(attachment: ApiAttachment) { + if (SUPPORTED_IMAGE_CONTENT_TYPES.has(attachment.mimeType)) { + return 'image'; + } + + if (SUPPORTED_VIDEO_CONTENT_TYPES.has(attachment.mimeType)) { + return 'video'; + } + + if (SUPPORTED_AUDIO_CONTENT_TYPES.has(attachment.mimeType)) { + return 'audio'; + } + + return 'file'; +} diff --git a/src/components/right/hooks/useManagePermissions.ts b/src/components/right/hooks/useManagePermissions.ts new file mode 100644 index 000000000..bd60c0cb9 --- /dev/null +++ b/src/components/right/hooks/useManagePermissions.ts @@ -0,0 +1,119 @@ +import type React from '../../../lib/teact/teact'; +import { useCallback, useEffect, useState } from '../../../lib/teact/teact'; + +import type { ApiChatBannedRights } from '../../../api/types'; + +const FLOATING_BUTTON_ANIMATION_TIMEOUT_MS = 250; +const MEDIA_PERMISSIONS: Array = [ + 'embedLinks', + 'sendPolls', + 'sendPhotos', + 'sendVideos', + 'sendRoundvideos', + 'sendVoices', + 'sendAudios', + 'sendDocs', + 'sendStickers', + 'sendGifs', +]; +const MESSAGE_PERMISSIONS: typeof MEDIA_PERMISSIONS = [...MEDIA_PERMISSIONS, 'sendPlain']; + +export default function useManagePermissions(defaultPermissions: ApiChatBannedRights | undefined) { + const [permissions, setPermissions] = useState({}); + const [havePermissionChanged, setHavePermissionChanged] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + setPermissions(defaultPermissions || {}); + setHavePermissionChanged(false); + setTimeout(() => { + setIsLoading(false); + }, FLOATING_BUTTON_ANIMATION_TIMEOUT_MS); + }, [defaultPermissions]); + + const handlePermissionChange = useCallback((e: React.ChangeEvent) => { + const { name: targetName } = e.target; + + const name = targetName as Exclude; + + function getUpdatedPermissionValue(value: true | undefined) { + return value ? undefined : true; + } + + const oldPermissions = permissions; + + let newPermissions: ApiChatBannedRights = { + ...oldPermissions, + [name]: getUpdatedPermissionValue(oldPermissions[name]), + ...(name === 'sendStickers' && { + sendGifs: getUpdatedPermissionValue(oldPermissions[name]), + }), + }; + + const checkMedia = () => { + const mediaPermissions = MEDIA_PERMISSIONS.map((key) => newPermissions[key]); + if (mediaPermissions.some((v) => !v)) { + newPermissions = { + ...newPermissions, + sendMedia: undefined, + }; + } else if (mediaPermissions.every(Boolean)) { + newPermissions = { + ...newPermissions, + sendMedia: true, + }; + } + }; + + if (name !== 'sendMedia') { + checkMedia(); + } else { + newPermissions = { + ...newPermissions, + ...(MEDIA_PERMISSIONS.reduce((acc, key) => ( + Object.assign(acc, { [key]: newPermissions.sendMedia }) + ), {})), + }; + } + + // Embed links can't be enabled if plain text is banned + if (name !== 'embedLinks' && !newPermissions.embedLinks && newPermissions.sendPlain) { + newPermissions = { + ...newPermissions, + embedLinks: true, + }; + } + + if (name !== 'sendPlain' && !newPermissions.embedLinks && newPermissions.sendPlain) { + newPermissions = { + ...newPermissions, + sendPlain: undefined, + }; + } + + if (name !== 'sendMedia') { + checkMedia(); + } + + const sendMessages = MESSAGE_PERMISSIONS.every((key) => newPermissions[key]); + newPermissions = { + ...newPermissions, + sendMessages: sendMessages ? true : undefined, + }; + + setPermissions(newPermissions); + + setHavePermissionChanged(!defaultPermissions || Object.keys(newPermissions).some((k) => { + const key = k as Exclude; + return Boolean(defaultPermissions[key]) !== Boolean(newPermissions[key]); + })); + }, [defaultPermissions, permissions]); + + return { + permissions, + isLoading, + havePermissionChanged, + handlePermissionChange, + setIsLoading, + }; +} diff --git a/src/components/right/management/ManageChatRemovedUsers.tsx b/src/components/right/management/ManageChatRemovedUsers.tsx index 5e8679905..2b9567e2c 100644 --- a/src/components/right/management/ManageChatRemovedUsers.tsx +++ b/src/components/right/management/ManageChatRemovedUsers.tsx @@ -100,6 +100,7 @@ const ManageChatRemovedUsers: FC = ({ ))} diff --git a/src/components/right/management/ManageGroup.tsx b/src/components/right/management/ManageGroup.tsx index bb166d4e7..74f4d61d7 100644 --- a/src/components/right/management/ManageGroup.tsx +++ b/src/components/right/management/ManageGroup.tsx @@ -63,9 +63,24 @@ type StateProps = { const GROUP_TITLE_EMPTY = 'Group title can\'t be empty'; const GROUP_MAX_DESCRIPTION = 255; +const ALL_PERMISSIONS: Array = [ + '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 = 9; +const TOTAL_PERMISSIONS_COUNT = ALL_PERMISSIONS.length + 1; const ManageGroup: FC = ({ chatId, @@ -245,16 +260,7 @@ const ManageGroup: FC = ({ return 0; } - let totalCount = [ - 'sendMessages', - 'sendMedia', - 'embedLinks', - 'sendPolls', - 'changeInfo', - 'inviteUsers', - 'pinMessages', - 'manageTopics', - ].filter( + let totalCount = ALL_PERMISSIONS.filter( (key) => !chat.defaultBannedRights![key as keyof ApiChatBannedRights], ).length; @@ -347,7 +353,7 @@ const ManageGroup: FC = ({ > {lang('ChannelPermissions')} - {enabledPermissionsCount}/{TOTAL_PERMISSIONS_COUNT} + {enabledPermissionsCount}/{TOTAL_PERMISSIONS_COUNT - (chat.isForum ? 0 : 1)} = ({ }) => { const { updateChatDefaultBannedRights } = getActions(); - const [permissions, setPermissions] = useState({}); - const [havePermissionChanged, setHavePermissionChanged] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const { + permissions, havePermissionChanged, isLoading, handlePermissionChange, setIsLoading, + } = useManagePermissions(chat?.defaultBannedRights); const lang = useLang(); - const { isForum } = chat || {}; useHistoryBack({ @@ -92,30 +110,11 @@ const ManageGroupPermissions: FC = ({ onScreenSelect(ManagementScreens.GroupUserPermissions); }, [currentUserId, onChatMemberSelect, onScreenSelect]); - useEffect(() => { - setPermissions((chat?.defaultBannedRights) || {}); - setHavePermissionChanged(false); - setTimeout(() => { - setIsLoading(false); - }, FLOATING_BUTTON_ANIMATION_TIMEOUT_MS); - }, [chat]); - - const handlePermissionChange = useCallback((e: React.ChangeEvent) => { - const { name } = e.target; - - function getUpdatedPermissionValue(value: true | undefined) { - return value ? undefined : true; - } - - setPermissions((p) => ({ - ...p, - [name]: getUpdatedPermissionValue(p[name as Exclude]), - ...(name === 'sendStickers' && { - sendGifs: getUpdatedPermissionValue(p[name]), - }), - })); - setHavePermissionChanged(true); - }, []); + const [isMediaDropdownOpen, setIsMediaDropdownOpen] = useState(false); + const handleOpenMediaDropdown = useCallback((e: React.MouseEvent) => { + stopEvent(e); + setIsMediaDropdownOpen(!isMediaDropdownOpen); + }, [isMediaDropdownOpen]); const handleSavePermissions = useCallback(() => { if (!chat) { @@ -124,7 +123,7 @@ const ManageGroupPermissions: FC = ({ setIsLoading(true); updateChatDefaultBannedRights({ chatId: chat.id, bannedRights: permissions }); - }, [chat, permissions, updateChatDefaultBannedRights]); + }, [chat, permissions, setIsLoading, updateChatDefaultBannedRights]); const removedUsersCount = useMemo(() => { if (!chat || !chat.fullInfo || !chat.fullInfo.kickedMembers) { @@ -150,10 +149,11 @@ const ManageGroupPermissions: FC = ({ const { defaultBannedRights } = chat; - return Object.keys(bannedRights).reduce((result, key) => { + return Object.keys(bannedRights).reduce((result, k) => { + const key = k as keyof ApiChatBannedRights; if ( - !bannedRights[key as keyof ApiChatBannedRights] - || (defaultBannedRights?.[key as keyof ApiChatBannedRights]) + !bannedRights[key] + || (defaultBannedRights?.[key]) || key === 'sendInline' || key === 'viewMessages' || key === 'sendGames' ) { return result; @@ -172,15 +172,19 @@ const ManageGroupPermissions: FC = ({ }, [chat, lang]); return ( -
+
-
+

{lang('ChannelPermissionsHeader')}

= ({ checked={!permissions.sendMedia} label={lang('UserRestrictionsSendMedia')} blocking + rightIcon={isMediaDropdownOpen ? 'up' : 'down'} onChange={handlePermissionChange} + onClickLabel={handleOpenMediaDropdown} />
-
- +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
-
- -
-
- -
-
- -
-
- -
-
- -
- {isForum && ( + +
- )} +
+ +
+
+ +
+ {isForum && ( +
+ +
+ )} +
-
+
= ({
-
+

{lang('PrivacyExceptions')}

= ({ ))} diff --git a/src/components/right/management/ManageGroupUserPermissions.tsx b/src/components/right/management/ManageGroupUserPermissions.tsx index d95caeb45..6c627c569 100644 --- a/src/components/right/management/ManageGroupUserPermissions.tsx +++ b/src/components/right/management/ManageGroupUserPermissions.tsx @@ -8,6 +8,9 @@ import type { ApiChat, ApiChatBannedRights } from '../../../api/types'; import { ManagementScreens } from '../../../types'; import { selectChat } from '../../../global/selectors'; +import stopEvent from '../../../util/stopEvent'; +import buildClassName from '../../../util/buildClassName'; +import useManagePermissions from '../hooks/useManagePermissions'; import useLang from '../../../hooks/useLang'; import useFlag from '../../../hooks/useFlag'; import useHistoryBack from '../../../hooks/useHistoryBack'; @@ -33,6 +36,12 @@ type StateProps = { isFormFullyDisabled?: boolean; }; +const ITEM_HEIGHT = 24 + 32; +const SHIFT_HEIGHT_MINUS = 1; +const BEFORE_ITEMS_COUNT = 2; +const BEFORE_USER_INFO_HEIGHT = 96; +const ITEMS_COUNT = 9; + const ManageGroupUserPermissions: FC = ({ chat, selectedChatMemberId, @@ -43,9 +52,17 @@ const ManageGroupUserPermissions: FC = ({ }) => { const { updateChatMemberBannedRights } = getActions(); - const [permissions, setPermissions] = useState({}); - const [havePermissionChanged, setHavePermissionChanged] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const selectedChatMember = useMemo(() => { + if (!chat || !chat.fullInfo || !chat.fullInfo.members) { + return undefined; + } + + return chat.fullInfo.members.find(({ userId }) => userId === selectedChatMemberId); + }, [chat, selectedChatMemberId]); + + const { + permissions, havePermissionChanged, isLoading, handlePermissionChange, setIsLoading, + } = useManagePermissions(selectedChatMember?.bannedRights || chat?.defaultBannedRights); const [isBanConfirmationDialogOpen, openBanConfirmationDialog, closeBanConfirmationDialog] = useFlag(); const lang = useLang(); @@ -56,43 +73,12 @@ const ManageGroupUserPermissions: FC = ({ onBack: onClose, }); - const selectedChatMember = useMemo(() => { - if (!chat || !chat.fullInfo || !chat.fullInfo.members) { - return undefined; - } - - return chat.fullInfo.members.find(({ userId }) => userId === selectedChatMemberId); - }, [chat, selectedChatMemberId]); - useEffect(() => { if (chat?.fullInfo && selectedChatMemberId && !selectedChatMember) { onScreenSelect(ManagementScreens.GroupPermissions); } }, [chat, onScreenSelect, selectedChatMember, selectedChatMemberId]); - useEffect(() => { - setPermissions((selectedChatMember?.bannedRights) || (chat?.defaultBannedRights) || {}); - setHavePermissionChanged(false); - setIsLoading(false); - }, [chat, selectedChatMember]); - - const handlePermissionChange = useCallback((e: React.ChangeEvent) => { - const { name } = e.target; - - function getUpdatedPermissionValue(value: true | undefined) { - return value ? undefined : true; - } - - setPermissions((p) => ({ - ...p, - [name]: getUpdatedPermissionValue(p[name as Exclude]), - ...(name === 'sendStickers' && { - sendGifs: getUpdatedPermissionValue(p[name]), - }), - })); - setHavePermissionChanged(true); - }, []); - const handleSavePermissions = useCallback(() => { if (!chat || !selectedChatMemberId) { return; @@ -104,7 +90,7 @@ const ManageGroupUserPermissions: FC = ({ userId: selectedChatMemberId, bannedRights: permissions, }); - }, [chat, selectedChatMemberId, permissions, updateChatMemberBannedRights]); + }, [chat, selectedChatMemberId, setIsLoading, updateChatMemberBannedRights, permissions]); const handleBanFromGroup = useCallback(() => { if (!chat || !selectedChatMemberId) { @@ -132,116 +118,216 @@ const ManageGroupUserPermissions: FC = ({ return chat.defaultBannedRights[key]; }, [chat, isFormFullyDisabled]); + const [isMediaDropdownOpen, setIsMediaDropdownOpen] = useState(false); + const handleOpenMediaDropdown = useCallback((e: React.MouseEvent) => { + stopEvent(e); + setIsMediaDropdownOpen(!isMediaDropdownOpen); + }, [isMediaDropdownOpen]); + if (!selectedChatMember) { return undefined; } return ( -
+
-
+
- +

{lang('UserRestrictionsCanDo')}

+
-
- + +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
-
- -
-
- -
-
- -
-
- -
-
- -
- {isForum && ( + +
+
- )} +
+ +
+
+ +
+ {isForum && ( +
+ +
+ )} +
{!isFormFullyDisabled && ( -
+
{lang('UserRestrictionsBlock')} diff --git a/src/components/right/management/Management.scss b/src/components/right/management/Management.scss index 5d4fd725c..4cdce5eba 100644 --- a/src/components/right/management/Management.scss +++ b/src/components/right/management/Management.scss @@ -305,3 +305,61 @@ margin-top: 0.5rem; } } + +.DropdownList { + transition: 0.25s ease-in-out transform; + transform: translateY(calc(-100%)); + position: absolute; + width: 100%; + left: 0; + padding: 0 1.5rem 0 2.5rem; + background: var(--color-background); + + &--open { + transform: translateY(-2rem); + } +} + +.DropdownListTrap { + height: 0; + width: 100%; + + &::before { + position: absolute; + top: 0; + left: 0; + right: 0; + height: calc(var(--before-shift-height) + 2.5rem); + background: var(--color-background); + content: ""; + z-index: 1; + } +} + +.with-shifted-dropdown { + .ListItem, .section-heading { + position: relative; + z-index: 2; + } + + .without-bottom-shadow { + box-shadow: none; + padding-bottom: 0; + } + + .part { + background-color: var(--color-background); + box-shadow: inset 0 -0.0625rem 0 0 var(--color-background-secondary-accent); + margin: 0 -1.5rem; + padding: 0 1.5rem 1rem; + } + + .section, .part { + position: relative; + transition: 0.25s ease-in-out transform; + + &.shifted { + transform: translateY(var(--shift-height)); + } + } +} diff --git a/src/components/ui/Checkbox.scss b/src/components/ui/Checkbox.scss index 29edb8159..b1bc8a96c 100644 --- a/src/components/ui/Checkbox.scss +++ b/src/components/ui/Checkbox.scss @@ -70,6 +70,7 @@ .Checkbox-main { &::before, &::after { + pointer-events: none; content: ""; display: block; position: absolute; @@ -96,12 +97,19 @@ } .label { - display: block; + display: flex; + align-items: center; text-align: initial; flex-wrap: wrap; column-gap: 0.25rem; } + .right-icon { + margin-left: auto; + color: var(--color-text-secondary); + font-size: 1.25rem; + } + .subLabel { display: block; font-size: 0.875rem; diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx index ddf04a333..20c3f09c6 100644 --- a/src/components/ui/Checkbox.tsx +++ b/src/components/ui/Checkbox.tsx @@ -1,6 +1,6 @@ import type { ChangeEvent } from 'react'; import type { FC, TeactNode } from '../../lib/teact/teact'; -import React, { memo, useCallback } from '../../lib/teact/teact'; +import React, { memo, useCallback, useRef } from '../../lib/teact/teact'; import buildClassName from '../../util/buildClassName'; import useLang from '../../hooks/useLang'; @@ -17,6 +17,7 @@ type OwnProps = { label: TeactNode; subLabel?: string; checked: boolean; + rightIcon?: string; disabled?: boolean; tabIndex?: number; round?: boolean; @@ -26,6 +27,7 @@ type OwnProps = { className?: string; onChange?: (e: ChangeEvent) => void; onCheck?: (isChecked: boolean) => void; + onClickLabel?: (e: React.MouseEvent) => void; }; const Checkbox: FC = ({ @@ -41,10 +43,16 @@ const Checkbox: FC = ({ blocking, isLoading, className, + rightIcon, onChange, onCheck, + onClickLabel, }) => { const lang = useLang(); + + // eslint-disable-next-line no-null/no-null + const labelRef = useRef(null); + const handleChange = useCallback((event: ChangeEvent) => { if (onChange) { onChange(event); @@ -55,6 +63,16 @@ const Checkbox: FC = ({ } }, [onChange, onCheck]); + function handleClick(event: React.MouseEvent) { + if (event.target !== labelRef.current) { + onClickLabel?.(event); + } + } + + function handleInputClick(event: React.MouseEvent) { + event.stopPropagation(); + } + const labelClassName = buildClassName( 'Checkbox', disabled && 'disabled', @@ -65,7 +83,13 @@ const Checkbox: FC = ({ ); return ( -