diff options
Diffstat (limited to 'web/source/settings')
20 files changed, 906 insertions, 453 deletions
| 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"; | 
