mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-11-15 08:47:30 -06:00
[feature/frontend] Reports frontend v2 (#3022)
* use apiutil + paging in admin processor+handlers * we're making it happen * fix little whoopsie * styling for report list * don't youuuu forget about meee don't don't don't don't * last bits * sanitize content before showing in report statuses * update report docs
This commit is contained in:
parent
b08c1bd0cb
commit
d2b3d37724
56 changed files with 1389 additions and 726 deletions
|
|
@ -1,56 +0,0 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useVerifyCredentialsQuery } from "../lib/query/oauth";
|
||||
|
||||
export default function FakeToot({ children }) {
|
||||
const { data: account = {
|
||||
avatar: "/assets/default_avatars/GoToSocial_icon1.png",
|
||||
display_name: "",
|
||||
username: ""
|
||||
} } = useVerifyCredentialsQuery();
|
||||
|
||||
return (
|
||||
<article className="status expanded">
|
||||
<header className="status-header">
|
||||
<address>
|
||||
<a style={{margin: 0}}>
|
||||
<img className="avatar" src={account.avatar} alt="" />
|
||||
<dl className="author-strap">
|
||||
<dt className="sr-only">Display name</dt>
|
||||
<dd className="displayname text-cutoff">
|
||||
{account.display_name.trim().length > 0 ? account.display_name : account.username}
|
||||
</dd>
|
||||
<dt className="sr-only">Username</dt>
|
||||
<dd className="username text-cutoff">@{account.username}</dd>
|
||||
</dl>
|
||||
</a>
|
||||
</address>
|
||||
</header>
|
||||
<section className="status-body">
|
||||
<div className="text">
|
||||
<div className="content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
242
web/source/settings/components/status.tsx
Normal file
242
web/source/settings/components/status.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { useVerifyCredentialsQuery } from "../lib/query/oauth";
|
||||
import { MediaAttachment, Status as StatusType } from "../lib/types/status";
|
||||
import sanitize from "sanitize-html";
|
||||
|
||||
export function FakeStatus({ children }) {
|
||||
const { data: account = {
|
||||
avatar: "/assets/default_avatars/GoToSocial_icon1.png",
|
||||
display_name: "",
|
||||
username: ""
|
||||
} } = useVerifyCredentialsQuery();
|
||||
|
||||
return (
|
||||
<article className="status expanded">
|
||||
<header className="status-header">
|
||||
<address>
|
||||
<a style={{margin: 0}}>
|
||||
<img className="avatar" src={account.avatar} alt="" />
|
||||
<dl className="author-strap">
|
||||
<dt className="sr-only">Display name</dt>
|
||||
<dd className="displayname text-cutoff">
|
||||
{account.display_name.trim().length > 0 ? account.display_name : account.username}
|
||||
</dd>
|
||||
<dt className="sr-only">Username</dt>
|
||||
<dd className="username text-cutoff">@{account.username}</dd>
|
||||
</dl>
|
||||
</a>
|
||||
</address>
|
||||
</header>
|
||||
<section className="status-body">
|
||||
<div className="text">
|
||||
<div className="content">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function Status({ status }: { status: StatusType }) {
|
||||
return (
|
||||
<article
|
||||
className="status expanded"
|
||||
id={status.id}
|
||||
role="region"
|
||||
>
|
||||
<StatusHeader status={status} />
|
||||
<StatusBody status={status} />
|
||||
<StatusFooter status={status} />
|
||||
<a
|
||||
href={status.url}
|
||||
target="_blank"
|
||||
className="status-link"
|
||||
data-nosnippet
|
||||
title="Open this status (opens in new tab)"
|
||||
>
|
||||
Open this status (opens in new tab)
|
||||
</a>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusHeader({ status }: { status: StatusType }) {
|
||||
const author = status.account;
|
||||
|
||||
return (
|
||||
<header className="status-header">
|
||||
<address>
|
||||
<a
|
||||
href={author.url}
|
||||
rel="author"
|
||||
title="Open profile"
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
className="avatar"
|
||||
aria-hidden="true"
|
||||
src={author.avatar}
|
||||
alt={`Avatar for ${author.username}`}
|
||||
title={`Avatar for ${author.username}`}
|
||||
/>
|
||||
<div className="author-strap">
|
||||
<span className="displayname text-cutoff">{author.display_name}</span>
|
||||
<span className="sr-only">,</span>
|
||||
<span className="username text-cutoff">@{author.acct}</span>
|
||||
</div>
|
||||
<span className="sr-only">(open profile)</span>
|
||||
</a>
|
||||
</address>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBody({ status }: { status: StatusType }) {
|
||||
let content: string;
|
||||
if (status.content.length === 0) {
|
||||
content = "[no content set]";
|
||||
} else {
|
||||
// HTML has already been through
|
||||
// the instance sanitizer by now,
|
||||
// but do it again just in case.
|
||||
content = sanitize(status.content);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="status-body">
|
||||
<details className="text-spoiler">
|
||||
<summary>
|
||||
<span
|
||||
className="spoiler-text"
|
||||
lang={status.language}
|
||||
>
|
||||
{ status.spoiler_text
|
||||
? status.spoiler_text + " "
|
||||
: "[no content warning set] "
|
||||
}
|
||||
</span>
|
||||
<span
|
||||
className="button"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Toggle content visibility"
|
||||
>
|
||||
Toggle content visibility
|
||||
</span>
|
||||
</summary>
|
||||
<div
|
||||
className="text"
|
||||
dangerouslySetInnerHTML={{__html: content}}
|
||||
/>
|
||||
</details>
|
||||
<StatusMedia status={status} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusMedia({ status }: { status: StatusType }) {
|
||||
if (status.media_attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const count = status.media_attachments.length;
|
||||
const aria_label = count === 1 ? "1 attachment" : `${count} attachments`;
|
||||
const oddOrEven = count % 2 === 0 ? "even" : "odd";
|
||||
const single = count === 1 ? " single" : "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`media ${oddOrEven}${single}`}
|
||||
role="group"
|
||||
aria-label={aria_label}
|
||||
>
|
||||
{ status.media_attachments.map((media) => {
|
||||
return (
|
||||
<StatusMediaEntry
|
||||
key={media.id}
|
||||
media={media}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusMediaEntry({ media }: { media: MediaAttachment }) {
|
||||
return (
|
||||
<div className="media-wrapper">
|
||||
<details className="image-spoiler media-spoiler">
|
||||
<summary>
|
||||
<div className="show sensitive button" aria-hidden="true">Show media</div>
|
||||
<span className="eye button" role="button" tabIndex={0} aria-label="Toggle show media">
|
||||
<i className="hide fa fa-fw fa-eye-slash" aria-hidden="true"></i>
|
||||
<i className="show fa fa-fw fa-eye" aria-hidden="true"></i>
|
||||
</span>
|
||||
<img
|
||||
src={media.preview_url}
|
||||
loading="lazy"
|
||||
alt={media.description}
|
||||
title={media.description}
|
||||
width={media.meta.small.width}
|
||||
height={media.meta.small.height}
|
||||
/>
|
||||
</summary>
|
||||
<a
|
||||
href={media.url}
|
||||
target="_blank"
|
||||
>
|
||||
<img
|
||||
src={media.url}
|
||||
loading="lazy"
|
||||
alt={media.description}
|
||||
width={media.meta.original.width}
|
||||
height={media.meta.original.height}
|
||||
/>
|
||||
</a>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusFooter({ status }: { status: StatusType }) {
|
||||
return (
|
||||
<aside className="status-info" aria-hidden="true">
|
||||
<dl className="status-stats">
|
||||
<div className="stats-grouping">
|
||||
<div className="stats-item published-at text-cutoff">
|
||||
<dt className="sr-only">Published</dt>
|
||||
<dd>
|
||||
<time dateTime={status.created_at}>
|
||||
{ new Date(status.created_at).toLocaleString() }
|
||||
</time>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stats-item language">
|
||||
<dt className="sr-only">Language</dt>
|
||||
<dd>{status.language}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -60,7 +60,7 @@ export default function Username({ account, linkTo, backLocation, classNames }:
|
|||
);
|
||||
|
||||
if (linkTo) {
|
||||
className += " spanlink";
|
||||
className += " pseudolink";
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
|
|
|
|||
|
|
@ -21,29 +21,51 @@ import { gtsApi } from "../../gts-api";
|
|||
|
||||
import type {
|
||||
AdminReport,
|
||||
AdminReportListParams,
|
||||
AdminSearchReportParams,
|
||||
AdminReportResolveParams,
|
||||
AdminSearchReportResp,
|
||||
} from "../../../types/report";
|
||||
import parse from "parse-link-header";
|
||||
|
||||
const extended = gtsApi.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
listReports: build.query<AdminReport[], AdminReportListParams | void>({
|
||||
query: (params) => ({
|
||||
url: "/api/v1/admin/reports",
|
||||
params: {
|
||||
// Override provided limit.
|
||||
limit: 100,
|
||||
...params
|
||||
searchReports: build.query<AdminSearchReportResp, AdminSearchReportParams>({
|
||||
query: (form) => {
|
||||
const params = new(URLSearchParams);
|
||||
Object.entries(form).forEach(([k, v]) => {
|
||||
if (v !== undefined) {
|
||||
params.append(k, v);
|
||||
}
|
||||
});
|
||||
|
||||
let query = "";
|
||||
if (params.size !== 0) {
|
||||
query = `?${params.toString()}`;
|
||||
}
|
||||
}),
|
||||
providesTags: [{ type: "Reports", id: "LIST" }]
|
||||
|
||||
return {
|
||||
url: `/api/v1/admin/reports${query}`
|
||||
};
|
||||
},
|
||||
// Headers required for paging.
|
||||
transformResponse: (apiResp: AdminReport[], meta) => {
|
||||
const accounts = apiResp;
|
||||
const linksStr = meta?.response?.headers.get("Link");
|
||||
const links = parse(linksStr);
|
||||
return { accounts, links };
|
||||
},
|
||||
// Only provide LIST tag id since this model is not the
|
||||
// same as getReport model (due to transformResponse).
|
||||
providesTags: [{ type: "Report", id: "TRANSFORMED" }]
|
||||
}),
|
||||
|
||||
getReport: build.query<AdminReport, string>({
|
||||
query: (id) => ({
|
||||
url: `/api/v1/admin/reports/${id}`
|
||||
}),
|
||||
providesTags: (_res, _error, id) => [{ type: "Reports", id }]
|
||||
providesTags: (_result, _error, id) => [
|
||||
{ type: 'Report', id }
|
||||
],
|
||||
}),
|
||||
|
||||
resolveReport: build.mutation<AdminReport, AdminReportResolveParams>({
|
||||
|
|
@ -55,8 +77,8 @@ const extended = gtsApi.injectEndpoints({
|
|||
}),
|
||||
invalidatesTags: (res) =>
|
||||
res
|
||||
? [{ type: "Reports", id: "LIST" }, { type: "Reports", id: res.id }]
|
||||
: [{ type: "Reports", id: "LIST" }]
|
||||
? [{ type: "Report", id: "LIST" }, { type: "Report", id: res.id }]
|
||||
: [{ type: "Report", id: "LIST" }]
|
||||
})
|
||||
})
|
||||
});
|
||||
|
|
@ -64,7 +86,7 @@ const extended = gtsApi.injectEndpoints({
|
|||
/**
|
||||
* List reports received on this instance, filtered using given parameters.
|
||||
*/
|
||||
const useListReportsQuery = extended.useListReportsQuery;
|
||||
const useLazySearchReportsQuery = extended.useLazySearchReportsQuery;
|
||||
|
||||
/**
|
||||
* Get a single report by its ID.
|
||||
|
|
@ -77,7 +99,7 @@ const useGetReportQuery = extended.useGetReportQuery;
|
|||
const useResolveReportMutation = extended.useResolveReportMutation;
|
||||
|
||||
export {
|
||||
useListReportsQuery,
|
||||
useLazySearchReportsQuery,
|
||||
useGetReportQuery,
|
||||
useResolveReportMutation,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export const gtsApi = createApi({
|
|||
tagTypes: [
|
||||
"Auth",
|
||||
"Emoji",
|
||||
"Reports",
|
||||
"Report",
|
||||
"Account",
|
||||
"InstanceRules",
|
||||
"HTTPHeaderAllows",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Links } from "parse-link-header";
|
||||
import { AdminAccount } from "./account";
|
||||
import { Status } from "./status";
|
||||
|
||||
/**
|
||||
* Admin model of a report. Differs from the client
|
||||
* model, which contains less detailed information.
|
||||
|
|
@ -56,29 +60,25 @@ export interface AdminReport {
|
|||
updated_at: string;
|
||||
/**
|
||||
* Account that created the report.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
account: Object;
|
||||
account: AdminAccount;
|
||||
/**
|
||||
* Reported account.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
target_account: Object;
|
||||
target_account: AdminAccount;
|
||||
/**
|
||||
* Admin account assigned to handle this report, if any.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
assigned_account?: Object;
|
||||
assigned_account?: AdminAccount;
|
||||
/**
|
||||
* Admin account that has taken action on this report, if any.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
action_taken_by_account?: Object;
|
||||
action_taken_by_account?: AdminAccount;
|
||||
/**
|
||||
* Statuses cited by this report, if any.
|
||||
* TODO: model this properly.
|
||||
*/
|
||||
statuses: Object[];
|
||||
statuses: Status[];
|
||||
/**
|
||||
* Rules broken according to the reporter, if any.
|
||||
* TODO: model this properly.
|
||||
|
|
@ -108,7 +108,7 @@ export interface AdminReportResolveParams {
|
|||
/**
|
||||
* Parameters for GET to /api/v1/admin/reports.
|
||||
*/
|
||||
export interface AdminReportListParams {
|
||||
export interface AdminSearchReportParams {
|
||||
/**
|
||||
* If set, show only resolved (true) or only unresolved (false) reports.
|
||||
*/
|
||||
|
|
@ -142,3 +142,8 @@ export interface AdminReportListParams {
|
|||
*/
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface AdminSearchReportResp {
|
||||
accounts: AdminReport[];
|
||||
links: Links | null;
|
||||
}
|
||||
|
|
|
|||
83
web/source/settings/lib/types/status.ts
Normal file
83
web/source/settings/lib/types/status.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Account } from "./account";
|
||||
import { CustomEmoji } from "./custom-emoji";
|
||||
|
||||
export interface Status {
|
||||
id: string;
|
||||
created_at: string;
|
||||
in_reply_to_id: string | null;
|
||||
in_reply_to_account_id: string | null;
|
||||
sensitive: boolean;
|
||||
spoiler_text: string;
|
||||
visibility: string;
|
||||
language: string;
|
||||
uri: string;
|
||||
url: string;
|
||||
replies_count: number;
|
||||
reblogs_count: number;
|
||||
favourites_count: number;
|
||||
favourited: boolean;
|
||||
reblogged: boolean;
|
||||
muted: boolean;
|
||||
bookmarked: boolean;
|
||||
pinned: boolean;
|
||||
content: string,
|
||||
reblog: Status | null,
|
||||
account: Account,
|
||||
media_attachments: MediaAttachment[],
|
||||
mentions: [];
|
||||
tags: [];
|
||||
emojis: CustomEmoji[];
|
||||
card: null;
|
||||
poll: null;
|
||||
}
|
||||
|
||||
export interface MediaAttachment {
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
text_url: string;
|
||||
preview_url: string;
|
||||
remote_url: string | null;
|
||||
preview_remote_url: string | null;
|
||||
meta: MediaAttachmentMeta;
|
||||
description: string;
|
||||
blurhash: string;
|
||||
}
|
||||
|
||||
interface MediaAttachmentMeta {
|
||||
original: {
|
||||
width: number;
|
||||
height: number;
|
||||
size: string;
|
||||
aspect: number;
|
||||
},
|
||||
small: {
|
||||
width: number;
|
||||
height: number;
|
||||
size: string;
|
||||
aspect: number;
|
||||
},
|
||||
focus: {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
}
|
||||
|
|
@ -19,8 +19,8 @@
|
|||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { AdminAccount } from "../../../../lib/types/account";
|
||||
import { store } from "../../../../redux/store";
|
||||
import { AdminAccount } from "../types/account";
|
||||
import { store } from "../../redux/store";
|
||||
|
||||
export function yesOrNo(b: boolean): string {
|
||||
return b ? "yes" : "no";
|
||||
|
|
@ -1045,62 +1045,62 @@ button.with-padding {
|
|||
}
|
||||
}
|
||||
|
||||
.reports {
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reports-view {
|
||||
.report {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0;
|
||||
|
||||
text-decoration: none;
|
||||
color: $fg;
|
||||
|
||||
padding: 1rem;
|
||||
|
||||
border: none;
|
||||
border-left: 0.3rem solid $border-accent;
|
||||
|
||||
.usernames {
|
||||
line-height: 2rem;
|
||||
}
|
||||
.username-lozenge {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
||||
.byline {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.5rem;
|
||||
|
||||
.report-status {
|
||||
color: $border-accent;
|
||||
.fa {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.2rem 0.5rem;
|
||||
padding: 0.5rem;
|
||||
|
||||
justify-items: start;
|
||||
.report-byline {
|
||||
max-width: fit-content;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
.info-list {
|
||||
border: none;
|
||||
|
||||
.info-list-entry {
|
||||
background: none;
|
||||
padding: 0;
|
||||
|
||||
.report-target .username-lozenge {
|
||||
color: $bg;
|
||||
}
|
||||
|
||||
.reported-by .username-lozenge {
|
||||
color: $fg;
|
||||
font-weight: initial;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.resolved {
|
||||
color: $fg-reduced;
|
||||
border-left: 0.4rem solid $bg;
|
||||
border-left: 0.3rem solid $list-entry-bg;
|
||||
|
||||
.byline .report-status {
|
||||
.info-list,
|
||||
.info-list .info-list-entry .reported-by .username-lozenge {
|
||||
color: $fg-reduced;
|
||||
}
|
||||
|
||||
.user {
|
||||
opacity: 0.8;
|
||||
|
||||
&:hover {
|
||||
border-color: $fg-accent;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1109,70 +1109,40 @@ button.with-padding {
|
|||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.report.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 1rem;
|
||||
gap: 1rem;
|
||||
|
||||
.info-block {
|
||||
padding: 0.5rem;
|
||||
background: $gray2;
|
||||
.report-detail {
|
||||
.info-list {
|
||||
|
||||
&.overview {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: block;
|
||||
}
|
||||
.username-lozenge {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
max-width: fit-content;
|
||||
|
||||
.reported-toots {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.toot .toot-info {
|
||||
padding: 0.5rem;
|
||||
background: $toot-info-bg;
|
||||
|
||||
a {
|
||||
color: $fg-reduced;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-left-radius: $br;
|
||||
border-bottom-right-radius: $br;
|
||||
.fa {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.username-lozenge {
|
||||
line-height: 1.3rem;
|
||||
display: inline-block;
|
||||
background: $fg-accent;
|
||||
color: $bg;
|
||||
border-radius: $br;
|
||||
padding: 0.15rem;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
|
||||
.acct {
|
||||
word-break: break-all;
|
||||
.report-statuses {
|
||||
width: min(100%, 50rem);
|
||||
|
||||
.thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.suspended {
|
||||
background: $bg-accent;
|
||||
color: $fg;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
&.local {
|
||||
background: $green1;
|
||||
}
|
||||
}
|
||||
|
||||
.spanlink {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.accounts-view {
|
||||
|
|
@ -1223,6 +1193,36 @@ button.with-padding {
|
|||
}
|
||||
}
|
||||
|
||||
.username-lozenge {
|
||||
line-height: 1.3rem;
|
||||
display: inline-block;
|
||||
background: $fg-accent;
|
||||
color: $bg;
|
||||
border-radius: $br;
|
||||
padding: 0.15rem;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
|
||||
.acct {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
&.suspended {
|
||||
background: $bg-accent;
|
||||
color: $fg;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
&.local {
|
||||
background: $green1;
|
||||
}
|
||||
}
|
||||
|
||||
.pseudolink {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.info-list {
|
||||
border: 0.1rem solid $gray1;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { Redirect, useParams } from "wouter";
|
|||
import { useComboBoxInput, useFileInput, useValue } from "../../../../lib/form";
|
||||
import useFormSubmit from "../../../../lib/form/submit";
|
||||
import { useBaseUrl } from "../../../../lib/navigation/util";
|
||||
import FakeToot from "../../../../components/fake-toot";
|
||||
import { FakeStatus } from "../../../../components/status";
|
||||
import FormWithData from "../../../../lib/form/form-with-data";
|
||||
import Loading from "../../../../components/loading";
|
||||
import { FileInput } from "../../../../components/form/inputs";
|
||||
|
|
@ -124,14 +124,14 @@ function EmojiDetailForm({ data: emoji }) {
|
|||
disabled={!form.image.value}
|
||||
/>
|
||||
|
||||
<FakeToot>
|
||||
<FakeStatus>
|
||||
Look at this new custom emoji <img
|
||||
className="emoji"
|
||||
src={form.image.previewValue ?? emoji.url}
|
||||
title={`:${emoji.shortcode}:`}
|
||||
alt={emoji.shortcode}
|
||||
/> isn't it cool?
|
||||
</FakeToot>
|
||||
</FakeStatus>
|
||||
|
||||
{result.error && <Error error={result.error} />}
|
||||
{deleteResult.error && <Error error={deleteResult.error} />}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import useShortcode from "./use-shortcode";
|
|||
import useFormSubmit from "../../../../lib/form/submit";
|
||||
import { TextInput, FileInput } from "../../../../components/form/inputs";
|
||||
import { CategorySelect } from '../category-select';
|
||||
import FakeToot from "../../../../components/fake-toot";
|
||||
import { FakeStatus } from "../../../../components/status";
|
||||
import MutationButton from "../../../../components/form/mutation-button";
|
||||
import { useAddEmojiMutation } from "../../../../lib/query/admin/custom-emoji";
|
||||
import { useInstanceV1Query } from "../../../../lib/query/gts-api";
|
||||
|
|
@ -103,9 +103,9 @@ export default function NewEmojiForm() {
|
|||
<div>
|
||||
<h2>Add new custom emoji</h2>
|
||||
|
||||
<FakeToot>
|
||||
<FakeStatus>
|
||||
Look at this new custom emoji {emojiOrShortcode} isn't it cool?
|
||||
</FakeToot>
|
||||
</FakeStatus>
|
||||
|
||||
<form onSubmit={submitForm} className="form-flex">
|
||||
<FileInput
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export default function HeaderPermsOverview() {
|
|||
return (
|
||||
<dl
|
||||
key={perm.id}
|
||||
className="entry spanlink"
|
||||
className="entry pseudolink"
|
||||
onClick={() => {
|
||||
// When clicking on a header perm,
|
||||
// go to the detail view for perm.
|
||||
|
|
|
|||
|
|
@ -21,13 +21,13 @@ import React from "react";
|
|||
|
||||
import { useGetAccountQuery } from "../../../../lib/query/admin";
|
||||
import FormWithData from "../../../../lib/form/form-with-data";
|
||||
import FakeProfile from "../../../../components/fake-profile";
|
||||
import FakeProfile from "../../../../components/profile";
|
||||
import { AdminAccount } from "../../../../lib/types/account";
|
||||
import { AccountActions } from "./actions";
|
||||
import { useParams } from "wouter";
|
||||
import { useBaseUrl } from "../../../../lib/navigation/util";
|
||||
import BackButton from "../../../../components/back-button";
|
||||
import { UseOurInstanceAccount, yesOrNo } from "./util";
|
||||
import { UseOurInstanceAccount, yesOrNo } from "../../../../lib/util";
|
||||
|
||||
export default function AccountDetail() {
|
||||
const params: { accountID: string } = useParams();
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export function AccountSearchForm() {
|
|||
}
|
||||
|
||||
// Location to return to when user clicks "back" on the account detail view.
|
||||
const backLocation = location + (urlQueryParams ? `?${urlQueryParams}` : "");
|
||||
const backLocation = location + (urlQueryParams.size > 0 ? `?${urlQueryParams}` : "");
|
||||
|
||||
// Function to map an item to a list entry.
|
||||
function itemToEntry(account: AdminAccount): ReactNode {
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@
|
|||
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useParams } from "wouter";
|
||||
import React from "react";
|
||||
import { useLocation, useParams } from "wouter";
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
import BackButton from "../../../components/back-button";
|
||||
import { useValue, useTextInput } from "../../../lib/form";
|
||||
|
|
@ -28,84 +28,172 @@ import MutationButton from "../../../components/form/mutation-button";
|
|||
import Username from "../../../components/username";
|
||||
import { useGetReportQuery, useResolveReportMutation } from "../../../lib/query/admin/reports";
|
||||
import { useBaseUrl } from "../../../lib/navigation/util";
|
||||
import { AdminReport } from "../../../lib/types/report";
|
||||
import { yesOrNo } from "../../../lib/util";
|
||||
import { Status } from "../../../components/status";
|
||||
|
||||
export default function ReportDetail({ }) {
|
||||
const params: { reportId: string } = useParams();
|
||||
const baseUrl = useBaseUrl();
|
||||
const params = useParams();
|
||||
const backLocation: String = history.state?.backLocation ?? `~${baseUrl}`;
|
||||
|
||||
return (
|
||||
<div className="reports">
|
||||
<h1><BackButton to={`~${baseUrl}`}/> Report Details</h1>
|
||||
<div className="report-detail">
|
||||
<h1><BackButton to={backLocation}/> Report Details</h1>
|
||||
<FormWithData
|
||||
dataQuery={useGetReportQuery}
|
||||
queryArg={params.reportId}
|
||||
DataForm={ReportDetailForm}
|
||||
{...{ backLocation: backLocation }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportDetailForm({ data: report }) {
|
||||
function ReportDetailForm({ data: report }: { data: AdminReport }) {
|
||||
const [ location ] = useLocation();
|
||||
const baseUrl = useBaseUrl();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReportBasicInfo
|
||||
report={report}
|
||||
baseUrl={baseUrl}
|
||||
location={location}
|
||||
/>
|
||||
|
||||
{ report.action_taken
|
||||
&& <ReportHistory
|
||||
report={report}
|
||||
baseUrl={baseUrl}
|
||||
location={location}
|
||||
/>
|
||||
}
|
||||
|
||||
{ report.statuses &&
|
||||
<ReportStatuses report={report} />
|
||||
}
|
||||
|
||||
{ !report.action_taken &&
|
||||
<ReportActionForm report={report} />
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReportSectionProps {
|
||||
report: AdminReport;
|
||||
baseUrl: string;
|
||||
location: string;
|
||||
}
|
||||
|
||||
function ReportBasicInfo({ report, baseUrl, location }: ReportSectionProps) {
|
||||
const from = report.account;
|
||||
const target = report.target_account;
|
||||
const comment = report.comment;
|
||||
const status = report.action_taken ? "Resolved" : "Unresolved";
|
||||
const created = new Date(report.created_at).toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="report detail">
|
||||
<div className="usernames">
|
||||
<Username
|
||||
account={from}
|
||||
linkTo={`~/settings/moderation/accounts/${from.id}`}
|
||||
backLocation={`~/settings/moderation/reports/${report.id}`}
|
||||
/>
|
||||
<> reported </>
|
||||
<Username
|
||||
account={target}
|
||||
linkTo={`~/settings/moderation/accounts/${target.id}`}
|
||||
backLocation={`~/settings/moderation/reports/${report.id}`}
|
||||
/>
|
||||
<dl className="info-list overview">
|
||||
<div className="info-list-entry">
|
||||
<dt>Reported account</dt>
|
||||
<dd>
|
||||
<Username
|
||||
account={target}
|
||||
linkTo={`~/settings/moderation/accounts/${target.id}`}
|
||||
backLocation={`~${baseUrl}${location}`}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Reported by</dt>
|
||||
<dd>
|
||||
<Username
|
||||
account={from}
|
||||
linkTo={`~/settings/moderation/accounts/${from.id}`}
|
||||
backLocation={`~${baseUrl}${location}`}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{report.action_taken &&
|
||||
<div className="info">
|
||||
<h3>Resolved by @{report.action_taken_by_account.account.acct}</h3>
|
||||
<span className="timestamp">at {new Date(report.action_taken_at).toLocaleString()}</span>
|
||||
<br />
|
||||
<b>Comment: </b><span>{report.action_taken_comment}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className="info-block">
|
||||
<h3>Report info:</h3>
|
||||
<div className="details">
|
||||
<b>Created: </b>
|
||||
<span>{new Date(report.created_at).toLocaleString()}</span>
|
||||
|
||||
<b>Forwarded: </b> <span>{report.forwarded ? "Yes" : "No"}</span>
|
||||
<b>Category: </b> <span>{report.category}</span>
|
||||
|
||||
<b>Reason: </b>
|
||||
{report.comment.length > 0
|
||||
? <p>{report.comment}</p>
|
||||
: <i className="no-comment">none provided</i>
|
||||
<div className="info-list-entry">
|
||||
<dt>Status</dt>
|
||||
<dd>
|
||||
{ report.action_taken
|
||||
? <>{status}</>
|
||||
: <b>{status}</b>
|
||||
}
|
||||
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{!report.action_taken && <ReportActionForm report={report} />}
|
||||
<div className="info-list-entry">
|
||||
<dt>Reason</dt>
|
||||
<dd>
|
||||
{ comment.length > 0
|
||||
? <>{comment}</>
|
||||
: <i>none provided</i>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{
|
||||
report.statuses.length > 0 &&
|
||||
<div className="info-block">
|
||||
<h3>Reported toots ({report.statuses.length}):</h3>
|
||||
<div className="reported-toots">
|
||||
{report.statuses.map((status) => (
|
||||
<ReportedToot key={status.id} toot={status} />
|
||||
))}
|
||||
</div>
|
||||
<div className="info-list-entry">
|
||||
<dt>Created</dt>
|
||||
<dd>
|
||||
<time dateTime={report.created_at}>{created}</time>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Category</dt>
|
||||
<dd>{ report.category }</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Forwarded</dt>
|
||||
<dd>{ yesOrNo(report.forwarded) }</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportHistory({ report, baseUrl, location }: ReportSectionProps) {
|
||||
const handled_by = report.action_taken_by_account;
|
||||
if (!handled_by) {
|
||||
throw "report handled by action_taken_by_account undefined";
|
||||
}
|
||||
|
||||
const handled = report.action_taken_at ? new Date(report.action_taken_at).toLocaleString() : "never";
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>Moderation History</h3>
|
||||
<dl className="info-list">
|
||||
<div className="info-list-entry">
|
||||
<dt>Handled by</dt>
|
||||
<dd>
|
||||
<Username
|
||||
account={handled_by}
|
||||
linkTo={`~/settings/moderation/accounts/${handled_by.id}`}
|
||||
backLocation={`~${baseUrl}${location}`}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Handled</dt>
|
||||
<dd>
|
||||
<time dateTime={report.action_taken_at}>{handled}</time>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Comment</dt>
|
||||
<dd>{ report.action_taken_comment ?? "none"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -118,13 +206,18 @@ function ReportActionForm({ report }) {
|
|||
const [submit, result] = useFormSubmit(form, useResolveReportMutation(), { changedOnly: false });
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="info-block">
|
||||
<h3>Resolving this report</h3>
|
||||
<p>
|
||||
<form onSubmit={submit}>
|
||||
<h3>Resolve this report</h3>
|
||||
<>
|
||||
An optional comment can be included while resolving this report.
|
||||
Useful for providing an explanation about what action was taken (if any) before the report was marked as resolved.<br />
|
||||
<b>This will be visible to the user that created the report!</b>
|
||||
</p>
|
||||
This is useful for providing an explanation about what action was
|
||||
taken (if any) before the report was marked as resolved.
|
||||
<br />
|
||||
<b>
|
||||
Any comment made here will be visible
|
||||
to the user that created the report!
|
||||
</b>
|
||||
</>
|
||||
<TextArea
|
||||
field={form.comment}
|
||||
label="Comment"
|
||||
|
|
@ -138,116 +231,24 @@ function ReportActionForm({ report }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ReportedToot({ toot }) {
|
||||
const account = toot.account;
|
||||
|
||||
return (
|
||||
<article className="status expanded">
|
||||
<header className="status-header">
|
||||
<address>
|
||||
<a style={{margin: 0}}>
|
||||
<img className="avatar" src={account.avatar} alt="" />
|
||||
<dl className="author-strap">
|
||||
<dt className="sr-only">Display name</dt>
|
||||
<dd className="displayname text-cutoff">
|
||||
{account.display_name.trim().length > 0 ? account.display_name : account.username}
|
||||
</dd>
|
||||
<dt className="sr-only">Username</dt>
|
||||
<dd className="username text-cutoff">@{account.username}</dd>
|
||||
</dl>
|
||||
</a>
|
||||
</address>
|
||||
</header>
|
||||
<section className="status-body">
|
||||
<div className="text">
|
||||
<div className="content">
|
||||
{toot.spoiler_text?.length > 0
|
||||
? <TootCW content={toot.content} note={toot.spoiler_text} />
|
||||
: toot.content
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{toot.media_attachments?.length > 0 &&
|
||||
<TootMedia media={toot.media_attachments} sensitive={toot.sensitive} />
|
||||
}
|
||||
</section>
|
||||
<aside className="status-info">
|
||||
<dl className="status-stats">
|
||||
<div className="stats-grouping">
|
||||
<div className="stats-item published-at text-cutoff">
|
||||
<dt className="sr-only">Published</dt>
|
||||
<dd>
|
||||
<time dateTime={toot.created_at}>{new Date(toot.created_at).toLocaleString()}</time>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function TootCW({ note, content }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
function toggleVisible() {
|
||||
setVisible(!visible);
|
||||
function ReportStatuses({ report }: { report: AdminReport }) {
|
||||
if (report.statuses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="spoiler">
|
||||
<span>{note}</span>
|
||||
<label className="button spoiler-label" onClick={toggleVisible}>Show {visible ? "less" : "more"}</label>
|
||||
</div>
|
||||
{visible && content}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TootMedia({ media, sensitive }) {
|
||||
let classes = (media.length % 2 == 0) ? "even" : "odd";
|
||||
if (media.length == 1) {
|
||||
classes += " single";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`media photoswipe-gallery ${classes}`}>
|
||||
{media.map((m) => (
|
||||
<div key={m.id} className="media-wrapper">
|
||||
{sensitive && <>
|
||||
<input id={`sensitiveMedia-${m.id}`} type="checkbox" className="sensitive-checkbox hidden" />
|
||||
<div className="sensitive">
|
||||
<div className="open">
|
||||
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
|
||||
<i className="fa fa-eye-slash" title="Hide sensitive media"></i>
|
||||
</label>
|
||||
</div>
|
||||
<div className="closed" title={m.description}>
|
||||
<label htmlFor={`sensitiveMedia-${m.id}`} className="button" role="button" tabIndex={0}>
|
||||
Show sensitive media
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</>}
|
||||
<a
|
||||
href={m.url}
|
||||
title={m.description}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
data-cropped="true"
|
||||
data-pswp-width={`${m.meta?.original.width}px`}
|
||||
data-pswp-height={`${m.meta?.original.height}px`}
|
||||
>
|
||||
<img
|
||||
alt={m.description}
|
||||
src={m.url}
|
||||
// thumb={m.preview_url}
|
||||
sizes={m.meta?.original}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
<div className="report-statuses">
|
||||
<h3>Reported Statuses</h3>
|
||||
<ul className="thread">
|
||||
{ report.statuses.map((status) => {
|
||||
return (
|
||||
<Status
|
||||
key={status.id}
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,97 +0,0 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Link } from "wouter";
|
||||
import FormWithData from "../../../lib/form/form-with-data";
|
||||
import Username from "../../../components/username";
|
||||
import { useListReportsQuery } from "../../../lib/query/admin/reports";
|
||||
|
||||
export function ReportOverview({ }) {
|
||||
return (
|
||||
<FormWithData
|
||||
dataQuery={useListReportsQuery}
|
||||
DataForm={ReportsList}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportsList({ data: reports }) {
|
||||
return (
|
||||
<div className="reports">
|
||||
<div className="form-section-docs">
|
||||
<h1>Reports</h1>
|
||||
<p>
|
||||
Here you can view and resolve reports made to your
|
||||
instance, originating from local and remote users.
|
||||
</p>
|
||||
<a
|
||||
href="https://docs.gotosocial.org/en/latest/admin/settings/#reports"
|
||||
target="_blank"
|
||||
className="docslink"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Learn more about this (opens in a new tab)
|
||||
</a>
|
||||
</div>
|
||||
<div className="list">
|
||||
{reports.map((report) => (
|
||||
<ReportEntry key={report.id} report={report} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportEntry({ report }) {
|
||||
const from = report.account;
|
||||
const target = report.target_account;
|
||||
|
||||
let comment = report.comment.length > 200
|
||||
? report.comment.slice(0, 200) + "..."
|
||||
: report.comment;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/${report.id}`}
|
||||
className="nounderline"
|
||||
>
|
||||
<div className={`report entry${report.action_taken ? " resolved" : ""}`}>
|
||||
<div className="byline">
|
||||
<div className="usernames">
|
||||
<Username account={from} /> reported <Username account={target} />
|
||||
</div>
|
||||
<h3 className="report-status">
|
||||
{report.action_taken ? "Resolved" : "Open"}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="details">
|
||||
<b>Created: </b>
|
||||
<span>{new Date(report.created_at).toLocaleString()}</span>
|
||||
|
||||
<b>Reason: </b>
|
||||
{comment.length > 0
|
||||
? <p>{comment}</p>
|
||||
: <i className="no-comment">none provided</i>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
252
web/source/settings/views/moderation/reports/search.tsx
Normal file
252
web/source/settings/views/moderation/reports/search.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) GoToSocial Authors admin@gotosocial.org
|
||||
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React, { ReactNode, useEffect, useMemo } from "react";
|
||||
|
||||
import { useLazySearchReportsQuery } from "../../../lib/query/admin/reports";
|
||||
import { useTextInput } from "../../../lib/form";
|
||||
import { PageableList } from "../../../components/pageable-list";
|
||||
import { Select } from "../../../components/form/inputs";
|
||||
import MutationButton from "../../../components/form/mutation-button";
|
||||
import { useLocation, useSearch } from "wouter";
|
||||
import Username from "../../../components/username";
|
||||
import { AdminReport } from "../../../lib/types/report";
|
||||
|
||||
export default function ReportsSearch() {
|
||||
return (
|
||||
<div className="reports-view">
|
||||
<h1>Reports Search</h1>
|
||||
<span>
|
||||
You can use the form below to search through reports
|
||||
created by, or directed towards, accounts on this instance.
|
||||
</span>
|
||||
<ReportSearchForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportSearchForm() {
|
||||
const [ location, setLocation ] = useLocation();
|
||||
const search = useSearch();
|
||||
const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]);
|
||||
const hasParams = urlQueryParams.size != 0;
|
||||
const [ searchReports, searchRes ] = useLazySearchReportsQuery();
|
||||
|
||||
// Populate search form using values from
|
||||
// urlQueryParams, to allow paging.
|
||||
const resolved = useMemo(() => {
|
||||
const resolvedRaw = urlQueryParams.get("resolved");
|
||||
if (resolvedRaw !== null) {
|
||||
return resolvedRaw;
|
||||
}
|
||||
}, [urlQueryParams]);
|
||||
|
||||
const form = {
|
||||
resolved: useTextInput("resolved", { defaultValue: resolved }),
|
||||
account_id: useTextInput("account_id", { defaultValue: urlQueryParams.get("account_id") ?? "" }),
|
||||
target_account_id: useTextInput("target_account_id", { defaultValue: urlQueryParams.get("target_account_id") ?? "" }),
|
||||
limit: useTextInput("limit", { defaultValue: urlQueryParams.get("limit") ?? "20" })
|
||||
};
|
||||
|
||||
const setResolved = form.resolved.setter;
|
||||
|
||||
// On mount, if urlQueryParams were provided,
|
||||
// trigger the search. For example, if page
|
||||
// was accessed at /search?origin=local&limit=20,
|
||||
// then run a search with origin=local and
|
||||
// limit=20 and immediately render the results.
|
||||
//
|
||||
// If no urlQueryParams set, use the default
|
||||
// search (just show unresolved reports).
|
||||
useEffect(() => {
|
||||
if (hasParams) {
|
||||
searchReports(Object.fromEntries(urlQueryParams));
|
||||
} else {
|
||||
setResolved("false");
|
||||
setLocation(location + "?resolved=false");
|
||||
}
|
||||
}, [
|
||||
urlQueryParams,
|
||||
hasParams,
|
||||
searchReports,
|
||||
location,
|
||||
setLocation,
|
||||
setResolved,
|
||||
]);
|
||||
|
||||
// Rather than triggering the search directly,
|
||||
// the "submit" button changes the location
|
||||
// based on form field params, and lets the
|
||||
// useEffect hook above actually do the search.
|
||||
function submitQuery(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Parse query parameters.
|
||||
const entries = Object.entries(form).map(([k, v]) => {
|
||||
// Take only defined form fields.
|
||||
if (v.value === undefined || v.value.length === 0 || v.value === "any") {
|
||||
return null;
|
||||
}
|
||||
return [[k, v.value]];
|
||||
}).flatMap(kv => {
|
||||
// Remove any nulls.
|
||||
return kv || [];
|
||||
});
|
||||
|
||||
const searchParams = new URLSearchParams(entries);
|
||||
setLocation(location + "?" + searchParams.toString());
|
||||
}
|
||||
|
||||
// Location to return to when user clicks "back" on the detail view.
|
||||
const backLocation = location + (hasParams ? `?${urlQueryParams}` : "");
|
||||
|
||||
// Function to map an item to a list entry.
|
||||
function itemToEntry(report: AdminReport): ReactNode {
|
||||
return (
|
||||
<ReportListEntry
|
||||
key={report.id}
|
||||
report={report}
|
||||
linkTo={`/${report.id}`}
|
||||
backLocation={backLocation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
onSubmit={submitQuery}
|
||||
// Prevent password managers
|
||||
// trying to fill in fields.
|
||||
autoComplete="off"
|
||||
>
|
||||
<Select
|
||||
field={form.resolved}
|
||||
label="Report status"
|
||||
options={
|
||||
<>
|
||||
<option value="false">Unresolved only</option>
|
||||
<option value="true">Resolved only</option>
|
||||
<option value="">Any</option>
|
||||
</>
|
||||
}
|
||||
></Select>
|
||||
<MutationButton
|
||||
disabled={false}
|
||||
label={"Search"}
|
||||
result={searchRes}
|
||||
/>
|
||||
</form>
|
||||
<PageableList
|
||||
isLoading={searchRes.isLoading}
|
||||
isFetching={searchRes.isFetching}
|
||||
isSuccess={searchRes.isSuccess}
|
||||
items={searchRes.data?.accounts}
|
||||
itemToEntry={itemToEntry}
|
||||
isError={searchRes.isError}
|
||||
error={searchRes.error}
|
||||
emptyMessage={<b>No reports found that match your query.</b>}
|
||||
prevNextLinks={searchRes.data?.links}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReportEntryProps {
|
||||
report: AdminReport;
|
||||
linkTo: string;
|
||||
backLocation: string;
|
||||
}
|
||||
|
||||
function ReportListEntry({ report, linkTo, backLocation }: ReportEntryProps) {
|
||||
const [ _location, setLocation ] = useLocation();
|
||||
|
||||
const from = report.account;
|
||||
const target = report.target_account;
|
||||
const comment = report.comment;
|
||||
const status = report.action_taken ? "Resolved" : "Unresolved";
|
||||
const created = new Date(report.created_at).toLocaleString();
|
||||
const title = `${status}. @${target.account.acct} was reported by @${from.account.acct} on ${created}. Reason: "${comment}"`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`pseudolink report entry${report.action_taken ? " resolved" : ""}`}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
onClick={() => {
|
||||
// When clicking on a report, direct
|
||||
// to the detail view for that report.
|
||||
setLocation(linkTo, {
|
||||
// Store the back location in history so
|
||||
// the detail view can use it to return to
|
||||
// this page (including query parameters).
|
||||
state: { backLocation: backLocation }
|
||||
});
|
||||
}}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
>
|
||||
<dl className="info-list">
|
||||
<div className="info-list-entry">
|
||||
<dt>Reported account:</dt>
|
||||
<dd className="text-cutoff">
|
||||
<Username
|
||||
account={target}
|
||||
classNames={["text-cutoff report-byline"]}
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Reported by:</dt>
|
||||
<dd className="text-cutoff reported-by">
|
||||
<Username account={from} />
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Status:</dt>
|
||||
<dd className="text-cutoff">
|
||||
{ report.action_taken
|
||||
? <>{status}</>
|
||||
: <b>{status}</b>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Reason:</dt>
|
||||
<dd className="text-cutoff">
|
||||
{ comment.length > 0
|
||||
? <>{comment}</>
|
||||
: <i>none provided</i>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="info-list-entry">
|
||||
<dt>Created:</dt>
|
||||
<dd className="text-cutoff">
|
||||
<time dateTime={report.created_at}>{created}</time>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
import React from "react";
|
||||
import { BaseUrlContext, useBaseUrl, useHasPermission } from "../../lib/navigation/util";
|
||||
import { Redirect, Route, Router, Switch } from "wouter";
|
||||
import { ReportOverview } from "./reports/overview";
|
||||
import ReportsSearch from "./reports/search";
|
||||
import ReportDetail from "./reports/detail";
|
||||
import { ErrorBoundary } from "../../lib/navigation/error";
|
||||
import ImportExport from "./domain-permissions/import-export";
|
||||
|
|
@ -85,8 +85,9 @@ function ModerationReportsRouter() {
|
|||
<Router base={thisBase}>
|
||||
<ErrorBoundary>
|
||||
<Switch>
|
||||
<Route path="/search" component={ReportsSearch}/>
|
||||
<Route path={"/:reportId"} component={ReportDetail} />
|
||||
<Route component={ReportOverview}/>
|
||||
<Route><Redirect to="/search"/></Route>
|
||||
</Switch>
|
||||
</ErrorBoundary>
|
||||
</Router>
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import {
|
|||
} from "../../components/form/inputs";
|
||||
|
||||
import FormWithData from "../../lib/form/form-with-data";
|
||||
import FakeProfile from "../../components/fake-profile";
|
||||
import FakeProfile from "../../components/profile";
|
||||
import MutationButton from "../../components/form/mutation-button";
|
||||
|
||||
import { useAccountThemesQuery } from "../../lib/query/user";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue