diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/source/settings/lib/query/gts-api.ts | 28 | ||||
| -rw-r--r-- | web/source/settings/lib/query/user/export-import.ts | 138 | ||||
| -rw-r--r-- | web/source/settings/lib/types/account.ts | 10 | ||||
| -rw-r--r-- | web/source/settings/style.css | 33 | ||||
| -rw-r--r-- | web/source/settings/views/user/export-import/export.tsx | 173 | ||||
| -rw-r--r-- | web/source/settings/views/user/export-import/index.tsx | 57 | ||||
| -rw-r--r-- | web/source/settings/views/user/menu.tsx | 5 | ||||
| -rw-r--r-- | web/source/settings/views/user/router.tsx | 3 | 
8 files changed, 446 insertions, 1 deletions
diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index d6741df3a..1c715e284 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -48,6 +48,11 @@ export interface GTSFetchArgs extends FetchArgs {  	 * as FormData before submission.   	 */  	asForm?: boolean; +	/** +	 * If set, then Accept header will +	 * be set to the provided contentType. +	 */ +	acceptContentType?: string;  }  /** @@ -77,6 +82,10 @@ const gtsBaseQuery: BaseQueryFn<  	// Derive baseUrl dynamically.  	let baseUrl: string | undefined; +	// Assume Accept value of +	// "application/json" by default. +	let accept = "application/json"; +  	// Check if simple string baseUrl provided  	// as args, or if more complex args provided.  	if (typeof args === "string") { @@ -101,11 +110,16 @@ const gtsBaseQuery: BaseQueryFn<  			});  		} +		if (args.acceptContentType !== undefined) { +			accept = args.acceptContentType; +		} +  		// Delete any of our extended arguments  		// to avoid confusing fetchBaseQuery.  		delete args.baseUrl;  		delete args.discardEmpty;  		delete args.asForm; +		delete args.acceptContentType;  	}  	if (!baseUrl) { @@ -124,9 +138,21 @@ const gtsBaseQuery: BaseQueryFn<  			if (token != undefined) {  				headers.set('Authorization', token);  			} -			headers.set("Accept", "application/json"); +			 +			headers.set("Accept", accept);  			return headers;  		}, +		responseHandler: (response) => { +			// Return just text if caller has +			// set a custom accept content-type. +			if (accept !== "application/json") { +				return response.text(); +			} + +			// Else return good old +			// fashioned JSON baby! +			return response.json(); +		},  	})(args, api, extraOptions);  }; diff --git a/web/source/settings/lib/query/user/export-import.ts b/web/source/settings/lib/query/user/export-import.ts new file mode 100644 index 000000000..56c48e364 --- /dev/null +++ b/web/source/settings/lib/query/user/export-import.ts @@ -0,0 +1,138 @@ +/* +	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 fileDownload from "js-file-download"; + +import { gtsApi } from "../gts-api"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/query"; +import { AccountExportStats } from "../../types/account"; + +const extended = gtsApi.injectEndpoints({ +	endpoints: (build) => ({ +		exportStats: build.query<AccountExportStats, void>({ +			query: () => ({ +				url: `/api/v1/exports/stats` +			}) +		}), +		 +		exportFollowing: build.mutation<string | null, void>({ +			async queryFn(_arg, _api, _extraOpts, fetchWithBQ) { +				const csvRes = await fetchWithBQ({ +					url: `/api/v1/exports/following.csv`, +					acceptContentType: "text/csv", +				}); +				if (csvRes.error) { +					return { error: csvRes.error as FetchBaseQueryError }; +				} + +				if (csvRes.meta?.response?.status !== 200) { +					return { error: csvRes.data }; +				} + +				fileDownload(csvRes.data, "following.csv", "text/csv"); +				return { data: null }; +			} +		}), + +		exportFollowers: build.mutation<string | null, void>({ +			async queryFn(_arg, _api, _extraOpts, fetchWithBQ) { +				const csvRes = await fetchWithBQ({ +					url: `/api/v1/exports/followers.csv`, +					acceptContentType: "text/csv", +				}); +				if (csvRes.error) { +					return { error: csvRes.error as FetchBaseQueryError }; +				} + +				if (csvRes.meta?.response?.status !== 200) { +					return { error: csvRes.data }; +				} + +				fileDownload(csvRes.data, "followers.csv", "text/csv"); +				return { data: null }; +			} +		}), + +		exportLists: build.mutation<string | null, void>({ +			async queryFn(_arg, _api, _extraOpts, fetchWithBQ) { +				const csvRes = await fetchWithBQ({ +					url: `/api/v1/exports/lists.csv`, +					acceptContentType: "text/csv", +				}); +				if (csvRes.error) { +					return { error: csvRes.error as FetchBaseQueryError }; +				} + +				if (csvRes.meta?.response?.status !== 200) { +					return { error: csvRes.data }; +				} + +				fileDownload(csvRes.data, "lists.csv", "text/csv"); +				return { data: null }; +			} +		}), + +		exportBlocks: build.mutation<string | null, void>({ +			async queryFn(_arg, _api, _extraOpts, fetchWithBQ) { +				const csvRes = await fetchWithBQ({ +					url: `/api/v1/exports/blocks.csv`, +					acceptContentType: "text/csv", +				}); +				if (csvRes.error) { +					return { error: csvRes.error as FetchBaseQueryError }; +				} + +				if (csvRes.meta?.response?.status !== 200) { +					return { error: csvRes.data }; +				} + +				fileDownload(csvRes.data, "blocks.csv", "text/csv"); +				return { data: null }; +			} +		}), + +		exportMutes: build.mutation<string | null, void>({ +			async queryFn(_arg, _api, _extraOpts, fetchWithBQ) { +				const csvRes = await fetchWithBQ({ +					url: `/api/v1/exports/mutes.csv`, +					acceptContentType: "text/csv", +				}); +				if (csvRes.error) { +					return { error: csvRes.error as FetchBaseQueryError }; +				} + +				if (csvRes.meta?.response?.status !== 200) { +					return { error: csvRes.data }; +				} + +				fileDownload(csvRes.data, "mutes.csv", "text/csv"); +				return { data: null }; +			} +		}), +	}) +}); + +export const { +	useExportStatsQuery, +	useExportFollowingMutation, +	useExportFollowersMutation, +	useExportListsMutation, +	useExportBlocksMutation, +	useExportMutesMutation, +} = extended; diff --git a/web/source/settings/lib/types/account.ts b/web/source/settings/lib/types/account.ts index 590b2c98e..6b8d2bc4d 100644 --- a/web/source/settings/lib/types/account.ts +++ b/web/source/settings/lib/types/account.ts @@ -110,3 +110,13 @@ export interface ActionAccountParams {  	action: "suspend";  	reason: string;  } + +export interface AccountExportStats { +	media_storage: string; +	followers_count: number; +	following_count: number; +	statuses_count: number; +	lists_count: number; +	blocks_count: number; +	mutes_count: number; +} diff --git a/web/source/settings/style.css b/web/source/settings/style.css index 9650f7466..3d1545634 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -1464,6 +1464,39 @@ button.tab-button {  	}  } +.export-data { +	.export-buttons-wrapper { +		display: grid; +		max-width: fit-content; +		gap: 0.5rem; + +		.stats-and-button { +			display: grid; +			grid-template-columns: 13rem 1fr; +			align-items: center; +			gap: 0.25rem; +	 +			.mutation-button { +				width: 100%; +				overflow-x: hidden; +	 +				button { +					font-size: 1rem; +					width: 100%; +				} +			} +		} +	 +		@media screen and (max-width: 35rem) { +			gap: 1rem; +			 +			.stats-and-button { +				grid-template-columns: auto; +			} +		} +	} +} +  @media screen and (orientation: portrait) {  	.reports .report .byline {  		grid-template-columns: 1fr; diff --git a/web/source/settings/views/user/export-import/export.tsx b/web/source/settings/views/user/export-import/export.tsx new file mode 100644 index 000000000..70bda60f2 --- /dev/null +++ b/web/source/settings/views/user/export-import/export.tsx @@ -0,0 +1,173 @@ +/* +	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 { +	useExportFollowingMutation, +	useExportFollowersMutation, +	useExportListsMutation, +	useExportBlocksMutation, +	useExportMutesMutation, +} from "../../../lib/query/user/export-import"; +import MutationButton from "../../../components/form/mutation-button"; +import useFormSubmit from "../../../lib/form/submit"; +import { useValue } from "../../../lib/form"; +import { AccountExportStats } from "../../../lib/types/account"; + +export default function Export({ exportStats }: { exportStats: AccountExportStats }) { +	const [exportFollowing, exportFollowingResult] = useFormSubmit( +		// Use a dummy value. +		{ type: useValue("exportFollowing", "exportFollowing") }, +		// Mutation we're wrapping. +		useExportFollowingMutation(), +		// Form never changes but +		// we want to always trigger. +		{ changedOnly: false }, +	); + +	const [exportFollowers, exportFollowersResult] = useFormSubmit( +		// Use a dummy value. +		{ type: useValue("exportFollowers", "exportFollowers") }, +		// Mutation we're wrapping. +		useExportFollowersMutation(), +		// Form never changes but +		// we want to always trigger. +		{ changedOnly: false }, +	); + +	const [exportLists, exportListsResult] = useFormSubmit( +		// Use a dummy value. +		{ type: useValue("exportLists", "exportLists") }, +		// Mutation we're wrapping. +		useExportListsMutation(), +		// Form never changes but +		// we want to always trigger. +		{ changedOnly: false }, +	); + + +	const [exportBlocks, exportBlocksResult] = useFormSubmit( +		// Use a dummy value. +		{ type: useValue("exportBlocks", "exportBlocks") }, +		// Mutation we're wrapping. +		useExportBlocksMutation(), +		// Form never changes but +		// we want to always trigger. +		{ changedOnly: false }, +	); + +	const [exportMutes, exportMutesResult] = useFormSubmit( +		// Use a dummy value. +		{ type: useValue("exportMutes", "exportMutes") }, +		// Mutation we're wrapping. +		useExportMutesMutation(), +		// Form never changes but +		// we want to always trigger. +		{ changedOnly: false }, +	); +	 +	return ( +		<form className="export-data"> +			<div className="form-section-docs"> +				<h3>Export Data</h3> +				<a +					href="https://docs.gotosocial.org/en/latest/user_guide/export-import#export" +					target="_blank" +					className="docslink" +					rel="noreferrer" +				> +				Learn more about this section (opens in a new tab) +				</a> +			</div> +			 +			<div className="export-buttons-wrapper"> +				<div className="stats-and-button"> +					<span className="text-cutoff"> +						Following {exportStats.following_count} account{ exportStats.following_count !== 1 && "s" } +					</span> +					<MutationButton +						className="text-cutoff" +						label="Download following.csv" +						type="button" +						onClick={() => exportFollowing()} +						result={exportFollowingResult} +						showError={true} +						disabled={exportStats.following_count === 0} +					/> +				</div> +				<div className="stats-and-button"> +					<span className="text-cutoff"> +						Followed by {exportStats.followers_count} account{ exportStats.followers_count !== 1 && "s" } +					</span> +					<MutationButton +						className="text-cutoff" +						label="Download followers.csv" +						type="button" +						onClick={() => exportFollowers()} +						result={exportFollowersResult} +						showError={true} +						disabled={exportStats.followers_count === 0} +					/> +				</div> +				<div className="stats-and-button"> +					<span className="text-cutoff"> +						Created {exportStats.lists_count} list{ exportStats.lists_count !== 1 && "s" } +					</span> +					<MutationButton +						className="text-cutoff" +						label="Download lists.csv" +						type="button" +						onClick={() => exportLists()} +						result={exportListsResult} +						showError={true} +						disabled={exportStats.lists_count === 0} +					/> +				</div> +				<div className="stats-and-button"> +					<span className="text-cutoff"> +						Blocking {exportStats.blocks_count} account{ exportStats.blocks_count !== 1 && "s" } +					</span> +					<MutationButton +						className="text-cutoff" +						label="Download blocks.csv" +						type="button" +						onClick={() => exportBlocks()} +						result={exportBlocksResult} +						showError={true} +						disabled={exportStats.blocks_count === 0} +					/> +				</div> +				<div className="stats-and-button"> +					<span className="text-cutoff"> +						Muting {exportStats.mutes_count} account{ exportStats.mutes_count !== 1 && "s" } +					</span> +					<MutationButton +						className="text-cutoff" +						label="Download mutes.csv" +						type="button" +						onClick={() => exportMutes()} +						result={exportMutesResult} +						showError={true} +						disabled={exportStats.mutes_count === 0} +					/> +				</div> +			</div> +		</form> +	); +} diff --git a/web/source/settings/views/user/export-import/index.tsx b/web/source/settings/views/user/export-import/index.tsx new file mode 100644 index 000000000..2e3533318 --- /dev/null +++ b/web/source/settings/views/user/export-import/index.tsx @@ -0,0 +1,57 @@ +/* +	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 Export from "./export"; +import Loading from "../../../components/loading"; +import { Error } from "../../../components/error"; +import { useExportStatsQuery } from "../../../lib/query/user/export-import"; + +export default function ExportImport() { +	const { +		data: exportStats, +		isLoading, +		isFetching, +		isError, +		error, +	} = useExportStatsQuery(); + +	if (isLoading || isFetching) { +		return <Loading />; +	} + +	if (isError) { +		return <Error error={error} />; +	} + +	if (exportStats === undefined) { +		throw "undefined account export stats"; +	} +	 +	return ( +		<> +			<h1>Export & Import</h1> +			<p> +				On this page you can export data from your GoToSocial account, or import data into +				your GoToSocial account. All exports and imports use Mastodon-compatible CSV files. +			</p> +			<Export exportStats={exportStats} /> +		</> +	); +} diff --git a/web/source/settings/views/user/menu.tsx b/web/source/settings/views/user/menu.tsx index 3d90bfe21..a0526d652 100644 --- a/web/source/settings/views/user/menu.tsx +++ b/web/source/settings/views/user/menu.tsx @@ -53,6 +53,11 @@ export default function UserMenu() {  				itemUrl="migration"  				icon="fa-exchange"  			/> +			<MenuItem +				name="Export & Import" +				itemUrl="export-import" +				icon="fa-floppy-o" +			/>  		</MenuItem>  	);  } diff --git a/web/source/settings/views/user/router.tsx b/web/source/settings/views/user/router.tsx index 5b74aee68..7b995b3b7 100644 --- a/web/source/settings/views/user/router.tsx +++ b/web/source/settings/views/user/router.tsx @@ -25,12 +25,14 @@ import UserProfile from "./profile";  import UserMigration from "./migration";  import PostSettings from "./posts";  import EmailPassword from "./emailpassword"; +import ExportImport from "./export-import";  /**   * - /settings/user/profile   * - /settings/user/posts   * - /settings/user/emailpassword   * - /settings/user/migration + * - /settings/user/export-import   */  export default function UserRouter() {  	const baseUrl = useBaseUrl(); @@ -46,6 +48,7 @@ export default function UserRouter() {  						<Route path="/posts" component={PostSettings} />  						<Route path="/emailpassword" component={EmailPassword} />  						<Route path="/migration" component={UserMigration} /> +						<Route path="/export-import" component={ExportImport} />  						<Route><Redirect to="/profile" /></Route>  					</Switch>  				</ErrorBoundary>  | 
