diff options
Diffstat (limited to 'web/source/settings/views')
| -rw-r--r-- | web/source/settings/views/user/interactions/detail.tsx | 117 | ||||
| -rw-r--r-- | web/source/settings/views/user/interactions/index.tsx | 36 | ||||
| -rw-r--r-- | web/source/settings/views/user/interactions/search.tsx | 251 | ||||
| -rw-r--r-- | web/source/settings/views/user/interactions/util.tsx | 98 | ||||
| -rw-r--r-- | web/source/settings/views/user/menu.tsx | 5 | ||||
| -rw-r--r-- | web/source/settings/views/user/router.tsx | 28 | 
6 files changed, 535 insertions, 0 deletions
| diff --git a/web/source/settings/views/user/interactions/detail.tsx b/web/source/settings/views/user/interactions/detail.tsx new file mode 100644 index 000000000..f32cc2058 --- /dev/null +++ b/web/source/settings/views/user/interactions/detail.tsx @@ -0,0 +1,117 @@ +/* +	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, { useMemo } from "react"; +import { useLocation, useParams } from "wouter"; +import FormWithData from "../../../lib/form/form-with-data"; +import BackButton from "../../../components/back-button"; +import { useBaseUrl } from "../../../lib/navigation/util"; +import { useApproveInteractionRequestMutation, useGetInteractionRequestQuery, useRejectInteractionRequestMutation } from "../../../lib/query/user/interactions"; +import { InteractionRequest } from "../../../lib/types/interaction"; +import { useIcon, useNoun, useVerbed } from "./util"; +import MutationButton from "../../../components/form/mutation-button"; +import { Status } from "../../../components/status"; + +export default function InteractionRequestDetail({ }) { +	const params: { reqId: string } = useParams(); +	const baseUrl = useBaseUrl(); +	const backLocation: String = history.state?.backLocation ?? `~${baseUrl}`; + +	return ( +		<div className="interaction-request-detail"> +			<h1><BackButton to={backLocation}/> Interaction Request Details</h1> +			<FormWithData +				dataQuery={useGetInteractionRequestQuery} +				queryArg={params.reqId} +				DataForm={InteractionRequestDetailForm} +				{...{ backLocation: backLocation }} +			/> +		</div> +	); +} + +function InteractionRequestDetailForm({ data: req, backLocation }: { data: InteractionRequest, backLocation: string }) {	 +	const [ _location, setLocation ] = useLocation(); +	 +	const [ approve, approveResult ] = useApproveInteractionRequestMutation(); +	const [ reject, rejectResult ] = useRejectInteractionRequestMutation(); +	 +	const verbed = useVerbed(req.type); +	const noun = useNoun(req.type); +	const icon = useIcon(req.type); + +	const strap = useMemo(() => { +		return "@" + req.account.acct + " " + verbed + " your post."; +	}, [req.account, verbed]); + +	return ( +		<> +			<span className="overview"> +				<i +					className={`fa fa-fw ${icon}`} +					aria-hidden="true" +				/> <strong>{strap}</strong> +			</span> +			 +			<h2>You wrote:</h2> +			<div className="thread"> +				<Status status={req.status} /> +			</div> + +			{ req.reply && <> +				<h2>They replied:</h2> +				<div className="thread"> +					<Status status={req.reply} /> +				</div> +			</> } +			 +			<div className="action-buttons"> +				<MutationButton +					label={`Accept ${noun}`} +					title={`Accept ${noun}`} +					type="button" +					className="button" +					onClick={(e) => { +						e.preventDefault(); +						approve(req.id); +						setLocation(backLocation); +					}} +					disabled={false} +					showError={false} +					result={approveResult} +				/> + +				<MutationButton +					label={`Reject ${noun}`} +					title={`Reject ${noun}`} +					type="button" +					className="button danger" +					onClick={(e) => { +						e.preventDefault(); +						reject(req.id); +						setLocation(backLocation); +					}} +					disabled={false} +					showError={false} +					result={rejectResult} +				/> +			</div> +		</> +	); +} diff --git a/web/source/settings/views/user/interactions/index.tsx b/web/source/settings/views/user/interactions/index.tsx new file mode 100644 index 000000000..ec3cd016b --- /dev/null +++ b/web/source/settings/views/user/interactions/index.tsx @@ -0,0 +1,36 @@ +/* +	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 InteractionRequestsSearchForm from "./search"; + +export default function InteractionRequests() { +	return ( +		<div className="interaction-requests-view"> +			<div className="form-section-docs"> +				<h1>Interaction Requests</h1> +				<p> +					On this page you can search through interaction requests +					targeting your statuses, and approve or reject them. +				</p> +			</div> +			<InteractionRequestsSearchForm /> +		</div> +	); +} diff --git a/web/source/settings/views/user/interactions/search.tsx b/web/source/settings/views/user/interactions/search.tsx new file mode 100644 index 000000000..b97899c51 --- /dev/null +++ b/web/source/settings/views/user/interactions/search.tsx @@ -0,0 +1,251 @@ +/* +	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 { useBoolInput, useTextInput } from "../../../lib/form"; +import { PageableList } from "../../../components/pageable-list"; +import MutationButton from "../../../components/form/mutation-button"; +import { useLocation, useSearch } from "wouter"; +import { useApproveInteractionRequestMutation, useLazySearchInteractionRequestsQuery, useRejectInteractionRequestMutation } from "../../../lib/query/user/interactions"; +import { InteractionRequest } from "../../../lib/types/interaction"; +import { Checkbox } from "../../../components/form/inputs"; +import { useContent, useIcon, useNoun, useVerbed } from "./util"; + +function defaultTrue(urlQueryVal: string | null): boolean { +	if (urlQueryVal === null) { +		return true; +	} +	 +	return urlQueryVal.toLowerCase() !== "false"; +} + +export default function InteractionRequestsSearchForm() { +	const [ location, setLocation ] = useLocation(); +	const search = useSearch(); +	const urlQueryParams = useMemo(() => new URLSearchParams(search), [search]); +	const [ searchReqs, searchRes ] = useLazySearchInteractionRequestsQuery(); + +	// Populate search form using values from +	// urlQueryParams, to allow paging. +	const form = { +		statusID: useTextInput("status_id", { +			defaultValue: urlQueryParams.get("status_id") ?? "" +		}), +		likes: useBoolInput("favourites", { +			defaultValue: defaultTrue(urlQueryParams.get("favourites")) +		}), +		replies: useBoolInput("replies", { +			defaultValue: defaultTrue(urlQueryParams.get("replies")) +		}), +		boosts: useBoolInput("reblogs", { +			defaultValue: defaultTrue(urlQueryParams.get("reblogs")) +		}), +	}; + +	// On mount, trigger search. +	useEffect(() => { +		searchReqs(Object.fromEntries(urlQueryParams), true); +	}, [urlQueryParams, searchReqs]); + +	// 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) { +				return null; +			} else if (typeof v.value === "string" && v.value.length === 0) { +				return null; +			} + +			return [[k, v.value.toString()]]; +		}).flatMap(kv => { +			// Remove any nulls. +			return kv !== null ? kv : []; +		}); + +		const searchParams = new URLSearchParams(entries); +		setLocation(location + "?" + searchParams.toString()); +	} + +	// Location to return to when user clicks +	// "back" on the interaction req detail view. +	const backLocation = location + (urlQueryParams.size > 0 ? `?${urlQueryParams}` : ""); +	 +	// Function to map an item to a list entry. +	function itemToEntry(req: InteractionRequest): ReactNode { +		return ( +			<ReqsListEntry +				key={req.id} +				req={req} +				linkTo={`/${req.id}`} +				backLocation={backLocation} +			/> +		); +	} + +	return ( +		<> +			<form +				onSubmit={submitQuery} +				// Prevent password managers +				// trying to fill in fields. +				autoComplete="off" +			> +				<Checkbox +					label="Include likes" +					field={form.likes} +				/> +				<Checkbox +					label="Include replies" +					field={form.replies} +				/> +				<Checkbox +					label="Include boosts" +					field={form.boosts} +				/> +				<MutationButton +					disabled={false} +					label={"Search"} +					result={searchRes} +				/> +			</form> +			<PageableList +				isLoading={searchRes.isLoading} +				isFetching={searchRes.isFetching} +				isSuccess={searchRes.isSuccess} +				items={searchRes.data?.requests} +				itemToEntry={itemToEntry} +				isError={searchRes.isError} +				error={searchRes.error} +				emptyMessage={<b>No interaction requests found that match your query.</b>} +				prevNextLinks={searchRes.data?.links} +			/> +		</> +	); +} + +interface ReqsListEntryProps { +	req: InteractionRequest; +	linkTo: string; +	backLocation: string; +} + +function ReqsListEntry({ req, linkTo, backLocation }: ReqsListEntryProps) { +	const [ _location, setLocation ] = useLocation(); +	 +	const [ approve, approveResult ] = useApproveInteractionRequestMutation(); +	const [ reject, rejectResult ] = useRejectInteractionRequestMutation(); +	 +	const verbed = useVerbed(req.type); +	const noun = useNoun(req.type); +	const icon = useIcon(req.type); +	 +	const strap = useMemo(() => { +		return "@" + req.account.acct + " " + verbed + " your post."; +	}, [req.account, verbed]); +	 +	const label = useMemo(() => { +		return noun + " from @" + req.account.acct; +	}, [req.account, noun]); + +	const ourContent = useContent(req.status); +	const theirContent = useContent(req.reply); + +	return ( +		<span +			className={`pseudolink entry interaction-request`} +			aria-label={label} +			title={label} +			onClick={() => { +				// When clicking on a request, direct +				// to the detail view for that request. +				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} +		> +			<span className="text-cutoff"> +				<i +					className={`fa fa-fw ${icon}`} +					aria-hidden="true" +				/> <strong>{strap}</strong> +			</span> +			<dl className="info-list"> +				<div className="info-list-entry"> +					<dt>You wrote:</dt> +					<dd className="text-cutoff"> +						{ourContent} +					</dd> +				</div> +				{ req.type === "reply" && +					<div className="info-list-entry"> +						<dt>They wrote:</dt> +						<dd className="text-cutoff"> +							{theirContent} +						</dd> +					</div> +				} +			</dl> +			<div className="action-buttons"> +				<MutationButton +					label="Accept" +					title={`Accept ${noun}`} +					type="button" +					className="button" +					onClick={(e) => { +						e.preventDefault(); +						e.stopPropagation(); +						approve(req.id); +					}} +					disabled={false} +					showError={false} +					result={approveResult} +				/> + +				<MutationButton +					label="Reject" +					title={`Reject ${noun}`} +					type="button" +					className="button danger" +					onClick={(e) => { +						e.preventDefault(); +						e.stopPropagation(); +						reject(req.id); +					}} +					disabled={false} +					showError={false} +					result={rejectResult} +				/> +			</div> +		</span> +	); +} + diff --git a/web/source/settings/views/user/interactions/util.tsx b/web/source/settings/views/user/interactions/util.tsx new file mode 100644 index 000000000..e5ce0a73c --- /dev/null +++ b/web/source/settings/views/user/interactions/util.tsx @@ -0,0 +1,98 @@ +/* +	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 { useMemo } from "react"; + +import sanitize from "sanitize-html"; +import { compile, HtmlToTextOptions } from "html-to-text"; +import { Status } from "../../../lib/types/status"; + +// Options for converting HTML statuses +// to plaintext representations. +const convertOptions: HtmlToTextOptions = { +	selectors: [ +		// Don't fancy format links, just use their text value. +		{ selector: 'a', options: { ignoreHref: true } }, +	] +}; +const convertHTML = compile(convertOptions); + +/** + * Convert input status to plaintext representation. + * @param status  + * @returns  + */ +export function useContent(status: Status | undefined): string { +	return useMemo(() => { +		if (!status) { +			return ""; +		} +		 +		if (status.content.length === 0) { +			return "[no content set]"; +		} else { +			// HTML has already been through +			// the instance sanitizer by now, +			// but do it again just in case. +			const content = sanitize(status.content); +			 +			// Return plaintext of sanitized HTML. +			return convertHTML(content); +		} +	}, [status]); +} + +export function useVerbed(type: "favourite" | "reply" | "reblog"): string { +	return useMemo(() => { +		switch (type) { +			case "favourite": +				return "liked"; +			case "reply": +				return "replied to"; +			case "reblog": +				return "boosted"; +		} +	}, [type]); +} + +export function useNoun(type: "favourite" | "reply" | "reblog"): string { +	return useMemo(() => { +		switch (type) { +			case "favourite": +				return "Like"; +			case "reply": +				return "Reply"; +			case "reblog": +				return "Boost"; +		} +	}, [type]); +} + +export function useIcon(type: "favourite" | "reply" | "reblog"): string { +	return useMemo(() => { +		switch (type) { +			case "favourite": +				return "fa-star"; +			case "reply": +				return "fa-reply"; +			case "reblog": +				return "fa-retweet"; +		} +	}, [type]); +} diff --git a/web/source/settings/views/user/menu.tsx b/web/source/settings/views/user/menu.tsx index a0526d652..85734ae52 100644 --- a/web/source/settings/views/user/menu.tsx +++ b/web/source/settings/views/user/menu.tsx @@ -44,6 +44,11 @@ export default function UserMenu() {  				icon="fa-paper-plane"  			/>  			<MenuItem +				name="Interaction Requests" +				itemUrl="interaction_requests" +				icon="fa-commenting-o" +			/> +			<MenuItem  				name="Email & Password"  				itemUrl="emailpassword"  				icon="fa-user-secret" diff --git a/web/source/settings/views/user/router.tsx b/web/source/settings/views/user/router.tsx index 7b995b3b7..86bcf4243 100644 --- a/web/source/settings/views/user/router.tsx +++ b/web/source/settings/views/user/router.tsx @@ -26,6 +26,8 @@ import UserMigration from "./migration";  import PostSettings from "./posts";  import EmailPassword from "./emailpassword";  import ExportImport from "./export-import"; +import InteractionRequests from "./interactions"; +import InteractionRequestDetail from "./interactions/detail";  /**   * - /settings/user/profile @@ -33,6 +35,7 @@ import ExportImport from "./export-import";   * - /settings/user/emailpassword   * - /settings/user/migration   * - /settings/user/export-import + * - /settings/users/interaction_requests   */  export default function UserRouter() {  	const baseUrl = useBaseUrl(); @@ -52,6 +55,31 @@ export default function UserRouter() {  						<Route><Redirect to="/profile" /></Route>  					</Switch>  				</ErrorBoundary> +				<InteractionRequestsRouter /> +			</Router> +		</BaseUrlContext.Provider> +	); +} + +/** + * - /settings/users/interaction_requests/search + * - /settings/users/interaction_requests/{reqId} + */ +function InteractionRequestsRouter() { +	const parentUrl = useBaseUrl(); +	const thisBase = "/interaction_requests"; +	const absBase = parentUrl + thisBase; + +	return ( +		<BaseUrlContext.Provider value={absBase}> +			<Router base={thisBase}> +				<ErrorBoundary> +					<Switch> +						<Route path="/search" component={InteractionRequests} /> +						<Route path="/:reqId" component={InteractionRequestDetail} /> +						<Route><Redirect to="/search"/></Route> +					</Switch> +				</ErrorBoundary>  			</Router>  		</BaseUrlContext.Provider>  	); | 
