diff options
author | 2024-08-24 11:49:37 +0200 | |
---|---|---|
committer | 2024-08-24 11:49:37 +0200 | |
commit | f23f04e0b1d117be714bf91d5266dab219ed741e (patch) | |
tree | 0b3ddd60d51c8729949c3669993910a7f8f32a7b /web/source | |
parent | [performance] ffmpeg ffprobe wrapper improvements (#3225) (diff) | |
download | gotosocial-f23f04e0b1d117be714bf91d5266dab219ed741e.tar.xz |
[feature] Interaction requests client api + settings panel (#3215)
* [feature] Interaction requests client api + settings panel
* test accept / reject
* fmt
* don't pin rejected interaction
* use single db model for interaction accept, reject, and request
* swaggor
* env sharting
* append errors
* remove ErrNoEntries checks
* change intReqID to reqID
* rename "pend" to "request"
* markIntsPending -> mark interactionsPending
* use log instead of returning error when rejecting interaction
* empty migration
* jolly renaming
* make interactionURI unique again
* swag grr
* remove unnecessary locks
* invalidate as last step
Diffstat (limited to 'web/source')
-rw-r--r-- | web/source/package.json | 2 | ||||
-rw-r--r-- | web/source/settings/components/status.tsx | 2 | ||||
-rw-r--r-- | web/source/settings/lib/query/admin/index.ts | 2 | ||||
-rw-r--r-- | web/source/settings/lib/query/admin/reports/index.ts | 4 | ||||
-rw-r--r-- | web/source/settings/lib/query/gts-api.ts | 1 | ||||
-rw-r--r-- | web/source/settings/lib/query/user/interactions.ts | 97 | ||||
-rw-r--r-- | web/source/settings/lib/types/interaction.ts | 82 | ||||
-rw-r--r-- | web/source/settings/style.css | 58 | ||||
-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 | ||||
-rw-r--r-- | web/source/yarn.lock | 53 |
15 files changed, 828 insertions, 8 deletions
diff --git a/web/source/package.json b/web/source/package.json index bce3546d2..3c239419e 100644 --- a/web/source/package.json +++ b/web/source/package.json @@ -14,6 +14,7 @@ "@reduxjs/toolkit": "^1.8.6", "ariakit": "^2.0.0-next.41", "get-by-dot": "^1.0.2", + "html-to-text": "^9.0.5", "is-valid-domain": "^0.1.6", "js-file-download": "^0.4.12", "langs": "^2.0.0", @@ -45,6 +46,7 @@ "@browserify/envify": "^6.0.0", "@browserify/uglifyify": "^6.0.0", "@joepie91/eslint-config": "^1.1.1", + "@types/html-to-text": "^9.0.4", "@types/is-valid-domain": "^0.0.2", "@types/papaparse": "^5.3.9", "@types/parse-link-header": "^2.0.3", diff --git a/web/source/settings/components/status.tsx b/web/source/settings/components/status.tsx index ba38e161c..d2116e60d 100644 --- a/web/source/settings/components/status.tsx +++ b/web/source/settings/components/status.tsx @@ -220,7 +220,7 @@ function StatusMediaEntry({ media }: { media: MediaAttachment }) { function StatusFooter({ status }: { status: StatusType }) { return ( - <aside className="status-info" aria-hidden="true"> + <aside className="status-info"> <dl className="status-stats"> <div className="stats-grouping"> <div className="stats-item published-at text-cutoff"> diff --git a/web/source/settings/lib/query/admin/index.ts b/web/source/settings/lib/query/admin/index.ts index fba028853..86388e273 100644 --- a/web/source/settings/lib/query/admin/index.ts +++ b/web/source/settings/lib/query/admin/index.ts @@ -114,7 +114,7 @@ const extended = gtsApi.injectEndpoints({ method: "POST", url: `/api/v1/admin/accounts/${id}/${approve_or_reject}`, asForm: true, - body: approve_or_reject === "reject" ?? formData, + body: approve_or_reject === "reject" && formData, }; }, // Do an optimistic update on this account to mark it approved diff --git a/web/source/settings/lib/query/admin/reports/index.ts b/web/source/settings/lib/query/admin/reports/index.ts index 8937d5358..6430cd6b1 100644 --- a/web/source/settings/lib/query/admin/reports/index.ts +++ b/web/source/settings/lib/query/admin/reports/index.ts @@ -77,8 +77,8 @@ const extended = gtsApi.injectEndpoints({ }), invalidatesTags: (res) => res - ? [{ type: "Report", id: "LIST" }, { type: "Report", id: res.id }] - : [{ type: "Report", id: "LIST" }] + ? [{ type: "Report", id: "TRANSFORMED" }, { type: "Report", id: res.id }] + : [{ type: "Report", id: "TRANSFORMED" }] }) }) }); diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts index 1c715e284..911ea58c7 100644 --- a/web/source/settings/lib/query/gts-api.ts +++ b/web/source/settings/lib/query/gts-api.ts @@ -168,6 +168,7 @@ export const gtsApi = createApi({ "HTTPHeaderAllows", "HTTPHeaderBlocks", "DefaultInteractionPolicies", + "InteractionRequest", ], endpoints: (build) => ({ instanceV1: build.query<InstanceV1, void>({ diff --git a/web/source/settings/lib/query/user/interactions.ts b/web/source/settings/lib/query/user/interactions.ts new file mode 100644 index 000000000..04e3a74ff --- /dev/null +++ b/web/source/settings/lib/query/user/interactions.ts @@ -0,0 +1,97 @@ +/* + 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 { + InteractionRequest, + SearchInteractionRequestsParams, + SearchInteractionRequestsResp, +} from "../../types/interaction"; +import { gtsApi } from "../gts-api"; +import parse from "parse-link-header"; + +const extended = gtsApi.injectEndpoints({ + endpoints: (build) => ({ + getInteractionRequest: build.query<InteractionRequest, string>({ + query: (id) => ({ + method: "GET", + url: `/api/v1/interaction_requests/${id}`, + }), + providesTags: (_result, _error, id) => [ + { type: 'InteractionRequest', id } + ], + }), + + searchInteractionRequests: build.query<SearchInteractionRequestsResp, SearchInteractionRequestsParams>({ + 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()}`; + } + + return { + url: `/api/v1/interaction_requests${query}` + }; + }, + // Headers required for paging. + transformResponse: (apiResp: InteractionRequest[], meta) => { + const requests = apiResp; + const linksStr = meta?.response?.headers.get("Link"); + const links = parse(linksStr); + return { requests, links }; + }, + providesTags: [{ type: "InteractionRequest", id: "TRANSFORMED" }] + }), + + approveInteractionRequest: build.mutation<InteractionRequest, string>({ + query: (id) => ({ + method: "POST", + url: `/api/v1/interaction_requests/${id}/authorize`, + }), + invalidatesTags: (res) => + res + ? [{ type: "InteractionRequest", id: "TRANSFORMED" }, { type: "InteractionRequest", id: res.id }] + : [{ type: "InteractionRequest", id: "TRANSFORMED" }] + }), + + rejectInteractionRequest: build.mutation<any, string>({ + query: (id) => ({ + method: "POST", + url: `/api/v1/interaction_requests/${id}/reject`, + }), + invalidatesTags: (res) => + res + ? [{ type: "InteractionRequest", id: "TRANSFORMED" }, { type: "InteractionRequest", id: res.id }] + : [{ type: "InteractionRequest", id: "TRANSFORMED" }] + }), + }) +}); + +export const { + useGetInteractionRequestQuery, + useLazySearchInteractionRequestsQuery, + useApproveInteractionRequestMutation, + useRejectInteractionRequestMutation, +} = extended; diff --git a/web/source/settings/lib/types/interaction.ts b/web/source/settings/lib/types/interaction.ts index 735a20ed2..caedd9544 100644 --- a/web/source/settings/lib/types/interaction.ts +++ b/web/source/settings/lib/types/interaction.ts @@ -17,6 +17,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>. */ +import { Links } from "parse-link-header"; +import { Account } from "./account"; +import { Status } from "./status"; + export interface DefaultInteractionPolicies { direct: InteractionPolicy; private: InteractionPolicy; @@ -61,3 +65,81 @@ export { PolicyValueAuthor, PolicyValueMe, }; + + +/** + * Interaction request targeting a status by an account. + */ +export interface InteractionRequest { + /** + * ID of the request. + */ + id: string; + /** + * Type of interaction being requested. + */ + type: "favourite" | "reply" | "reblog"; + /** + * Time when the request was created. + */ + created_at: string; + /** + * Account that created the request. + */ + account: Account; + /** + * Status being interacted with. + */ + status: Status; + /** + * Replying status, if type = "reply". + */ + reply?: Status; +} + +/** + * Parameters for GET to /api/v1/interaction_requests. + */ +export interface SearchInteractionRequestsParams { + /** + * If set, show only requests targeting the given status_id. + */ + status_id?: string; + /** + * If true or not set, include favourites in the results. + */ + favourites?: boolean; + /** + * If true or not set, include replies in the results. + */ + replies?: boolean; + /** + * If true or not set, include reblogs in the results. + */ + reblogs?: boolean; + /** + * If set, show only requests older (ie., lower) than the given ID. + * Request with the given ID will not be included in response. + */ + max_id?: string; + /** + * If set, show only requests newer (ie., higher) than the given ID. + * Request with the given ID will not be included in response. + */ + since_id?: string; + /** + * If set, show only requests *immediately newer* than the given ID. + * Request with the given ID will not be included in response. + */ + min_id?: string; + /** + * If set, limit returned requests to this number. + * Else, fall back to GtS API defaults. + */ + limit?: number; +} + +export interface SearchInteractionRequestsResp { + requests: InteractionRequest[]; + links: Links | null; +} diff --git a/web/source/settings/style.css b/web/source/settings/style.css index d34dd0eb7..96ff0ff50 100644 --- a/web/source/settings/style.css +++ b/web/source/settings/style.css @@ -1317,10 +1317,10 @@ button.tab-button { word-break: break-word; } - dt, dd { + dt, dd, span { /* Make sure any fa icons used in keys - or values are properly aligned. + or values etc. are properly aligned. */ .fa { vertical-align: middle; @@ -1516,6 +1516,60 @@ button.tab-button { } } + +.interaction-requests-view { + .interaction-request { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + gap: 0.5rem; + color: $fg; + + .info-list { + border: none; + + .info-list-entry { + grid-template-columns: max(20%, 8rem) 1fr; + background: none; + padding: 0; + } + } + + .action-buttons { + display: flex; + gap: 0.5rem; + align-items: center; + + > .mutation-button + > button { + font-size: 1rem; + line-height: 1rem; + } + } + } +} + +.interaction-request-detail { + .overview { + margin-top: 1rem; + } + + h2 { + font-size: 1rem; + } + + .thread .status .status-info { + border-bottom-left-radius: $br; + border-bottom-right-radius: $br; + } + + .action-buttons { + display: flex; + gap: 0.5rem; + align-items: center; + } +} + @media screen and (orientation: portrait) { .reports .report .byline { grid-template-columns: 1fr; 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> ); diff --git a/web/source/yarn.lock b/web/source/yarn.lock index dd09d746f..fa3b6106b 100644 --- a/web/source/yarn.lock +++ b/web/source/yarn.lock @@ -1411,6 +1411,14 @@ redux-thunk "^2.4.2" reselect "^4.1.8" +"@selderee/plugin-htmlparser2@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz#d5b5e29a7ba6d3958a1972c7be16f4b2c188c517" + integrity sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ== + dependencies: + domhandler "^5.0.3" + selderee "^0.11.0" + "@tsconfig/node10@^1.0.7": version "1.0.9" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" @@ -1439,6 +1447,11 @@ "@types/react" "*" hoist-non-react-statics "^3.3.0" +"@types/html-to-text@^9.0.4": + version "9.0.4" + resolved "https://registry.yarnpkg.com/@types/html-to-text/-/html-to-text-9.0.4.tgz#4a83dd8ae8bfa91457d0b1ffc26f4d0537eff58c" + integrity sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ== + "@types/http-proxy@^1.17.8": version "1.17.12" resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.12.tgz#86e849e9eeae0362548803c37a0a1afc616bd96b" @@ -3004,7 +3017,7 @@ deep-is@^0.1.3, deep-is@~0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== -deepmerge@^4.2.2: +deepmerge@^4.2.2, deepmerge@^4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== @@ -4078,12 +4091,23 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: dependencies: react-is "^16.7.0" +html-to-text@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/html-to-text/-/html-to-text-9.0.5.tgz#6149a0f618ae7a0db8085dca9bbf96d32bb8368d" + integrity sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg== + dependencies: + "@selderee/plugin-htmlparser2" "^0.11.0" + deepmerge "^4.3.1" + dom-serializer "^2.0.0" + htmlparser2 "^8.0.2" + selderee "^0.11.0" + htmlescape@^1.1.0: version "1.1.1" resolved "https://registry.yarnpkg.com/htmlescape/-/htmlescape-1.1.1.tgz#3a03edc2214bca3b66424a3e7959349509cb0351" integrity sha512-eVcrzgbR4tim7c7soKQKtxa/kQM4TzjnlU83rcZ9bHU6t31ehfV7SktN6McWgwPWg+JYMA/O3qpGxBvFq1z2Jg== -htmlparser2@^8.0.0: +htmlparser2@^8.0.0, htmlparser2@^8.0.2: version "8.0.2" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== @@ -4696,6 +4720,11 @@ langs@^2.0.0: resolved "https://registry.yarnpkg.com/langs/-/langs-2.0.0.tgz#00c32ce48152a49a614450b9ba2632ab58a0a364" integrity sha512-v4pxOBEQVN1WBTfB1crhTtxzNLZU9HPWgadlwzWKISJtt6Ku/CnpBrwVy+jFv8StjxsPfwPFzO0CMwdZLJ0/BA== +leac@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912" + integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg== + levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -5261,6 +5290,14 @@ parse-srcset@^1.0.2: resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q== +parseley@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.12.1.tgz#4afd561d50215ebe259e3e7a853e62f600683aef" + integrity sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw== + dependencies: + leac "^0.6.0" + peberminta "^0.9.0" + parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -5317,6 +5354,11 @@ pbkdf2@^3.0.3: safe-buffer "^5.0.1" sha.js "^2.4.8" +peberminta@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/peberminta/-/peberminta-0.9.0.tgz#8ec9bc0eb84b7d368126e71ce9033501dca2a352" + integrity sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ== + photoswipe-dynamic-caption-plugin@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/photoswipe-dynamic-caption-plugin/-/photoswipe-dynamic-caption-plugin-1.2.7.tgz#53aa5059f1c4dccc8aa36196ff3e09baa5e537c2" @@ -5966,6 +6008,13 @@ scope-analyzer@^2.0.1: estree-is-function "^1.0.0" get-assigned-identifiers "^1.1.0" +selderee@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/selderee/-/selderee-0.11.0.tgz#6af0c7983e073ad3e35787ffe20cefd9daf0ec8a" + integrity sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA== + dependencies: + parseley "^0.12.0" + semver@^6.1.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" |