Message: Add ability to report bad messages (#1259)

This commit is contained in:
Alexander Zinchuk 2021-07-13 17:31:23 +03:00
parent d4ad38bd07
commit c3d6285795
20 changed files with 290 additions and 34 deletions

View File

@ -27,7 +27,7 @@
} }
}, },
"lint-staged": { "lint-staged": {
"*.{ts,tsx,js}": "eslint" "*.{ts,tsx}": "eslint"
}, },
"author": "Alexander Zinchuk (alexander@zinchuk.com)", "author": "Alexander Zinchuk (alexander@zinchuk.com)",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",

View File

@ -13,6 +13,7 @@ import {
ApiChatFolder, ApiChatFolder,
ApiChatBannedRights, ApiChatBannedRights,
ApiChatAdminRights, ApiChatAdminRights,
ApiReportReason,
} from '../../types'; } from '../../types';
import localDb from '../localDb'; import localDb from '../localDb';
import { pick } from '../../../util/iteratees'; import { pick } from '../../../util/iteratees';
@ -382,3 +383,26 @@ export function buildInputPrivacyKey(privacyKey: ApiPrivacyKey) {
return undefined; return undefined;
} }
export function buildInputReportReason(reason: ApiReportReason) {
switch (reason) {
case 'spam':
return new GramJs.InputReportReasonSpam();
case 'violence':
return new GramJs.InputReportReasonViolence();
case 'childAbuse':
return new GramJs.InputReportReasonChildAbuse();
case 'pornography':
return new GramJs.InputReportReasonPornography();
case 'copyright':
return new GramJs.InputReportReasonCopyright();
case 'fake':
return new GramJs.InputReportReasonFake();
case 'geoIrrelevant':
return new GramJs.InputReportReasonGeoIrrelevant();
case 'other':
return new GramJs.InputReportReasonOther();
}
return undefined;
}

View File

@ -22,7 +22,7 @@ export {
markMessageListRead, markMessagesRead, requestThreadInfoUpdate, searchMessagesLocal, searchMessagesGlobal, markMessageListRead, markMessagesRead, requestThreadInfoUpdate, searchMessagesLocal, searchMessagesGlobal,
fetchWebPagePreview, editMessage, forwardMessages, loadPollOptionResults, sendPollVote, findFirstMessageIdAfterDate, fetchWebPagePreview, editMessage, forwardMessages, loadPollOptionResults, sendPollVote, findFirstMessageIdAfterDate,
fetchPinnedMessages, fetchScheduledHistory, sendScheduledMessages, rescheduleMessage, deleteScheduledMessages, fetchPinnedMessages, fetchScheduledHistory, sendScheduledMessages, rescheduleMessage, deleteScheduledMessages,
fetchMessageLink, fetchMessageLink, reportMessages,
} from './messages'; } from './messages';
export { export {

View File

@ -15,6 +15,7 @@ import {
MAIN_THREAD_ID, MAIN_THREAD_ID,
MESSAGE_DELETED, MESSAGE_DELETED,
ApiGlobalMessageSearchType, ApiGlobalMessageSearchType,
ApiReportReason,
} from '../../types'; } from '../../types';
import { ALL_FOLDER_ID, DEBUG, PINNED_MESSAGES_LIMIT } from '../../../config'; import { ALL_FOLDER_ID, DEBUG, PINNED_MESSAGES_LIMIT } from '../../../config';
@ -37,6 +38,7 @@ import {
isMessageWithMedia, isMessageWithMedia,
isServiceMessageWithMedia, isServiceMessageWithMedia,
calculateResultHash, calculateResultHash,
buildInputReportReason,
} from '../gramjsBuilders'; } from '../gramjsBuilders';
import localDb from '../localDb'; import localDb from '../localDb';
import { buildApiChatFromPreview } from '../apiBuilders/chats'; import { buildApiChatFromPreview } from '../apiBuilders/chats';
@ -616,6 +618,21 @@ export async function deleteHistory({
}); });
} }
export async function reportMessages({
peer, messageIds, reason, description,
}: {
peer: ApiChat | ApiUser; messageIds: number[]; reason: ApiReportReason; description?: string;
}) {
const result = await invokeRequest(new GramJs.messages.Report({
peer: buildInputPeer(peer.id, peer.accessHash),
id: messageIds,
reason: buildInputReportReason(reason),
message: description,
}));
return result;
}
export async function markMessageListRead({ export async function markMessageListRead({
chat, threadId, maxId, serverTimeOffset, chat, threadId, maxId, serverTimeOffset,
}: { }: {

View File

@ -275,6 +275,9 @@ export type ApiKeyboardButtons = ApiKeyboardButton[][];
export type ApiMessageSearchType = 'text' | 'media' | 'documents' | 'links' | 'audio' | 'profilePhoto'; export type ApiMessageSearchType = 'text' | 'media' | 'documents' | 'links' | 'audio' | 'profilePhoto';
export type ApiGlobalMessageSearchType = 'text' | 'media' | 'documents' | 'links' | 'audio' | 'voice'; export type ApiGlobalMessageSearchType = 'text' | 'media' | 'documents' | 'links' | 'audio' | 'voice';
export type ApiReportReason = 'spam' | 'violence' | 'pornography' | 'childAbuse'
| 'copyright' | 'geoIrrelevant' | 'fake' | 'other';
export const MAIN_THREAD_ID = -1; export const MAIN_THREAD_ID = -1;
// `Symbol` can not be transferred from worker // `Symbol` can not be transferred from worker

View File

@ -392,6 +392,7 @@ export type ApiUpdateServerTimeOffset = {
serverTimeOffset: number; serverTimeOffset: number;
}; };
export type ApiUpdate = ( export type ApiUpdate = (
ApiUpdateReady | ApiUpdateSession | ApiUpdateReady | ApiUpdateSession |
ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser | ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser |

View File

@ -0,0 +1,98 @@
import { ChangeEvent } from 'react';
import React, {
FC, memo, useCallback, useState,
} from '../../lib/teact/teact';
import { withGlobal } from '../../lib/teact/teactn';
import { ApiReportReason } from '../../api/types';
import { GlobalActions } from '../../global/types';
import { pick } from '../../util/iteratees';
import useLang from '../../hooks/useLang';
import Modal from '../ui/Modal';
import Button from '../ui/Button';
import RadioGroup from '../ui/RadioGroup';
import InputText from '../ui/InputText';
export type OwnProps = {
isOpen: boolean;
messageIds?: number[];
onClose: () => void;
};
type DispatchProps = Pick<GlobalActions, 'reportMessages' | 'exitMessageSelectMode'>;
const ReportMessageModal: FC<OwnProps & DispatchProps> = ({
isOpen,
messageIds,
reportMessages,
exitMessageSelectMode,
onClose,
}) => {
const [selectedReason, setSelectedReason] = useState<ApiReportReason>('spam');
const [description, setDescription] = useState('');
const handleReport = () => {
reportMessages({ messageIds, reason: selectedReason, description });
exitMessageSelectMode();
onClose();
};
const handleSelectReason = useCallback((value: string) => {
setSelectedReason(value as ApiReportReason);
}, []);
const handleDescriptionChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setDescription(e.target.value);
}, []);
const lang = useLang();
const REPORT_OPTIONS: {value: ApiReportReason; label: string}[] = [
{ value: 'spam', label: lang('lng_report_reason_spam') },
{ value: 'violence', label: lang('lng_report_reason_violence') },
{ value: 'pornography', label: lang('lng_report_reason_pornography') },
{ value: 'childAbuse', label: lang('lng_report_reason_child_abuse') },
{ value: 'copyright', label: lang('ReportPeer.ReasonCopyright') },
{ value: 'other', label: lang('lng_report_reason_other') },
];
if (!messageIds) {
return undefined;
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
onEnter={isOpen ? handleReport : undefined}
className="report"
title={lang('lng_report_message_title')}
>
<RadioGroup
name="report-message"
options={REPORT_OPTIONS}
onChange={handleSelectReason}
selected={selectedReason}
/>
<InputText
label={lang('lng_report_reason_description')}
value={description}
onChange={handleDescriptionChange}
/>
<Button color="danger" className="confirm-dialog-button" isText onClick={handleReport}>
{lang('lng_report_button')}
</Button>
<Button className="confirm-dialog-button" isText onClick={onClose}>{lang('Cancel')}</Button>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
undefined, (setGlobal, actions): DispatchProps => pick(actions, [
'reportMessages', 'exitMessageSelectMode',
]),
)(ReportMessageModal));

View File

@ -34,7 +34,7 @@ type StateProps = {
type DispatchProps = Pick<GlobalActions, 'deleteMessages' | 'exitMessageSelectMode' | 'deleteScheduledMessages'>; type DispatchProps = Pick<GlobalActions, 'deleteMessages' | 'exitMessageSelectMode' | 'deleteScheduledMessages'>;
const DeleteSelectedMessagesModal: FC<OwnProps & StateProps & DispatchProps> = ({ const DeleteSelectedMessageModal: FC<OwnProps & StateProps & DispatchProps> = ({
isOpen, isOpen,
isSchedule, isSchedule,
selectedMessageIds, selectedMessageIds,
@ -127,4 +127,4 @@ export default memo(withGlobal<OwnProps>(
'deleteScheduledMessages', 'deleteScheduledMessages',
'exitMessageSelectMode', 'exitMessageSelectMode',
]), ]),
)(DeleteSelectedMessagesModal)); )(DeleteSelectedMessageModal));

View File

@ -1,8 +1,8 @@
.MessageSelectToolbar { .MessageSelectToolbar {
position: absolute; position: absolute;
bottom: 0.5rem; bottom: 0.5rem;
left: .5rem; left: 0.5rem;
right: .5rem; right: 0.5rem;
width: auto; width: auto;
z-index: 20; z-index: 20;
justify-content: center; justify-content: center;
@ -105,6 +105,7 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
flex-grow: 1;
@media (max-width: 600px) { @media (max-width: 600px) {
margin-right: 0.5rem; margin-right: 0.5rem;
@ -116,24 +117,15 @@
display: flex; display: flex;
.MenuItem { .MenuItem {
border-radius: var(--border-radius-default); padding: 0.6875rem;
padding: 0.75rem 2rem 0.75rem 1rem; border-radius: 50%;
i { i {
margin-right: 1rem; margin-right: 0;
} }
@media (max-width: 600px) { .item-text {
padding: 0.6875rem; display: none;
border-radius: 50%;
i {
margin-right: 0;
}
.item-text {
display: none;
}
} }
} }
} }

View File

@ -5,6 +5,7 @@ import { GlobalActions, MessageListType } from '../../global/types';
import { import {
selectCanDeleteSelectedMessages, selectCanDeleteSelectedMessages,
selectCanReportSelectedMessages,
selectCurrentMessageList, selectCurrentMessageList,
selectSelectedMessagesCount, selectSelectedMessagesCount,
} from '../../modules/selectors'; } from '../../modules/selectors';
@ -18,7 +19,8 @@ import useLang from '../../hooks/useLang';
import Button from '../ui/Button'; import Button from '../ui/Button';
import MenuItem from '../ui/MenuItem'; import MenuItem from '../ui/MenuItem';
import DeleteSelectedMessagesModal from './DeleteSelectedMessagesModal'; import DeleteSelectedMessageModal from './DeleteSelectedMessageModal';
import ReportMessageModal from '../common/ReportMessageModal';
import './MessageSelectToolbar.scss'; import './MessageSelectToolbar.scss';
@ -32,6 +34,8 @@ type StateProps = {
isSchedule: boolean; isSchedule: boolean;
selectedMessagesCount?: number; selectedMessagesCount?: number;
canDeleteMessages?: boolean; canDeleteMessages?: boolean;
canReportMessages?: boolean;
selectedMessageIds?: number[];
}; };
type DispatchProps = Pick<GlobalActions, 'exitMessageSelectMode' | 'openForwardMenuForSelectedMessages'>; type DispatchProps = Pick<GlobalActions, 'exitMessageSelectMode' | 'openForwardMenuForSelectedMessages'>;
@ -43,20 +47,23 @@ const MessageSelectToolbar: FC<OwnProps & StateProps & DispatchProps> = ({
isSchedule, isSchedule,
selectedMessagesCount, selectedMessagesCount,
canDeleteMessages, canDeleteMessages,
canReportMessages,
selectedMessageIds,
exitMessageSelectMode, exitMessageSelectMode,
openForwardMenuForSelectedMessages, openForwardMenuForSelectedMessages,
}) => { }) => {
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag(); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useFlag();
const [isReportModalOpen, openReportModal, closeReportModal] = useFlag();
useEffect(() => { useEffect(() => {
return isActive && !isDeleteModalOpen return isActive && !isDeleteModalOpen && !isReportModalOpen
? captureKeyboardListeners({ ? captureKeyboardListeners({
onBackspace: openDeleteModal, onBackspace: openDeleteModal,
onDelete: openDeleteModal, onDelete: openDeleteModal,
onEsc: exitMessageSelectMode, onEsc: exitMessageSelectMode,
}) })
: undefined; : undefined;
}, [isActive, isDeleteModalOpen, openDeleteModal, exitMessageSelectMode]); }, [isActive, isDeleteModalOpen, isReportModalOpen, openDeleteModal, exitMessageSelectMode]);
const prevSelectedMessagesCount = usePrevious(selectedMessagesCount || undefined, true); const prevSelectedMessagesCount = usePrevious(selectedMessagesCount || undefined, true);
const renderingSelectedMessagesCount = isActive ? selectedMessagesCount : prevSelectedMessagesCount; const renderingSelectedMessagesCount = isActive ? selectedMessagesCount : prevSelectedMessagesCount;
@ -99,6 +106,18 @@ const MessageSelectToolbar: FC<OwnProps & StateProps & DispatchProps> = ({
</span> </span>
</MenuItem> </MenuItem>
)} )}
{canReportMessages && (
<MenuItem
icon="flag"
onClick={openReportModal}
disabled={!canReportMessages}
ariaLabel={lang('Conversation.ReportMessages')}
>
<span className="item-text">
{lang('Report')}
</span>
</MenuItem>
)}
<MenuItem <MenuItem
destructive destructive
icon="delete" icon="delete"
@ -113,11 +132,16 @@ const MessageSelectToolbar: FC<OwnProps & StateProps & DispatchProps> = ({
</div> </div>
)} )}
</div> </div>
<DeleteSelectedMessagesModal <DeleteSelectedMessageModal
isOpen={isDeleteModalOpen} isOpen={isDeleteModalOpen}
isSchedule={isSchedule} isSchedule={isSchedule}
onClose={closeDeleteModal} onClose={closeDeleteModal}
/> />
<ReportMessageModal
isOpen={isReportModalOpen}
onClose={closeReportModal}
messageIds={selectedMessageIds}
/>
</div> </div>
); );
}; };
@ -126,11 +150,15 @@ export default memo(withGlobal<OwnProps>(
(global): StateProps => { (global): StateProps => {
const { type: messageListType } = selectCurrentMessageList(global) || {}; const { type: messageListType } = selectCurrentMessageList(global) || {};
const { canDelete } = selectCanDeleteSelectedMessages(global); const { canDelete } = selectCanDeleteSelectedMessages(global);
const canReport = selectCanReportSelectedMessages(global);
const { messageIds: selectedMessageIds } = global.selectedMessages || {};
return { return {
isSchedule: messageListType === 'scheduled', isSchedule: messageListType === 'scheduled',
selectedMessagesCount: selectSelectedMessagesCount(global), selectedMessagesCount: selectSelectedMessagesCount(global),
canDeleteMessages: canDelete, canDeleteMessages: canDelete,
canReportMessages: canReport,
selectedMessageIds,
}; };
}, },
(setGlobal, actions): DispatchProps => pick(actions, ['exitMessageSelectMode', 'openForwardMenuForSelectedMessages']), (setGlobal, actions): DispatchProps => pick(actions, ['exitMessageSelectMode', 'openForwardMenuForSelectedMessages']),

View File

@ -1,5 +1,5 @@
import React, { import React, {
FC, memo, useCallback, useEffect, useState, FC, memo, useCallback, useEffect, useMemo, useState,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { withGlobal } from '../../../lib/teact/teactn'; import { withGlobal } from '../../../lib/teact/teactn';
@ -13,6 +13,7 @@ import useShowTransition from '../../../hooks/useShowTransition';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import DeleteMessageModal from '../../common/DeleteMessageModal'; import DeleteMessageModal from '../../common/DeleteMessageModal';
import ReportMessageModal from '../../common/ReportMessageModal';
import PinMessageModal from '../../common/PinMessageModal'; import PinMessageModal from '../../common/PinMessageModal';
import MessageContextMenu from './MessageContextMenu'; import MessageContextMenu from './MessageContextMenu';
import CalendarModal from '../../common/CalendarModal'; import CalendarModal from '../../common/CalendarModal';
@ -36,6 +37,7 @@ type StateProps = {
canPin?: boolean; canPin?: boolean;
canUnpin?: boolean; canUnpin?: boolean;
canDelete?: boolean; canDelete?: boolean;
canReport?: boolean;
canEdit?: boolean; canEdit?: boolean;
canForward?: boolean; canForward?: boolean;
canFaveSticker?: boolean; canFaveSticker?: boolean;
@ -66,6 +68,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
canPin, canPin,
canUnpin, canUnpin,
canDelete, canDelete,
canReport,
canEdit, canEdit,
canForward, canForward,
canFaveSticker, canFaveSticker,
@ -87,6 +90,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false); const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false);
const [isMenuOpen, setIsMenuOpen] = useState(true); const [isMenuOpen, setIsMenuOpen] = useState(true);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
const [isPinModalOpen, setIsPinModalOpen] = useState(false); const [isPinModalOpen, setIsPinModalOpen] = useState(false);
const [isCalendarOpen, openCalendar, closeCalendar] = useFlag(); const [isCalendarOpen, openCalendar, closeCalendar] = useFlag();
@ -95,6 +99,11 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
}, []); }, []);
const handleReport = useCallback(() => {
setIsMenuOpen(false);
setIsReportModalOpen(true);
}, []);
const closeMenu = useCallback(() => { const closeMenu = useCallback(() => {
setIsMenuOpen(false); setIsMenuOpen(false);
onClose(); onClose();
@ -105,6 +114,11 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
onClose(); onClose();
}, [onClose]); }, [onClose]);
const closeReportModal = useCallback(() => {
setIsReportModalOpen(false);
onClose();
}, [onClose]);
const closePinModal = useCallback(() => { const closePinModal = useCallback(() => {
setIsPinModalOpen(false); setIsPinModalOpen(false);
onClose(); onClose();
@ -200,6 +214,8 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
return enableScrolling; return enableScrolling;
}, []); }, []);
const reportMessageIds = useMemo(() => (album ? album.messages : [message]).map(({ id }) => id), [album, message]);
if (noOptions) { if (noOptions) {
closeMenu(); closeMenu();
@ -219,6 +235,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
canReschedule={canReschedule} canReschedule={canReschedule}
canReply={canReply} canReply={canReply}
canDelete={canDelete} canDelete={canDelete}
canReport={canReport}
canPin={canPin} canPin={canPin}
canUnpin={canUnpin} canUnpin={canUnpin}
canEdit={canEdit} canEdit={canEdit}
@ -234,6 +251,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
onUnpin={handleUnpin} onUnpin={handleUnpin}
onForward={handleForward} onForward={handleForward}
onDelete={handleDelete} onDelete={handleDelete}
onReport={handleReport}
onFaveSticker={handleFaveSticker} onFaveSticker={handleFaveSticker}
onUnfaveSticker={handleUnfaveSticker} onUnfaveSticker={handleUnfaveSticker}
onSelect={handleSelectMessage} onSelect={handleSelectMessage}
@ -249,6 +267,11 @@ const ContextMenuContainer: FC<OwnProps & StateProps & DispatchProps> = ({
album={album} album={album}
message={message} message={message}
/> />
<ReportMessageModal
isOpen={isReportModalOpen}
onClose={closeReportModal}
messageIds={reportMessageIds}
/>
<PinMessageModal <PinMessageModal
isOpen={isPinModalOpen} isOpen={isPinModalOpen}
messageId={message.id} messageId={message.id}
@ -277,6 +300,7 @@ export default memo(withGlobal<OwnProps>(
canPin, canPin,
canUnpin, canUnpin,
canDelete, canDelete,
canReport,
canEdit, canEdit,
canForward, canForward,
canFaveSticker, canFaveSticker,
@ -296,6 +320,7 @@ export default memo(withGlobal<OwnProps>(
canPin: !isScheduled && canPin, canPin: !isScheduled && canPin,
canUnpin: !isScheduled && canUnpin, canUnpin: !isScheduled && canUnpin,
canDelete, canDelete,
canReport,
canEdit: !isPinned && canEdit, canEdit: !isPinned && canEdit,
canForward: !isScheduled && canForward, canForward: !isScheduled && canForward,
canFaveSticker: !isScheduled && canFaveSticker, canFaveSticker: !isScheduled && canFaveSticker,

View File

@ -22,6 +22,7 @@ type OwnProps = {
canPin?: boolean; canPin?: boolean;
canUnpin?: boolean; canUnpin?: boolean;
canDelete?: boolean; canDelete?: boolean;
canReport?: boolean;
canEdit?: boolean; canEdit?: boolean;
canForward?: boolean; canForward?: boolean;
canFaveSticker?: boolean; canFaveSticker?: boolean;
@ -35,6 +36,7 @@ type OwnProps = {
onUnpin: () => void; onUnpin: () => void;
onForward: () => void; onForward: () => void;
onDelete: () => void; onDelete: () => void;
onReport: () => void;
onFaveSticker: () => void; onFaveSticker: () => void;
onUnfaveSticker: () => void; onUnfaveSticker: () => void;
onSelect: () => void; onSelect: () => void;
@ -58,6 +60,7 @@ const MessageContextMenu: FC<OwnProps> = ({
canPin, canPin,
canUnpin, canUnpin,
canDelete, canDelete,
canReport,
canForward, canForward,
canFaveSticker, canFaveSticker,
canUnfaveSticker, canUnfaveSticker,
@ -70,6 +73,7 @@ const MessageContextMenu: FC<OwnProps> = ({
onUnpin, onUnpin,
onForward, onForward,
onDelete, onDelete,
onReport,
onFaveSticker, onFaveSticker,
onUnfaveSticker, onUnfaveSticker,
onSelect, onSelect,
@ -135,6 +139,7 @@ const MessageContextMenu: FC<OwnProps> = ({
{canUnpin && <MenuItem icon="unpin" onClick={onUnpin}>{lang('DialogUnpin')}</MenuItem>} {canUnpin && <MenuItem icon="unpin" onClick={onUnpin}>{lang('DialogUnpin')}</MenuItem>}
{canForward && <MenuItem icon="forward" onClick={onForward}>{lang('Forward')}</MenuItem>} {canForward && <MenuItem icon="forward" onClick={onForward}>{lang('Forward')}</MenuItem>}
{canSelect && <MenuItem icon="select" onClick={onSelect}>{lang('Common.Select')}</MenuItem>} {canSelect && <MenuItem icon="select" onClick={onSelect}>{lang('Common.Select')}</MenuItem>}
{canReport && <MenuItem icon="flag" onClick={onReport}>{lang('lng_context_report_msg')}</MenuItem>}
{canDelete && <MenuItem destructive icon="delete" onClick={onDelete}>{lang('Delete')}</MenuItem>} {canDelete && <MenuItem destructive icon="delete" onClick={onDelete}>{lang('Delete')}</MenuItem>}
</Menu> </Menu>
); );

View File

@ -43,7 +43,7 @@ export function getMessageCopyOptions(
)); ));
options.push({ options.push({
label: getCopyLabel(hasSelection, canImageBeCopied), label: getCopyLabel(hasSelection),
handler: () => { handler: () => {
const clipboardText = hasSelection && selection ? selection.toString() : text; const clipboardText = hasSelection && selection ? selection.toString() : text;
copyTextToClipboard(clipboardText); copyTextToClipboard(clipboardText);
@ -71,14 +71,9 @@ export function getMessageCopyOptions(
return options; return options;
} }
function getCopyLabel(hasSelection: boolean, canImageBeCopied: boolean): string { function getCopyLabel(hasSelection: boolean): string {
if (hasSelection) { if (hasSelection) {
return 'lng_context_copy_selected'; return 'lng_context_copy_selected';
} }
return 'lng_context_copy_text';
if (canImageBeCopied) {
return 'lng_context_copy_text';
}
return 'Copy';
} }

View File

@ -14,6 +14,12 @@
} }
} }
&.report {
.modal-dialog {
max-width: 15rem;
}
}
.modal-container { .modal-container {
position: fixed; position: fixed;
top: 0; top: 0;

View File

@ -422,6 +422,7 @@ export type ActionTypes = (
'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' | 'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' |
'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' | 'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' |
'loadMessageLink' | 'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' | 'loadMessageLink' | 'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' |
'reportMessages' |
// scheduled messages // scheduled messages
'loadScheduledHistory' | 'sendScheduledMessages' | 'rescheduleMessage' | 'deleteScheduledMessages' | 'loadScheduledHistory' | 'sendScheduledMessages' | 'rescheduleMessage' | 'deleteScheduledMessages' |
// poll result // poll result

View File

@ -967,6 +967,7 @@ messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action
messages.sendMessage#520c3870 flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector<MessageEntity> schedule_date:flags.10?int = Updates; messages.sendMessage#520c3870 flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector<MessageEntity> schedule_date:flags.10?int = Updates;
messages.sendMedia#3491eba9 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector<MessageEntity> schedule_date:flags.10?int = Updates; messages.sendMedia#3491eba9 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector<MessageEntity> schedule_date:flags.10?int = Updates;
messages.forwardMessages#d9fee60e flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true from_peer:InputPeer id:Vector<int> random_id:Vector<long> to_peer:InputPeer schedule_date:flags.10?int = Updates; messages.forwardMessages#d9fee60e flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true from_peer:InputPeer id:Vector<int> random_id:Vector<long> to_peer:InputPeer schedule_date:flags.10?int = Updates;
messages.report#8953ab4e peer:InputPeer id:Vector<int> reason:ReportReason message:string = Bool;
messages.getChats#3c6aa187 id:Vector<int> = messages.Chats; messages.getChats#3c6aa187 id:Vector<int> = messages.Chats;
messages.getFullChat#3b831c66 chat_id:int = messages.ChatFull; messages.getFullChat#3b831c66 chat_id:int = messages.ChatFull;
messages.editChatTitle#dc452855 chat_id:int title:string = Updates; messages.editChatTitle#dc452855 chat_id:int title:string = Updates;

View File

@ -967,6 +967,7 @@ messages.setTyping#58943ee2 flags:# peer:InputPeer top_msg_id:flags.0?int action
messages.sendMessage#520c3870 flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector<MessageEntity> schedule_date:flags.10?int = Updates; messages.sendMessage#520c3870 flags:# no_webpage:flags.1?true silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector<MessageEntity> schedule_date:flags.10?int = Updates;
messages.sendMedia#3491eba9 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector<MessageEntity> schedule_date:flags.10?int = Updates; messages.sendMedia#3491eba9 flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true peer:InputPeer reply_to_msg_id:flags.0?int media:InputMedia message:string random_id:long reply_markup:flags.2?ReplyMarkup entities:flags.3?Vector<MessageEntity> schedule_date:flags.10?int = Updates;
messages.forwardMessages#d9fee60e flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true from_peer:InputPeer id:Vector<int> random_id:Vector<long> to_peer:InputPeer schedule_date:flags.10?int = Updates; messages.forwardMessages#d9fee60e flags:# silent:flags.5?true background:flags.6?true with_my_score:flags.8?true from_peer:InputPeer id:Vector<int> random_id:Vector<long> to_peer:InputPeer schedule_date:flags.10?int = Updates;
messages.report#8953ab4e peer:InputPeer id:Vector<int> reason:ReportReason message:string = Bool;
messages.getChats#3c6aa187 id:Vector<int> = messages.Chats; messages.getChats#3c6aa187 id:Vector<int> = messages.Chats;
messages.getFullChat#3b831c66 chat_id:int = messages.ChatFull; messages.getFullChat#3b831c66 chat_id:int = messages.ChatFull;
messages.editChatTitle#dc452855 chat_id:int title:string = Updates; messages.editChatTitle#dc452855 chat_id:int title:string = Updates;

View File

@ -415,6 +415,31 @@ addReducer('deleteHistory', (global, actions, payload) => {
})(); })();
}); });
addReducer('reportMessages', (global, actions, payload) => {
(async () => {
const {
messageIds, reason, description,
} = payload!;
const currentMessageList = selectCurrentMessageList(global);
if (!currentMessageList) {
return;
}
const { chatId } = currentMessageList;
const chat = selectChat(global, chatId)!;
const result = await callApi('reportMessages', {
peer: chat, messageIds, reason, description,
});
actions.showNotification({
message: result
? 'Thank you! Your report will be reviewed by our team.'
: 'Error occured while submiting report. Please, try again later.',
});
})();
});
addReducer('markMessageListRead', (global, actions, payload) => { addReducer('markMessageListRead', (global, actions, payload) => {
const { serverTimeOffset } = global; const { serverTimeOffset } = global;
const currentMessageList = selectCurrentMessageList(global); const currentMessageList = selectCurrentMessageList(global);

View File

@ -386,6 +386,8 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
|| chat.isCreator || chat.isCreator
|| getHasAdminRight(chat, 'deleteMessages'); || getHasAdminRight(chat, 'deleteMessages');
const canReport = !isPrivate && !isOwn;
const canDeleteForAll = canDelete && !isServiceNotification && ( const canDeleteForAll = canDelete && !isServiceNotification && (
(isPrivate && !isChatWithSelf) (isPrivate && !isChatWithSelf)
|| (isBasicGroup && ( || (isBasicGroup && (
@ -429,6 +431,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
canPin, canPin,
canUnpin, canUnpin,
canDelete, canDelete,
canReport,
canDeleteForAll, canDeleteForAll,
canForward, canForward,
canFaveSticker, canFaveSticker,
@ -439,6 +442,7 @@ export function selectAllowedMessageActions(global: GlobalState, message: ApiMes
}; };
} }
// This selector always returns a new object which can not be safely used in shallow-equal checks
export function selectCanDeleteSelectedMessages(global: GlobalState) { export function selectCanDeleteSelectedMessages(global: GlobalState) {
const { messageIds: selectedMessageIds } = global.selectedMessages || {}; const { messageIds: selectedMessageIds } = global.selectedMessages || {};
const { chatId, threadId } = selectCurrentMessageList(global) || {}; const { chatId, threadId } = selectCurrentMessageList(global) || {};
@ -457,6 +461,21 @@ export function selectCanDeleteSelectedMessages(global: GlobalState) {
}; };
} }
export function selectCanReportSelectedMessages(global: GlobalState) {
const { messageIds: selectedMessageIds } = global.selectedMessages || {};
const { chatId, threadId } = selectCurrentMessageList(global) || {};
const chatMessages = chatId && selectChatMessages(global, chatId);
if (!chatMessages || !selectedMessageIds || !threadId) {
return false;
}
const messageActions = selectedMessageIds
.map((id) => chatMessages[id] && selectAllowedMessageActions(global, chatMessages[id], threadId))
.filter(Boolean);
return messageActions.every((actions) => actions.canReport);
}
export function selectUploadProgress(global: GlobalState, message: ApiMessage) { export function selectUploadProgress(global: GlobalState, message: ApiMessage) {
const fileTransfer = global.fileUploads.byMessageLocalId[message.previousLocalId || message.id]; const fileTransfer = global.fileUploads.byMessageLocalId[message.previousLocalId || message.id];

View File

@ -15,12 +15,27 @@ const IGNORED_KEYS: Record<string, boolean> = {
Tab: true, Tab: true,
}; };
function isTextBox(target: EventTarget | null) {
if (!target || !(target instanceof HTMLElement)) return false;
const element = target;
const tagName = element.tagName.toLowerCase();
if (tagName === 'textarea') return true;
if (tagName !== 'input') return false;
const type = element.getAttribute('type');
if (!type) return false;
const inputTypes = [
'text', 'password', 'number', 'email', 'tel', 'url',
'search', 'date', 'datetime', 'datetime-local', 'time', 'month', 'week',
];
return inputTypes.indexOf(type.toLowerCase()) > -1;
}
const preventDefault = (e: Event) => { const preventDefault = (e: Event) => {
e.preventDefault(); e.preventDefault();
}; };
function preventDefaultForScrollKeys(e: KeyboardEvent) { function preventDefaultForScrollKeys(e: KeyboardEvent) {
if (IGNORED_KEYS[e.key]) { if (IGNORED_KEYS[e.key] && !isTextBox(e.target)) {
preventDefault(e); preventDefault(e);
} }
} }