diff options
author | 2024-06-18 18:18:00 +0200 | |
---|---|---|
committer | 2024-06-18 18:18:00 +0200 | |
commit | d2b3d37724a999d4cc78c46157593267e29d184e (patch) | |
tree | ac72be127d8adb80bbd756ad970ae14df7b5618f /web/source | |
parent | [feature] Implement types[] param for notifications (#3009) (diff) | |
download | gotosocial-d2b3d37724a999d4cc78c46157593267e29d184e.tar.xz |
[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
Diffstat (limited to 'web/source')
22 files changed, 996 insertions, 453 deletions
diff --git a/web/source/package.json b/web/source/package.json index e90176308..bce3546d2 100644 --- a/web/source/package.json +++ b/web/source/package.json @@ -33,6 +33,7 @@ "react-redux": "^8.1.3", "redux": "^4.2.0", "redux-persist": "^6.0.0", + "sanitize-html": "^2.13.0", "skulk": "^0.0.8-fix", "wouter": "^3.1.0" }, @@ -49,6 +50,7 @@ "@types/parse-link-header": "^2.0.3", "@types/psl": "^1.1.1", "@types/react-dom": "^18.2.8", + "@types/sanitize-html": "^2.11.0", "@typescript-eslint/eslint-plugin": "^6.7.4", "@typescript-eslint/parser": "^6.7.4", "autoprefixer": "^10.4.19", diff --git a/web/source/settings/components/fake-toot.tsx b/web/source/settings/components/fake-toot.tsx deleted file mode 100644 index ad0c387a4..000000000 --- a/web/source/settings/components/fake-toot.tsx +++ /dev/null @@ -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> - ); -} diff --git a/web/source/settings/components/fake-profile.tsx b/web/source/settings/components/profile.tsx index 4a5157378..4a5157378 100644 --- a/web/source/settings/components/fake-profile.tsx +++ b/web/source/settings/components/profile.tsx diff --git a/web/source/settings/components/status.tsx b/web/source/settings/components/status.tsx new file mode 100644 index 000000000..56b061d39 --- /dev/null +++ b/web/source/settings/components/status.tsx @@ -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> + ); +} diff --git a/web/source/settings/components/username.tsx b/web/source/settings/components/username.tsx index f7be1cd4a..56ba67c4f 100644 --- a/web/source/settings/components/username.tsx +++ b/web/source/settings/components/username.tsx @@ -60,7 +60,7 @@ export default function Username({ account, linkTo, backLocation, classNames }: ); if (linkTo) { - className += " spanlink"; + className += " pseudolink"; return ( <span className={className} diff --git a/web/source/settings/lib/query/admin/reports/index.ts b/web/source/settings/lib/query/admin/reports/index.ts index 600e78ac3..8937d5358 100644 --- a/web/source/settings/lib/query/admin/reports/index.ts +++ b/web/source/settings/lib/query/admin/reports/index.ts @@ -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, }; diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index ef994e655..f96a55fda 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -136,7 +136,7 @@ export const gtsApi = createApi({ tagTypes: [ "Auth", "Emoji", - "Reports", + "Report", "Account", "InstanceRules", "HTTPHeaderAllows", diff --git a/web/source/settings/lib/types/report.ts b/web/source/settings/lib/types/report.ts index bb3d53c27..4ef694be6 100644 --- a/web/source/settings/lib/types/report.ts +++ b/web/source/settings/lib/types/report.ts @@ -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; +} diff --git a/web/source/settings/lib/types/status.ts b/web/source/settings/lib/types/status.ts new file mode 100644 index 000000000..e46f4a6b7 --- /dev/null +++ b/web/source/settings/lib/types/status.ts @@ -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; + } +} diff --git a/web/source/settings/views/moderation/accounts/detail/util.tsx b/web/source/settings/lib/util/index.ts index b82d44a6e..d016f3398 100644 --- a/web/source/settings/views/moderation/accounts/detail/util.tsx +++ b/web/source/settings/lib/util/index.ts @@ -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"; diff --git a/web/source/settings/style.css b/web/source/settings/style.css index d2420bdfc..cdae6b972 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -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; - } - - .byline { - display: grid; - grid-template-columns: 1fr auto; - gap: 0.5rem; + .username-lozenge { + display: flex; + flex-wrap: nowrap; + height: 100%; + align-items: center; + padding-top: 0; + padding-bottom: 0; - .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,72 +1109,42 @@ button.with-padding { padding: 0; } } +} - .report.detail { - display: flex; - flex-direction: column; - margin-top: 1rem; - gap: 1rem; - - .info-block { - padding: 0.5rem; - background: $gray2; - } - - .info { - display: block; - } - - .reported-toots { - margin-top: 0.5rem; +.report-detail { + .info-list { + + &.overview { + margin-top: 1rem; } - .toot .toot-info { - padding: 0.5rem; - background: $toot-info-bg; - - a { - color: $fg-reduced; - } + .username-lozenge { + display: flex; + flex-wrap: nowrap; + height: 100%; + align-items: center; + padding-top: 0; + padding-bottom: 0; + max-width: fit-content; - &: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; - } - &.suspended { - background: $bg-accent; - color: $fg; - text-decoration: line-through; - } + .report-statuses { + width: min(100%, 50rem); - &.local { - background: $green1; + .thread { + display: flex; + flex-direction: column; + gap: 2rem; + padding: 0; + } } } -.spanlink { - cursor: pointer; - text-decoration: none; -} - .accounts-view { .pageable-list { .username-lozenge { @@ -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; diff --git a/web/source/settings/views/admin/emoji/local/detail.tsx b/web/source/settings/views/admin/emoji/local/detail.tsx index 2913b6c17..4126bbedc 100644 --- a/web/source/settings/views/admin/emoji/local/detail.tsx +++ b/web/source/settings/views/admin/emoji/local/detail.tsx @@ -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} />} diff --git a/web/source/settings/views/admin/emoji/local/new-emoji.tsx b/web/source/settings/views/admin/emoji/local/new-emoji.tsx index 20f45f372..f2f5a56b1 100644 --- a/web/source/settings/views/admin/emoji/local/new-emoji.tsx +++ b/web/source/settings/views/admin/emoji/local/new-emoji.tsx @@ -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 diff --git a/web/source/settings/views/admin/http-header-permissions/overview.tsx b/web/source/settings/views/admin/http-header-permissions/overview.tsx index 7735e624e..54b58b642 100644 --- a/web/source/settings/views/admin/http-header-permissions/overview.tsx +++ b/web/source/settings/views/admin/http-header-permissions/overview.tsx @@ -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. diff --git a/web/source/settings/views/moderation/accounts/detail/index.tsx b/web/source/settings/views/moderation/accounts/detail/index.tsx index 830a894cb..958a3121b 100644 --- a/web/source/settings/views/moderation/accounts/detail/index.tsx +++ b/web/source/settings/views/moderation/accounts/detail/index.tsx @@ -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(); diff --git a/web/source/settings/views/moderation/accounts/search/index.tsx b/web/source/settings/views/moderation/accounts/search/index.tsx index 16e89ce43..f37e22a66 100644 --- a/web/source/settings/views/moderation/accounts/search/index.tsx +++ b/web/source/settings/views/moderation/accounts/search/index.tsx @@ -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 { diff --git a/web/source/settings/views/moderation/reports/detail.tsx b/web/source/settings/views/moderation/reports/detail.tsx index ad8d69a47..7d6e542fb 100644 --- a/web/source/settings/views/moderation/reports/detail.tsx +++ b/web/source/settings/views/moderation/reports/detail.tsx @@ -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-list-entry"> + <dt>Status</dt> + <dd> + { report.action_taken + ? <>{status}</> + : <b>{status}</b> + } + </dd> + </div> - <div className="info-block"> - <h3>Report info:</h3> - <div className="details"> - <b>Created: </b> - <span>{new Date(report.created_at).toLocaleString()}</span> + <div className="info-list-entry"> + <dt>Reason</dt> + <dd> + { comment.length > 0 + ? <>{comment}</> + : <i>none provided</i> + } + </dd> + </div> - <b>Forwarded: </b> <span>{report.forwarded ? "Yes" : "No"}</span> - <b>Category: </b> <span>{report.category}</span> + <div className="info-list-entry"> + <dt>Created</dt> + <dd> + <time dateTime={report.created_at}>{created}</time> + </dd> + </div> - <b>Reason: </b> - {report.comment.length > 0 - ? <p>{report.comment}</p> - : <i className="no-comment">none provided</i> - } + <div className="info-list-entry"> + <dt>Category</dt> + <dd>{ report.category }</dd> + </div> - </div> + <div className="info-list-entry"> + <dt>Forwarded</dt> + <dd>{ yesOrNo(report.forwarded) }</dd> </div> + </dl> + ); +} - {!report.action_taken && <ReportActionForm report={report} />} - - { - 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> +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> ); } diff --git a/web/source/settings/views/moderation/reports/overview.tsx b/web/source/settings/views/moderation/reports/overview.tsx deleted file mode 100644 index 18eb5492a..000000000 --- a/web/source/settings/views/moderation/reports/overview.tsx +++ /dev/null @@ -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> - ); -} diff --git a/web/source/settings/views/moderation/reports/search.tsx b/web/source/settings/views/moderation/reports/search.tsx new file mode 100644 index 000000000..da0c80d69 --- /dev/null +++ b/web/source/settings/views/moderation/reports/search.tsx @@ -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> + ); +} diff --git a/web/source/settings/views/moderation/router.tsx b/web/source/settings/views/moderation/router.tsx index d23ab336a..93f7e481a 100644 --- a/web/source/settings/views/moderation/router.tsx +++ b/web/source/settings/views/moderation/router.tsx @@ -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> diff --git a/web/source/settings/views/user/profile.tsx b/web/source/settings/views/user/profile.tsx index c1735259e..a65405faa 100644 --- a/web/source/settings/views/user/profile.tsx +++ b/web/source/settings/views/user/profile.tsx @@ -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"; diff --git a/web/source/yarn.lock b/web/source/yarn.lock index 0f1376dd2..dd09d746f 100644 --- a/web/source/yarn.lock +++ b/web/source/yarn.lock @@ -1499,6 +1499,13 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/sanitize-html@^2.11.0": + version "2.11.0" + resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.11.0.tgz#582d8c72215c0228e3af2be136e40e0b531addf2" + integrity sha512-7oxPGNQHXLHE48r/r/qjn7q0hlrs3kL7oZnGj0Wf/h9tj/6ibFyRkNbsDxaBBZ4XUZ0Dx5LGCyDJ04ytSofacQ== + dependencies: + htmlparser2 "^8.0.0" + "@types/scheduler@*": version "0.16.4" resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.4.tgz#fedc3e5b15c26dc18faae96bf1317487cb3658cf" @@ -3125,11 +3132,41 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + domain-browser@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e" + integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + drange@^1.0.2: version "1.1.1" resolved "https://registry.yarnpkg.com/drange/-/drange-1.1.1.tgz#b2aecec2aab82fcef11dbbd7b9e32b83f8f6c0b8" @@ -3198,6 +3235,11 @@ enhanced-resolve@^5.0.0: graceful-fs "^4.2.4" tapable "^2.2.0" +entities@^4.2.0, entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + error-ex@^1.2.0: version "1.3.2" resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" @@ -4041,6 +4083,16 @@ htmlescape@^1.1.0: resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351" integrity sha512-eVcrzgbR4tim7c7soKQKtxa/kQM4TzjnlU83rcZ9bHU6t31ehfV7SktN6McWgwPWg+JYMA/O3qpGxBvFq1z2Jg== +htmlparser2@^8.0.0: + version "8.0.2" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" + integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + entities "^4.4.0" + http-errors@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" @@ -4944,6 +4996,11 @@ nanoid@^3.3.6: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c" integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA== +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + nanoid@^4.0.0: version "4.0.2" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.2.tgz#140b3c5003959adbebf521c170f282c5e7f9fb9e" @@ -5199,6 +5256,11 @@ parse-ms@^2.1.0: resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d" integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA== +parse-srcset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" + integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q== + parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -5353,6 +5415,15 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^ resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== +postcss@^8.3.11: + version "8.4.38" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" + integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.0" + source-map-js "^1.2.0" + postcss@^8.4.12, postcss@^8.4.18: version "8.4.31" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" @@ -5863,6 +5934,18 @@ safe-regex-test@^1.0.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +sanitize-html@^2.13.0: + version "2.13.0" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.13.0.tgz#71aedcdb777897985a4ea1877bf4f895a1170dae" + integrity sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA== + dependencies: + deepmerge "^4.2.2" + escape-string-regexp "^4.0.0" + htmlparser2 "^8.0.0" + is-plain-object "^5.0.0" + parse-srcset "^1.0.2" + postcss "^8.3.11" + scheduler@^0.23.0: version "0.23.0" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" @@ -6058,6 +6141,11 @@ source-map-js@^1.0.2: resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== +source-map-js@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" + integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== + source-map-loader@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-4.0.1.tgz#72f00d05f5d1f90f80974eda781cbd7107c125f2" |