summaryrefslogtreecommitdiff
path: root/web/source
diff options
context:
space:
mode:
authorLibravatar tobi <31960611+tsmethurst@users.noreply.github.com>2024-08-24 11:49:37 +0200
committerLibravatar GitHub <noreply@github.com>2024-08-24 11:49:37 +0200
commitf23f04e0b1d117be714bf91d5266dab219ed741e (patch)
tree0b3ddd60d51c8729949c3669993910a7f8f32a7b /web/source
parent[performance] ffmpeg ffprobe wrapper improvements (#3225) (diff)
downloadgotosocial-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.json2
-rw-r--r--web/source/settings/components/status.tsx2
-rw-r--r--web/source/settings/lib/query/admin/index.ts2
-rw-r--r--web/source/settings/lib/query/admin/reports/index.ts4
-rw-r--r--web/source/settings/lib/query/gts-api.ts1
-rw-r--r--web/source/settings/lib/query/user/interactions.ts97
-rw-r--r--web/source/settings/lib/types/interaction.ts82
-rw-r--r--web/source/settings/style.css58
-rw-r--r--web/source/settings/views/user/interactions/detail.tsx117
-rw-r--r--web/source/settings/views/user/interactions/index.tsx36
-rw-r--r--web/source/settings/views/user/interactions/search.tsx251
-rw-r--r--web/source/settings/views/user/interactions/util.tsx98
-rw-r--r--web/source/settings/views/user/menu.tsx5
-rw-r--r--web/source/settings/views/user/router.tsx28
-rw-r--r--web/source/yarn.lock53
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"