diff --git a/src/components/AccountWarning/AccountWarning.styl b/src/components/AccountWarning/AccountWarning.styl index 246695ab9a..39c8aa2028 100644 --- a/src/components/AccountWarning/AccountWarning.styl +++ b/src/components/AccountWarning/AccountWarning.styl @@ -97,4 +97,64 @@ +} + +.AccountWarningAck { + position: fixed; + top: auto; + bottom: auto; + left: auto; + right: auto; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: 30rem; + max-width: calc(95vw - 2rem); + max-height: calc(95vh - 2rem); + + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.5); + + z-index: z.account-warning; + themed background-color info + themed color colored-background-fg + border-radius: 0.5rem; + padding: 1rem; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + + .space { + flex-grow: 1; + height: 1rem; + } + + .buttons { + font-size: 1.5rem; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + user-select: none; + } + + button { + font-size: 1.1rem; + } + + input[type="checkbox"] { + margin-right: 0.5rem; + width: 1.2rem; + height: 1.2rem; + } + + label { + cursor: pointer; + margin-right: 1rem; + } + + + } diff --git a/src/components/AccountWarning/AccountWarning.tsx b/src/components/AccountWarning/AccountWarning.tsx index baaa604ef1..d514be14b0 100644 --- a/src/components/AccountWarning/AccountWarning.tsx +++ b/src/components/AccountWarning/AccountWarning.tsx @@ -15,6 +15,10 @@ * along with this program. If not, see . */ +// An "AccountWarning" was initially "warn them not to do bad things" and ensure they acknowledge +// Now it is extended to "notify them about something (maybe just information)" and record that they saw it +// So the word "warning" kind of means "message" now. + import * as React from "react"; import { _, pgettext } from "translate"; import { get, patch } from "requests"; @@ -22,14 +26,14 @@ import { useUser } from "hooks"; import { AutoTranslate } from "AutoTranslate"; import { useLocation } from "react-router"; +import { CANNED_MESSAGES } from "./CannedMessages"; + const BUTTON_COUNTDOWN_TIME = 10000; // ms; export function AccountWarning() { const user = useUser(); const location = useLocation(); - const [warning, setWarning] = React.useState(null); - const [acceptTimer, setAcceptTimer] = React.useState(null); - const [boxChecked, setBoxChecked] = React.useState(false); + const [warning, setWarning] = React.useState(null); React.useEffect(() => { if (user && !user.anonymous && user.has_active_warning_flag) { @@ -38,14 +42,6 @@ export function AccountWarning() { console.log(warning); if (Object.keys(warning).length > 0) { setWarning(warning); - - const now = Date.now(); - const interval = setInterval(() => { - setAcceptTimer(BUTTON_COUNTDOWN_TIME - (Date.now() - now)); - if (Date.now() - now > BUTTON_COUNTDOWN_TIME) { - clearInterval(interval); - } - }, 100); } else { setWarning(null); } @@ -75,15 +71,87 @@ export function AccountWarning() { void patch(`me/warning/${warning.id}`, { accept: true }); }; + const Renderers = { + warning: WarningModal, + acknowledgement: AckModal, + }; + + const MessageRenderer = Renderers[warning.severity]; + + return ( + <> + + + ); +} + +// Support warnings that carry messages either as a reference to a a canned message, or explicit text... + +interface MessageTextRenderProps { + warning: rest_api.warnings.Warning; +} +function MessageTextRender(props: MessageTextRenderProps): JSX.Element { + console.log("rendering", props); + if (props.warning.message_id) { + return ( +
{CANNED_MESSAGES[props.warning.message_id](props.warning.interpolation_data)}
+ ); + } else { + return ( + + ); + } +} + +// Support "warnings" that should be displayed differently depending on severity... + +interface WarningModalProps { + warning: rest_api.warnings.Warning; + accept: () => void; +} + +function AckModal(props: WarningModalProps): JSX.Element { + return ( + <> +
+
+ +
+
+ +
+
+ + ); +} + +function WarningModal(props: WarningModalProps): JSX.Element { + const [acceptTimer, setAcceptTimer] = React.useState(null); + const [boxChecked, setBoxChecked] = React.useState(false); + + React.useEffect(() => { + if (props.warning) { + const now = Date.now(); + const interval = setInterval(() => { + setAcceptTimer(BUTTON_COUNTDOWN_TIME - (Date.now() - now)); + if (Date.now() - now > BUTTON_COUNTDOWN_TIME) { + clearInterval(interval); + } + }, 100); + } + }, [props.warning]); + return ( <>
- +
0 || !boxChecked} - onClick={ok} + onClick={props.accept} > {_("OK") + (acceptTimer > 0 ? " (" + Math.ceil(acceptTimer / 1000) + ")" : "")} diff --git a/src/components/AccountWarning/CannedMessages.ts b/src/components/AccountWarning/CannedMessages.ts new file mode 100644 index 0000000000..0c3e3e7955 --- /dev/null +++ b/src/components/AccountWarning/CannedMessages.ts @@ -0,0 +1,95 @@ +/* + * Copyright (C) Online-Go.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { _, interpolate } from "translate"; + +export const CANNED_MESSAGES: rest_api.warnings.WarningMessages = { + warn_beginner_score_cheat: (game_id) => + interpolate( + _(` + It appears that you delayed the end of game #{{game_id}}, by clicking + on the board to change the score incorrectly. This can frustrate your + opponent and prevent them from moving on to the next game. + + Since you are a new player, no action will be taken against + your account. We simply ask that you learn when to end a game. + + Until you develop the experience to judge better, if your + opponent passes and there are no open borders between your + stones then you should also pass. + + After passing, promptly accept the correct score. + + If in doubt about this sort of situation. please ask for help in chat + or the forums.`), + { game_id }, + ), + warn_score_cheat: (game_id) => + interpolate( + _(` + Our records show that you attempted to illegally change the score at the end of + game #{{game_id}}. This is a form of cheating and is prohibited by the + OGS Terms of Service. + + https://online-go.com/docs/terms-of-service + + We ask that you end your games properly by accepting the correct score + immediately after passing. Further instances of score cheating will result + in suspension of your account.`), + { game_id }, + ), + ack_educated_beginner_score_cheat: (reported) => + interpolate( + _(` + Thanks for the report about {{reported}}. It seems you were playing against a + complete beginner - we have tried to explain that games should + be ended correctly, to pass when their opponent passes, and to accept promptly, + trusting the auto-score.`), + { reported }, + ), + ack_educated_beginner_score_cheat_and_annul: (reported) => + interpolate( + _(` + Thanks for the report about {{reported}}. It seems you were playing against a + complete beginner - we have tried to explain that games should + be ended correctly, to pass when their opponent passes, and to accept promptly, + trusting the auto-score. That incorrectly scored game has been annulled.`), + { reported }, + ), + ack_warned_score_cheat: (reported) => + interpolate( + _(` + Thank you for your report, {{reported}} has been given a formal warning about scoring properly.`), + { reported }, + ), + ack_warned_score_cheat_and_annul: (reported) => + interpolate( + _(` + Thank you for your report, {{reported}} has been given a formal warning about scoring properly, and that cheated game annulled.`), + reported, + ), + no_score_cheating_evident: (reported) => + interpolate( + _(` + Thank you for bringing the possible instance of score cheating by {{reported}} to + our attention. We looked into the report and their actions seemed approprate. If a pattern of + complaints emerges, we will investigate further. + + Thank you for helping keep OGS enjoyable for everyone. We appreciate it.`), + { reported }, + ), +}; diff --git a/src/components/ModerateUser/ModerateUser.styl b/src/components/ModerateUser/ModerateUser.styl index df3186c589..65c1479231 100644 --- a/src/components/ModerateUser/ModerateUser.styl +++ b/src/components/ModerateUser/ModerateUser.styl @@ -59,4 +59,9 @@ #ui-class-extra { width: 5rem; } + + // We'll probably lay these out better when there are more of them. + .avoid-wrap { + font-size: smaller; + } } diff --git a/src/components/ModerateUser/ModerateUser.tsx b/src/components/ModerateUser/ModerateUser.tsx index b405067f4c..9321c78070 100644 --- a/src/components/ModerateUser/ModerateUser.tsx +++ b/src/components/ModerateUser/ModerateUser.tsx @@ -19,13 +19,11 @@ import * as React from "react"; import * as data from "data"; import { _ } from "translate"; import { put, get, del } from "requests"; -import { errorAlerter } from "misc"; +import { MOD_POWER_HANDLE_SCORE_CHEAT, errorAlerter } from "misc"; import { proRankList } from "rank_utils"; import { Modal, openModal } from "Modal"; import { lookup } from "player_cache"; -import { MOD_POWER_ANNUL } from "misc"; - interface Events {} interface ModerateUserProperties { @@ -52,7 +50,8 @@ export class ModerateUser extends Modal { this.setState( Object.assign({ loading: false }, result.user, { bot_owner: result.user.bot_owner ? result.user.bot_owner.id : null, - can_annul: result.user.moderator_powers & MOD_POWER_ANNUL, + can_handle_score_cheat: + result.user.moderator_powers & MOD_POWER_HANDLE_SCORE_CHEAT, }), ); }) @@ -95,9 +94,9 @@ export class ModerateUser extends Modal { settings[f] = this.state[f]; } - // Can-annul is bit zero of moderator_powers + // handle_score_cheat is bit zero of moderator_powers settings["moderator_powers"] = - (this.state.moderator_powers & ~1) | this.state.can_annul; + (this.state.moderator_powers & ~1) | this.state.can_handle_score_cheat; settings.moderation_note = reason; @@ -111,7 +110,7 @@ export class ModerateUser extends Modal { setLockedUsername = (ev) => this.setState({ locked_username: ev.target.checked }); setSupporter = (ev) => this.setState({ supporter: ev.target.checked }); setAnnouncer = (ev) => this.setState({ is_announcer: ev.target.checked }); - setCanAnnul = (ev) => this.setState({ can_annul: ev.target.checked }); + setHandleScoreCheat = (ev) => this.setState({ can_handle_score_cheat: ev.target.checked }); setProfessional = (ev) => this.setState({ professional: ev.target.checked }); //setBanned = (ev) => this.setState({ is_banned: ev.target.checked }); setShadowbanned = (ev) => this.setState({ is_shadowbanned: ev.target.checked }); @@ -231,14 +230,16 @@ export class ModerateUser extends Modal { )}
- +
diff --git a/src/components/NavBar/NavBar.tsx b/src/components/NavBar/NavBar.tsx index fcbc7daa82..1a3aad830b 100644 --- a/src/components/NavBar/NavBar.tsx +++ b/src/components/NavBar/NavBar.tsx @@ -41,7 +41,7 @@ import { logout } from "auth"; import { useUser, useData } from "hooks"; import { OmniSearch } from "./OmniSearch"; -import { MOD_POWER_ANNUL } from "misc"; +import { MOD_POWER_HANDLE_SCORE_CHEAT } from "misc"; const body = $(document.body); @@ -277,7 +277,8 @@ export function NavBar(): JSX.Element { {_("Rating Calculator")} - {(user.is_moderator || !!(user.moderator_powers & MOD_POWER_ANNUL)) && ( + {(user.is_moderator || + !!(user.moderator_powers & MOD_POWER_HANDLE_SCORE_CHEAT)) && ( {_("Reports Center")} diff --git a/src/lib/misc.ts b/src/lib/misc.ts index fe5671c093..40458695af 100644 --- a/src/lib/misc.ts +++ b/src/lib/misc.ts @@ -21,7 +21,7 @@ import { browserHistory } from "ogsHistory"; import * as preferences from "preferences"; import { alert } from "swal_config"; -export const MOD_POWER_ANNUL = 1; // Matches back-end MOD_POWER +export const MOD_POWER_HANDLE_SCORE_CHEAT = 1; // Matches back-end MOD_POWER export type Timeout = ReturnType; diff --git a/src/lib/report_manager.ts b/src/lib/report_manager.tsx similarity index 84% rename from src/lib/report_manager.ts rename to src/lib/report_manager.tsx index 0d2a7d6980..0a0cb2ed24 100644 --- a/src/lib/report_manager.ts +++ b/src/lib/report_manager.tsx @@ -20,24 +20,36 @@ * which is used by our IncidentReportTracker widget and our ReportsCenter view. */ +import * as React from "react"; import * as data from "data"; import * as preferences from "preferences"; +import { toast } from "toast"; import { alert } from "swal_config"; import { socket } from "sockets"; +import { pgettext } from "translate"; import { ReportedConversation } from "Report"; import { PlayerCacheEntry } from "player_cache"; import { EventEmitter } from "eventemitter3"; import { emitNotification } from "Notifications"; import { browserHistory } from "ogsHistory"; import { get, post } from "requests"; +import { MOD_POWER_HANDLE_SCORE_CHEAT } from "./misc"; export const DAILY_REPORT_GOAL = 10; +interface Vote { + voter_id: number; + action: string; +} + export interface Report { + // TBD put this into /models, in a suitable namespace? + // TBD: relationship between this and SeverToClient['incident-report'] id: number; created: string; updated: string; state: string; + escalated: boolean; source: string; report_type: string; reporting_user: any; @@ -63,6 +75,9 @@ export interface Report { automod_to_reporter?: string; automod_to_reported?: string; + available_actions: Array; // community moderator actions + voters: Vote[]; // votes from community moderators on this report + unclaim: () => void; claim: () => void; steal: () => void; @@ -122,6 +137,7 @@ class ReportManager extends EventEmitter { } public updateIncidentReport(report: Report) { + const user = data.get("user"); report.id = parseInt(report.id as unknown as string); if (!(report.id in this.active_incident_reports)) { @@ -146,7 +162,10 @@ class ReportManager extends EventEmitter { } } - if (report.state === "resolved") { + if ( + report.state === "resolved" || + report.voters?.some((vote) => vote.voter_id === user.id) + ) { delete this.active_incident_reports[report.id]; } else { this.active_incident_reports[report.id] = report; @@ -189,7 +208,25 @@ class ReportManager extends EventEmitter { if (this.getIgnored(report.id)) { return false; } - if (!user.is_moderator && !(report.report_type === "score_cheating")) { + if (!user.is_moderator && !user.moderator_powers) { + return false; + } + // Community moderators only get to see score_cheating reports that they + // have not yet voted on. + if ( + !user.is_moderator && + user.moderator_powers && + (!( + report.report_type === "score_cheating" && + user.moderator_powers & MOD_POWER_HANDLE_SCORE_CHEAT + ) || + report.voters?.some((vote) => vote.voter_id === user.id)) + ) { + return false; + } + // don't hand score cheating reports to full mods unless the report is escalated, + // because community moderators are supposed to do these! + if (user.is_moderator && report.report_type === "score_cheating" && !report.escalated) { return false; } return !report.moderator || report.moderator?.id === user.id; @@ -323,6 +360,23 @@ class ReportManager extends EventEmitter { this.updateIncidentReport(res); return res; } + public async vote(report_id: number, voted_action: string) { + delete this.active_incident_reports[report_id]; + this.update(); + const res = await post(`moderation/incident/${report_id}`, { + action: "vote", // darn, yes, two different uses of the word "action" collide here + voted_action: voted_action, + }).then((res) => { + toast( +
+ {pgettext("Thanking a community moderator for voting", "Submitted, thanks!")} +
, + 2000, + ); + return res; + }); + this.updateIncidentReport(res); + } public getHandledTodayCount(): number { return data.get("user").reports_handled_today || 0; diff --git a/src/models/warning.d.ts b/src/models/warning.d.ts new file mode 100644 index 0000000000..506ad19fa2 --- /dev/null +++ b/src/models/warning.d.ts @@ -0,0 +1,50 @@ +/* + * Copyright (C) Online-Go.com + * Copyright (C) Ben Jones + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +declare namespace rest_api { + namespace warnings { + type WarningMessageId = + | "warn_beginner_score_cheat" + | "warn_score_cheat" + | "ack_educated_beginner_score_cheat" + | "ack_educated_beginner_score_cheat_and_annul" + | "ack_warned_score_cheat" + | "ack_warned_score_cheat_and_annul" + | "no_score_cheating_evident"; + + type Severity = "warning" | "acknowledgement"; + + type InterpolatedMessage = (data) => string; + + type WarningMessages = { + [K in WarningMessageId]: InterpolatedMessage; + }; + + interface Warning { + id: number; + created: string; + acknowledged: string | null; // date + player_id: number; + moderator: number | null; + text: string | null; + message_id: WarningMessageId | null; + severity: Severity; + interpolation_data: string | null; + } + } +} diff --git a/src/views/ReportsCenter/ModerationActionSelector.tsx b/src/views/ReportsCenter/ModerationActionSelector.tsx new file mode 100644 index 0000000000..bcd05068ed --- /dev/null +++ b/src/views/ReportsCenter/ModerationActionSelector.tsx @@ -0,0 +1,93 @@ +/* + * Copyright (C) Online-Go.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import * as React from "react"; +import { Report } from "report_manager"; +import { _, pgettext } from "translate"; + +interface ModerationActionSelectorProps { + report: Report; + enable: boolean; + claim: () => void; + submit: (action: string) => void; +} + +const ACTION_PROMPTS = { + annul_score_cheat: pgettext( + "A label for a moderator to select this option", + "Annul the game and warn the cheater.", + ), + warn_score_cheat: pgettext( + "Label for a moderator to select this option", + "The accused tried to cheat - warn the cheater.", + ), + no_score_cheat: pgettext( + "Label for a moderator to select this option", + "No cheating - inform the reporter.", + ), + escalate: pgettext( + "A label for a community moderator to select this option - send report to to full moderators", + "Escalate.", + ), +}; + +export function ModerationActionSelector({ + report, + enable, + claim, + submit, +}: ModerationActionSelectorProps): JSX.Element { + const [selectedOption, setSelectedOption] = React.useState(""); + + const updateSelectedAction = (e: React.ChangeEvent) => { + setSelectedOption(e.target.value); + claim(); + }; + + return ( +
+

+ {pgettext( + "The heading for community moderators 'action choices' section", + "Actions", + )} +

+ {report.available_actions.map((a) => ( +
+ + +
+ ))} + {(report.available_actions || null) && ( + + )} +
+ ); +} diff --git a/src/views/ReportsCenter/ReportedGame.tsx b/src/views/ReportsCenter/ReportedGame.tsx index 6eca587a4a..add0889b40 100644 --- a/src/views/ReportsCenter/ReportedGame.tsx +++ b/src/views/ReportsCenter/ReportedGame.tsx @@ -174,34 +174,39 @@ export function ReportedGame({ game_id }: { game_id: number }): JSX.Element {
)} - {goban.engine.phase === "finished" ? ( -
- {goban.engine.config.ranked && !annulled && ( - + {user.is_moderator && ( + <> + {goban.engine.phase === "finished" ? ( +
+ {goban.engine.config.ranked && !annulled && ( + + )} + {goban.engine.config.ranked && annulled && ( + + )} +
+ ) : ( +
+ + +
)} - {goban.engine.config.ranked && annulled && ( - - )} -
- ) : ( -
- - -
+ )} - {((goban.engine.phase === "finished" && goban.engine.game_id === game_id && ((goban.engine.width === 19 && goban.engine.height === 19) || diff --git a/src/views/ReportsCenter/ReportsCenter.tsx b/src/views/ReportsCenter/ReportsCenter.tsx index bd315c2e61..5eba1febab 100644 --- a/src/views/ReportsCenter/ReportsCenter.tsx +++ b/src/views/ReportsCenter/ReportsCenter.tsx @@ -143,34 +143,39 @@ export function ReportsCenter(): JSX.Element { {_("Reports Center")} -
-
- {report_manager.getReportsLeftUntilGoal() <= 0 - ? "All done, thank you!" - : report_manager.getHandledTodayCount() || ""} -
-
- {report_manager.getHandledTodayCount() === 0 - ? "Daily report goal: " + DAILY_REPORT_GOAL - : ""} + {user.is_moderator && ( +
+
+ {report_manager.getReportsLeftUntilGoal() <= 0 + ? "All done, thank you!" + : report_manager.getHandledTodayCount() || ""} +
+
+ {report_manager.getHandledTodayCount() === 0 + ? "Daily report goal: " + DAILY_REPORT_GOAL + : ""} +
-
+ )}
diff --git a/src/views/ReportsCenter/ReportsCenterHistory.styl b/src/views/ReportsCenter/ReportsCenterHistory.styl index c145a398bf..11cc75234d 100644 --- a/src/views/ReportsCenter/ReportsCenterHistory.styl +++ b/src/views/ReportsCenter/ReportsCenterHistory.styl @@ -24,4 +24,8 @@ padding-right: 0.25rem; padding-bottom: 0.4em; } + + .resolved { + themed color shade2 + } } \ No newline at end of file diff --git a/src/views/ReportsCenter/ReportsCenterHistory.tsx b/src/views/ReportsCenter/ReportsCenterHistory.tsx index e29439aff1..98dcdc22ca 100644 --- a/src/views/ReportsCenter/ReportsCenterHistory.tsx +++ b/src/views/ReportsCenter/ReportsCenterHistory.tsx @@ -96,7 +96,7 @@ export function ReportsCenterHistory(): JSX.Element { }, { header: "State", - className: () => "state", + className: (X) => `state ${X?.state}`, render: (X) => X.state, }, { diff --git a/src/views/ReportsCenter/ViewReport.styl b/src/views/ReportsCenter/ViewReport.styl index c94b62f070..c2920352c4 100644 --- a/src/views/ReportsCenter/ViewReport.styl +++ b/src/views/ReportsCenter/ViewReport.styl @@ -107,6 +107,31 @@ margin-right: 0; } } + + .notes { + flex-basis: 50%; + } + + .voting { + flex-basis: 50%; + + .action-selector { + display: flex; + align-items: center + + input { + margin-bottom: 3px; // override bizarre default 0 margin bottom so text lines up properly + } + } + + button { + margin-top: 0.5rem; + } + } + + .reported-game h3 { + margin-bottom: 0; + } } @media (max-width: 767px) { diff --git a/src/views/ReportsCenter/ViewReport.tsx b/src/views/ReportsCenter/ViewReport.tsx index 09ef7fc883..65b7b9e146 100644 --- a/src/views/ReportsCenter/ViewReport.tsx +++ b/src/views/ReportsCenter/ViewReport.tsx @@ -33,6 +33,7 @@ import { ReportedGame } from "./ReportedGame"; import { AppealView } from "./AppealView"; import { get } from "requests"; import { MessageTemplate, WARNING_TEMPLATES, REPORTER_RESPONSE_TEMPLATES } from "./MessageTemplate"; +import { ModerationActionSelector } from "./ModerationActionSelector"; // Used for saving updates to the report let report_note_id = 0; @@ -55,6 +56,7 @@ export function ViewReport({ report_id, reports, onChange }: ViewReportProps): J const [error, setError] = React.useState(null); const [moderator_id, setModeratorId] = React.useState(report?.moderator?.id); const [reportState, setReportState] = React.useState(report?.state); + const related = report_manager.getRelatedReports(report_id); React.useEffect(() => { @@ -174,7 +176,7 @@ export function ViewReport({ report_id, reports, onChange }: ViewReportProps): J ); const claimReport = () => { - if (report.moderator?.id !== user.id) { + if (report.moderator?.id !== user.id && user.is_moderator) { setReportState("claimed"); void report_manager.claim(report.id); } @@ -320,25 +322,28 @@ export function ViewReport({ report_id, reports, onChange }: ViewReportProps): J Next > - {report.moderator ? ( - <> - {(report.moderator.id === user.id || null) && ( - - )} - - ) : ( - - )} + {(user.is_moderator || null) && + (report.moderator ? ( + <> + {(report.moderator.id === user.id || null) && ( + + )} + + ) : ( + + ))} {!claimed_by_me && (
- {(report.reporter_note || null) && ( -
-

Reporter Notes

-
- {report.reporter_note_translation ? ( +
+

Reporter Notes

+
+ {(report.reporter_note || null) && + (report.reporter_note_translation ? ( <> {report.reporter_note_translation.source_text} {(report.reporter_note_translation.target_language !== @@ -395,10 +400,9 @@ export function ViewReport({ report_id, reports, onChange }: ViewReportProps): J ) : ( - )} -
+ ))}
- )} +
{(report.system_note || null) && (
@@ -413,97 +417,131 @@ export function ViewReport({ report_id, reports, onChange }: ViewReportProps): J