From f23f04e0b1d117be714bf91d5266dab219ed741e Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Sat, 24 Aug 2024 11:49:37 +0200
Subject: [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
---
.../settings/views/user/interactions/detail.tsx | 117 ++++++++++
.../settings/views/user/interactions/index.tsx | 36 +++
.../settings/views/user/interactions/search.tsx | 251 +++++++++++++++++++++
.../settings/views/user/interactions/util.tsx | 98 ++++++++
4 files changed, 502 insertions(+)
create mode 100644 web/source/settings/views/user/interactions/detail.tsx
create mode 100644 web/source/settings/views/user/interactions/index.tsx
create mode 100644 web/source/settings/views/user/interactions/search.tsx
create mode 100644 web/source/settings/views/user/interactions/util.tsx
(limited to 'web/source/settings/views/user/interactions')
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 .
+*/
+
+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 (
+
+
Interaction Request Details
+
+
+ );
+}
+
+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 (
+ <>
+
+ {strap}
+
+
+ You wrote:
+
+
+
+
+ { req.reply && <>
+ They replied:
+
+
+
+ > }
+
+
+ {
+ e.preventDefault();
+ approve(req.id);
+ setLocation(backLocation);
+ }}
+ disabled={false}
+ showError={false}
+ result={approveResult}
+ />
+
+ {
+ e.preventDefault();
+ reject(req.id);
+ setLocation(backLocation);
+ }}
+ disabled={false}
+ showError={false}
+ result={rejectResult}
+ />
+
+ >
+ );
+}
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 .
+*/
+
+import React from "react";
+import InteractionRequestsSearchForm from "./search";
+
+export default function InteractionRequests() {
+ return (
+
+
+
Interaction Requests
+
+ On this page you can search through interaction requests
+ targeting your statuses, and approve or reject them.
+
+
+
+
+ );
+}
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 .
+*/
+
+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 (
+
+ );
+ }
+
+ return (
+ <>
+
+ No interaction requests found that match your query.}
+ 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 (
+ {
+ // 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}
+ >
+
+ {strap}
+
+
+
+
- You wrote:
+ -
+ {ourContent}
+
+
+ { req.type === "reply" &&
+
+
- They wrote:
+ -
+ {theirContent}
+
+
+ }
+
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ approve(req.id);
+ }}
+ disabled={false}
+ showError={false}
+ result={approveResult}
+ />
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ reject(req.id);
+ }}
+ disabled={false}
+ showError={false}
+ result={rejectResult}
+ />
+
+
+ );
+}
+
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 .
+*/
+
+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]);
+}
--
cgit v1.3