diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index a48a38798..f797b2f1e 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -16,6 +16,7 @@ import type { ApiMessageActionStarGift, ApiMessageEntity, ApiMessageForwardInfo, + ApiMessageReportResult, ApiNewPoll, ApiPeer, ApiPhoto, @@ -1246,3 +1247,33 @@ export function buildApiQuickReply(reply: GramJs.TypeQuickReply): ApiQuickReply topMessageId: topMessage, }; } + +export function buildApiReportResult( + result: GramJs.TypeReportResult, +): ApiMessageReportResult { + if (result instanceof GramJs.ReportResultReported) { + return { + type: 'reported', + }; + } + + if (result instanceof GramJs.ReportResultAddComment) { + return { + type: 'comment', + isOptional: result.optional, + option: serializeBytes(result.option), + }; + } + + const title = result.title; + const options = result.options.map((option) => ({ + text: option.text, + option: serializeBytes(option.option), + })); + + return { + type: 'selectOption', + title, + options, + }; +} diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index c155cc2f0..a2849342e 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -8,6 +8,7 @@ import type { ApiChat, ApiClickSponsoredMessage, ApiContact, + ApiError, ApiFormattedText, ApiGlobalMessageSearchType, ApiInputReplyInfo, @@ -19,7 +20,6 @@ import type { ApiPeer, ApiPoll, ApiReaction, - ApiReportReason, ApiSendMessageAction, ApiSticker, ApiStory, @@ -36,6 +36,7 @@ import { GIF_MIME_TYPE, MAX_INT_32, MENTION_UNREAD_SLICE, + MESSAGE_ID_REQUIRED_ERROR, PINNED_MESSAGES_LIMIT, REACTION_UNREAD_SLICE, SUPPORTED_PHOTO_CONTENT_TYPES, @@ -47,13 +48,17 @@ import { compact, split } from '../../../util/iteratees'; import { getMessageKey } from '../../../util/keys/messageKey'; import { getServerTimeOffset } from '../../../util/serverTime'; import { interpolateArray } from '../../../util/waveform'; -import { buildApiChatFromPreview, buildApiSendAsPeerId } from '../apiBuilders/chats'; +import { + buildApiChatFromPreview, + buildApiSendAsPeerId, +} from '../apiBuilders/chats'; import { buildApiFormattedText } from '../apiBuilders/common'; import { buildMessageMediaContent, buildMessageTextContent, buildWebPage } from '../apiBuilders/messageContent'; import { buildApiFactCheck, buildApiMessage, buildApiQuickReply, + buildApiReportResult, buildApiSponsoredMessage, buildApiThreadInfo, buildLocalForwardedMessage, @@ -901,18 +906,45 @@ export async function deleteSavedHistory({ } export async function reportMessages({ - peer, messageIds, description, + peer, messageIds, description, option, }: { - peer: ApiPeer; messageIds: number[]; reason: ApiReportReason; description?: string; + peer: ApiPeer; messageIds: number[]; description: string; option: string; }) { - const result = await invokeRequest(new GramJs.messages.Report({ - peer: buildInputPeer(peer.id, peer.accessHash), - id: messageIds, - option: Buffer.alloc(0), - message: description, - })); + try { + const result = await invokeRequest(new GramJs.messages.Report({ + peer: buildInputPeer(peer.id, peer.accessHash), + id: messageIds, + option: deserializeBytes(option), + message: description, + }), { shouldThrow: true }); - return result; + if (!result) return undefined; + + return { result: buildApiReportResult(result), error: undefined }; + } catch (err: any) { + const errorMessage = (err as ApiError).message; + + if (errorMessage === MESSAGE_ID_REQUIRED_ERROR) { + return { + result: undefined, + error: errorMessage, + }; + } + + throw err; + } +} + +export function reportChannelSpam({ + peer, chat, messageIds, +}: { + peer: ApiPeer; chat: ApiChat; messageIds: number[]; +}) { + return invokeRequest(new GramJs.channels.ReportSpam({ + participant: buildInputPeer(peer.id, peer.accessHash), + channel: buildInputEntity(chat.id, chat.accessHash) as GramJs.InputChannel, + id: messageIds, + })); } export async function sendMessageAction({ diff --git a/src/api/gramjs/methods/stories.ts b/src/api/gramjs/methods/stories.ts index 0e3ce55de..d4bf066f6 100644 --- a/src/api/gramjs/methods/stories.ts +++ b/src/api/gramjs/methods/stories.ts @@ -2,16 +2,17 @@ import { Api as GramJs } from '../../../lib/gramjs'; import type { ApiInputPrivacyRules } from '../../../types'; import type { + ApiError, ApiPeer, ApiPeerStories, ApiReaction, - ApiReportReason, ApiStealthMode, ApiTypeStory, } from '../../types'; -import { STORY_LIST_LIMIT } from '../../../config'; +import { MESSAGE_ID_REQUIRED_ERROR, STORY_LIST_LIMIT } from '../../../config'; import { buildCollectionByCallback } from '../../../util/iteratees'; +import { buildApiReportResult } from '../apiBuilders/messages'; import { getApiChatIdFromMtpPeer } from '../apiBuilders/peers'; import { buildApiPeerStories, @@ -25,7 +26,7 @@ import { buildInputPrivacyRules, buildInputReaction, } from '../gramjsBuilders'; -import { addStoryToLocalDb } from '../helpers'; +import { addStoryToLocalDb, deserializeBytes } from '../helpers'; import { invokeRequest } from './client'; export async function fetchAllStories({ @@ -328,19 +329,37 @@ export async function fetchStoryLink({ peer, storyId }: { peer: ApiPeer ; storyI return result.link; } -export function reportStory({ +export async function reportStory({ peer, storyId, description, + option, }: { - peer: ApiPeer; storyId: number; reason: ApiReportReason; description?: string; + peer: ApiPeer; storyId: number; description: string; option: string; }) { - return invokeRequest(new GramJs.stories.Report({ - peer: buildInputPeer(peer.id, peer.accessHash), - id: [storyId], - option: Buffer.alloc(0), - message: description, - })); + try { + const result = await invokeRequest(new GramJs.stories.Report({ + peer: buildInputPeer(peer.id, peer.accessHash), + id: [storyId], + option: deserializeBytes(option), + message: description, + }), { shouldThrow: true }); + + if (!result) return undefined; + + return { result: buildApiReportResult(result), error: undefined }; + } catch (err: any) { + const errorMessage = (err as ApiError).message; + + if (errorMessage === MESSAGE_ID_REQUIRED_ERROR) { + return { + result: undefined, + error: errorMessage, + }; + } + + throw err; + } } export function editStoryPrivacy({ diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 288d6b967..9d4c46c29 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -871,6 +871,21 @@ export interface ApiMessageThreadInfo extends ApiBaseThreadInfo { export type ApiThreadInfo = ApiCommentsInfo | ApiMessageThreadInfo; +export type ApiMessageReportResult = { + type: 'reported'; +} | { + type: 'comment'; + isOptional?: boolean; + option: string; +} | { + type: 'selectOption'; + title: string; + options: { + text: string; + option: string; + }[]; +}; + export type ApiMessageOutgoingStatus = 'read' | 'succeeded' | 'pending' | 'failed'; export type ApiSponsoredMessage = { diff --git a/src/assets/tgs/Report.tgs b/src/assets/tgs/Report.tgs new file mode 100644 index 000000000..02d5a9de6 Binary files /dev/null and b/src/assets/tgs/Report.tgs differ diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index f55458cb0..f0abbea3d 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -29,6 +29,7 @@ export { default as AboutAdsModal } from '../components/common/AboutAdsModal'; export { default as AboutMonetizationModal } from '../components/common/AboutMonetizationModal'; export { default as VerificationMonetizationModal } from '../components/common/VerificationMonetizationModal'; export { default as ReportAdModal } from '../components/modals/reportAd/ReportAdModal'; +export { default as ReportModal } from '../components/modals/reportModal/ReportModal'; export { default as CalendarModal } from '../components/common/CalendarModal'; export { default as DeleteMessageModal } from '../components/common/DeleteMessageModal'; export { default as PinMessageModal } from '../components/common/PinMessageModal'; diff --git a/src/components/common/DeleteMessageModal.tsx b/src/components/common/DeleteMessageModal.tsx index c94209751..9e1d74a4a 100644 --- a/src/components/common/DeleteMessageModal.tsx +++ b/src/components/common/DeleteMessageModal.tsx @@ -96,7 +96,7 @@ const DeleteMessageModal: FC = ({ const { deleteMessages, deleteScheduledMessages, - reportMessages, + reportChannelSpam, deleteChatMember, updateChatMemberBannedRights, closeDeleteMessageModal, @@ -231,10 +231,10 @@ const DeleteMessageModal: FC = ({ if (isSchedule) { deleteScheduledMessages({ messageIds }); } else if (!isOwn && (chosenSpanOption || chosenDeleteOption || chosenBanOption) && (isGroup || isSuperGroup)) { - if (chosenSpanOption) { + if (chosenSpanOption && sender) { const filteredMessageIdList = filterMessageIdByUserId(chosenSpanOption, messageIdList!); if (filteredMessageIdList && filteredMessageIdList.length) { - reportMessages({ messageIds: filteredMessageIdList, reason: 'spam', description: '' }); + reportChannelSpam({ participantId: sender.id, chatId: chat.id, messageIds: filteredMessageIdList }); } } diff --git a/src/components/common/ReportModal.tsx b/src/components/common/ReportAvatarModal.tsx similarity index 66% rename from src/components/common/ReportModal.tsx rename to src/components/common/ReportAvatarModal.tsx index ef74b1da9..783d7eb4b 100644 --- a/src/components/common/ReportModal.tsx +++ b/src/components/common/ReportAvatarModal.tsx @@ -5,8 +5,6 @@ import { getActions } from '../../global'; import type { ApiPhoto, ApiReportReason } from '../../api/types'; -import buildClassName from '../../util/buildClassName'; - import useLastCallback from '../../hooks/useLastCallback'; import useOldLang from '../../hooks/useOldLang'; @@ -17,55 +15,28 @@ import RadioGroup from '../ui/RadioGroup'; export type OwnProps = { isOpen: boolean; - subject?: 'peer' | 'messages' | 'media' | 'story'; peerId?: string; photo?: ApiPhoto; - messageIds?: number[]; - storyId?: number; onClose: () => void; onCloseAnimationEnd?: () => void; }; -const ReportModal: FC = ({ +const ReportAvatarModal: FC = ({ isOpen, - subject = 'messages', peerId, photo, - messageIds, - storyId, onClose, onCloseAnimationEnd, }) => { - const { - reportMessages, - reportPeer, - reportProfilePhoto, - reportStory, - exitMessageSelectMode, - } = getActions(); + const { reportProfilePhoto } = getActions(); const [selectedReason, setSelectedReason] = useState('spam'); const [description, setDescription] = useState(''); const handleReport = useLastCallback(() => { - switch (subject) { - case 'messages': - reportMessages({ messageIds: messageIds!, reason: selectedReason, description }); - exitMessageSelectMode(); - break; - case 'peer': - reportPeer({ chatId: peerId, reason: selectedReason, description }); - break; - case 'media': - reportProfilePhoto({ - chatId: peerId, photo, reason: selectedReason, description, - }); - break; - case 'story': - reportStory({ - peerId: peerId!, storyId: storyId!, reason: selectedReason, description, - }); - } + reportProfilePhoto({ + chatId: peerId, photo, reason: selectedReason, description, + }); onClose(); }); @@ -90,18 +61,11 @@ const ReportModal: FC = ({ { value: 'other', label: lang('lng_report_reason_other') }, ], [lang]); - if ( - (subject === 'messages' && !messageIds) - || (subject === 'peer' && !peerId) - || (subject === 'media' && (!peerId || !photo)) - || (subject === 'story' && (!storyId || !peerId)) - ) { + if (!peerId || !photo) { return undefined; } - const title = subject === 'messages' - ? lang('lng_report_message_title') - : lang('ReportPeer.Report'); + const title = lang('ReportPeer.Report'); return ( = ({ onClose={onClose} onEnter={isOpen ? handleReport : undefined} onCloseAnimationEnd={onCloseAnimationEnd} - className={buildClassName('narrow', subject === 'story' && 'component-theme-dark')} + className="narrow" title={title} > = ({ ); }; -export default memo(ReportModal); +export default memo(ReportAvatarModal); diff --git a/src/components/common/helpers/animatedAssets.ts b/src/components/common/helpers/animatedAssets.ts index 4053e6b0f..1642b2aa0 100644 --- a/src/components/common/helpers/animatedAssets.ts +++ b/src/components/common/helpers/animatedAssets.ts @@ -19,6 +19,7 @@ import MonkeyIdle from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyIdle.tgs import MonkeyPeek from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyPeek.tgs'; import MonkeyTracking from '../../../assets/tgs/monkeys/TwoFactorSetupMonkeyTracking.tgs'; import ReadTime from '../../../assets/tgs/ReadTime.tgs'; +import Report from '../../../assets/tgs/Report.tgs'; import Congratulations from '../../../assets/tgs/settings/Congratulations.tgs'; import DiscussionGroups from '../../../assets/tgs/settings/DiscussionGroupsDucks.tgs'; import Experimental from '../../../assets/tgs/settings/Experimental.tgs'; @@ -62,4 +63,5 @@ export const LOCAL_TGS_URLS = { Fragment, StarReactionEffect, StarReaction, + Report, }; diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index 37cda8a2f..b9c05d468 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -69,7 +69,6 @@ import DeleteChatModal from '../../common/DeleteChatModal'; import FullNameTitle from '../../common/FullNameTitle'; import StarIcon from '../../common/icons/StarIcon'; import LastMessageMeta from '../../common/LastMessageMeta'; -import ReportModal from '../../common/ReportModal'; import ListItem from '../../ui/ListItem'; import ChatFolderModal from '../ChatFolderModal.async'; import MuteChatModal from '../MuteChatModal.async'; @@ -170,17 +169,16 @@ const Chat: FC = ({ openForumPanel, closeForumPanel, setShouldCloseRightColumn, + reportMessages, } = getActions(); const { isMobile } = useAppLayout(); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag(); const [isMuteModalOpen, openMuteModal, closeMuteModal] = useFlag(); const [isChatFolderModalOpen, openChatFolderModal, closeChatFolderModal] = useFlag(); - const [isReportModalOpen, openReportModal, closeReportModal] = useFlag(); const [shouldRenderDeleteModal, markRenderDeleteModal, unmarkRenderDeleteModal] = useFlag(); const [shouldRenderMuteModal, markRenderMuteModal, unmarkRenderMuteModal] = useFlag(); const [shouldRenderChatFolderModal, markRenderChatFolderModal, unmarkRenderChatFolderModal] = useFlag(); - const [shouldRenderReportModal, markRenderReportModal, unmarkRenderReportModal] = useFlag(); const { isForum, isForumAsMessages } = chat || {}; @@ -275,8 +273,8 @@ const Chat: FC = ({ }); const handleReport = useLastCallback(() => { - markRenderReportModal(); - openReportModal(); + if (!chat) return; + reportMessages({ chatId: chat.id, messageIds: [] }); }); const contextActions = useChatContextActions({ @@ -432,15 +430,6 @@ const Chat: FC = ({ chatId={chatId} /> )} - {shouldRenderReportModal && ( - - )} ); }; diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index d94e6ca9d..9a0bd49a5 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -53,7 +53,7 @@ import usePreviousDeprecated from '../../hooks/usePreviousDeprecated'; import { dispatchPriorityPlaybackEvent } from '../../hooks/usePriorityPlaybackCheck'; import { useMediaProps } from './hooks/useMediaProps'; -import ReportModal from '../common/ReportModal'; +import ReportAvatarModal from '../common/ReportAvatarModal'; import Button from '../ui/Button'; import ShowTransition from '../ui/ShowTransition'; import Transition from '../ui/Transition'; @@ -446,10 +446,9 @@ const MediaViewer = ({ onCloseMediaViewer={handleClose} onForward={handleForward} /> - diff --git a/src/components/middle/DeleteSelectedMessageModal.tsx b/src/components/middle/DeleteSelectedMessageModal.tsx index 11edd5ed7..e06bbb606 100644 --- a/src/components/middle/DeleteSelectedMessageModal.tsx +++ b/src/components/middle/DeleteSelectedMessageModal.tsx @@ -94,7 +94,7 @@ const DeleteSelectedMessageModal: FC = ({ }) => { const { deleteMessages, - reportMessages, + reportChannelSpam, deleteChatMember, deleteScheduledMessages, exitMessageSelectMode, @@ -227,6 +227,18 @@ const DeleteSelectedMessageModal: FC = ({ }); }); + const handleReportSpam = useLastCallback((userMessagesMap: Record) => { + Object.entries(userMessagesMap).forEach(([userId, messageIdList]) => { + if (messageIdList.length) { + reportChannelSpam({ + participantId: userId, + chatId: chat!.id, + messageIds: messageIdList, + }); + } + }); + }); + const handleDeleteMessages = useLastCallback((filteredMessageIdList: number[]) => { if (filteredMessageIdList && filteredMessageIdList.length) { deleteMessages({ messageIds: filteredMessageIdList, shouldDeleteForAll: true }); @@ -257,10 +269,18 @@ const DeleteSelectedMessageModal: FC = ({ } else if (!isSenderOwner && shouldShowOptions) { if (chosenSpanOption) { const userIdList = chosenSpanOption.filter((option) => !Number.isNaN(Number(option))); - const filteredMessageIdList = filterMessageIdByUserId(userIdList, selectedMessageIds!); - if (filteredMessageIdList?.length) { - reportMessages({ messageIds: filteredMessageIdList, reason: 'spam', description: '' }); - } + const userMessagesMap = selectedMessageIds!.reduce>((acc, msgId) => { + const sender = selectSenderFromMessage(getGlobal(), chat.id, msgId); + if (sender && userIdList.includes(sender.id)) { + if (!acc[sender.id]) { + acc[sender.id] = []; + } + acc[sender.id].push(Number(msgId)); + } + return acc; + }, {}); + + handleReportSpam(userMessagesMap); } if (chosenDeleteOption) { diff --git a/src/components/middle/HeaderMenuContainer.tsx b/src/components/middle/HeaderMenuContainer.tsx index 29b29f345..b83042457 100644 --- a/src/components/middle/HeaderMenuContainer.tsx +++ b/src/components/middle/HeaderMenuContainer.tsx @@ -49,7 +49,6 @@ import usePrevDuringAnimation from '../../hooks/usePrevDuringAnimation'; import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated'; import DeleteChatModal from '../common/DeleteChatModal'; -import ReportModal from '../common/ReportModal'; import MuteChatModal from '../left/MuteChatModal.async'; import Menu from '../ui/Menu'; import MenuItem from '../ui/MenuItem'; @@ -109,8 +108,8 @@ type StateProps = { isForum?: boolean; isForumAsMessages?: true; canAddContact?: boolean; - canReportChat?: boolean; canDeleteChat?: boolean; + canReportChat?: boolean; canGift?: boolean; canCreateTopic?: boolean; canEditTopic?: boolean; @@ -143,6 +142,7 @@ const HeaderMenuContainer: FC = ({ isChatInfoShown, canStartBot, canSubscribe, + canReportChat, canSearch, canCall, canMute, @@ -156,7 +156,6 @@ const HeaderMenuContainer: FC = ({ chat, isPrivate, isMuted, - canReportChat, canDeleteChat, canGift, hasLinkedChat, @@ -203,13 +202,13 @@ const HeaderMenuContainer: FC = ({ unblockUser, setViewForumAsMessages, openBoostModal, + reportMessages, } = getActions(); const { isMobile } = useAppLayout(); const [isMenuOpen, setIsMenuOpen] = useState(true); const [shouldCloseFast, setShouldCloseFast] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [isReportModalOpen, setIsReportModalOpen] = useState(false); const [isMuteModalOpen, setIsMuteModalOpen] = useState(false); const [shouldRenderMuteModal, markRenderMuteModal, unmarkRenderMuteModal] = useFlag(); const { x, y } = anchor; @@ -219,18 +218,14 @@ const HeaderMenuContainer: FC = ({ (!isChatInfoShown && isForum) ? true : undefined, CLOSE_MENU_ANIMATION_DURATION, ); - const handleReport = useLastCallback(() => { - setIsMenuOpen(false); - setIsReportModalOpen(true); - }); - - const closeReportModal = useLastCallback(() => { - setIsReportModalOpen(false); + const closeMuteModal = useLastCallback(() => { + setIsMuteModalOpen(false); onClose(); }); - const closeMuteModal = useLastCallback(() => { - setIsMuteModalOpen(false); + const handleReport = useLastCallback(() => { + setIsMenuOpen(false); + reportMessages({ chatId, messageIds: [] }); onClose(); }); @@ -725,14 +720,6 @@ const HeaderMenuContainer: FC = ({ chatId={chat.id} /> )} - {canReportChat && chat?.id && ( - - )} ); @@ -749,8 +736,8 @@ export default memo(withGlobal( const canAddContact = user && getCanAddContact(user); const isMainThread = threadId === MAIN_THREAD_ID; const isChatWithSelf = selectIsChatWithSelf(global, chatId); - const canReportChat = isMainThread && (isChatChannel(chat) || isChatGroup(chat) || (user && !user.isSelf)); const { chatId: currentChatId, threadId: currentThreadId } = selectCurrentMessageList(global) || {}; + const canReportChat = isMainThread && !user && (isChatChannel(chat) || isChatGroup(chat)); const chatBot = !isSystemBot(chatId) ? selectBot(global, chatId) : undefined; const userFullInfo = isPrivate ? selectUserFullInfo(global, chatId) : undefined; @@ -778,8 +765,8 @@ export default memo(withGlobal( isForum: chat?.isForum, isForumAsMessages: chat?.isForumAsMessages, canAddContact, - canReportChat, canDeleteChat: getCanDeleteChat(chat), + canReportChat, canGift, hasLinkedChat: Boolean(chatFullInfo?.linkedChatId), botCommands: chatBot ? userFullInfo?.botInfo?.commands : undefined, diff --git a/src/components/middle/MessageSelectToolbar.tsx b/src/components/middle/MessageSelectToolbar.tsx index a383a4b45..1db1dca6f 100644 --- a/src/components/middle/MessageSelectToolbar.tsx +++ b/src/components/middle/MessageSelectToolbar.tsx @@ -2,6 +2,7 @@ import type { FC } from '../../lib/teact/teact'; import React, { memo, useEffect } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; +import type { ApiChat } from '../../api/types'; import type { MessageListType } from '../../global/types'; import type { IconName } from '../../types/icons'; @@ -9,7 +10,7 @@ import { selectCanDeleteSelectedMessages, selectCanDownloadSelectedMessages, selectCanForwardMessages, - selectCanReportSelectedMessages, + selectCanReportSelectedMessages, selectCurrentChat, selectCurrentMessageList, selectHasProtectedMessage, selectSelectedMessagesCount, selectTabState, @@ -23,7 +24,6 @@ import useOldLang from '../../hooks/useOldLang'; import usePreviousDeprecated from '../../hooks/usePreviousDeprecated'; import useCopySelectedMessages from './hooks/useCopySelectedMessages'; -import ReportModal from '../common/ReportModal'; import Button from '../ui/Button'; import DeleteSelectedMessageModal from './DeleteSelectedMessageModal'; @@ -36,6 +36,7 @@ export type OwnProps = { }; type StateProps = { + chat?: ApiChat; isSchedule: boolean; selectedMessagesCount?: number; canDeleteMessages?: boolean; @@ -48,6 +49,7 @@ type StateProps = { }; const MessageSelectToolbar: FC = ({ + chat, canPost, isActive, messageListType, @@ -67,11 +69,11 @@ const MessageSelectToolbar: FC = ({ downloadSelectedMessages, copySelectedMessages, showNotification, + reportMessages, } = getActions(); const lang = useOldLang(); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag(); - const [isReportModalOpen, openReportModal, closeReportModal] = useFlag(); useCopySelectedMessages(isActive); @@ -80,7 +82,7 @@ const MessageSelectToolbar: FC = ({ }); useEffect(() => { - return isActive && !isDeleteModalOpen && !isReportModalOpen && !isAnyModalOpen + return isActive && !isDeleteModalOpen && !isAnyModalOpen ? captureKeyboardListeners({ onBackspace: canDeleteMessages ? openDeleteModal : undefined, onDelete: canDeleteMessages ? openDeleteModal : undefined, @@ -88,7 +90,7 @@ const MessageSelectToolbar: FC = ({ }) : undefined; }, [ - isActive, isDeleteModalOpen, isReportModalOpen, openDeleteModal, handleExitMessageSelectMode, isAnyModalOpen, + isActive, isDeleteModalOpen, openDeleteModal, handleExitMessageSelectMode, isAnyModalOpen, canDeleteMessages, ]); @@ -110,6 +112,15 @@ const MessageSelectToolbar: FC = ({ const formattedMessagesCount = lang('VoiceOver.Chat.MessagesSelected', renderingSelectedMessagesCount, 'i'); + const openMessageReport = useLastCallback(() => { + if (!selectedMessageIds || !chat) return; + reportMessages({ + chatId: chat.id, + messageIds: selectedMessageIds, + }); + exitMessageSelectMode(); + }); + const className = buildClassName( 'MessageSelectToolbar', canPost && 'with-composer', @@ -160,7 +171,7 @@ const MessageSelectToolbar: FC = ({ ) )} {canReportMessages && ( - renderButton('flag', lang('Conversation.ReportMessages'), openReportModal) + renderButton('flag', lang('Conversation.ReportMessages'), openMessageReport) )} {canDownloadMessages && !hasProtectedMessage && ( renderButton('download', lang('lng_media_download'), handleDownload) @@ -181,11 +192,6 @@ const MessageSelectToolbar: FC = ({ onClose={closeDeleteModal} /> )} - ); }; @@ -193,6 +199,7 @@ const MessageSelectToolbar: FC = ({ export default memo(withGlobal( (global): StateProps => { const tabState = selectTabState(global); + const chat = selectCurrentChat(global); const { type: messageListType, chatId } = selectCurrentMessageList(global) || {}; const isSchedule = messageListType === 'scheduled'; const { canDelete } = selectCanDeleteSelectedMessages(global); @@ -203,9 +210,10 @@ export default memo(withGlobal( const canForward = !isSchedule && chatId ? selectCanForwardMessages(global, chatId, selectedMessageIds) : false; const isShareMessageModalOpen = tabState.isShareMessageModalShown; const isAnyModalOpen = Boolean(isShareMessageModalOpen || tabState.requestedDraft - || tabState.requestedAttachBotInChat || tabState.requestedAttachBotInstall); + || tabState.requestedAttachBotInChat || tabState.requestedAttachBotInstall || tabState.reportModal); return { + chat, isSchedule, selectedMessagesCount: selectSelectedMessagesCount(global), canDeleteMessages: canDelete, diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index fe2cf63f2..8a4f891b9 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -6,6 +6,7 @@ import { getActions, getGlobal, withGlobal } from '../../../global'; import type { ApiAvailableReaction, + ApiChat, ApiChatReactions, ApiMessage, ApiPoll, @@ -72,7 +73,6 @@ import useSchedule from '../../../hooks/useSchedule'; import useShowTransition from '../../../hooks/useShowTransition'; import PinMessageModal from '../../common/PinMessageModal.async'; -import ReportModal from '../../common/ReportModal'; import ConfirmDialog from '../../ui/ConfirmDialog'; import MessageContextMenu from './MessageContextMenu'; @@ -94,6 +94,7 @@ type StateProps = { threadId?: ThreadId; poll?: ApiPoll; story?: ApiTypeStory; + chat?: ApiChat; availableReactions?: ApiAvailableReaction[]; topReactions?: ApiReaction[]; defaultTagReactions?: ApiReaction[]; @@ -169,8 +170,9 @@ const ContextMenuContainer: FC = ({ repliesThreadInfo, canUnpin, canDelete, - canReport, canShowReactionsCount, + chat, + canReport, canShowReactionList, canEdit, enabledReactions, @@ -241,6 +243,7 @@ const ContextMenuContainer: FC = ({ openDeleteMessageModal, addLocalPaidReaction, openPaidReactionModal, + reportMessages, } = getActions(); const lang = useOldLang(); @@ -250,7 +253,6 @@ const ContextMenuContainer: FC = ({ className: false, }); const [isMenuOpen, setIsMenuOpen] = useState(true); - const [isReportModalOpen, setIsReportModalOpen] = useState(false); const [isPinModalOpen, setIsPinModalOpen] = useState(false); const [isClosePollDialogOpen, openClosePollDialog, closeClosePollDialog] = useFlag(); const [canQuoteSelection, setCanQuoteSelection] = useState(false); @@ -366,16 +368,6 @@ const ContextMenuContainer: FC = ({ openDeleteMessageModal({ isSchedule: messageListType === 'scheduled', album, message }); }); - const handleReport = useLastCallback(() => { - setIsMenuOpen(false); - setIsReportModalOpen(true); - }); - - const closeReportModal = useLastCallback(() => { - setIsReportModalOpen(false); - onClose(); - }); - const closePinModal = useLastCallback(() => { setIsPinModalOpen(false); onClose(); @@ -589,6 +581,15 @@ const ContextMenuContainer: FC = ({ const reportMessageIds = useMemo(() => (album ? album.messages : [message]).map(({ id }) => id), [album, message]); + const handleReport = useLastCallback(() => { + if (!chat) return; + setIsMenuOpen(false); + onClose(); + reportMessages({ + chatId: chat.id, messageIds: reportMessageIds, + }); + }); + if (noOptions) { closeMenu(); @@ -622,8 +623,8 @@ const ContextMenuContainer: FC = ({ canReply={canReply} canQuote={canQuoteSelection} canDelete={canDelete} - canReport={canReport} canPin={canPin} + canReport={canReport} repliesThreadInfo={repliesThreadInfo} canUnpin={canUnpin} canEdit={canEdit} @@ -683,11 +684,6 @@ const ContextMenuContainer: FC = ({ onShowOriginal={handleShowOriginal} onSelectLanguage={handleSelectLanguage} /> - ( return { threadId, + chat, availableReactions, topReactions, defaultTagReactions: defaultTags, noOptions, + canReport, canSendNow: isScheduled, canReschedule: isScheduled, canReply: !isPinned && !isScheduled && canReplyGlobally, canPin: !isScheduled && canPin, canUnpin: !isScheduled && canUnpin, canDelete, - canReport, canEdit: !isPinned && canEdit, canForward: !isScheduled && canForward, canFaveSticker: !isScheduled && canFaveSticker, diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index 0e5bda322..63feafbbe 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -101,8 +101,8 @@ type OwnProps = { onUnpin?: NoneToVoidFunction; onForward?: NoneToVoidFunction; onDelete?: NoneToVoidFunction; - onReport?: NoneToVoidFunction; onFaveSticker?: NoneToVoidFunction; + onReport?: NoneToVoidFunction; onUnfaveSticker?: NoneToVoidFunction; onSelect?: NoneToVoidFunction; onSend?: NoneToVoidFunction; @@ -161,8 +161,8 @@ const MessageContextMenu: FC = ({ canPin, canUnpin, canDelete, - canReport, canForward, + canReport, canFaveSticker, canUnfaveSticker, canCopy, @@ -194,8 +194,8 @@ const MessageContextMenu: FC = ({ onUnpin, onForward, onDelete, - onReport, onFaveSticker, + onReport, onUnfaveSticker, onSelect, onSend, diff --git a/src/components/modals/ModalContainer.tsx b/src/components/modals/ModalContainer.tsx index 14add8a42..4ef21e6a5 100644 --- a/src/components/modals/ModalContainer.tsx +++ b/src/components/modals/ModalContainer.tsx @@ -20,6 +20,7 @@ import MapModal from './map/MapModal.async'; import OneTimeMediaModal from './oneTimeMedia/OneTimeMediaModal.async'; import PaidReactionModal from './paidReaction/PaidReactionModal.async'; import ReportAdModal from './reportAd/ReportAdModal.async'; +import ReportModal from './reportModal/ReportModal.async'; import StarsGiftModal from './stars/gift/StarsGiftModal.async'; import StarsBalanceModal from './stars/StarsBalanceModal.async'; import StarsPaymentModal from './stars/StarsPaymentModal.async'; @@ -40,6 +41,7 @@ type ModalKey = keyof Pick = (props) => { + const { modal } = props; + const ReportModal = useModuleLoader(Bundles.Extra, 'ReportModal', !modal); + + // eslint-disable-next-line react/jsx-props-no-spreading + return ReportModal ? : undefined; +}; + +export default ReportModalAsync; diff --git a/src/components/modals/reportModal/ReportModal.module.scss b/src/components/modals/reportModal/ReportModal.module.scss new file mode 100644 index 000000000..871570401 --- /dev/null +++ b/src/components/modals/reportModal/ReportModal.module.scss @@ -0,0 +1,92 @@ +.slide { // Do not remove .slide, identifier used in JS + overflow-x: hidden; +} + +.root { + :global(.modal-dialog) { + max-width: 23rem; + } +} + +.modalTitle { + display: flex; + flex-direction: column; + margin-left: 1rem; +} + +.header { + display: flex; + align-items: center; +} + +.optionText { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.option { + margin-bottom: 0 !important; +} + +.optionButton { + padding-block: 0 !important; + padding: 0 0 0 1rem !important; +} + +.sectionTitle { + color: var(--color-primary); + padding-inline: 1rem; + margin-top: 0.75rem; + font-weight: 500; + font-size: 1rem; +} + +.description { + margin-top: 0.75rem; + margin-bottom: 0; + color: var(--color-text-secondary); + padding-inline: 1rem; +} + +.title { + margin-bottom: 0; + font-size: 1.1875rem; +} + +.hasDepth { + margin-top: 0; +} + +.titleMultiline > .title { + font-size: 1rem; +} + +.subtitle { + font-size: 0.875rem; + color: var(--color-text-secondary); +} + +.transition { + /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ + transition: height 0.25s ease-in-out; + overflow: hidden; +} + +.block { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0 1rem; +} + +.optionInfo { + width: 100%; + margin-bottom: 2rem; +} + +.reportIcon { + margin-bottom: 2rem; +} diff --git a/src/components/modals/reportModal/ReportModal.tsx b/src/components/modals/reportModal/ReportModal.tsx new file mode 100644 index 000000000..cf62ecb7c --- /dev/null +++ b/src/components/modals/reportModal/ReportModal.tsx @@ -0,0 +1,232 @@ +import type { ChangeEvent } from 'react'; +import React, { + memo, useEffect, useMemo, useRef, useState, +} from '../../../lib/teact/teact'; +import { getActions } from '../../../global'; + +import type { TabState } from '../../../global/types'; + +import { requestMeasure, requestMutation } from '../../../lib/fasterdom/fasterdom'; +import buildClassName from '../../../util/buildClassName'; +import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; + +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; + +import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview'; +import Icon from '../../common/icons/Icon'; +import Button from '../../ui/Button'; +import ListItem from '../../ui/ListItem'; +import Modal from '../../ui/Modal'; +import TextArea from '../../ui/TextArea'; +import Transition, { ACTIVE_SLIDE_CLASS_NAME, TO_SLIDE_CLASS_NAME } from '../../ui/Transition'; + +import styles from './ReportModal.module.scss'; + +const MAX_DESCRIPTION = 512; + +export type OwnProps = { + modal: TabState['reportModal']; +}; + +const ReportModal = ({ + modal, +}: OwnProps) => { + const { + reportMessages, reportStory, closeReportModal, openPreviousReportModal, + } = getActions(); + const lang = useOldLang(); + const isOpen = Boolean(modal); + + // eslint-disable-next-line no-null/no-null + const transitionRef = useRef(null); + + const [text, setText] = useState(''); + + const handleOptionClick = useLastCallback((e, option: string) => { + const { + messageIds, subject, peerId, chatId, + } = modal!; + if (!messageIds) return; + switch (subject) { + case 'message': + reportMessages({ chatId: chatId!, messageIds, option }); + break; + case 'story': + reportStory({ + storyId: messageIds[0], peerId: peerId!, option, + }); + break; + } + }); + + const [renderingSection, renderingDepth] = useMemo(() => { + if (!modal) return [undefined, 0]; + const sectionDepth = modal.sections.length - 1; + return [modal?.sections[sectionDepth], sectionDepth]; + }, [modal]); + + const handleBackClick = useLastCallback(() => { + openPreviousReportModal(); + }); + + const handleCloseClick = useLastCallback(() => { + closeReportModal(); + }); + + const header = useMemo(() => { + if (!modal) { + return undefined; + } + + const hasSubtitle = Boolean(renderingSection?.subtitle); + + return ( +
+ {renderingDepth ? ( + + ) : ( + + )} +
+

+ {renderingSection?.options + ? lang(modal?.subject === 'story' ? 'ReportStory' : 'Report') : renderingSection?.title} +

+ {hasSubtitle && ( + {renderingSection.subtitle} + )} +
+
+ ); + }, [lang, modal, renderingDepth, renderingSection?.options, renderingSection?.subtitle, renderingSection?.title]); + + const handleTextChange = useLastCallback((e: ChangeEvent) => { + setText(e.target.value); + }); + + useEffect(() => { + if (!modal) return; + const slide = document.querySelector(`.${ACTIVE_SLIDE_CLASS_NAME} > .${styles.slide}`); + if (!slide) return; + + const height = slide.scrollHeight; + requestMutation(() => { + transitionRef.current!.style.height = `${height}px`; + }); + }, [modal]); + + const handleAnimationStart = useLastCallback(() => { + const slide = document.querySelector(`.${TO_SLIDE_CLASS_NAME} > .${styles.slide}`)!; + + requestMeasure(() => { + const height = slide.scrollHeight; + requestMutation(() => { + transitionRef.current!.style.height = `${height}px`; + }); + }); + }); + + const closeReportMessageModalHandler = useLastCallback(() => { + setText(''); + closeReportModal(); + }); + + const sendMessageReportHandler = useLastCallback(() => { + const { + messageIds, subject, peerId, chatId, + } = modal!; + switch (subject) { + case 'message': + reportMessages({ + chatId: chatId!, messageIds, option: renderingSection?.option, description: text, + }); + break; + case 'story': + reportStory({ + storyId: messageIds?.[0], peerId: peerId!, option: renderingSection?.option, description: text, + }); + break; + } + closeReportMessageModalHandler(); + }); + + return ( + + +
+ {renderingSection?.options + ?

{renderingSection?.title}

: undefined} + {renderingSection?.options?.map((option) => ( + +
{option.text}
+
+ ))} + {renderingSection?.option ? ( +
+ +