]> Untitled Git - lemmy-ui.git/commitdiff
Use http client (#1081)
authorSleeplessOne1917 <abias1122@gmail.com>
Wed, 14 Jun 2023 12:20:40 +0000 (12:20 +0000)
committerGitHub <noreply@github.com>
Wed, 14 Jun 2023 12:20:40 +0000 (08:20 -0400)
* Beginning work on websocket -> http client conversion.

* About 30% done.

* half done.

* more done.

* Almost passing lint.

* Passing lint, but untested.

* Add back in event listeners.

* Fixing some community forms.

* Remove webpack cache.

* fixing some more.

* Fixed ISOwrappers.

* A few more fixes.

* Refactor utils

* Fix instance add/remove buttons

* Not catching errors in isoWrapper.

* Wrap Http client

* Fixing up tagline and ratelimit forms.

* Make all http client wrapping be in one place

* Reworking some more forms.

* Upgrading lemmy-js-client.

* Fixing verify email.

* Fix linting errors

* Upgrading woodpecker node.

* Fix comment scrolling rerender bug.

* Fixing a few things, commenting out props for now.

* v0.18.0-beta.1

* Trying to fix woodpecker, 1.

* Trying to fix woodpecker, 2.

* Handroll prompt

* Add navigation prompt to other pages

* Fix prompt navigation bug

* Fix prompt bug introduced from last bug fix

* Fix PWA bug

* Fix isoData not working

* Fix search page update url

* Fix sharp issue.

* v0.18.0-beta.2

* Make create post pre-fetch communities

* Fix bug from last commit

* Fix issue of posts/comments not being switched when changing select options

* Fix unnecessary fetches on home screen

* Make circular icon buttons not look stupid

* Prevent unnecessary fetches

* Make login experience smoother

* Add PWA shortcuts

* Add related application to PWA

* Update translations

* Forgot to add post editing.

* Fixing site setup.

* Deploy script setup.

* v0.18.0-beta.4

* Sanitize again.

* Adding sanitize json function.

* Upping version.

* Another sanitize fix.

* Upping version.

* Prevent search nav item from disappearing when on search page

* Allow admin and mod actions on non-local comments.

* Fix mobile menu collapse bug

* Completely fix prompt component

* Fix undefined value checks in use_http_client_2 (#1230)

* fix: filter out undefined from posts

* fix: emoji initialisation passing undefined

* fix: || => ?? to be more explicit

* linting

---------

Co-authored-by: Alex Maras <alexmaras@gmail.com>
* Re-add accidentally removed state

* Fix dropdown bug

* Use linkEvent where appropriate

* Fix navigation warnings.

---------

Co-authored-by: Dessalines <tyhou13@gmx.com>
Co-authored-by: Alex Maras <dev@alexmaras.com>
Co-authored-by: Alex Maras <alexmaras@gmail.com>
66 files changed:
.eslintrc.json
.woodpecker.yml
README.md
deploy.sh
lemmy-translations
package.json
src/client/index.tsx
src/server/index.tsx
src/shared/components/app/navbar.tsx
src/shared/components/comment/comment-form.tsx
src/shared/components/comment/comment-node.tsx
src/shared/components/comment/comment-nodes.tsx
src/shared/components/comment/comment-report.tsx
src/shared/components/common/image-upload-form.tsx
src/shared/components/common/listing-type-select.tsx
src/shared/components/common/markdown-textarea.tsx
src/shared/components/common/registration-application.tsx
src/shared/components/common/searchable-select.tsx
src/shared/components/common/sort-select.tsx
src/shared/components/community/communities.tsx
src/shared/components/community/community-form.tsx
src/shared/components/community/community.tsx
src/shared/components/community/create-community.tsx
src/shared/components/community/sidebar.tsx
src/shared/components/home/admin-settings.tsx
src/shared/components/home/emojis-form.tsx
src/shared/components/home/home.tsx
src/shared/components/home/instances.tsx
src/shared/components/home/login.tsx
src/shared/components/home/rate-limit-form.tsx
src/shared/components/home/setup.tsx
src/shared/components/home/signup.tsx
src/shared/components/home/site-form.tsx
src/shared/components/home/tagline-form.tsx
src/shared/components/modlog.tsx
src/shared/components/person/inbox.tsx
src/shared/components/person/password-change.tsx
src/shared/components/person/person-details.tsx
src/shared/components/person/profile.tsx
src/shared/components/person/registration-applications.tsx
src/shared/components/person/reports.tsx
src/shared/components/person/settings.tsx
src/shared/components/person/verify-email.tsx
src/shared/components/post/create-post.tsx
src/shared/components/post/post-form.tsx
src/shared/components/post/post-listing.tsx
src/shared/components/post/post-listings.tsx
src/shared/components/post/post-report.tsx
src/shared/components/post/post.tsx
src/shared/components/private_message/create-private-message.tsx
src/shared/components/private_message/private-message-form.tsx
src/shared/components/private_message/private-message-report.tsx
src/shared/components/private_message/private-message.tsx
src/shared/components/search.tsx
src/shared/env.ts
src/shared/interfaces.ts
src/shared/routes.ts
src/shared/services/FirstLoadService.ts [new file with mode: 0644]
src/shared/services/HistoryService.ts [new file with mode: 0644]
src/shared/services/HttpService.ts [new file with mode: 0644]
src/shared/services/UserService.ts
src/shared/services/WebSocketService.ts [deleted file]
src/shared/services/index.ts
src/shared/utils.ts
webpack.config.js
yarn.lock

index 44dab4288132ae66177f241efdb66c52334687b0..3a60a6bb5bac9a26edd9b6a347e502f97eee34d0 100644 (file)
@@ -18,6 +18,7 @@
     "@typescript-eslint/ban-ts-comment": 0,
     "@typescript-eslint/no-explicit-any": 0,
     "@typescript-eslint/explicit-module-boundary-types": 0,
+    "@typescript-eslint/no-empty-function": 0,
     "arrow-body-style": 0,
     "curly": 0,
     "eol-last": 0,
index 8d3c6f1c7d330086bb7ad7a4b52fe689bde2c495..656903a10f6c46d5b30e6ac6e1f9baebd3571d7b 100644 (file)
@@ -1,6 +1,6 @@
 pipeline:
   fetch_git_submodules:
-    image: node:14-alpine
+    image: node:alpine
     commands:
       - apk add git
       - git submodule init
@@ -8,93 +8,27 @@ pipeline:
       # - git fetch --tags
 
   yarn:
-    image: node:14-alpine
+    image: node:alpine
     commands:
       - yarn
 
   yarn_lint:
-    image: node:14-alpine
+    image: node:alpine
     commands:
       - yarn lint
 
   yarn_build_dev:
-    image: node:14-alpine
+    image: node:alpine
     commands:
       - yarn build:dev
 
-  nightly_build:
-    image: plugins/docker
+  publish_release_docker:
+    image: woodpeckerci/plugin-docker-buildx
+    secrets: [docker_username, docker_password]
     settings:
-      dockerfile: Dockerfile
-      repo: dessalines/lemmy-ui
-      username:
-        from_secret: docker_username
-      password:
-        from_secret: docker_password
-      tags:
-        - dev
-    when:
-      event:
-        - cron
-
-  publish_release_docker_image_amd:
-    image: plugins/docker
-    settings:
-      dockerfile: Dockerfile
       repo: dessalines/lemmy-ui
-      auto_tag: true
-      auto_tag_suffix: linux-amd64
-      username:
-        from_secret: docker_username
-      password:
-        from_secret: docker_password
-    when:
-      event: tag
-      platform: linux/arm64
-
-  publish_release_docker_image_arm:
-    image: plugins/docker
-    settings:
       dockerfile: Dockerfile
-      repo: dessalines/lemmy-ui
+      platforms: linux/amd64
       auto_tag: true
-      auto_tag_suffix: linux-arm64
-      username:
-        from_secret: docker_username
-      password:
-        from_secret: docker_password
-    when:
-      event: tag
-      platform: linux/amd64
-
-  publish_release_docker_manifest:
-    image: plugins/manifest
-    settings:
-      username:
-        from_secret: docker_username
-      password:
-        from_secret: docker_password
-      target: "dessalines/lemmy-ui:${CI_COMMIT_TAG}"
-      template: "dessalines/lemmy-ui:${CI_COMMIT_TAG}-OS-ARCH"
-      platforms:
-        - linux/amd64
-        - linux/arm64
-      ignore_missing: true
-    when:
-      event: tag
-
-  publish_latest_release_docker_manifest:
-    image: plugins/manifest
-    settings:
-      username:
-        from_secret: docker_username
-      password:
-        from_secret: docker_password
-      target: "dessalines/lemmy-ui:latest"
-      template: "dessalines/lemmy-ui:${CI_COMMIT_TAG}-OS-ARCH"
-      platforms:
-        - linux/amd64
-        - linux/arm64
-      ignore_missing: true
     when:
       event: tag
index 6c9ef63affee4d81033ec7a5eec4f1ba6d48cbfc..f1917bff15cf07d6f27e61e5aa6fa47cf6ece068 100644 (file)
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# lemmy-ui
+# Lemmy-UI
 
 The official web app for [Lemmy](https://github.com/LemmyNet/lemmy), written in inferno.
 
@@ -13,7 +13,6 @@ The following environment variables can be used to configure lemmy-ui:
 | `LEMMY_UI_HOST`                | `string` | `0.0.0.0:1234`   | The IP / port that the lemmy-ui isomorphic node server is hosted at.                |
 | `LEMMY_UI_LEMMY_INTERNAL_HOST` | `string` | `0.0.0.0:8536`   | The internal IP / port that lemmy is hosted at. Often `lemmy:8536` if using docker. |
 | `LEMMY_UI_LEMMY_EXTERNAL_HOST` | `string` | `0.0.0.0:8536`   | The external IP / port that lemmy is hosted at. Often `DOMAIN.TLD`.                 |
-| `LEMMY_UI_LEMMY_WS_HOST`       | `string` | `0.0.0.0:8536`   | An alternate location for lemmy's websocket address. Not usually necessary.         |
 | `LEMMY_UI_HTTPS`               | `bool`   | `false`          | Whether to use https.                                                               |
 | `LEMMY_UI_EXTRA_THEMES_FOLDER` | `string` | `./extra_themes` | A location for additional lemmy css themes.                                         |
 | `LEMMY_UI_DEBUG`               | `bool`   | `false`          | Loads the [Eruda](https://github.com/liriliri/eruda) debugging utility.             |
index c53988d28859a8709c8aa5ff484406de6898db16..e919779a57b6b4b86c2056c4c088f8376176add8 100755 (executable)
--- a/deploy.sh
+++ b/deploy.sh
@@ -4,6 +4,7 @@ set -e
 new_tag="$1"
 
 # Old deploy
+# sudo docker build . --tag dessalines/lemmy-ui:$new_tag --platform=linux/amd64 --push
 # sudo docker build . --tag dessalines/lemmy-ui:$new_tag --platform=linux/amd64
 # sudo docker push dessalines/lemmy-ui:$new_tag
 
index ddf0d3a4dcfba5eddbcdb702db2470b52abb3815..f45ddff206adb52ab0ac7555bf14978edac5d2f2 160000 (submodule)
@@ -1 +1 @@
-Subproject commit ddf0d3a4dcfba5eddbcdb702db2470b52abb3815
+Subproject commit f45ddff206adb52ab0ac7555bf14978edac5d2f2
index 413144e3ecab97917bcd2600df1c6fb67410713b..fd7cf4ad9a0e694cb645f259f74de5216e328609 100644 (file)
@@ -1,6 +1,6 @@
 {
   "name": "lemmy-ui",
-  "version": "0.17.1",
+  "version": "0.18.0-beta.6",
   "description": "An isomorphic UI for lemmy",
   "repository": "https://github.com/LemmyNet/lemmy-ui",
   "license": "AGPL-3.0",
     "start": "yarn build:dev --watch"
   },
   "lint-staged": {
-    "*.{ts,tsx,js}": [
-      "prettier --write",
-      "eslint --fix"
-    ],
-    "*.{css, scss}": [
-      "prettier --write"
-    ],
-    "package.json": [
-      "sortpack"
-    ]
+    "*.{ts,tsx,js}": ["prettier --write", "eslint --fix"],
+    "*.{css, scss}": ["prettier --write"],
+    "package.json": ["sortpack"]
   },
   "dependencies": {
     "@babel/plugin-proposal-decorators": "^7.21.0",
@@ -49,6 +42,7 @@
     "emoji-mart": "^5.4.0",
     "emoji-short-name": "^2.0.0",
     "express": "~4.18.2",
+    "history": "^5.3.0",
     "html-to-text": "^9.0.5",
     "i18next": "^22.4.15",
     "inferno": "^8.1.1",
@@ -60,7 +54,7 @@
     "inferno-server": "^8.1.1",
     "isomorphic-cookie": "^1.2.4",
     "jwt-decode": "^3.1.2",
-    "lemmy-js-client": "0.17.2-rc.17",
+    "lemmy-js-client": "0.17.2-rc.24",
     "lodash": "^4.17.21",
     "markdown-it": "^13.0.1",
     "markdown-it-container": "^3.0.0",
@@ -73,7 +67,6 @@
     "moment": "^2.29.4",
     "register-service-worker": "^1.7.2",
     "run-node-webpack-plugin": "^1.3.0",
-    "rxjs": "^7.8.1",
     "sanitize-html": "^2.10.0",
     "sass": "^1.62.1",
     "sass-loader": "^13.2.2",
@@ -85,8 +78,7 @@
     "tributejs": "^5.1.3",
     "webpack": "5.82.1",
     "webpack-cli": "^5.1.1",
-    "webpack-node-externals": "^3.0.0",
-    "websocket-ts": "^1.1.1"
+    "webpack-node-externals": "^3.0.0"
   },
   "devDependencies": {
     "@babel/core": "^7.21.8",
index 99f12371a10a4205f1ec95084d969619b013e2eb..7b6b6b1cd662932736a83e4daca2b355d77f2196 100644 (file)
@@ -1,18 +1,19 @@
 import { hydrate } from "inferno-hydrate";
-import { BrowserRouter } from "inferno-router";
+import { Router } from "inferno-router";
 import { App } from "../shared/components/app/app";
 import { initializeSite } from "../shared/utils";
 
 import "bootstrap/js/dist/collapse";
 import "bootstrap/js/dist/dropdown";
+import { HistoryService } from "../shared/services/HistoryService";
 
 const site = window.isoData.site_res;
 initializeSite(site);
 
 const wrapper = (
-  <BrowserRouter>
+  <Router history={HistoryService.history}>
     <App />
-  </BrowserRouter>
+  </Router>
 );
 
 const root = document.getElementById("root");
index 716a936dbd0cb4424cbb53884afec4ad76dce51f..43024076ebb74db9d7624a89cf354555d355d3f0 100644 (file)
@@ -6,19 +6,20 @@ import { Helmet } from "inferno-helmet";
 import { matchPath, StaticRouter } from "inferno-router";
 import { renderToString } from "inferno-server";
 import IsomorphicCookie from "isomorphic-cookie";
-import { GetSite, GetSiteResponse, LemmyHttp, Site } from "lemmy-js-client";
+import { GetSite, GetSiteResponse, LemmyHttp } from "lemmy-js-client";
 import path from "path";
 import process from "process";
 import serialize from "serialize-javascript";
 import sharp from "sharp";
 import { App } from "../shared/components/app/app";
-import { getHttpBase, getHttpBaseInternal } from "../shared/env";
+import { getHttpBaseExternal, getHttpBaseInternal } from "../shared/env";
 import {
   ILemmyConfig,
   InitialFetchRequest,
   IsoDataOptionalSite,
 } from "../shared/interfaces";
 import { routes } from "../shared/routes";
+import { RequestState, wrapClient } from "../shared/services/HttpService";
 import {
   ErrorPageData,
   favIconPngUrl,
@@ -64,7 +65,13 @@ Disallow: /search/
 
 server.get("/service-worker.js", async (_req, res) => {
   res.setHeader("Content-Type", "application/javascript");
-  res.sendFile(path.resolve("./dist/service-worker.js"));
+  res.sendFile(
+    path.resolve(
+      `./dist/service-worker${
+        process.env.NODE_ENV === "development" ? "-development" : ""
+      }.js`
+    )
+  );
 });
 
 server.get("/robots.txt", async (_req, res) => {
@@ -121,7 +128,7 @@ server.get("/*", async (req, res) => {
     const getSiteForm: GetSite = { auth };
 
     const headers = setForwardedHeaders(req.headers);
-    const client = new LemmyHttp(getHttpBaseInternal(), headers);
+    const client = wrapClient(new LemmyHttp(getHttpBaseInternal(), headers));
 
     const { path, url, query } = req;
 
@@ -129,27 +136,30 @@ server.get("/*", async (req, res) => {
     // This bypasses errors, so that the client can hit the error on its own,
     // in order to remove the jwt on the browser. Necessary for wrong jwts
     let site: GetSiteResponse | undefined = undefined;
-    let routeData: any[] = [];
-    let errorPageData: ErrorPageData | undefined;
-    try {
-      let try_site: any = await client.getSite(getSiteForm);
-      if (try_site.error == "not_logged_in") {
-        console.error(
-          "Incorrect JWT token, skipping auth so frontend can remove jwt cookie"
-        );
-        getSiteForm.auth = undefined;
-        auth = undefined;
-        try_site = await client.getSite(getSiteForm);
-      }
+    const routeData: RequestState<any>[] = [];
+    let errorPageData: ErrorPageData | undefined = undefined;
+    let try_site = await client.getSite(getSiteForm);
+    if (try_site.state === "failed" && try_site.msg == "not_logged_in") {
+      console.error(
+        "Incorrect JWT token, skipping auth so frontend can remove jwt cookie"
+      );
+      getSiteForm.auth = undefined;
+      auth = undefined;
+      try_site = await client.getSite(getSiteForm);
+    }
 
-      if (!auth && isAuthPath(path)) {
-        res.redirect("/login");
-        return;
-      }
+    if (!auth && isAuthPath(path)) {
+      return res.redirect("/login");
+    }
 
-      site = try_site;
+    if (try_site.state === "success") {
+      site = try_site.data;
       initializeSite(site);
 
+      if (path != "/setup" && !site.site_view.local_site.site_setup) {
+        return res.redirect("/setup");
+      }
+
       if (site) {
         const initialFetchReq: InitialFetchRequest = {
           client,
@@ -160,23 +170,25 @@ server.get("/*", async (req, res) => {
         };
 
         if (activeRoute?.fetchInitialData) {
-          routeData = await Promise.all([
-            ...activeRoute.fetchInitialData(initialFetchReq),
-          ]);
+          routeData.push(
+            ...(await Promise.all([
+              ...activeRoute.fetchInitialData(initialFetchReq),
+            ]))
+          );
         }
       }
-    } catch (error) {
-      errorPageData = getErrorPageData(error, site);
+    } else if (try_site.state === "failed") {
+      errorPageData = getErrorPageData(new Error(try_site.msg), site);
     }
 
     // Redirect to the 404 if there's an API error
-    if (routeData[0] && routeData[0].error) {
-      const error = routeData[0].error;
+    if (routeData[0] && routeData[0].state === "failed") {
+      const error = routeData[0].msg;
       console.error(error);
       if (error === "instance_is_private") {
         return res.redirect(`/signup`);
       } else {
-        errorPageData = getErrorPageData(error, site);
+        errorPageData = getErrorPageData(new Error(error), site);
       }
     }
 
@@ -234,7 +246,7 @@ process.on("SIGINT", () => {
   process.exit(0);
 });
 
-const iconSizes = [72, 96, 128, 144, 152, 192, 384, 512];
+const iconSizes = [72, 96, 144, 192, 512];
 const defaultLogoPathDirectory = path.join(
   process.cwd(),
   "dist",
@@ -242,12 +254,15 @@ const defaultLogoPathDirectory = path.join(
   "icons"
 );
 
-export async function generateManifestBase64(site: Site) {
-  const url = (
-    process.env.NODE_ENV === "development"
-      ? "http://localhost:1236/"
-      : getHttpBase()
-  ).replace(/\/$/g, "");
+export async function generateManifestBase64({
+  my_user,
+  site_view: {
+    site,
+    local_site: { community_creation_admin_only },
+  },
+}: GetSiteResponse) {
+  const url = getHttpBaseExternal();
+
   const icon = site.icon ? await fetchIconPng(site.icon) : null;
 
   const manifest = {
@@ -281,15 +296,58 @@ export async function generateManifestBase64(site: Site) {
         };
       })
     ),
+    shortcuts: [
+      {
+        name: "Search",
+        short_name: "Search",
+        description: "Perform a search.",
+        url: "/search",
+      },
+      {
+        name: "Communities",
+        url: "/communities",
+        short_name: "Communities",
+        description: "Browse communities",
+      },
+    ]
+      .concat(
+        my_user
+          ? [
+              {
+                name: "Create Post",
+                url: "/create_post",
+                short_name: "Create Post",
+                description: "Create a post.",
+              },
+            ]
+          : []
+      )
+      .concat(
+        my_user?.local_user_view.person.admin || !community_creation_admin_only
+          ? [
+              {
+                name: "Create Community",
+                url: "/create_community",
+                short_name: "Create Community",
+                description: "Create a community",
+              },
+            ]
+          : []
+      ),
+    related_applications: [
+      {
+        platform: "f-droid",
+        url: "https://f-droid.org/packages/com.jerboa/",
+        id: "com.jerboa",
+      },
+    ],
   };
 
   return Buffer.from(JSON.stringify(manifest)).toString("base64");
 }
 
 async function fetchIconPng(iconUrl: string) {
-  return await fetch(
-    iconUrl.replace(/https?:\/\/[^\/]+/g, getHttpBaseInternal())
-  )
+  return await fetch(iconUrl)
     .then(res => res.blob())
     .then(blob => blob.arrayBuffer());
 }
@@ -376,9 +434,9 @@ async function createSsrHtml(root: string, isoData: IsoDataOptionalSite) {
     site &&
     `<link
         rel="manifest"
-        href={${`data:application/manifest+json;base64,${await generateManifestBase64(
-          site.site_view.site
-        )}`}}
+        href=${`data:application/manifest+json;base64,${await generateManifestBase64(
+          site
+        )}`}
       />`
   }
   <link rel="apple-touch-icon" href=${appleTouchIcon} />
index 751bf9bd1968a81203b416c10a1765f4f8865eb4..bdbac9ff4818adbb80144599b121ad39296e69b0 100644 (file)
@@ -1,35 +1,23 @@
 import { Component, createRef, linkEvent } from "inferno";
 import { NavLink } from "inferno-router";
 import {
-  CommentResponse,
-  GetReportCount,
   GetReportCountResponse,
   GetSiteResponse,
-  GetUnreadCount,
   GetUnreadCountResponse,
-  GetUnreadRegistrationApplicationCount,
   GetUnreadRegistrationApplicationCountResponse,
-  PrivateMessageResponse,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
-import { UserService, WebSocketService } from "../../services";
+import { UserService } from "../../services";
+import { HttpService, RequestState } from "../../services/HttpService";
 import {
   amAdmin,
   canCreateCommunity,
   donateLemmyUrl,
   isBrowser,
   myAuth,
-  notifyComment,
-  notifyPrivateMessage,
   numToSI,
   showAvatars,
   toast,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { Icon } from "../common/icon";
 import { PictrsImage } from "../common/pictrs-image";
@@ -39,14 +27,16 @@ interface NavbarProps {
 }
 
 interface NavbarState {
-  unreadInboxCount: number;
-  unreadReportCount: number;
-  unreadApplicationCount: number;
+  unreadInboxCountRes: RequestState<GetUnreadCountResponse>;
+  unreadReportCountRes: RequestState<GetReportCountResponse>;
+  unreadApplicationCountRes: RequestState<GetUnreadRegistrationApplicationCountResponse>;
   onSiteBanner?(url: string): any;
 }
 
 function handleCollapseClick(i: Navbar) {
-  i.collapseButtonRef.current?.click();
+  if (i.collapseButtonRef.current?.ariaExpanded === "true") {
+    i.collapseButtonRef.current?.click();
+  }
 }
 
 function handleLogOut(i: Navbar) {
@@ -55,77 +45,42 @@ function handleLogOut(i: Navbar) {
 }
 
 export class Navbar extends Component<NavbarProps, NavbarState> {
-  private wsSub: Subscription;
-  private userSub: Subscription;
-  private unreadInboxCountSub: Subscription;
-  private unreadReportCountSub: Subscription;
-  private unreadApplicationCountSub: Subscription;
   state: NavbarState = {
-    unreadInboxCount: 0,
-    unreadReportCount: 0,
-    unreadApplicationCount: 0,
+    unreadInboxCountRes: { state: "empty" },
+    unreadReportCountRes: { state: "empty" },
+    unreadApplicationCountRes: { state: "empty" },
   };
-  subscription: any;
   collapseButtonRef = createRef<HTMLButtonElement>();
   mobileMenuRef = createRef<HTMLDivElement>();
 
   constructor(props: any, context: any) {
     super(props, context);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
     this.handleOutsideMenuClick = this.handleOutsideMenuClick.bind(this);
   }
 
-  componentDidMount() {
+  async componentDidMount() {
     // Subscribe to jwt changes
     if (isBrowser()) {
       // On the first load, check the unreads
-      const auth = myAuth(false);
-      if (auth && UserService.Instance.myUserInfo) {
-        this.requestNotificationPermission();
-        WebSocketService.Instance.send(
-          wsClient.userJoin({
-            auth,
-          })
-        );
-
-        this.fetchUnreads();
-      }
-
+      this.requestNotificationPermission();
+      await this.fetchUnreads();
       this.requestNotificationPermission();
 
-      // Subscribe to unread count changes
-      this.unreadInboxCountSub =
-        UserService.Instance.unreadInboxCountSub.subscribe(res => {
-          this.setState({ unreadInboxCount: res });
-        });
-      // Subscribe to unread report count changes
-      this.unreadReportCountSub =
-        UserService.Instance.unreadReportCountSub.subscribe(res => {
-          this.setState({ unreadReportCount: res });
-        });
-      // Subscribe to unread application count
-      this.unreadApplicationCountSub =
-        UserService.Instance.unreadApplicationCountSub.subscribe(res => {
-          this.setState({ unreadApplicationCount: res });
-        });
-
-      document.addEventListener("click", this.handleOutsideMenuClick);
+      document.addEventListener("mouseup", this.handleOutsideMenuClick);
     }
   }
 
   componentWillUnmount() {
-    this.wsSub.unsubscribe();
-    this.userSub.unsubscribe();
-    this.unreadInboxCountSub.unsubscribe();
-    this.unreadReportCountSub.unsubscribe();
-    this.unreadApplicationCountSub.unsubscribe();
-    document.removeEventListener("click", this.handleOutsideMenuClick);
+    document.removeEventListener("mouseup", this.handleOutsideMenuClick);
   }
 
-  // TODO class active corresponding to current page
   render() {
+    return this.navbar();
+  }
+
+  // TODO class active corresponding to current page
+  navbar() {
     const siteView = this.props.siteRes?.site_view;
     const person = UserService.Instance.myUserInfo?.local_user_view.person;
     return (
@@ -148,15 +103,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
                 to="/inbox"
                 className="p-1 nav-link border-0"
                 title={i18n.t("unread_messages", {
-                  count: Number(this.state.unreadInboxCount),
-                  formattedCount: numToSI(this.state.unreadInboxCount),
+                  count: Number(this.state.unreadApplicationCountRes.state),
+                  formattedCount: numToSI(this.unreadInboxCount),
                 })}
                 onMouseUp={linkEvent(this, handleCollapseClick)}
               >
                 <Icon icon="bell" />
-                {this.state.unreadInboxCount > 0 && (
+                {this.unreadInboxCount > 0 && (
                   <span className="mx-1 badge badge-light">
-                    {numToSI(this.state.unreadInboxCount)}
+                    {numToSI(this.unreadInboxCount)}
                   </span>
                 )}
               </NavLink>
@@ -167,15 +122,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
                   to="/reports"
                   className="p-1 nav-link border-0"
                   title={i18n.t("unread_reports", {
-                    count: Number(this.state.unreadReportCount),
-                    formattedCount: numToSI(this.state.unreadReportCount),
+                    count: Number(this.unreadReportCount),
+                    formattedCount: numToSI(this.unreadReportCount),
                   })}
                   onMouseUp={linkEvent(this, handleCollapseClick)}
                 >
                   <Icon icon="shield" />
-                  {this.state.unreadReportCount > 0 && (
+                  {this.unreadReportCount > 0 && (
                     <span className="mx-1 badge badge-light">
-                      {numToSI(this.state.unreadReportCount)}
+                      {numToSI(this.unreadReportCount)}
                     </span>
                   )}
                 </NavLink>
@@ -187,15 +142,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
                   to="/registration_applications"
                   className="p-1 nav-link border-0"
                   title={i18n.t("unread_registration_applications", {
-                    count: Number(this.state.unreadApplicationCount),
-                    formattedCount: numToSI(this.state.unreadApplicationCount),
+                    count: Number(this.unreadApplicationCount),
+                    formattedCount: numToSI(this.unreadApplicationCount),
                   })}
                   onMouseUp={linkEvent(this, handleCollapseClick)}
                 >
                   <Icon icon="clipboard" />
-                  {this.state.unreadApplicationCount > 0 && (
+                  {this.unreadApplicationCount > 0 && (
                     <span className="mx-1 badge badge-light">
-                      {numToSI(this.state.unreadApplicationCount)}
+                      {numToSI(this.unreadApplicationCount)}
                     </span>
                   )}
                 </NavLink>
@@ -272,20 +227,16 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
             </li>
           </ul>
           <ul className="navbar-nav">
-            {!this.context.router.history.location.pathname.match(
-              /^\/search/
-            ) && (
-              <li className="nav-item">
-                <NavLink
-                  to="/search"
-                  className="nav-link"
-                  title={i18n.t("search")}
-                  onMouseUp={linkEvent(this, handleCollapseClick)}
-                >
-                  <Icon icon="search" />
-                </NavLink>
-              </li>
-            )}
+            <li className="nav-item">
+              <NavLink
+                to="/search"
+                className="nav-link"
+                title={i18n.t("search")}
+                onMouseUp={linkEvent(this, handleCollapseClick)}
+              >
+                <Icon icon="search" />
+              </NavLink>
+            </li>
             {amAdmin() && (
               <li className="nav-item">
                 <NavLink
@@ -305,15 +256,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
                     className="nav-link"
                     to="/inbox"
                     title={i18n.t("unread_messages", {
-                      count: Number(this.state.unreadInboxCount),
-                      formattedCount: numToSI(this.state.unreadInboxCount),
+                      count: Number(this.unreadInboxCount),
+                      formattedCount: numToSI(this.unreadInboxCount),
                     })}
                     onMouseUp={linkEvent(this, handleCollapseClick)}
                   >
                     <Icon icon="bell" />
-                    {this.state.unreadInboxCount > 0 && (
-                      <span className="ml-1 badge badge-light">
-                        {numToSI(this.state.unreadInboxCount)}
+                    {this.unreadInboxCount > 0 && (
+                      <span className="mx-1 badge badge-light">
+                        {numToSI(this.unreadInboxCount)}
                       </span>
                     )}
                   </NavLink>
@@ -324,15 +275,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
                       className="nav-link"
                       to="/reports"
                       title={i18n.t("unread_reports", {
-                        count: Number(this.state.unreadReportCount),
-                        formattedCount: numToSI(this.state.unreadReportCount),
+                        count: Number(this.unreadReportCount),
+                        formattedCount: numToSI(this.unreadReportCount),
                       })}
                       onMouseUp={linkEvent(this, handleCollapseClick)}
                     >
                       <Icon icon="shield" />
-                      {this.state.unreadReportCount > 0 && (
-                        <span className="ml-1 badge badge-light">
-                          {numToSI(this.state.unreadReportCount)}
+                      {this.unreadReportCount > 0 && (
+                        <span className="mx-1 badge badge-light">
+                          {numToSI(this.unreadReportCount)}
                         </span>
                       )}
                     </NavLink>
@@ -344,17 +295,15 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
                       to="/registration_applications"
                       className="nav-link"
                       title={i18n.t("unread_registration_applications", {
-                        count: Number(this.state.unreadApplicationCount),
-                        formattedCount: numToSI(
-                          this.state.unreadApplicationCount
-                        ),
+                        count: Number(this.unreadApplicationCount),
+                        formattedCount: numToSI(this.unreadApplicationCount),
                       })}
                       onMouseUp={linkEvent(this, handleCollapseClick)}
                     >
                       <Icon icon="clipboard" />
-                      {this.state.unreadApplicationCount > 0 && (
+                      {this.unreadApplicationCount > 0 && (
                         <span className="mx-1 badge badge-light">
-                          {numToSI(this.state.unreadApplicationCount)}
+                          {numToSI(this.unreadApplicationCount)}
                         </span>
                       )}
                     </NavLink>
@@ -457,122 +406,70 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
     return amAdmin() || moderatesS;
   }
 
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      if (msg.error == "not_logged_in") {
-        UserService.Instance.logout();
-      }
-      return;
-    } else if (msg.reconnect) {
-      console.log(i18n.t("websocket_reconnected"));
-      const auth = myAuth(false);
-      if (UserService.Instance.myUserInfo && auth) {
-        WebSocketService.Instance.send(
-          wsClient.userJoin({
-            auth,
-          })
-        );
-        this.fetchUnreads();
-      }
-    } else if (op == UserOperation.GetUnreadCount) {
-      const data = wsJsonToRes<GetUnreadCountResponse>(msg);
-      this.setState({
-        unreadInboxCount: data.replies + data.mentions + data.private_messages,
-      });
-      this.sendUnreadCount();
-    } else if (op == UserOperation.GetReportCount) {
-      const data = wsJsonToRes<GetReportCountResponse>(msg);
+  async fetchUnreads() {
+    const auth = myAuth();
+    if (auth) {
+      this.setState({ unreadInboxCountRes: { state: "loading" } });
       this.setState({
-        unreadReportCount:
-          data.post_reports +
-          data.comment_reports +
-          (data.private_message_reports ?? 0),
+        unreadInboxCountRes: await HttpService.client.getUnreadCount({
+          auth,
+        }),
       });
-      this.sendReportUnread();
-    } else if (op == UserOperation.GetUnreadRegistrationApplicationCount) {
-      const data =
-        wsJsonToRes<GetUnreadRegistrationApplicationCountResponse>(msg);
-      this.setState({ unreadApplicationCount: data.registration_applications });
-      this.sendApplicationUnread();
-    } else if (op == UserOperation.CreateComment) {
-      const data = wsJsonToRes<CommentResponse>(msg);
-      const mui = UserService.Instance.myUserInfo;
-      if (
-        mui &&
-        data.recipient_ids.includes(mui.local_user_view.local_user.id)
-      ) {
+
+      if (this.moderatesSomething) {
+        this.setState({ unreadReportCountRes: { state: "loading" } });
         this.setState({
-          unreadInboxCount: this.state.unreadInboxCount + 1,
+          unreadReportCountRes: await HttpService.client.getReportCount({
+            auth,
+          }),
         });
-        this.sendUnreadCount();
-        notifyComment(data.comment_view, this.context.router);
       }
-    } else if (op == UserOperation.CreatePrivateMessage) {
-      const data = wsJsonToRes<PrivateMessageResponse>(msg);
 
-      if (
-        data.private_message_view.recipient.id ==
-        UserService.Instance.myUserInfo?.local_user_view.person.id
-      ) {
+      if (amAdmin()) {
+        this.setState({ unreadApplicationCountRes: { state: "loading" } });
         this.setState({
-          unreadInboxCount: this.state.unreadInboxCount + 1,
+          unreadApplicationCountRes:
+            await HttpService.client.getUnreadRegistrationApplicationCount({
+              auth,
+            }),
         });
-        this.sendUnreadCount();
-        notifyPrivateMessage(data.private_message_view, this.context.router);
       }
     }
   }
 
-  fetchUnreads() {
-    console.log("Fetching inbox unreads...");
-
-    const auth = myAuth();
-    if (auth) {
-      const unreadForm: GetUnreadCount = {
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.getUnreadCount(unreadForm));
-
-      console.log("Fetching reports...");
-
-      const reportCountForm: GetReportCount = {
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.getReportCount(reportCountForm));
-
-      if (amAdmin()) {
-        console.log("Fetching applications...");
-
-        const applicationCountForm: GetUnreadRegistrationApplicationCount = {
-          auth,
-        };
-        WebSocketService.Instance.send(
-          wsClient.getUnreadRegistrationApplicationCount(applicationCountForm)
-        );
-      }
+  get unreadInboxCount(): number {
+    if (this.state.unreadInboxCountRes.state == "success") {
+      const data = this.state.unreadInboxCountRes.data;
+      return data.replies + data.mentions + data.private_messages;
+    } else {
+      return 0;
     }
   }
 
-  get currentLocation() {
-    return this.context.router.history.location.pathname;
-  }
-
-  sendUnreadCount() {
-    UserService.Instance.unreadInboxCountSub.next(this.state.unreadInboxCount);
+  get unreadReportCount(): number {
+    if (this.state.unreadReportCountRes.state == "success") {
+      const data = this.state.unreadReportCountRes.data;
+      return (
+        data.post_reports +
+        data.comment_reports +
+        (data.private_message_reports ?? 0)
+      );
+    } else {
+      return 0;
+    }
   }
 
-  sendReportUnread() {
-    UserService.Instance.unreadReportCountSub.next(
-      this.state.unreadReportCount
-    );
+  get unreadApplicationCount(): number {
+    if (this.state.unreadApplicationCountRes.state == "success") {
+      const data = this.state.unreadApplicationCountRes.data;
+      return data.registration_applications;
+    } else {
+      return 0;
+    }
   }
 
-  sendApplicationUnread() {
-    UserService.Instance.unreadApplicationCountSub.next(
-      this.state.unreadApplicationCount
-    );
+  get currentLocation() {
+    return this.context.router.history.location.pathname;
   }
 
   requestNotificationPermission() {
index 42ed226d28cc9c0cf9089774ad28091bc2126440..c60cde20161407905dd8f3ad977423e60c94392d 100644 (file)
@@ -1,25 +1,11 @@
 import { Component } from "inferno";
 import { T } from "inferno-i18next-dess";
 import { Link } from "inferno-router";
-import {
-  CommentResponse,
-  CreateComment,
-  EditComment,
-  Language,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
-} from "lemmy-js-client";
-import { Subscription } from "rxjs";
+import { CreateComment, EditComment, Language } from "lemmy-js-client";
 import { i18n } from "../../i18next";
 import { CommentNodeI } from "../../interfaces";
-import { UserService, WebSocketService } from "../../services";
-import {
-  capitalizeFirstLetter,
-  myAuth,
-  wsClient,
-  wsSubscribe,
-} from "../../utils";
+import { UserService } from "../../services";
+import { capitalizeFirstLetter, myAuthRequired } from "../../utils";
 import { Icon } from "../common/icon";
 import { MarkdownTextArea } from "../common/markdown-textarea";
 
@@ -28,44 +14,21 @@ interface CommentFormProps {
    * Can either be the parent, or the editable comment. The right side is a postId.
    */
   node: CommentNodeI | number;
+  finished?: boolean;
   edit?: boolean;
   disabled?: boolean;
   focus?: boolean;
-  onReplyCancel?(): any;
+  onReplyCancel?(): void;
   allLanguages: Language[];
   siteLanguages: number[];
+  onUpsertComment(form: EditComment | CreateComment): void;
 }
 
-interface CommentFormState {
-  buttonTitle: string;
-  finished: boolean;
-  formId?: string;
-}
-
-export class CommentForm extends Component<CommentFormProps, CommentFormState> {
-  private subscription?: Subscription;
-  state: CommentFormState = {
-    buttonTitle:
-      typeof this.props.node === "number"
-        ? capitalizeFirstLetter(i18n.t("post"))
-        : this.props.edit
-        ? capitalizeFirstLetter(i18n.t("save"))
-        : capitalizeFirstLetter(i18n.t("reply")),
-    finished: false,
-  };
-
+export class CommentForm extends Component<CommentFormProps, any> {
   constructor(props: any, context: any) {
     super(props, context);
 
     this.handleCommentSubmit = this.handleCommentSubmit.bind(this);
-    this.handleReplyCancel = this.handleReplyCancel.bind(this);
-
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
-  }
-
-  componentWillUnmount() {
-    this.subscription?.unsubscribe();
   }
 
   render() {
@@ -82,13 +45,13 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
           <MarkdownTextArea
             initialContent={initialContent}
             showLanguage
-            buttonTitle={this.state.buttonTitle}
-            finished={this.state.finished}
+            buttonTitle={this.buttonTitle}
+            finished={this.props.finished}
             replyType={typeof this.props.node !== "number"}
             focus={this.props.focus}
             disabled={this.props.disabled}
             onSubmit={this.handleCommentSubmit}
-            onReplyCancel={this.handleReplyCancel}
+            onReplyCancel={this.props.onReplyCancel}
             placeholder={i18n.t("comment_here")}
             allLanguages={this.props.allLanguages}
             siteLanguages={this.props.siteLanguages}
@@ -108,77 +71,46 @@ export class CommentForm extends Component<CommentFormProps, CommentFormState> {
     );
   }
 
-  handleCommentSubmit(msg: {
-    val: string;
-    formId: string;
-    languageId?: number;
-  }) {
-    const content = msg.val;
-    const language_id = msg.languageId;
-    const node = this.props.node;
-
-    this.setState({ formId: msg.formId });
+  get buttonTitle(): string {
+    return typeof this.props.node === "number"
+      ? capitalizeFirstLetter(i18n.t("post"))
+      : this.props.edit
+      ? capitalizeFirstLetter(i18n.t("save"))
+      : capitalizeFirstLetter(i18n.t("reply"));
+  }
 
-    const auth = myAuth();
-    if (auth) {
-      if (typeof node === "number") {
-        const postId = node;
-        const form: CreateComment = {
+  handleCommentSubmit(content: string, form_id: string, language_id?: number) {
+    const { node, onUpsertComment, edit } = this.props;
+    if (typeof node === "number") {
+      const post_id = node;
+      onUpsertComment({
+        content,
+        post_id,
+        language_id,
+        form_id,
+        auth: myAuthRequired(),
+      });
+    } else {
+      if (edit) {
+        const comment_id = node.comment_view.comment.id;
+        onUpsertComment({
           content,
-          form_id: this.state.formId,
-          post_id: postId,
+          comment_id,
+          form_id,
           language_id,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.createComment(form));
+          auth: myAuthRequired(),
+        });
       } else {
-        if (this.props.edit) {
-          const form: EditComment = {
-            content,
-            form_id: this.state.formId,
-            comment_id: node.comment_view.comment.id,
-            language_id,
-            auth,
-          };
-          WebSocketService.Instance.send(wsClient.editComment(form));
-        } else {
-          const form: CreateComment = {
-            content,
-            form_id: this.state.formId,
-            post_id: node.comment_view.post.id,
-            parent_id: node.comment_view.comment.id,
-            language_id,
-            auth,
-          };
-          WebSocketService.Instance.send(wsClient.createComment(form));
-        }
-      }
-    }
-  }
-
-  handleReplyCancel() {
-    this.props.onReplyCancel?.();
-  }
-
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-
-    // Only do the showing and hiding if logged in
-    if (UserService.Instance.myUserInfo) {
-      if (
-        op == UserOperation.CreateComment ||
-        op == UserOperation.EditComment
-      ) {
-        const data = wsJsonToRes<CommentResponse>(msg);
-
-        // This only finishes this form, if the randomly generated form_id matches the one received
-        if (this.state.formId && this.state.formId == data.form_id) {
-          this.setState({ finished: true });
-
-          // Necessary because it broke tribute for some reason
-          this.setState({ finished: false });
-        }
+        const post_id = node.comment_view.post.id;
+        const parent_id = node.comment_view.comment.id;
+        this.props.onUpsertComment({
+          content,
+          parent_id,
+          post_id,
+          form_id,
+          language_id,
+          auth: myAuthRequired(),
+        });
       }
     }
   }
index f80cc8b595d0919035477710e0a6e6eab7f3df01..8559f38baa1355d0809ed3766d34f328a35dc639 100644 (file)
@@ -1,5 +1,5 @@
 import classNames from "classnames";
-import { Component, linkEvent } from "inferno";
+import { Component, InfernoNode, linkEvent } from "inferno";
 import { Link } from "inferno-router";
 import {
   AddAdmin,
@@ -7,13 +7,16 @@ import {
   BanFromCommunity,
   BanPerson,
   BlockPerson,
+  CommentId,
   CommentReplyView,
   CommentView,
   CommunityModeratorView,
+  CreateComment,
   CreateCommentLike,
   CreateCommentReport,
   DeleteComment,
   DistinguishComment,
+  EditComment,
   GetComments,
   Language,
   MarkCommentReplyAsRead,
@@ -33,8 +36,9 @@ import {
   CommentNodeI,
   CommentViewType,
   PurgeType,
+  VoteType,
 } from "../../interfaces";
-import { UserService, WebSocketService } from "../../services";
+import { UserService } from "../../services";
 import {
   amCommunityCreator,
   canAdmin,
@@ -49,10 +53,11 @@ import {
   mdToHtml,
   mdToHtmlNoImages,
   myAuth,
+  myAuthRequired,
+  newVote,
   numToSI,
   setupTippy,
   showScores,
-  wsClient,
 } from "../../utils";
 import { Icon, PurgeWarning, Spinner } from "../common/icon";
 import { MomentTime } from "../common/moment-time";
@@ -74,7 +79,6 @@ interface CommentNodeState {
   showPurgeDialog: boolean;
   purgeReason?: string;
   purgeType: PurgeType;
-  purgeLoading: boolean;
   showConfirmTransferSite: boolean;
   showConfirmTransferCommunity: boolean;
   showConfirmAppointAsMod: boolean;
@@ -84,12 +88,22 @@ interface CommentNodeState {
   showAdvanced: boolean;
   showReportDialog: boolean;
   reportReason?: string;
-  my_vote?: number;
-  score: number;
-  upvotes: number;
-  downvotes: number;
-  readLoading: boolean;
+  createOrEditCommentLoading: boolean;
+  upvoteLoading: boolean;
+  downvoteLoading: boolean;
   saveLoading: boolean;
+  readLoading: boolean;
+  blockPersonLoading: boolean;
+  deleteLoading: boolean;
+  removeLoading: boolean;
+  distinguishLoading: boolean;
+  banLoading: boolean;
+  addModLoading: boolean;
+  addAdminLoading: boolean;
+  transferCommunityLoading: boolean;
+  fetchChildrenLoading: boolean;
+  reportLoading: boolean;
+  purgeLoading: boolean;
 }
 
 interface CommentNodeProps {
@@ -108,6 +122,26 @@ interface CommentNodeProps {
   allLanguages: Language[];
   siteLanguages: number[];
   hideImages?: boolean;
+  finished: Map<CommentId, boolean | undefined>;
+  onSaveComment(form: SaveComment): void;
+  onCommentReplyRead(form: MarkCommentReplyAsRead): void;
+  onPersonMentionRead(form: MarkPersonMentionAsRead): void;
+  onCreateComment(form: EditComment | CreateComment): void;
+  onEditComment(form: EditComment | CreateComment): void;
+  onCommentVote(form: CreateCommentLike): void;
+  onBlockPerson(form: BlockPerson): void;
+  onDeleteComment(form: DeleteComment): void;
+  onRemoveComment(form: RemoveComment): void;
+  onDistinguishComment(form: DistinguishComment): void;
+  onAddModToCommunity(form: AddModToCommunity): void;
+  onAddAdmin(form: AddAdmin): void;
+  onBanPersonFromCommunity(form: BanFromCommunity): void;
+  onBanPerson(form: BanPerson): void;
+  onTransferCommunity(form: TransferCommunity): void;
+  onFetchChildren?(form: GetComments): void;
+  onCommentReport(form: CreateCommentReport): void;
+  onPurgePerson(form: PurgePerson): void;
+  onPurgeComment(form: PurgeComment): void;
 }
 
 export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
@@ -119,7 +153,6 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     removeData: false,
     banType: BanType.Community,
     showPurgeDialog: false,
-    purgeLoading: false,
     purgeType: PurgeType.Person,
     collapsed: false,
     viewSource: false,
@@ -129,67 +162,109 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     showConfirmAppointAsMod: false,
     showConfirmAppointAsAdmin: false,
     showReportDialog: false,
-    my_vote: this.props.node.comment_view.my_vote,
-    score: this.props.node.comment_view.counts.score,
-    upvotes: this.props.node.comment_view.counts.upvotes,
-    downvotes: this.props.node.comment_view.counts.downvotes,
-    readLoading: false,
+    createOrEditCommentLoading: false,
+    upvoteLoading: false,
+    downvoteLoading: false,
     saveLoading: false,
+    readLoading: false,
+    blockPersonLoading: false,
+    deleteLoading: false,
+    removeLoading: false,
+    distinguishLoading: false,
+    banLoading: false,
+    addModLoading: false,
+    addAdminLoading: false,
+    transferCommunityLoading: false,
+    fetchChildrenLoading: false,
+    reportLoading: false,
+    purgeLoading: false,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
 
     this.handleReplyCancel = this.handleReplyCancel.bind(this);
-    this.handleCommentUpvote = this.handleCommentUpvote.bind(this);
-    this.handleCommentDownvote = this.handleCommentDownvote.bind(this);
   }
 
-  // TODO see if there's a better way to do this, and all willReceiveProps
-  componentWillReceiveProps(nextProps: CommentNodeProps) {
-    const cv = nextProps.node.comment_view;
-    this.setState({
-      my_vote: cv.my_vote,
-      upvotes: cv.counts.upvotes,
-      downvotes: cv.counts.downvotes,
-      score: cv.counts.score,
-      readLoading: false,
-      saveLoading: false,
-    });
+  get commentView(): CommentView {
+    return this.props.node.comment_view;
+  }
+
+  get commentId(): CommentId {
+    return this.commentView.comment.id;
+  }
+
+  componentWillReceiveProps(
+    nextProps: Readonly<{ children?: InfernoNode } & CommentNodeProps>
+  ): void {
+    if (this.props != nextProps) {
+      this.setState({
+        showReply: false,
+        showEdit: false,
+        showRemoveDialog: false,
+        showBanDialog: false,
+        removeData: false,
+        banType: BanType.Community,
+        showPurgeDialog: false,
+        purgeType: PurgeType.Person,
+        collapsed: false,
+        viewSource: false,
+        showAdvanced: false,
+        showConfirmTransferSite: false,
+        showConfirmTransferCommunity: false,
+        showConfirmAppointAsMod: false,
+        showConfirmAppointAsAdmin: false,
+        showReportDialog: false,
+        createOrEditCommentLoading: false,
+        upvoteLoading: false,
+        downvoteLoading: false,
+        saveLoading: false,
+        readLoading: false,
+        blockPersonLoading: false,
+        deleteLoading: false,
+        removeLoading: false,
+        distinguishLoading: false,
+        banLoading: false,
+        addModLoading: false,
+        addAdminLoading: false,
+        transferCommunityLoading: false,
+        fetchChildrenLoading: false,
+        reportLoading: false,
+        purgeLoading: false,
+      });
+    }
   }
 
   render() {
     const node = this.props.node;
-    const cv = this.props.node.comment_view;
+    const cv = this.commentView;
 
     const purgeTypeText =
       this.state.purgeType == PurgeType.Comment
         ? i18n.t("purge_comment")
         : `${i18n.t("purge")} ${cv.creator.name}`;
 
-    const canMod_ =
-      canMod(cv.creator.id, this.props.moderators, this.props.admins) &&
-      cv.community.local;
-    const canModOnSelf =
-      canMod(
-        cv.creator.id,
-        this.props.moderators,
-        this.props.admins,
-        UserService.Instance.myUserInfo,
-        true
-      ) && cv.community.local;
-    const canAdmin_ =
-      canAdmin(cv.creator.id, this.props.admins) && cv.community.local;
-    const canAdminOnSelf =
-      canAdmin(
-        cv.creator.id,
-        this.props.admins,
-        UserService.Instance.myUserInfo,
-        true
-      ) && cv.community.local;
+    const canMod_ = canMod(
+      cv.creator.id,
+      this.props.moderators,
+      this.props.admins
+    );
+    const canModOnSelf = canMod(
+      cv.creator.id,
+      this.props.moderators,
+      this.props.admins,
+      UserService.Instance.myUserInfo,
+      true
+    );
+    const canAdmin_ = canAdmin(cv.creator.id, this.props.admins);
+    const canAdminOnSelf = canAdmin(
+      cv.creator.id,
+      this.props.admins,
+      UserService.Instance.myUserInfo,
+      true
+    );
     const isMod_ = isMod(cv.creator.id, this.props.moderators);
-    const isAdmin_ =
-      isAdmin(cv.creator.id, this.props.admins) && cv.community.local;
+    const isAdmin_ = isAdmin(cv.creator.id, this.props.admins);
     const amCommunityCreator_ = amCommunityCreator(
       cv.creator.id,
       this.props.moderators
@@ -218,9 +293,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
           id={`comment-${cv.comment.id}`}
           className={classNames(`details comment-node py-2`, {
             "border-top border-light": !this.props.noBorder,
-            mark:
-              this.isCommentNew ||
-              this.props.node.comment_view.comment.distinguished,
+            mark: this.isCommentNew || this.commentView.comment.distinguished,
           })}
           style={
             !this.props.noIndent && this.props.node.depth
@@ -297,18 +370,24 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                 <>
                   <a
                     className={`unselectable pointer ${this.scoreColor}`}
-                    onClick={this.handleCommentUpvote}
+                    onClick={linkEvent(this, this.handleUpvote)}
                     data-tippy-content={this.pointsTippy}
                   >
-                    <span
-                      className="mr-1 font-weight-bold"
-                      aria-label={i18n.t("number_of_points", {
-                        count: Number(this.state.score),
-                        formattedCount: numToSI(this.state.score),
-                      })}
-                    >
-                      {numToSI(this.state.score)}
-                    </span>
+                    {this.state.upvoteLoading ? (
+                      <Spinner />
+                    ) : (
+                      <span
+                        className="mr-1 font-weight-bold"
+                        aria-label={i18n.t("number_of_points", {
+                          count: Number(this.commentView.counts.score),
+                          formattedCount: numToSI(
+                            this.commentView.counts.score
+                          ),
+                        })}
+                      >
+                        {numToSI(this.commentView.counts.score)}
+                      </span>
+                    )}
                   </a>
                   <span className="mr-1">•</span>
                 </>
@@ -327,9 +406,13 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                 edit
                 onReplyCancel={this.handleReplyCancel}
                 disabled={this.props.locked}
+                finished={this.props.finished.get(
+                  this.props.node.comment_view.comment.id
+                )}
                 focus
                 allLanguages={this.props.allLanguages}
                 siteLanguages={this.props.siteLanguages}
+                onUpsertComment={this.props.onEditComment}
               />
             )}
             {!this.state.showEdit && !this.state.collapsed && (
@@ -351,7 +434,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                   {this.props.markable && (
                     <button
                       className="btn btn-link btn-animate text-muted"
-                      onClick={linkEvent(this, this.handleMarkRead)}
+                      onClick={linkEvent(this, this.handleMarkAsRead)}
                       data-tippy-content={
                         this.commentReplyOrMentionRead
                           ? i18n.t("mark_as_unread")
@@ -364,7 +447,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                       }
                     >
                       {this.state.readLoading ? (
-                        this.loadingIcon
+                        <Spinner />
                       ) : (
                         <Icon
                           icon="check"
@@ -379,40 +462,56 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                     <>
                       <button
                         className={`btn btn-link btn-animate ${
-                          this.state.my_vote === 1 ? "text-info" : "text-muted"
+                          this.commentView.my_vote === 1
+                            ? "text-info"
+                            : "text-muted"
                         }`}
-                        onClick={this.handleCommentUpvote}
+                        onClick={linkEvent(this, this.handleUpvote)}
                         data-tippy-content={i18n.t("upvote")}
                         aria-label={i18n.t("upvote")}
-                        aria-pressed={this.state.my_vote === 1}
+                        aria-pressed={this.commentView.my_vote === 1}
                       >
-                        <Icon icon="arrow-up1" classes="icon-inline" />
-                        {showScores() &&
-                          this.state.upvotes !== this.state.score && (
-                            <span className="ml-1">
-                              {numToSI(this.state.upvotes)}
-                            </span>
-                          )}
+                        {this.state.upvoteLoading ? (
+                          <Spinner />
+                        ) : (
+                          <>
+                            <Icon icon="arrow-up1" classes="icon-inline" />
+                            {showScores() &&
+                              this.commentView.counts.upvotes !==
+                                this.commentView.counts.score && (
+                                <span className="ml-1">
+                                  {numToSI(this.commentView.counts.upvotes)}
+                                </span>
+                              )}
+                          </>
+                        )}
                       </button>
                       {this.props.enableDownvotes && (
                         <button
                           className={`btn btn-link btn-animate ${
-                            this.state.my_vote === -1
+                            this.commentView.my_vote === -1
                               ? "text-danger"
                               : "text-muted"
                           }`}
-                          onClick={this.handleCommentDownvote}
+                          onClick={linkEvent(this, this.handleDownvote)}
                           data-tippy-content={i18n.t("downvote")}
                           aria-label={i18n.t("downvote")}
-                          aria-pressed={this.state.my_vote === -1}
+                          aria-pressed={this.commentView.my_vote === -1}
                         >
-                          <Icon icon="arrow-down1" classes="icon-inline" />
-                          {showScores() &&
-                            this.state.upvotes !== this.state.score && (
-                              <span className="ml-1">
-                                {numToSI(this.state.downvotes)}
-                              </span>
-                            )}
+                          {this.state.downvoteLoading ? (
+                            <Spinner />
+                          ) : (
+                            <>
+                              <Icon icon="arrow-down1" classes="icon-inline" />
+                              {showScores() &&
+                                this.commentView.counts.upvotes !==
+                                  this.commentView.counts.score && (
+                                  <span className="ml-1">
+                                    {numToSI(this.commentView.counts.downvotes)}
+                                  </span>
+                                )}
+                            </>
+                          )}
                         </button>
                       )}
                       <button
@@ -460,21 +559,22 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                                 className="btn btn-link btn-animate text-muted"
                                 onClick={linkEvent(
                                   this,
-                                  this.handleBlockUserClick
+                                  this.handleBlockPerson
                                 )}
                                 data-tippy-content={i18n.t("block_user")}
                                 aria-label={i18n.t("block_user")}
                               >
-                                <Icon icon="slash" />
+                                {this.state.blockPersonLoading ? (
+                                  <Spinner />
+                                ) : (
+                                  <Icon icon="slash" />
+                                )}
                               </button>
                             </>
                           )}
                           <button
                             className="btn btn-link btn-animate text-muted"
-                            onClick={linkEvent(
-                              this,
-                              this.handleSaveCommentClick
-                            )}
+                            onClick={linkEvent(this, this.handleSaveComment)}
                             data-tippy-content={
                               cv.saved ? i18n.t("unsave") : i18n.t("save")
                             }
@@ -483,7 +583,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                             }
                           >
                             {this.state.saveLoading ? (
-                              this.loadingIcon
+                              <Spinner />
                             ) : (
                               <Icon
                                 icon="star"
@@ -520,7 +620,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                                 className="btn btn-link btn-animate text-muted"
                                 onClick={linkEvent(
                                   this,
-                                  this.handleDeleteClick
+                                  this.handleDeleteComment
                                 )}
                                 data-tippy-content={
                                   !cv.comment.deleted
@@ -533,12 +633,16 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                                     : i18n.t("restore")
                                 }
                               >
-                                <Icon
-                                  icon="trash"
-                                  classes={`icon-inline ${
-                                    cv.comment.deleted && "text-danger"
-                                  }`}
-                                />
+                                {this.state.deleteLoading ? (
+                                  <Spinner />
+                                ) : (
+                                  <Icon
+                                    icon="trash"
+                                    classes={`icon-inline ${
+                                      cv.comment.deleted && "text-danger"
+                                    }`}
+                                  />
+                                )}
                               </button>
 
                               {(canModOnSelf || canAdminOnSelf) && (
@@ -546,7 +650,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                                   className="btn btn-link btn-animate text-muted"
                                   onClick={linkEvent(
                                     this,
-                                    this.handleDistinguishClick
+                                    this.handleDistinguishComment
                                   )}
                                   data-tippy-content={
                                     !cv.comment.distinguished
@@ -588,11 +692,15 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                                   className="btn btn-link btn-animate text-muted"
                                   onClick={linkEvent(
                                     this,
-                                    this.handleModRemoveSubmit
+                                    this.handleRemoveComment
                                   )}
                                   aria-label={i18n.t("restore")}
                                 >
-                                  {i18n.t("restore")}
+                                  {this.state.removeLoading ? (
+                                    <Spinner />
+                                  ) : (
+                                    i18n.t("restore")
+                                  )}
                                 </button>
                               )}
                             </>
@@ -617,11 +725,15 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                                     className="btn btn-link btn-animate text-muted"
                                     onClick={linkEvent(
                                       this,
-                                      this.handleModBanFromCommunitySubmit
+                                      this.handleBanPersonFromCommunity
                                     )}
                                     aria-label={i18n.t("unban")}
                                   >
-                                    {i18n.t("unban")}
+                                    {this.state.banLoading ? (
+                                      <Spinner />
+                                    ) : (
+                                      i18n.t("unban")
+                                    )}
                                   </button>
                                 ))}
                               {!cv.creator_banned_from_community &&
@@ -658,7 +770,11 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                                       )}
                                       aria-label={i18n.t("yes")}
                                     >
-                                      {i18n.t("yes")}
+                                      {this.state.addModLoading ? (
+                                        <Spinner />
+                                      ) : (
+                                        i18n.t("yes")
+                                      )}
                                     </button>
                                     <button
                                       className="btn btn-link btn-animate text-muted"
@@ -705,7 +821,11 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                                   )}
                                   aria-label={i18n.t("yes")}
                                 >
-                                  {i18n.t("yes")}
+                                  {this.state.transferCommunityLoading ? (
+                                    <Spinner />
+                                  ) : (
+                                    i18n.t("yes")
+                                  )}
                                 </button>
                                 <button
                                   className="btn btn-link btn-animate text-muted"
@@ -762,11 +882,15 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                                       className="btn btn-link btn-animate text-muted"
                                       onClick={linkEvent(
                                         this,
-                                        this.handleModBanSubmit
+                                        this.handleBanPerson
                                       )}
                                       aria-label={i18n.t("unban_from_site")}
                                     >
-                                      {i18n.t("unban_from_site")}
+                                      {this.state.banLoading ? (
+                                        <Spinner />
+                                      ) : (
+                                        i18n.t("unban_from_site")
+                                      )}
                                     </button>
                                   )}
                                 </>
@@ -803,7 +927,11 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                                       )}
                                       aria-label={i18n.t("yes")}
                                     >
-                                      {i18n.t("yes")}
+                                      {this.state.addAdminLoading ? (
+                                        <Spinner />
+                                      ) : (
+                                        i18n.t("yes")
+                                      )}
                                     </button>
                                     <button
                                       className="btn btn-link btn-animate text-muted"
@@ -840,11 +968,19 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
               className="btn btn-link text-muted"
               onClick={linkEvent(this, this.handleFetchChildren)}
             >
-              {i18n.t("x_more_replies", {
-                count: node.comment_view.counts.child_count,
-                formattedCount: numToSI(node.comment_view.counts.child_count),
-              })}{" "}
-              âž”
+              {this.state.fetchChildrenLoading ? (
+                <Spinner />
+              ) : (
+                <>
+                  {i18n.t("x_more_replies", {
+                    count: node.comment_view.counts.child_count,
+                    formattedCount: numToSI(
+                      node.comment_view.counts.child_count
+                    ),
+                  })}{" "}
+                  âž”
+                </>
+              )}
             </button>
           </div>
         )}
@@ -852,7 +988,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
         {this.state.showRemoveDialog && (
           <form
             className="form-inline"
-            onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
+            onSubmit={linkEvent(this, this.handleRemoveComment)}
           >
             <label
               className="sr-only"
@@ -880,7 +1016,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
         {this.state.showReportDialog && (
           <form
             className="form-inline"
-            onSubmit={linkEvent(this, this.handleReportSubmit)}
+            onSubmit={linkEvent(this, this.handleReportComment)}
           >
             <label
               className="sr-only"
@@ -967,14 +1103,20 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                 className="btn btn-secondary"
                 aria-label={i18n.t("ban")}
               >
-                {i18n.t("ban")} {cv.creator.name}
+                {this.state.banLoading ? (
+                  <Spinner />
+                ) : (
+                  <span>
+                    {i18n.t("ban")} {cv.creator.name}
+                  </span>
+                )}
               </button>
             </div>
           </form>
         )}
 
         {this.state.showPurgeDialog && (
-          <form onSubmit={linkEvent(this, this.handlePurgeSubmit)}>
+          <form onSubmit={linkEvent(this, this.handlePurgeBothSubmit)}>
             <PurgeWarning />
             <label className="sr-only" htmlFor="purge-reason">
               {i18n.t("reason")}
@@ -1007,9 +1149,13 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
             node={node}
             onReplyCancel={this.handleReplyCancel}
             disabled={this.props.locked}
+            finished={this.props.finished.get(
+              this.props.node.comment_view.comment.id
+            )}
             focus
             allLanguages={this.props.allLanguages}
             siteLanguages={this.props.siteLanguages}
+            onUpsertComment={this.props.onCreateComment}
           />
         )}
         {!this.state.collapsed && node.children.length > 0 && (
@@ -1023,6 +1169,26 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
             allLanguages={this.props.allLanguages}
             siteLanguages={this.props.siteLanguages}
             hideImages={this.props.hideImages}
+            finished={this.props.finished}
+            onCommentReplyRead={this.props.onCommentReplyRead}
+            onPersonMentionRead={this.props.onPersonMentionRead}
+            onCreateComment={this.props.onCreateComment}
+            onEditComment={this.props.onEditComment}
+            onCommentVote={this.props.onCommentVote}
+            onBlockPerson={this.props.onBlockPerson}
+            onSaveComment={this.props.onSaveComment}
+            onDeleteComment={this.props.onDeleteComment}
+            onRemoveComment={this.props.onRemoveComment}
+            onDistinguishComment={this.props.onDistinguishComment}
+            onAddModToCommunity={this.props.onAddModToCommunity}
+            onAddAdmin={this.props.onAddAdmin}
+            onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
+            onBanPerson={this.props.onBanPerson}
+            onTransferCommunity={this.props.onTransferCommunity}
+            onFetchChildren={this.props.onFetchChildren}
+            onCommentReport={this.props.onCommentReport}
+            onPurgePerson={this.props.onPurgePerson}
+            onPurgeComment={this.props.onPurgeComment}
           />
         )}
         {/* A collapsed clearfix */}
@@ -1032,7 +1198,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
   }
 
   get commentReplyOrMentionRead(): boolean {
-    const cv = this.props.node.comment_view;
+    const cv = this.commentView;
 
     if (this.isPersonMentionType(cv)) {
       return cv.person_mention.read;
@@ -1044,7 +1210,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
   }
 
   linkBtn(small = false) {
-    const cv = this.props.node.comment_view;
+    const cv = this.commentView;
     const classnames = classNames("btn btn-link btn-animate text-muted", {
       "btn-sm": small,
     });
@@ -1074,26 +1240,52 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     );
   }
 
-  get loadingIcon() {
-    return <Spinner />;
-  }
-
   get myComment(): boolean {
     return (
       UserService.Instance.myUserInfo?.local_user_view.person.id ==
-      this.props.node.comment_view.creator.id
+      this.commentView.creator.id
     );
   }
 
   get isPostCreator(): boolean {
-    return (
-      this.props.node.comment_view.creator.id ==
-      this.props.node.comment_view.post.creator_id
-    );
+    return this.commentView.creator.id == this.commentView.post.creator_id;
+  }
+
+  get scoreColor() {
+    if (this.commentView.my_vote == 1) {
+      return "text-info";
+    } else if (this.commentView.my_vote == -1) {
+      return "text-danger";
+    } else {
+      return "text-muted";
+    }
+  }
+
+  get pointsTippy(): string {
+    const points = i18n.t("number_of_points", {
+      count: Number(this.commentView.counts.score),
+      formattedCount: numToSI(this.commentView.counts.score),
+    });
+
+    const upvotes = i18n.t("number_of_upvotes", {
+      count: Number(this.commentView.counts.upvotes),
+      formattedCount: numToSI(this.commentView.counts.upvotes),
+    });
+
+    const downvotes = i18n.t("number_of_downvotes", {
+      count: Number(this.commentView.counts.downvotes),
+      formattedCount: numToSI(this.commentView.counts.downvotes),
+    });
+
+    return `${points} â€¢ ${upvotes} â€¢ ${downvotes}`;
+  }
+
+  get expandText(): string {
+    return this.state.collapsed ? i18n.t("expand") : i18n.t("collapse");
   }
 
   get commentUnlessRemoved(): string {
-    const comment = this.props.node.comment_view.comment;
+    const comment = this.commentView.comment;
     return comment.removed
       ? `*${i18n.t("removed")}*`
       : comment.deleted
@@ -1109,127 +1301,10 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     i.setState({ showEdit: true });
   }
 
-  handleBlockUserClick(i: CommentNode) {
-    const auth = myAuth();
-    if (auth) {
-      const blockUserForm: BlockPerson = {
-        person_id: i.props.node.comment_view.creator.id,
-        block: true,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
-    }
-  }
-
-  handleDeleteClick(i: CommentNode) {
-    const comment = i.props.node.comment_view.comment;
-    const auth = myAuth();
-    if (auth) {
-      const deleteForm: DeleteComment = {
-        comment_id: comment.id,
-        deleted: !comment.deleted,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.deleteComment(deleteForm));
-    }
-  }
-
-  handleSaveCommentClick(i: CommentNode) {
-    const cv = i.props.node.comment_view;
-    const save = cv.saved == undefined ? true : !cv.saved;
-    const auth = myAuth();
-    if (auth) {
-      const form: SaveComment = {
-        comment_id: cv.comment.id,
-        save,
-        auth,
-      };
-
-      WebSocketService.Instance.send(wsClient.saveComment(form));
-
-      i.setState({ saveLoading: true });
-    }
-  }
-
   handleReplyCancel() {
     this.setState({ showReply: false, showEdit: false });
   }
 
-  handleCommentUpvote(event: any) {
-    event.preventDefault();
-    const myVote = this.state.my_vote;
-    const newVote = myVote == 1 ? 0 : 1;
-
-    if (myVote == 1) {
-      this.setState({
-        score: this.state.score - 1,
-        upvotes: this.state.upvotes - 1,
-      });
-    } else if (myVote == -1) {
-      this.setState({
-        downvotes: this.state.downvotes - 1,
-        upvotes: this.state.upvotes + 1,
-        score: this.state.score + 2,
-      });
-    } else {
-      this.setState({
-        score: this.state.score + 1,
-        upvotes: this.state.upvotes + 1,
-      });
-    }
-
-    this.setState({ my_vote: newVote });
-
-    const auth = myAuth();
-    if (auth) {
-      const form: CreateCommentLike = {
-        comment_id: this.props.node.comment_view.comment.id,
-        score: newVote,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.likeComment(form));
-      setupTippy();
-    }
-  }
-
-  handleCommentDownvote(event: any) {
-    event.preventDefault();
-    const myVote = this.state.my_vote;
-    const newVote = myVote == -1 ? 0 : -1;
-
-    if (myVote == 1) {
-      this.setState({
-        downvotes: this.state.downvotes + 1,
-        upvotes: this.state.upvotes - 1,
-        score: this.state.score - 2,
-      });
-    } else if (myVote == -1) {
-      this.setState({
-        downvotes: this.state.downvotes - 1,
-        score: this.state.score + 1,
-      });
-    } else {
-      this.setState({
-        downvotes: this.state.downvotes + 1,
-        score: this.state.score - 1,
-      });
-    }
-
-    this.setState({ my_vote: newVote });
-
-    const auth = myAuth();
-    if (auth) {
-      const form: CreateCommentLike = {
-        comment_id: this.props.node.comment_view.comment.id,
-        score: newVote,
-        auth,
-      };
-
-      WebSocketService.Instance.send(wsClient.likeComment(form));
-      setupTippy();
-    }
-  }
-
   handleShowReportDialog(i: CommentNode) {
     i.setState({ showReportDialog: !i.state.showReportDialog });
   }
@@ -1238,21 +1313,6 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     i.setState({ reportReason: event.target.value });
   }
 
-  handleReportSubmit(i: CommentNode) {
-    const comment = i.props.node.comment_view.comment;
-    const reason = i.state.reportReason;
-    const auth = myAuth();
-    if (reason && auth) {
-      const form: CreateCommentReport = {
-        comment_id: comment.id,
-        reason,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.createCommentReport(form));
-      i.setState({ showReportDialog: false });
-    }
-  }
-
   handleModRemoveShow(i: CommentNode) {
     i.setState({
       showRemoveDialog: !i.state.showRemoveDialog,
@@ -1268,36 +1328,6 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     i.setState({ removeData: event.target.checked });
   }
 
-  handleModRemoveSubmit(i: CommentNode) {
-    const comment = i.props.node.comment_view.comment;
-    const auth = myAuth();
-    if (auth) {
-      const form: RemoveComment = {
-        comment_id: comment.id,
-        removed: !comment.removed,
-        reason: i.state.removeReason,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.removeComment(form));
-
-      i.setState({ showRemoveDialog: false });
-    }
-  }
-
-  handleDistinguishClick(i: CommentNode) {
-    const comment = i.props.node.comment_view.comment;
-    const auth = myAuth();
-    if (auth) {
-      const form: DistinguishComment = {
-        comment_id: comment.id,
-        distinguished: !comment.distinguished,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.editComment(form));
-      i.setState(i.state);
-    }
-  }
-
   isPersonMentionType(
     item: CommentView | PersonMentionView | CommentReplyView
   ): item is PersonMentionView {
@@ -1310,29 +1340,6 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     return (item as CommentReplyView).comment_reply?.id !== undefined;
   }
 
-  handleMarkRead(i: CommentNode) {
-    const auth = myAuth();
-    if (auth) {
-      if (i.isPersonMentionType(i.props.node.comment_view)) {
-        const form: MarkPersonMentionAsRead = {
-          person_mention_id: i.props.node.comment_view.person_mention.id,
-          read: !i.props.node.comment_view.person_mention.read,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.markPersonMentionAsRead(form));
-      } else if (i.isCommentReplyType(i.props.node.comment_view)) {
-        const form: MarkCommentReplyAsRead = {
-          comment_reply_id: i.props.node.comment_view.comment_reply.id,
-          read: !i.props.node.comment_view.comment_reply.read,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.markCommentReplyAsRead(form));
-      }
-
-      i.setState({ readLoading: true });
-    }
-  }
-
   handleModBanFromCommunityShow(i: CommentNode) {
     i.setState({
       showBanDialog: true,
@@ -1357,57 +1364,6 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     i.setState({ banExpireDays: event.target.value });
   }
 
-  handleModBanFromCommunitySubmit(i: CommentNode) {
-    i.setState({ banType: BanType.Community });
-    i.handleModBanBothSubmit(i);
-  }
-
-  handleModBanSubmit(i: CommentNode) {
-    i.setState({ banType: BanType.Site });
-    i.handleModBanBothSubmit(i);
-  }
-
-  handleModBanBothSubmit(i: CommentNode) {
-    const cv = i.props.node.comment_view;
-    const auth = myAuth();
-    if (auth) {
-      if (i.state.banType == BanType.Community) {
-        // If its an unban, restore all their data
-        const ban = !cv.creator_banned_from_community;
-        if (ban == false) {
-          i.setState({ removeData: false });
-        }
-        const form: BanFromCommunity = {
-          person_id: cv.creator.id,
-          community_id: cv.community.id,
-          ban,
-          remove_data: i.state.removeData,
-          reason: i.state.banReason,
-          expires: futureDaysToUnixTime(i.state.banExpireDays),
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.banFromCommunity(form));
-      } else {
-        // If its an unban, restore all their data
-        const ban = !cv.creator.banned;
-        if (ban == false) {
-          i.setState({ removeData: false });
-        }
-        const form: BanPerson = {
-          person_id: cv.creator.id,
-          ban,
-          remove_data: i.state.removeData,
-          reason: i.state.banReason,
-          expires: futureDaysToUnixTime(i.state.banExpireDays),
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.banPerson(form));
-      }
-
-      i.setState({ showBanDialog: false });
-    }
-  }
-
   handlePurgePersonShow(i: CommentNode) {
     i.setState({
       showPurgeDialog: true,
@@ -1428,30 +1384,6 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     i.setState({ purgeReason: event.target.value });
   }
 
-  handlePurgeSubmit(i: CommentNode, event: any) {
-    event.preventDefault();
-    const auth = myAuth();
-    if (auth) {
-      if (i.state.purgeType == PurgeType.Person) {
-        const form: PurgePerson = {
-          person_id: i.props.node.comment_view.creator.id,
-          reason: i.state.purgeReason,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.purgePerson(form));
-      } else if (i.state.purgeType == PurgeType.Comment) {
-        const form: PurgeComment = {
-          comment_id: i.props.node.comment_view.comment.id,
-          reason: i.state.purgeReason,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.purgeComment(form));
-      }
-
-      i.setState({ purgeLoading: true });
-    }
-  }
-
   handleShowConfirmAppointAsMod(i: CommentNode) {
     i.setState({ showConfirmAppointAsMod: true });
   }
@@ -1460,21 +1392,6 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     i.setState({ showConfirmAppointAsMod: false });
   }
 
-  handleAddModToCommunity(i: CommentNode) {
-    const cv = i.props.node.comment_view;
-    const auth = myAuth();
-    if (auth) {
-      const form: AddModToCommunity = {
-        person_id: cv.creator.id,
-        community_id: cv.community.id,
-        added: !isMod(cv.creator.id, i.props.moderators),
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.addModToCommunity(form));
-      i.setState({ showConfirmAppointAsMod: false });
-    }
-  }
-
   handleShowConfirmAppointAsAdmin(i: CommentNode) {
     i.setState({ showConfirmAppointAsAdmin: true });
   }
@@ -1483,20 +1400,6 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     i.setState({ showConfirmAppointAsAdmin: false });
   }
 
-  handleAddAdmin(i: CommentNode) {
-    const auth = myAuth();
-    if (auth) {
-      const creatorId = i.props.node.comment_view.creator.id;
-      const form: AddAdmin = {
-        person_id: creatorId,
-        added: !isAdmin(creatorId, i.props.admins),
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.addAdmin(form));
-      i.setState({ showConfirmAppointAsAdmin: false });
-    }
-  }
-
   handleShowConfirmTransferCommunity(i: CommentNode) {
     i.setState({ showConfirmTransferCommunity: true });
   }
@@ -1505,20 +1408,6 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     i.setState({ showConfirmTransferCommunity: false });
   }
 
-  handleTransferCommunity(i: CommentNode) {
-    const cv = i.props.node.comment_view;
-    const auth = myAuth();
-    if (auth) {
-      const form: TransferCommunity = {
-        community_id: cv.community.id,
-        person_id: cv.creator.id,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.transferCommunity(form));
-      i.setState({ showConfirmTransferCommunity: false });
-    }
-  }
-
   handleShowConfirmTransferSite(i: CommentNode) {
     i.setState({ showConfirmTransferSite: true });
   }
@@ -1529,7 +1418,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
 
   get isCommentNew(): boolean {
     const now = moment.utc().subtract(10, "minutes");
-    const then = moment.utc(this.props.node.comment_view.comment.published);
+    const then = moment.utc(this.commentView.comment.published);
     return now.isBefore(then);
   }
 
@@ -1547,50 +1436,193 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
     setupTippy();
   }
 
-  handleFetchChildren(i: CommentNode) {
-    const form: GetComments = {
-      post_id: i.props.node.comment_view.post.id,
-      parent_id: i.props.node.comment_view.comment.id,
-      max_depth: commentTreeMaxDepth,
-      limit: 999, // TODO
-      type_: "All",
-      saved_only: false,
-      auth: myAuth(false),
-    };
+  handleSaveComment(i: CommentNode) {
+    i.setState({ saveLoading: true });
+
+    i.props.onSaveComment({
+      comment_id: i.commentView.comment.id,
+      save: !i.commentView.saved,
+      auth: myAuthRequired(),
+    });
+  }
 
-    WebSocketService.Instance.send(wsClient.getComments(form));
+  handleUpvote(i: CommentNode) {
+    i.setState({ upvoteLoading: true });
+    i.props.onCommentVote({
+      comment_id: i.commentId,
+      score: newVote(VoteType.Upvote, i.commentView.my_vote),
+      auth: myAuthRequired(),
+    });
   }
 
-  get scoreColor() {
-    if (this.state.my_vote == 1) {
-      return "text-info";
-    } else if (this.state.my_vote == -1) {
-      return "text-danger";
+  handleDownvote(i: CommentNode) {
+    i.setState({ downvoteLoading: true });
+    i.props.onCommentVote({
+      comment_id: i.commentId,
+      score: newVote(VoteType.Downvote, i.commentView.my_vote),
+      auth: myAuthRequired(),
+    });
+  }
+
+  handleBlockPerson(i: CommentNode) {
+    i.setState({ blockPersonLoading: true });
+    i.props.onBlockPerson({
+      person_id: i.commentView.creator.id,
+      block: true,
+      auth: myAuthRequired(),
+    });
+  }
+
+  handleMarkAsRead(i: CommentNode) {
+    i.setState({ readLoading: true });
+    const cv = i.commentView;
+    if (i.isPersonMentionType(cv)) {
+      i.props.onPersonMentionRead({
+        person_mention_id: cv.person_mention.id,
+        read: !cv.person_mention.read,
+        auth: myAuthRequired(),
+      });
+    } else if (i.isCommentReplyType(cv)) {
+      i.props.onCommentReplyRead({
+        comment_reply_id: cv.comment_reply.id,
+        read: !cv.comment_reply.read,
+        auth: myAuthRequired(),
+      });
+    }
+  }
+
+  handleDeleteComment(i: CommentNode) {
+    i.setState({ deleteLoading: true });
+    i.props.onDeleteComment({
+      comment_id: i.commentId,
+      deleted: !i.commentView.comment.deleted,
+      auth: myAuthRequired(),
+    });
+  }
+
+  handleRemoveComment(i: CommentNode, event: any) {
+    event.preventDefault();
+    i.setState({ removeLoading: true });
+    i.props.onRemoveComment({
+      comment_id: i.commentId,
+      removed: !i.commentView.comment.removed,
+      auth: myAuthRequired(),
+    });
+  }
+
+  handleDistinguishComment(i: CommentNode) {
+    i.setState({ distinguishLoading: true });
+    i.props.onDistinguishComment({
+      comment_id: i.commentId,
+      distinguished: !i.commentView.comment.distinguished,
+      auth: myAuthRequired(),
+    });
+  }
+
+  handleBanPersonFromCommunity(i: CommentNode) {
+    i.setState({ banLoading: true });
+    i.props.onBanPersonFromCommunity({
+      community_id: i.commentView.community.id,
+      person_id: i.commentView.creator.id,
+      ban: !i.commentView.creator_banned_from_community,
+      reason: i.state.banReason,
+      remove_data: i.state.removeData,
+      expires: futureDaysToUnixTime(i.state.banExpireDays),
+      auth: myAuthRequired(),
+    });
+  }
+
+  handleBanPerson(i: CommentNode) {
+    i.setState({ banLoading: true });
+    i.props.onBanPerson({
+      person_id: i.commentView.creator.id,
+      ban: !i.commentView.creator_banned_from_community,
+      reason: i.state.banReason,
+      remove_data: i.state.removeData,
+      expires: futureDaysToUnixTime(i.state.banExpireDays),
+      auth: myAuthRequired(),
+    });
+  }
+
+  handleModBanBothSubmit(i: CommentNode, event: any) {
+    event.preventDefault();
+    if (i.state.banType == BanType.Community) {
+      i.handleBanPersonFromCommunity(i);
     } else {
-      return "text-muted";
+      i.handleBanPerson(i);
     }
   }
 
-  get pointsTippy(): string {
-    const points = i18n.t("number_of_points", {
-      count: Number(this.state.score),
-      formattedCount: numToSI(this.state.score),
+  handleAddModToCommunity(i: CommentNode) {
+    i.setState({ addModLoading: true });
+
+    const added = !isMod(i.commentView.comment.creator_id, i.props.moderators);
+    i.props.onAddModToCommunity({
+      community_id: i.commentView.community.id,
+      person_id: i.commentView.creator.id,
+      added,
+      auth: myAuthRequired(),
     });
+  }
 
-    const upvotes = i18n.t("number_of_upvotes", {
-      count: Number(this.state.upvotes),
-      formattedCount: numToSI(this.state.upvotes),
+  handleAddAdmin(i: CommentNode) {
+    i.setState({ addAdminLoading: true });
+
+    const added = !isAdmin(i.commentView.comment.creator_id, i.props.admins);
+    i.props.onAddAdmin({
+      person_id: i.commentView.creator.id,
+      added,
+      auth: myAuthRequired(),
     });
+  }
 
-    const downvotes = i18n.t("number_of_downvotes", {
-      count: Number(this.state.downvotes),
-      formattedCount: numToSI(this.state.downvotes),
+  handleTransferCommunity(i: CommentNode) {
+    i.setState({ transferCommunityLoading: true });
+    i.props.onTransferCommunity({
+      community_id: i.commentView.community.id,
+      person_id: i.commentView.creator.id,
+      auth: myAuthRequired(),
     });
+  }
 
-    return `${points} â€¢ ${upvotes} â€¢ ${downvotes}`;
+  handleReportComment(i: CommentNode, event: any) {
+    event.preventDefault();
+    i.setState({ reportLoading: true });
+    i.props.onCommentReport({
+      comment_id: i.commentId,
+      reason: i.state.reportReason ?? "",
+      auth: myAuthRequired(),
+    });
   }
 
-  get expandText(): string {
-    return this.state.collapsed ? i18n.t("expand") : i18n.t("collapse");
+  handlePurgeBothSubmit(i: CommentNode, event: any) {
+    event.preventDefault();
+    i.setState({ purgeLoading: true });
+
+    if (i.state.purgeType == PurgeType.Person) {
+      i.props.onPurgePerson({
+        person_id: i.commentView.creator.id,
+        reason: i.state.purgeReason,
+        auth: myAuthRequired(),
+      });
+    } else {
+      i.props.onPurgeComment({
+        comment_id: i.commentId,
+        reason: i.state.purgeReason,
+        auth: myAuthRequired(),
+      });
+    }
+  }
+
+  handleFetchChildren(i: CommentNode) {
+    i.setState({ fetchChildrenLoading: true });
+    i.props.onFetchChildren?.({
+      parent_id: i.commentId,
+      max_depth: commentTreeMaxDepth,
+      limit: 999, // TODO
+      type_: "All",
+      saved_only: false,
+      auth: myAuth(),
+    });
   }
 }
index 23f22fe8f6260ba74a9f9aa26b9e1fdcaaf293b0..3f9b48ef68eaa0dffa5fb5ded882ce53a25c7557 100644 (file)
@@ -1,5 +1,29 @@
 import { Component } from "inferno";
-import { CommunityModeratorView, Language, PersonView } from "lemmy-js-client";
+import {
+  AddAdmin,
+  AddModToCommunity,
+  BanFromCommunity,
+  BanPerson,
+  BlockPerson,
+  CommentId,
+  CommunityModeratorView,
+  CreateComment,
+  CreateCommentLike,
+  CreateCommentReport,
+  DeleteComment,
+  DistinguishComment,
+  EditComment,
+  GetComments,
+  Language,
+  MarkCommentReplyAsRead,
+  MarkPersonMentionAsRead,
+  PersonView,
+  PurgeComment,
+  PurgePerson,
+  RemoveComment,
+  SaveComment,
+  TransferCommunity,
+} from "lemmy-js-client";
 import { CommentNodeI, CommentViewType } from "../../interfaces";
 import { CommentNode } from "./comment-node";
 
@@ -20,6 +44,26 @@ interface CommentNodesProps {
   allLanguages: Language[];
   siteLanguages: number[];
   hideImages?: boolean;
+  finished: Map<CommentId, boolean | undefined>;
+  onSaveComment(form: SaveComment): void;
+  onCommentReplyRead(form: MarkCommentReplyAsRead): void;
+  onPersonMentionRead(form: MarkPersonMentionAsRead): void;
+  onCreateComment(form: EditComment | CreateComment): void;
+  onEditComment(form: EditComment | CreateComment): void;
+  onCommentVote(form: CreateCommentLike): void;
+  onBlockPerson(form: BlockPerson): void;
+  onDeleteComment(form: DeleteComment): void;
+  onRemoveComment(form: RemoveComment): void;
+  onDistinguishComment(form: DistinguishComment): void;
+  onAddModToCommunity(form: AddModToCommunity): void;
+  onAddAdmin(form: AddAdmin): void;
+  onBanPersonFromCommunity(form: BanFromCommunity): void;
+  onBanPerson(form: BanPerson): void;
+  onTransferCommunity(form: TransferCommunity): void;
+  onFetchChildren?(form: GetComments): void;
+  onCommentReport(form: CreateCommentReport): void;
+  onPurgePerson(form: PurgePerson): void;
+  onPurgeComment(form: PurgeComment): void;
 }
 
 export class CommentNodes extends Component<CommentNodesProps, any> {
@@ -50,6 +94,26 @@ export class CommentNodes extends Component<CommentNodesProps, any> {
             allLanguages={this.props.allLanguages}
             siteLanguages={this.props.siteLanguages}
             hideImages={this.props.hideImages}
+            onCommentReplyRead={this.props.onCommentReplyRead}
+            onPersonMentionRead={this.props.onPersonMentionRead}
+            finished={this.props.finished}
+            onCreateComment={this.props.onCreateComment}
+            onEditComment={this.props.onEditComment}
+            onCommentVote={this.props.onCommentVote}
+            onBlockPerson={this.props.onBlockPerson}
+            onSaveComment={this.props.onSaveComment}
+            onDeleteComment={this.props.onDeleteComment}
+            onRemoveComment={this.props.onRemoveComment}
+            onDistinguishComment={this.props.onDistinguishComment}
+            onAddModToCommunity={this.props.onAddModToCommunity}
+            onAddAdmin={this.props.onAddAdmin}
+            onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
+            onBanPerson={this.props.onBanPerson}
+            onTransferCommunity={this.props.onTransferCommunity}
+            onFetchChildren={this.props.onFetchChildren}
+            onCommentReport={this.props.onCommentReport}
+            onPurgePerson={this.props.onPurgePerson}
+            onPurgeComment={this.props.onPurgeComment}
           />
         ))}
       </div>
index 64c659fb3ad54e38d505f0955175017ff08bb026..ff00bc593e8fe8e60e70814c0b32f895a8837d7f 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, linkEvent } from "inferno";
+import { Component, InfernoNode, linkEvent } from "inferno";
 import { T } from "inferno-i18next-dess";
 import {
   CommentReportView,
@@ -7,21 +7,39 @@ import {
 } from "lemmy-js-client";
 import { i18n } from "../../i18next";
 import { CommentNodeI, CommentViewType } from "../../interfaces";
-import { WebSocketService } from "../../services";
-import { myAuth, wsClient } from "../../utils";
-import { Icon } from "../common/icon";
+import { myAuthRequired } from "../../utils";
+import { Icon, Spinner } from "../common/icon";
 import { PersonListing } from "../person/person-listing";
 import { CommentNode } from "./comment-node";
 
 interface CommentReportProps {
   report: CommentReportView;
+  onResolveReport(form: ResolveCommentReport): void;
 }
 
-export class CommentReport extends Component<CommentReportProps, any> {
+interface CommentReportState {
+  loading: boolean;
+}
+
+export class CommentReport extends Component<
+  CommentReportProps,
+  CommentReportState
+> {
+  state: CommentReportState = {
+    loading: false,
+  };
   constructor(props: any, context: any) {
     super(props, context);
   }
 
+  componentWillReceiveProps(
+    nextProps: Readonly<{ children?: InfernoNode } & CommentReportProps>
+  ): void {
+    if (this.props != nextProps) {
+      this.setState({ loading: false });
+    }
+  }
+
   render() {
     const r = this.props.report;
     const comment = r.comment;
@@ -62,6 +80,26 @@ export class CommentReport extends Component<CommentReportProps, any> {
           allLanguages={[]}
           siteLanguages={[]}
           hideImages
+          // All of these are unused, since its viewonly
+          finished={new Map()}
+          onSaveComment={() => {}}
+          onBlockPerson={() => {}}
+          onDeleteComment={() => {}}
+          onRemoveComment={() => {}}
+          onCommentVote={() => {}}
+          onCommentReport={() => {}}
+          onDistinguishComment={() => {}}
+          onAddModToCommunity={() => {}}
+          onAddAdmin={() => {}}
+          onTransferCommunity={() => {}}
+          onPurgeComment={() => {}}
+          onPurgePerson={() => {}}
+          onCommentReplyRead={() => {}}
+          onPersonMentionRead={() => {}}
+          onBanPersonFromCommunity={() => {}}
+          onBanPerson={() => {}}
+          onCreateComment={() => Promise.resolve({ state: "empty" })}
+          onEditComment={() => Promise.resolve({ state: "empty" })}
         />
         <div>
           {i18n.t("reporter")}: <PersonListing person={r.creator} />
@@ -90,26 +128,27 @@ export class CommentReport extends Component<CommentReportProps, any> {
           data-tippy-content={tippyContent}
           aria-label={tippyContent}
         >
-          <Icon
-            icon="check"
-            classes={`icon-inline ${
-              r.comment_report.resolved ? "text-success" : "text-danger"
-            }`}
-          />
+          {this.state.loading ? (
+            <Spinner />
+          ) : (
+            <Icon
+              icon="check"
+              classes={`icon-inline ${
+                r.comment_report.resolved ? "text-success" : "text-danger"
+              }`}
+            />
+          )}
         </button>
       </div>
     );
   }
 
   handleResolveReport(i: CommentReport) {
-    const auth = myAuth();
-    if (auth) {
-      const form: ResolveCommentReport = {
-        report_id: i.props.report.comment_report.id,
-        resolved: !i.props.report.comment_report.resolved,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.resolveCommentReport(form));
-    }
+    i.setState({ loading: true });
+    i.props.onResolveReport({
+      report_id: i.props.report.comment_report.id,
+      resolved: !i.props.report.comment_report.resolved,
+      auth: myAuthRequired(),
+    });
   }
 }
index cfb861501b0f5a456a6ecbcee967aca30276f854..61fdd6101b553bf09556044e9a797525dadd129f 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, linkEvent } from "inferno";
 import { i18n } from "../../i18next";
-import { UserService } from "../../services";
-import { randomStr, toast, uploadImage } from "../../utils";
+import { HttpService, UserService } from "../../services";
+import { randomStr, toast } from "../../utils";
 import { Icon } from "./icon";
 
 interface ImageUploadFormProps {
@@ -73,27 +73,26 @@ export class ImageUploadForm extends Component<
 
   handleImageUpload(i: ImageUploadForm, event: any) {
     event.preventDefault();
-    const file = event.target.files[0];
+    const image = event.target.files[0] as File;
 
     i.setState({ loading: true });
 
-    uploadImage(file)
-      .then(res => {
-        console.log("pictrs upload:");
-        console.log(res);
-        if (res.msg === "ok") {
-          i.setState({ loading: false });
-          i.props.onUpload(res.url as string);
+    HttpService.client.uploadImage({ image }).then(res => {
+      console.log("pictrs upload:");
+      console.log(res);
+      if (res.state === "success") {
+        if (res.data.msg === "ok") {
+          i.props.onUpload(res.data.url as string);
         } else {
-          i.setState({ loading: false });
           toast(JSON.stringify(res), "danger");
         }
-      })
-      .catch(error => {
-        i.setState({ loading: false });
-        console.error(error);
-        toast(error, "danger");
-      });
+      } else if (res.state === "failed") {
+        console.error(res.msg);
+        toast(res.msg, "danger");
+      }
+
+      i.setState({ loading: false });
+    });
   }
 
   handleRemoveImage(i: ImageUploadForm, event: any) {
index abafe3752e88b18d0be490cd23b662db66a37fac..3e534d341c86056954ae61416d0136191e1e03a7 100644 (file)
@@ -8,7 +8,7 @@ interface ListingTypeSelectProps {
   type_: ListingType;
   showLocal: boolean;
   showSubscribed: boolean;
-  onChange?(val: ListingType): any;
+  onChange(val: ListingType): void;
 }
 
 interface ListingTypeSelectState {
@@ -29,11 +29,11 @@ export class ListingTypeSelect extends Component<
     super(props, context);
   }
 
-  static getDerivedStateFromProps(props: any): ListingTypeSelectProps {
+  static getDerivedStateFromProps(
+    props: ListingTypeSelectProps
+  ): ListingTypeSelectState {
     return {
       type_: props.type_,
-      showLocal: props.showLocal,
-      showSubscribed: props.showSubscribed,
     };
   }
 
@@ -97,6 +97,6 @@ export class ListingTypeSelect extends Component<
   }
 
   handleTypeChange(i: ListingTypeSelect, event: any) {
-    i.props.onChange?.(event.target.value);
+    i.props.onChange(event.target.value);
   }
 }
index 92b8e2b91d92c8275d9fe814a52f8626324e266b..9318d3bb8f972b25b19a48711fcf05aed2ccb8b3 100644 (file)
@@ -3,7 +3,7 @@ import { NoOptionI18nKeys } from "i18next";
 import { Component, linkEvent } from "inferno";
 import { Language } from "lemmy-js-client";
 import { i18n } from "../../i18next";
-import { UserService } from "../../services";
+import { HttpService, UserService } from "../../services";
 import {
   concurrentImageUpload,
   customEmojisLookup,
@@ -19,7 +19,6 @@ import {
   setupTippy,
   setupTribute,
   toast,
-  uploadImage,
 } from "../../utils";
 import { EmojiPicker } from "./emoji-picker";
 import { Icon, Spinner } from "./icon";
@@ -39,9 +38,9 @@ interface MarkdownTextAreaProps {
   finished?: boolean;
   showLanguage?: boolean;
   hideNavigationWarnings?: boolean;
-  onContentChange?(val: string): any;
-  onReplyCancel?(): any;
-  onSubmit?(msg: { val?: string; formId: string; languageId?: number }): any;
+  onContentChange?(val: string): void;
+  onReplyCancel?(): void;
+  onSubmit?(content: string, formId: string, languageId?: number): void;
   allLanguages: Language[]; // TODO should probably be nullable
   siteLanguages: number[]; // TODO same
 }
@@ -55,8 +54,9 @@ interface MarkdownTextAreaState {
   content?: string;
   languageId?: number;
   previewMode: boolean;
-  loading: boolean;
   imageUploadStatus?: ImageUploadStatus;
+  loading: boolean;
+  submitted: boolean;
 }
 
 export class MarkdownTextArea extends Component<
@@ -72,6 +72,7 @@ export class MarkdownTextArea extends Component<
     languageId: this.props.initialLanguageId,
     previewMode: false,
     loading: false,
+    submitted: false,
   };
 
   constructor(props: any, context: any) {
@@ -105,17 +106,14 @@ export class MarkdownTextArea extends Component<
     }
   }
 
-  componentDidUpdate() {
-    if (!this.props.hideNavigationWarnings && this.state.content) {
-      window.onbeforeunload = () => true;
-    } else {
-      window.onbeforeunload = null;
-    }
-  }
-
   componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
     if (nextProps.finished) {
-      this.setState({ previewMode: false, loading: false, content: undefined });
+      this.setState({
+        previewMode: false,
+        imageUploadStatus: undefined,
+        loading: false,
+        content: undefined,
+      });
       if (this.props.replyType) {
         this.props.onReplyCancel?.();
       }
@@ -127,16 +125,23 @@ export class MarkdownTextArea extends Component<
     }
   }
 
-  componentWillUnmount() {
-    window.onbeforeunload = null;
-  }
-
   render() {
     const languageId = this.state.languageId;
 
+    // TODO add these prompts back in at some point
+    // <Prompt
+    //   when={!this.props.hideNavigationWarnings && this.state.content}
+    //   message={i18n.t("block_leaving")}
+    // />
     return (
       <form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
-        <NavigationPrompt when={!!this.state.content} />
+        <NavigationPrompt
+          when={
+            !this.props.hideNavigationWarnings &&
+            !!this.state.content &&
+            !this.state.submitted
+          }
+        />
         <div className="form-group row">
           <div className={`col-sm-12`}>
             <textarea
@@ -390,29 +395,29 @@ export class MarkdownTextArea extends Component<
     }
   }
 
-  async uploadSingleImage(i: MarkdownTextArea, file: File) {
-    try {
-      const res = await uploadImage(file);
-      console.log("pictrs upload:");
-      console.log(res);
-      if (res.msg === "ok") {
-        const imageMarkdown = `![](${res.url})`;
+  async uploadSingleImage(i: MarkdownTextArea, image: File) {
+    const res = await HttpService.client.uploadImage({ image });
+    console.log("pictrs upload:");
+    console.log(res);
+    if (res.state === "success") {
+      if (res.data.msg === "ok") {
+        const imageMarkdown = `![](${res.data.url})`;
         i.setState(({ content }) => ({
           content: content ? `${content}\n${imageMarkdown}` : imageMarkdown,
         }));
         i.contentChange();
         const textarea: any = document.getElementById(i.id);
         autosize.update(textarea);
-        pictrsDeleteToast(file.name, res.delete_url as string);
+        pictrsDeleteToast(image.name, res.data.delete_url as string);
       } else {
-        throw JSON.stringify(res);
+        throw JSON.stringify(res.data);
       }
-    } catch (error) {
+    } else if (res.state === "failed") {
       i.setState({ imageUploadStatus: undefined });
-      console.error(error);
-      toast(error, "danger");
+      console.error(res.msg);
+      toast(res.msg, "danger");
 
-      throw error;
+      throw res.msg;
     }
   }
 
@@ -486,13 +491,10 @@ export class MarkdownTextArea extends Component<
 
   handleSubmit(i: MarkdownTextArea, event: any) {
     event.preventDefault();
-    i.setState({ loading: true });
-    const msg = {
-      val: i.state.content,
-      formId: i.formId,
-      languageId: i.state.languageId,
-    };
-    i.props.onSubmit?.(msg);
+    if (i.state.content) {
+      i.setState({ loading: true, submitted: true });
+      i.props.onSubmit?.(i.state.content, i.formId, i.state.languageId);
+    }
   }
 
   handleReplyCancel(i: MarkdownTextArea) {
index edf03a1fec8662638871ded0472291a2ae8f8d3d..503892c3d13eebcb1885bc39e881a0f2c033ffb1 100644 (file)
@@ -1,23 +1,26 @@
-import { Component, linkEvent } from "inferno";
+import { Component, InfernoNode, linkEvent } from "inferno";
 import { T } from "inferno-i18next-dess";
 import {
   ApproveRegistrationApplication,
   RegistrationApplicationView,
 } from "lemmy-js-client";
 import { i18n } from "../../i18next";
-import { WebSocketService } from "../../services";
-import { mdToHtml, myAuth, wsClient } from "../../utils";
+import { mdToHtml, myAuthRequired } from "../../utils";
 import { PersonListing } from "../person/person-listing";
+import { Spinner } from "./icon";
 import { MarkdownTextArea } from "./markdown-textarea";
 import { MomentTime } from "./moment-time";
 
 interface RegistrationApplicationProps {
   application: RegistrationApplicationView;
+  onApproveApplication(form: ApproveRegistrationApplication): void;
 }
 
 interface RegistrationApplicationState {
   denyReason?: string;
   denyExpanded: boolean;
+  approveLoading: boolean;
+  denyLoading: boolean;
 }
 
 export class RegistrationApplication extends Component<
@@ -27,12 +30,27 @@ export class RegistrationApplication extends Component<
   state: RegistrationApplicationState = {
     denyReason: this.props.application.registration_application.deny_reason,
     denyExpanded: false,
+    approveLoading: false,
+    denyLoading: false,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
     this.handleDenyReasonChange = this.handleDenyReasonChange.bind(this);
   }
+  componentWillReceiveProps(
+    nextProps: Readonly<
+      { children?: InfernoNode } & RegistrationApplicationProps
+    >
+  ): void {
+    if (this.props != nextProps) {
+      this.setState({
+        denyExpanded: false,
+        approveLoading: false,
+        denyLoading: false,
+      });
+    }
+  }
 
   render() {
     const a = this.props.application;
@@ -99,7 +117,7 @@ export class RegistrationApplication extends Component<
             onClick={linkEvent(this, this.handleApprove)}
             aria-label={i18n.t("approve")}
           >
-            {i18n.t("approve")}
+            {this.state.approveLoading ? <Spinner /> : i18n.t("approve")}
           </button>
         )}
         {(!ra.admin_id || (ra.admin_id && accepted)) && (
@@ -108,7 +126,7 @@ export class RegistrationApplication extends Component<
             onClick={linkEvent(this, this.handleDeny)}
             aria-label={i18n.t("deny")}
           >
-            {i18n.t("deny")}
+            {this.state.denyLoading ? <Spinner /> : i18n.t("deny")}
           </button>
         )}
       </div>
@@ -116,35 +134,23 @@ export class RegistrationApplication extends Component<
   }
 
   handleApprove(i: RegistrationApplication) {
-    const auth = myAuth();
-    if (auth) {
-      i.setState({ denyExpanded: false });
-      const form: ApproveRegistrationApplication = {
-        id: i.props.application.registration_application.id,
-        approve: true,
-        auth,
-      };
-      WebSocketService.Instance.send(
-        wsClient.approveRegistrationApplication(form)
-      );
-    }
+    i.setState({ denyExpanded: false, approveLoading: true });
+    i.props.onApproveApplication({
+      id: i.props.application.registration_application.id,
+      approve: true,
+      auth: myAuthRequired(),
+    });
   }
 
   handleDeny(i: RegistrationApplication) {
     if (i.state.denyExpanded) {
-      i.setState({ denyExpanded: false });
-      const auth = myAuth();
-      if (auth) {
-        const form: ApproveRegistrationApplication = {
-          id: i.props.application.registration_application.id,
-          approve: false,
-          deny_reason: i.state.denyReason,
-          auth,
-        };
-        WebSocketService.Instance.send(
-          wsClient.approveRegistrationApplication(form)
-        );
-      }
+      i.setState({ denyExpanded: false, denyLoading: true });
+      i.props.onApproveApplication({
+        id: i.props.application.registration_application.id,
+        approve: false,
+        deny_reason: i.state.denyReason,
+        auth: myAuthRequired(),
+      });
     } else {
       i.setState({ denyExpanded: true });
     }
index a5a75f2328507927fffc91725affc808f80efbba..cd6303672082092251f66fe5dbfff56eb6d00487 100644 (file)
@@ -38,12 +38,38 @@ function handleSearch(i: SearchableSelect, e: ChangeEvent<HTMLInputElement>) {
   });
 }
 
+function focusSearch(i: SearchableSelect) {
+  if (i.toggleButtonRef.current?.ariaExpanded !== "true") {
+    i.searchInputRef.current?.focus();
+
+    if (i.props.onSearch) {
+      i.props.onSearch("");
+    }
+
+    i.setState({
+      searchText: "",
+    });
+  }
+}
+
+function handleChange({ option, i }: { option: Choice; i: SearchableSelect }) {
+  const { onChange, value } = i.props;
+
+  if (option.value !== value?.toString()) {
+    if (onChange) {
+      onChange(option);
+    }
+
+    i.setState({ searchText: "" });
+  }
+}
+
 export class SearchableSelect extends Component<
   SearchableSelectProps,
   SearchableSelectState
 > {
-  private searchInputRef: RefObject<HTMLInputElement> = createRef();
-  private toggleButtonRef: RefObject<HTMLButtonElement> = createRef();
+  searchInputRef: RefObject<HTMLInputElement> = createRef();
+  toggleButtonRef: RefObject<HTMLButtonElement> = createRef();
   private loadingEllipsesInterval?: NodeJS.Timer = undefined;
 
   state: SearchableSelectState = {
@@ -55,9 +81,6 @@ export class SearchableSelect extends Component<
   constructor(props: SearchableSelectProps, context: any) {
     super(props, context);
 
-    this.handleChange = this.handleChange.bind(this);
-    this.focusSearch = this.focusSearch.bind(this);
-
     if (props.value) {
       let selectedIndex = props.options.findIndex(
         ({ value }) => value === props.value?.toString()
@@ -86,7 +109,8 @@ export class SearchableSelect extends Component<
           className="custom-select text-start"
           aria-haspopup="listbox"
           data-bs-toggle="dropdown"
-          onClick={this.focusSearch}
+          onClick={linkEvent(this, focusSearch)}
+          ref={this.toggleButtonRef}
         >
           {loading
             ? `${i18n.t("loading")}${loadingEllipses}`
@@ -127,7 +151,7 @@ export class SearchableSelect extends Component<
                 aria-disabled={option.disabled}
                 disabled={option.disabled}
                 aria-selected={selectedIndex === index}
-                onClick={() => this.handleChange(option)}
+                onClick={linkEvent({ i: this, option }, handleChange)}
                 type="button"
               >
                 {option.label}
@@ -138,20 +162,6 @@ export class SearchableSelect extends Component<
     );
   }
 
-  focusSearch() {
-    if (this.toggleButtonRef.current?.ariaExpanded !== "true") {
-      this.searchInputRef.current?.focus();
-
-      if (this.props.onSearch) {
-        this.props.onSearch("");
-      }
-
-      this.setState({
-        searchText: "",
-      });
-    }
-  }
-
   static getDerivedStateFromProps({
     value,
     options,
@@ -189,16 +199,4 @@ export class SearchableSelect extends Component<
       clearInterval(this.loadingEllipsesInterval);
     }
   }
-
-  handleChange(option: Choice) {
-    const { onChange, value } = this.props;
-
-    if (option.value !== value?.toString()) {
-      if (onChange) {
-        onChange(option);
-      }
-
-      this.setState({ searchText: "" });
-    }
-  }
 }
index f54d87d80bfcc2a3de42ac33a685b8f2b8b17f23..dac6e20d51723fda24763c34e3fa9f3eceb3dd3f 100644 (file)
@@ -6,7 +6,7 @@ import { Icon } from "./icon";
 
 interface SortSelectProps {
   sort: SortType;
-  onChange?(val: SortType): any;
+  onChange(val: SortType): void;
   hideHot?: boolean;
   hideMostComments?: boolean;
 }
@@ -25,7 +25,7 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
     super(props, context);
   }
 
-  static getDerivedStateFromProps(props: any): SortSelectState {
+  static getDerivedStateFromProps(props: SortSelectProps): SortSelectState {
     return {
       sort: props.sort,
     };
@@ -85,6 +85,6 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
   }
 
   handleSortChange(i: SortSelect, event: any) {
-    i.props.onChange?.(event.target.value);
+    i.props.onChange(event.target.value);
   }
 }
index e2e352424e2627f5749970919ad675371904d478..623269439f8642444aad0fcaa6cb8cdb0649d02d 100644 (file)
@@ -1,32 +1,26 @@
 import { Component, linkEvent } from "inferno";
 import {
   CommunityResponse,
-  FollowCommunity,
   GetSiteResponse,
   ListCommunities,
   ListCommunitiesResponse,
   ListingType,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
 import { InitialFetchRequest } from "../../interfaces";
-import { WebSocketService } from "../../services";
+import { FirstLoadService } from "../../services/FirstLoadService";
+import { HttpService, RequestState } from "../../services/HttpService";
 import {
   QueryParams,
+  editCommunity,
   getPageFromString,
   getQueryParams,
   getQueryString,
-  isBrowser,
   myAuth,
+  myAuthRequired,
   numToSI,
   setIsoData,
   showLocal,
-  toast,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { HtmlTags } from "../common/html-tags";
 import { Spinner } from "../common/icon";
@@ -37,10 +31,10 @@ import { CommunityLink } from "./community-link";
 const communityLimit = 50;
 
 interface CommunitiesState {
-  listCommunitiesResponse?: ListCommunitiesResponse;
-  loading: boolean;
+  listCommunitiesResponse: RequestState<ListCommunitiesResponse>;
   siteRes: GetSiteResponse;
   searchText: string;
+  isIsomorphic: boolean;
 }
 
 interface CommunitiesProps {
@@ -48,51 +42,17 @@ interface CommunitiesProps {
   page: number;
 }
 
-function getCommunitiesQueryParams() {
-  return getQueryParams<CommunitiesProps>({
-    listingType: getListingTypeFromQuery,
-    page: getPageFromString,
-  });
-}
-
 function getListingTypeFromQuery(listingType?: string): ListingType {
   return listingType ? (listingType as ListingType) : "Local";
 }
 
-function toggleSubscribe(community_id: number, follow: boolean) {
-  const auth = myAuth();
-  if (auth) {
-    const form: FollowCommunity = {
-      community_id,
-      follow,
-      auth,
-    };
-
-    WebSocketService.Instance.send(wsClient.followCommunity(form));
-  }
-}
-
-function refetch() {
-  const { listingType, page } = getCommunitiesQueryParams();
-
-  const listCommunitiesForm: ListCommunities = {
-    type_: listingType,
-    sort: "TopMonth",
-    limit: communityLimit,
-    page,
-    auth: myAuth(false),
-  };
-
-  WebSocketService.Instance.send(wsClient.listCommunities(listCommunitiesForm));
-}
-
 export class Communities extends Component<any, CommunitiesState> {
-  private subscription?: Subscription;
   private isoData = setIsoData(this.context);
   state: CommunitiesState = {
-    loading: true,
+    listCommunitiesResponse: { state: "empty" },
     siteRes: this.isoData.site_res,
     searchText: "",
+    isIsomorphic: false,
   };
 
   constructor(props: any, context: any) {
@@ -100,25 +60,19 @@ export class Communities extends Component<any, CommunitiesState> {
     this.handlePageChange = this.handlePageChange.bind(this);
     this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
-
     // Only fetch the data if coming from another route
-    if (this.isoData.path === this.context.router.route.match.url) {
-      const listRes = this.isoData.routeData[0] as ListCommunitiesResponse;
+    if (FirstLoadService.isFirstLoad) {
       this.state = {
         ...this.state,
-        listCommunitiesResponse: listRes,
-        loading: false,
+        listCommunitiesResponse: this.isoData.routeData[0],
+        isIsomorphic: true,
       };
-    } else {
-      refetch();
     }
   }
 
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
+  async componentDidMount() {
+    if (!this.state.isIsomorphic) {
+      await this.refetch();
     }
   }
 
@@ -128,20 +82,17 @@ export class Communities extends Component<any, CommunitiesState> {
     }`;
   }
 
-  render() {
-    const { listingType, page } = getCommunitiesQueryParams();
-
-    return (
-      <div className="container-lg">
-        <HtmlTags
-          title={this.documentTitle}
-          path={this.context.router.route.match.url}
-        />
-        {this.state.loading ? (
+  renderListings() {
+    switch (this.state.listCommunitiesResponse.state) {
+      case "loading":
+        return (
           <h5>
             <Spinner large />
           </h5>
-        ) : (
+        );
+      case "success": {
+        const { listingType, page } = this.getCommunitiesQueryParams();
+        return (
           <div>
             <div className="row">
               <div className="col-md-6">
@@ -182,60 +133,82 @@ export class Communities extends Component<any, CommunitiesState> {
                   </tr>
                 </thead>
                 <tbody>
-                  {this.state.listCommunitiesResponse?.communities.map(cv => (
-                    <tr key={cv.community.id}>
-                      <td>
-                        <CommunityLink community={cv.community} />
-                      </td>
-                      <td className="text-right">
-                        {numToSI(cv.counts.subscribers)}
-                      </td>
-                      <td className="text-right">
-                        {numToSI(cv.counts.users_active_month)}
-                      </td>
-                      <td className="text-right d-none d-lg-table-cell">
-                        {numToSI(cv.counts.posts)}
-                      </td>
-                      <td className="text-right d-none d-lg-table-cell">
-                        {numToSI(cv.counts.comments)}
-                      </td>
-                      <td className="text-right">
-                        {cv.subscribed == "Subscribed" && (
-                          <button
-                            className="btn btn-link d-inline-block"
-                            onClick={linkEvent(
-                              cv.community.id,
-                              this.handleUnsubscribe
-                            )}
-                          >
-                            {i18n.t("unsubscribe")}
-                          </button>
-                        )}
-                        {cv.subscribed === "NotSubscribed" && (
-                          <button
-                            className="btn btn-link d-inline-block"
-                            onClick={linkEvent(
-                              cv.community.id,
-                              this.handleSubscribe
-                            )}
-                          >
-                            {i18n.t("subscribe")}
-                          </button>
-                        )}
-                        {cv.subscribed === "Pending" && (
-                          <div className="text-warning d-inline-block">
-                            {i18n.t("subscribe_pending")}
-                          </div>
-                        )}
-                      </td>
-                    </tr>
-                  ))}
+                  {this.state.listCommunitiesResponse.data.communities.map(
+                    cv => (
+                      <tr key={cv.community.id}>
+                        <td>
+                          <CommunityLink community={cv.community} />
+                        </td>
+                        <td className="text-right">
+                          {numToSI(cv.counts.subscribers)}
+                        </td>
+                        <td className="text-right">
+                          {numToSI(cv.counts.users_active_month)}
+                        </td>
+                        <td className="text-right d-none d-lg-table-cell">
+                          {numToSI(cv.counts.posts)}
+                        </td>
+                        <td className="text-right d-none d-lg-table-cell">
+                          {numToSI(cv.counts.comments)}
+                        </td>
+                        <td className="text-right">
+                          {cv.subscribed == "Subscribed" && (
+                            <button
+                              className="btn btn-link d-inline-block"
+                              onClick={linkEvent(
+                                {
+                                  i: this,
+                                  communityId: cv.community.id,
+                                  follow: false,
+                                },
+                                this.handleFollow
+                              )}
+                            >
+                              {i18n.t("unsubscribe")}
+                            </button>
+                          )}
+                          {cv.subscribed === "NotSubscribed" && (
+                            <button
+                              className="btn btn-link d-inline-block"
+                              onClick={linkEvent(
+                                {
+                                  i: this,
+                                  communityId: cv.community.id,
+                                  follow: true,
+                                },
+                                this.handleFollow
+                              )}
+                            >
+                              {i18n.t("subscribe")}
+                            </button>
+                          )}
+                          {cv.subscribed === "Pending" && (
+                            <div className="text-warning d-inline-block">
+                              {i18n.t("subscribe_pending")}
+                            </div>
+                          )}
+                        </td>
+                      </tr>
+                    )
+                  )}
                 </tbody>
               </table>
             </div>
             <Paginator page={page} onChange={this.handlePageChange} />
           </div>
-        )}
+        );
+      }
+    }
+  }
+
+  render() {
+    return (
+      <div className="container-lg">
+        <HtmlTags
+          title={this.documentTitle}
+          path={this.context.router.route.match.url}
+        />
+        {this.renderListings()}
       </div>
     );
   }
@@ -266,9 +239,9 @@ export class Communities extends Component<any, CommunitiesState> {
     );
   }
 
-  updateUrl({ listingType, page }: Partial<CommunitiesProps>) {
+  async updateUrl({ listingType, page }: Partial<CommunitiesProps>) {
     const { listingType: urlListingType, page: urlPage } =
-      getCommunitiesQueryParams();
+      this.getCommunitiesQueryParams();
 
     const queryParams: QueryParams<CommunitiesProps> = {
       listingType: listingType ?? urlListingType,
@@ -277,7 +250,7 @@ export class Communities extends Component<any, CommunitiesState> {
 
     this.props.history.push(`/communities${getQueryString(queryParams)}`);
 
-    refetch();
+    await this.refetch();
   }
 
   handlePageChange(page: number) {
@@ -291,19 +264,12 @@ export class Communities extends Component<any, CommunitiesState> {
     });
   }
 
-  handleUnsubscribe(communityId: number) {
-    toggleSubscribe(communityId, false);
-  }
-
-  handleSubscribe(communityId: number) {
-    toggleSubscribe(communityId, true);
-  }
-
   handleSearchChange(i: Communities, event: any) {
     i.setState({ searchText: event.target.value });
   }
 
-  handleSearchSubmit(i: Communities) {
+  handleSearchSubmit(i: Communities, event: any) {
+    event.preventDefault();
     const searchParamEncoded = encodeURIComponent(i.state.searchText);
     i.context.router.history.push(`/search?q=${searchParamEncoded}`);
   }
@@ -312,7 +278,9 @@ export class Communities extends Component<any, CommunitiesState> {
     query: { listingType, page },
     client,
     auth,
-  }: InitialFetchRequest<QueryParams<CommunitiesProps>>): Promise<any>[] {
+  }: InitialFetchRequest<QueryParams<CommunitiesProps>>): Promise<
+    RequestState<any>
+  >[] {
     const listCommunitiesForm: ListCommunities = {
       type_: getListingTypeFromQuery(listingType),
       sort: "TopMonth",
@@ -324,33 +292,56 @@ export class Communities extends Component<any, CommunitiesState> {
     return [client.listCommunities(listCommunitiesForm)];
   }
 
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-    } else if (op === UserOperation.ListCommunities) {
-      const data = wsJsonToRes<ListCommunitiesResponse>(msg);
-      this.setState({ listCommunitiesResponse: data, loading: false });
-      window.scrollTo(0, 0);
-    } else if (op === UserOperation.FollowCommunity) {
-      const {
-        community_view: {
-          community,
-          subscribed,
-          counts: { subscribers },
-        },
-      } = wsJsonToRes<CommunityResponse>(msg);
-      const res = this.state.listCommunitiesResponse;
-      const found = res?.communities.find(
-        ({ community: { id } }) => id == community.id
-      );
+  getCommunitiesQueryParams() {
+    return getQueryParams<CommunitiesProps>({
+      listingType: getListingTypeFromQuery,
+      page: getPageFromString,
+    });
+  }
+
+  async handleFollow(data: {
+    i: Communities;
+    communityId: number;
+    follow: boolean;
+  }) {
+    const res = await HttpService.client.followCommunity({
+      community_id: data.communityId,
+      follow: data.follow,
+      auth: myAuthRequired(),
+    });
+    data.i.findAndUpdateCommunity(res);
+  }
+
+  async refetch() {
+    this.setState({ listCommunitiesResponse: { state: "loading" } });
+
+    const { listingType, page } = this.getCommunitiesQueryParams();
 
-      if (found) {
-        found.subscribed = subscribed;
-        found.counts.subscribers = subscribers;
-        this.setState(this.state);
+    this.setState({
+      listCommunitiesResponse: await HttpService.client.listCommunities({
+        type_: listingType,
+        sort: "TopMonth",
+        limit: communityLimit,
+        page,
+        auth: myAuth(),
+      }),
+    });
+
+    window.scrollTo(0, 0);
+  }
+
+  findAndUpdateCommunity(res: RequestState<CommunityResponse>) {
+    this.setState(s => {
+      if (
+        s.listCommunitiesResponse.state == "success" &&
+        res.state == "success"
+      ) {
+        s.listCommunitiesResponse.data.communities = editCommunity(
+          res.data.community_view,
+          s.listCommunitiesResponse.data.communities
+        );
       }
-    }
+      return s;
+    });
   }
 }
index bbc8ba89788468c84e39bc29f986ca2bfe3cf63f..f317c983b364fde1725c28e5e26a684ebf52679e 100644 (file)
@@ -1,24 +1,12 @@
 import { Component, linkEvent } from "inferno";
 import {
-  CommunityResponse,
   CommunityView,
   CreateCommunity,
   EditCommunity,
   Language,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
-import { UserService, WebSocketService } from "../../services";
-import {
-  capitalizeFirstLetter,
-  myAuth,
-  randomStr,
-  wsClient,
-  wsSubscribe,
-} from "../../utils";
+import { capitalizeFirstLetter, myAuthRequired, randomStr } from "../../utils";
 import { Icon, Spinner } from "../common/icon";
 import { ImageUploadForm } from "../common/image-upload-form";
 import { LanguageSelect } from "../common/language-select";
@@ -31,8 +19,7 @@ interface CommunityFormProps {
   siteLanguages: number[];
   communityLanguages?: number[];
   onCancel?(): any;
-  onCreate?(community: CommunityView): any;
-  onEdit?(community: CommunityView): any;
+  onUpsertCommunity(form: CreateCommunity | EditCommunity): void;
   enableNsfw?: boolean;
 }
 
@@ -48,6 +35,7 @@ interface CommunityFormState {
     discussion_languages?: number[];
   };
   loading: boolean;
+  submitted: boolean;
 }
 
 export class CommunityForm extends Component<
@@ -55,11 +43,11 @@ export class CommunityForm extends Component<
   CommunityFormState
 > {
   private id = `community-form-${randomStr()}`;
-  private subscription?: Subscription;
 
   state: CommunityFormState = {
     form: {},
     loading: false,
+    submitted: false,
   };
 
   constructor(props: any, context: any) {
@@ -77,12 +65,11 @@ export class CommunityForm extends Component<
     this.handleDiscussionLanguageChange =
       this.handleDiscussionLanguageChange.bind(this);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
     const cv = this.props.community_view;
 
     if (cv) {
       this.state = {
+        ...this.state,
         form: {
           name: cv.community.name,
           title: cv.community.title,
@@ -98,27 +85,9 @@ export class CommunityForm extends Component<
     }
   }
 
-  componentDidUpdate() {
-    if (
-      !this.state.loading &&
-      (this.state.form.name ||
-        this.state.form.title ||
-        this.state.form.description)
-    ) {
-      window.onbeforeunload = () => true;
-    } else {
-      window.onbeforeunload = null;
-    }
-  }
-
-  componentWillUnmount() {
-    this.subscription?.unsubscribe();
-    window.onbeforeunload = null;
-  }
-
   render() {
     return (
-      <>
+      <form onSubmit={linkEvent(this, this.handleCreateCommunitySubmit)}>
         <NavigationPrompt
           when={
             !this.state.loading &&
@@ -126,48 +95,20 @@ export class CommunityForm extends Component<
               this.state.form.name ||
               this.state.form.title ||
               this.state.form.description
-            )
+            ) &&
+            !this.state.submitted
           }
         />
-        <form onSubmit={linkEvent(this, this.handleCreateCommunitySubmit)}>
-          {!this.props.community_view && (
-            <div className="form-group row">
-              <label
-                className="col-12 col-sm-2 col-form-label"
-                htmlFor="community-name"
-              >
-                {i18n.t("name")}
-                <span
-                  className="position-absolute pointer unselectable ml-2 text-muted"
-                  data-tippy-content={i18n.t("name_explain")}
-                >
-                  <Icon icon="help-circle" classes="icon-inline" />
-                </span>
-              </label>
-              <div className="col-12 col-sm-10">
-                <input
-                  type="text"
-                  id="community-name"
-                  className="form-control"
-                  value={this.state.form.name}
-                  onInput={linkEvent(this, this.handleCommunityNameChange)}
-                  required
-                  minLength={3}
-                  pattern="[a-z0-9_]+"
-                  title={i18n.t("community_reqs")}
-                />
-              </div>
-            </div>
-          )}
+        {!this.props.community_view && (
           <div className="form-group row">
             <label
               className="col-12 col-sm-2 col-form-label"
-              htmlFor="community-title"
+              htmlFor="community-name"
             >
-              {i18n.t("display_name")}
+              {i18n.t("name")}
               <span
                 className="position-absolute pointer unselectable ml-2 text-muted"
-                data-tippy-content={i18n.t("display_name_explain")}
+                data-tippy-content={i18n.t("name_explain")}
               >
                 <Icon icon="help-circle" classes="icon-inline" />
               </span>
@@ -175,142 +116,182 @@ export class CommunityForm extends Component<
             <div className="col-12 col-sm-10">
               <input
                 type="text"
-                id="community-title"
-                value={this.state.form.title}
-                onInput={linkEvent(this, this.handleCommunityTitleChange)}
+                id="community-name"
                 className="form-control"
+                value={this.state.form.name}
+                onInput={linkEvent(this, this.handleCommunityNameChange)}
                 required
                 minLength={3}
-                maxLength={100}
+                pattern="[a-z0-9_]+"
+                title={i18n.t("community_reqs")}
               />
             </div>
           </div>
-          <div className="form-group row">
-            <label className="col-12 col-sm-2">{i18n.t("icon")}</label>
-            <div className="col-12 col-sm-10">
-              <ImageUploadForm
-                uploadTitle={i18n.t("upload_icon")}
-                imageSrc={this.state.form.icon}
-                onUpload={this.handleIconUpload}
-                onRemove={this.handleIconRemove}
-                rounded
-              />
-            </div>
+        )}
+        <div className="form-group row">
+          <label
+            className="col-12 col-sm-2 col-form-label"
+            htmlFor="community-title"
+          >
+            {i18n.t("display_name")}
+            <span
+              className="position-absolute pointer unselectable ml-2 text-muted"
+              data-tippy-content={i18n.t("display_name_explain")}
+            >
+              <Icon icon="help-circle" classes="icon-inline" />
+            </span>
+          </label>
+          <div className="col-12 col-sm-10">
+            <input
+              type="text"
+              id="community-title"
+              value={this.state.form.title}
+              onInput={linkEvent(this, this.handleCommunityTitleChange)}
+              className="form-control"
+              required
+              minLength={3}
+              maxLength={100}
+            />
           </div>
-          <div className="form-group row">
-            <label className="col-12 col-sm-2">{i18n.t("banner")}</label>
-            <div className="col-12 col-sm-10">
-              <ImageUploadForm
-                uploadTitle={i18n.t("upload_banner")}
-                imageSrc={this.state.form.banner}
-                onUpload={this.handleBannerUpload}
-                onRemove={this.handleBannerRemove}
-              />
-            </div>
+        </div>
+        <div className="form-group row">
+          <label className="col-12 col-sm-2">{i18n.t("icon")}</label>
+          <div className="col-12 col-sm-10">
+            <ImageUploadForm
+              uploadTitle={i18n.t("upload_icon")}
+              imageSrc={this.state.form.icon}
+              onUpload={this.handleIconUpload}
+              onRemove={this.handleIconRemove}
+              rounded
+            />
           </div>
-          <div className="form-group row">
-            <label className="col-12 col-sm-2 col-form-label" htmlFor={this.id}>
-              {i18n.t("sidebar")}
-            </label>
-            <div className="col-12 col-sm-10">
-              <MarkdownTextArea
-                initialContent={this.state.form.description}
-                placeholder={i18n.t("description")}
-                onContentChange={this.handleCommunityDescriptionChange}
-                allLanguages={[]}
-                siteLanguages={[]}
-              />
-            </div>
+        </div>
+        <div className="form-group row">
+          <label className="col-12 col-sm-2">{i18n.t("banner")}</label>
+          <div className="col-12 col-sm-10">
+            <ImageUploadForm
+              uploadTitle={i18n.t("upload_banner")}
+              imageSrc={this.state.form.banner}
+              onUpload={this.handleBannerUpload}
+              onRemove={this.handleBannerRemove}
+            />
           </div>
+        </div>
+        <div className="form-group row">
+          <label className="col-12 col-sm-2 col-form-label" htmlFor={this.id}>
+            {i18n.t("sidebar")}
+          </label>
+          <div className="col-12 col-sm-10">
+            <MarkdownTextArea
+              initialContent={this.state.form.description}
+              placeholder={i18n.t("description")}
+              onContentChange={this.handleCommunityDescriptionChange}
+              hideNavigationWarnings
+              allLanguages={[]}
+              siteLanguages={[]}
+            />
+          </div>
+        </div>
 
-          {this.props.enableNsfw && (
-            <div className="form-group row">
-              <legend className="col-form-label col-sm-2 pt-0">
-                {i18n.t("nsfw")}
-              </legend>
-              <div className="col-10">
-                <div className="form-check">
-                  <input
-                    className="form-check-input position-static"
-                    id="community-nsfw"
-                    type="checkbox"
-                    checked={this.state.form.nsfw}
-                    onChange={linkEvent(this, this.handleCommunityNsfwChange)}
-                  />
-                </div>
-              </div>
-            </div>
-          )}
+        {this.props.enableNsfw && (
           <div className="form-group row">
-            <legend className="col-form-label col-6 pt-0">
-              {i18n.t("only_mods_can_post_in_community")}
+            <legend className="col-form-label col-sm-2 pt-0">
+              {i18n.t("nsfw")}
             </legend>
-            <div className="col-6">
+            <div className="col-10">
               <div className="form-check">
                 <input
                   className="form-check-input position-static"
-                  id="community-only-mods-can-post"
+                  id="community-nsfw"
                   type="checkbox"
-                  checked={this.state.form.posting_restricted_to_mods}
-                  onChange={linkEvent(
-                    this,
-                    this.handleCommunityPostingRestrictedToMods
-                  )}
+                  checked={this.state.form.nsfw}
+                  onChange={linkEvent(this, this.handleCommunityNsfwChange)}
                 />
               </div>
             </div>
           </div>
-          <LanguageSelect
-            allLanguages={this.props.allLanguages}
-            siteLanguages={this.props.siteLanguages}
-            showSite
-            selectedLanguageIds={this.state.form.discussion_languages}
-            multiple={true}
-            onChange={this.handleDiscussionLanguageChange}
-          />
-          <div className="form-group row">
-            <div className="col-12">
+        )}
+        <div className="form-group row">
+          <legend className="col-form-label col-6 pt-0">
+            {i18n.t("only_mods_can_post_in_community")}
+          </legend>
+          <div className="col-6">
+            <div className="form-check">
+              <input
+                className="form-check-input position-static"
+                id="community-only-mods-can-post"
+                type="checkbox"
+                checked={this.state.form.posting_restricted_to_mods}
+                onChange={linkEvent(
+                  this,
+                  this.handleCommunityPostingRestrictedToMods
+                )}
+              />
+            </div>
+          </div>
+        </div>
+        <LanguageSelect
+          allLanguages={this.props.allLanguages}
+          siteLanguages={this.props.siteLanguages}
+          showSite
+          selectedLanguageIds={this.state.form.discussion_languages}
+          multiple={true}
+          onChange={this.handleDiscussionLanguageChange}
+        />
+        <div className="form-group row">
+          <div className="col-12">
+            <button
+              type="submit"
+              className="btn btn-secondary mr-2"
+              disabled={this.state.loading}
+            >
+              {this.state.loading ? (
+                <Spinner />
+              ) : this.props.community_view ? (
+                capitalizeFirstLetter(i18n.t("save"))
+              ) : (
+                capitalizeFirstLetter(i18n.t("create"))
+              )}
+            </button>
+            {this.props.community_view && (
               <button
-                type="submit"
-                className="btn btn-secondary mr-2"
-                disabled={this.state.loading}
+                type="button"
+                className="btn btn-secondary"
+                onClick={linkEvent(this, this.handleCancel)}
               >
-                {this.state.loading ? (
-                  <Spinner />
-                ) : this.props.community_view ? (
-                  capitalizeFirstLetter(i18n.t("save"))
-                ) : (
-                  capitalizeFirstLetter(i18n.t("create"))
-                )}
+                {i18n.t("cancel")}
               </button>
-              {this.props.community_view && (
-                <button
-                  type="button"
-                  className="btn btn-secondary"
-                  onClick={linkEvent(this, this.handleCancel)}
-                >
-                  {i18n.t("cancel")}
-                </button>
-              )}
-            </div>
+            )}
           </div>
-        </form>
-      </>
+        </div>
+      </form>
     );
   }
 
   handleCreateCommunitySubmit(i: CommunityForm, event: any) {
     event.preventDefault();
-    i.setState({ loading: true });
+    i.setState({ loading: true, submitted: true });
     const cForm = i.state.form;
-    const auth = myAuth();
+    const auth = myAuthRequired();
 
     const cv = i.props.community_view;
 
-    if (auth) {
-      if (cv) {
-        const form: EditCommunity = {
-          community_id: cv.community.id,
+    if (cv) {
+      i.props.onUpsertCommunity({
+        community_id: cv.community.id,
+        title: cForm.title,
+        description: cForm.description,
+        icon: cForm.icon,
+        banner: cForm.banner,
+        nsfw: cForm.nsfw,
+        posting_restricted_to_mods: cForm.posting_restricted_to_mods,
+        discussion_languages: cForm.discussion_languages,
+        auth,
+      });
+    } else {
+      if (cForm.title && cForm.name) {
+        i.props.onUpsertCommunity({
+          name: cForm.name,
           title: cForm.title,
           description: cForm.description,
           icon: cForm.icon,
@@ -319,37 +300,17 @@ export class CommunityForm extends Component<
           posting_restricted_to_mods: cForm.posting_restricted_to_mods,
           discussion_languages: cForm.discussion_languages,
           auth,
-        };
-
-        WebSocketService.Instance.send(wsClient.editCommunity(form));
-      } else {
-        if (cForm.title && cForm.name) {
-          const form: CreateCommunity = {
-            name: cForm.name,
-            title: cForm.title,
-            description: cForm.description,
-            icon: cForm.icon,
-            banner: cForm.banner,
-            nsfw: cForm.nsfw,
-            posting_restricted_to_mods: cForm.posting_restricted_to_mods,
-            discussion_languages: cForm.discussion_languages,
-            auth,
-          };
-          WebSocketService.Instance.send(wsClient.createCommunity(form));
-        }
+        });
       }
     }
-    i.setState(i.state);
   }
 
   handleCommunityNameChange(i: CommunityForm, event: any) {
-    i.state.form.name = event.target.value;
-    i.setState(i.state);
+    i.setState(s => ((s.form.name = event.target.value), s));
   }
 
   handleCommunityTitleChange(i: CommunityForm, event: any) {
-    i.state.form.title = event.target.value;
-    i.setState(i.state);
+    i.setState(s => ((s.form.title = event.target.value), s));
   }
 
   handleCommunityDescriptionChange(val: string) {
@@ -357,13 +318,13 @@ export class CommunityForm extends Component<
   }
 
   handleCommunityNsfwChange(i: CommunityForm, event: any) {
-    i.state.form.nsfw = event.target.checked;
-    i.setState(i.state);
+    i.setState(s => ((s.form.nsfw = event.target.checked), s));
   }
 
   handleCommunityPostingRestrictedToMods(i: CommunityForm, event: any) {
-    i.state.form.posting_restricted_to_mods = event.target.checked;
-    i.setState(i.state);
+    i.setState(
+      s => ((s.form.posting_restricted_to_mods = event.target.checked), s)
+    );
   }
 
   handleCancel(i: CommunityForm) {
@@ -389,56 +350,4 @@ export class CommunityForm extends Component<
   handleDiscussionLanguageChange(val: number[]) {
     this.setState(s => ((s.form.discussion_languages = val), s));
   }
-
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      // Errors handled by top level pages
-      // toast(i18n.t(msg.error), "danger");
-      this.setState({ loading: false });
-      return;
-    } else if (op == UserOperation.CreateCommunity) {
-      const data = wsJsonToRes<CommunityResponse>(msg);
-      this.props.onCreate?.(data.community_view);
-
-      // Update myUserInfo
-      const community = data.community_view.community;
-
-      const mui = UserService.Instance.myUserInfo;
-      if (mui) {
-        const person = mui.local_user_view.person;
-        mui.follows.push({
-          community,
-          follower: person,
-        });
-        mui.moderates.push({
-          community,
-          moderator: person,
-        });
-      }
-    } else if (op == UserOperation.EditCommunity) {
-      const data = wsJsonToRes<CommunityResponse>(msg);
-      this.setState({ loading: false });
-      this.props.onEdit?.(data.community_view);
-      const community = data.community_view.community;
-
-      const mui = UserService.Instance.myUserInfo;
-      if (mui) {
-        const followFound = mui.follows.findIndex(
-          f => f.community.id == community.id
-        );
-        if (followFound) {
-          mui.follows[followFound].community = community;
-        }
-
-        const moderatesFound = mui.moderates.findIndex(
-          f => f.community.id == community.id
-        );
-        if (moderatesFound) {
-          mui.moderates[moderatesFound].community = community;
-        }
-      }
-    }
-  }
 }
index af562c195cac2bf4b248b3d443371fec402c7eb0..7dc150f332fd89b40a8635c79e526ffa53e3b3ed 100644 (file)
@@ -1,59 +1,85 @@
 import { Component, linkEvent } from "inferno";
 import { RouteComponentProps } from "inferno-router/dist/Route";
 import {
+  AddAdmin,
+  AddModToCommunity,
   AddModToCommunityResponse,
+  BanFromCommunity,
   BanFromCommunityResponse,
-  BlockCommunityResponse,
-  BlockPersonResponse,
+  BanPerson,
+  BanPersonResponse,
+  BlockCommunity,
+  BlockPerson,
+  CommentId,
+  CommentReplyResponse,
   CommentResponse,
-  CommentView,
   CommunityResponse,
+  CreateComment,
+  CreateCommentLike,
+  CreateCommentReport,
+  CreatePostLike,
+  CreatePostReport,
+  DeleteComment,
+  DeleteCommunity,
+  DeletePost,
+  DistinguishComment,
+  EditComment,
+  EditCommunity,
+  EditPost,
+  FeaturePost,
+  FollowCommunity,
   GetComments,
   GetCommentsResponse,
   GetCommunity,
   GetCommunityResponse,
   GetPosts,
   GetPostsResponse,
-  PostReportResponse,
+  GetSiteResponse,
+  LockPost,
+  MarkCommentReplyAsRead,
+  MarkPersonMentionAsRead,
   PostResponse,
-  PostView,
+  PurgeComment,
+  PurgeCommunity,
   PurgeItemResponse,
+  PurgePerson,
+  PurgePost,
+  RemoveComment,
+  RemoveCommunity,
+  RemovePost,
+  SaveComment,
+  SavePost,
   SortType,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
+  TransferCommunity,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
 import {
   CommentViewType,
   DataType,
   InitialFetchRequest,
 } from "../../interfaces";
-import { UserService, WebSocketService } from "../../services";
+import { UserService } from "../../services";
+import { FirstLoadService } from "../../services/FirstLoadService";
+import { HttpService, RequestState } from "../../services/HttpService";
 import {
   QueryParams,
   commentsToFlatNodes,
   communityRSSUrl,
-  createCommentLikeRes,
-  createPostLikeFindRes,
-  editCommentRes,
-  editPostFindRes,
+  editComment,
+  editPost,
+  editWith,
   enableDownvotes,
   enableNsfw,
   fetchLimit,
+  getCommentParentId,
   getDataTypeString,
   getPageFromString,
   getQueryParams,
   getQueryString,
-  isPostBlocked,
   myAuth,
-  notifyPost,
-  nsfwCheck,
   postToCommentSortType,
   relTags,
   restoreScrollPosition,
-  saveCommentRes,
   saveScrollPosition,
   setIsoData,
   setupTippy,
@@ -61,8 +87,6 @@ import {
   toast,
   updateCommunityBlock,
   updatePersonBlock,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { CommentNodes } from "../comment/comment-nodes";
 import { BannerIconHeader } from "../common/banner-icon-header";
@@ -77,12 +101,13 @@ import { PostListings } from "../post/post-listings";
 import { CommunityLink } from "./community-link";
 
 interface State {
-  communityRes?: GetCommunityResponse;
-  communityLoading: boolean;
-  listingsLoading: boolean;
-  posts: PostView[];
-  comments: CommentView[];
+  communityRes: RequestState<GetCommunityResponse>;
+  postsRes: RequestState<GetPostsResponse>;
+  commentsRes: RequestState<GetCommentsResponse>;
+  siteRes: GetSiteResponse;
   showSidebarMobile: boolean;
+  finished: Map<CommentId, boolean | undefined>;
+  isIsomorphic: boolean;
 }
 
 interface CommunityProps {
@@ -116,13 +141,14 @@ export class Community extends Component<
   State
 > {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
   state: State = {
-    communityLoading: true,
-    listingsLoading: true,
-    posts: [],
-    comments: [],
+    communityRes: { state: "empty" },
+    postsRes: { state: "empty" },
+    commentsRes: { state: "empty" },
+    siteRes: this.isoData.site_res,
     showSidebarMobile: false,
+    finished: new Map(),
+    isIsomorphic: false,
   };
 
   constructor(props: RouteComponentProps<{ name: string }>, context: any) {
@@ -132,56 +158,73 @@ export class Community extends Component<
     this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
     this.handlePageChange = this.handlePageChange.bind(this);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
+    // All of the action binds
+    this.handleDeleteCommunity = this.handleDeleteCommunity.bind(this);
+    this.handleEditCommunity = this.handleEditCommunity.bind(this);
+    this.handleFollow = this.handleFollow.bind(this);
+    this.handleRemoveCommunity = this.handleRemoveCommunity.bind(this);
+    this.handleCreateComment = this.handleCreateComment.bind(this);
+    this.handleEditComment = this.handleEditComment.bind(this);
+    this.handleSaveComment = this.handleSaveComment.bind(this);
+    this.handleBlockCommunity = this.handleBlockCommunity.bind(this);
+    this.handleBlockPerson = this.handleBlockPerson.bind(this);
+    this.handleDeleteComment = this.handleDeleteComment.bind(this);
+    this.handleRemoveComment = this.handleRemoveComment.bind(this);
+    this.handleCommentVote = this.handleCommentVote.bind(this);
+    this.handleAddModToCommunity = this.handleAddModToCommunity.bind(this);
+    this.handleAddAdmin = this.handleAddAdmin.bind(this);
+    this.handlePurgePerson = this.handlePurgePerson.bind(this);
+    this.handlePurgeComment = this.handlePurgeComment.bind(this);
+    this.handleCommentReport = this.handleCommentReport.bind(this);
+    this.handleDistinguishComment = this.handleDistinguishComment.bind(this);
+    this.handleTransferCommunity = this.handleTransferCommunity.bind(this);
+    this.handleCommentReplyRead = this.handleCommentReplyRead.bind(this);
+    this.handlePersonMentionRead = this.handlePersonMentionRead.bind(this);
+    this.handleBanFromCommunity = this.handleBanFromCommunity.bind(this);
+    this.handleBanPerson = this.handleBanPerson.bind(this);
+    this.handlePostVote = this.handlePostVote.bind(this);
+    this.handlePostEdit = this.handlePostEdit.bind(this);
+    this.handlePostReport = this.handlePostReport.bind(this);
+    this.handleLockPost = this.handleLockPost.bind(this);
+    this.handleDeletePost = this.handleDeletePost.bind(this);
+    this.handleRemovePost = this.handleRemovePost.bind(this);
+    this.handleSavePost = this.handleSavePost.bind(this);
+    this.handlePurgePost = this.handlePurgePost.bind(this);
+    this.handleFeaturePost = this.handleFeaturePost.bind(this);
 
     // Only fetch the data if coming from another route
-    if (this.isoData.path == this.context.router.route.match.url) {
+    if (FirstLoadService.isFirstLoad) {
+      const [communityRes, postsRes, commentsRes] = this.isoData.routeData;
       this.state = {
         ...this.state,
-        communityRes: this.isoData.routeData[0] as GetCommunityResponse,
+        communityRes,
+        postsRes,
+        commentsRes,
+        isIsomorphic: true,
       };
-      const postsRes = this.isoData.routeData[1] as
-        | GetPostsResponse
-        | undefined;
-      const commentsRes = this.isoData.routeData[2] as
-        | GetCommentsResponse
-        | undefined;
-
-      if (postsRes) {
-        this.state = { ...this.state, posts: postsRes.posts };
-      }
-
-      if (commentsRes) {
-        this.state = { ...this.state, comments: commentsRes.comments };
-      }
-
-      this.state = {
-        ...this.state,
-        communityLoading: false,
-        listingsLoading: false,
-      };
-    } else {
-      this.fetchCommunity();
-      this.fetchData();
     }
   }
 
-  fetchCommunity() {
-    const form: GetCommunity = {
-      name: this.props.match.params.name,
-      auth: myAuth(false),
-    };
-    WebSocketService.Instance.send(wsClient.getCommunity(form));
+  async fetchCommunity() {
+    this.setState({ communityRes: { state: "loading" } });
+    this.setState({
+      communityRes: await HttpService.client.getCommunity({
+        name: this.props.match.params.name,
+        auth: myAuth(),
+      }),
+    });
   }
 
-  componentDidMount() {
+  async componentDidMount() {
+    if (!this.state.isIsomorphic) {
+      await Promise.all([this.fetchCommunity(), this.fetchData()]);
+    }
+
     setupTippy();
   }
 
   componentWillUnmount() {
     saveScrollPosition(this.context);
-    this.subscription?.unsubscribe();
   }
 
   static fetchInitialData({
@@ -189,9 +232,11 @@ export class Community extends Component<
     path,
     query: { dataType: urlDataType, page: urlPage, sort: urlSort },
     auth,
-  }: InitialFetchRequest<QueryParams<CommunityProps>>): Promise<any>[] {
+  }: InitialFetchRequest<QueryParams<CommunityProps>>): Promise<
+    RequestState<any>
+  >[] {
     const pathSplit = path.split("/");
-    const promises: Promise<any>[] = [];
+    const promises: Promise<RequestState<any>>[] = [];
 
     const communityName = pathSplit[2];
     const communityForm: GetCommunity = {
@@ -217,7 +262,7 @@ export class Community extends Component<
         auth,
       };
       promises.push(client.getPosts(getPostsForm));
-      promises.push(Promise.resolve());
+      promises.push(Promise.resolve({ state: "empty" }));
     } else {
       const getCommentsForm: GetComments = {
         community_name: communityName,
@@ -228,7 +273,7 @@ export class Community extends Component<
         saved_only: false,
         auth,
       };
-      promises.push(Promise.resolve());
+      promises.push(Promise.resolve({ state: "empty" }));
       promises.push(client.getComments(getCommentsForm));
     }
 
@@ -237,141 +282,192 @@ export class Community extends Component<
 
   get documentTitle(): string {
     const cRes = this.state.communityRes;
-    return cRes
-      ? `${cRes.community_view.community.title} - ${this.isoData.site_res.site_view.site.name}`
+    return cRes.state == "success"
+      ? `${cRes.data.community_view.community.title} - ${this.isoData.site_res.site_view.site.name}`
       : "";
   }
 
-  render() {
-    const res = this.state.communityRes;
-    const { page } = getCommunityQueryParams();
-
-    return (
-      <div className="container-lg">
-        {this.state.communityLoading ? (
+  renderCommunity() {
+    switch (this.state.communityRes.state) {
+      case "loading":
+        return (
           <h5>
             <Spinner large />
           </h5>
-        ) : (
-          res && (
-            <>
-              <HtmlTags
-                title={this.documentTitle}
-                path={this.context.router.route.match.url}
-                description={res.community_view.community.description}
-                image={res.community_view.community.icon}
-              />
-
-              <div className="row">
-                <div className="col-12 col-md-8">
-                  {this.communityInfo}
-                  <div className="d-block d-md-none">
-                    <button
-                      className="btn btn-secondary d-inline-block mb-2 mr-3"
-                      onClick={linkEvent(this, this.handleShowSidebarMobile)}
-                    >
-                      {i18n.t("sidebar")}{" "}
-                      <Icon
-                        icon={
-                          this.state.showSidebarMobile
-                            ? `minus-square`
-                            : `plus-square`
-                        }
-                        classes="icon-inline"
-                      />
-                    </button>
-                    {this.state.showSidebarMobile && this.sidebar(res)}
-                  </div>
-                  {this.selects}
-                  {this.listings}
-                  <Paginator page={page} onChange={this.handlePageChange} />
-                </div>
-                <div className="d-none d-md-block col-md-4">
-                  {this.sidebar(res)}
+        );
+      case "success": {
+        const res = this.state.communityRes.data;
+        const { page } = getCommunityQueryParams();
+
+        return (
+          <>
+            <HtmlTags
+              title={this.documentTitle}
+              path={this.context.router.route.match.url}
+              description={res.community_view.community.description}
+              image={res.community_view.community.icon}
+            />
+
+            <div className="row">
+              <div className="col-12 col-md-8">
+                {this.communityInfo(res)}
+                <div className="d-block d-md-none">
+                  <button
+                    className="btn btn-secondary d-inline-block mb-2 mr-3"
+                    onClick={linkEvent(this, this.handleShowSidebarMobile)}
+                  >
+                    {i18n.t("sidebar")}{" "}
+                    <Icon
+                      icon={
+                        this.state.showSidebarMobile
+                          ? `minus-square`
+                          : `plus-square`
+                      }
+                      classes="icon-inline"
+                    />
+                  </button>
+                  {this.state.showSidebarMobile && this.sidebar(res)}
                 </div>
+                {this.selects(res)}
+                {this.listings(res)}
+                <Paginator page={page} onChange={this.handlePageChange} />
               </div>
-            </>
-          )
-        )}
-      </div>
-    );
+              <div className="d-none d-md-block col-md-4">
+                {this.sidebar(res)}
+              </div>
+            </div>
+          </>
+        );
+      }
+    }
   }
 
-  sidebar({
-    community_view,
-    moderators,
-    online,
-    discussion_languages,
-    site,
-  }: GetCommunityResponse) {
+  render() {
+    return <div className="container-lg">{this.renderCommunity()}</div>;
+  }
+
+  sidebar(res: GetCommunityResponse) {
     const { site_res } = this.isoData;
     // For some reason, this returns an empty vec if it matches the site langs
     const communityLangs =
-      discussion_languages.length === 0
+      res.discussion_languages.length === 0
         ? site_res.all_languages.map(({ id }) => id)
-        : discussion_languages;
+        : res.discussion_languages;
 
     return (
       <>
         <Sidebar
-          community_view={community_view}
-          moderators={moderators}
+          community_view={res.community_view}
+          moderators={res.moderators}
           admins={site_res.admins}
-          online={online}
+          online={res.online}
           enableNsfw={enableNsfw(site_res)}
           editable
           allLanguages={site_res.all_languages}
           siteLanguages={site_res.discussion_languages}
           communityLanguages={communityLangs}
+          onDeleteCommunity={this.handleDeleteCommunity}
+          onRemoveCommunity={this.handleRemoveCommunity}
+          onLeaveModTeam={this.handleAddModToCommunity}
+          onFollowCommunity={this.handleFollow}
+          onBlockCommunity={this.handleBlockCommunity}
+          onPurgeCommunity={this.handlePurgeCommunity}
+          onEditCommunity={this.handleEditCommunity}
         />
-        {!community_view.community.local && site && (
-          <SiteSidebar site={site} showLocal={showLocal(this.isoData)} />
+        {!res.community_view.community.local && res.site && (
+          <SiteSidebar site={res.site} showLocal={showLocal(this.isoData)} />
         )}
       </>
     );
   }
 
-  get listings() {
+  listings(communityRes: GetCommunityResponse) {
     const { dataType } = getCommunityQueryParams();
     const { site_res } = this.isoData;
-    const { listingsLoading, posts, comments, communityRes } = this.state;
-
-    if (listingsLoading) {
-      return (
-        <h5>
-          <Spinner large />
-        </h5>
-      );
-    } else if (dataType === DataType.Post) {
-      return (
-        <PostListings
-          posts={posts}
-          removeDuplicates
-          enableDownvotes={enableDownvotes(site_res)}
-          enableNsfw={enableNsfw(site_res)}
-          allLanguages={site_res.all_languages}
-          siteLanguages={site_res.discussion_languages}
-        />
-      );
+
+    if (dataType === DataType.Post) {
+      switch (this.state.postsRes.state) {
+        case "loading":
+          return (
+            <h5>
+              <Spinner large />
+            </h5>
+          );
+        case "success":
+          return (
+            <PostListings
+              posts={this.state.postsRes.data.posts}
+              removeDuplicates
+              enableDownvotes={enableDownvotes(site_res)}
+              enableNsfw={enableNsfw(site_res)}
+              allLanguages={site_res.all_languages}
+              siteLanguages={site_res.discussion_languages}
+              onBlockPerson={this.handleBlockPerson}
+              onPostEdit={this.handlePostEdit}
+              onPostVote={this.handlePostVote}
+              onPostReport={this.handlePostReport}
+              onLockPost={this.handleLockPost}
+              onDeletePost={this.handleDeletePost}
+              onRemovePost={this.handleRemovePost}
+              onSavePost={this.handleSavePost}
+              onPurgePerson={this.handlePurgePerson}
+              onPurgePost={this.handlePurgePost}
+              onBanPerson={this.handleBanPerson}
+              onBanPersonFromCommunity={this.handleBanFromCommunity}
+              onAddModToCommunity={this.handleAddModToCommunity}
+              onAddAdmin={this.handleAddAdmin}
+              onTransferCommunity={this.handleTransferCommunity}
+              onFeaturePost={this.handleFeaturePost}
+            />
+          );
+      }
     } else {
-      return (
-        <CommentNodes
-          nodes={commentsToFlatNodes(comments)}
-          viewType={CommentViewType.Flat}
-          noIndent
-          showContext
-          enableDownvotes={enableDownvotes(site_res)}
-          moderators={communityRes?.moderators}
-          admins={site_res.admins}
-          allLanguages={site_res.all_languages}
-          siteLanguages={site_res.discussion_languages}
-        />
-      );
+      switch (this.state.commentsRes.state) {
+        case "loading":
+          return (
+            <h5>
+              <Spinner large />
+            </h5>
+          );
+        case "success":
+          return (
+            <CommentNodes
+              nodes={commentsToFlatNodes(this.state.commentsRes.data.comments)}
+              viewType={CommentViewType.Flat}
+              finished={this.state.finished}
+              noIndent
+              showContext
+              enableDownvotes={enableDownvotes(site_res)}
+              moderators={communityRes.moderators}
+              admins={site_res.admins}
+              allLanguages={site_res.all_languages}
+              siteLanguages={site_res.discussion_languages}
+              onSaveComment={this.handleSaveComment}
+              onBlockPerson={this.handleBlockPerson}
+              onDeleteComment={this.handleDeleteComment}
+              onRemoveComment={this.handleRemoveComment}
+              onCommentVote={this.handleCommentVote}
+              onCommentReport={this.handleCommentReport}
+              onDistinguishComment={this.handleDistinguishComment}
+              onAddModToCommunity={this.handleAddModToCommunity}
+              onAddAdmin={this.handleAddAdmin}
+              onTransferCommunity={this.handleTransferCommunity}
+              onPurgeComment={this.handlePurgeComment}
+              onPurgePerson={this.handlePurgePerson}
+              onCommentReplyRead={this.handleCommentReplyRead}
+              onPersonMentionRead={this.handlePersonMentionRead}
+              onBanPersonFromCommunity={this.handleBanFromCommunity}
+              onBanPerson={this.handleBanPerson}
+              onCreateComment={this.handleCreateComment}
+              onEditComment={this.handleEditComment}
+            />
+          );
+      }
     }
   }
 
-  get communityInfo() {
-    const community = this.state.communityRes?.community_view.community;
+  communityInfo(res: GetCommunityResponse) {
+    const community = res.community_view.community;
 
     return (
       community && (
@@ -390,12 +486,11 @@ export class Community extends Component<
     );
   }
 
-  get selects() {
+  selects(res: GetCommunityResponse) {
     // let communityRss = this.state.communityRes.map(r =>
     //   communityRSSUrl(r.community_view.community.actor_id, this.state.sort)
     // );
     const { dataType, sort } = getCommunityQueryParams();
-    const res = this.state.communityRes;
     const communityRss = res
       ? communityRSSUrl(res.community_view.community.actor_id, sort)
       : undefined;
@@ -448,7 +543,7 @@ export class Community extends Component<
     }));
   }
 
-  updateUrl({ dataType, page, sort }: Partial<CommunityProps>) {
+  async updateUrl({ dataType, page, sort }: Partial<CommunityProps>) {
     const {
       dataType: urlDataType,
       page: urlPage,
@@ -465,284 +560,368 @@ export class Community extends Component<
       `/c/${this.props.match.params.name}${getQueryString(queryParams)}`
     );
 
-    this.setState({
-      comments: [],
-      posts: [],
-      listingsLoading: true,
-    });
-
-    this.fetchData();
+    await this.fetchData();
   }
 
-  fetchData() {
+  async fetchData() {
     const { dataType, page, sort } = getCommunityQueryParams();
     const { name } = this.props.match.params;
 
-    let req: string;
     if (dataType === DataType.Post) {
-      const form: GetPosts = {
-        page,
-        limit: fetchLimit,
-        sort,
-        type_: "All",
-        community_name: name,
-        saved_only: false,
-        auth: myAuth(false),
-      };
-      req = wsClient.getPosts(form);
+      this.setState({ postsRes: { state: "loading" } });
+      this.setState({
+        postsRes: await HttpService.client.getPosts({
+          page,
+          limit: fetchLimit,
+          sort,
+          type_: "All",
+          community_name: name,
+          saved_only: false,
+          auth: myAuth(),
+        }),
+      });
     } else {
-      const form: GetComments = {
-        page,
-        limit: fetchLimit,
-        sort: postToCommentSortType(sort),
-        type_: "All",
-        community_name: name,
-        saved_only: false,
-        auth: myAuth(false),
-      };
-
-      req = wsClient.getComments(form);
+      this.setState({ commentsRes: { state: "loading" } });
+      this.setState({
+        commentsRes: await HttpService.client.getComments({
+          page,
+          limit: fetchLimit,
+          sort: postToCommentSortType(sort),
+          type_: "All",
+          community_name: name,
+          saved_only: false,
+          auth: myAuth(),
+        }),
+      });
     }
 
-    WebSocketService.Instance.send(req);
+    restoreScrollPosition(this.context);
+    setupTippy();
   }
 
-  parseMessage(msg: any) {
-    const { page } = getCommunityQueryParams();
-    const op = wsUserOp(msg);
-    console.log(msg);
-    const res = this.state.communityRes;
-
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      this.context.router.history.push("/");
-    } else if (msg.reconnect) {
-      if (res) {
-        WebSocketService.Instance.send(
-          wsClient.communityJoin({
-            community_id: res.community_view.community.id,
-          })
-        );
-      }
-
-      this.fetchData();
-    } else {
-      switch (op) {
-        case UserOperation.GetCommunity: {
-          const data = wsJsonToRes<GetCommunityResponse>(msg);
-
-          this.setState({ communityRes: data, communityLoading: false });
-          // TODO why is there no auth in this form?
-          WebSocketService.Instance.send(
-            wsClient.communityJoin({
-              community_id: data.community_view.community.id,
-            })
-          );
+  async handleDeleteCommunity(form: DeleteCommunity) {
+    const deleteCommunityRes = await HttpService.client.deleteCommunity(form);
+    this.updateCommunity(deleteCommunityRes);
+  }
 
-          break;
-        }
+  async handleAddModToCommunity(form: AddModToCommunity) {
+    const addModRes = await HttpService.client.addModToCommunity(form);
+    this.updateModerators(addModRes);
+  }
 
-        case UserOperation.EditCommunity:
-        case UserOperation.DeleteCommunity:
-        case UserOperation.RemoveCommunity: {
-          const { community_view, discussion_languages } =
-            wsJsonToRes<CommunityResponse>(msg);
+  async handleFollow(form: FollowCommunity) {
+    const followCommunityRes = await HttpService.client.followCommunity(form);
+    this.updateCommunity(followCommunityRes);
 
-          if (res) {
-            res.community_view = community_view;
-            res.discussion_languages = discussion_languages;
-            this.setState(this.state);
-          }
+    // Update myUserInfo
+    if (followCommunityRes.state == "success") {
+      const communityId = followCommunityRes.data.community_view.community.id;
+      const mui = UserService.Instance.myUserInfo;
+      if (mui) {
+        mui.follows = mui.follows.filter(i => i.community.id != communityId);
+      }
+    }
+  }
 
-          break;
-        }
+  async handlePurgeCommunity(form: PurgeCommunity) {
+    const purgeCommunityRes = await HttpService.client.purgeCommunity(form);
+    this.purgeItem(purgeCommunityRes);
+  }
 
-        case UserOperation.FollowCommunity: {
-          const {
-            community_view: {
-              subscribed,
-              counts: { subscribers },
-            },
-          } = wsJsonToRes<CommunityResponse>(msg);
-
-          if (res) {
-            res.community_view.subscribed = subscribed;
-            res.community_view.counts.subscribers = subscribers;
-            this.setState(this.state);
-          }
-
-          break;
-        }
+  async handlePurgePerson(form: PurgePerson) {
+    const purgePersonRes = await HttpService.client.purgePerson(form);
+    this.purgeItem(purgePersonRes);
+  }
 
-        case UserOperation.GetPosts: {
-          const { posts } = wsJsonToRes<GetPostsResponse>(msg);
+  async handlePurgeComment(form: PurgeComment) {
+    const purgeCommentRes = await HttpService.client.purgeComment(form);
+    this.purgeItem(purgeCommentRes);
+  }
 
-          this.setState({ posts, listingsLoading: false });
-          restoreScrollPosition(this.context);
-          setupTippy();
+  async handlePurgePost(form: PurgePost) {
+    const purgeRes = await HttpService.client.purgePost(form);
+    this.purgeItem(purgeRes);
+  }
 
-          break;
-        }
+  async handleBlockCommunity(form: BlockCommunity) {
+    const blockCommunityRes = await HttpService.client.blockCommunity(form);
+    if (blockCommunityRes.state == "success") {
+      updateCommunityBlock(blockCommunityRes.data);
+    }
+  }
 
-        case UserOperation.EditPost:
-        case UserOperation.DeletePost:
-        case UserOperation.RemovePost:
-        case UserOperation.LockPost:
-        case UserOperation.FeaturePost:
-        case UserOperation.SavePost: {
-          const { post_view } = wsJsonToRes<PostResponse>(msg);
+  async handleBlockPerson(form: BlockPerson) {
+    const blockPersonRes = await HttpService.client.blockPerson(form);
+    if (blockPersonRes.state == "success") {
+      updatePersonBlock(blockPersonRes.data);
+    }
+  }
 
-          editPostFindRes(post_view, this.state.posts);
-          this.setState(this.state);
+  async handleRemoveCommunity(form: RemoveCommunity) {
+    const removeCommunityRes = await HttpService.client.removeCommunity(form);
+    this.updateCommunity(removeCommunityRes);
+  }
 
-          break;
-        }
+  async handleEditCommunity(form: EditCommunity) {
+    const res = await HttpService.client.editCommunity(form);
+    this.updateCommunity(res);
 
-        case UserOperation.CreatePost: {
-          const { post_view } = wsJsonToRes<PostResponse>(msg);
+    return res;
+  }
 
-          const showPostNotifs =
-            UserService.Instance.myUserInfo?.local_user_view.local_user
-              .show_new_post_notifs;
+  async handleCreateComment(form: CreateComment) {
+    const createCommentRes = await HttpService.client.createComment(form);
+    this.createAndUpdateComments(createCommentRes);
 
-          // Only push these if you're on the first page, you pass the nsfw check, and it isn't blocked
-          if (page === 1 && nsfwCheck(post_view) && !isPostBlocked(post_view)) {
-            this.state.posts.unshift(post_view);
-            if (showPostNotifs) {
-              notifyPost(post_view, this.context.router);
-            }
-            this.setState(this.state);
-          }
+    return createCommentRes;
+  }
 
-          break;
-        }
+  async handleEditComment(form: EditComment) {
+    const editCommentRes = await HttpService.client.editComment(form);
+    this.findAndUpdateComment(editCommentRes);
 
-        case UserOperation.CreatePostLike: {
-          const { post_view } = wsJsonToRes<PostResponse>(msg);
+    return editCommentRes;
+  }
 
-          createPostLikeFindRes(post_view, this.state.posts);
-          this.setState(this.state);
+  async handleDeleteComment(form: DeleteComment) {
+    const deleteCommentRes = await HttpService.client.deleteComment(form);
+    this.findAndUpdateComment(deleteCommentRes);
+  }
 
-          break;
-        }
+  async handleDeletePost(form: DeletePost) {
+    const deleteRes = await HttpService.client.deletePost(form);
+    this.findAndUpdatePost(deleteRes);
+  }
 
-        case UserOperation.AddModToCommunity: {
-          const { moderators } = wsJsonToRes<AddModToCommunityResponse>(msg);
+  async handleRemovePost(form: RemovePost) {
+    const removeRes = await HttpService.client.removePost(form);
+    this.findAndUpdatePost(removeRes);
+  }
 
-          if (res) {
-            res.moderators = moderators;
-            this.setState(this.state);
-          }
+  async handleRemoveComment(form: RemoveComment) {
+    const removeCommentRes = await HttpService.client.removeComment(form);
+    this.findAndUpdateComment(removeCommentRes);
+  }
 
-          break;
-        }
+  async handleSaveComment(form: SaveComment) {
+    const saveCommentRes = await HttpService.client.saveComment(form);
+    this.findAndUpdateComment(saveCommentRes);
+  }
 
-        case UserOperation.BanFromCommunity: {
-          const {
-            person_view: {
-              person: { id: personId },
-            },
-            banned,
-          } = wsJsonToRes<BanFromCommunityResponse>(msg);
+  async handleSavePost(form: SavePost) {
+    const saveRes = await HttpService.client.savePost(form);
+    this.findAndUpdatePost(saveRes);
+  }
 
-          // TODO this might be incorrect
-          this.state.posts
-            .filter(p => p.creator.id === personId)
-            .forEach(p => (p.creator_banned_from_community = banned));
+  async handleFeaturePost(form: FeaturePost) {
+    const featureRes = await HttpService.client.featurePost(form);
+    this.findAndUpdatePost(featureRes);
+  }
 
-          this.setState(this.state);
+  async handleCommentVote(form: CreateCommentLike) {
+    const voteRes = await HttpService.client.likeComment(form);
+    this.findAndUpdateComment(voteRes);
+  }
 
-          break;
-        }
+  async handlePostEdit(form: EditPost) {
+    const res = await HttpService.client.editPost(form);
+    this.findAndUpdatePost(res);
+  }
 
-        case UserOperation.GetComments: {
-          const { comments } = wsJsonToRes<GetCommentsResponse>(msg);
-          this.setState({ comments, listingsLoading: false });
+  async handlePostVote(form: CreatePostLike) {
+    const voteRes = await HttpService.client.likePost(form);
+    this.findAndUpdatePost(voteRes);
+  }
 
-          break;
-        }
+  async handleCommentReport(form: CreateCommentReport) {
+    const reportRes = await HttpService.client.createCommentReport(form);
+    if (reportRes.state == "success") {
+      toast(i18n.t("report_created"));
+    }
+  }
 
-        case UserOperation.EditComment:
-        case UserOperation.DeleteComment:
-        case UserOperation.RemoveComment: {
-          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
-          editCommentRes(comment_view, this.state.comments);
-          this.setState(this.state);
+  async handlePostReport(form: CreatePostReport) {
+    const reportRes = await HttpService.client.createPostReport(form);
+    if (reportRes.state == "success") {
+      toast(i18n.t("report_created"));
+    }
+  }
 
-          break;
-        }
+  async handleLockPost(form: LockPost) {
+    const lockRes = await HttpService.client.lockPost(form);
+    this.findAndUpdatePost(lockRes);
+  }
 
-        case UserOperation.CreateComment: {
-          const { form_id, comment_view } = wsJsonToRes<CommentResponse>(msg);
+  async handleDistinguishComment(form: DistinguishComment) {
+    const distinguishRes = await HttpService.client.distinguishComment(form);
+    this.findAndUpdateComment(distinguishRes);
+  }
 
-          // Necessary since it might be a user reply
-          if (form_id) {
-            this.setState(({ comments }) => ({
-              comments: [comment_view].concat(comments),
-            }));
-          }
+  async handleAddAdmin(form: AddAdmin) {
+    const addAdminRes = await HttpService.client.addAdmin(form);
 
-          break;
-        }
+    if (addAdminRes.state == "success") {
+      this.setState(s => ((s.siteRes.admins = addAdminRes.data.admins), s));
+    }
+  }
 
-        case UserOperation.SaveComment: {
-          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
+  async handleTransferCommunity(form: TransferCommunity) {
+    const transferCommunityRes = await HttpService.client.transferCommunity(
+      form
+    );
+    toast(i18n.t("transfer_community"));
+    this.updateCommunityFull(transferCommunityRes);
+  }
 
-          saveCommentRes(comment_view, this.state.comments);
-          this.setState(this.state);
+  async handleCommentReplyRead(form: MarkCommentReplyAsRead) {
+    const readRes = await HttpService.client.markCommentReplyAsRead(form);
+    this.findAndUpdateCommentReply(readRes);
+  }
 
-          break;
-        }
+  async handlePersonMentionRead(form: MarkPersonMentionAsRead) {
+    // TODO not sure what to do here. Maybe it is actually optional, because post doesn't need it.
+    await HttpService.client.markPersonMentionAsRead(form);
+  }
 
-        case UserOperation.CreateCommentLike: {
-          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
+  async handleBanFromCommunity(form: BanFromCommunity) {
+    const banRes = await HttpService.client.banFromCommunity(form);
+    this.updateBanFromCommunity(banRes);
+  }
 
-          createCommentLikeRes(comment_view, this.state.comments);
-          this.setState(this.state);
+  async handleBanPerson(form: BanPerson) {
+    const banRes = await HttpService.client.banPerson(form);
+    this.updateBan(banRes);
+  }
 
-          break;
+  updateBanFromCommunity(banRes: RequestState<BanFromCommunityResponse>) {
+    // Maybe not necessary
+    if (banRes.state == "success") {
+      this.setState(s => {
+        if (s.postsRes.state == "success") {
+          s.postsRes.data.posts
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(
+              c => (c.creator_banned_from_community = banRes.data.banned)
+            );
         }
+        if (s.commentsRes.state == "success") {
+          s.commentsRes.data.comments
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(
+              c => (c.creator_banned_from_community = banRes.data.banned)
+            );
+        }
+        return s;
+      });
+    }
+  }
 
-        case UserOperation.BlockPerson: {
-          const data = wsJsonToRes<BlockPersonResponse>(msg);
-          updatePersonBlock(data);
-
-          break;
+  updateBan(banRes: RequestState<BanPersonResponse>) {
+    // Maybe not necessary
+    if (banRes.state == "success") {
+      this.setState(s => {
+        if (s.postsRes.state == "success") {
+          s.postsRes.data.posts
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(c => (c.creator.banned = banRes.data.banned));
+        }
+        if (s.commentsRes.state == "success") {
+          s.commentsRes.data.comments
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(c => (c.creator.banned = banRes.data.banned));
         }
+        return s;
+      });
+    }
+  }
 
-        case UserOperation.CreatePostReport:
-        case UserOperation.CreateCommentReport: {
-          const data = wsJsonToRes<PostReportResponse>(msg);
+  updateCommunity(res: RequestState<CommunityResponse>) {
+    this.setState(s => {
+      if (s.communityRes.state == "success" && res.state == "success") {
+        s.communityRes.data.community_view = res.data.community_view;
+        s.communityRes.data.discussion_languages =
+          res.data.discussion_languages;
+      }
+      return s;
+    });
+  }
 
-          if (data) {
-            toast(i18n.t("report_created"));
-          }
+  updateCommunityFull(res: RequestState<GetCommunityResponse>) {
+    this.setState(s => {
+      if (s.communityRes.state == "success" && res.state == "success") {
+        s.communityRes.data.community_view = res.data.community_view;
+        s.communityRes.data.moderators = res.data.moderators;
+      }
+      return s;
+    });
+  }
 
-          break;
-        }
+  purgeItem(purgeRes: RequestState<PurgeItemResponse>) {
+    if (purgeRes.state == "success") {
+      toast(i18n.t("purge_success"));
+      this.context.router.history.push(`/`);
+    }
+  }
 
-        case UserOperation.PurgeCommunity: {
-          const { success } = wsJsonToRes<PurgeItemResponse>(msg);
+  findAndUpdateComment(res: RequestState<CommentResponse>) {
+    this.setState(s => {
+      if (s.commentsRes.state == "success" && res.state == "success") {
+        s.commentsRes.data.comments = editComment(
+          res.data.comment_view,
+          s.commentsRes.data.comments
+        );
+        s.finished.set(res.data.comment_view.comment.id, true);
+      }
+      return s;
+    });
+  }
 
-          if (success) {
-            toast(i18n.t("purge_success"));
-            this.context.router.history.push(`/`);
-          }
+  createAndUpdateComments(res: RequestState<CommentResponse>) {
+    this.setState(s => {
+      if (s.commentsRes.state == "success" && res.state == "success") {
+        s.commentsRes.data.comments.unshift(res.data.comment_view);
 
-          break;
-        }
+        // Set finished for the parent
+        s.finished.set(
+          getCommentParentId(res.data.comment_view.comment) ?? 0,
+          true
+        );
+      }
+      return s;
+    });
+  }
+
+  findAndUpdateCommentReply(res: RequestState<CommentReplyResponse>) {
+    this.setState(s => {
+      if (s.commentsRes.state == "success" && res.state == "success") {
+        s.commentsRes.data.comments = editWith(
+          res.data.comment_reply_view,
+          s.commentsRes.data.comments
+        );
+      }
+      return s;
+    });
+  }
 
-        case UserOperation.BlockCommunity: {
-          const data = wsJsonToRes<BlockCommunityResponse>(msg);
-          if (res) {
-            res.community_view.blocked = data.blocked;
-            this.setState(this.state);
-          }
-          updateCommunityBlock(data);
+  findAndUpdatePost(res: RequestState<PostResponse>) {
+    this.setState(s => {
+      if (s.postsRes.state == "success" && res.state == "success") {
+        s.postsRes.data.posts = editPost(
+          res.data.post_view,
+          s.postsRes.data.posts
+        );
+      }
+      return s;
+    });
+  }
 
-          break;
-        }
+  updateModerators(res: RequestState<AddModToCommunityResponse>) {
+    // Update the moderators
+    this.setState(s => {
+      if (s.communityRes.state == "success" && res.state == "success") {
+        s.communityRes.data.moderators = res.data.moderators;
       }
-    }
+      return s;
+    });
   }
 }
index 3650356876841261d2470999847215de1ebfc536..f75c4fbb3e7db0fe2a94ccd5845f3a1cf2ee5b33 100644 (file)
@@ -1,42 +1,26 @@
 import { Component } from "inferno";
-import { CommunityView, GetSiteResponse } from "lemmy-js-client";
-import { Subscription } from "rxjs";
-import { i18n } from "../../i18next";
 import {
-  enableNsfw,
-  isBrowser,
-  setIsoData,
-  toast,
-  wsSubscribe,
-} from "../../utils";
+  CreateCommunity as CreateCommunityI,
+  GetSiteResponse,
+} from "lemmy-js-client";
+import { i18n } from "../../i18next";
+import { HttpService } from "../../services/HttpService";
+import { enableNsfw, setIsoData } from "../../utils";
 import { HtmlTags } from "../common/html-tags";
-import { Spinner } from "../common/icon";
 import { CommunityForm } from "./community-form";
 
 interface CreateCommunityState {
   siteRes: GetSiteResponse;
-  loading: boolean;
 }
 
 export class CreateCommunity extends Component<any, CreateCommunityState> {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
   state: CreateCommunityState = {
     siteRes: this.isoData.site_res,
-    loading: false,
   };
   constructor(props: any, context: any) {
     super(props, context);
     this.handleCommunityCreate = this.handleCommunityCreate.bind(this);
-
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
-  }
-
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
-    }
   }
 
   get documentTitle(): string {
@@ -52,35 +36,27 @@ export class CreateCommunity extends Component<any, CreateCommunityState> {
           title={this.documentTitle}
           path={this.context.router.route.match.url}
         />
-        {this.state.loading ? (
-          <h5>
-            <Spinner large />
-          </h5>
-        ) : (
-          <div className="row">
-            <div className="col-12 col-lg-6 offset-lg-3 mb-4">
-              <h5>{i18n.t("create_community")}</h5>
-              <CommunityForm
-                onCreate={this.handleCommunityCreate}
-                enableNsfw={enableNsfw(this.state.siteRes)}
-                allLanguages={this.state.siteRes.all_languages}
-                siteLanguages={this.state.siteRes.discussion_languages}
-                communityLanguages={this.state.siteRes.discussion_languages}
-              />
-            </div>
+        <div className="row">
+          <div className="col-12 col-lg-6 offset-lg-3 mb-4">
+            <h5>{i18n.t("create_community")}</h5>
+            <CommunityForm
+              onUpsertCommunity={this.handleCommunityCreate}
+              enableNsfw={enableNsfw(this.state.siteRes)}
+              allLanguages={this.state.siteRes.all_languages}
+              siteLanguages={this.state.siteRes.discussion_languages}
+              communityLanguages={this.state.siteRes.discussion_languages}
+            />
           </div>
-        )}
+        </div>
       </div>
     );
   }
 
-  handleCommunityCreate(cv: CommunityView) {
-    this.props.history.push(`/c/${cv.community.name}`);
-  }
-
-  parseMessage(msg: any) {
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
+  async handleCommunityCreate(form: CreateCommunityI) {
+    const res = await HttpService.client.createCommunity(form);
+    if (res.state === "success") {
+      const name = res.data.community_view.community.name;
+      this.props.history.replace(`/c/${name}`);
     }
   }
 }
index d592571c5a83604edfcd9581295e4efeeaa7fc40..a5c620f3b4b5704e8763a4d549af8fbd97c877d8 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, linkEvent } from "inferno";
+import { Component, InfernoNode, linkEvent } from "inferno";
 import { Link } from "inferno-router";
 import {
   AddModToCommunity,
@@ -6,6 +6,7 @@ import {
   CommunityModeratorView,
   CommunityView,
   DeleteCommunity,
+  EditCommunity,
   FollowCommunity,
   Language,
   PersonView,
@@ -13,7 +14,7 @@ import {
   RemoveCommunity,
 } from "lemmy-js-client";
 import { i18n } from "../../i18next";
-import { UserService, WebSocketService } from "../../services";
+import { UserService } from "../../services";
 import {
   amAdmin,
   amMod,
@@ -21,9 +22,8 @@ import {
   getUnixTime,
   hostname,
   mdToHtml,
-  myAuth,
+  myAuthRequired,
   numToSI,
-  wsClient,
 } from "../../utils";
 import { BannerIconHeader } from "../common/banner-icon-header";
 import { Icon, PurgeWarning, Spinner } from "../common/icon";
@@ -42,6 +42,13 @@ interface SidebarProps {
   enableNsfw?: boolean;
   showIcon?: boolean;
   editable?: boolean;
+  onDeleteCommunity(form: DeleteCommunity): void;
+  onRemoveCommunity(form: RemoveCommunity): void;
+  onLeaveModTeam(form: AddModToCommunity): void;
+  onFollowCommunity(form: FollowCommunity): void;
+  onBlockCommunity(form: BlockCommunity): void;
+  onPurgeCommunity(form: PurgeCommunity): void;
+  onEditCommunity(form: EditCommunity): void;
 }
 
 interface SidebarState {
@@ -51,8 +58,13 @@ interface SidebarState {
   showRemoveDialog: boolean;
   showPurgeDialog: boolean;
   purgeReason?: string;
-  purgeLoading: boolean;
   showConfirmLeaveModTeam: boolean;
+  deleteCommunityLoading: boolean;
+  removeCommunityLoading: boolean;
+  leaveModTeamLoading: boolean;
+  followCommunityLoading: boolean;
+  blockCommunityLoading: boolean;
+  purgeCommunityLoading: boolean;
 }
 
 export class Sidebar extends Component<SidebarProps, SidebarState> {
@@ -60,16 +72,44 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
     showEdit: false,
     showRemoveDialog: false,
     showPurgeDialog: false,
-    purgeLoading: false,
     showConfirmLeaveModTeam: false,
+    deleteCommunityLoading: false,
+    removeCommunityLoading: false,
+    leaveModTeamLoading: false,
+    followCommunityLoading: false,
+    blockCommunityLoading: false,
+    purgeCommunityLoading: false,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
-    this.handleEditCommunity = this.handleEditCommunity.bind(this);
     this.handleEditCancel = this.handleEditCancel.bind(this);
   }
 
+  componentWillReceiveProps(
+    nextProps: Readonly<{ children?: InfernoNode } & SidebarProps>
+  ): void {
+    if (this.props.moderators != nextProps.moderators) {
+      this.setState({
+        showConfirmLeaveModTeam: false,
+      });
+    }
+
+    if (this.props.community_view != nextProps.community_view) {
+      this.setState({
+        showEdit: false,
+        showPurgeDialog: false,
+        showRemoveDialog: false,
+        deleteCommunityLoading: false,
+        removeCommunityLoading: false,
+        leaveModTeamLoading: false,
+        followCommunityLoading: false,
+        blockCommunityLoading: false,
+        purgeCommunityLoading: false,
+      });
+    }
+  }
+
   render() {
     return (
       <div>
@@ -81,7 +121,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
             allLanguages={this.props.allLanguages}
             siteLanguages={this.props.siteLanguages}
             communityLanguages={this.props.communityLanguages}
-            onEdit={this.handleEditCommunity}
+            onUpsertCommunity={this.props.onEditCommunity}
             onCancel={this.handleEditCancel}
             enableNsfw={this.props.enableNsfw}
           />
@@ -138,18 +178,28 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
           {subscribed === "Subscribed" && (
             <button
               className="btn btn-secondary btn-sm mr-2"
-              onClick={linkEvent(this, this.handleUnsubscribe)}
+              onClick={linkEvent(this, this.handleUnfollowCommunity)}
             >
-              <Icon icon="check" classes="icon-inline text-success mr-1" />
-              {i18n.t("joined")}
+              {this.state.followCommunityLoading ? (
+                <Spinner />
+              ) : (
+                <>
+                  <Icon icon="check" classes="icon-inline text-success mr-1" />
+                  {i18n.t("joined")}
+                </>
+              )}
             </button>
           )}
           {subscribed === "Pending" && (
             <button
               className="btn btn-warning mr-2"
-              onClick={linkEvent(this, this.handleUnsubscribe)}
+              onClick={linkEvent(this, this.handleUnfollowCommunity)}
             >
-              {i18n.t("subscribe_pending")}
+              {this.state.followCommunityLoading ? (
+                <Spinner />
+              ) : (
+                i18n.t("subscribe_pending")
+              )}
             </button>
           )}
           {community.removed && (
@@ -306,9 +356,13 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
         {community_view.subscribed == "NotSubscribed" && (
           <button
             className="btn btn-secondary btn-block"
-            onClick={linkEvent(this, this.handleSubscribe)}
+            onClick={linkEvent(this, this.handleFollowCommunity)}
           >
-            {i18n.t("subscribe")}
+            {this.state.followCommunityLoading ? (
+              <Spinner />
+            ) : (
+              i18n.t("subscribe")
+            )}
           </button>
         )}
       </div>
@@ -325,16 +379,24 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
           (blocked ? (
             <button
               className="btn btn-danger btn-block"
-              onClick={linkEvent(this, this.handleUnblock)}
+              onClick={linkEvent(this, this.handleBlockCommunity)}
             >
-              {i18n.t("unblock_community")}
+              {this.state.blockCommunityLoading ? (
+                <Spinner />
+              ) : (
+                i18n.t("unblock_community")
+              )}
             </button>
           ) : (
             <button
               className="btn btn-danger btn-block"
-              onClick={linkEvent(this, this.handleBlock)}
+              onClick={linkEvent(this, this.handleBlockCommunity)}
             >
-              {i18n.t("block_community")}
+              {this.state.blockCommunityLoading ? (
+                <Spinner />
+              ) : (
+                i18n.t("block_community")
+              )}
             </button>
           ))}
       </div>
@@ -388,7 +450,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
                     <li className="list-inline-item-action">
                       <button
                         className="btn btn-link text-muted d-inline-block"
-                        onClick={linkEvent(this, this.handleLeaveModTeamClick)}
+                        onClick={linkEvent(this, this.handleLeaveModTeam)}
                       >
                         {i18n.t("yes")}
                       </button>
@@ -410,7 +472,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
                 <li className="list-inline-item-action">
                   <button
                     className="btn btn-link text-muted d-inline-block"
-                    onClick={linkEvent(this, this.handleDeleteClick)}
+                    onClick={linkEvent(this, this.handleDeleteCommunity)}
                     data-tippy-content={
                       !community_view.community.deleted
                         ? i18n.t("delete")
@@ -422,12 +484,16 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
                         : i18n.t("restore")
                     }
                   >
-                    <Icon
-                      icon="trash"
-                      classes={`icon-inline ${
-                        community_view.community.deleted && "text-danger"
-                      }`}
-                    />
+                    {this.state.deleteCommunityLoading ? (
+                      <Spinner />
+                    ) : (
+                      <Icon
+                        icon="trash"
+                        classes={`icon-inline ${
+                          community_view.community.deleted && "text-danger"
+                        }`}
+                      />
+                    )}{" "}
                   </button>
                 </li>
               )}
@@ -445,9 +511,13 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
               ) : (
                 <button
                   className="btn btn-link text-muted d-inline-block"
-                  onClick={linkEvent(this, this.handleModRemoveSubmit)}
+                  onClick={linkEvent(this, this.handleRemoveCommunity)}
                 >
-                  {i18n.t("restore")}
+                  {this.state.removeCommunityLoading ? (
+                    <Spinner />
+                  ) : (
+                    i18n.t("restore")
+                  )}
                 </button>
               )}
               <button
@@ -461,7 +531,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
           )}
         </ul>
         {this.state.showRemoveDialog && (
-          <form onSubmit={linkEvent(this, this.handleModRemoveSubmit)}>
+          <form onSubmit={linkEvent(this, this.handleRemoveCommunity)}>
             <div className="form-group">
               <label className="col-form-label" htmlFor="remove-reason">
                 {i18n.t("reason")}
@@ -482,13 +552,17 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
             {/* </div> */}
             <div className="form-group">
               <button type="submit" className="btn btn-secondary">
-                {i18n.t("remove_community")}
+                {this.state.removeCommunityLoading ? (
+                  <Spinner />
+                ) : (
+                  i18n.t("remove_community")
+                )}
               </button>
             </div>
           </form>
         )}
         {this.state.showPurgeDialog && (
-          <form onSubmit={linkEvent(this, this.handlePurgeSubmit)}>
+          <form onSubmit={linkEvent(this, this.handlePurgeCommunity)}>
             <div className="form-group">
               <PurgeWarning />
             </div>
@@ -506,7 +580,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
               />
             </div>
             <div className="form-group">
-              {this.state.purgeLoading ? (
+              {this.state.purgeCommunityLoading ? (
                 <Spinner />
               ) : (
                 <button
@@ -528,93 +602,18 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
     i.setState({ showEdit: true });
   }
 
-  handleEditCommunity() {
-    this.setState({ showEdit: false });
-  }
-
   handleEditCancel() {
     this.setState({ showEdit: false });
   }
 
-  handleDeleteClick(i: Sidebar, event: any) {
-    event.preventDefault();
-    const auth = myAuth();
-    if (auth) {
-      const deleteForm: DeleteCommunity = {
-        community_id: i.props.community_view.community.id,
-        deleted: !i.props.community_view.community.deleted,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.deleteCommunity(deleteForm));
-    }
-  }
-
   handleShowConfirmLeaveModTeamClick(i: Sidebar) {
     i.setState({ showConfirmLeaveModTeam: true });
   }
 
-  handleLeaveModTeamClick(i: Sidebar) {
-    const mui = UserService.Instance.myUserInfo;
-    const auth = myAuth();
-    if (auth && mui) {
-      const form: AddModToCommunity = {
-        person_id: mui.local_user_view.person.id,
-        community_id: i.props.community_view.community.id,
-        added: false,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.addModToCommunity(form));
-      i.setState({ showConfirmLeaveModTeam: false });
-    }
-  }
-
   handleCancelLeaveModTeamClick(i: Sidebar) {
     i.setState({ showConfirmLeaveModTeam: false });
   }
 
-  handleUnsubscribe(i: Sidebar, event: any) {
-    event.preventDefault();
-    const community_id = i.props.community_view.community.id;
-    const auth = myAuth();
-    if (auth) {
-      const form: FollowCommunity = {
-        community_id,
-        follow: false,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.followCommunity(form));
-    }
-
-    // Update myUserInfo
-    const mui = UserService.Instance.myUserInfo;
-    if (mui) {
-      mui.follows = mui.follows.filter(i => i.community.id != community_id);
-    }
-  }
-
-  handleSubscribe(i: Sidebar, event: any) {
-    event.preventDefault();
-    const community_id = i.props.community_view.community.id;
-    const auth = myAuth();
-    if (auth) {
-      const form: FollowCommunity = {
-        community_id,
-        follow: true,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.followCommunity(form));
-    }
-
-    // Update myUserInfo
-    const mui = UserService.Instance.myUserInfo;
-    if (mui) {
-      mui.follows.push({
-        community: i.props.community_view.community,
-        follower: mui.local_user_view.person,
-      });
-    }
-  }
-
   get canPost(): boolean {
     return (
       !this.props.community_view.community.posting_restricted_to_mods ||
@@ -635,23 +634,6 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
     i.setState({ removeExpires: event.target.value });
   }
 
-  handleModRemoveSubmit(i: Sidebar, event: any) {
-    event.preventDefault();
-    const auth = myAuth();
-    if (auth) {
-      const removeForm: RemoveCommunity = {
-        community_id: i.props.community_view.community.id,
-        removed: !i.props.community_view.community.removed,
-        reason: i.state.removeReason,
-        expires: getUnixTime(i.state.removeExpires),
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.removeCommunity(removeForm));
-
-      i.setState({ showRemoveDialog: false });
-    }
-  }
-
   handlePurgeCommunityShow(i: Sidebar) {
     i.setState({ showPurgeDialog: true, showRemoveDialog: false });
   }
@@ -660,48 +642,75 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
     i.setState({ purgeReason: event.target.value });
   }
 
-  handlePurgeSubmit(i: Sidebar, event: any) {
-    event.preventDefault();
+  // TODO Do we need two of these?
+  handleUnfollowCommunity(i: Sidebar) {
+    i.setState({ followCommunityLoading: true });
+    i.props.onFollowCommunity({
+      community_id: i.props.community_view.community.id,
+      follow: false,
+      auth: myAuthRequired(),
+    });
+  }
+
+  handleFollowCommunity(i: Sidebar) {
+    i.setState({ followCommunityLoading: true });
+    i.props.onFollowCommunity({
+      community_id: i.props.community_view.community.id,
+      follow: true,
+      auth: myAuthRequired(),
+    });
+  }
+
+  handleBlockCommunity(i: Sidebar) {
+    i.setState({ blockCommunityLoading: true });
+    i.props.onBlockCommunity({
+      community_id: 0,
+      block: !i.props.community_view.blocked,
+      auth: myAuthRequired(),
+    });
+  }
 
-    const auth = myAuth();
-    if (auth) {
-      const form: PurgeCommunity = {
+  handleLeaveModTeam(i: Sidebar) {
+    const myId = UserService.Instance.myUserInfo?.local_user_view.person.id;
+    if (myId) {
+      i.setState({ leaveModTeamLoading: true });
+      i.props.onLeaveModTeam({
         community_id: i.props.community_view.community.id,
-        reason: i.state.purgeReason,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.purgeCommunity(form));
-      i.setState({ purgeLoading: true });
+        person_id: 92,
+        added: false,
+        auth: myAuthRequired(),
+      });
     }
   }
 
-  handleBlock(i: Sidebar, event: any) {
+  handleDeleteCommunity(i: Sidebar) {
+    i.setState({ deleteCommunityLoading: true });
+    i.props.onDeleteCommunity({
+      community_id: i.props.community_view.community.id,
+      deleted: !i.props.community_view.community.deleted,
+      auth: myAuthRequired(),
+    });
+  }
+
+  handleRemoveCommunity(i: Sidebar, event: any) {
     event.preventDefault();
-    const auth = myAuth();
-    if (auth) {
-      const blockCommunityForm: BlockCommunity = {
-        community_id: i.props.community_view.community.id,
-        block: true,
-        auth,
-      };
-      WebSocketService.Instance.send(
-        wsClient.blockCommunity(blockCommunityForm)
-      );
-    }
+    i.setState({ removeCommunityLoading: true });
+    i.props.onRemoveCommunity({
+      community_id: i.props.community_view.community.id,
+      removed: !i.props.community_view.community.removed,
+      reason: i.state.removeReason,
+      expires: getUnixTime(i.state.removeExpires), // TODO fix this
+      auth: myAuthRequired(),
+    });
   }
 
-  handleUnblock(i: Sidebar, event: any) {
+  handlePurgeCommunity(i: Sidebar, event: any) {
     event.preventDefault();
-    const auth = myAuth();
-    if (auth) {
-      const blockCommunityForm: BlockCommunity = {
-        community_id: i.props.community_view.community.id,
-        block: false,
-        auth,
-      };
-      WebSocketService.Instance.send(
-        wsClient.blockCommunity(blockCommunityForm)
-      );
-    }
+    i.setState({ purgeCommunityLoading: true });
+    i.props.onPurgeCommunity({
+      community_id: i.props.community_view.community.id,
+      reason: i.state.purgeReason,
+      auth: myAuthRequired(),
+    });
   }
 }
index eb455b9ae92ead3c267b77c60c4750e63f6735c1..9b7256d03a507e561f5bf2e92cecc0586b216b60 100644 (file)
@@ -1,30 +1,27 @@
-import autosize from "autosize";
 import { Component, linkEvent } from "inferno";
 import {
   BannedPersonsResponse,
-  GetBannedPersons,
+  CreateCustomEmoji,
+  DeleteCustomEmoji,
+  EditCustomEmoji,
+  EditSite,
   GetFederatedInstancesResponse,
   GetSiteResponse,
   PersonView,
-  SiteResponse,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
 import { InitialFetchRequest } from "../../interfaces";
-import { WebSocketService } from "../../services";
+import { FirstLoadService } from "../../services/FirstLoadService";
+import { HttpService, RequestState } from "../../services/HttpService";
 import {
   capitalizeFirstLetter,
-  isBrowser,
-  myAuth,
-  randomStr,
+  fetchThemeList,
+  myAuthRequired,
+  removeFromEmojiDataModel,
   setIsoData,
   showLocal,
   toast,
-  wsClient,
-  wsSubscribe,
+  updateEmojiDataModel,
 } from "../../utils";
 import { HtmlTags } from "../common/html-tags";
 import { Spinner } from "../common/icon";
@@ -37,76 +34,92 @@ import { TaglineForm } from "./tagline-form";
 
 interface AdminSettingsState {
   siteRes: GetSiteResponse;
-  instancesRes?: GetFederatedInstancesResponse;
   banned: PersonView[];
-  loading: boolean;
-  leaveAdminTeamLoading: boolean;
+  currentTab: string;
+  instancesRes: RequestState<GetFederatedInstancesResponse>;
+  bannedRes: RequestState<BannedPersonsResponse>;
+  leaveAdminTeamRes: RequestState<GetSiteResponse>;
+  themeList: string[];
+  isIsomorphic: boolean;
 }
 
 export class AdminSettings extends Component<any, AdminSettingsState> {
-  private siteConfigTextAreaId = `site-config-${randomStr()}`;
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
   state: AdminSettingsState = {
     siteRes: this.isoData.site_res,
     banned: [],
-    loading: true,
-    leaveAdminTeamLoading: false,
+    currentTab: "site",
+    bannedRes: { state: "empty" },
+    instancesRes: { state: "empty" },
+    leaveAdminTeamRes: { state: "empty" },
+    themeList: [],
+    isIsomorphic: false,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
+    this.handleEditSite = this.handleEditSite.bind(this);
+    this.handleEditEmoji = this.handleEditEmoji.bind(this);
+    this.handleDeleteEmoji = this.handleDeleteEmoji.bind(this);
+    this.handleCreateEmoji = this.handleCreateEmoji.bind(this);
 
     // Only fetch the data if coming from another route
-    if (this.isoData.path == this.context.router.route.match.url) {
+    if (FirstLoadService.isFirstLoad) {
+      const [bannedRes, instancesRes] = this.isoData.routeData;
       this.state = {
         ...this.state,
-        banned: (this.isoData.routeData[0] as BannedPersonsResponse).banned,
-        instancesRes: this.isoData
-          .routeData[1] as GetFederatedInstancesResponse,
-        loading: false,
+        bannedRes,
+        instancesRes,
+        isIsomorphic: true,
       };
-    } else {
-      const cAuth = myAuth();
-      if (cAuth) {
-        WebSocketService.Instance.send(
-          wsClient.getBannedPersons({
-            auth: cAuth,
-          })
-        );
-        WebSocketService.Instance.send(
-          wsClient.getFederatedInstances({ auth: cAuth })
-        );
-      }
     }
   }
 
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
-    const promises: Promise<any>[] = [];
+  async fetchData() {
+    this.setState({
+      bannedRes: { state: "loading" },
+      instancesRes: { state: "loading" },
+      themeList: [],
+    });
 
-    const auth = req.auth;
-    if (auth) {
-      const bannedPersonsForm: GetBannedPersons = { auth };
-      promises.push(req.client.getBannedPersons(bannedPersonsForm));
-      promises.push(req.client.getFederatedInstances({ auth }));
-    }
+    const auth = myAuthRequired();
 
-    return promises;
+    const [bannedRes, instancesRes, themeList] = await Promise.all([
+      HttpService.client.getBannedPersons({ auth }),
+      HttpService.client.getFederatedInstances({ auth }),
+      fetchThemeList(),
+    ]);
+
+    this.setState({
+      bannedRes,
+      instancesRes,
+      themeList,
+    });
   }
 
-  componentDidMount() {
-    if (isBrowser()) {
-      var textarea: any = document.getElementById(this.siteConfigTextAreaId);
-      autosize(textarea);
+  static fetchInitialData({
+    auth,
+    client,
+  }: InitialFetchRequest): Promise<any>[] {
+    const promises: Promise<RequestState<any>>[] = [];
+
+    if (auth) {
+      promises.push(client.getBannedPersons({ auth }));
+      promises.push(client.getFederatedInstances({ auth }));
+    } else {
+      promises.push(
+        Promise.resolve({ state: "empty" }),
+        Promise.resolve({ state: "empty" })
+      );
     }
+
+    return promises;
   }
 
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
+  async componentDidMount() {
+    if (!this.state.isIsomorphic) {
+      await this.fetchData();
     }
   }
 
@@ -117,74 +130,80 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
   }
 
   render() {
+    const federationData =
+      this.state.instancesRes.state === "success"
+        ? this.state.instancesRes.data.federated_instances
+        : undefined;
+
     return (
       <div className="container-lg">
         <HtmlTags
           title={this.documentTitle}
           path={this.context.router.route.match.url}
         />
-        {this.state.loading ? (
-          <h5>
-            <Spinner large />
-          </h5>
-        ) : (
-          <Tabs
-            tabs={[
-              {
-                key: "site",
-                label: i18n.t("site"),
-                getNode: () => (
-                  <div className="row">
-                    <div className="col-12 col-md-6">
-                      <SiteForm
-                        siteRes={this.state.siteRes}
-                        instancesRes={this.state.instancesRes}
-                        showLocal={showLocal(this.isoData)}
-                      />
-                    </div>
-                    <div className="col-12 col-md-6">
-                      {this.admins()}
-                      {this.bannedUsers()}
-                    </div>
+        <Tabs
+          tabs={[
+            {
+              key: "site",
+              label: i18n.t("site"),
+              getNode: () => (
+                <div className="row">
+                  <div className="col-12 col-md-6">
+                    <SiteForm
+                      showLocal={showLocal(this.isoData)}
+                      allowedInstances={federationData?.allowed}
+                      blockedInstances={federationData?.blocked}
+                      onSaveSite={this.handleEditSite}
+                      siteRes={this.state.siteRes}
+                      themeList={this.state.themeList}
+                    />
                   </div>
-                ),
-              },
-              {
-                key: "rate_limiting",
-                label: "Rate Limiting",
-                getNode: () => (
-                  <RateLimitForm
-                    localSiteRateLimit={
-                      this.state.siteRes.site_view.local_site_rate_limit
-                    }
-                    applicationQuestion={
-                      this.state.siteRes.site_view.local_site
-                        .application_question
-                    }
-                  />
-                ),
-              },
-              {
-                key: "taglines",
-                label: i18n.t("taglines"),
-                getNode: () => (
-                  <div className="row">
-                    <TaglineForm siteRes={this.state.siteRes} />
-                  </div>
-                ),
-              },
-              {
-                key: "emojis",
-                label: i18n.t("emojis"),
-                getNode: () => (
-                  <div className="row">
-                    <EmojiForm />
+                  <div className="col-12 col-md-6">
+                    {this.admins()}
+                    {this.bannedUsers()}
                   </div>
-                ),
-              },
-            ]}
-          />
-        )}
+                </div>
+              ),
+            },
+            {
+              key: "rate_limiting",
+              label: "Rate Limiting",
+              getNode: () => (
+                <RateLimitForm
+                  rateLimits={
+                    this.state.siteRes.site_view.local_site_rate_limit
+                  }
+                  onSaveSite={this.handleEditSite}
+                />
+              ),
+            },
+            {
+              key: "taglines",
+              label: i18n.t("taglines"),
+              getNode: () => (
+                <div className="row">
+                  <TaglineForm
+                    taglines={this.state.siteRes.taglines}
+                    onSaveSite={this.handleEditSite}
+                  />
+                </div>
+              ),
+            },
+            {
+              key: "emojis",
+              label: i18n.t("emojis"),
+              getNode: () => (
+                <div className="row">
+                  <EmojiForm
+                    onCreate={this.handleCreateEmoji}
+                    onDelete={this.handleDeleteEmoji}
+                    onEdit={this.handleEditEmoji}
+                  />
+                </div>
+              ),
+            },
+          ]}
+        />
       </div>
     );
   }
@@ -211,7 +230,7 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
         onClick={linkEvent(this, this.handleLeaveAdminTeam)}
         className="btn btn-danger mb-2"
       >
-        {this.state.leaveAdminTeamLoading ? (
+        {this.state.leaveAdminTeamRes.state == "loading" ? (
           <Spinner />
         ) : (
           i18n.t("leave_admin_team")
@@ -221,52 +240,83 @@ export class AdminSettings extends Component<any, AdminSettingsState> {
   }
 
   bannedUsers() {
-    return (
-      <>
-        <h5>{i18n.t("banned_users")}</h5>
-        <ul className="list-unstyled">
-          {this.state.banned.map(banned => (
-            <li key={banned.person.id} className="list-inline-item">
-              <PersonListing person={banned.person} />
-            </li>
-          ))}
-        </ul>
-      </>
-    );
+    switch (this.state.bannedRes.state) {
+      case "loading":
+        return (
+          <h5>
+            <Spinner large />
+          </h5>
+        );
+      case "success": {
+        const bans = this.state.bannedRes.data.banned;
+        return (
+          <>
+            <h5>{i18n.t("banned_users")}</h5>
+            <ul className="list-unstyled">
+              {bans.map(banned => (
+                <li key={banned.person.id} className="list-inline-item">
+                  <PersonListing person={banned.person} />
+                </li>
+              ))}
+            </ul>
+          </>
+        );
+      }
+    }
   }
 
-  handleLeaveAdminTeam(i: AdminSettings) {
-    const auth = myAuth();
-    if (auth) {
-      i.setState({ leaveAdminTeamLoading: true });
-      WebSocketService.Instance.send(wsClient.leaveAdmin({ auth }));
+  async handleEditSite(form: EditSite) {
+    const editRes = await HttpService.client.editSite(form);
+
+    if (editRes.state === "success") {
+      this.setState(s => {
+        s.siteRes.site_view = editRes.data.site_view;
+        // TODO: Where to get taglines from?
+        s.siteRes.taglines = editRes.data.taglines;
+        return s;
+      });
+      toast(i18n.t("site_saved"));
     }
+
+    return editRes;
   }
 
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      this.context.router.history.push("/");
-      this.setState({ loading: false });
-      return;
-    } else if (op == UserOperation.EditSite) {
-      const data = wsJsonToRes<SiteResponse>(msg);
-      this.setState(s => ((s.siteRes.site_view = data.site_view), s));
-      toast(i18n.t("site_saved"));
-    } else if (op == UserOperation.GetBannedPersons) {
-      const data = wsJsonToRes<BannedPersonsResponse>(msg);
-      this.setState({ banned: data.banned, loading: false });
-    } else if (op == UserOperation.LeaveAdmin) {
-      const data = wsJsonToRes<GetSiteResponse>(msg);
-      this.setState(s => ((s.siteRes.site_view = data.site_view), s));
-      this.setState({ leaveAdminTeamLoading: false });
+  handleSwitchTab(i: { ctx: AdminSettings; tab: string }) {
+    i.ctx.setState({ currentTab: i.tab });
+  }
+
+  async handleLeaveAdminTeam(i: AdminSettings) {
+    i.setState({ leaveAdminTeamRes: { state: "loading" } });
+    this.setState({
+      leaveAdminTeamRes: await HttpService.client.leaveAdmin({
+        auth: myAuthRequired(),
+      }),
+    });
+
+    if (this.state.leaveAdminTeamRes.state === "success") {
       toast(i18n.t("left_admin_team"));
-      this.context.router.history.push("/");
-    } else if (op == UserOperation.GetFederatedInstances) {
-      const data = wsJsonToRes<GetFederatedInstancesResponse>(msg);
-      this.setState({ instancesRes: data });
+      this.context.router.history.replace("/");
+    }
+  }
+
+  async handleEditEmoji(form: EditCustomEmoji) {
+    const res = await HttpService.client.editCustomEmoji(form);
+    if (res.state === "success") {
+      updateEmojiDataModel(res.data.custom_emoji);
+    }
+  }
+
+  async handleDeleteEmoji(form: DeleteCustomEmoji) {
+    const res = await HttpService.client.deleteCustomEmoji(form);
+    if (res.state === "success") {
+      removeFromEmojiDataModel(res.data.id);
+    }
+  }
+
+  async handleCreateEmoji(form: CreateCustomEmoji) {
+    const res = await HttpService.client.createCustomEmoji(form);
+    if (res.state === "success") {
+      updateEmojiDataModel(res.data.custom_emoji);
     }
   }
 }
index 044964da724f03ee25e6b67c9226c656d8922377..171b7c99b2d8f4e71543e14612377d7cb1be1888 100644 (file)
@@ -1,36 +1,30 @@
 import { Component, linkEvent } from "inferno";
 import {
   CreateCustomEmoji,
-  CustomEmojiResponse,
   DeleteCustomEmoji,
-  DeleteCustomEmojiResponse,
   EditCustomEmoji,
   GetSiteResponse,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
-import { WebSocketService } from "../../services";
+import { HttpService } from "../../services/HttpService";
 import {
   customEmojisLookup,
-  isBrowser,
-  myAuth,
+  myAuthRequired,
   pictrsDeleteToast,
-  removeFromEmojiDataModel,
   setIsoData,
   toast,
-  updateEmojiDataModel,
-  uploadImage,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { EmojiMart } from "../common/emoji-mart";
 import { HtmlTags } from "../common/html-tags";
 import { Icon } from "../common/icon";
 import { Paginator } from "../common/paginator";
 
+interface EmojiFormProps {
+  onEdit(form: EditCustomEmoji): void;
+  onCreate(form: CreateCustomEmoji): void;
+  onDelete(form: DeleteCustomEmoji): void;
+}
+
 interface EmojiFormState {
   siteRes: GetSiteResponse;
   customEmojis: CustomEmojiViewForm[];
@@ -49,9 +43,8 @@ interface CustomEmojiViewForm {
   page: number;
 }
 
-export class EmojiForm extends Component<any, EmojiFormState> {
+export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
   private isoData = setIsoData(this.context);
-  private subscription: Subscription | undefined;
   private itemsPerPage = 15;
   private emptyState: EmojiFormState = {
     loading: false,
@@ -75,20 +68,12 @@ export class EmojiForm extends Component<any, EmojiFormState> {
     this.state = this.emptyState;
 
     this.handlePageChange = this.handlePageChange.bind(this);
-    this.parseMessage = this.parseMessage.bind(this);
     this.handleEmojiClick = this.handleEmojiClick.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
   }
   get documentTitle(): string {
     return i18n.t("custom_emojis");
   }
 
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
-    }
-  }
-
   render() {
     return (
       <div className="col-12">
@@ -232,7 +217,7 @@ export class EmojiForm extends Component<any, EmojiFormState> {
                               "btn btn-link btn-animate"
                             }
                             onClick={linkEvent(
-                              { form: this, cv: cv },
+                              { i: this, cv: cv },
                               this.handleEditEmojiClick
                             )}
                             data-tippy-content={i18n.t("save")}
@@ -253,7 +238,7 @@ export class EmojiForm extends Component<any, EmojiFormState> {
                         <button
                           className="btn btn-link btn-animate text-muted"
                           onClick={linkEvent(
-                            { form: this, index: index, cv: cv },
+                            { i: this, index: index, cv: cv },
                             this.handleDeleteEmojiClick
                           )}
                           data-tippy-content={i18n.t("delete")}
@@ -401,51 +386,47 @@ export class EmojiForm extends Component<any, EmojiFormState> {
     props.form.setState({ customEmojis: custom_emojis });
   }
 
-  handleDeleteEmojiClick(props: {
-    form: EmojiForm;
+  handleDeleteEmojiClick(d: {
+    i: EmojiForm;
     index: number;
     cv: CustomEmojiViewForm;
   }) {
-    const pagedIndex =
-      (props.form.state.page - 1) * props.form.itemsPerPage + props.index;
-    if (props.cv.id != 0) {
-      const deleteForm: DeleteCustomEmoji = {
-        id: props.cv.id,
-        auth: myAuth() ?? "",
-      };
-      WebSocketService.Instance.send(wsClient.deleteCustomEmoji(deleteForm));
+    const pagedIndex = (d.i.state.page - 1) * d.i.itemsPerPage + d.index;
+    if (d.cv.id != 0) {
+      d.i.props.onDelete({
+        id: d.cv.id,
+        auth: myAuthRequired(),
+      });
     } else {
-      const custom_emojis = [...props.form.state.customEmojis];
+      const custom_emojis = [...d.i.state.customEmojis];
       custom_emojis.splice(Number(pagedIndex), 1);
-      props.form.setState({ customEmojis: custom_emojis });
+      d.i.setState({ customEmojis: custom_emojis });
     }
   }
 
-  handleEditEmojiClick(props: { form: EmojiForm; cv: CustomEmojiViewForm }) {
-    const keywords = props.cv.keywords
+  handleEditEmojiClick(d: { i: EmojiForm; cv: CustomEmojiViewForm }) {
+    const keywords = d.cv.keywords
       .split(" ")
       .filter(x => x.length > 0) as string[];
     const uniqueKeywords = Array.from(new Set(keywords));
-    if (props.cv.id != 0) {
-      const editForm: EditCustomEmoji = {
-        id: props.cv.id,
-        category: props.cv.category,
-        image_url: props.cv.image_url,
-        alt_text: props.cv.alt_text,
+    if (d.cv.id != 0) {
+      d.i.props.onEdit({
+        id: d.cv.id,
+        category: d.cv.category,
+        image_url: d.cv.image_url,
+        alt_text: d.cv.alt_text,
         keywords: uniqueKeywords,
-        auth: myAuth() ?? "",
-      };
-      WebSocketService.Instance.send(wsClient.editCustomEmoji(editForm));
+        auth: myAuthRequired(),
+      });
     } else {
-      const createForm: CreateCustomEmoji = {
-        category: props.cv.category,
-        shortcode: props.cv.shortcode,
-        image_url: props.cv.image_url,
-        alt_text: props.cv.alt_text,
+      d.i.props.onCreate({
+        category: d.cv.category,
+        shortcode: d.cv.shortcode,
+        image_url: d.cv.image_url,
+        alt_text: d.cv.alt_text,
         keywords: uniqueKeywords,
-        auth: myAuth() ?? "",
-      };
-      WebSocketService.Instance.send(wsClient.createCustomEmoji(createForm));
+        auth: myAuthRequired(),
+      });
     }
   }
 
@@ -477,26 +458,26 @@ export class EmojiForm extends Component<any, EmojiFormState> {
       file = event;
     }
 
-    uploadImage(file)
-      .then(res => {
-        console.log("pictrs upload:");
-        console.log(res);
-        if (res.msg === "ok") {
-          pictrsDeleteToast(file.name, res.delete_url as string);
+    HttpService.client.uploadImage({ image: file }).then(res => {
+      console.log("pictrs upload:");
+      console.log(res);
+      if (res.state === "success") {
+        if (res.data.msg === "ok") {
+          pictrsDeleteToast(file.name, res.data.delete_url as string);
         } else {
           toast(JSON.stringify(res), "danger");
-          const hash = res.files?.at(0)?.file;
-          const url = `${res.url}/${hash}`;
+          const hash = res.data.files?.at(0)?.file;
+          const url = `${res.data.url}/${hash}`;
           props.form.handleEmojiImageUrlChange(
             { form: props.form, index: props.index, overrideValue: url },
             event
           );
         }
-      })
-      .catch(error => {
-        console.error(error);
-        toast(error, "danger");
-      });
+      } else if (res.state === "failed") {
+        console.error(res.msg);
+        toast(res.msg, "danger");
+      }
+    });
   }
 
   configurePicker(): any {
@@ -506,51 +487,4 @@ export class EmojiForm extends Component<any, EmojiFormState> {
       dynamicWidth: true,
     };
   }
-
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      this.context.router.history.push("/");
-      this.setState({ loading: false });
-      return;
-    } else if (op == UserOperation.CreateCustomEmoji) {
-      const data = wsJsonToRes<CustomEmojiResponse>(msg);
-      const custom_emoji_view = data.custom_emoji;
-      updateEmojiDataModel(custom_emoji_view);
-      const currentEmojis = this.state.customEmojis;
-      const newEmojiIndex = currentEmojis.findIndex(
-        x => x.shortcode == custom_emoji_view.custom_emoji.shortcode
-      );
-      currentEmojis[newEmojiIndex].id = custom_emoji_view.custom_emoji.id;
-      currentEmojis[newEmojiIndex].changed = false;
-      this.setState({ customEmojis: currentEmojis });
-      toast(i18n.t("saved_emoji"));
-      this.setState({ loading: false });
-    } else if (op == UserOperation.EditCustomEmoji) {
-      const data = wsJsonToRes<CustomEmojiResponse>(msg);
-      const custom_emoji_view = data.custom_emoji;
-      updateEmojiDataModel(data.custom_emoji);
-      const currentEmojis = this.state.customEmojis;
-      const newEmojiIndex = currentEmojis.findIndex(
-        x => x.shortcode == custom_emoji_view.custom_emoji.shortcode
-      );
-      currentEmojis[newEmojiIndex].changed = false;
-      this.setState({ customEmojis: currentEmojis });
-      toast(i18n.t("saved_emoji"));
-      this.setState({ loading: false });
-    } else if (op == UserOperation.DeleteCustomEmoji) {
-      const data = wsJsonToRes<DeleteCustomEmojiResponse>(msg);
-      if (data.success) {
-        removeFromEmojiDataModel(data.id);
-        const custom_emojis = [
-          ...this.state.customEmojis.filter(x => x.id != data.id),
-        ];
-        this.setState({ customEmojis: custom_emojis });
-        toast(i18n.t("deleted_emoji"));
-      }
-      this.setState({ loading: false });
-    }
-  }
 }
index 52a82126082eb3d371fb47fc16c5a0c8fe8512dc..8be983042a5ce584988bb282d928656678ddfeb0 100644 (file)
@@ -3,13 +3,27 @@ import { Component, linkEvent, MouseEventHandler } from "inferno";
 import { T } from "inferno-i18next-dess";
 import { Link } from "inferno-router";
 import {
-  AddAdminResponse,
+  AddAdmin,
+  AddModToCommunity,
+  BanFromCommunity,
+  BanFromCommunityResponse,
+  BanPerson,
   BanPersonResponse,
-  BlockPersonResponse,
-  CommentReportResponse,
+  BlockPerson,
+  CommentId,
+  CommentReplyResponse,
   CommentResponse,
-  CommentView,
-  CommunityView,
+  CreateComment,
+  CreateCommentLike,
+  CreateCommentReport,
+  CreatePostLike,
+  CreatePostReport,
+  DeleteComment,
+  DeletePost,
+  DistinguishComment,
+  EditComment,
+  EditPost,
+  FeaturePost,
   GetComments,
   GetCommentsResponse,
   GetPosts,
@@ -18,50 +32,51 @@ import {
   ListCommunities,
   ListCommunitiesResponse,
   ListingType,
-  PostReportResponse,
+  LockPost,
+  MarkCommentReplyAsRead,
+  MarkPersonMentionAsRead,
   PostResponse,
-  PostView,
+  PurgeComment,
   PurgeItemResponse,
-  SiteResponse,
+  PurgePerson,
+  PurgePost,
+  RemoveComment,
+  RemovePost,
+  SaveComment,
+  SavePost,
   SortType,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
+  TransferCommunity,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
 import {
   CommentViewType,
   DataType,
   InitialFetchRequest,
 } from "../../interfaces";
-import { UserService, WebSocketService } from "../../services";
+import { UserService } from "../../services";
+import { FirstLoadService } from "../../services/FirstLoadService";
+import { HttpService, RequestState } from "../../services/HttpService";
 import {
   canCreateCommunity,
   commentsToFlatNodes,
-  createCommentLikeRes,
-  createPostLikeFindRes,
-  editCommentRes,
-  editPostFindRes,
+  editComment,
+  editPost,
+  editWith,
   enableDownvotes,
   enableNsfw,
   fetchLimit,
+  getCommentParentId,
   getDataTypeString,
   getPageFromString,
   getQueryParams,
   getQueryString,
   getRandomFromList,
-  isBrowser,
-  isPostBlocked,
   mdToHtml,
   myAuth,
-  notifyPost,
-  nsfwCheck,
   postToCommentSortType,
   QueryParams,
   relTags,
   restoreScrollPosition,
-  saveCommentRes,
   saveScrollPosition,
   setIsoData,
   setupTippy,
@@ -69,8 +84,6 @@ import {
   toast,
   trendingFetchLimit,
   updatePersonBlock,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { CommentNodes } from "../comment/comment-nodes";
 import { DataTypeSelect } from "../common/data-type-select";
@@ -84,16 +97,17 @@ import { PostListings } from "../post/post-listings";
 import { SiteSidebar } from "./site-sidebar";
 
 interface HomeState {
-  trendingCommunities: CommunityView[];
-  siteRes: GetSiteResponse;
-  posts: PostView[];
-  comments: CommentView[];
+  postsRes: RequestState<GetPostsResponse>;
+  commentsRes: RequestState<GetCommentsResponse>;
+  trendingCommunitiesRes: RequestState<ListCommunitiesResponse>;
   showSubscribedMobile: boolean;
   showTrendingMobile: boolean;
   showSidebarMobile: boolean;
   subscribedCollapsed: boolean;
-  loading: boolean;
   tagline?: string;
+  siteRes: GetSiteResponse;
+  finished: Map<CommentId, boolean | undefined>;
+  isIsomorphic: boolean;
 }
 
 interface HomeProps {
@@ -112,7 +126,7 @@ function getListingTypeFromQuery(type?: string): ListingType {
     UserService.Instance.myUserInfo?.local_user_view?.local_user
       ?.default_listing_type;
 
-  return type ? (type as ListingType) : myListingType ?? "Local";
+  return (type ? (type as ListingType) : myListingType) ?? "Local";
 }
 
 function getSortTypeFromQuery(type?: string): SortType {
@@ -120,7 +134,7 @@ function getSortTypeFromQuery(type?: string): SortType {
     UserService.Instance.myUserInfo?.local_user_view?.local_user
       ?.default_sort_type;
 
-  return type ? (type as SortType) : mySortType ?? "Active";
+  return (type ? (type as SortType) : mySortType) ?? "Active";
 }
 
 const getHomeQueryParams = () =>
@@ -131,48 +145,6 @@ const getHomeQueryParams = () =>
     dataType: getDataTypeFromQuery,
   });
 
-function fetchTrendingCommunities() {
-  const listCommunitiesForm: ListCommunities = {
-    type_: "Local",
-    sort: "Hot",
-    limit: trendingFetchLimit,
-    auth: myAuth(false),
-  };
-  WebSocketService.Instance.send(wsClient.listCommunities(listCommunitiesForm));
-}
-
-function fetchData() {
-  const auth = myAuth(false);
-  const { dataType, page, listingType, sort } = getHomeQueryParams();
-  let req: string;
-
-  if (dataType === DataType.Post) {
-    const getPostsForm: GetPosts = {
-      page,
-      limit: fetchLimit,
-      sort,
-      saved_only: false,
-      type_: listingType,
-      auth,
-    };
-
-    req = wsClient.getPosts(getPostsForm);
-  } else {
-    const getCommentsForm: GetComments = {
-      page,
-      limit: fetchLimit,
-      sort: postToCommentSortType(sort),
-      saved_only: false,
-      type_: listingType,
-      auth,
-    };
-
-    req = wsClient.getComments(getCommentsForm);
-  }
-
-  WebSocketService.Instance.send(req);
-}
-
 const MobileButton = ({
   textKey,
   show,
@@ -203,52 +175,19 @@ const LinkButton = ({
   </Link>
 );
 
-function getRss(listingType: ListingType) {
-  const { sort } = getHomeQueryParams();
-  const auth = myAuth(false);
-
-  let rss: string | undefined = undefined;
-
-  switch (listingType) {
-    case "All": {
-      rss = `/feeds/all.xml?sort=${sort}`;
-      break;
-    }
-    case "Local": {
-      rss = `/feeds/local.xml?sort=${sort}`;
-      break;
-    }
-    case "Subscribed": {
-      rss = auth ? `/feeds/front/${auth}.xml?sort=${sort}` : undefined;
-      break;
-    }
-  }
-
-  return (
-    rss && (
-      <>
-        <a href={rss} rel={relTags} title="RSS">
-          <Icon icon="rss" classes="text-muted small" />
-        </a>
-        <link rel="alternate" type="application/atom+xml" href={rss} />
-      </>
-    )
-  );
-}
-
 export class Home extends Component<any, HomeState> {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
   state: HomeState = {
-    trendingCommunities: [],
+    postsRes: { state: "empty" },
+    commentsRes: { state: "empty" },
+    trendingCommunitiesRes: { state: "empty" },
     siteRes: this.isoData.site_res,
     showSubscribedMobile: false,
     showTrendingMobile: false,
     showSidebarMobile: false,
     subscribedCollapsed: false,
-    loading: true,
-    posts: [],
-    comments: [],
+    finished: new Map(),
+    isIsomorphic: false,
   };
 
   constructor(props: any, context: any) {
@@ -259,65 +198,69 @@ export class Home extends Component<any, HomeState> {
     this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
     this.handlePageChange = this.handlePageChange.bind(this);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
+    this.handleCreateComment = this.handleCreateComment.bind(this);
+    this.handleEditComment = this.handleEditComment.bind(this);
+    this.handleSaveComment = this.handleSaveComment.bind(this);
+    this.handleBlockPerson = this.handleBlockPerson.bind(this);
+    this.handleDeleteComment = this.handleDeleteComment.bind(this);
+    this.handleRemoveComment = this.handleRemoveComment.bind(this);
+    this.handleCommentVote = this.handleCommentVote.bind(this);
+    this.handleAddModToCommunity = this.handleAddModToCommunity.bind(this);
+    this.handleAddAdmin = this.handleAddAdmin.bind(this);
+    this.handlePurgePerson = this.handlePurgePerson.bind(this);
+    this.handlePurgeComment = this.handlePurgeComment.bind(this);
+    this.handleCommentReport = this.handleCommentReport.bind(this);
+    this.handleDistinguishComment = this.handleDistinguishComment.bind(this);
+    this.handleTransferCommunity = this.handleTransferCommunity.bind(this);
+    this.handleCommentReplyRead = this.handleCommentReplyRead.bind(this);
+    this.handlePersonMentionRead = this.handlePersonMentionRead.bind(this);
+    this.handleBanFromCommunity = this.handleBanFromCommunity.bind(this);
+    this.handleBanPerson = this.handleBanPerson.bind(this);
+    this.handlePostEdit = this.handlePostEdit.bind(this);
+    this.handlePostVote = this.handlePostVote.bind(this);
+    this.handlePostReport = this.handlePostReport.bind(this);
+    this.handleLockPost = this.handleLockPost.bind(this);
+    this.handleDeletePost = this.handleDeletePost.bind(this);
+    this.handleRemovePost = this.handleRemovePost.bind(this);
+    this.handleSavePost = this.handleSavePost.bind(this);
+    this.handlePurgePost = this.handlePurgePost.bind(this);
+    this.handleFeaturePost = this.handleFeaturePost.bind(this);
 
     // Only fetch the data if coming from another route
-    if (this.isoData.path === this.context.router.route.match.url) {
-      const postsRes = this.isoData.routeData[0] as
-        | GetPostsResponse
-        | undefined;
-      const commentsRes = this.isoData.routeData[1] as
-        | GetCommentsResponse
-        | undefined;
-      const trendingRes = this.isoData.routeData[2] as
-        | ListCommunitiesResponse
-        | undefined;
-
-      if (postsRes) {
-        this.state = { ...this.state, posts: postsRes.posts };
-      }
-
-      if (commentsRes) {
-        this.state = { ...this.state, comments: commentsRes.comments };
-      }
+    if (FirstLoadService.isFirstLoad) {
+      const [postsRes, commentsRes, trendingCommunitiesRes] =
+        this.isoData.routeData;
 
-      if (isBrowser()) {
-        WebSocketService.Instance.send(
-          wsClient.communityJoin({ community_id: 0 })
-        );
-      }
-      const taglines = this.state?.siteRes?.taglines ?? [];
       this.state = {
         ...this.state,
-        trendingCommunities: trendingRes?.communities ?? [],
-        loading: false,
-        tagline: getRandomFromList(taglines)?.content,
+        postsRes,
+        commentsRes,
+        trendingCommunitiesRes,
+        tagline: getRandomFromList(this.state?.siteRes?.taglines ?? [])
+          ?.content,
+        isIsomorphic: true,
       };
-    } else {
-      fetchTrendingCommunities();
-      fetchData();
     }
   }
 
-  componentDidMount() {
-    // This means it hasn't been set up yet
-    if (!this.state.siteRes.site_view.local_site.site_setup) {
-      this.context.router.history.push("/setup");
+  async componentDidMount() {
+    if (!this.state.isIsomorphic) {
+      await Promise.all([this.fetchTrendingCommunities(), this.fetchData()]);
     }
     setupTippy();
   }
 
   componentWillUnmount() {
     saveScrollPosition(this.context);
-    this.subscription?.unsubscribe();
   }
 
   static fetchInitialData({
     client,
     auth,
     query: { dataType: urlDataType, listingType, page: urlPage, sort: urlSort },
-  }: InitialFetchRequest<QueryParams<HomeProps>>): Promise<any>[] {
+  }: InitialFetchRequest<QueryParams<HomeProps>>): Promise<
+    RequestState<any>
+  >[] {
     const dataType = getDataTypeFromQuery(urlDataType);
 
     // TODO figure out auth default_listingType, default_sort_type
@@ -326,7 +269,7 @@ export class Home extends Component<any, HomeState> {
 
     const page = urlPage ? Number(urlPage) : 1;
 
-    const promises: Promise<any>[] = [];
+    const promises: Promise<RequestState<any>>[] = [];
 
     if (dataType === DataType.Post) {
       const getPostsForm: GetPosts = {
@@ -339,7 +282,7 @@ export class Home extends Component<any, HomeState> {
       };
 
       promises.push(client.getPosts(getPostsForm));
-      promises.push(Promise.resolve());
+      promises.push(Promise.resolve({ state: "empty" }));
     } else {
       const getCommentsForm: GetComments = {
         page,
@@ -349,7 +292,7 @@ export class Home extends Component<any, HomeState> {
         saved_only: false,
         auth,
       };
-      promises.push(Promise.resolve());
+      promises.push(Promise.resolve({ state: "empty" }));
       promises.push(client.getComments(getCommentsForm));
     }
 
@@ -475,69 +418,77 @@ export class Home extends Component<any, HomeState> {
         admins,
         online,
       },
-      loading,
     } = this.state;
 
     return (
       <div>
-        {!loading && (
-          <div>
-            <div className="card border-secondary mb-3">
-              <div className="card-body">
-                {this.trendingCommunities()}
-                {canCreateCommunity(this.state.siteRes) && (
-                  <LinkButton
-                    path="/create_community"
-                    translationKey="create_a_community"
-                  />
-                )}
+        <div>
+          <div className="card border-secondary mb-3">
+            <div className="card-body">
+              {this.trendingCommunities()}
+              {canCreateCommunity(this.state.siteRes) && (
                 <LinkButton
-                  path="/communities"
-                  translationKey="explore_communities"
+                  path="/create_community"
+                  translationKey="create_a_community"
                 />
-              </div>
+              )}
+              <LinkButton
+                path="/communities"
+                translationKey="explore_communities"
+              />
             </div>
-            <SiteSidebar
-              site={site}
-              admins={admins}
-              counts={counts}
-              online={online}
-              showLocal={showLocal(this.isoData)}
-            />
-            {this.hasFollows && (
-              <div className="card border-secondary mb-3">
-                <div className="card-body">{this.subscribedCommunities}</div>
-              </div>
-            )}
           </div>
-        )}
+          <SiteSidebar
+            site={site}
+            admins={admins}
+            counts={counts}
+            online={online}
+            showLocal={showLocal(this.isoData)}
+          />
+          {this.hasFollows && (
+            <div className="card border-secondary mb-3">
+              <div className="card-body">{this.subscribedCommunities}</div>
+            </div>
+          )}
+        </div>
       </div>
     );
   }
 
   trendingCommunities(isMobile = false) {
-    return (
-      <div className={!isMobile ? "mb-2" : ""}>
-        <h5>
-          <T i18nKey="trending_communities">
-            #
-            <Link className="text-body" to="/communities">
-              #
-            </Link>
-          </T>
-        </h5>
-        <ul className="list-inline mb-0">
-          {this.state.trendingCommunities.map(cv => (
-            <li
-              key={cv.community.id}
-              className="list-inline-item d-inline-block"
-            >
-              <CommunityLink community={cv.community} />
-            </li>
-          ))}
-        </ul>
-      </div>
-    );
+    switch (this.state.trendingCommunitiesRes.state) {
+      case "loading":
+        return (
+          <h5>
+            <Spinner large />
+          </h5>
+        );
+      case "success": {
+        const trending = this.state.trendingCommunitiesRes.data.communities;
+        return (
+          <div className={!isMobile ? "mb-2" : ""}>
+            <h5>
+              <T i18nKey="trending_communities">
+                #
+                <Link className="text-body" to="/communities">
+                  #
+                </Link>
+              </T>
+            </h5>
+            <ul className="list-inline mb-0">
+              {trending.map(cv => (
+                <li
+                  key={cv.community.id}
+                  className="list-inline-item d-inline-block"
+                >
+                  <CommunityLink community={cv.community} />
+                </li>
+              ))}
+            </ul>
+          </div>
+        );
+      }
+    }
   }
 
   get subscribedCommunities() {
@@ -580,7 +531,7 @@ export class Home extends Component<any, HomeState> {
     );
   }
 
-  updateUrl({ dataType, listingType, page, sort }: Partial<HomeProps>) {
+  async updateUrl({ dataType, listingType, page, sort }: Partial<HomeProps>) {
     const {
       dataType: urlDataType,
       listingType: urlListingType,
@@ -600,13 +551,7 @@ export class Home extends Component<any, HomeState> {
       search: getQueryString(queryParams),
     });
 
-    this.setState({
-      loading: true,
-      posts: [],
-      comments: [],
-    });
-
-    fetchData();
+    await this.fetchData();
   }
 
   posts() {
@@ -614,50 +559,105 @@ export class Home extends Component<any, HomeState> {
 
     return (
       <div className="main-content-wrapper">
-        {this.state.loading ? (
-          <h5>
-            <Spinner large />
-          </h5>
-        ) : (
-          <div>
-            {this.selects()}
-            {this.listings}
-            <Paginator page={page} onChange={this.handlePageChange} />
-          </div>
-        )}
+        <div>
+          {this.selects}
+          {this.listings}
+          <Paginator page={page} onChange={this.handlePageChange} />
+        </div>
       </div>
     );
   }
 
   get listings() {
     const { dataType } = getHomeQueryParams();
-    const { siteRes, posts, comments } = this.state;
-
-    return dataType === DataType.Post ? (
-      <PostListings
-        posts={posts}
-        showCommunity
-        removeDuplicates
-        enableDownvotes={enableDownvotes(siteRes)}
-        enableNsfw={enableNsfw(siteRes)}
-        allLanguages={siteRes.all_languages}
-        siteLanguages={siteRes.discussion_languages}
-      />
-    ) : (
-      <CommentNodes
-        nodes={commentsToFlatNodes(comments)}
-        viewType={CommentViewType.Flat}
-        noIndent
-        showCommunity
-        showContext
-        enableDownvotes={enableDownvotes(siteRes)}
-        allLanguages={siteRes.all_languages}
-        siteLanguages={siteRes.discussion_languages}
-      />
-    );
+    const siteRes = this.state.siteRes;
+
+    if (dataType === DataType.Post) {
+      switch (this.state.postsRes.state) {
+        case "loading":
+          return (
+            <h5>
+              <Spinner large />
+            </h5>
+          );
+        case "success": {
+          const posts = this.state.postsRes.data.posts;
+          return (
+            <PostListings
+              posts={posts}
+              showCommunity
+              removeDuplicates
+              enableDownvotes={enableDownvotes(siteRes)}
+              enableNsfw={enableNsfw(siteRes)}
+              allLanguages={siteRes.all_languages}
+              siteLanguages={siteRes.discussion_languages}
+              onBlockPerson={this.handleBlockPerson}
+              onPostEdit={this.handlePostEdit}
+              onPostVote={this.handlePostVote}
+              onPostReport={this.handlePostReport}
+              onLockPost={this.handleLockPost}
+              onDeletePost={this.handleDeletePost}
+              onRemovePost={this.handleRemovePost}
+              onSavePost={this.handleSavePost}
+              onPurgePerson={this.handlePurgePerson}
+              onPurgePost={this.handlePurgePost}
+              onBanPerson={this.handleBanPerson}
+              onBanPersonFromCommunity={this.handleBanFromCommunity}
+              onAddModToCommunity={this.handleAddModToCommunity}
+              onAddAdmin={this.handleAddAdmin}
+              onTransferCommunity={this.handleTransferCommunity}
+              onFeaturePost={this.handleFeaturePost}
+            />
+          );
+        }
+      }
+    } else {
+      switch (this.state.commentsRes.state) {
+        case "loading":
+          return (
+            <h5>
+              <Spinner large />
+            </h5>
+          );
+        case "success": {
+          const comments = this.state.commentsRes.data.comments;
+          return (
+            <CommentNodes
+              nodes={commentsToFlatNodes(comments)}
+              viewType={CommentViewType.Flat}
+              finished={this.state.finished}
+              noIndent
+              showCommunity
+              showContext
+              enableDownvotes={enableDownvotes(siteRes)}
+              allLanguages={siteRes.all_languages}
+              siteLanguages={siteRes.discussion_languages}
+              onSaveComment={this.handleSaveComment}
+              onBlockPerson={this.handleBlockPerson}
+              onDeleteComment={this.handleDeleteComment}
+              onRemoveComment={this.handleRemoveComment}
+              onCommentVote={this.handleCommentVote}
+              onCommentReport={this.handleCommentReport}
+              onDistinguishComment={this.handleDistinguishComment}
+              onAddModToCommunity={this.handleAddModToCommunity}
+              onAddAdmin={this.handleAddAdmin}
+              onTransferCommunity={this.handleTransferCommunity}
+              onPurgeComment={this.handlePurgeComment}
+              onPurgePerson={this.handlePurgePerson}
+              onCommentReplyRead={this.handleCommentReplyRead}
+              onPersonMentionRead={this.handlePersonMentionRead}
+              onBanPersonFromCommunity={this.handleBanFromCommunity}
+              onBanPerson={this.handleBanPerson}
+              onCreateComment={this.handleCreateComment}
+              onEditComment={this.handleEditComment}
+            />
+          );
+        }
+      }
+    }
   }
 
-  selects() {
+  get selects() {
     const { listingType, dataType, sort } = getHomeQueryParams();
 
     return (
@@ -679,11 +679,90 @@ export class Home extends Component<any, HomeState> {
         <span className="mr-2">
           <SortSelect sort={sort} onChange={this.handleSortChange} />
         </span>
-        {getRss(listingType)}
+        {this.getRss(listingType)}
       </div>
     );
   }
 
+  getRss(listingType: ListingType) {
+    const { sort } = getHomeQueryParams();
+    const auth = myAuth();
+
+    let rss: string | undefined = undefined;
+
+    switch (listingType) {
+      case "All": {
+        rss = `/feeds/all.xml?sort=${sort}`;
+        break;
+      }
+      case "Local": {
+        rss = `/feeds/local.xml?sort=${sort}`;
+        break;
+      }
+      case "Subscribed": {
+        rss = auth ? `/feeds/front/${auth}.xml?sort=${sort}` : undefined;
+        break;
+      }
+    }
+
+    return (
+      rss && (
+        <>
+          <a href={rss} rel={relTags} title="RSS">
+            <Icon icon="rss" classes="text-muted small" />
+          </a>
+          <link rel="alternate" type="application/atom+xml" href={rss} />
+        </>
+      )
+    );
+  }
+
+  async fetchTrendingCommunities() {
+    this.setState({ trendingCommunitiesRes: { state: "loading" } });
+    this.setState({
+      trendingCommunitiesRes: await HttpService.client.listCommunities({
+        type_: "Local",
+        sort: "Hot",
+        limit: trendingFetchLimit,
+        auth: myAuth(),
+      }),
+    });
+  }
+
+  async fetchData() {
+    const auth = myAuth();
+    const { dataType, page, listingType, sort } = getHomeQueryParams();
+
+    if (dataType === DataType.Post) {
+      this.setState({ postsRes: { state: "loading" } });
+      this.setState({
+        postsRes: await HttpService.client.getPosts({
+          page,
+          limit: fetchLimit,
+          sort,
+          saved_only: false,
+          type_: listingType,
+          auth,
+        }),
+      });
+    } else {
+      this.setState({ commentsRes: { state: "loading" } });
+      this.setState({
+        commentsRes: await HttpService.client.getComments({
+          page,
+          limit: fetchLimit,
+          sort: postToCommentSortType(sort),
+          saved_only: false,
+          type_: listingType,
+          auth,
+        }),
+      });
+    }
+
+    restoreScrollPosition(this.context);
+    setupTippy();
+  }
+
   handleShowSubscribedMobile(i: Home) {
     i.setState({ showSubscribedMobile: !i.state.showSubscribedMobile });
   }
@@ -720,229 +799,252 @@ export class Home extends Component<any, HomeState> {
     window.scrollTo(0, 0);
   }
 
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
+  async handleAddModToCommunity(form: AddModToCommunity) {
+    // TODO not sure what to do here
+    await HttpService.client.addModToCommunity(form);
+  }
 
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-    } else if (msg.reconnect) {
-      WebSocketService.Instance.send(
-        wsClient.communityJoin({ community_id: 0 })
-      );
-      fetchData();
-    } else {
-      switch (op) {
-        case UserOperation.ListCommunities: {
-          const { communities } = wsJsonToRes<ListCommunitiesResponse>(msg);
-          this.setState({ trendingCommunities: communities });
+  async handlePurgePerson(form: PurgePerson) {
+    const purgePersonRes = await HttpService.client.purgePerson(form);
+    this.purgeItem(purgePersonRes);
+  }
 
-          break;
-        }
+  async handlePurgeComment(form: PurgeComment) {
+    const purgeCommentRes = await HttpService.client.purgeComment(form);
+    this.purgeItem(purgeCommentRes);
+  }
 
-        case UserOperation.EditSite: {
-          const { site_view } = wsJsonToRes<SiteResponse>(msg);
-          this.setState(s => ((s.siteRes.site_view = site_view), s));
-          toast(i18n.t("site_saved"));
+  async handlePurgePost(form: PurgePost) {
+    const purgeRes = await HttpService.client.purgePost(form);
+    this.purgeItem(purgeRes);
+  }
 
-          break;
-        }
+  async handleBlockPerson(form: BlockPerson) {
+    const blockPersonRes = await HttpService.client.blockPerson(form);
+    if (blockPersonRes.state == "success") {
+      updatePersonBlock(blockPersonRes.data);
+    }
+  }
 
-        case UserOperation.GetPosts: {
-          const { posts } = wsJsonToRes<GetPostsResponse>(msg);
-          this.setState({ posts, loading: false });
-          WebSocketService.Instance.send(
-            wsClient.communityJoin({ community_id: 0 })
-          );
-          restoreScrollPosition(this.context);
-          setupTippy();
+  async handleCreateComment(form: CreateComment) {
+    const createCommentRes = await HttpService.client.createComment(form);
+    this.createAndUpdateComments(createCommentRes);
 
-          break;
-        }
+    return createCommentRes;
+  }
 
-        case UserOperation.CreatePost: {
-          const { page, listingType } = getHomeQueryParams();
-          const { post_view } = wsJsonToRes<PostResponse>(msg);
-
-          // Only push these if you're on the first page, you pass the nsfw check, and it isn't blocked
-          if (page === 1 && nsfwCheck(post_view) && !isPostBlocked(post_view)) {
-            const mui = UserService.Instance.myUserInfo;
-            const showPostNotifs =
-              mui?.local_user_view.local_user.show_new_post_notifs;
-            let shouldAddPost: boolean;
-
-            switch (listingType) {
-              case "Subscribed": {
-                // If you're on subscribed, only push it if you're subscribed.
-                shouldAddPost = !!mui?.follows.some(
-                  ({ community: { id } }) => id === post_view.community.id
-                );
-                break;
-              }
-              case "Local": {
-                // If you're on the local view, only push it if its local
-                shouldAddPost = post_view.post.local;
-                break;
-              }
-              default: {
-                shouldAddPost = true;
-                break;
-              }
-            }
-
-            if (shouldAddPost) {
-              this.setState(({ posts }) => ({
-                posts: [post_view].concat(posts),
-              }));
-              if (showPostNotifs) {
-                notifyPost(post_view, this.context.router);
-              }
-            }
-          }
-
-          break;
-        }
+  async handleEditComment(form: EditComment) {
+    const editCommentRes = await HttpService.client.editComment(form);
+    this.findAndUpdateComment(editCommentRes);
 
-        case UserOperation.EditPost:
-        case UserOperation.DeletePost:
-        case UserOperation.RemovePost:
-        case UserOperation.LockPost:
-        case UserOperation.FeaturePost:
-        case UserOperation.SavePost: {
-          const { post_view } = wsJsonToRes<PostResponse>(msg);
-          editPostFindRes(post_view, this.state.posts);
-          this.setState(this.state);
-
-          break;
-        }
+    return editCommentRes;
+  }
 
-        case UserOperation.CreatePostLike: {
-          const { post_view } = wsJsonToRes<PostResponse>(msg);
-          createPostLikeFindRes(post_view, this.state.posts);
-          this.setState(this.state);
+  async handleDeleteComment(form: DeleteComment) {
+    const deleteCommentRes = await HttpService.client.deleteComment(form);
+    this.findAndUpdateComment(deleteCommentRes);
+  }
 
-          break;
-        }
+  async handleDeletePost(form: DeletePost) {
+    const deleteRes = await HttpService.client.deletePost(form);
+    this.findAndUpdatePost(deleteRes);
+  }
 
-        case UserOperation.AddAdmin: {
-          const { admins } = wsJsonToRes<AddAdminResponse>(msg);
-          this.setState(s => ((s.siteRes.admins = admins), s));
+  async handleRemovePost(form: RemovePost) {
+    const removeRes = await HttpService.client.removePost(form);
+    this.findAndUpdatePost(removeRes);
+  }
 
-          break;
-        }
+  async handleRemoveComment(form: RemoveComment) {
+    const removeCommentRes = await HttpService.client.removeComment(form);
+    this.findAndUpdateComment(removeCommentRes);
+  }
 
-        case UserOperation.BanPerson: {
-          const {
-            banned,
-            person_view: {
-              person: { id },
-            },
-          } = wsJsonToRes<BanPersonResponse>(msg);
+  async handleSaveComment(form: SaveComment) {
+    const saveCommentRes = await HttpService.client.saveComment(form);
+    this.findAndUpdateComment(saveCommentRes);
+  }
 
-          this.state.posts
-            .filter(p => p.creator.id == id)
-            .forEach(p => (p.creator.banned = banned));
-          this.setState(this.state);
+  async handleSavePost(form: SavePost) {
+    const saveRes = await HttpService.client.savePost(form);
+    this.findAndUpdatePost(saveRes);
+  }
 
-          break;
-        }
+  async handleFeaturePost(form: FeaturePost) {
+    const featureRes = await HttpService.client.featurePost(form);
+    this.findAndUpdatePost(featureRes);
+  }
 
-        case UserOperation.GetComments: {
-          const { comments } = wsJsonToRes<GetCommentsResponse>(msg);
-          this.setState({ comments, loading: false });
+  async handleCommentVote(form: CreateCommentLike) {
+    const voteRes = await HttpService.client.likeComment(form);
+    this.findAndUpdateComment(voteRes);
+  }
 
-          break;
-        }
+  async handlePostEdit(form: EditPost) {
+    const res = await HttpService.client.editPost(form);
+    this.findAndUpdatePost(res);
+  }
 
-        case UserOperation.EditComment:
-        case UserOperation.DeleteComment:
-        case UserOperation.RemoveComment: {
-          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
-          editCommentRes(comment_view, this.state.comments);
-          this.setState(this.state);
+  async handlePostVote(form: CreatePostLike) {
+    const voteRes = await HttpService.client.likePost(form);
+    this.findAndUpdatePost(voteRes);
+  }
 
-          break;
-        }
+  async handleCommentReport(form: CreateCommentReport) {
+    const reportRes = await HttpService.client.createCommentReport(form);
+    if (reportRes.state == "success") {
+      toast(i18n.t("report_created"));
+    }
+  }
 
-        case UserOperation.CreateComment: {
-          const { form_id, comment_view } = wsJsonToRes<CommentResponse>(msg);
-
-          // Necessary since it might be a user reply
-          if (form_id) {
-            const { listingType } = getHomeQueryParams();
-
-            // If you're on subscribed, only push it if you're subscribed.
-            const shouldAddComment =
-              listingType === "Subscribed"
-                ? UserService.Instance.myUserInfo?.follows.some(
-                    ({ community: { id } }) => id === comment_view.community.id
-                  )
-                : true;
-
-            if (shouldAddComment) {
-              this.setState(({ comments }) => ({
-                comments: [comment_view].concat(comments),
-              }));
-            }
-          }
-
-          break;
-        }
+  async handlePostReport(form: CreatePostReport) {
+    const reportRes = await HttpService.client.createPostReport(form);
+    if (reportRes.state == "success") {
+      toast(i18n.t("report_created"));
+    }
+  }
 
-        case UserOperation.SaveComment: {
-          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
-          saveCommentRes(comment_view, this.state.comments);
-          this.setState(this.state);
+  async handleLockPost(form: LockPost) {
+    const lockRes = await HttpService.client.lockPost(form);
+    this.findAndUpdatePost(lockRes);
+  }
 
-          break;
-        }
+  async handleDistinguishComment(form: DistinguishComment) {
+    const distinguishRes = await HttpService.client.distinguishComment(form);
+    this.findAndUpdateComment(distinguishRes);
+  }
 
-        case UserOperation.CreateCommentLike: {
-          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
-          createCommentLikeRes(comment_view, this.state.comments);
-          this.setState(this.state);
+  async handleAddAdmin(form: AddAdmin) {
+    const addAdminRes = await HttpService.client.addAdmin(form);
 
-          break;
-        }
+    if (addAdminRes.state == "success") {
+      this.setState(s => ((s.siteRes.admins = addAdminRes.data.admins), s));
+    }
+  }
 
-        case UserOperation.BlockPerson: {
-          const data = wsJsonToRes<BlockPersonResponse>(msg);
-          updatePersonBlock(data);
+  async handleTransferCommunity(form: TransferCommunity) {
+    await HttpService.client.transferCommunity(form);
+    toast(i18n.t("transfer_community"));
+  }
 
-          break;
-        }
+  async handleCommentReplyRead(form: MarkCommentReplyAsRead) {
+    const readRes = await HttpService.client.markCommentReplyAsRead(form);
+    this.findAndUpdateCommentReply(readRes);
+  }
 
-        case UserOperation.CreatePostReport: {
-          const data = wsJsonToRes<PostReportResponse>(msg);
-          if (data) {
-            toast(i18n.t("report_created"));
-          }
+  async handlePersonMentionRead(form: MarkPersonMentionAsRead) {
+    // TODO not sure what to do here. Maybe it is actually optional, because post doesn't need it.
+    await HttpService.client.markPersonMentionAsRead(form);
+  }
 
-          break;
-        }
+  async handleBanFromCommunity(form: BanFromCommunity) {
+    const banRes = await HttpService.client.banFromCommunity(form);
+    this.updateBanFromCommunity(banRes);
+  }
 
-        case UserOperation.CreateCommentReport: {
-          const data = wsJsonToRes<CommentReportResponse>(msg);
-          if (data) {
-            toast(i18n.t("report_created"));
-          }
+  async handleBanPerson(form: BanPerson) {
+    const banRes = await HttpService.client.banPerson(form);
+    this.updateBan(banRes);
+  }
 
-          break;
+  updateBanFromCommunity(banRes: RequestState<BanFromCommunityResponse>) {
+    // Maybe not necessary
+    if (banRes.state == "success") {
+      this.setState(s => {
+        if (s.postsRes.state == "success") {
+          s.postsRes.data.posts
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(
+              c => (c.creator_banned_from_community = banRes.data.banned)
+            );
         }
+        if (s.commentsRes.state == "success") {
+          s.commentsRes.data.comments
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(
+              c => (c.creator_banned_from_community = banRes.data.banned)
+            );
+        }
+        return s;
+      });
+    }
+  }
 
-        case UserOperation.PurgePerson:
-        case UserOperation.PurgePost:
-        case UserOperation.PurgeComment:
-        case UserOperation.PurgeCommunity: {
-          const data = wsJsonToRes<PurgeItemResponse>(msg);
-          if (data.success) {
-            toast(i18n.t("purge_success"));
-            this.context.router.history.push(`/`);
-          }
-
-          break;
+  updateBan(banRes: RequestState<BanPersonResponse>) {
+    // Maybe not necessary
+    if (banRes.state == "success") {
+      this.setState(s => {
+        if (s.postsRes.state == "success") {
+          s.postsRes.data.posts
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(c => (c.creator.banned = banRes.data.banned));
         }
-      }
+        if (s.commentsRes.state == "success") {
+          s.commentsRes.data.comments
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(c => (c.creator.banned = banRes.data.banned));
+        }
+        return s;
+      });
     }
   }
+
+  purgeItem(purgeRes: RequestState<PurgeItemResponse>) {
+    if (purgeRes.state == "success") {
+      toast(i18n.t("purge_success"));
+      this.context.router.history.push(`/`);
+    }
+  }
+
+  findAndUpdateComment(res: RequestState<CommentResponse>) {
+    this.setState(s => {
+      if (s.commentsRes.state == "success" && res.state == "success") {
+        s.commentsRes.data.comments = editComment(
+          res.data.comment_view,
+          s.commentsRes.data.comments
+        );
+        s.finished.set(res.data.comment_view.comment.id, true);
+      }
+      return s;
+    });
+  }
+
+  createAndUpdateComments(res: RequestState<CommentResponse>) {
+    this.setState(s => {
+      if (s.commentsRes.state == "success" && res.state == "success") {
+        s.commentsRes.data.comments.unshift(res.data.comment_view);
+
+        // Set finished for the parent
+        s.finished.set(
+          getCommentParentId(res.data.comment_view.comment) ?? 0,
+          true
+        );
+      }
+      return s;
+    });
+  }
+
+  findAndUpdateCommentReply(res: RequestState<CommentReplyResponse>) {
+    this.setState(s => {
+      if (s.commentsRes.state == "success" && res.state == "success") {
+        s.commentsRes.data.comments = editWith(
+          res.data.comment_reply_view,
+          s.commentsRes.data.comments
+        );
+      }
+      return s;
+    });
+  }
+
+  findAndUpdatePost(res: RequestState<PostResponse>) {
+    this.setState(s => {
+      if (s.postsRes.state == "success" && res.state == "success") {
+        s.postsRes.data.posts = editPost(
+          res.data.post_view,
+          s.postsRes.data.posts
+        );
+      }
+      return s;
+    });
+  }
 }
index 80f7c24a0e5157078f057acb76d686d17b28858c..30cb9dea0c0491a020890c3206d20c14709dd09e 100644 (file)
@@ -3,106 +3,113 @@ import {
   GetFederatedInstancesResponse,
   GetSiteResponse,
   Instance,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
 import { InitialFetchRequest } from "../../interfaces";
-import { WebSocketService } from "../../services";
-import {
-  isBrowser,
-  relTags,
-  setIsoData,
-  toast,
-  wsClient,
-  wsSubscribe,
-} from "../../utils";
+import { FirstLoadService } from "../../services/FirstLoadService";
+import { HttpService, RequestState } from "../../services/HttpService";
+import { relTags, setIsoData } from "../../utils";
 import { HtmlTags } from "../common/html-tags";
+import { Spinner } from "../common/icon";
 
 interface InstancesState {
+  instancesRes: RequestState<GetFederatedInstancesResponse>;
   siteRes: GetSiteResponse;
-  instancesRes?: GetFederatedInstancesResponse;
-  loading: boolean;
+  isIsomorphic: boolean;
 }
 
 export class Instances extends Component<any, InstancesState> {
   private isoData = setIsoData(this.context);
   state: InstancesState = {
+    instancesRes: { state: "empty" },
     siteRes: this.isoData.site_res,
-    loading: true,
+    isIsomorphic: false,
   };
-  private subscription?: Subscription;
 
   constructor(props: any, context: any) {
     super(props, context);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
-
     // Only fetch the data if coming from another route
-    if (this.isoData.path == this.context.router.route.match.url) {
+    if (FirstLoadService.isFirstLoad) {
       this.state = {
         ...this.state,
-        instancesRes: this.isoData
-          .routeData[0] as GetFederatedInstancesResponse,
-        loading: false,
+        instancesRes: this.isoData.routeData[0],
+        isIsomorphic: true,
       };
-    } else {
-      WebSocketService.Instance.send(wsClient.getFederatedInstances({}));
     }
   }
 
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
-    const promises: Promise<any>[] = [];
+  async componentDidMount() {
+    if (!this.state.isIsomorphic) {
+      await this.fetchInstances();
+    }
+  }
 
-    promises.push(req.client.getFederatedInstances({}));
+  async fetchInstances() {
+    this.setState({
+      instancesRes: { state: "loading" },
+    });
 
-    return promises;
+    this.setState({
+      instancesRes: await HttpService.client.getFederatedInstances({}),
+    });
+  }
+
+  static fetchInitialData(
+    req: InitialFetchRequest
+  ): Promise<RequestState<any>>[] {
+    return [req.client.getFederatedInstances({})];
   }
 
   get documentTitle(): string {
     return `${i18n.t("instances")} - ${this.state.siteRes.site_view.site.name}`;
   }
 
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
+  renderInstances() {
+    switch (this.state.instancesRes.state) {
+      case "loading":
+        return (
+          <h5>
+            <Spinner large />
+          </h5>
+        );
+      case "success": {
+        const instances = this.state.instancesRes.data.federated_instances;
+        return instances ? (
+          <div className="row">
+            <div className="col-md-6">
+              <h5>{i18n.t("linked_instances")}</h5>
+              {this.itemList(instances.linked)}
+            </div>
+            {instances.allowed && instances.allowed.length > 0 && (
+              <div className="col-md-6">
+                <h5>{i18n.t("allowed_instances")}</h5>
+                {this.itemList(instances.allowed)}
+              </div>
+            )}
+            {instances.blocked && instances.blocked.length > 0 && (
+              <div className="col-md-6">
+                <h5>{i18n.t("blocked_instances")}</h5>
+                {this.itemList(instances.blocked)}
+              </div>
+            )}
+          </div>
+        ) : (
+          <></>
+        );
+      }
     }
   }
 
   render() {
-    const federated_instances = this.state.instancesRes?.federated_instances;
-    return federated_instances ? (
+    return (
       <div className="container-lg">
         <HtmlTags
           title={this.documentTitle}
           path={this.context.router.route.match.url}
         />
-        <div className="row">
-          <div className="col-md-6">
-            <h5>{i18n.t("linked_instances")}</h5>
-            {this.itemList(federated_instances.linked)}
-          </div>
-          {federated_instances.allowed &&
-            federated_instances.allowed.length > 0 && (
-              <div className="col-md-6">
-                <h5>{i18n.t("allowed_instances")}</h5>
-                {this.itemList(federated_instances.allowed)}
-              </div>
-            )}
-          {federated_instances.blocked &&
-            federated_instances.blocked.length > 0 && (
-              <div className="col-md-6">
-                <h5>{i18n.t("blocked_instances")}</h5>
-                {this.itemList(federated_instances.blocked)}
-              </div>
-            )}
-        </div>
+        {this.renderInstances()}
       </div>
-    ) : (
-      <></>
     );
   }
 
@@ -136,17 +143,4 @@ export class Instances extends Component<any, InstancesState> {
       <div>{i18n.t("none_found")}</div>
     );
   }
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      this.context.router.history.push("/");
-      this.setState({ loading: false });
-      return;
-    } else if (op == UserOperation.GetFederatedInstances) {
-      const data = wsJsonToRes<GetFederatedInstancesResponse>(msg);
-      this.setState({ loading: false, instancesRes: data });
-    }
-  }
 }
index ee728729beb743a4f2301c89842808bffe215680..87ef234e543ca3461012c05438da3bc8af8241b7 100644 (file)
@@ -1,58 +1,35 @@
 import { Component, linkEvent } from "inferno";
-import {
-  GetSiteResponse,
-  Login as LoginI,
-  LoginResponse,
-  PasswordReset,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
-} from "lemmy-js-client";
-import { Subscription } from "rxjs";
+import { GetSiteResponse, LoginResponse } from "lemmy-js-client";
 import { i18n } from "../../i18next";
-import { UserService, WebSocketService } from "../../services";
-import {
-  isBrowser,
-  setIsoData,
-  toast,
-  validEmail,
-  wsClient,
-  wsSubscribe,
-} from "../../utils";
+import { UserService } from "../../services";
+import { HttpService, RequestState } from "../../services/HttpService";
+import { isBrowser, myAuth, setIsoData, toast, validEmail } from "../../utils";
 import { HtmlTags } from "../common/html-tags";
 import { Spinner } from "../common/icon";
 
 interface State {
+  loginRes: RequestState<LoginResponse>;
   form: {
     username_or_email?: string;
     password?: string;
     totp_2fa_token?: string;
   };
-  loginLoading: boolean;
   showTotp: boolean;
   siteRes: GetSiteResponse;
 }
 
 export class Login extends Component<any, State> {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
 
   state: State = {
+    loginRes: { state: "empty" },
     form: {},
-    loginLoading: false,
     showTotp: false,
     siteRes: this.isoData.site_res,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
-
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
-
-    if (isBrowser()) {
-      WebSocketService.Instance.send(wsClient.getCaptcha({}));
-    }
   }
 
   componentDidMount() {
@@ -62,12 +39,6 @@ export class Login extends Component<any, State> {
     }
   }
 
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
-    }
-  }
-
   get documentTitle(): string {
     return `${i18n.t("login")} - ${this.state.siteRes.site_view.site.name}`;
   }
@@ -169,7 +140,11 @@ export class Login extends Component<any, State> {
           <div className="form-group row">
             <div className="col-sm-10">
               <button type="submit" className="btn btn-secondary">
-                {this.state.loginLoading ? <Spinner /> : i18n.t("login")}
+                {this.state.loginRes.state == "loading" ? (
+                  <Spinner />
+                ) : (
+                  i18n.t("login")
+                )}
               </button>
             </div>
           </div>
@@ -178,20 +153,44 @@ export class Login extends Component<any, State> {
     );
   }
 
-  handleLoginSubmit(i: Login, event: any) {
+  async handleLoginSubmit(i: Login, event: any) {
     event.preventDefault();
-    i.setState({ loginLoading: true });
-    const lForm = i.state.form;
-    const username_or_email = lForm.username_or_email;
-    const password = lForm.password;
-    const totp_2fa_token = lForm.totp_2fa_token;
+    const { password, totp_2fa_token, username_or_email } = i.state.form;
+
     if (username_or_email && password) {
-      const form: LoginI = {
+      i.setState({ loginRes: { state: "loading" } });
+
+      const loginRes = await HttpService.client.login({
         username_or_email,
         password,
         totp_2fa_token,
-      };
-      WebSocketService.Instance.send(wsClient.login(form));
+      });
+      switch (loginRes.state) {
+        case "failed": {
+          if (loginRes.msg === "missing_totp_token") {
+            i.setState({ showTotp: true });
+            toast(i18n.t("enter_two_factor_code"), "info");
+          }
+
+          i.setState({ loginRes: { state: "empty" } });
+          break;
+        }
+
+        case "success": {
+          UserService.Instance.login(loginRes.data);
+          const site = await HttpService.client.getSite({
+            auth: myAuth(),
+          });
+
+          if (site.state === "success") {
+            UserService.Instance.myUserInfo = site.data.my_user;
+          }
+
+          i.props.history.replace("/");
+
+          break;
+        }
+      }
     }
   }
 
@@ -210,40 +209,13 @@ export class Login extends Component<any, State> {
     i.setState(i.state);
   }
 
-  handlePasswordReset(i: Login, event: any) {
+  async handlePasswordReset(i: Login, event: any) {
     event.preventDefault();
     const email = i.state.form.username_or_email;
     if (email) {
-      const resetForm: PasswordReset = { email };
-      WebSocketService.Instance.send(wsClient.passwordReset(resetForm));
-    }
-  }
-
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      // If the error comes back that the token is missing, show the TOTP field
-      if (msg.error == "missing_totp_token") {
-        this.setState({ showTotp: true, loginLoading: false });
-        toast(i18n.t("enter_two_factor_code"));
-        return;
-      } else {
-        toast(i18n.t(msg.error), "danger");
-        this.setState({ form: {}, loginLoading: false });
-        return;
-      }
-    } else {
-      if (op == UserOperation.Login) {
-        const data = wsJsonToRes<LoginResponse>(msg);
-        UserService.Instance.login(data);
-        this.props.history.push("/");
-        location.reload();
-      } else if (op == UserOperation.PasswordReset) {
+      const res = await HttpService.client.passwordReset({ email });
+      if (res.state == "success") {
         toast(i18n.t("reset_password_mail_sent"));
-      } else if (op == UserOperation.GetSite) {
-        const data = wsJsonToRes<GetSiteResponse>(msg);
-        this.setState({ siteRes: data });
       }
     }
   }
index 8f2a1a81603d6e51b4c1f90b1552f1f31fd9fdfc..74ed18e32c81bd19ab67325997561950cb3fd8f3 100644 (file)
@@ -1,8 +1,7 @@
 import { Component, FormEventHandler, linkEvent } from "inferno";
 import { EditSite, LocalSiteRateLimit } from "lemmy-js-client";
 import { i18n } from "../../i18next";
-import { WebSocketService } from "../../services";
-import { capitalizeFirstLetter, myAuth, wsClient } from "../../utils";
+import { capitalizeFirstLetter, myAuthRequired } from "../../utils";
 import { Spinner } from "../common/icon";
 import Tabs from "../common/tabs";
 
@@ -23,8 +22,8 @@ interface RateLimitsProps {
 }
 
 interface RateLimitFormProps {
-  localSiteRateLimit: LocalSiteRateLimit;
-  applicationQuestion?: string;
+  rateLimits: LocalSiteRateLimit;
+  onSaveSite(form: EditSite): void;
 }
 
 interface RateLimitFormState {
@@ -107,18 +106,19 @@ function handlePerSecondChange(
 
 function submitRateLimitForm(i: RateLimitsForm, event: any) {
   event.preventDefault();
-  const auth = myAuth() ?? "TODO";
+  const auth = myAuthRequired();
   const form: EditSite = Object.entries(i.state.form).reduce(
     (acc, [key, val]) => {
       acc[`rate_limit_${key}`] = val;
       return acc;
     },
-    { auth, application_question: i.props.applicationQuestion }
+    {
+      auth,
+    }
   );
 
   i.setState({ loading: true });
-
-  WebSocketService.Instance.send(wsClient.editSite(form));
+  i.props.onSaveSite(form);
 }
 
 export default class RateLimitsForm extends Component<
@@ -127,43 +127,10 @@ export default class RateLimitsForm extends Component<
 > {
   state: RateLimitFormState = {
     loading: false,
-    form: {},
+    form: this.props.rateLimits,
   };
-  constructor(props: RateLimitFormProps, context) {
+  constructor(props: RateLimitFormProps, context: any) {
     super(props, context);
-
-    const {
-      comment,
-      comment_per_second,
-      image,
-      image_per_second,
-      message,
-      message_per_second,
-      post,
-      post_per_second,
-      register,
-      register_per_second,
-      search,
-      search_per_second,
-    } = props.localSiteRateLimit;
-
-    this.state = {
-      ...this.state,
-      form: {
-        comment,
-        comment_per_second,
-        image,
-        image_per_second,
-        message,
-        message_per_second,
-        post,
-        post_per_second,
-        register,
-        register_per_second,
-        search,
-        search_per_second,
-      },
-    };
   }
 
   render() {
@@ -210,15 +177,4 @@ export default class RateLimitsForm extends Component<
       </form>
     );
   }
-
-  componentDidUpdate({ localSiteRateLimit }: RateLimitFormProps) {
-    if (
-      this.state.loading &&
-      Object.entries(localSiteRateLimit).some(
-        ([key, val]) => this.state.form[key] !== val
-      )
-    ) {
-      this.setState({ loading: false });
-    }
-  }
 }
index b1ecb272ae762da2368ac82305785218d5263eee..581c1c563db33eb685c48b70f4d2c3c9babbf6db 100644 (file)
@@ -1,18 +1,15 @@
 import { Component, linkEvent } from "inferno";
 import { Helmet } from "inferno-helmet";
 import {
+  CreateSite,
   GetSiteResponse,
   LoginResponse,
   Register,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
-import { delay, retryWhen, take } from "rxjs/operators";
 import { i18n } from "../../i18next";
-import { UserService, WebSocketService } from "../../services";
-import { setIsoData, toast, wsClient } from "../../utils";
+import { UserService } from "../../services";
+import { HttpService, RequestState } from "../../services/HttpService";
+import { fetchThemeList, setIsoData } from "../../utils";
 import { Spinner } from "../common/icon";
 import { SiteForm } from "./site-form";
 
@@ -29,37 +26,32 @@ interface State {
     answer?: string;
   };
   doneRegisteringUser: boolean;
-  userLoading: boolean;
+  registerRes: RequestState<LoginResponse>;
+  themeList: string[];
   siteRes: GetSiteResponse;
 }
 
 export class Setup extends Component<any, State> {
-  private subscription: Subscription;
   private isoData = setIsoData(this.context);
 
   state: State = {
+    registerRes: { state: "empty" },
+    themeList: [],
     form: {
       show_nsfw: true,
     },
     doneRegisteringUser: !!UserService.Instance.myUserInfo,
-    userLoading: false,
     siteRes: this.isoData.site_res,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
 
-    this.subscription = WebSocketService.Instance.subject
-      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
-      .subscribe(
-        msg => this.parseMessage(msg),
-        err => console.error(err),
-        () => console.log("complete")
-      );
+    this.handleCreateSite = this.handleCreateSite.bind(this);
   }
 
-  componentWillUnmount() {
-    this.subscription.unsubscribe();
+  async componentDidMount() {
+    this.setState({ themeList: await fetchThemeList() });
   }
 
   get documentTitle(): string {
@@ -76,7 +68,12 @@ export class Setup extends Component<any, State> {
             {!this.state.doneRegisteringUser ? (
               this.registerUser()
             ) : (
-              <SiteForm siteRes={this.state.siteRes} showLocal />
+              <SiteForm
+                showLocal
+                onSaveSite={this.handleCreateSite}
+                siteRes={this.state.siteRes}
+                themeList={this.state.themeList}
+              />
             )}
           </div>
         </div>
@@ -161,7 +158,11 @@ export class Setup extends Component<any, State> {
         <div className="form-group row">
           <div className="col-sm-10">
             <button type="submit" className="btn btn-secondary">
-              {this.state.userLoading ? <Spinner /> : i18n.t("sign_up")}
+              {this.state.registerRes.state == "loading" ? (
+                <Spinner />
+              ) : (
+                i18n.t("sign_up")
+              )}
             </button>
           </div>
         </div>
@@ -169,24 +170,53 @@ export class Setup extends Component<any, State> {
     );
   }
 
-  handleRegisterSubmit(i: Setup, event: any) {
+  async handleRegisterSubmit(i: Setup, event: any) {
     event.preventDefault();
-    i.setState({ userLoading: true });
-    event.preventDefault();
-    const cForm = i.state.form;
-    if (cForm.username && cForm.password && cForm.password_verify) {
+    i.setState({ registerRes: { state: "loading" } });
+    const {
+      username,
+      password_verify,
+      password,
+      email,
+      show_nsfw,
+      captcha_uuid,
+      captcha_answer,
+      honeypot,
+      answer,
+    } = i.state.form;
+
+    if (username && password && password_verify) {
       const form: Register = {
-        username: cForm.username,
-        password: cForm.password,
-        password_verify: cForm.password_verify,
-        email: cForm.email,
-        show_nsfw: cForm.show_nsfw,
-        captcha_uuid: cForm.captcha_uuid,
-        captcha_answer: cForm.captcha_answer,
-        honeypot: cForm.honeypot,
-        answer: cForm.answer,
+        username,
+        password,
+        password_verify,
+        email,
+        show_nsfw,
+        captcha_uuid,
+        captcha_answer,
+        honeypot,
+        answer,
       };
-      WebSocketService.Instance.send(wsClient.register(form));
+      i.setState({
+        registerRes: await HttpService.client.register(form),
+      });
+
+      if (i.state.registerRes.state == "success") {
+        const data = i.state.registerRes.data;
+
+        UserService.Instance.login(data);
+        if (UserService.Instance.jwtInfo) {
+          i.setState({ doneRegisteringUser: true });
+        }
+      }
+    }
+  }
+
+  async handleCreateSite(form: CreateSite) {
+    const createRes = await HttpService.client.createSite(form);
+    if (createRes.state === "success") {
+      this.props.history.replace("/");
+      location.reload();
     }
   }
 
@@ -209,22 +239,4 @@ export class Setup extends Component<any, State> {
     i.state.form.password_verify = event.target.value;
     i.setState(i.state);
   }
-
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      this.setState({ userLoading: false });
-      return;
-    } else if (op == UserOperation.Register) {
-      const data = wsJsonToRes<LoginResponse>(msg);
-      this.setState({ userLoading: false });
-      UserService.Instance.login(data);
-      if (UserService.Instance.jwtInfo) {
-        this.setState({ doneRegisteringUser: true });
-      }
-    } else if (op == UserOperation.CreateSite) {
-      window.location.href = "/";
-    }
-  }
 }
index 65741eba3c789f7d5d91ab84addbb9addb7ece3c..3efeac6208e104b99b3cf123dbc1afeceaa495a0 100644 (file)
@@ -7,24 +7,19 @@ import {
   GetCaptchaResponse,
   GetSiteResponse,
   LoginResponse,
-  Register,
   SiteView,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
-import { UserService, WebSocketService } from "../../services";
+import { UserService } from "../../services";
+import { HttpService, RequestState } from "../../services/HttpService";
 import {
   isBrowser,
   joinLemmyUrl,
   mdToHtml,
+  myAuth,
   setIsoData,
   toast,
   validEmail,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { HtmlTags } from "../common/html-tags";
 import { Icon, Spinner } from "../common/icon";
@@ -58,6 +53,8 @@ const passwordStrengthOptions: Options<string> = [
 ];
 
 interface State {
+  registerRes: RequestState<LoginResponse>;
+  captchaRes: RequestState<GetCaptchaResponse>;
   form: {
     username?: string;
     email?: string;
@@ -69,22 +66,20 @@ interface State {
     honeypot?: string;
     answer?: string;
   };
-  registerLoading: boolean;
-  captcha?: GetCaptchaResponse;
   captchaPlaying: boolean;
   siteRes: GetSiteResponse;
 }
 
 export class Signup extends Component<any, State> {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
   private audio?: HTMLAudioElement;
 
   state: State = {
+    registerRes: { state: "empty" },
+    captchaRes: { state: "empty" },
     form: {
       show_nsfw: false,
     },
-    registerLoading: false,
     captchaPlaying: false,
     siteRes: this.isoData.site_res,
   };
@@ -93,19 +88,26 @@ export class Signup extends Component<any, State> {
     super(props, context);
 
     this.handleAnswerChange = this.handleAnswerChange.bind(this);
+  }
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
-
-    if (isBrowser()) {
-      WebSocketService.Instance.send(wsClient.getCaptcha({}));
+  async componentDidMount() {
+    if (this.state.siteRes.site_view.local_site.captcha_enabled) {
+      await this.fetchCaptcha();
     }
   }
 
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
-    }
+  async fetchCaptcha() {
+    this.setState({ captchaRes: { state: "loading" } });
+    this.setState({
+      captchaRes: await HttpService.client.getCaptcha({}),
+    });
+
+    this.setState(s => {
+      if (s.captchaRes.state == "success") {
+        s.form.captcha_uuid = s.captchaRes.data.ok?.uuid;
+      }
+      return s;
+    });
   }
 
   get documentTitle(): string {
@@ -285,6 +287,7 @@ export class Signup extends Component<any, State> {
               </label>
               <div className="col-sm-10">
                 <MarkdownTextArea
+                  initialContent=""
                   onContentChange={this.handleAnswerChange}
                   hideNavigationWarnings
                   allLanguages={[]}
@@ -294,36 +297,7 @@ export class Signup extends Component<any, State> {
             </div>
           </>
         )}
-
-        {this.state.captcha && (
-          <div className="form-group row">
-            <label className="col-sm-2" htmlFor="register-captcha">
-              <span className="mr-2">{i18n.t("enter_code")}</span>
-              <button
-                type="button"
-                className="btn btn-secondary"
-                onClick={linkEvent(this, this.handleRegenCaptcha)}
-                aria-label={i18n.t("captcha")}
-              >
-                <Icon icon="refresh-cw" classes="icon-refresh-cw" />
-              </button>
-            </label>
-            {this.showCaptcha()}
-            <div className="col-sm-6">
-              <input
-                type="text"
-                className="form-control"
-                id="register-captcha"
-                value={this.state.form.captcha_answer}
-                onInput={linkEvent(
-                  this,
-                  this.handleRegisterCaptchaAnswerChange
-                )}
-                required
-              />
-            </div>
-          </div>
-        )}
+        {this.renderCaptcha()}
         {siteView.local_site.enable_nsfw && (
           <div className="form-group row">
             <div className="col-sm-10">
@@ -358,7 +332,7 @@ export class Signup extends Component<any, State> {
         <div className="form-group row">
           <div className="col-sm-10">
             <button type="submit" className="btn btn-secondary">
-              {this.state.registerLoading ? (
+              {this.state.registerRes.state == "loading" ? (
                 <Spinner />
               ) : (
                 this.titleName(siteView)
@@ -370,8 +344,47 @@ export class Signup extends Component<any, State> {
     );
   }
 
-  showCaptcha() {
-    const captchaRes = this.state.captcha?.ok;
+  renderCaptcha() {
+    switch (this.state.captchaRes.state) {
+      case "loading":
+        return <Spinner />;
+      case "success": {
+        const res = this.state.captchaRes.data;
+        return (
+          <div className="form-group row">
+            <label className="col-sm-2" htmlFor="register-captcha">
+              <span className="mr-2">{i18n.t("enter_code")}</span>
+              <button
+                type="button"
+                className="btn btn-secondary"
+                onClick={linkEvent(this, this.handleRegenCaptcha)}
+                aria-label={i18n.t("captcha")}
+              >
+                <Icon icon="refresh-cw" classes="icon-refresh-cw" />
+              </button>
+            </label>
+            {this.showCaptcha(res)}
+            <div className="col-sm-6">
+              <input
+                type="text"
+                className="form-control"
+                id="register-captcha"
+                value={this.state.form.captcha_answer}
+                onInput={linkEvent(
+                  this,
+                  this.handleRegisterCaptchaAnswerChange
+                )}
+                required
+              />
+            </div>
+          </div>
+        );
+      }
+    }
+  }
+
+  showCaptcha(res: GetCaptchaResponse) {
+    const captchaRes = res?.ok;
     return captchaRes ? (
       <div className="col-sm-4">
         <>
@@ -419,23 +432,66 @@ export class Signup extends Component<any, State> {
     }
   }
 
-  handleRegisterSubmit(i: Signup, event: any) {
+  async handleRegisterSubmit(i: Signup, event: any) {
     event.preventDefault();
-    i.setState({ registerLoading: true });
-    const cForm = i.state.form;
-    if (cForm.username && cForm.password && cForm.password_verify) {
-      const form: Register = {
-        username: cForm.username,
-        password: cForm.password,
-        password_verify: cForm.password_verify,
-        email: cForm.email,
-        show_nsfw: cForm.show_nsfw,
-        captcha_uuid: cForm.captcha_uuid,
-        captcha_answer: cForm.captcha_answer,
-        honeypot: cForm.honeypot,
-        answer: cForm.answer,
-      };
-      WebSocketService.Instance.send(wsClient.register(form));
+    const {
+      show_nsfw,
+      answer,
+      captcha_answer,
+      captcha_uuid,
+      email,
+      honeypot,
+      password,
+      password_verify,
+      username,
+    } = i.state.form;
+    if (username && password && password_verify) {
+      i.setState({ registerRes: { state: "loading" } });
+
+      const registerRes = await HttpService.client.register({
+        username,
+        password,
+        password_verify,
+        email,
+        show_nsfw,
+        captcha_uuid,
+        captcha_answer,
+        honeypot,
+        answer,
+      });
+      switch (registerRes.state) {
+        case "failed": {
+          toast(registerRes.msg, "danger");
+          i.setState({ registerRes: { state: "empty" } });
+          break;
+        }
+
+        case "success": {
+          const data = registerRes.data;
+
+          // Only log them in if a jwt was set
+          if (data.jwt) {
+            UserService.Instance.login(data);
+
+            const site = await HttpService.client.getSite({ auth: myAuth() });
+
+            if (site.state === "success") {
+              UserService.Instance.myUserInfo = site.data.my_user;
+            }
+
+            i.props.history.replace("/communities");
+          } else {
+            if (data.verify_email_sent) {
+              toast(i18n.t("verify_email_sent"));
+            }
+            if (data.registration_created) {
+              toast(i18n.t("registration_application_sent"));
+            }
+            i.props.history.push("/");
+          }
+          break;
+        }
+      }
     }
   }
 
@@ -481,17 +537,18 @@ export class Signup extends Component<any, State> {
     i.setState(i.state);
   }
 
-  handleRegenCaptcha(i: Signup) {
+  async handleRegenCaptcha(i: Signup) {
     i.audio = undefined;
     i.setState({ captchaPlaying: false });
-    WebSocketService.Instance.send(wsClient.getCaptcha({}));
+    await i.fetchCaptcha();
   }
 
   handleCaptchaPlay(i: Signup) {
     // This was a bad bug, it should only build the new audio on a new file.
     // Replays would stop prematurely if this was rebuilt every time.
-    const captchaRes = i.state.captcha?.ok;
-    if (captchaRes) {
+
+    if (i.state.captchaRes.state == "success" && i.state.captchaRes.data.ok) {
+      const captchaRes = i.state.captchaRes.data.ok;
       if (!i.audio) {
         const base64 = `data:audio/wav;base64,${captchaRes.wav}`;
         i.audio = new Audio(base64);
@@ -512,45 +569,4 @@ export class Signup extends Component<any, State> {
   captchaPngSrc(captcha: CaptchaResponse) {
     return `data:image/png;base64,${captcha.png}`;
   }
-
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      this.setState(s => ((s.form.captcha_answer = undefined), s));
-      // Refetch another captcha
-      // WebSocketService.Instance.send(wsClient.getCaptcha());
-      return;
-    } else {
-      if (op == UserOperation.Register) {
-        const data = wsJsonToRes<LoginResponse>(msg);
-        // Only log them in if a jwt was set
-        if (data.jwt) {
-          UserService.Instance.login(data);
-          this.props.history.push("/communities");
-          location.reload();
-        } else {
-          if (data.verify_email_sent) {
-            toast(i18n.t("verify_email_sent"));
-          }
-          if (data.registration_created) {
-            toast(i18n.t("registration_application_sent"));
-          }
-          this.props.history.push("/");
-        }
-      } else if (op == UserOperation.GetCaptcha) {
-        const data = wsJsonToRes<GetCaptchaResponse>(msg);
-        if (data.ok) {
-          this.setState({ captcha: data });
-          this.setState(s => ((s.form.captcha_uuid = data.ok?.uuid), s));
-        }
-      } else if (op == UserOperation.PasswordReset) {
-        toast(i18n.t("reset_password_mail_sent"));
-      } else if (op == UserOperation.GetSite) {
-        const data = wsJsonToRes<GetSiteResponse>(msg);
-        this.setState({ siteRes: data });
-      }
-    }
-  }
 }
index 40588ac8cf1ed29df848aabea87d83726de092dd..3b451e66ab10bd04493faea8b1ece5e4deda08e7 100644 (file)
@@ -7,18 +7,12 @@ import {
 import {
   CreateSite,
   EditSite,
-  GetFederatedInstancesResponse,
   GetSiteResponse,
+  Instance,
   ListingType,
 } from "lemmy-js-client";
 import { i18n } from "../../i18next";
-import { WebSocketService } from "../../services";
-import {
-  capitalizeFirstLetter,
-  fetchThemeList,
-  myAuth,
-  wsClient,
-} from "../../utils";
+import { capitalizeFirstLetter, myAuthRequired } from "../../utils";
 import { Icon, Spinner } from "../common/icon";
 import { ImageUploadForm } from "../common/image-upload-form";
 import { LanguageSelect } from "../common/language-select";
@@ -27,35 +21,73 @@ import { MarkdownTextArea } from "../common/markdown-textarea";
 import NavigationPrompt from "../common/navigation-prompt";
 
 interface SiteFormProps {
-  siteRes: GetSiteResponse;
-  instancesRes?: GetFederatedInstancesResponse;
+  blockedInstances?: Instance[];
+  allowedInstances?: Instance[];
   showLocal?: boolean;
+  themeList?: string[];
+  onSaveSite(form: EditSite): void;
+  siteRes: GetSiteResponse;
 }
 
 interface SiteFormState {
   siteForm: EditSite;
   loading: boolean;
-  themeList?: string[];
   instance_select: {
     allowed_instances: string;
     blocked_instances: string;
   };
+  submitted: boolean;
 }
 
 type InstanceKey = "allowed_instances" | "blocked_instances";
 
 export class SiteForm extends Component<SiteFormProps, SiteFormState> {
   state: SiteFormState = {
-    siteForm: {
-      auth: "TODO",
-    },
+    siteForm: this.initSiteForm(),
     loading: false,
     instance_select: {
       allowed_instances: "",
       blocked_instances: "",
     },
+    submitted: false,
   };
 
+  initSiteForm(): EditSite {
+    const site = this.props.siteRes.site_view.site;
+    const ls = this.props.siteRes.site_view.local_site;
+    return {
+      name: site.name,
+      sidebar: site.sidebar,
+      description: site.description,
+      enable_downvotes: ls.enable_downvotes,
+      registration_mode: ls.registration_mode,
+      enable_nsfw: ls.enable_nsfw,
+      community_creation_admin_only: ls.community_creation_admin_only,
+      icon: site.icon,
+      banner: site.banner,
+      require_email_verification: ls.require_email_verification,
+      application_question: ls.application_question,
+      private_instance: ls.private_instance,
+      default_theme: ls.default_theme,
+      default_post_listing_type: ls.default_post_listing_type,
+      legal_information: ls.legal_information,
+      application_email_admins: ls.application_email_admins,
+      reports_email_admins: ls.reports_email_admins,
+      hide_modlog_mod_names: ls.hide_modlog_mod_names,
+      discussion_languages: this.props.siteRes.discussion_languages,
+      slur_filter_regex: ls.slur_filter_regex,
+      actor_name_max_length: ls.actor_name_max_length,
+      federation_enabled: ls.federation_enabled,
+      federation_debug: ls.federation_debug,
+      federation_worker_count: ls.federation_worker_count,
+      captcha_enabled: ls.captcha_enabled,
+      captcha_difficulty: ls.captcha_difficulty,
+      allowed_instances: this.props.allowedInstances?.map(i => i.domain),
+      blocked_instances: this.props.blockedInstances?.map(i => i.domain),
+      auth: "TODO",
+    };
+  }
+
   constructor(props: any, context: any) {
     super(props, context);
 
@@ -75,53 +107,9 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
 
     this.handleDiscussionLanguageChange =
       this.handleDiscussionLanguageChange.bind(this);
-
-    const site = this.props.siteRes.site_view.site;
-    const ls = this.props.siteRes.site_view.local_site;
-    this.state = {
-      ...this.state,
-      siteForm: {
-        name: site.name,
-        sidebar: site.sidebar,
-        description: site.description,
-        enable_downvotes: ls.enable_downvotes,
-        registration_mode: ls.registration_mode,
-        enable_nsfw: ls.enable_nsfw,
-        community_creation_admin_only: ls.community_creation_admin_only,
-        icon: site.icon,
-        banner: site.banner,
-        require_email_verification: ls.require_email_verification,
-        application_question: ls.application_question,
-        private_instance: ls.private_instance,
-        default_theme: ls.default_theme,
-        default_post_listing_type: ls.default_post_listing_type,
-        legal_information: ls.legal_information,
-        application_email_admins: ls.application_email_admins,
-        reports_email_admins: ls.reports_email_admins,
-        hide_modlog_mod_names: ls.hide_modlog_mod_names,
-        discussion_languages: this.props.siteRes.discussion_languages,
-        slur_filter_regex: ls.slur_filter_regex,
-        actor_name_max_length: ls.actor_name_max_length,
-        federation_enabled: ls.federation_enabled,
-        federation_debug: ls.federation_debug,
-        federation_worker_count: ls.federation_worker_count,
-        captcha_enabled: ls.captcha_enabled,
-        captcha_difficulty: ls.captcha_difficulty,
-        allowed_instances:
-          this.props.instancesRes?.federated_instances?.allowed.map(
-            i => i.domain
-          ),
-        blocked_instances:
-          this.props.instancesRes?.federated_instances?.blocked.map(
-            i => i.domain
-          ),
-        auth: "TODO",
-      },
-    };
-  }
-
-  async componentDidMount() {
-    this.setState({ themeList: await fetchThemeList() });
+    this.handleAddInstance = this.handleAddInstance.bind(this);
+    this.handleInstanceEnterPress = this.handleInstanceEnterPress.bind(this);
+    this.handleInstanceTextChange = this.handleInstanceTextChange.bind(this);
   }
 
   // Necessary to stop the loading
@@ -129,29 +117,10 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
     this.setState({ loading: false });
   }
 
-  componentDidUpdate() {
-    if (
-      !this.state.loading &&
-      !this.props.siteRes.site_view.local_site.site_setup &&
-      (this.state.siteForm.name ||
-        this.state.siteForm.sidebar ||
-        this.state.siteForm.application_question ||
-        this.state.siteForm.description)
-    ) {
-      window.onbeforeunload = () => true;
-    } else {
-      window.onbeforeunload = null;
-    }
-  }
-
-  componentWillUnmount() {
-    window.onbeforeunload = null;
-  }
-
   render() {
     const siteSetup = this.props.siteRes.site_view.local_site.site_setup;
     return (
-      <>
+      <form onSubmit={linkEvent(this, this.handleSaveSiteSubmit)}>
         <NavigationPrompt
           when={
             !this.state.loading &&
@@ -161,507 +130,498 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
               this.state.siteForm.sidebar ||
               this.state.siteForm.application_question ||
               this.state.siteForm.description
-            )
+            ) &&
+            !this.state.submitted
           }
         />
-        <form onSubmit={linkEvent(this, this.handleCreateSiteSubmit)}>
-          <h5>{`${
-            siteSetup
-              ? capitalizeFirstLetter(i18n.t("save"))
-              : capitalizeFirstLetter(i18n.t("name"))
-          } ${i18n.t("your_site")}`}</h5>
-          <div className="form-group row">
-            <label className="col-12 col-form-label" htmlFor="create-site-name">
-              {i18n.t("name")}
-            </label>
-            <div className="col-12">
-              <input
-                type="text"
-                id="create-site-name"
-                className="form-control"
-                value={this.state.siteForm.name}
-                onInput={linkEvent(this, this.handleSiteNameChange)}
-                required
-                minLength={3}
-                maxLength={20}
-              />
-            </div>
+        <h5>{`${
+          siteSetup
+            ? capitalizeFirstLetter(i18n.t("save"))
+            : capitalizeFirstLetter(i18n.t("name"))
+        } ${i18n.t("your_site")}`}</h5>
+        <div className="form-group row">
+          <label className="col-12 col-form-label" htmlFor="create-site-name">
+            {i18n.t("name")}
+          </label>
+          <div className="col-12">
+            <input
+              type="text"
+              id="create-site-name"
+              className="form-control"
+              value={this.state.siteForm.name}
+              onInput={linkEvent(this, this.handleSiteNameChange)}
+              required
+              minLength={3}
+              maxLength={20}
+            />
           </div>
-          <div className="form-group">
-            <label>{i18n.t("icon")}</label>
-            <ImageUploadForm
-              uploadTitle={i18n.t("upload_icon")}
-              imageSrc={this.state.siteForm.icon}
-              onUpload={this.handleIconUpload}
-              onRemove={this.handleIconRemove}
-              rounded
+        </div>
+        <div className="form-group">
+          <label>{i18n.t("icon")}</label>
+          <ImageUploadForm
+            uploadTitle={i18n.t("upload_icon")}
+            imageSrc={this.state.siteForm.icon}
+            onUpload={this.handleIconUpload}
+            onRemove={this.handleIconRemove}
+            rounded
+          />
+        </div>
+        <div className="form-group">
+          <label>{i18n.t("banner")}</label>
+          <ImageUploadForm
+            uploadTitle={i18n.t("upload_banner")}
+            imageSrc={this.state.siteForm.banner}
+            onUpload={this.handleBannerUpload}
+            onRemove={this.handleBannerRemove}
+          />
+        </div>
+        <div className="form-group row">
+          <label className="col-12 col-form-label" htmlFor="site-desc">
+            {i18n.t("description")}
+          </label>
+          <div className="col-12">
+            <input
+              type="text"
+              className="form-control"
+              id="site-desc"
+              value={this.state.siteForm.description}
+              onInput={linkEvent(this, this.handleSiteDescChange)}
+              maxLength={150}
             />
           </div>
-          <div className="form-group">
-            <label>{i18n.t("banner")}</label>
-            <ImageUploadForm
-              uploadTitle={i18n.t("upload_banner")}
-              imageSrc={this.state.siteForm.banner}
-              onUpload={this.handleBannerUpload}
-              onRemove={this.handleBannerRemove}
+        </div>
+        <div className="form-group row">
+          <label className="col-12 col-form-label">{i18n.t("sidebar")}</label>
+          <div className="col-12">
+            <MarkdownTextArea
+              initialContent={this.state.siteForm.sidebar}
+              onContentChange={this.handleSiteSidebarChange}
+              hideNavigationWarnings
+              allLanguages={[]}
+              siteLanguages={[]}
             />
           </div>
-          <div className="form-group row">
-            <label className="col-12 col-form-label" htmlFor="site-desc">
-              {i18n.t("description")}
-            </label>
-            <div className="col-12">
+        </div>
+        <div className="form-group row">
+          <label className="col-12 col-form-label">
+            {i18n.t("legal_information")}
+          </label>
+          <div className="col-12">
+            <MarkdownTextArea
+              initialContent={this.state.siteForm.legal_information}
+              onContentChange={this.handleSiteLegalInfoChange}
+              hideNavigationWarnings
+              allLanguages={[]}
+              siteLanguages={[]}
+            />
+          </div>
+        </div>
+        <div className="form-group row">
+          <div className="col-12">
+            <div className="form-check">
               <input
-                type="text"
-                className="form-control"
-                id="site-desc"
-                value={this.state.siteForm.description}
-                onInput={linkEvent(this, this.handleSiteDescChange)}
-                maxLength={150}
+                className="form-check-input"
+                id="create-site-downvotes"
+                type="checkbox"
+                checked={this.state.siteForm.enable_downvotes}
+                onChange={linkEvent(this, this.handleSiteEnableDownvotesChange)}
               />
+              <label
+                className="form-check-label"
+                htmlFor="create-site-downvotes"
+              >
+                {i18n.t("enable_downvotes")}
+              </label>
             </div>
           </div>
-          <div className="form-group row">
-            <label className="col-12 col-form-label">{i18n.t("sidebar")}</label>
-            <div className="col-12">
-              <MarkdownTextArea
-                initialContent={this.state.siteForm.sidebar}
-                onContentChange={this.handleSiteSidebarChange}
-                hideNavigationWarnings
-                allLanguages={[]}
-                siteLanguages={[]}
+        </div>
+        <div className="form-group row">
+          <div className="col-12">
+            <div className="form-check">
+              <input
+                className="form-check-input"
+                id="create-site-enable-nsfw"
+                type="checkbox"
+                checked={this.state.siteForm.enable_nsfw}
+                onChange={linkEvent(this, this.handleSiteEnableNsfwChange)}
               />
+              <label
+                className="form-check-label"
+                htmlFor="create-site-enable-nsfw"
+              >
+                {i18n.t("enable_nsfw")}
+              </label>
             </div>
           </div>
+        </div>
+        <div className="form-group row">
+          <div className="col-12">
+            <label
+              className="form-check-label mr-2"
+              htmlFor="create-site-registration-mode"
+            >
+              {i18n.t("registration_mode")}
+            </label>
+            <select
+              id="create-site-registration-mode"
+              value={this.state.siteForm.registration_mode}
+              onChange={linkEvent(this, this.handleSiteRegistrationModeChange)}
+              className="custom-select w-auto"
+            >
+              <option value={"RequireApplication"}>
+                {i18n.t("require_registration_application")}
+              </option>
+              <option value={"Open"}>{i18n.t("open_registration")}</option>
+              <option value={"Closed"}>{i18n.t("close_registration")}</option>
+            </select>
+          </div>
+        </div>
+        {this.state.siteForm.registration_mode == "RequireApplication" && (
           <div className="form-group row">
             <label className="col-12 col-form-label">
-              {i18n.t("legal_information")}
+              {i18n.t("application_questionnaire")}
             </label>
             <div className="col-12">
               <MarkdownTextArea
-                initialContent={this.state.siteForm.legal_information}
-                onContentChange={this.handleSiteLegalInfoChange}
+                initialContent={this.state.siteForm.application_question}
+                onContentChange={this.handleSiteApplicationQuestionChange}
                 hideNavigationWarnings
                 allLanguages={[]}
                 siteLanguages={[]}
               />
             </div>
           </div>
-          <div className="form-group row">
-            <div className="col-12">
-              <div className="form-check">
-                <input
-                  className="form-check-input"
-                  id="create-site-downvotes"
-                  type="checkbox"
-                  checked={this.state.siteForm.enable_downvotes}
-                  onChange={linkEvent(
-                    this,
-                    this.handleSiteEnableDownvotesChange
-                  )}
-                />
-                <label
-                  className="form-check-label"
-                  htmlFor="create-site-downvotes"
-                >
-                  {i18n.t("enable_downvotes")}
-                </label>
-              </div>
-            </div>
-          </div>
-          <div className="form-group row">
-            <div className="col-12">
-              <div className="form-check">
-                <input
-                  className="form-check-input"
-                  id="create-site-enable-nsfw"
-                  type="checkbox"
-                  checked={this.state.siteForm.enable_nsfw}
-                  onChange={linkEvent(this, this.handleSiteEnableNsfwChange)}
-                />
-                <label
-                  className="form-check-label"
-                  htmlFor="create-site-enable-nsfw"
-                >
-                  {i18n.t("enable_nsfw")}
-                </label>
-              </div>
-            </div>
-          </div>
-          <div className="form-group row">
-            <div className="col-12">
+        )}
+        <div className="form-group row">
+          <div className="col-12">
+            <div className="form-check">
+              <input
+                className="form-check-input"
+                id="create-site-community-creation-admin-only"
+                type="checkbox"
+                checked={this.state.siteForm.community_creation_admin_only}
+                onChange={linkEvent(
+                  this,
+                  this.handleSiteCommunityCreationAdminOnly
+                )}
+              />
               <label
-                className="form-check-label mr-2"
-                htmlFor="create-site-registration-mode"
+                className="form-check-label"
+                htmlFor="create-site-community-creation-admin-only"
               >
-                {i18n.t("registration_mode")}
+                {i18n.t("community_creation_admin_only")}
               </label>
-              <select
-                id="create-site-registration-mode"
-                value={this.state.siteForm.registration_mode}
+            </div>
+          </div>
+        </div>
+        <div className="form-group row">
+          <div className="col-12">
+            <div className="form-check">
+              <input
+                className="form-check-input"
+                id="create-site-require-email-verification"
+                type="checkbox"
+                checked={this.state.siteForm.require_email_verification}
                 onChange={linkEvent(
                   this,
-                  this.handleSiteRegistrationModeChange
+                  this.handleSiteRequireEmailVerification
                 )}
-                className="custom-select w-auto"
+              />
+              <label
+                className="form-check-label"
+                htmlFor="create-site-require-email-verification"
               >
-                <option value={"RequireApplication"}>
-                  {i18n.t("require_registration_application")}
-                </option>
-                <option value={"Open"}>{i18n.t("open_registration")}</option>
-                <option value={"Closed"}>{i18n.t("close_registration")}</option>
-              </select>
-            </div>
-          </div>
-          {this.state.siteForm.registration_mode == "RequireApplication" && (
-            <div className="form-group row">
-              <label className="col-12 col-form-label">
-                {i18n.t("application_questionnaire")}
+                {i18n.t("require_email_verification")}
               </label>
-              <div className="col-12">
-                <MarkdownTextArea
-                  initialContent={this.state.siteForm.application_question}
-                  onContentChange={this.handleSiteApplicationQuestionChange}
-                  hideNavigationWarnings
-                  allLanguages={[]}
-                  siteLanguages={[]}
-                />
-              </div>
-            </div>
-          )}
-          <div className="form-group row">
-            <div className="col-12">
-              <div className="form-check">
-                <input
-                  className="form-check-input"
-                  id="create-site-community-creation-admin-only"
-                  type="checkbox"
-                  checked={this.state.siteForm.community_creation_admin_only}
-                  onChange={linkEvent(
-                    this,
-                    this.handleSiteCommunityCreationAdminOnly
-                  )}
-                />
-                <label
-                  className="form-check-label"
-                  htmlFor="create-site-community-creation-admin-only"
-                >
-                  {i18n.t("community_creation_admin_only")}
-                </label>
-              </div>
-            </div>
-          </div>
-          <div className="form-group row">
-            <div className="col-12">
-              <div className="form-check">
-                <input
-                  className="form-check-input"
-                  id="create-site-require-email-verification"
-                  type="checkbox"
-                  checked={this.state.siteForm.require_email_verification}
-                  onChange={linkEvent(
-                    this,
-                    this.handleSiteRequireEmailVerification
-                  )}
-                />
-                <label
-                  className="form-check-label"
-                  htmlFor="create-site-require-email-verification"
-                >
-                  {i18n.t("require_email_verification")}
-                </label>
-              </div>
-            </div>
-          </div>
-          <div className="form-group row">
-            <div className="col-12">
-              <div className="form-check">
-                <input
-                  className="form-check-input"
-                  id="create-site-application-email-admins"
-                  type="checkbox"
-                  checked={this.state.siteForm.application_email_admins}
-                  onChange={linkEvent(
-                    this,
-                    this.handleSiteApplicationEmailAdmins
-                  )}
-                />
-                <label
-                  className="form-check-label"
-                  htmlFor="create-site-email-admins"
-                >
-                  {i18n.t("application_email_admins")}
-                </label>
-              </div>
-            </div>
-          </div>
-          <div className="form-group row">
-            <div className="col-12">
-              <div className="form-check">
-                <input
-                  className="form-check-input"
-                  id="create-site-reports-email-admins"
-                  type="checkbox"
-                  checked={this.state.siteForm.reports_email_admins}
-                  onChange={linkEvent(this, this.handleSiteReportsEmailAdmins)}
-                />
-                <label
-                  className="form-check-label"
-                  htmlFor="create-site-reports-email-admins"
-                >
-                  {i18n.t("reports_email_admins")}
-                </label>
-              </div>
             </div>
           </div>
-          <div className="form-group row">
-            <div className="col-12">
+        </div>
+        <div className="form-group row">
+          <div className="col-12">
+            <div className="form-check">
+              <input
+                className="form-check-input"
+                id="create-site-application-email-admins"
+                type="checkbox"
+                checked={this.state.siteForm.application_email_admins}
+                onChange={linkEvent(
+                  this,
+                  this.handleSiteApplicationEmailAdmins
+                )}
+              />
               <label
-                className="form-check-label mr-2"
-                htmlFor="create-site-default-theme"
+                className="form-check-label"
+                htmlFor="create-site-email-admins"
               >
-                {i18n.t("theme")}
+                {i18n.t("application_email_admins")}
               </label>
-              <select
-                id="create-site-default-theme"
-                value={this.state.siteForm.default_theme}
-                onChange={linkEvent(this, this.handleSiteDefaultTheme)}
-                className="custom-select w-auto"
-              >
-                <option value="browser">{i18n.t("browser_default")}</option>
-                {this.state.themeList?.map(theme => (
-                  <option key={theme} value={theme}>
-                    {theme}
-                  </option>
-                ))}
-              </select>
-            </div>
-          </div>
-          {this.props.showLocal && (
-            <form className="form-group row">
-              <label className="col-sm-3">{i18n.t("listing_type")}</label>
-              <div className="col-sm-9">
-                <ListingTypeSelect
-                  type_={
-                    this.state.siteForm.default_post_listing_type ?? "Local"
-                  }
-                  showLocal
-                  showSubscribed={false}
-                  onChange={this.handleDefaultPostListingTypeChange}
-                />
-              </div>
-            </form>
-          )}
-          <div className="form-group row">
-            <div className="col-12">
-              <div className="form-check">
-                <input
-                  className="form-check-input"
-                  id="create-site-private-instance"
-                  type="checkbox"
-                  checked={this.state.siteForm.private_instance}
-                  onChange={linkEvent(this, this.handleSitePrivateInstance)}
-                />
-                <label
-                  className="form-check-label"
-                  htmlFor="create-site-private-instance"
-                >
-                  {i18n.t("private_instance")}
-                </label>
-              </div>
             </div>
           </div>
-          <div className="form-group row">
-            <div className="col-12">
-              <div className="form-check">
-                <input
-                  className="form-check-input"
-                  id="create-site-hide-modlog-mod-names"
-                  type="checkbox"
-                  checked={this.state.siteForm.hide_modlog_mod_names}
-                  onChange={linkEvent(this, this.handleSiteHideModlogModNames)}
-                />
-                <label
-                  className="form-check-label"
-                  htmlFor="create-site-hide-modlog-mod-names"
-                >
-                  {i18n.t("hide_modlog_mod_names")}
-                </label>
-              </div>
+        </div>
+        <div className="form-group row">
+          <div className="col-12">
+            <div className="form-check">
+              <input
+                className="form-check-input"
+                id="create-site-reports-email-admins"
+                type="checkbox"
+                checked={this.state.siteForm.reports_email_admins}
+                onChange={linkEvent(this, this.handleSiteReportsEmailAdmins)}
+              />
+              <label
+                className="form-check-label"
+                htmlFor="create-site-reports-email-admins"
+              >
+                {i18n.t("reports_email_admins")}
+              </label>
             </div>
           </div>
-          <div className="form-group row">
+        </div>
+        <div className="form-group row">
+          <div className="col-12">
             <label
-              className="col-12 col-form-label"
-              htmlFor="create-site-slur-filter-regex"
+              className="form-check-label mr-2"
+              htmlFor="create-site-default-theme"
             >
-              {i18n.t("slur_filter_regex")}
+              {i18n.t("theme")}
             </label>
-            <div className="col-12">
+            <select
+              id="create-site-default-theme"
+              value={this.state.siteForm.default_theme}
+              onChange={linkEvent(this, this.handleSiteDefaultTheme)}
+              className="custom-select w-auto"
+            >
+              <option value="browser">{i18n.t("browser_default")}</option>
+              {this.props.themeList?.map(theme => (
+                <option key={theme} value={theme}>
+                  {theme}
+                </option>
+              ))}
+            </select>
+          </div>
+        </div>
+        {this.props.showLocal && (
+          <form className="form-group row">
+            <label className="col-sm-3">{i18n.t("listing_type")}</label>
+            <div className="col-sm-9">
+              <ListingTypeSelect
+                type_={this.state.siteForm.default_post_listing_type ?? "Local"}
+                showLocal
+                showSubscribed={false}
+                onChange={this.handleDefaultPostListingTypeChange}
+              />
+            </div>
+          </form>
+        )}
+        <div className="form-group row">
+          <div className="col-12">
+            <div className="form-check">
               <input
-                type="text"
-                id="create-site-slur-filter-regex"
-                placeholder="(word1|word2)"
-                className="form-control"
-                value={this.state.siteForm.slur_filter_regex}
-                onInput={linkEvent(this, this.handleSiteSlurFilterRegex)}
-                minLength={3}
+                className="form-check-input"
+                id="create-site-private-instance"
+                type="checkbox"
+                checked={this.state.siteForm.private_instance}
+                onChange={linkEvent(this, this.handleSitePrivateInstance)}
               />
+              <label
+                className="form-check-label"
+                htmlFor="create-site-private-instance"
+              >
+                {i18n.t("private_instance")}
+              </label>
             </div>
           </div>
-          <LanguageSelect
-            allLanguages={this.props.siteRes.all_languages}
-            siteLanguages={this.props.siteRes.discussion_languages}
-            selectedLanguageIds={this.state.siteForm.discussion_languages}
-            multiple={true}
-            onChange={this.handleDiscussionLanguageChange}
-            showAll
-          />
-          <div className="form-group row">
-            <label
-              className="col-12 col-form-label"
-              htmlFor="create-site-actor-name"
-            >
-              {i18n.t("actor_name_max_length")}
-            </label>
-            <div className="col-12">
+        </div>
+        <div className="form-group row">
+          <div className="col-12">
+            <div className="form-check">
               <input
-                type="number"
-                id="create-site-actor-name"
-                className="form-control"
-                min={5}
-                value={this.state.siteForm.actor_name_max_length}
-                onInput={linkEvent(this, this.handleSiteActorNameMaxLength)}
+                className="form-check-input"
+                id="create-site-hide-modlog-mod-names"
+                type="checkbox"
+                checked={this.state.siteForm.hide_modlog_mod_names}
+                onChange={linkEvent(this, this.handleSiteHideModlogModNames)}
               />
+              <label
+                className="form-check-label"
+                htmlFor="create-site-hide-modlog-mod-names"
+              >
+                {i18n.t("hide_modlog_mod_names")}
+              </label>
             </div>
           </div>
-          <div className="form-group row">
-            <div className="col-12">
-              <div className="form-check">
-                <input
-                  className="form-check-input"
-                  id="create-site-federation-enabled"
-                  type="checkbox"
-                  checked={this.state.siteForm.federation_enabled}
-                  onChange={linkEvent(this, this.handleSiteFederationEnabled)}
-                />
-                <label
-                  className="form-check-label"
-                  htmlFor="create-site-federation-enabled"
-                >
-                  {i18n.t("federation_enabled")}
-                </label>
-              </div>
+        </div>
+        <div className="form-group row">
+          <label
+            className="col-12 col-form-label"
+            htmlFor="create-site-slur-filter-regex"
+          >
+            {i18n.t("slur_filter_regex")}
+          </label>
+          <div className="col-12">
+            <input
+              type="text"
+              id="create-site-slur-filter-regex"
+              placeholder="(word1|word2)"
+              className="form-control"
+              value={this.state.siteForm.slur_filter_regex}
+              onInput={linkEvent(this, this.handleSiteSlurFilterRegex)}
+              minLength={3}
+            />
+          </div>
+        </div>
+        <LanguageSelect
+          allLanguages={this.props.siteRes.all_languages}
+          siteLanguages={this.props.siteRes.discussion_languages}
+          selectedLanguageIds={this.state.siteForm.discussion_languages}
+          multiple={true}
+          onChange={this.handleDiscussionLanguageChange}
+          showAll
+        />
+        <div className="form-group row">
+          <label
+            className="col-12 col-form-label"
+            htmlFor="create-site-actor-name"
+          >
+            {i18n.t("actor_name_max_length")}
+          </label>
+          <div className="col-12">
+            <input
+              type="number"
+              id="create-site-actor-name"
+              className="form-control"
+              min={5}
+              value={this.state.siteForm.actor_name_max_length}
+              onInput={linkEvent(this, this.handleSiteActorNameMaxLength)}
+            />
+          </div>
+        </div>
+        <div className="form-group row">
+          <div className="col-12">
+            <div className="form-check">
+              <input
+                className="form-check-input"
+                id="create-site-federation-enabled"
+                type="checkbox"
+                checked={this.state.siteForm.federation_enabled}
+                onChange={linkEvent(this, this.handleSiteFederationEnabled)}
+              />
+              <label
+                className="form-check-label"
+                htmlFor="create-site-federation-enabled"
+              >
+                {i18n.t("federation_enabled")}
+              </label>
             </div>
           </div>
-          {this.state.siteForm.federation_enabled && (
-            <>
-              <div className="form-group row">
-                {this.federatedInstanceSelect("allowed_instances")}
-                {this.federatedInstanceSelect("blocked_instances")}
-              </div>
-              <div className="form-group row">
-                <div className="col-12">
-                  <div className="form-check">
-                    <input
-                      className="form-check-input"
-                      id="create-site-federation-debug"
-                      type="checkbox"
-                      checked={this.state.siteForm.federation_debug}
-                      onChange={linkEvent(this, this.handleSiteFederationDebug)}
-                    />
-                    <label
-                      className="form-check-label"
-                      htmlFor="create-site-federation-debug"
-                    >
-                      {i18n.t("federation_debug")}
-                    </label>
-                  </div>
-                </div>
-              </div>
-              <div className="form-group row">
-                <label
-                  className="col-12 col-form-label"
-                  htmlFor="create-site-federation-worker-count"
-                >
-                  {i18n.t("federation_worker_count")}
-                </label>
-                <div className="col-12">
+        </div>
+        {this.state.siteForm.federation_enabled && (
+          <>
+            <div className="form-group row">
+              {this.federatedInstanceSelect("allowed_instances")}
+              {this.federatedInstanceSelect("blocked_instances")}
+            </div>
+            <div className="form-group row">
+              <div className="col-12">
+                <div className="form-check">
                   <input
-                    type="number"
-                    id="create-site-federation-worker-count"
-                    className="form-control"
-                    min={0}
-                    value={this.state.siteForm.federation_worker_count}
-                    onInput={linkEvent(
-                      this,
-                      this.handleSiteFederationWorkerCount
-                    )}
+                    className="form-check-input"
+                    id="create-site-federation-debug"
+                    type="checkbox"
+                    checked={this.state.siteForm.federation_debug}
+                    onChange={linkEvent(this, this.handleSiteFederationDebug)}
                   />
+                  <label
+                    className="form-check-label"
+                    htmlFor="create-site-federation-debug"
+                  >
+                    {i18n.t("federation_debug")}
+                  </label>
                 </div>
               </div>
-            </>
-          )}
-          <div className="form-group row">
-            <div className="col-12">
-              <div className="form-check">
-                <input
-                  className="form-check-input"
-                  id="create-site-captcha-enabled"
-                  type="checkbox"
-                  checked={this.state.siteForm.captcha_enabled}
-                  onChange={linkEvent(this, this.handleSiteCaptchaEnabled)}
-                />
-                <label
-                  className="form-check-label"
-                  htmlFor="create-site-captcha-enabled"
-                >
-                  {i18n.t("captcha_enabled")}
-                </label>
-              </div>
             </div>
-          </div>
-          {this.state.siteForm.captcha_enabled && (
             <div className="form-group row">
+              <label
+                className="col-12 col-form-label"
+                htmlFor="create-site-federation-worker-count"
+              >
+                {i18n.t("federation_worker_count")}
+              </label>
               <div className="col-12">
-                <label
-                  className="form-check-label mr-2"
-                  htmlFor="create-site-captcha-difficulty"
-                >
-                  {i18n.t("captcha_difficulty")}
-                </label>
-                <select
-                  id="create-site-captcha-difficulty"
-                  value={this.state.siteForm.captcha_difficulty}
-                  onChange={linkEvent(this, this.handleSiteCaptchaDifficulty)}
-                  className="custom-select w-auto"
-                >
-                  <option value="easy">{i18n.t("easy")}</option>
-                  <option value="medium">{i18n.t("medium")}</option>
-                  <option value="hard">{i18n.t("hard")}</option>
-                </select>
+                <input
+                  type="number"
+                  id="create-site-federation-worker-count"
+                  className="form-control"
+                  min={0}
+                  value={this.state.siteForm.federation_worker_count}
+                  onInput={linkEvent(
+                    this,
+                    this.handleSiteFederationWorkerCount
+                  )}
+                />
               </div>
             </div>
-          )}
+          </>
+        )}
+        <div className="form-group row">
+          <div className="col-12">
+            <div className="form-check">
+              <input
+                className="form-check-input"
+                id="create-site-captcha-enabled"
+                type="checkbox"
+                checked={this.state.siteForm.captcha_enabled}
+                onChange={linkEvent(this, this.handleSiteCaptchaEnabled)}
+              />
+              <label
+                className="form-check-label"
+                htmlFor="create-site-captcha-enabled"
+              >
+                {i18n.t("captcha_enabled")}
+              </label>
+            </div>
+          </div>
+        </div>
+        {this.state.siteForm.captcha_enabled && (
           <div className="form-group row">
             <div className="col-12">
-              <button
-                type="submit"
-                className="btn btn-secondary mr-2"
-                disabled={this.state.loading}
+              <label
+                className="form-check-label mr-2"
+                htmlFor="create-site-captcha-difficulty"
               >
-                {this.state.loading ? (
-                  <Spinner />
-                ) : siteSetup ? (
-                  capitalizeFirstLetter(i18n.t("save"))
-                ) : (
-                  capitalizeFirstLetter(i18n.t("create"))
-                )}
-              </button>
+                {i18n.t("captcha_difficulty")}
+              </label>
+              <select
+                id="create-site-captcha-difficulty"
+                value={this.state.siteForm.captcha_difficulty}
+                onChange={linkEvent(this, this.handleSiteCaptchaDifficulty)}
+                className="custom-select w-auto"
+              >
+                <option value="easy">{i18n.t("easy")}</option>
+                <option value="medium">{i18n.t("medium")}</option>
+                <option value="hard">{i18n.t("hard")}</option>
+              </select>
             </div>
           </div>
-        </form>
-      </>
+        )}
+        <div className="form-group row">
+          <div className="col-12">
+            <button
+              type="submit"
+              className="btn btn-secondary mr-2"
+              disabled={this.state.loading}
+            >
+              {this.state.loading ? (
+                <Spinner />
+              ) : siteSetup ? (
+                capitalizeFirstLetter(i18n.t("save"))
+              ) : (
+                capitalizeFirstLetter(i18n.t("create"))
+              )}
+            </button>
+          </div>
+        </div>
+      </form>
     );
   }
 
@@ -688,11 +648,15 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
             type="button"
             className="btn btn-sm bg-success ml-2"
             onClick={linkEvent(key, this.handleAddInstance)}
+            style={"width: 2rem; height: 2rem;"}
             tabIndex={
               -1 /* Making this untabble because handling enter key in text input makes keyboard support for this button redundant */
             }
           >
-            <Icon icon="add" classes="icon-inline text-light m-auto" />
+            <Icon
+              icon="add"
+              classes="icon-inline text-light m-auto d-block position-static"
+            />
           </button>
         </div>
         {selectedInstances && selectedInstances.length > 0 && (
@@ -708,13 +672,17 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
                 <button
                   id={instance}
                   type="button"
+                  style={"width: 2rem; height: 2rem;"}
                   className="btn btn-sm bg-danger"
                   onClick={linkEvent(
                     { key, instance },
                     this.handleRemoveInstance
                   )}
                 >
-                  <Icon icon="x" classes="icon-inline text-light m-auto" />
+                  <Icon
+                    icon="x"
+                    classes="icon-inline text-light m-auto d-block position-static"
+                  />
                 </button>
               </li>
             ))}
@@ -745,48 +713,69 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
     }
   }
 
-  handleCreateSiteSubmit(i: SiteForm, event: any) {
+  handleSaveSiteSubmit(i: SiteForm, event: any) {
     event.preventDefault();
-    i.setState({ loading: true });
-    const auth = myAuth() ?? "TODO";
+    const auth = myAuthRequired();
     i.setState(s => ((s.siteForm.auth = auth), s));
+    i.setState({ loading: true, submitted: true });
+
+    const stateSiteForm = i.state.siteForm;
+
+    let form: EditSite | CreateSite;
+
     if (i.props.siteRes.site_view.local_site.site_setup) {
-      WebSocketService.Instance.send(wsClient.editSite(i.state.siteForm));
+      form = stateSiteForm;
     } else {
-      const sForm = i.state.siteForm;
-      const form: CreateSite = {
-        name: sForm.name ?? "My site",
-        sidebar: sForm.sidebar,
-        description: sForm.description,
-        icon: sForm.icon,
-        banner: sForm.banner,
-        community_creation_admin_only: sForm.community_creation_admin_only,
-        enable_nsfw: sForm.enable_nsfw,
-        enable_downvotes: sForm.enable_downvotes,
-        application_question: sForm.application_question,
-        registration_mode: sForm.registration_mode,
-        require_email_verification: sForm.require_email_verification,
-        private_instance: sForm.private_instance,
-        default_theme: sForm.default_theme,
-        default_post_listing_type: sForm.default_post_listing_type,
-        application_email_admins: sForm.application_email_admins,
-        hide_modlog_mod_names: sForm.hide_modlog_mod_names,
-        legal_information: sForm.legal_information,
-        slur_filter_regex: sForm.slur_filter_regex,
-        actor_name_max_length: sForm.actor_name_max_length,
-        federation_enabled: sForm.federation_enabled,
-        federation_debug: sForm.federation_debug,
-        federation_worker_count: sForm.federation_worker_count,
-        captcha_enabled: sForm.captcha_enabled,
-        captcha_difficulty: sForm.captcha_difficulty,
-        allowed_instances: sForm.allowed_instances,
-        blocked_instances: sForm.blocked_instances,
-        discussion_languages: sForm.discussion_languages,
+      form = {
+        name: stateSiteForm.name ?? "My site",
+        sidebar: stateSiteForm.sidebar,
+        description: stateSiteForm.description,
+        icon: stateSiteForm.icon,
+        banner: stateSiteForm.banner,
+        community_creation_admin_only:
+          stateSiteForm.community_creation_admin_only,
+        enable_nsfw: stateSiteForm.enable_nsfw,
+        enable_downvotes: stateSiteForm.enable_downvotes,
+        application_question: stateSiteForm.application_question,
+        registration_mode: stateSiteForm.registration_mode,
+        require_email_verification: stateSiteForm.require_email_verification,
+        private_instance: stateSiteForm.private_instance,
+        default_theme: stateSiteForm.default_theme,
+        default_post_listing_type: stateSiteForm.default_post_listing_type,
+        application_email_admins: stateSiteForm.application_email_admins,
+        hide_modlog_mod_names: stateSiteForm.hide_modlog_mod_names,
+        legal_information: stateSiteForm.legal_information,
+        slur_filter_regex: stateSiteForm.slur_filter_regex,
+        actor_name_max_length: stateSiteForm.actor_name_max_length,
+        rate_limit_message: stateSiteForm.rate_limit_message,
+        rate_limit_message_per_second:
+          stateSiteForm.rate_limit_message_per_second,
+        rate_limit_comment: stateSiteForm.rate_limit_comment,
+        rate_limit_comment_per_second:
+          stateSiteForm.rate_limit_comment_per_second,
+        rate_limit_image: stateSiteForm.rate_limit_image,
+        rate_limit_image_per_second: stateSiteForm.rate_limit_image_per_second,
+        rate_limit_post: stateSiteForm.rate_limit_post,
+        rate_limit_post_per_second: stateSiteForm.rate_limit_post_per_second,
+        rate_limit_register: stateSiteForm.rate_limit_register,
+        rate_limit_register_per_second:
+          stateSiteForm.rate_limit_register_per_second,
+        rate_limit_search: stateSiteForm.rate_limit_search,
+        rate_limit_search_per_second:
+          stateSiteForm.rate_limit_search_per_second,
+        federation_enabled: stateSiteForm.federation_enabled,
+        federation_debug: stateSiteForm.federation_debug,
+        federation_worker_count: stateSiteForm.federation_worker_count,
+        captcha_enabled: stateSiteForm.captcha_enabled,
+        captcha_difficulty: stateSiteForm.captcha_difficulty,
+        allowed_instances: stateSiteForm.allowed_instances,
+        blocked_instances: stateSiteForm.blocked_instances,
+        discussion_languages: stateSiteForm.discussion_languages,
         auth,
       };
-      WebSocketService.Instance.send(wsClient.createSite(form));
     }
-    i.setState(i.state);
+
+    i.props.onSaveSite(form);
   }
 
   handleAddInstance(key: InstanceKey) {
index 59eeb4a7b8ce3aaf2ab2254df6beb2e41418da62..44ca4fc02f4c1172689da10913e6d39f0a3182ab 100644 (file)
@@ -1,19 +1,18 @@
 import { Component, InfernoMouseEvent, linkEvent } from "inferno";
-import { EditSite, GetSiteResponse } from "lemmy-js-client";
+import { EditSite, Tagline } from "lemmy-js-client";
 import { i18n } from "../../i18next";
-import { WebSocketService } from "../../services";
-import { capitalizeFirstLetter, myAuth, wsClient } from "../../utils";
+import { capitalizeFirstLetter, myAuthRequired } from "../../utils";
 import { HtmlTags } from "../common/html-tags";
 import { Icon, Spinner } from "../common/icon";
 import { MarkdownTextArea } from "../common/markdown-textarea";
 
 interface TaglineFormProps {
-  siteRes: GetSiteResponse;
+  taglines: Array<Tagline>;
+  onSaveSite(form: EditSite): void;
 }
 
 interface TaglineFormState {
-  siteRes: GetSiteResponse;
-  siteForm: EditSite;
+  taglines: Array<string>;
   loading: boolean;
   editingRow?: number;
 }
@@ -21,12 +20,8 @@ interface TaglineFormState {
 export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
   state: TaglineFormState = {
     loading: false,
-    siteRes: this.props.siteRes,
     editingRow: undefined,
-    siteForm: {
-      taglines: this.props.siteRes.taglines?.map(x => x.content),
-      auth: "TODO",
-    },
+    taglines: this.props.taglines.map(x => x.content),
   };
   constructor(props: any, context: any) {
     super(props, context);
@@ -54,7 +49,7 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
               <th style="width:121px"></th>
             </thead>
             <tbody>
-              {this.state.siteForm.taglines?.map((cv, index) => (
+              {this.state.taglines.map((cv, index) => (
                 <tr key={index}>
                   <td>
                     {this.state.editingRow == index && (
@@ -64,8 +59,8 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
                           this.handleTaglineChange(this, index, s)
                         }
                         hideNavigationWarnings
-                        allLanguages={this.state.siteRes.all_languages}
-                        siteLanguages={this.state.siteRes.discussion_languages}
+                        allLanguages={[]}
+                        siteLanguages={[]}
                       />
                     )}
                     {this.state.editingRow != index && <div>{cv}</div>}
@@ -74,7 +69,7 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
                     <button
                       className="btn btn-link btn-animate text-muted"
                       onClick={linkEvent(
-                        { form: this, index: index },
+                        { i: this, index: index },
                         this.handleEditTaglineClick
                       )}
                       data-tippy-content={i18n.t("edit")}
@@ -86,7 +81,7 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
                     <button
                       className="btn btn-link btn-animate text-muted"
                       onClick={linkEvent(
-                        { form: this, index: index },
+                        { i: this, index: index },
                         this.handleDeleteTaglineClick
                       )}
                       data-tippy-content={i18n.t("delete")}
@@ -131,46 +126,38 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
   }
 
   handleTaglineChange(i: TaglineForm, index: number, val: string) {
-    const taglines = i.state.siteForm.taglines;
-    if (taglines) {
-      taglines[index] = val;
-      i.setState(i.state);
+    if (i.state.taglines) {
+      i.setState(prev => ({
+        ...prev,
+        taglines: prev.taglines.map((tl, i) => (i === index ? val : tl)),
+      }));
     }
   }
 
-  handleDeleteTaglineClick(
-    props: { form: TaglineForm; index: number },
-    event: any
-  ) {
+  handleDeleteTaglineClick(d: { i: TaglineForm; index: number }, event: any) {
     event.preventDefault();
-    const taglines = props.form.state.siteForm.taglines;
-    if (taglines) {
-      taglines.splice(props.index, 1);
-      props.form.state.siteForm.taglines = undefined;
-      props.form.setState(props.form.state);
-      props.form.state.siteForm.taglines = taglines;
-      props.form.setState({ ...props.form.state, editingRow: undefined });
-    }
+    d.i.setState(prev => ({
+      ...prev,
+      taglines: prev.taglines.filter((_, i) => i !== d.index),
+      editingRow: undefined,
+    }));
   }
 
-  handleEditTaglineClick(
-    props: { form: TaglineForm; index: number },
-    event: any
-  ) {
+  handleEditTaglineClick(d: { i: TaglineForm; index: number }, event: any) {
     event.preventDefault();
-    if (this.state.editingRow == props.index) {
-      props.form.setState({ editingRow: undefined });
+    if (this.state.editingRow == d.index) {
+      d.i.setState({ editingRow: undefined });
     } else {
-      props.form.setState({ editingRow: props.index });
+      d.i.setState({ editingRow: d.index });
     }
   }
 
-  handleSaveClick(i: TaglineForm) {
+  async handleSaveClick(i: TaglineForm) {
     i.setState({ loading: true });
-    const auth = myAuth() ?? "TODO";
-    i.setState(s => ((s.siteForm.auth = auth), s));
-    WebSocketService.Instance.send(wsClient.editSite(i.state.siteForm));
-    i.setState({ ...i.state, editingRow: undefined });
+    i.props.onSaveSite({
+      taglines: i.state.taglines,
+      auth: myAuthRequired(),
+    });
   }
 
   handleAddTaglineClick(
@@ -178,13 +165,12 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
     event: InfernoMouseEvent<HTMLButtonElement>
   ) {
     event.preventDefault();
-    if (!i.state.siteForm.taglines) {
-      i.state.siteForm.taglines = [];
-    }
-    i.state.siteForm.taglines.push("");
+    const newTaglines = [...i.state.taglines];
+    newTaglines.push("");
+
     i.setState({
-      ...i.state,
-      editingRow: i.state.siteForm.taglines.length - 1,
+      taglines: newTaglines,
+      editingRow: newTaglines.length - 1,
     });
   }
 }
index ef28816b668147dbb56ab8326ccfe579e150a401..d917f5f35ed675842e43169fa6da129b696335b6 100644 (file)
@@ -8,13 +8,11 @@ import {
   AdminPurgeCommunityView,
   AdminPurgePersonView,
   AdminPurgePostView,
-  CommunityModeratorView,
   GetCommunity,
   GetCommunityResponse,
   GetModlog,
   GetModlogResponse,
   GetPersonDetails,
-  GetPersonDetailsResponse,
   ModAddCommunityView,
   ModAddView,
   ModBanFromCommunityView,
@@ -27,15 +25,12 @@ import {
   ModTransferCommunityView,
   ModlogActionType,
   Person,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
 import moment from "moment";
-import { Subscription } from "rxjs";
 import { i18n } from "../i18next";
 import { InitialFetchRequest } from "../interfaces";
-import { WebSocketService } from "../services";
+import { FirstLoadService } from "../services/FirstLoadService";
+import { HttpService, RequestState } from "../services/HttpService";
 import {
   Choice,
   QueryParams,
@@ -49,13 +44,9 @@ import {
   getQueryParams,
   getQueryString,
   getUpdatedSearchId,
-  isBrowser,
   myAuth,
   personToChoice,
   setIsoData,
-  toast,
-  wsClient,
-  wsSubscribe,
 } from "../utils";
 import { HtmlTags } from "./common/html-tags";
 import { Icon, Spinner } from "./common/icon";
@@ -100,10 +91,8 @@ const getModlogQueryParams = () =>
   });
 
 interface ModlogState {
-  res?: GetModlogResponse;
-  communityMods?: CommunityModeratorView[];
-  communityName?: string;
-  loadingModlog: boolean;
+  res: RequestState<GetModlogResponse>;
+  communityRes: RequestState<GetCommunityResponse>;
   loadingModSearch: boolean;
   loadingUserSearch: boolean;
   modSearchOptions: Choice[];
@@ -629,7 +618,7 @@ async function createNewOptions({
 
   if (text.length > 0) {
     newOptions.push(
-      ...(await fetchUsers(text)).users
+      ...(await fetchUsers(text))
         .slice(0, Number(fetchLimit))
         .map<Choice>(personToChoice)
     );
@@ -643,10 +632,10 @@ export class Modlog extends Component<
   ModlogState
 > {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
 
   state: ModlogState = {
-    loadingModlog: true,
+    res: { state: "empty" },
+    communityRes: { state: "empty" },
     loadingModSearch: false,
     loadingUserSearch: false,
     userSearchOptions: [],
@@ -662,58 +651,35 @@ export class Modlog extends Component<
     this.handleUserChange = this.handleUserChange.bind(this);
     this.handleModChange = this.handleModChange.bind(this);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
-
     // Only fetch the data if coming from another route
-    if (this.isoData.path === this.context.router.route.match.url) {
+    if (FirstLoadService.isFirstLoad) {
+      const [res, communityRes, filteredModRes, filteredUserRes] =
+        this.isoData.routeData;
       this.state = {
         ...this.state,
-        res: this.isoData.routeData[0] as GetModlogResponse,
+        res,
+        communityRes,
       };
 
-      const communityRes: GetCommunityResponse | undefined =
-        this.isoData.routeData[1];
-
-      // Getting the moderators
-      this.state = {
-        ...this.state,
-        communityMods: communityRes?.moderators,
-      };
-
-      const filteredModRes: GetPersonDetailsResponse | undefined =
-        this.isoData.routeData[2];
-      if (filteredModRes) {
+      if (filteredModRes.state === "success") {
         this.state = {
           ...this.state,
-          modSearchOptions: [personToChoice(filteredModRes.person_view)],
+          modSearchOptions: [personToChoice(filteredModRes.data.person_view)],
         };
       }
 
-      const filteredUserRes: GetPersonDetailsResponse | undefined =
-        this.isoData.routeData[3];
-      if (filteredUserRes) {
+      if (filteredUserRes.state === "success") {
         this.state = {
           ...this.state,
-          userSearchOptions: [personToChoice(filteredUserRes.person_view)],
+          userSearchOptions: [personToChoice(filteredUserRes.data.person_view)],
         };
       }
-
-      this.state = { ...this.state, loadingModlog: false };
-    } else {
-      this.refetch();
-    }
-  }
-
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
     }
   }
 
   get combined() {
     const res = this.state.res;
-    const combined = res ? buildCombined(res) : [];
+    const combined = res.state == "success" ? buildCombined(res.data) : [];
 
     return (
       <tbody>
@@ -737,7 +703,10 @@ export class Modlog extends Component<
   }
 
   get amAdminOrMod(): boolean {
-    return amAdmin() || amMod(this.state.communityMods);
+    const amMod_ =
+      this.state.communityRes.state == "success" &&
+      amMod(this.state.communityRes.data.moderators);
+    return amAdmin() || amMod_;
   }
 
   modOrAdminText(person?: Person): string {
@@ -755,14 +724,12 @@ export class Modlog extends Component<
 
   render() {
     const {
-      communityName,
-      loadingModlog,
       loadingModSearch,
       loadingUserSearch,
       userSearchOptions,
       modSearchOptions,
     } = this.state;
-    const { actionType, page, modId, userId } = getModlogQueryParams();
+    const { actionType, modId, userId } = getModlogQueryParams();
 
     return (
       <div className="container-lg">
@@ -785,14 +752,17 @@ export class Modlog extends Component<
               #<strong>#</strong>#
             </T>
           </div>
-          <h5>
-            {communityName && (
-              <Link className="text-body" to={`/c/${communityName}`}>
-                /c/{communityName}{" "}
+          {this.state.communityRes.state === "success" && (
+            <h5>
+              <Link
+                className="text-body"
+                to={`/c/${this.state.communityRes.data.community_view.community.name}`}
+              >
+                /c/{this.state.communityRes.data.community_view.community.name}{" "}
               </Link>
-            )}
-            <span>{i18n.t("modlog")}</span>
-          </h5>
+              <span>{i18n.t("modlog")}</span>
+            </h5>
+          )}
           <div className="form-row">
             <select
               value={actionType}
@@ -841,30 +811,41 @@ export class Modlog extends Component<
               />
             )}
           </div>
-          <div className="table-responsive">
-            {loadingModlog ? (
-              <h5>
-                <Spinner large />
-              </h5>
-            ) : (
-              <table id="modlog_table" className="table table-sm table-hover">
-                <thead className="pointer">
-                  <tr>
-                    <th> {i18n.t("time")}</th>
-                    <th>{i18n.t("mod")}</th>
-                    <th>{i18n.t("action")}</th>
-                  </tr>
-                </thead>
-                {this.combined}
-              </table>
-            )}
-            <Paginator page={page} onChange={this.handlePageChange} />
-          </div>
+          {this.renderModlogTable()}
         </div>
       </div>
     );
   }
 
+  renderModlogTable() {
+    switch (this.state.res.state) {
+      case "loading":
+        return (
+          <h5>
+            <Spinner large />
+          </h5>
+        );
+      case "success": {
+        const page = getModlogQueryParams().page;
+        return (
+          <div className="table-responsive">
+            <table id="modlog_table" className="table table-sm table-hover">
+              <thead className="pointer">
+                <tr>
+                  <th> {i18n.t("time")}</th>
+                  <th>{i18n.t("mod")}</th>
+                  <th>{i18n.t("action")}</th>
+                </tr>
+              </thead>
+              {this.combined}
+            </table>
+            <Paginator page={page} onChange={this.handlePageChange} />
+          </div>
+        );
+      }
+    }
+  }
+
   handleFilterActionChange(i: Modlog, event: any) {
     i.updateUrl({
       actionType: event.target.value as ModlogActionType,
@@ -918,7 +899,7 @@ export class Modlog extends Component<
     });
   });
 
-  updateUrl({ actionType, modId, page, userId }: Partial<ModlogProps>) {
+  async updateUrl({ actionType, modId, page, userId }: Partial<ModlogProps>) {
     const {
       page: urlPage,
       actionType: urlActionType,
@@ -941,42 +922,39 @@ export class Modlog extends Component<
       )}`
     );
 
-    this.setState({
-      loadingModlog: true,
-      res: undefined,
-    });
-
-    this.refetch();
+    await this.refetch();
   }
 
-  refetch() {
-    const auth = myAuth(false);
+  async refetch() {
+    const auth = myAuth();
     const { actionType, page, modId, userId } = getModlogQueryParams();
     const { communityId: urlCommunityId } = this.props.match.params;
     const communityId = getIdFromString(urlCommunityId);
 
-    const modlogForm: GetModlog = {
-      community_id: communityId,
-      page,
-      limit: fetchLimit,
-      type_: actionType,
-      other_person_id: userId ?? undefined,
-      mod_person_id: !this.isoData.site_res.site_view.local_site
-        .hide_modlog_mod_names
-        ? modId ?? undefined
-        : undefined,
-      auth,
-    };
-
-    WebSocketService.Instance.send(wsClient.getModlog(modlogForm));
-
-    if (communityId) {
-      const communityForm: GetCommunity = {
-        id: communityId,
+    this.setState({ res: { state: "loading" } });
+    this.setState({
+      res: await HttpService.client.getModlog({
+        community_id: communityId,
+        page,
+        limit: fetchLimit,
+        type_: actionType,
+        other_person_id: userId ?? undefined,
+        mod_person_id: !this.isoData.site_res.site_view.local_site
+          .hide_modlog_mod_names
+          ? modId ?? undefined
+          : undefined,
         auth,
-      };
+      }),
+    });
 
-      WebSocketService.Instance.send(wsClient.getCommunity(communityForm));
+    if (communityId) {
+      this.setState({ communityRes: { state: "loading" } });
+      this.setState({
+        communityRes: await HttpService.client.getCommunity({
+          id: communityId,
+          auth,
+        }),
+      });
     }
   }
 
@@ -986,9 +964,11 @@ export class Modlog extends Component<
     query: { modId: urlModId, page, userId: urlUserId, actionType },
     auth,
     site,
-  }: InitialFetchRequest<QueryParams<ModlogProps>>): Promise<any>[] {
+  }: InitialFetchRequest<QueryParams<ModlogProps>>): Promise<
+    RequestState<any>
+  >[] {
     const pathSplit = path.split("/");
-    const promises: Promise<any>[] = [];
+    const promises: Promise<RequestState<any>>[] = [];
     const communityId = getIdFromString(pathSplit[2]);
     const modId = !site.site_view.local_site.hide_modlog_mod_names
       ? getIdFromString(urlModId)
@@ -1014,7 +994,7 @@ export class Modlog extends Component<
       };
       promises.push(client.getCommunity(communityForm));
     } else {
-      promises.push(Promise.resolve());
+      promises.push(Promise.resolve({ state: "empty" }));
     }
 
     if (modId) {
@@ -1025,7 +1005,7 @@ export class Modlog extends Component<
 
       promises.push(client.getPersonDetails(getPersonForm));
     } else {
-      promises.push(Promise.resolve());
+      promises.push(Promise.resolve({ state: "empty" }));
     }
 
     if (userId) {
@@ -1036,43 +1016,9 @@ export class Modlog extends Component<
 
       promises.push(client.getPersonDetails(getPersonForm));
     } else {
-      promises.push(Promise.resolve());
+      promises.push(Promise.resolve({ state: "empty" }));
     }
 
     return promises;
   }
-
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-    } else {
-      switch (op) {
-        case UserOperation.GetModlog: {
-          const res = wsJsonToRes<GetModlogResponse>(msg);
-          window.scrollTo(0, 0);
-          this.setState({ res, loadingModlog: false });
-
-          break;
-        }
-
-        case UserOperation.GetCommunity: {
-          const {
-            moderators,
-            community_view: {
-              community: { name },
-            },
-          } = wsJsonToRes<GetCommunityResponse>(msg);
-          this.setState({
-            communityMods: moderators,
-            communityName: name,
-          });
-
-          break;
-        }
-      }
-    }
-  }
 }
index b718e9acb1cf53719f3dc70c0c1b3b47f557f268..731667c0ddef6108f8fe1a307a4b56fdc66d5338 100644 (file)
@@ -1,49 +1,72 @@
 import { Component, linkEvent } from "inferno";
 import {
-  BlockPersonResponse,
+  AddAdmin,
+  AddModToCommunity,
+  BanFromCommunity,
+  BanFromCommunityResponse,
+  BanPerson,
+  BanPersonResponse,
+  BlockPerson,
+  CommentId,
   CommentReplyResponse,
   CommentReplyView,
   CommentReportResponse,
   CommentResponse,
   CommentSortType,
   CommentView,
+  CreateComment,
+  CreateCommentLike,
+  CreateCommentReport,
+  CreatePrivateMessage,
+  CreatePrivateMessageReport,
+  DeleteComment,
+  DeletePrivateMessage,
+  DistinguishComment,
+  EditComment,
+  EditPrivateMessage,
   GetPersonMentions,
   GetPersonMentionsResponse,
   GetPrivateMessages,
   GetReplies,
   GetRepliesResponse,
   GetSiteResponse,
+  MarkCommentReplyAsRead,
+  MarkPersonMentionAsRead,
+  MarkPrivateMessageAsRead,
   PersonMentionResponse,
   PersonMentionView,
-  PostReportResponse,
   PrivateMessageReportResponse,
   PrivateMessageResponse,
   PrivateMessageView,
   PrivateMessagesResponse,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
+  PurgeComment,
+  PurgeItemResponse,
+  PurgePerson,
+  PurgePost,
+  RemoveComment,
+  SaveComment,
+  TransferCommunity,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
 import { CommentViewType, InitialFetchRequest } from "../../interfaces";
-import { UserService, WebSocketService } from "../../services";
+import { UserService } from "../../services";
+import { FirstLoadService } from "../../services/FirstLoadService";
+import { HttpService, RequestState } from "../../services/HttpService";
 import {
   commentsToFlatNodes,
-  createCommentLikeRes,
-  editCommentRes,
+  editCommentReply,
+  editMention,
+  editPrivateMessage,
+  editWith,
   enableDownvotes,
   fetchLimit,
-  isBrowser,
+  getCommentParentId,
   myAuth,
+  myAuthRequired,
   relTags,
-  saveCommentRes,
   setIsoData,
-  setupTippy,
   toast,
   updatePersonBlock,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { CommentNodes } from "../comment/comment-nodes";
 import { CommentSortSelect } from "../common/comment-sort-select";
@@ -79,30 +102,31 @@ type ReplyType = {
 interface InboxState {
   unreadOrAll: UnreadOrAll;
   messageType: MessageType;
-  replies: CommentReplyView[];
-  mentions: PersonMentionView[];
-  messages: PrivateMessageView[];
-  combined: ReplyType[];
+  repliesRes: RequestState<GetRepliesResponse>;
+  mentionsRes: RequestState<GetPersonMentionsResponse>;
+  messagesRes: RequestState<PrivateMessagesResponse>;
+  markAllAsReadRes: RequestState<GetRepliesResponse>;
   sort: CommentSortType;
   page: number;
   siteRes: GetSiteResponse;
-  loading: boolean;
+  finished: Map<CommentId, boolean | undefined>;
+  isIsomorphic: boolean;
 }
 
 export class Inbox extends Component<any, InboxState> {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
   state: InboxState = {
     unreadOrAll: UnreadOrAll.Unread,
     messageType: MessageType.All,
-    replies: [],
-    mentions: [],
-    messages: [],
-    combined: [],
     sort: "New",
     page: 1,
     siteRes: this.isoData.site_res,
-    loading: true,
+    repliesRes: { state: "empty" },
+    mentionsRes: { state: "empty" },
+    messagesRes: { state: "empty" },
+    markAllAsReadRes: { state: "empty" },
+    finished: new Map(),
+    isIsomorphic: false,
   };
 
   constructor(props: any, context: any) {
@@ -111,32 +135,48 @@ export class Inbox extends Component<any, InboxState> {
     this.handleSortChange = this.handleSortChange.bind(this);
     this.handlePageChange = this.handlePageChange.bind(this);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
+    this.handleCreateComment = this.handleCreateComment.bind(this);
+    this.handleEditComment = this.handleEditComment.bind(this);
+    this.handleSaveComment = this.handleSaveComment.bind(this);
+    this.handleBlockPerson = this.handleBlockPerson.bind(this);
+    this.handleDeleteComment = this.handleDeleteComment.bind(this);
+    this.handleRemoveComment = this.handleRemoveComment.bind(this);
+    this.handleCommentVote = this.handleCommentVote.bind(this);
+    this.handleAddModToCommunity = this.handleAddModToCommunity.bind(this);
+    this.handleAddAdmin = this.handleAddAdmin.bind(this);
+    this.handlePurgePerson = this.handlePurgePerson.bind(this);
+    this.handlePurgeComment = this.handlePurgeComment.bind(this);
+    this.handleCommentReport = this.handleCommentReport.bind(this);
+    this.handleDistinguishComment = this.handleDistinguishComment.bind(this);
+    this.handleTransferCommunity = this.handleTransferCommunity.bind(this);
+    this.handleCommentReplyRead = this.handleCommentReplyRead.bind(this);
+    this.handlePersonMentionRead = this.handlePersonMentionRead.bind(this);
+    this.handleBanFromCommunity = this.handleBanFromCommunity.bind(this);
+    this.handleBanPerson = this.handleBanPerson.bind(this);
+
+    this.handleDeleteMessage = this.handleDeleteMessage.bind(this);
+    this.handleMarkMessageAsRead = this.handleMarkMessageAsRead.bind(this);
+    this.handleMessageReport = this.handleMessageReport.bind(this);
+    this.handleCreateMessage = this.handleCreateMessage.bind(this);
+    this.handleEditMessage = this.handleEditMessage.bind(this);
 
     // Only fetch the data if coming from another route
-    if (this.isoData.path == this.context.router.route.match.url) {
+    if (FirstLoadService.isFirstLoad) {
+      const [repliesRes, mentionsRes, messagesRes] = this.isoData.routeData;
+
       this.state = {
         ...this.state,
-        replies:
-          (this.isoData.routeData[0] as GetRepliesResponse).replies || [],
-        mentions:
-          (this.isoData.routeData[1] as GetPersonMentionsResponse).mentions ||
-          [],
-        messages:
-          (this.isoData.routeData[2] as PrivateMessagesResponse)
-            .private_messages || [],
-        loading: false,
+        repliesRes,
+        mentionsRes,
+        messagesRes,
+        isIsomorphic: true,
       };
-      this.state = { ...this.state, combined: this.buildCombined() };
-    } else {
-      this.refetch();
     }
   }
 
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
+  async componentDidMount() {
+    if (!this.state.isIsomorphic) {
+      await this.refetch();
     }
   }
 
@@ -149,67 +189,94 @@ export class Inbox extends Component<any, InboxState> {
       : "";
   }
 
+  get hasUnreads(): boolean {
+    if (this.state.unreadOrAll == UnreadOrAll.Unread) {
+      const { repliesRes, mentionsRes, messagesRes } = this.state;
+      const replyCount =
+        repliesRes.state == "success" ? repliesRes.data.replies.length : 0;
+      const mentionCount =
+        mentionsRes.state == "success" ? mentionsRes.data.mentions.length : 0;
+      const messageCount =
+        messagesRes.state == "success"
+          ? messagesRes.data.private_messages.length
+          : 0;
+
+      return replyCount + mentionCount + messageCount > 0;
+    } else {
+      return false;
+    }
+  }
+
   render() {
     const auth = myAuth();
     const inboxRss = auth ? `/feeds/inbox/${auth}.xml` : undefined;
     return (
       <div className="container-lg">
-        {this.state.loading ? (
-          <h5>
-            <Spinner large />
-          </h5>
-        ) : (
-          <div className="row">
-            <div className="col-12">
-              <HtmlTags
-                title={this.documentTitle}
-                path={this.context.router.route.match.url}
-              />
-              <h5 className="mb-2">
-                {i18n.t("inbox")}
-                {inboxRss && (
-                  <small>
-                    <a href={inboxRss} title="RSS" rel={relTags}>
-                      <Icon icon="rss" classes="ml-2 text-muted small" />
-                    </a>
-                    <link
-                      rel="alternate"
-                      type="application/atom+xml"
-                      href={inboxRss}
-                    />
-                  </small>
-                )}
-              </h5>
-              {this.state.replies.length +
-                this.state.mentions.length +
-                this.state.messages.length >
-                0 &&
-                this.state.unreadOrAll == UnreadOrAll.Unread && (
-                  <button
-                    className="btn btn-secondary mb-2"
-                    onClick={linkEvent(this, this.markAllAsRead)}
-                  >
-                    {i18n.t("mark_all_as_read")}
-                  </button>
+        <div className="row">
+          <div className="col-12">
+            <HtmlTags
+              title={this.documentTitle}
+              path={this.context.router.route.match.url}
+            />
+            <h5 className="mb-2">
+              {i18n.t("inbox")}
+              {inboxRss && (
+                <small>
+                  <a href={inboxRss} title="RSS" rel={relTags}>
+                    <Icon icon="rss" classes="ml-2 text-muted small" />
+                  </a>
+                  <link
+                    rel="alternate"
+                    type="application/atom+xml"
+                    href={inboxRss}
+                  />
+                </small>
+              )}
+            </h5>
+            {this.hasUnreads && (
+              <button
+                className="btn btn-secondary mb-2"
+                onClick={linkEvent(this, this.handleMarkAllAsRead)}
+              >
+                {this.state.markAllAsReadRes.state == "loading" ? (
+                  <Spinner />
+                ) : (
+                  i18n.t("mark_all_as_read")
                 )}
-              {this.selects()}
-              {this.state.messageType == MessageType.All && this.all()}
-              {this.state.messageType == MessageType.Replies && this.replies()}
-              {this.state.messageType == MessageType.Mentions &&
-                this.mentions()}
-              {this.state.messageType == MessageType.Messages &&
-                this.messages()}
-              <Paginator
-                page={this.state.page}
-                onChange={this.handlePageChange}
-              />
-            </div>
+              </button>
+            )}
+            {this.selects()}
+            {this.section}
+            <Paginator
+              page={this.state.page}
+              onChange={this.handlePageChange}
+            />
           </div>
-        )}
+        </div>
       </div>
     );
   }
 
+  get section() {
+    switch (this.state.messageType) {
+      case MessageType.All: {
+        return this.all();
+      }
+      case MessageType.Replies: {
+        return this.replies();
+      }
+      case MessageType.Mentions: {
+        return this.mentions();
+      }
+      case MessageType.Messages: {
+        return this.messages();
+      }
+      default: {
+        return null;
+      }
+    }
+  }
+
   unreadOrAllRadios() {
     return (
       <div className="btn-group btn-group-toggle flex-wrap mb-2">
@@ -343,15 +410,20 @@ export class Inbox extends Component<any, InboxState> {
   }
 
   buildCombined(): ReplyType[] {
-    const replies: ReplyType[] = this.state.replies.map(r =>
-      this.replyToReplyType(r)
-    );
-    const mentions: ReplyType[] = this.state.mentions.map(r =>
-      this.mentionToReplyType(r)
-    );
-    const messages: ReplyType[] = this.state.messages.map(r =>
-      this.messageToReplyType(r)
-    );
+    const replies: ReplyType[] =
+      this.state.repliesRes.state == "success"
+        ? this.state.repliesRes.data.replies.map(this.replyToReplyType)
+        : [];
+    const mentions: ReplyType[] =
+      this.state.mentionsRes.state == "success"
+        ? this.state.mentionsRes.data.mentions.map(this.mentionToReplyType)
+        : [];
+    const messages: ReplyType[] =
+      this.state.messagesRes.state == "success"
+        ? this.state.messagesRes.data.private_messages.map(
+            this.messageToReplyType
+          )
+        : [];
 
     return [...replies, ...mentions, ...messages].sort((a, b) =>
       b.published.localeCompare(a.published)
@@ -368,6 +440,7 @@ export class Inbox extends Component<any, InboxState> {
               { comment_view: i.view as CommentView, children: [], depth: 0 },
             ]}
             viewType={CommentViewType.Flat}
+            finished={this.state.finished}
             noIndent
             markable
             showCommunity
@@ -375,6 +448,24 @@ export class Inbox extends Component<any, InboxState> {
             enableDownvotes={enableDownvotes(this.state.siteRes)}
             allLanguages={this.state.siteRes.all_languages}
             siteLanguages={this.state.siteRes.discussion_languages}
+            onSaveComment={this.handleSaveComment}
+            onBlockPerson={this.handleBlockPerson}
+            onDeleteComment={this.handleDeleteComment}
+            onRemoveComment={this.handleRemoveComment}
+            onCommentVote={this.handleCommentVote}
+            onCommentReport={this.handleCommentReport}
+            onDistinguishComment={this.handleDistinguishComment}
+            onAddModToCommunity={this.handleAddModToCommunity}
+            onAddAdmin={this.handleAddAdmin}
+            onTransferCommunity={this.handleTransferCommunity}
+            onPurgeComment={this.handlePurgeComment}
+            onPurgePerson={this.handlePurgePerson}
+            onCommentReplyRead={this.handleCommentReplyRead}
+            onPersonMentionRead={this.handlePersonMentionRead}
+            onBanPersonFromCommunity={this.handleBanFromCommunity}
+            onBanPerson={this.handleBanPerson}
+            onCreateComment={this.handleCreateComment}
+            onEditComment={this.handleEditComment}
           />
         );
       case ReplyEnum.Mention:
@@ -388,6 +479,7 @@ export class Inbox extends Component<any, InboxState> {
                 depth: 0,
               },
             ]}
+            finished={this.state.finished}
             viewType={CommentViewType.Flat}
             noIndent
             markable
@@ -396,6 +488,24 @@ export class Inbox extends Component<any, InboxState> {
             enableDownvotes={enableDownvotes(this.state.siteRes)}
             allLanguages={this.state.siteRes.all_languages}
             siteLanguages={this.state.siteRes.discussion_languages}
+            onSaveComment={this.handleSaveComment}
+            onBlockPerson={this.handleBlockPerson}
+            onDeleteComment={this.handleDeleteComment}
+            onRemoveComment={this.handleRemoveComment}
+            onCommentVote={this.handleCommentVote}
+            onCommentReport={this.handleCommentReport}
+            onDistinguishComment={this.handleDistinguishComment}
+            onAddModToCommunity={this.handleAddModToCommunity}
+            onAddAdmin={this.handleAddAdmin}
+            onTransferCommunity={this.handleTransferCommunity}
+            onPurgeComment={this.handlePurgeComment}
+            onPurgePerson={this.handlePurgePerson}
+            onCommentReplyRead={this.handleCommentReplyRead}
+            onPersonMentionRead={this.handlePersonMentionRead}
+            onBanPersonFromCommunity={this.handleBanFromCommunity}
+            onBanPerson={this.handleBanPerson}
+            onCreateComment={this.handleCreateComment}
+            onEditComment={this.handleEditComment}
           />
         );
       case ReplyEnum.Message:
@@ -403,6 +513,11 @@ export class Inbox extends Component<any, InboxState> {
           <PrivateMessage
             key={i.id}
             private_message_view={i.view as PrivateMessageView}
+            onDelete={this.handleDeleteMessage}
+            onMarkRead={this.handleMarkMessageAsRead}
+            onReport={this.handleMessageReport}
+            onCreate={this.handleCreateMessage}
+            onEdit={this.handleEditMessage}
           />
         );
       default:
@@ -411,92 +526,184 @@ export class Inbox extends Component<any, InboxState> {
   }
 
   all() {
-    return <div>{this.state.combined.map(i => this.renderReplyType(i))}</div>;
+    if (
+      this.state.repliesRes.state == "loading" ||
+      this.state.mentionsRes.state == "loading" ||
+      this.state.messagesRes.state == "loading"
+    ) {
+      return (
+        <h5>
+          <Spinner large />
+        </h5>
+      );
+    } else {
+      return (
+        <div>{this.buildCombined().map(r => this.renderReplyType(r))}</div>
+      );
+    }
   }
 
   replies() {
-    return (
-      <div>
-        <CommentNodes
-          nodes={commentsToFlatNodes(this.state.replies)}
-          viewType={CommentViewType.Flat}
-          noIndent
-          markable
-          showCommunity
-          showContext
-          enableDownvotes={enableDownvotes(this.state.siteRes)}
-          allLanguages={this.state.siteRes.all_languages}
-          siteLanguages={this.state.siteRes.discussion_languages}
-        />
-      </div>
-    );
+    switch (this.state.repliesRes.state) {
+      case "loading":
+        return (
+          <h5>
+            <Spinner large />
+          </h5>
+        );
+      case "success": {
+        const replies = this.state.repliesRes.data.replies;
+        return (
+          <div>
+            <CommentNodes
+              nodes={commentsToFlatNodes(replies)}
+              viewType={CommentViewType.Flat}
+              finished={this.state.finished}
+              noIndent
+              markable
+              showCommunity
+              showContext
+              enableDownvotes={enableDownvotes(this.state.siteRes)}
+              allLanguages={this.state.siteRes.all_languages}
+              siteLanguages={this.state.siteRes.discussion_languages}
+              onSaveComment={this.handleSaveComment}
+              onBlockPerson={this.handleBlockPerson}
+              onDeleteComment={this.handleDeleteComment}
+              onRemoveComment={this.handleRemoveComment}
+              onCommentVote={this.handleCommentVote}
+              onCommentReport={this.handleCommentReport}
+              onDistinguishComment={this.handleDistinguishComment}
+              onAddModToCommunity={this.handleAddModToCommunity}
+              onAddAdmin={this.handleAddAdmin}
+              onTransferCommunity={this.handleTransferCommunity}
+              onPurgeComment={this.handlePurgeComment}
+              onPurgePerson={this.handlePurgePerson}
+              onCommentReplyRead={this.handleCommentReplyRead}
+              onPersonMentionRead={this.handlePersonMentionRead}
+              onBanPersonFromCommunity={this.handleBanFromCommunity}
+              onBanPerson={this.handleBanPerson}
+              onCreateComment={this.handleCreateComment}
+              onEditComment={this.handleEditComment}
+            />
+          </div>
+        );
+      }
+    }
   }
 
   mentions() {
-    return (
-      <div>
-        {this.state.mentions.map(umv => (
-          <CommentNodes
-            key={umv.person_mention.id}
-            nodes={[{ comment_view: umv, children: [], depth: 0 }]}
-            viewType={CommentViewType.Flat}
-            noIndent
-            markable
-            showCommunity
-            showContext
-            enableDownvotes={enableDownvotes(this.state.siteRes)}
-            allLanguages={this.state.siteRes.all_languages}
-            siteLanguages={this.state.siteRes.discussion_languages}
-          />
-        ))}
-      </div>
-    );
+    switch (this.state.mentionsRes.state) {
+      case "loading":
+        return (
+          <h5>
+            <Spinner large />
+          </h5>
+        );
+      case "success": {
+        const mentions = this.state.mentionsRes.data.mentions;
+        return (
+          <div>
+            {mentions.map(umv => (
+              <CommentNodes
+                key={umv.person_mention.id}
+                nodes={[{ comment_view: umv, children: [], depth: 0 }]}
+                viewType={CommentViewType.Flat}
+                finished={this.state.finished}
+                noIndent
+                markable
+                showCommunity
+                showContext
+                enableDownvotes={enableDownvotes(this.state.siteRes)}
+                allLanguages={this.state.siteRes.all_languages}
+                siteLanguages={this.state.siteRes.discussion_languages}
+                onSaveComment={this.handleSaveComment}
+                onBlockPerson={this.handleBlockPerson}
+                onDeleteComment={this.handleDeleteComment}
+                onRemoveComment={this.handleRemoveComment}
+                onCommentVote={this.handleCommentVote}
+                onCommentReport={this.handleCommentReport}
+                onDistinguishComment={this.handleDistinguishComment}
+                onAddModToCommunity={this.handleAddModToCommunity}
+                onAddAdmin={this.handleAddAdmin}
+                onTransferCommunity={this.handleTransferCommunity}
+                onPurgeComment={this.handlePurgeComment}
+                onPurgePerson={this.handlePurgePerson}
+                onCommentReplyRead={this.handleCommentReplyRead}
+                onPersonMentionRead={this.handlePersonMentionRead}
+                onBanPersonFromCommunity={this.handleBanFromCommunity}
+                onBanPerson={this.handleBanPerson}
+                onCreateComment={this.handleCreateComment}
+                onEditComment={this.handleEditComment}
+              />
+            ))}
+          </div>
+        );
+      }
+    }
   }
 
   messages() {
-    return (
-      <div>
-        {this.state.messages.map(pmv => (
-          <PrivateMessage
-            key={pmv.private_message.id}
-            private_message_view={pmv}
-          />
-        ))}
-      </div>
-    );
+    switch (this.state.messagesRes.state) {
+      case "loading":
+        return (
+          <h5>
+            <Spinner large />
+          </h5>
+        );
+      case "success": {
+        const messages = this.state.messagesRes.data.private_messages;
+        return (
+          <div>
+            {messages.map(pmv => (
+              <PrivateMessage
+                key={pmv.private_message.id}
+                private_message_view={pmv}
+                onDelete={this.handleDeleteMessage}
+                onMarkRead={this.handleMarkMessageAsRead}
+                onReport={this.handleMessageReport}
+                onCreate={this.handleCreateMessage}
+                onEdit={this.handleEditMessage}
+              />
+            ))}
+          </div>
+        );
+      }
+    }
   }
 
-  handlePageChange(page: number) {
+  async handlePageChange(page: number) {
     this.setState({ page });
-    this.refetch();
+    await this.refetch();
   }
 
-  handleUnreadOrAllChange(i: Inbox, event: any) {
+  async handleUnreadOrAllChange(i: Inbox, event: any) {
     i.setState({ unreadOrAll: Number(event.target.value), page: 1 });
-    i.refetch();
+    await i.refetch();
   }
 
-  handleMessageTypeChange(i: Inbox, event: any) {
+  async handleMessageTypeChange(i: Inbox, event: any) {
     i.setState({ messageType: Number(event.target.value), page: 1 });
-    i.refetch();
+    await i.refetch();
   }
 
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
-    const promises: Promise<any>[] = [];
+  static fetchInitialData({
+    client,
+    auth,
+  }: InitialFetchRequest): Promise<any>[] {
+    const promises: Promise<RequestState<any>>[] = [];
 
     const sort: CommentSortType = "New";
-    const auth = req.auth;
 
     if (auth) {
       // It can be /u/me, or /username/1
       const repliesForm: GetReplies = {
-        sort: "New",
+        sort,
         unread_only: true,
         page: 1,
         limit: fetchLimit,
         auth,
       };
-      promises.push(req.client.getReplies(repliesForm));
+      promises.push(client.getReplies(repliesForm));
 
       const personMentionsForm: GetPersonMentions = {
         sort,
@@ -505,7 +712,7 @@ export class Inbox extends Component<any, InboxState> {
         limit: fetchLimit,
         auth,
       };
-      promises.push(req.client.getPersonMentions(personMentionsForm));
+      promises.push(client.getPersonMentions(personMentionsForm));
 
       const privateMessagesForm: GetPrivateMessages = {
         unread_only: true,
@@ -513,353 +720,350 @@ export class Inbox extends Component<any, InboxState> {
         limit: fetchLimit,
         auth,
       };
-      promises.push(req.client.getPrivateMessages(privateMessagesForm));
+      promises.push(client.getPrivateMessages(privateMessagesForm));
+    } else {
+      promises.push(
+        Promise.resolve({ state: "empty" }),
+        Promise.resolve({ state: "empty" }),
+        Promise.resolve({ state: "empty" })
+      );
     }
 
     return promises;
   }
 
-  refetch() {
+  async refetch() {
     const sort = this.state.sort;
     const unread_only = this.state.unreadOrAll == UnreadOrAll.Unread;
     const page = this.state.page;
     const limit = fetchLimit;
-    const auth = myAuth();
+    const auth = myAuthRequired();
 
-    if (auth) {
-      const repliesForm: GetReplies = {
+    this.setState({ repliesRes: { state: "loading" } });
+    this.setState({
+      repliesRes: await HttpService.client.getReplies({
         sort,
         unread_only,
         page,
         limit,
         auth,
-      };
-      WebSocketService.Instance.send(wsClient.getReplies(repliesForm));
+      }),
+    });
 
-      const personMentionsForm: GetPersonMentions = {
+    this.setState({ mentionsRes: { state: "loading" } });
+    this.setState({
+      mentionsRes: await HttpService.client.getPersonMentions({
         sort,
         unread_only,
         page,
         limit,
         auth,
-      };
-      WebSocketService.Instance.send(
-        wsClient.getPersonMentions(personMentionsForm)
-      );
+      }),
+    });
 
-      const privateMessagesForm: GetPrivateMessages = {
+    this.setState({ messagesRes: { state: "loading" } });
+    this.setState({
+      messagesRes: await HttpService.client.getPrivateMessages({
         unread_only,
         page,
         limit,
         auth,
-      };
-      WebSocketService.Instance.send(
-        wsClient.getPrivateMessages(privateMessagesForm)
-      );
-    }
+      }),
+    });
   }
 
-  handleSortChange(val: CommentSortType) {
+  async handleSortChange(val: CommentSortType) {
     this.setState({ sort: val, page: 1 });
-    this.refetch();
+    await this.refetch();
   }
 
-  markAllAsRead(i: Inbox) {
-    const auth = myAuth();
-    if (auth) {
-      WebSocketService.Instance.send(
-        wsClient.markAllAsRead({
-          auth,
-        })
-      );
-      i.setState({ replies: [], mentions: [], messages: [] });
-      i.setState({ combined: i.buildCombined() });
-      UserService.Instance.unreadInboxCountSub.next(0);
-      window.scrollTo(0, 0);
-      i.setState(i.state);
+  async handleMarkAllAsRead(i: Inbox) {
+    i.setState({ markAllAsReadRes: { state: "loading" } });
+
+    i.setState({
+      markAllAsReadRes: await HttpService.client.markAllAsRead({
+        auth: myAuthRequired(),
+      }),
+    });
+
+    if (i.state.markAllAsReadRes.state == "success") {
+      i.setState({
+        repliesRes: { state: "empty" },
+        mentionsRes: { state: "empty" },
+        messagesRes: { state: "empty" },
+      });
     }
   }
 
-  sendUnreadCount(read: boolean) {
-    const urcs = UserService.Instance.unreadInboxCountSub;
-    if (read) {
-      urcs.next(urcs.getValue() - 1);
-    } else {
-      urcs.next(urcs.getValue() + 1);
+  async handleAddModToCommunity(form: AddModToCommunity) {
+    // TODO not sure what to do here
+    HttpService.client.addModToCommunity(form);
+  }
+
+  async handlePurgePerson(form: PurgePerson) {
+    const purgePersonRes = await HttpService.client.purgePerson(form);
+    this.purgeItem(purgePersonRes);
+  }
+
+  async handlePurgeComment(form: PurgeComment) {
+    const purgeCommentRes = await HttpService.client.purgeComment(form);
+    this.purgeItem(purgeCommentRes);
+  }
+
+  async handlePurgePost(form: PurgePost) {
+    const purgeRes = await HttpService.client.purgePost(form);
+    this.purgeItem(purgeRes);
+  }
+
+  async handleBlockPerson(form: BlockPerson) {
+    const blockPersonRes = await HttpService.client.blockPerson(form);
+    if (blockPersonRes.state == "success") {
+      updatePersonBlock(blockPersonRes.data);
     }
   }
 
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      return;
-    } else if (msg.reconnect) {
-      this.refetch();
-    } else if (op == UserOperation.GetReplies) {
-      const data = wsJsonToRes<GetRepliesResponse>(msg);
-      this.setState({ replies: data.replies });
-      this.setState({ combined: this.buildCombined(), loading: false });
-      window.scrollTo(0, 0);
-      setupTippy();
-    } else if (op == UserOperation.GetPersonMentions) {
-      const data = wsJsonToRes<GetPersonMentionsResponse>(msg);
-      this.setState({ mentions: data.mentions });
-      this.setState({ combined: this.buildCombined() });
-      window.scrollTo(0, 0);
-      setupTippy();
-    } else if (op == UserOperation.GetPrivateMessages) {
-      const data = wsJsonToRes<PrivateMessagesResponse>(msg);
-      this.setState({ messages: data.private_messages });
-      this.setState({ combined: this.buildCombined() });
-      window.scrollTo(0, 0);
-      setupTippy();
-    } else if (op == UserOperation.EditPrivateMessage) {
-      const data = wsJsonToRes<PrivateMessageResponse>(msg);
-      const found = this.state.messages.find(
-        m =>
-          m.private_message.id === data.private_message_view.private_message.id
-      );
-      if (found) {
-        const combinedView = this.state.combined.find(
-          i => i.id == data.private_message_view.private_message.id
-        )?.view as PrivateMessageView | undefined;
-        if (combinedView) {
-          found.private_message.content = combinedView.private_message.content =
-            data.private_message_view.private_message.content;
-          found.private_message.updated = combinedView.private_message.updated =
-            data.private_message_view.private_message.updated;
-        }
+  async handleCreateComment(form: CreateComment) {
+    const res = await HttpService.client.createComment(form);
+
+    if (res.state === "success") {
+      toast(i18n.t("reply_sent"));
+      this.findAndUpdateComment(res);
+    }
+
+    return res;
+  }
+
+  async handleEditComment(form: EditComment) {
+    const res = await HttpService.client.editComment(form);
+
+    if (res.state === "success") {
+      toast(i18n.t("edit"));
+      this.findAndUpdateComment(res);
+    } else if (res.state === "failed") {
+      toast(res.msg, "danger");
+    }
+
+    return res;
+  }
+
+  async handleDeleteComment(form: DeleteComment) {
+    const res = await HttpService.client.deleteComment(form);
+    if (res.state == "success") {
+      toast(i18n.t("deleted"));
+      this.findAndUpdateComment(res);
+    }
+  }
+
+  async handleRemoveComment(form: RemoveComment) {
+    const res = await HttpService.client.removeComment(form);
+    if (res.state == "success") {
+      toast(i18n.t("remove_comment"));
+      this.findAndUpdateComment(res);
+    }
+  }
+
+  async handleSaveComment(form: SaveComment) {
+    const res = await HttpService.client.saveComment(form);
+    this.findAndUpdateComment(res);
+  }
+
+  async handleCommentVote(form: CreateCommentLike) {
+    const res = await HttpService.client.likeComment(form);
+    this.findAndUpdateComment(res);
+  }
+
+  async handleCommentReport(form: CreateCommentReport) {
+    const reportRes = await HttpService.client.createCommentReport(form);
+    this.reportToast(reportRes);
+  }
+
+  async handleDistinguishComment(form: DistinguishComment) {
+    const res = await HttpService.client.distinguishComment(form);
+    this.findAndUpdateComment(res);
+  }
+
+  async handleAddAdmin(form: AddAdmin) {
+    const addAdminRes = await HttpService.client.addAdmin(form);
+
+    if (addAdminRes.state === "success") {
+      this.setState(s => ((s.siteRes.admins = addAdminRes.data.admins), s));
+    }
+  }
+
+  async handleTransferCommunity(form: TransferCommunity) {
+    await HttpService.client.transferCommunity(form);
+    toast(i18n.t("transfer_community"));
+  }
+
+  async handleCommentReplyRead(form: MarkCommentReplyAsRead) {
+    const res = await HttpService.client.markCommentReplyAsRead(form);
+    this.findAndUpdateCommentReply(res);
+  }
+
+  async handlePersonMentionRead(form: MarkPersonMentionAsRead) {
+    const res = await HttpService.client.markPersonMentionAsRead(form);
+    this.findAndUpdateMention(res);
+  }
+
+  async handleBanFromCommunity(form: BanFromCommunity) {
+    const banRes = await HttpService.client.banFromCommunity(form);
+    this.updateBanFromCommunity(banRes);
+  }
+
+  async handleBanPerson(form: BanPerson) {
+    const banRes = await HttpService.client.banPerson(form);
+    this.updateBan(banRes);
+  }
+
+  async handleDeleteMessage(form: DeletePrivateMessage) {
+    const res = await HttpService.client.deletePrivateMessage(form);
+    this.findAndUpdateMessage(res);
+  }
+
+  async handleEditMessage(form: EditPrivateMessage) {
+    const res = await HttpService.client.editPrivateMessage(form);
+    this.findAndUpdateMessage(res);
+  }
+
+  async handleMarkMessageAsRead(form: MarkPrivateMessageAsRead) {
+    const res = await HttpService.client.markPrivateMessageAsRead(form);
+    this.findAndUpdateMessage(res);
+  }
+
+  async handleMessageReport(form: CreatePrivateMessageReport) {
+    const res = await HttpService.client.createPrivateMessageReport(form);
+    this.reportToast(res);
+  }
+
+  async handleCreateMessage(form: CreatePrivateMessage) {
+    const res = await HttpService.client.createPrivateMessage(form);
+    this.setState(s => {
+      if (s.messagesRes.state == "success" && res.state == "success") {
+        s.messagesRes.data.private_messages.unshift(
+          res.data.private_message_view
+        );
       }
-      this.setState(this.state);
-    } else if (op == UserOperation.DeletePrivateMessage) {
-      const data = wsJsonToRes<PrivateMessageResponse>(msg);
-      const found = this.state.messages.find(
-        m =>
-          m.private_message.id === data.private_message_view.private_message.id
-      );
-      if (found) {
-        const combinedView = this.state.combined.find(
-          i => i.id == data.private_message_view.private_message.id
-        )?.view as PrivateMessageView | undefined;
-        if (combinedView) {
-          found.private_message.deleted = combinedView.private_message.deleted =
-            data.private_message_view.private_message.deleted;
-          found.private_message.updated = combinedView.private_message.updated =
-            data.private_message_view.private_message.updated;
-        }
+
+      return s;
+    });
+  }
+
+  findAndUpdateMessage(res: RequestState<PrivateMessageResponse>) {
+    this.setState(s => {
+      if (s.messagesRes.state === "success" && res.state === "success") {
+        s.messagesRes.data.private_messages = editPrivateMessage(
+          res.data.private_message_view,
+          s.messagesRes.data.private_messages
+        );
       }
-      this.setState(this.state);
-    } else if (op == UserOperation.MarkPrivateMessageAsRead) {
-      const data = wsJsonToRes<PrivateMessageResponse>(msg);
-      const found = this.state.messages.find(
-        m =>
-          m.private_message.id === data.private_message_view.private_message.id
-      );
+      return s;
+    });
+  }
 
-      if (found) {
-        const combinedView = this.state.combined.find(
-          i =>
-            i.id == data.private_message_view.private_message.id &&
-            i.type_ == ReplyEnum.Message
-        )?.view as PrivateMessageView | undefined;
-        if (combinedView) {
-          found.private_message.updated = combinedView.private_message.updated =
-            data.private_message_view.private_message.updated;
-
-          // If youre in the unread view, just remove it from the list
-          if (
-            this.state.unreadOrAll == UnreadOrAll.Unread &&
-            data.private_message_view.private_message.read
-          ) {
-            this.setState({
-              messages: this.state.messages.filter(
-                r =>
-                  r.private_message.id !==
-                  data.private_message_view.private_message.id
-              ),
-            });
-            this.setState({
-              combined: this.state.combined.filter(
-                r => r.id !== data.private_message_view.private_message.id
-              ),
-            });
-          } else {
-            found.private_message.read = combinedView.private_message.read =
-              data.private_message_view.private_message.read;
-          }
+  updateBanFromCommunity(banRes: RequestState<BanFromCommunityResponse>) {
+    // Maybe not necessary
+    if (banRes.state == "success") {
+      this.setState(s => {
+        if (s.repliesRes.state == "success") {
+          s.repliesRes.data.replies
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(
+              c => (c.creator_banned_from_community = banRes.data.banned)
+            );
         }
-      }
-      this.sendUnreadCount(data.private_message_view.private_message.read);
-      this.setState(this.state);
-    } else if (op == UserOperation.MarkAllAsRead) {
-      // Moved to be instant
-    } else if (
-      op == UserOperation.EditComment ||
-      op == UserOperation.DeleteComment ||
-      op == UserOperation.RemoveComment
-    ) {
-      const data = wsJsonToRes<CommentResponse>(msg);
-      editCommentRes(data.comment_view, this.state.replies);
-      this.setState(this.state);
-    } else if (op == UserOperation.MarkCommentReplyAsRead) {
-      const data = wsJsonToRes<CommentReplyResponse>(msg);
-
-      const found = this.state.replies.find(
-        c => c.comment_reply.id == data.comment_reply_view.comment_reply.id
-      );
+        if (s.mentionsRes.state == "success") {
+          s.mentionsRes.data.mentions
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(
+              c => (c.creator_banned_from_community = banRes.data.banned)
+            );
+        }
+        return s;
+      });
+    }
+  }
 
-      if (found) {
-        const combinedView = this.state.combined.find(
-          i =>
-            i.id == data.comment_reply_view.comment_reply.id &&
-            i.type_ == ReplyEnum.Reply
-        )?.view as CommentReplyView | undefined;
-        if (combinedView) {
-          found.comment.content = combinedView.comment.content =
-            data.comment_reply_view.comment.content;
-          found.comment.updated = combinedView.comment.updated =
-            data.comment_reply_view.comment.updated;
-          found.comment.removed = combinedView.comment.removed =
-            data.comment_reply_view.comment.removed;
-          found.comment.deleted = combinedView.comment.deleted =
-            data.comment_reply_view.comment.deleted;
-          found.counts.upvotes = combinedView.counts.upvotes =
-            data.comment_reply_view.counts.upvotes;
-          found.counts.downvotes = combinedView.counts.downvotes =
-            data.comment_reply_view.counts.downvotes;
-          found.counts.score = combinedView.counts.score =
-            data.comment_reply_view.counts.score;
-
-          // If youre in the unread view, just remove it from the list
-          if (
-            this.state.unreadOrAll == UnreadOrAll.Unread &&
-            data.comment_reply_view.comment_reply.read
-          ) {
-            this.setState({
-              replies: this.state.replies.filter(
-                r =>
-                  r.comment_reply.id !==
-                  data.comment_reply_view.comment_reply.id
-              ),
-            });
-            this.setState({
-              combined: this.state.combined.filter(
-                r => r.id !== data.comment_reply_view.comment_reply.id
-              ),
-            });
-          } else {
-            found.comment_reply.read = combinedView.comment_reply.read =
-              data.comment_reply_view.comment_reply.read;
-          }
+  updateBan(banRes: RequestState<BanPersonResponse>) {
+    // Maybe not necessary
+    if (banRes.state == "success") {
+      this.setState(s => {
+        if (s.repliesRes.state == "success") {
+          s.repliesRes.data.replies
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(c => (c.creator.banned = banRes.data.banned));
         }
-      }
-      this.sendUnreadCount(data.comment_reply_view.comment_reply.read);
-      this.setState(this.state);
-    } else if (op == UserOperation.MarkPersonMentionAsRead) {
-      const data = wsJsonToRes<PersonMentionResponse>(msg);
-
-      // TODO this might not be correct, it might need to use the comment id
-      const found = this.state.mentions.find(
-        c => c.person_mention.id == data.person_mention_view.person_mention.id
-      );
+        if (s.mentionsRes.state == "success") {
+          s.mentionsRes.data.mentions
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(c => (c.creator.banned = banRes.data.banned));
+        }
+        return s;
+      });
+    }
+  }
+
+  purgeItem(purgeRes: RequestState<PurgeItemResponse>) {
+    if (purgeRes.state == "success") {
+      toast(i18n.t("purge_success"));
+      this.context.router.history.push(`/`);
+    }
+  }
+
+  reportToast(
+    res: RequestState<PrivateMessageReportResponse | CommentReportResponse>
+  ) {
+    if (res.state == "success") {
+      toast(i18n.t("report_created"));
+    }
+  }
 
-      if (found) {
-        const combinedView = this.state.combined.find(
-          i =>
-            i.id == data.person_mention_view.person_mention.id &&
-            i.type_ == ReplyEnum.Mention
-        )?.view as PersonMentionView | undefined;
-        if (combinedView) {
-          found.comment.content = combinedView.comment.content =
-            data.person_mention_view.comment.content;
-          found.comment.updated = combinedView.comment.updated =
-            data.person_mention_view.comment.updated;
-          found.comment.removed = combinedView.comment.removed =
-            data.person_mention_view.comment.removed;
-          found.comment.deleted = combinedView.comment.deleted =
-            data.person_mention_view.comment.deleted;
-          found.counts.upvotes = combinedView.counts.upvotes =
-            data.person_mention_view.counts.upvotes;
-          found.counts.downvotes = combinedView.counts.downvotes =
-            data.person_mention_view.counts.downvotes;
-          found.counts.score = combinedView.counts.score =
-            data.person_mention_view.counts.score;
-
-          // If youre in the unread view, just remove it from the list
-          if (
-            this.state.unreadOrAll == UnreadOrAll.Unread &&
-            data.person_mention_view.person_mention.read
-          ) {
-            this.setState({
-              mentions: this.state.mentions.filter(
-                r =>
-                  r.person_mention.id !==
-                  data.person_mention_view.person_mention.id
-              ),
-            });
-            this.setState({
-              combined: this.state.combined.filter(
-                r => r.id !== data.person_mention_view.person_mention.id
-              ),
-            });
-          } else {
-            // TODO test to make sure these mentions are getting marked as read
-            found.person_mention.read = combinedView.person_mention.read =
-              data.person_mention_view.person_mention.read;
-          }
+  // A weird case, since you have only replies and mentions, not comment responses
+  findAndUpdateComment(res: RequestState<CommentResponse>) {
+    if (res.state == "success") {
+      this.setState(s => {
+        if (s.repliesRes.state == "success") {
+          s.repliesRes.data.replies = editWith(
+            res.data.comment_view,
+            s.repliesRes.data.replies
+          );
         }
-      }
-      this.sendUnreadCount(data.person_mention_view.person_mention.read);
-      this.setState(this.state);
-    } else if (op == UserOperation.CreatePrivateMessage) {
-      const data = wsJsonToRes<PrivateMessageResponse>(msg);
-      const mui = UserService.Instance.myUserInfo;
-      if (
-        data.private_message_view.recipient.id == mui?.local_user_view.person.id
-      ) {
-        this.state.messages.unshift(data.private_message_view);
-        this.state.combined.unshift(
-          this.messageToReplyType(data.private_message_view)
+        if (s.mentionsRes.state == "success") {
+          s.mentionsRes.data.mentions = editWith(
+            res.data.comment_view,
+            s.mentionsRes.data.mentions
+          );
+        }
+        // Set finished for the parent
+        s.finished.set(
+          getCommentParentId(res.data.comment_view.comment) ?? 0,
+          true
         );
-        this.setState(this.state);
-      }
-    } else if (op == UserOperation.SaveComment) {
-      const data = wsJsonToRes<CommentResponse>(msg);
-      saveCommentRes(data.comment_view, this.state.replies);
-      this.setState(this.state);
-      setupTippy();
-    } else if (op == UserOperation.CreateCommentLike) {
-      const data = wsJsonToRes<CommentResponse>(msg);
-      createCommentLikeRes(data.comment_view, this.state.replies);
-      this.setState(this.state);
-    } else if (op == UserOperation.BlockPerson) {
-      const data = wsJsonToRes<BlockPersonResponse>(msg);
-      updatePersonBlock(data);
-    } else if (op == UserOperation.CreatePostReport) {
-      const data = wsJsonToRes<PostReportResponse>(msg);
-      if (data) {
-        toast(i18n.t("report_created"));
-      }
-    } else if (op == UserOperation.CreateCommentReport) {
-      const data = wsJsonToRes<CommentReportResponse>(msg);
-      if (data) {
-        toast(i18n.t("report_created"));
-      }
-    } else if (op == UserOperation.CreatePrivateMessageReport) {
-      const data = wsJsonToRes<PrivateMessageReportResponse>(msg);
-      if (data) {
-        toast(i18n.t("report_created"));
-      }
+        return s;
+      });
     }
   }
 
-  isMention(view: any): view is PersonMentionView {
-    return (view as PersonMentionView).person_mention !== undefined;
+  findAndUpdateCommentReply(res: RequestState<CommentReplyResponse>) {
+    this.setState(s => {
+      if (s.repliesRes.state == "success" && res.state == "success") {
+        s.repliesRes.data.replies = editCommentReply(
+          res.data.comment_reply_view,
+          s.repliesRes.data.replies
+        );
+      }
+      return s;
+    });
   }
 
-  isReply(view: any): view is CommentReplyView {
-    return (view as CommentReplyView).comment_reply !== undefined;
+  findAndUpdateMention(res: RequestState<PersonMentionResponse>) {
+    this.setState(s => {
+      if (s.mentionsRes.state == "success" && res.state == "success") {
+        s.mentionsRes.data.mentions = editMention(
+          res.data.person_mention_view,
+          s.mentionsRes.data.mentions
+        );
+      }
+      return s;
+    });
   }
 }
index 743fef38e83a29b48c6e909e35dd9a588b7898b9..9f5bf9277d0247982667fbb41d3fcf7b01c117c5 100644 (file)
@@ -1,59 +1,35 @@
 import { Component, linkEvent } from "inferno";
-import {
-  GetSiteResponse,
-  LoginResponse,
-  PasswordChangeAfterReset,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
-} from "lemmy-js-client";
-import { Subscription } from "rxjs";
+import { GetSiteResponse, LoginResponse } from "lemmy-js-client";
 import { i18n } from "../../i18next";
-import { UserService, WebSocketService } from "../../services";
-import {
-  capitalizeFirstLetter,
-  isBrowser,
-  setIsoData,
-  toast,
-  wsClient,
-  wsSubscribe,
-} from "../../utils";
+import { HttpService, UserService } from "../../services";
+import { RequestState } from "../../services/HttpService";
+import { capitalizeFirstLetter, myAuth, setIsoData } from "../../utils";
 import { HtmlTags } from "../common/html-tags";
 import { Spinner } from "../common/icon";
 
 interface State {
+  passwordChangeRes: RequestState<LoginResponse>;
   form: {
     token: string;
     password?: string;
     password_verify?: string;
   };
-  loading: boolean;
   siteRes: GetSiteResponse;
 }
 
 export class PasswordChange extends Component<any, State> {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
 
   state: State = {
+    passwordChangeRes: { state: "empty" },
+    siteRes: this.isoData.site_res,
     form: {
       token: this.props.match.params.token,
     },
-    loading: false,
-    siteRes: this.isoData.site_res,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
-
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
-  }
-
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
-    }
   }
 
   get documentTitle(): string {
@@ -117,7 +93,7 @@ export class PasswordChange extends Component<any, State> {
         <div className="form-group row">
           <div className="col-sm-10">
             <button type="submit" className="btn btn-secondary">
-              {this.state.loading ? (
+              {this.state.passwordChangeRes.state == "loading" ? (
                 <Spinner />
               ) : (
                 capitalizeFirstLetter(i18n.t("save"))
@@ -139,36 +115,33 @@ export class PasswordChange extends Component<any, State> {
     i.setState(i.state);
   }
 
-  handlePasswordChangeSubmit(i: PasswordChange, event: any) {
+  async handlePasswordChangeSubmit(i: PasswordChange, event: any) {
     event.preventDefault();
-    i.setState({ loading: true });
+    i.setState({ passwordChangeRes: { state: "loading" } });
 
     const password = i.state.form.password;
     const password_verify = i.state.form.password_verify;
 
     if (password && password_verify) {
-      const form: PasswordChangeAfterReset = {
-        token: i.state.form.token,
-        password,
-        password_verify,
-      };
-
-      WebSocketService.Instance.send(wsClient.passwordChange(form));
-    }
-  }
-
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      this.setState({ loading: false });
-      return;
-    } else if (op == UserOperation.PasswordChangeAfterReset) {
-      const data = wsJsonToRes<LoginResponse>(msg);
-      UserService.Instance.login(data);
-      this.props.history.push("/");
-      location.reload();
+      i.setState({
+        passwordChangeRes: await HttpService.client.passwordChangeAfterReset({
+          token: i.state.form.token,
+          password,
+          password_verify,
+        }),
+      });
+
+      if (i.state.passwordChangeRes.state === "success") {
+        const data = i.state.passwordChangeRes.data;
+        UserService.Instance.login(data);
+
+        const site = await HttpService.client.getSite({ auth: myAuth() });
+        if (site.state === "success") {
+          UserService.Instance.myUserInfo = site.data.my_user;
+        }
+
+        this.props.history.replace("/");
+      }
     }
   }
 }
index 650640a76aef110c98d9732746e1633400a2df49..07f5431fff25c04ba9d0ca72ff8e99f5035495e8 100644 (file)
@@ -1,11 +1,40 @@
 import { Component } from "inferno";
 import {
+  AddAdmin,
+  AddModToCommunity,
+  BanFromCommunity,
+  BanPerson,
+  BlockPerson,
+  CommentId,
   CommentView,
+  CreateComment,
+  CreateCommentLike,
+  CreateCommentReport,
+  CreatePostLike,
+  CreatePostReport,
+  DeleteComment,
+  DeletePost,
+  DistinguishComment,
+  EditComment,
+  EditPost,
+  FeaturePost,
+  GetComments,
   GetPersonDetailsResponse,
   Language,
+  LockPost,
+  MarkCommentReplyAsRead,
+  MarkPersonMentionAsRead,
   PersonView,
   PostView,
+  PurgeComment,
+  PurgePerson,
+  PurgePost,
+  RemoveComment,
+  RemovePost,
+  SaveComment,
+  SavePost,
   SortType,
+  TransferCommunity,
 } from "lemmy-js-client";
 import { CommentViewType, PersonDetailsView } from "../../interfaces";
 import { commentsToFlatNodes, setupTippy } from "../../utils";
@@ -15,6 +44,7 @@ import { PostListing } from "../post/post-listing";
 
 interface PersonDetailsProps {
   personRes: GetPersonDetailsResponse;
+  finished: Map<CommentId, boolean | undefined>;
   admins: PersonView[];
   allLanguages: Language[];
   siteLanguages: number[];
@@ -25,6 +55,34 @@ interface PersonDetailsProps {
   enableNsfw: boolean;
   view: PersonDetailsView;
   onPageChange(page: number): number | any;
+  onSaveComment(form: SaveComment): void;
+  onCommentReplyRead(form: MarkCommentReplyAsRead): void;
+  onPersonMentionRead(form: MarkPersonMentionAsRead): void;
+  onCreateComment(form: CreateComment): void;
+  onEditComment(form: EditComment): void;
+  onCommentVote(form: CreateCommentLike): void;
+  onBlockPerson(form: BlockPerson): void;
+  onDeleteComment(form: DeleteComment): void;
+  onRemoveComment(form: RemoveComment): void;
+  onDistinguishComment(form: DistinguishComment): void;
+  onAddModToCommunity(form: AddModToCommunity): void;
+  onAddAdmin(form: AddAdmin): void;
+  onBanPersonFromCommunity(form: BanFromCommunity): void;
+  onBanPerson(form: BanPerson): void;
+  onTransferCommunity(form: TransferCommunity): void;
+  onFetchChildren?(form: GetComments): void;
+  onCommentReport(form: CreateCommentReport): void;
+  onPurgePerson(form: PurgePerson): void;
+  onPurgeComment(form: PurgeComment): void;
+  onPostEdit(form: EditPost): void;
+  onPostVote(form: CreatePostLike): void;
+  onPostReport(form: CreatePostReport): void;
+  onLockPost(form: LockPost): void;
+  onDeletePost(form: DeletePost): void;
+  onRemovePost(form: RemovePost): void;
+  onSavePost(form: SavePost): void;
+  onFeaturePost(form: FeaturePost): void;
+  onPurgePost(form: PurgePost): void;
 }
 
 enum ItemEnum {
@@ -93,6 +151,7 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
             key={i.id}
             nodes={[{ comment_view: c, children: [], depth: 0 }]}
             viewType={CommentViewType.Flat}
+            finished={this.props.finished}
             admins={this.props.admins}
             noBorder
             noIndent
@@ -101,6 +160,25 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
             enableDownvotes={this.props.enableDownvotes}
             allLanguages={this.props.allLanguages}
             siteLanguages={this.props.siteLanguages}
+            onCommentReplyRead={this.props.onCommentReplyRead}
+            onPersonMentionRead={this.props.onPersonMentionRead}
+            onCreateComment={this.props.onCreateComment}
+            onEditComment={this.props.onEditComment}
+            onCommentVote={this.props.onCommentVote}
+            onBlockPerson={this.props.onBlockPerson}
+            onSaveComment={this.props.onSaveComment}
+            onDeleteComment={this.props.onDeleteComment}
+            onRemoveComment={this.props.onRemoveComment}
+            onDistinguishComment={this.props.onDistinguishComment}
+            onAddModToCommunity={this.props.onAddModToCommunity}
+            onAddAdmin={this.props.onAddAdmin}
+            onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
+            onBanPerson={this.props.onBanPerson}
+            onTransferCommunity={this.props.onTransferCommunity}
+            onFetchChildren={this.props.onFetchChildren}
+            onCommentReport={this.props.onCommentReport}
+            onPurgePerson={this.props.onPurgePerson}
+            onPurgeComment={this.props.onPurgeComment}
           />
         );
       }
@@ -116,6 +194,22 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
             enableNsfw={this.props.enableNsfw}
             allLanguages={this.props.allLanguages}
             siteLanguages={this.props.siteLanguages}
+            onPostEdit={this.props.onPostEdit}
+            onPostVote={this.props.onPostVote}
+            onPostReport={this.props.onPostReport}
+            onBlockPerson={this.props.onBlockPerson}
+            onLockPost={this.props.onLockPost}
+            onDeletePost={this.props.onDeletePost}
+            onRemovePost={this.props.onRemovePost}
+            onSavePost={this.props.onSavePost}
+            onFeaturePost={this.props.onFeaturePost}
+            onPurgePerson={this.props.onPurgePerson}
+            onPurgePost={this.props.onPurgePost}
+            onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
+            onBanPerson={this.props.onBanPerson}
+            onAddModToCommunity={this.props.onAddModToCommunity}
+            onAddAdmin={this.props.onAddAdmin}
+            onTransferCommunity={this.props.onTransferCommunity}
           />
         );
       }
@@ -167,12 +261,32 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
           nodes={commentsToFlatNodes(this.props.personRes.comments)}
           viewType={CommentViewType.Flat}
           admins={this.props.admins}
+          finished={this.props.finished}
           noIndent
           showCommunity
           showContext
           enableDownvotes={this.props.enableDownvotes}
           allLanguages={this.props.allLanguages}
           siteLanguages={this.props.siteLanguages}
+          onCommentReplyRead={this.props.onCommentReplyRead}
+          onPersonMentionRead={this.props.onPersonMentionRead}
+          onCreateComment={this.props.onCreateComment}
+          onEditComment={this.props.onEditComment}
+          onCommentVote={this.props.onCommentVote}
+          onBlockPerson={this.props.onBlockPerson}
+          onSaveComment={this.props.onSaveComment}
+          onDeleteComment={this.props.onDeleteComment}
+          onRemoveComment={this.props.onRemoveComment}
+          onDistinguishComment={this.props.onDistinguishComment}
+          onAddModToCommunity={this.props.onAddModToCommunity}
+          onAddAdmin={this.props.onAddAdmin}
+          onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
+          onBanPerson={this.props.onBanPerson}
+          onTransferCommunity={this.props.onTransferCommunity}
+          onFetchChildren={this.props.onFetchChildren}
+          onCommentReport={this.props.onCommentReport}
+          onPurgePerson={this.props.onPurgePerson}
+          onPurgeComment={this.props.onPurgeComment}
         />
       </div>
     );
@@ -191,6 +305,22 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
               enableNsfw={this.props.enableNsfw}
               allLanguages={this.props.allLanguages}
               siteLanguages={this.props.siteLanguages}
+              onPostEdit={this.props.onPostEdit}
+              onPostVote={this.props.onPostVote}
+              onPostReport={this.props.onPostReport}
+              onBlockPerson={this.props.onBlockPerson}
+              onLockPost={this.props.onLockPost}
+              onDeletePost={this.props.onDeletePost}
+              onRemovePost={this.props.onRemovePost}
+              onSavePost={this.props.onSavePost}
+              onFeaturePost={this.props.onFeaturePost}
+              onPurgePerson={this.props.onPurgePerson}
+              onPurgePost={this.props.onPurgePost}
+              onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
+              onBanPerson={this.props.onBanPerson}
+              onAddModToCommunity={this.props.onAddModToCommunity}
+              onAddAdmin={this.props.onAddAdmin}
+              onTransferCommunity={this.props.onTransferCommunity}
             />
             <hr className="my-3" />
           </>
index 81186504eac2312f0b25d9bbef887f816ee37259..f80d5b907a2f7f1972dc0b62d1bebb51d06df1fe 100644 (file)
@@ -4,41 +4,66 @@ import { Component, linkEvent } from "inferno";
 import { Link } from "inferno-router";
 import { RouteComponentProps } from "inferno-router/dist/Route";
 import {
-  AddAdminResponse,
+  AddAdmin,
+  AddModToCommunity,
+  BanFromCommunity,
+  BanFromCommunityResponse,
   BanPerson,
   BanPersonResponse,
   BlockPerson,
-  BlockPersonResponse,
+  CommentId,
+  CommentReplyResponse,
   CommentResponse,
   Community,
   CommunityModeratorView,
+  CreateComment,
+  CreateCommentLike,
+  CreateCommentReport,
+  CreatePostLike,
+  CreatePostReport,
+  DeleteComment,
+  DeletePost,
+  DistinguishComment,
+  EditComment,
+  EditPost,
+  FeaturePost,
   GetPersonDetails,
   GetPersonDetailsResponse,
   GetSiteResponse,
+  LockPost,
+  MarkCommentReplyAsRead,
+  MarkPersonMentionAsRead,
+  PersonView,
   PostResponse,
+  PurgeComment,
   PurgeItemResponse,
+  PurgePerson,
+  PurgePost,
+  RemoveComment,
+  RemovePost,
+  SaveComment,
+  SavePost,
   SortType,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
+  TransferCommunity,
 } from "lemmy-js-client";
 import moment from "moment";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
 import { InitialFetchRequest, PersonDetailsView } from "../../interfaces";
-import { UserService, WebSocketService } from "../../services";
+import { UserService } from "../../services";
+import { FirstLoadService } from "../../services/FirstLoadService";
+import { HttpService, RequestState } from "../../services/HttpService";
 import {
   QueryParams,
   canMod,
   capitalizeFirstLetter,
-  createCommentLikeRes,
-  createPostLikeFindRes,
-  editCommentRes,
-  editPostFindRes,
+  editComment,
+  editPost,
+  editWith,
   enableDownvotes,
   enableNsfw,
   fetchLimit,
   futureDaysToUnixTime,
+  getCommentParentId,
   getPageFromString,
   getQueryParams,
   getQueryString,
@@ -46,17 +71,15 @@ import {
   isBanned,
   mdToHtml,
   myAuth,
+  myAuthRequired,
   numToSI,
   relTags,
   restoreScrollPosition,
-  saveCommentRes,
   saveScrollPosition,
   setIsoData,
   setupTippy,
   toast,
   updatePersonBlock,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { BannerIconHeader } from "../common/banner-icon-header";
 import { HtmlTags } from "../common/html-tags";
@@ -68,14 +91,15 @@ import { PersonDetails } from "./person-details";
 import { PersonListing } from "./person-listing";
 
 interface ProfileState {
-  personRes?: GetPersonDetailsResponse;
-  loading: boolean;
+  personRes: RequestState<GetPersonDetailsResponse>;
   personBlocked: boolean;
   banReason?: string;
   banExpireDays?: number;
   showBanDialog: boolean;
   removeData: boolean;
   siteRes: GetSiteResponse;
+  finished: Map<CommentId, boolean | undefined>;
+  isIsomorphic: boolean;
 }
 
 interface ProfileProps {
@@ -102,26 +126,6 @@ function getViewFromProps(view?: string): PersonDetailsView {
     : PersonDetailsView.Overview;
 }
 
-function toggleBlockPerson(recipientId: number, block: boolean) {
-  const auth = myAuth();
-
-  if (auth) {
-    const blockUserForm: BlockPerson = {
-      person_id: recipientId,
-      block,
-      auth,
-    };
-
-    WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
-  }
-}
-
-const handleUnblockPerson = (personId: number) =>
-  toggleBlockPerson(personId, false);
-
-const handleBlockPerson = (personId: number) =>
-  toggleBlockPerson(personId, true);
-
 const getCommunitiesListing = (
   translationKey: NoOptionI18nKeys,
   communityViews?: { community: Community }[]
@@ -153,13 +157,14 @@ export class Profile extends Component<
   ProfileState
 > {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
   state: ProfileState = {
-    loading: true,
+    personRes: { state: "empty" },
     personBlocked: false,
     siteRes: this.isoData.site_res,
     showBanDialog: false,
     removeData: false,
+    finished: new Map(),
+    isIsomorphic: false,
   };
 
   constructor(props: RouteComponentProps<{ username: string }>, context: any) {
@@ -168,51 +173,95 @@ export class Profile extends Component<
     this.handleSortChange = this.handleSortChange.bind(this);
     this.handlePageChange = this.handlePageChange.bind(this);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
+    this.handleBlockPerson = this.handleBlockPerson.bind(this);
+    this.handleUnblockPerson = this.handleUnblockPerson.bind(this);
+
+    this.handleCreateComment = this.handleCreateComment.bind(this);
+    this.handleEditComment = this.handleEditComment.bind(this);
+    this.handleSaveComment = this.handleSaveComment.bind(this);
+    this.handleBlockPersonAlt = this.handleBlockPersonAlt.bind(this);
+    this.handleDeleteComment = this.handleDeleteComment.bind(this);
+    this.handleRemoveComment = this.handleRemoveComment.bind(this);
+    this.handleCommentVote = this.handleCommentVote.bind(this);
+    this.handleAddModToCommunity = this.handleAddModToCommunity.bind(this);
+    this.handleAddAdmin = this.handleAddAdmin.bind(this);
+    this.handlePurgePerson = this.handlePurgePerson.bind(this);
+    this.handlePurgeComment = this.handlePurgeComment.bind(this);
+    this.handleCommentReport = this.handleCommentReport.bind(this);
+    this.handleDistinguishComment = this.handleDistinguishComment.bind(this);
+    this.handleTransferCommunity = this.handleTransferCommunity.bind(this);
+    this.handleCommentReplyRead = this.handleCommentReplyRead.bind(this);
+    this.handlePersonMentionRead = this.handlePersonMentionRead.bind(this);
+    this.handleBanFromCommunity = this.handleBanFromCommunity.bind(this);
+    this.handleBanPerson = this.handleBanPerson.bind(this);
+    this.handlePostVote = this.handlePostVote.bind(this);
+    this.handlePostEdit = this.handlePostEdit.bind(this);
+    this.handlePostReport = this.handlePostReport.bind(this);
+    this.handleLockPost = this.handleLockPost.bind(this);
+    this.handleDeletePost = this.handleDeletePost.bind(this);
+    this.handleRemovePost = this.handleRemovePost.bind(this);
+    this.handleSavePost = this.handleSavePost.bind(this);
+    this.handlePurgePost = this.handlePurgePost.bind(this);
+    this.handleFeaturePost = this.handleFeaturePost.bind(this);
 
     // Only fetch the data if coming from another route
-    if (this.isoData.path === this.context.router.route.match.url) {
+    if (FirstLoadService.isFirstLoad) {
       this.state = {
         ...this.state,
-        personRes: this.isoData.routeData[0] as GetPersonDetailsResponse,
-        loading: false,
+        personRes: this.isoData.routeData[0],
+        isIsomorphic: true,
       };
-    } else {
-      this.fetchUserData();
     }
   }
 
-  fetchUserData() {
-    const { page, sort, view } = getProfileQueryParams();
+  async componentDidMount() {
+    if (!this.state.isIsomorphic) {
+      await this.fetchUserData();
+    }
+    setupTippy();
+  }
 
-    const form: GetPersonDetails = {
-      username: this.props.match.params.username,
-      sort,
-      saved_only: view === PersonDetailsView.Saved,
-      page,
-      limit: fetchLimit,
-      auth: myAuth(false),
-    };
+  componentWillUnmount() {
+    saveScrollPosition(this.context);
+  }
+
+  async fetchUserData() {
+    const { page, sort, view } = getProfileQueryParams();
 
-    WebSocketService.Instance.send(wsClient.getPersonDetails(form));
+    this.setState({ personRes: { state: "empty" } });
+    this.setState({
+      personRes: await HttpService.client.getPersonDetails({
+        username: this.props.match.params.username,
+        sort,
+        saved_only: view === PersonDetailsView.Saved,
+        page,
+        limit: fetchLimit,
+        auth: myAuth(),
+      }),
+    });
+    restoreScrollPosition(this.context);
+    this.setPersonBlock();
   }
 
   get amCurrentUser() {
-    return (
-      UserService.Instance.myUserInfo?.local_user_view.person.id ===
-      this.state.personRes?.person_view.person.id
-    );
+    if (this.state.personRes.state === "success") {
+      return (
+        UserService.Instance.myUserInfo?.local_user_view.person.id ===
+        this.state.personRes.data.person_view.person.id
+      );
+    } else {
+      return false;
+    }
   }
 
   setPersonBlock() {
     const mui = UserService.Instance.myUserInfo;
     const res = this.state.personRes;
 
-    if (mui && res) {
+    if (mui && res.state === "success") {
       this.setState({
         personBlocked: mui.person_blocks.some(
-          ({ target: { id } }) => id === res.person_view.person.id
+          ({ target: { id } }) => id === res.data.person_view.person.id
         ),
       });
     }
@@ -223,7 +272,9 @@ export class Profile extends Component<
     path,
     query: { page, sort, view: urlView },
     auth,
-  }: InitialFetchRequest<QueryParams<ProfileProps>>): Promise<any>[] {
+  }: InitialFetchRequest<QueryParams<ProfileProps>>): Promise<
+    RequestState<any>
+  >[] {
     const pathSplit = path.split("/");
 
     const username = pathSplit[2];
@@ -241,74 +292,99 @@ export class Profile extends Component<
     return [client.getPersonDetails(form)];
   }
 
-  componentDidMount() {
-    this.setPersonBlock();
-    setupTippy();
-  }
-
-  componentWillUnmount() {
-    this.subscription?.unsubscribe();
-    saveScrollPosition(this.context);
-  }
-
   get documentTitle(): string {
+    const siteName = this.state.siteRes.site_view.site.name;
     const res = this.state.personRes;
-    return res
-      ? `@${res.person_view.person.name} - ${this.state.siteRes.site_view.site.name}`
-      : "";
+    return res.state == "success"
+      ? `@${res.data.person_view.person.name} - ${siteName}`
+      : siteName;
   }
 
-  render() {
-    const { personRes, loading, siteRes } = this.state;
-    const { page, sort, view } = getProfileQueryParams();
-
-    return (
-      <div className="container-lg">
-        {loading ? (
+  renderPersonRes() {
+    switch (this.state.personRes.state) {
+      case "loading":
+        return (
           <h5>
             <Spinner large />
           </h5>
-        ) : (
-          personRes && (
-            <div className="row">
-              <div className="col-12 col-md-8">
-                <HtmlTags
-                  title={this.documentTitle}
-                  path={this.context.router.route.match.url}
-                  description={personRes.person_view.person.bio}
-                  image={personRes.person_view.person.avatar}
-                />
-
-                {this.userInfo}
-
-                <hr />
-
-                {this.selects}
-
-                <PersonDetails
-                  personRes={personRes}
-                  admins={siteRes.admins}
-                  sort={sort}
-                  page={page}
-                  limit={fetchLimit}
-                  enableDownvotes={enableDownvotes(siteRes)}
-                  enableNsfw={enableNsfw(siteRes)}
-                  view={view}
-                  onPageChange={this.handlePageChange}
-                  allLanguages={siteRes.all_languages}
-                  siteLanguages={siteRes.discussion_languages}
-                />
-              </div>
+        );
+      case "success": {
+        const siteRes = this.state.siteRes;
+        const personRes = this.state.personRes.data;
+        const { page, sort, view } = getProfileQueryParams();
+
+        return (
+          <div className="row">
+            <div className="col-12 col-md-8">
+              <HtmlTags
+                title={this.documentTitle}
+                path={this.context.router.route.match.url}
+                description={personRes.person_view.person.bio}
+                image={personRes.person_view.person.avatar}
+              />
+
+              {this.userInfo(personRes.person_view)}
+
+              <hr />
+
+              {this.selects}
+
+              <PersonDetails
+                personRes={personRes}
+                admins={siteRes.admins}
+                sort={sort}
+                page={page}
+                limit={fetchLimit}
+                finished={this.state.finished}
+                enableDownvotes={enableDownvotes(siteRes)}
+                enableNsfw={enableNsfw(siteRes)}
+                view={view}
+                onPageChange={this.handlePageChange}
+                allLanguages={siteRes.all_languages}
+                siteLanguages={siteRes.discussion_languages}
+                // TODO all the forms here
+                onSaveComment={this.handleSaveComment}
+                onBlockPerson={this.handleBlockPersonAlt}
+                onDeleteComment={this.handleDeleteComment}
+                onRemoveComment={this.handleRemoveComment}
+                onCommentVote={this.handleCommentVote}
+                onCommentReport={this.handleCommentReport}
+                onDistinguishComment={this.handleDistinguishComment}
+                onAddModToCommunity={this.handleAddModToCommunity}
+                onAddAdmin={this.handleAddAdmin}
+                onTransferCommunity={this.handleTransferCommunity}
+                onPurgeComment={this.handlePurgeComment}
+                onPurgePerson={this.handlePurgePerson}
+                onCommentReplyRead={this.handleCommentReplyRead}
+                onPersonMentionRead={this.handlePersonMentionRead}
+                onBanPersonFromCommunity={this.handleBanFromCommunity}
+                onBanPerson={this.handleBanPerson}
+                onCreateComment={this.handleCreateComment}
+                onEditComment={this.handleEditComment}
+                onPostEdit={this.handlePostEdit}
+                onPostVote={this.handlePostVote}
+                onPostReport={this.handlePostReport}
+                onLockPost={this.handleLockPost}
+                onDeletePost={this.handleDeletePost}
+                onRemovePost={this.handleRemovePost}
+                onSavePost={this.handleSavePost}
+                onPurgePost={this.handlePurgePost}
+                onFeaturePost={this.handleFeaturePost}
+              />
+            </div>
 
-              <div className="col-12 col-md-4">
-                <Moderates moderates={personRes.moderates} />
-                {this.amCurrentUser && <Follows />}
-              </div>
+            <div className="col-12 col-md-4">
+              <Moderates moderates={personRes.moderates} />
+              {this.amCurrentUser && <Follows />}
             </div>
-          )
-        )}
-      </div>
-    );
+          </div>
+        );
+      }
+    }
+  }
+
+  render() {
+    return <div className="container-lg">{this.renderPersonRes()}</div>;
   }
 
   get viewRadios() {
@@ -366,8 +442,7 @@ export class Profile extends Component<
     );
   }
 
-  get userInfo() {
-    const pv = this.state.personRes?.person_view;
+  userInfo(pv: PersonView) {
     const {
       personBlocked,
       siteRes: { admins },
@@ -422,7 +497,7 @@ export class Profile extends Component<
                     )}
                   </ul>
                 </div>
-                {this.banDialog}
+                {this.banDialog(pv)}
                 <div className="flex-grow-1 unselectable pointer mx-2"></div>
                 {!this.amCurrentUser && UserService.Instance.myUserInfo && (
                   <>
@@ -448,7 +523,10 @@ export class Profile extends Component<
                         className={
                           "d-flex align-self-start btn btn-secondary mr-2"
                         }
-                        onClick={linkEvent(pv.person.id, handleUnblockPerson)}
+                        onClick={linkEvent(
+                          pv.person.id,
+                          this.handleUnblockPerson
+                        )}
                       >
                         {i18n.t("unblock_user")}
                       </button>
@@ -457,7 +535,10 @@ export class Profile extends Component<
                         className={
                           "d-flex align-self-start btn btn-secondary mr-2"
                         }
-                        onClick={linkEvent(pv.person.id, handleBlockPerson)}
+                        onClick={linkEvent(
+                          pv.person.id,
+                          this.handleBlockPerson
+                        )}
                       >
                         {i18n.t("block_user")}
                       </button>
@@ -544,87 +625,82 @@ export class Profile extends Component<
     );
   }
 
-  get banDialog() {
-    const pv = this.state.personRes?.person_view;
+  banDialog(pv: PersonView) {
     const { showBanDialog } = this.state;
 
     return (
-      pv && (
-        <>
-          {showBanDialog && (
-            <form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
-              <div className="form-group row col-12">
-                <label className="col-form-label" htmlFor="profile-ban-reason">
-                  {i18n.t("reason")}
-                </label>
-                <input
-                  type="text"
-                  id="profile-ban-reason"
-                  className="form-control mr-2"
-                  placeholder={i18n.t("reason")}
-                  value={this.state.banReason}
-                  onInput={linkEvent(this, this.handleModBanReasonChange)}
-                />
-                <label className="col-form-label" htmlFor={`mod-ban-expires`}>
-                  {i18n.t("expires")}
-                </label>
+      showBanDialog && (
+        <form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
+          <div className="form-group row col-12">
+            <label className="col-form-label" htmlFor="profile-ban-reason">
+              {i18n.t("reason")}
+            </label>
+            <input
+              type="text"
+              id="profile-ban-reason"
+              className="form-control mr-2"
+              placeholder={i18n.t("reason")}
+              value={this.state.banReason}
+              onInput={linkEvent(this, this.handleModBanReasonChange)}
+            />
+            <label className="col-form-label" htmlFor={`mod-ban-expires`}>
+              {i18n.t("expires")}
+            </label>
+            <input
+              type="number"
+              id={`mod-ban-expires`}
+              className="form-control mr-2"
+              placeholder={i18n.t("number_of_days")}
+              value={this.state.banExpireDays}
+              onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
+            />
+            <div className="form-group">
+              <div className="form-check">
                 <input
-                  type="number"
-                  id={`mod-ban-expires`}
-                  className="form-control mr-2"
-                  placeholder={i18n.t("number_of_days")}
-                  value={this.state.banExpireDays}
-                  onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
+                  className="form-check-input"
+                  id="mod-ban-remove-data"
+                  type="checkbox"
+                  checked={this.state.removeData}
+                  onChange={linkEvent(this, this.handleModRemoveDataChange)}
                 />
-                <div className="form-group">
-                  <div className="form-check">
-                    <input
-                      className="form-check-input"
-                      id="mod-ban-remove-data"
-                      type="checkbox"
-                      checked={this.state.removeData}
-                      onChange={linkEvent(this, this.handleModRemoveDataChange)}
-                    />
-                    <label
-                      className="form-check-label"
-                      htmlFor="mod-ban-remove-data"
-                      title={i18n.t("remove_content_more")}
-                    >
-                      {i18n.t("remove_content")}
-                    </label>
-                  </div>
-                </div>
-              </div>
-              {/* TODO hold off on expires until later */}
-              {/* <div class="form-group row"> */}
-              {/*   <label class="col-form-label">Expires</label> */}
-              {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
-              {/* </div> */}
-              <div className="form-group row">
-                <button
-                  type="reset"
-                  className="btn btn-secondary mr-2"
-                  aria-label={i18n.t("cancel")}
-                  onClick={linkEvent(this, this.handleModBanSubmitCancel)}
-                >
-                  {i18n.t("cancel")}
-                </button>
-                <button
-                  type="submit"
-                  className="btn btn-secondary"
-                  aria-label={i18n.t("ban")}
+                <label
+                  className="form-check-label"
+                  htmlFor="mod-ban-remove-data"
+                  title={i18n.t("remove_content_more")}
                 >
-                  {i18n.t("ban")} {pv.person.name}
-                </button>
+                  {i18n.t("remove_content")}
+                </label>
               </div>
-            </form>
-          )}
-        </>
+            </div>
+          </div>
+          {/* TODO hold off on expires until later */}
+          {/* <div class="form-group row"> */}
+          {/*   <label class="col-form-label">Expires</label> */}
+          {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
+          {/* </div> */}
+          <div className="form-group row">
+            <button
+              type="reset"
+              className="btn btn-secondary mr-2"
+              aria-label={i18n.t("cancel")}
+              onClick={linkEvent(this, this.handleModBanSubmitCancel)}
+            >
+              {i18n.t("cancel")}
+            </button>
+            <button
+              type="submit"
+              className="btn btn-secondary"
+              aria-label={i18n.t("ban")}
+            >
+              {i18n.t("ban")} {pv.person.name}
+            </button>
+          </div>
+        </form>
       )
     );
   }
 
-  updateUrl({ page, sort, view }: Partial<ProfileProps>) {
+  async updateUrl({ page, sort, view }: Partial<ProfileProps>) {
     const {
       page: urlPage,
       sort: urlSort,
@@ -640,9 +716,7 @@ export class Profile extends Component<
     const { username } = this.props.match.params;
 
     this.props.history.push(`/u/${username}${getQueryString(queryParams)}`);
-
-    this.setState({ loading: true });
-    this.fetchUserData();
+    await this.fetchUserData();
   }
 
   handlePageChange(page: number) {
@@ -676,19 +750,18 @@ export class Profile extends Component<
     i.setState({ removeData: event.target.checked });
   }
 
-  handleModBanSubmitCancel(i: Profile, event?: any) {
-    event.preventDefault();
+  handleModBanSubmitCancel(i: Profile) {
     i.setState({ showBanDialog: false });
   }
 
-  handleModBanSubmit(i: Profile, event?: any) {
-    if (event) event.preventDefault();
-    const { personRes, removeData, banReason, banExpireDays } = i.state;
+  async handleModBanSubmit(i: Profile, event: any) {
+    event.preventDefault();
+    const { removeData, banReason, banExpireDays } = i.state;
 
-    const person = personRes?.person_view.person;
-    const auth = myAuth();
+    const personRes = i.state.personRes;
 
-    if (person && auth) {
+    if (personRes.state == "success") {
+      const person = personRes.data.person_view.person;
       const ban = !person.banned;
 
       // If its an unban, restore all their data
@@ -696,154 +769,281 @@ export class Profile extends Component<
         i.setState({ removeData: false });
       }
 
-      const form: BanPerson = {
+      const res = await HttpService.client.banPerson({
         person_id: person.id,
         ban,
         remove_data: removeData,
         reason: banReason,
         expires: futureDaysToUnixTime(banExpireDays),
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.banPerson(form));
-
+        auth: myAuthRequired(),
+      });
+      // TODO
+      this.updateBan(res);
       i.setState({ showBanDialog: false });
     }
   }
 
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
+  async toggleBlockPerson(recipientId: number, block: boolean) {
+    const res = await HttpService.client.blockPerson({
+      person_id: recipientId,
+      block,
+      auth: myAuthRequired(),
+    });
+    if (res.state == "success") {
+      updatePersonBlock(res.data);
+    }
+  }
+
+  handleUnblockPerson(personId: number) {
+    this.toggleBlockPerson(personId, false);
+  }
 
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
+  handleBlockPerson(personId: number) {
+    this.toggleBlockPerson(personId, true);
+  }
 
-      if (msg.error === "couldnt_find_that_username_or_email") {
-        this.context.router.history.push("/");
-      }
-    } else if (msg.reconnect) {
-      this.fetchUserData();
-    } else {
-      switch (op) {
-        case UserOperation.GetPersonDetails: {
-          // Since the PersonDetails contains posts/comments as well as some general user info we listen here as well
-          // and set the parent state if it is not set or differs
-          // TODO this might need to get abstracted
-          const data = wsJsonToRes<GetPersonDetailsResponse>(msg);
-          this.setState({ personRes: data, loading: false });
-          this.setPersonBlock();
-          restoreScrollPosition(this.context);
-
-          break;
-        }
+  async handleAddModToCommunity(form: AddModToCommunity) {
+    // TODO not sure what to do here
+    await HttpService.client.addModToCommunity(form);
+  }
 
-        case UserOperation.AddAdmin: {
-          const { admins } = wsJsonToRes<AddAdminResponse>(msg);
-          this.setState(s => ((s.siteRes.admins = admins), s));
+  async handlePurgePerson(form: PurgePerson) {
+    const purgePersonRes = await HttpService.client.purgePerson(form);
+    this.purgeItem(purgePersonRes);
+  }
 
-          break;
-        }
+  async handlePurgeComment(form: PurgeComment) {
+    const purgeCommentRes = await HttpService.client.purgeComment(form);
+    this.purgeItem(purgeCommentRes);
+  }
 
-        case UserOperation.CreateCommentLike: {
-          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
-          createCommentLikeRes(comment_view, this.state.personRes?.comments);
-          this.setState(this.state);
+  async handlePurgePost(form: PurgePost) {
+    const purgeRes = await HttpService.client.purgePost(form);
+    this.purgeItem(purgeRes);
+  }
 
-          break;
-        }
+  async handleBlockPersonAlt(form: BlockPerson) {
+    const blockPersonRes = await HttpService.client.blockPerson(form);
+    if (blockPersonRes.state === "success") {
+      updatePersonBlock(blockPersonRes.data);
+    }
+  }
 
-        case UserOperation.EditComment:
-        case UserOperation.DeleteComment:
-        case UserOperation.RemoveComment: {
-          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
-          editCommentRes(comment_view, this.state.personRes?.comments);
-          this.setState(this.state);
+  async handleCreateComment(form: CreateComment) {
+    const createCommentRes = await HttpService.client.createComment(form);
+    this.createAndUpdateComments(createCommentRes);
 
-          break;
-        }
+    return createCommentRes;
+  }
 
-        case UserOperation.CreateComment: {
-          const {
-            comment_view: {
-              creator: { id },
-            },
-          } = wsJsonToRes<CommentResponse>(msg);
-          const mui = UserService.Instance.myUserInfo;
+  async handleEditComment(form: EditComment) {
+    const editCommentRes = await HttpService.client.editComment(form);
+    this.findAndUpdateComment(editCommentRes);
 
-          if (id === mui?.local_user_view.person.id) {
-            toast(i18n.t("reply_sent"));
-          }
+    return editCommentRes;
+  }
 
-          break;
-        }
+  async handleDeleteComment(form: DeleteComment) {
+    const deleteCommentRes = await HttpService.client.deleteComment(form);
+    this.findAndUpdateComment(deleteCommentRes);
+  }
 
-        case UserOperation.SaveComment: {
-          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
-          saveCommentRes(comment_view, this.state.personRes?.comments);
-          this.setState(this.state);
+  async handleDeletePost(form: DeletePost) {
+    const deleteRes = await HttpService.client.deletePost(form);
+    this.findAndUpdatePost(deleteRes);
+  }
 
-          break;
-        }
+  async handleRemovePost(form: RemovePost) {
+    const removeRes = await HttpService.client.removePost(form);
+    this.findAndUpdatePost(removeRes);
+  }
 
-        case UserOperation.EditPost:
-        case UserOperation.DeletePost:
-        case UserOperation.RemovePost:
-        case UserOperation.LockPost:
-        case UserOperation.FeaturePost:
-        case UserOperation.SavePost: {
-          const { post_view } = wsJsonToRes<PostResponse>(msg);
-          editPostFindRes(post_view, this.state.personRes?.posts);
-          this.setState(this.state);
-
-          break;
-        }
+  async handleRemoveComment(form: RemoveComment) {
+    const removeCommentRes = await HttpService.client.removeComment(form);
+    this.findAndUpdateComment(removeCommentRes);
+  }
+
+  async handleSaveComment(form: SaveComment) {
+    const saveCommentRes = await HttpService.client.saveComment(form);
+    this.findAndUpdateComment(saveCommentRes);
+  }
+
+  async handleSavePost(form: SavePost) {
+    const saveRes = await HttpService.client.savePost(form);
+    this.findAndUpdatePost(saveRes);
+  }
+
+  async handleFeaturePost(form: FeaturePost) {
+    const featureRes = await HttpService.client.featurePost(form);
+    this.findAndUpdatePost(featureRes);
+  }
+
+  async handleCommentVote(form: CreateCommentLike) {
+    const voteRes = await HttpService.client.likeComment(form);
+    this.findAndUpdateComment(voteRes);
+  }
+
+  async handlePostVote(form: CreatePostLike) {
+    const voteRes = await HttpService.client.likePost(form);
+    this.findAndUpdatePost(voteRes);
+  }
+
+  async handlePostEdit(form: EditPost) {
+    const res = await HttpService.client.editPost(form);
+    this.findAndUpdatePost(res);
+  }
+
+  async handleCommentReport(form: CreateCommentReport) {
+    const reportRes = await HttpService.client.createCommentReport(form);
+    if (reportRes.state === "success") {
+      toast(i18n.t("report_created"));
+    }
+  }
 
-        case UserOperation.CreatePostLike: {
-          const { post_view } = wsJsonToRes<PostResponse>(msg);
-          createPostLikeFindRes(post_view, this.state.personRes?.posts);
-          this.setState(this.state);
+  async handlePostReport(form: CreatePostReport) {
+    const reportRes = await HttpService.client.createPostReport(form);
+    if (reportRes.state === "success") {
+      toast(i18n.t("report_created"));
+    }
+  }
+
+  async handleLockPost(form: LockPost) {
+    const lockRes = await HttpService.client.lockPost(form);
+    this.findAndUpdatePost(lockRes);
+  }
+
+  async handleDistinguishComment(form: DistinguishComment) {
+    const distinguishRes = await HttpService.client.distinguishComment(form);
+    this.findAndUpdateComment(distinguishRes);
+  }
 
-          break;
+  async handleAddAdmin(form: AddAdmin) {
+    const addAdminRes = await HttpService.client.addAdmin(form);
+
+    if (addAdminRes.state == "success") {
+      this.setState(s => ((s.siteRes.admins = addAdminRes.data.admins), s));
+    }
+  }
+
+  async handleTransferCommunity(form: TransferCommunity) {
+    await HttpService.client.transferCommunity(form);
+    toast(i18n.t("transfer_community"));
+  }
+
+  async handleCommentReplyRead(form: MarkCommentReplyAsRead) {
+    const readRes = await HttpService.client.markCommentReplyAsRead(form);
+    this.findAndUpdateCommentReply(readRes);
+  }
+
+  async handlePersonMentionRead(form: MarkPersonMentionAsRead) {
+    // TODO not sure what to do here. Maybe it is actually optional, because post doesn't need it.
+    await HttpService.client.markPersonMentionAsRead(form);
+  }
+
+  async handleBanFromCommunity(form: BanFromCommunity) {
+    const banRes = await HttpService.client.banFromCommunity(form);
+    this.updateBanFromCommunity(banRes);
+  }
+
+  async handleBanPerson(form: BanPerson) {
+    const banRes = await HttpService.client.banPerson(form);
+    this.updateBan(banRes);
+  }
+
+  updateBanFromCommunity(banRes: RequestState<BanFromCommunityResponse>) {
+    // Maybe not necessary
+    if (banRes.state === "success") {
+      this.setState(s => {
+        if (s.personRes.state == "success") {
+          s.personRes.data.posts
+            .filter(c => c.creator.id === banRes.data.person_view.person.id)
+            .forEach(
+              c => (c.creator_banned_from_community = banRes.data.banned)
+            );
+
+          s.personRes.data.comments
+            .filter(c => c.creator.id === banRes.data.person_view.person.id)
+            .forEach(
+              c => (c.creator_banned_from_community = banRes.data.banned)
+            );
         }
+        return s;
+      });
+    }
+  }
 
-        case UserOperation.BanPerson: {
-          const data = wsJsonToRes<BanPersonResponse>(msg);
-          const res = this.state.personRes;
-          res?.comments
-            .filter(c => c.creator.id === data.person_view.person.id)
-            .forEach(c => (c.creator.banned = data.banned));
-          res?.posts
-            .filter(c => c.creator.id === data.person_view.person.id)
-            .forEach(c => (c.creator.banned = data.banned));
-          const pv = res?.person_view;
-
-          if (pv?.person.id === data.person_view.person.id) {
-            pv.person.banned = data.banned;
-          }
-          this.setState(this.state);
-
-          break;
+  updateBan(banRes: RequestState<BanPersonResponse>) {
+    // Maybe not necessary
+    if (banRes.state == "success") {
+      this.setState(s => {
+        if (s.personRes.state == "success") {
+          s.personRes.data.posts
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(c => (c.creator.banned = banRes.data.banned));
+          s.personRes.data.comments
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(c => (c.creator.banned = banRes.data.banned));
         }
+        return s;
+      });
+    }
+  }
 
-        case UserOperation.BlockPerson: {
-          const data = wsJsonToRes<BlockPersonResponse>(msg);
-          updatePersonBlock(data);
-          this.setPersonBlock();
+  purgeItem(purgeRes: RequestState<PurgeItemResponse>) {
+    if (purgeRes.state == "success") {
+      toast(i18n.t("purge_success"));
+      this.context.router.history.push(`/`);
+    }
+  }
 
-          break;
-        }
+  findAndUpdateComment(res: RequestState<CommentResponse>) {
+    this.setState(s => {
+      if (s.personRes.state == "success" && res.state == "success") {
+        s.personRes.data.comments = editComment(
+          res.data.comment_view,
+          s.personRes.data.comments
+        );
+        s.finished.set(res.data.comment_view.comment.id, true);
+      }
+      return s;
+    });
+  }
 
-        case UserOperation.PurgePerson:
-        case UserOperation.PurgePost:
-        case UserOperation.PurgeComment:
-        case UserOperation.PurgeCommunity: {
-          const { success } = wsJsonToRes<PurgeItemResponse>(msg);
+  createAndUpdateComments(res: RequestState<CommentResponse>) {
+    this.setState(s => {
+      if (s.personRes.state == "success" && res.state == "success") {
+        s.personRes.data.comments.unshift(res.data.comment_view);
+        // Set finished for the parent
+        s.finished.set(
+          getCommentParentId(res.data.comment_view.comment) ?? 0,
+          true
+        );
+      }
+      return s;
+    });
+  }
 
-          if (success) {
-            toast(i18n.t("purge_success"));
-            this.context.router.history.push(`/`);
-          }
-        }
+  findAndUpdateCommentReply(res: RequestState<CommentReplyResponse>) {
+    this.setState(s => {
+      if (s.personRes.state == "success" && res.state == "success") {
+        s.personRes.data.comments = editWith(
+          res.data.comment_reply_view,
+          s.personRes.data.comments
+        );
       }
-    }
+      return s;
+    });
+  }
+
+  findAndUpdatePost(res: RequestState<PostResponse>) {
+    this.setState(s => {
+      if (s.personRes.state == "success" && res.state == "success") {
+        s.personRes.data.posts = editPost(
+          res.data.post_view,
+          s.personRes.data.posts
+        );
+      }
+      return s;
+    });
   }
 }
index b4318e1537dfc674566670095003c8aa06f9c5d1..17b2a02585f8cce401170a24c1c58f70ef6499a0 100644 (file)
@@ -1,27 +1,22 @@
 import { Component, linkEvent } from "inferno";
 import {
+  ApproveRegistrationApplication,
   GetSiteResponse,
   ListRegistrationApplications,
   ListRegistrationApplicationsResponse,
-  RegistrationApplicationResponse,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
+  RegistrationApplicationView,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
 import { InitialFetchRequest } from "../../interfaces";
-import { UserService, WebSocketService } from "../../services";
+import { UserService } from "../../services";
+import { FirstLoadService } from "../../services/FirstLoadService";
+import { HttpService, RequestState } from "../../services/HttpService";
 import {
+  editRegistrationApplication,
   fetchLimit,
-  isBrowser,
-  myAuth,
+  myAuthRequired,
   setIsoData,
   setupTippy,
-  toast,
-  updateRegistrationApplicationRes,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { HtmlTags } from "../common/html-tags";
 import { Spinner } from "../common/icon";
@@ -34,11 +29,11 @@ enum UnreadOrAll {
 }
 
 interface RegistrationApplicationsState {
-  listRegistrationApplicationsResponse?: ListRegistrationApplicationsResponse;
+  appsRes: RequestState<ListRegistrationApplicationsResponse>;
   siteRes: GetSiteResponse;
   unreadOrAll: UnreadOrAll;
   page: number;
-  loading: boolean;
+  isIsomorphic: boolean;
 }
 
 export class RegistrationApplications extends Component<
@@ -46,43 +41,35 @@ export class RegistrationApplications extends Component<
   RegistrationApplicationsState
 > {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
   state: RegistrationApplicationsState = {
+    appsRes: { state: "empty" },
     siteRes: this.isoData.site_res,
     unreadOrAll: UnreadOrAll.Unread,
     page: 1,
-    loading: true,
+    isIsomorphic: false,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
 
     this.handlePageChange = this.handlePageChange.bind(this);
-
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
+    this.handleApproveApplication = this.handleApproveApplication.bind(this);
 
     // Only fetch the data if coming from another route
-    if (this.isoData.path == this.context.router.route.match.url) {
+    if (FirstLoadService.isFirstLoad) {
       this.state = {
         ...this.state,
-        listRegistrationApplicationsResponse: this.isoData
-          .routeData[0] as ListRegistrationApplicationsResponse,
-        loading: false,
+        appsRes: this.isoData.routeData[0],
+        isIsomorphic: true,
       };
-    } else {
-      this.refetch();
     }
   }
 
-  componentDidMount() {
-    setupTippy();
-  }
-
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
+  async componentDidMount() {
+    if (!this.state.isIsomorphic) {
+      await this.refetch();
     }
+    setupTippy();
   }
 
   get documentTitle(): string {
@@ -94,14 +81,17 @@ export class RegistrationApplications extends Component<
       : "";
   }
 
-  render() {
-    return (
-      <div className="container-lg">
-        {this.state.loading ? (
+  renderApps() {
+    switch (this.state.appsRes.state) {
+      case "loading":
+        return (
           <h5>
             <Spinner large />
           </h5>
-        ) : (
+        );
+      case "success": {
+        const apps = this.state.appsRes.data.registration_applications;
+        return (
           <div className="row">
             <div className="col-12">
               <HtmlTags
@@ -110,16 +100,20 @@ export class RegistrationApplications extends Component<
               />
               <h5 className="mb-2">{i18n.t("registration_applications")}</h5>
               {this.selects()}
-              {this.applicationList()}
+              {this.applicationList(apps)}
               <Paginator
                 page={this.state.page}
                 onChange={this.handlePageChange}
               />
             </div>
           </div>
-        )}
-      </div>
-    );
+        );
+      }
+    }
+  }
+
+  render() {
+    return <div className="container-lg">{this.renderApps()}</div>;
   }
 
   unreadOrAllRadios() {
@@ -163,22 +157,20 @@ export class RegistrationApplications extends Component<
     );
   }
 
-  applicationList() {
-    const res = this.state.listRegistrationApplicationsResponse;
+  applicationList(apps: RegistrationApplicationView[]) {
     return (
-      res && (
-        <div>
-          {res.registration_applications.map(ra => (
-            <>
-              <hr />
-              <RegistrationApplication
-                key={ra.registration_application.id}
-                application={ra}
-              />
-            </>
-          ))}
-        </div>
-      )
+      <div>
+        {apps.map(ra => (
+          <>
+            <hr />
+            <RegistrationApplication
+              key={ra.registration_application.id}
+              application={ra}
+              onApproveApplication={this.handleApproveApplication}
+            />
+          </>
+        ))}
+      </div>
     );
   }
 
@@ -192,10 +184,12 @@ export class RegistrationApplications extends Component<
     this.refetch();
   }
 
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
-    const promises: Promise<any>[] = [];
+  static fetchInitialData({
+    auth,
+    client,
+  }: InitialFetchRequest): Promise<any>[] {
+    const promises: Promise<RequestState<any>>[] = [];
 
-    const auth = req.auth;
     if (auth) {
       const form: ListRegistrationApplications = {
         unread_only: true,
@@ -203,54 +197,41 @@ export class RegistrationApplications extends Component<
         limit: fetchLimit,
         auth,
       };
-      promises.push(req.client.listRegistrationApplications(form));
+      promises.push(client.listRegistrationApplications(form));
+    } else {
+      promises.push(Promise.resolve({ state: "empty" }));
     }
 
     return promises;
   }
 
-  refetch() {
+  async refetch() {
     const unread_only = this.state.unreadOrAll == UnreadOrAll.Unread;
-    const auth = myAuth();
-    if (auth) {
-      const form: ListRegistrationApplications = {
+    this.setState({
+      appsRes: { state: "loading" },
+    });
+    this.setState({
+      appsRes: await HttpService.client.listRegistrationApplications({
         unread_only: unread_only,
         page: this.state.page,
         limit: fetchLimit,
-        auth,
-      };
-      WebSocketService.Instance.send(
-        wsClient.listRegistrationApplications(form)
-      );
-    }
+        auth: myAuthRequired(),
+      }),
+    });
   }
 
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      return;
-    } else if (msg.reconnect) {
-      this.refetch();
-    } else if (op == UserOperation.ListRegistrationApplications) {
-      const data = wsJsonToRes<ListRegistrationApplicationsResponse>(msg);
-      this.setState({
-        listRegistrationApplicationsResponse: data,
-        loading: false,
-      });
-      window.scrollTo(0, 0);
-    } else if (op == UserOperation.ApproveRegistrationApplication) {
-      const data = wsJsonToRes<RegistrationApplicationResponse>(msg);
-      updateRegistrationApplicationRes(
-        data.registration_application,
-        this.state.listRegistrationApplicationsResponse
-          ?.registration_applications
-      );
-      const uacs = UserService.Instance.unreadApplicationCountSub;
-      // Minor bug, where if the application switches from deny to approve, the count will still go down
-      uacs.next(uacs.getValue() - 1);
-      this.setState(this.state);
-    }
+  async handleApproveApplication(form: ApproveRegistrationApplication) {
+    const approveRes = await HttpService.client.approveRegistrationApplication(
+      form
+    );
+    this.setState(s => {
+      if (s.appsRes.state == "success" && approveRes.state == "success") {
+        s.appsRes.data.registration_applications = editRegistrationApplication(
+          approveRes.data.registration_application,
+          s.appsRes.data.registration_applications
+        );
+      }
+      return s;
+    });
   }
 }
index 888b2cd2756e93ad9432cdda71c63b77cf328bfa..29daa3ff6c92dbad9d621d8c2ed9be2e6945985c 100644 (file)
@@ -13,27 +13,23 @@ import {
   PostReportView,
   PrivateMessageReportResponse,
   PrivateMessageReportView,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
+  ResolveCommentReport,
+  ResolvePostReport,
+  ResolvePrivateMessageReport,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
 import { InitialFetchRequest } from "../../interfaces";
-import { UserService, WebSocketService } from "../../services";
+import { HttpService, UserService } from "../../services";
+import { FirstLoadService } from "../../services/FirstLoadService";
+import { RequestState } from "../../services/HttpService";
 import {
   amAdmin,
+  editCommentReport,
+  editPostReport,
+  editPrivateMessageReport,
   fetchLimit,
-  isBrowser,
-  myAuth,
+  myAuthRequired,
   setIsoData,
-  setupTippy,
-  toast,
-  updateCommentReportRes,
-  updatePostReportRes,
-  updatePrivateMessageReportRes,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { CommentReport } from "../comment/comment-report";
 import { HtmlTags } from "../common/html-tags";
@@ -68,66 +64,62 @@ type ItemType = {
 };
 
 interface ReportsState {
-  listCommentReportsResponse?: ListCommentReportsResponse;
-  listPostReportsResponse?: ListPostReportsResponse;
-  listPrivateMessageReportsResponse?: ListPrivateMessageReportsResponse;
+  commentReportsRes: RequestState<ListCommentReportsResponse>;
+  postReportsRes: RequestState<ListPostReportsResponse>;
+  messageReportsRes: RequestState<ListPrivateMessageReportsResponse>;
   unreadOrAll: UnreadOrAll;
   messageType: MessageType;
-  combined: ItemType[];
   siteRes: GetSiteResponse;
   page: number;
-  loading: boolean;
+  isIsomorphic: boolean;
 }
 
 export class Reports extends Component<any, ReportsState> {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
   state: ReportsState = {
+    commentReportsRes: { state: "empty" },
+    postReportsRes: { state: "empty" },
+    messageReportsRes: { state: "empty" },
     unreadOrAll: UnreadOrAll.Unread,
     messageType: MessageType.All,
-    combined: [],
     page: 1,
     siteRes: this.isoData.site_res,
-    loading: true,
+    isIsomorphic: false,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
 
     this.handlePageChange = this.handlePageChange.bind(this);
-
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
+    this.handleResolveCommentReport =
+      this.handleResolveCommentReport.bind(this);
+    this.handleResolvePostReport = this.handleResolvePostReport.bind(this);
+    this.handleResolvePrivateMessageReport =
+      this.handleResolvePrivateMessageReport.bind(this);
 
     // Only fetch the data if coming from another route
-    if (this.isoData.path == this.context.router.route.match.url) {
+    if (FirstLoadService.isFirstLoad) {
+      const [commentReportsRes, postReportsRes, messageReportsRes] =
+        this.isoData.routeData;
       this.state = {
         ...this.state,
-        listCommentReportsResponse: this.isoData
-          .routeData[0] as ListCommentReportsResponse,
-        listPostReportsResponse: this.isoData
-          .routeData[1] as ListPostReportsResponse,
+        commentReportsRes,
+        postReportsRes,
+        isIsomorphic: true,
       };
+
       if (amAdmin()) {
         this.state = {
           ...this.state,
-          listPrivateMessageReportsResponse: this.isoData
-            .routeData[2] as ListPrivateMessageReportsResponse,
+          messageReportsRes,
         };
       }
-      this.state = {
-        ...this.state,
-        combined: this.buildCombined(),
-        loading: false,
-      };
-    } else {
-      this.refetch();
     }
   }
 
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
+  async componentDidMount() {
+    if (!this.state.isIsomorphic) {
+      await this.refetch();
     }
   }
 
@@ -143,37 +135,46 @@ export class Reports extends Component<any, ReportsState> {
   render() {
     return (
       <div className="container-lg">
-        {this.state.loading ? (
-          <h5>
-            <Spinner large />
-          </h5>
-        ) : (
-          <div className="row">
-            <div className="col-12">
-              <HtmlTags
-                title={this.documentTitle}
-                path={this.context.router.route.match.url}
-              />
-              <h5 className="mb-2">{i18n.t("reports")}</h5>
-              {this.selects()}
-              {this.state.messageType == MessageType.All && this.all()}
-              {this.state.messageType == MessageType.CommentReport &&
-                this.commentReports()}
-              {this.state.messageType == MessageType.PostReport &&
-                this.postReports()}
-              {this.state.messageType == MessageType.PrivateMessageReport &&
-                this.privateMessageReports()}
-              <Paginator
-                page={this.state.page}
-                onChange={this.handlePageChange}
-              />
-            </div>
+        <div className="row">
+          <div className="col-12">
+            <HtmlTags
+              title={this.documentTitle}
+              path={this.context.router.route.match.url}
+            />
+            <h5 className="mb-2">{i18n.t("reports")}</h5>
+            {this.selects()}
+            {this.section}
+            <Paginator
+              page={this.state.page}
+              onChange={this.handlePageChange}
+            />
           </div>
-        )}
+        </div>
       </div>
     );
   }
 
+  get section() {
+    switch (this.state.messageType) {
+      case MessageType.All: {
+        return this.all();
+      }
+      case MessageType.CommentReport: {
+        return this.commentReports();
+      }
+      case MessageType.PostReport: {
+        return this.postReports();
+      }
+      case MessageType.PrivateMessageReport: {
+        return this.privateMessageReports();
+      }
+
+      default: {
+        return null;
+      }
+    }
+  }
+
   unreadOrAllRadios() {
     return (
       <div className="btn-group btn-group-toggle flex-wrap mb-2">
@@ -309,23 +310,25 @@ export class Reports extends Component<any, ReportsState> {
     };
   }
 
-  buildCombined(): ItemType[] {
-    // let comments: ItemType[] = this.state.listCommentReportsResponse
-    //   .map(r => r.comment_reports)
-    //   .unwrapOr([])
-    //   .map(r => this.commentReportToItemType(r));
+  get buildCombined(): ItemType[] {
+    const commentRes = this.state.commentReportsRes;
     const comments =
-      this.state.listCommentReportsResponse?.comment_reports.map(
-        this.commentReportToItemType
-      ) ?? [];
+      commentRes.state == "success"
+        ? commentRes.data.comment_reports.map(this.commentReportToItemType)
+        : [];
+
+    const postRes = this.state.postReportsRes;
     const posts =
-      this.state.listPostReportsResponse?.post_reports.map(
-        this.postReportToItemType
-      ) ?? [];
+      postRes.state == "success"
+        ? postRes.data.post_reports.map(this.postReportToItemType)
+        : [];
+    const pmRes = this.state.messageReportsRes;
     const privateMessages =
-      this.state.listPrivateMessageReportsResponse?.private_message_reports.map(
-        this.privateMessageReportToItemType
-      ) ?? [];
+      pmRes.state == "success"
+        ? pmRes.data.private_message_reports.map(
+            this.privateMessageReportToItemType
+          )
+        : [];
 
     return [...comments, ...posts, ...privateMessages].sort((a, b) =>
       b.published.localeCompare(a.published)
@@ -336,15 +339,26 @@ export class Reports extends Component<any, ReportsState> {
     switch (i.type_) {
       case MessageEnum.CommentReport:
         return (
-          <CommentReport key={i.id} report={i.view as CommentReportView} />
+          <CommentReport
+            key={i.id}
+            report={i.view as CommentReportView}
+            onResolveReport={this.handleResolveCommentReport}
+          />
         );
       case MessageEnum.PostReport:
-        return <PostReport key={i.id} report={i.view as PostReportView} />;
+        return (
+          <PostReport
+            key={i.id}
+            report={i.view as PostReportView}
+            onResolveReport={this.handleResolvePostReport}
+          />
+        );
       case MessageEnum.PrivateMessageReport:
         return (
           <PrivateMessageReport
             key={i.id}
             report={i.view as PrivateMessageReportView}
+            onResolveReport={this.handleResolvePrivateMessageReport}
           />
         );
       default:
@@ -355,7 +369,7 @@ export class Reports extends Component<any, ReportsState> {
   all() {
     return (
       <div>
-        {this.state.combined.map(i => (
+        {this.buildCombined.map(i => (
           <>
             <hr />
             {this.renderItemType(i)}
@@ -366,79 +380,116 @@ export class Reports extends Component<any, ReportsState> {
   }
 
   commentReports() {
-    const reports = this.state.listCommentReportsResponse?.comment_reports;
-    return (
-      reports && (
-        <div>
-          {reports.map(cr => (
-            <>
-              <hr />
-              <CommentReport key={cr.comment_report.id} report={cr} />
-            </>
-          ))}
-        </div>
-      )
-    );
+    const res = this.state.commentReportsRes;
+    switch (res.state) {
+      case "loading":
+        return (
+          <h5>
+            <Spinner large />
+          </h5>
+        );
+      case "success": {
+        const reports = res.data.comment_reports;
+        return (
+          <div>
+            {reports.map(cr => (
+              <>
+                <hr />
+                <CommentReport
+                  key={cr.comment_report.id}
+                  report={cr}
+                  onResolveReport={this.handleResolveCommentReport}
+                />
+              </>
+            ))}
+          </div>
+        );
+      }
+    }
   }
 
   postReports() {
-    const reports = this.state.listPostReportsResponse?.post_reports;
-    return (
-      reports && (
-        <div>
-          {reports.map(pr => (
-            <>
-              <hr />
-              <PostReport key={pr.post_report.id} report={pr} />
-            </>
-          ))}
-        </div>
-      )
-    );
+    const res = this.state.postReportsRes;
+    switch (res.state) {
+      case "loading":
+        return (
+          <h5>
+            <Spinner large />
+          </h5>
+        );
+      case "success": {
+        const reports = res.data.post_reports;
+        return (
+          <div>
+            {reports.map(pr => (
+              <>
+                <hr />
+                <PostReport
+                  key={pr.post_report.id}
+                  report={pr}
+                  onResolveReport={this.handleResolvePostReport}
+                />
+              </>
+            ))}
+          </div>
+        );
+      }
+    }
   }
 
   privateMessageReports() {
-    const reports =
-      this.state.listPrivateMessageReportsResponse?.private_message_reports;
-    return (
-      reports && (
-        <div>
-          {reports.map(pmr => (
-            <>
-              <hr />
-              <PrivateMessageReport
-                key={pmr.private_message_report.id}
-                report={pmr}
-              />
-            </>
-          ))}
-        </div>
-      )
-    );
+    const res = this.state.messageReportsRes;
+    switch (res.state) {
+      case "loading":
+        return (
+          <h5>
+            <Spinner large />
+          </h5>
+        );
+      case "success": {
+        const reports = res.data.private_message_reports;
+        return (
+          <div>
+            {reports.map(pmr => (
+              <>
+                <hr />
+                <PrivateMessageReport
+                  key={pmr.private_message_report.id}
+                  report={pmr}
+                  onResolveReport={this.handleResolvePrivateMessageReport}
+                />
+              </>
+            ))}
+          </div>
+        );
+      }
+    }
   }
 
-  handlePageChange(page: number) {
+  async handlePageChange(page: number) {
     this.setState({ page });
-    this.refetch();
+    await this.refetch();
   }
 
-  handleUnreadOrAllChange(i: Reports, event: any) {
+  async handleUnreadOrAllChange(i: Reports, event: any) {
     i.setState({ unreadOrAll: Number(event.target.value), page: 1 });
-    i.refetch();
+    await i.refetch();
   }
 
-  handleMessageTypeChange(i: Reports, event: any) {
+  async handleMessageTypeChange(i: Reports, event: any) {
     i.setState({ messageType: Number(event.target.value), page: 1 });
-    i.refetch();
+    await i.refetch();
   }
 
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
-    const promises: Promise<any>[] = [];
+  static fetchInitialData({
+    auth,
+    client,
+  }: InitialFetchRequest): Promise<any>[] {
+    const promises: Promise<RequestState<any>>[] = [];
 
     const unresolved_only = true;
     const page = 1;
     const limit = fetchLimit;
-    const auth = req.auth;
 
     if (auth) {
       const commentReportsForm: ListCommentReports = {
@@ -447,7 +498,7 @@ export class Reports extends Component<any, ReportsState> {
         limit,
         auth,
       };
-      promises.push(req.client.listCommentReports(commentReportsForm));
+      promises.push(client.listCommentReports(commentReportsForm));
 
       const postReportsForm: ListPostReports = {
         unresolved_only,
@@ -455,7 +506,7 @@ export class Reports extends Component<any, ReportsState> {
         limit,
         auth,
       };
-      promises.push(req.client.listPostReports(postReportsForm));
+      promises.push(client.listPostReports(postReportsForm));
 
       if (amAdmin()) {
         const privateMessageReportsForm: ListPrivateMessageReports = {
@@ -465,120 +516,109 @@ export class Reports extends Component<any, ReportsState> {
           auth,
         };
         promises.push(
-          req.client.listPrivateMessageReports(privateMessageReportsForm)
+          client.listPrivateMessageReports(privateMessageReportsForm)
         );
+      } else {
+        promises.push(Promise.resolve({ state: "empty" }));
       }
+    } else {
+      promises.push(
+        Promise.resolve({ state: "empty" }),
+        Promise.resolve({ state: "empty" }),
+        Promise.resolve({ state: "empty" })
+      );
     }
 
     return promises;
   }
 
-  refetch() {
+  async refetch() {
     const unresolved_only = this.state.unreadOrAll == UnreadOrAll.Unread;
     const page = this.state.page;
     const limit = fetchLimit;
-    const auth = myAuth();
-    if (auth) {
-      const commentReportsForm: ListCommentReports = {
-        unresolved_only,
-        page,
-        limit,
-        auth,
-      };
-      WebSocketService.Instance.send(
-        wsClient.listCommentReports(commentReportsForm)
-      );
+    const auth = myAuthRequired();
+
+    this.setState({
+      commentReportsRes: { state: "loading" },
+      postReportsRes: { state: "loading" },
+      messageReportsRes: { state: "loading" },
+    });
+
+    const form:
+      | ListCommentReports
+      | ListPostReports
+      | ListPrivateMessageReports = {
+      unresolved_only,
+      page,
+      limit,
+      auth,
+    };
 
-      const postReportsForm: ListPostReports = {
-        unresolved_only,
-        page,
-        limit,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.listPostReports(postReportsForm));
+    this.setState({
+      commentReportsRes: await HttpService.client.listCommentReports(form),
+      postReportsRes: await HttpService.client.listPostReports(form),
+    });
+
+    if (amAdmin()) {
+      this.setState({
+        messageReportsRes: await HttpService.client.listPrivateMessageReports(
+          form
+        ),
+      });
+    }
+  }
 
-      if (amAdmin()) {
-        const privateMessageReportsForm: ListPrivateMessageReports = {
-          unresolved_only,
-          page,
-          limit,
-          auth,
-        };
-        WebSocketService.Instance.send(
-          wsClient.listPrivateMessageReports(privateMessageReportsForm)
+  async handleResolveCommentReport(form: ResolveCommentReport) {
+    const res = await HttpService.client.resolveCommentReport(form);
+    this.findAndUpdateCommentReport(res);
+  }
+
+  async handleResolvePostReport(form: ResolvePostReport) {
+    const res = await HttpService.client.resolvePostReport(form);
+    this.findAndUpdatePostReport(res);
+  }
+
+  async handleResolvePrivateMessageReport(form: ResolvePrivateMessageReport) {
+    const res = await HttpService.client.resolvePrivateMessageReport(form);
+    this.findAndUpdatePrivateMessageReport(res);
+  }
+
+  findAndUpdateCommentReport(res: RequestState<CommentReportResponse>) {
+    this.setState(s => {
+      if (s.commentReportsRes.state == "success" && res.state == "success") {
+        s.commentReportsRes.data.comment_reports = editCommentReport(
+          res.data.comment_report_view,
+          s.commentReportsRes.data.comment_reports
         );
       }
-    }
+      return s;
+    });
   }
 
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      return;
-    } else if (msg.reconnect) {
-      this.refetch();
-    } else if (op == UserOperation.ListCommentReports) {
-      const data = wsJsonToRes<ListCommentReportsResponse>(msg);
-      this.setState({ listCommentReportsResponse: data });
-      this.setState({ combined: this.buildCombined(), loading: false });
-      // this.sendUnreadCount();
-      window.scrollTo(0, 0);
-      setupTippy();
-    } else if (op == UserOperation.ListPostReports) {
-      const data = wsJsonToRes<ListPostReportsResponse>(msg);
-      this.setState({ listPostReportsResponse: data });
-      this.setState({ combined: this.buildCombined(), loading: false });
-      // this.sendUnreadCount();
-      window.scrollTo(0, 0);
-      setupTippy();
-    } else if (op == UserOperation.ListPrivateMessageReports) {
-      const data = wsJsonToRes<ListPrivateMessageReportsResponse>(msg);
-      this.setState({ listPrivateMessageReportsResponse: data });
-      this.setState({ combined: this.buildCombined(), loading: false });
-      // this.sendUnreadCount();
-      window.scrollTo(0, 0);
-      setupTippy();
-    } else if (op == UserOperation.ResolvePostReport) {
-      const data = wsJsonToRes<PostReportResponse>(msg);
-      updatePostReportRes(
-        data.post_report_view,
-        this.state.listPostReportsResponse?.post_reports
-      );
-      const urcs = UserService.Instance.unreadReportCountSub;
-      if (data.post_report_view.post_report.resolved) {
-        urcs.next(urcs.getValue() - 1);
-      } else {
-        urcs.next(urcs.getValue() + 1);
-      }
-      this.setState(this.state);
-    } else if (op == UserOperation.ResolveCommentReport) {
-      const data = wsJsonToRes<CommentReportResponse>(msg);
-      updateCommentReportRes(
-        data.comment_report_view,
-        this.state.listCommentReportsResponse?.comment_reports
-      );
-      const urcs = UserService.Instance.unreadReportCountSub;
-      if (data.comment_report_view.comment_report.resolved) {
-        urcs.next(urcs.getValue() - 1);
-      } else {
-        urcs.next(urcs.getValue() + 1);
+  findAndUpdatePostReport(res: RequestState<PostReportResponse>) {
+    this.setState(s => {
+      if (s.postReportsRes.state == "success" && res.state == "success") {
+        s.postReportsRes.data.post_reports = editPostReport(
+          res.data.post_report_view,
+          s.postReportsRes.data.post_reports
+        );
       }
-      this.setState(this.state);
-    } else if (op == UserOperation.ResolvePrivateMessageReport) {
-      const data = wsJsonToRes<PrivateMessageReportResponse>(msg);
-      updatePrivateMessageReportRes(
-        data.private_message_report_view,
-        this.state.listPrivateMessageReportsResponse?.private_message_reports
-      );
-      const urcs = UserService.Instance.unreadReportCountSub;
-      if (data.private_message_report_view.private_message_report.resolved) {
-        urcs.next(urcs.getValue() - 1);
-      } else {
-        urcs.next(urcs.getValue() + 1);
+      return s;
+    });
+  }
+
+  findAndUpdatePrivateMessageReport(
+    res: RequestState<PrivateMessageReportResponse>
+  ) {
+    this.setState(s => {
+      if (s.messageReportsRes.state == "success" && res.state == "success") {
+        s.messageReportsRes.data.private_message_reports =
+          editPrivateMessageReport(
+            res.data.private_message_report_view,
+            s.messageReportsRes.data.private_message_reports
+          );
       }
-      this.setState(this.state);
-    }
+      return s;
+    });
   }
 }
index 40878f37fddc4fa93b3d751bc57e5458306247a9..a29f61b008102ba4d60e3ab0d4988bd13e8f34d4 100644 (file)
@@ -1,26 +1,19 @@
 import { NoOptionI18nKeys } from "i18next";
 import { Component, linkEvent } from "inferno";
 import {
-  BlockCommunity,
   BlockCommunityResponse,
-  BlockPerson,
   BlockPersonResponse,
-  ChangePassword,
   CommunityBlockView,
-  DeleteAccount,
+  DeleteAccountResponse,
   GetSiteResponse,
   ListingType,
   LoginResponse,
   PersonBlockView,
-  SaveUserSettings,
   SortType,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n, languages } from "../../i18next";
-import { UserService, WebSocketService } from "../../services";
+import { UserService } from "../../services";
+import { HttpService, RequestState } from "../../services/HttpService";
 import {
   Choice,
   capitalizeFirstLetter,
@@ -34,6 +27,7 @@ import {
   fetchUsers,
   getLanguages,
   myAuth,
+  myAuthRequired,
   personToChoice,
   relTags,
   setIsoData,
@@ -43,8 +37,6 @@ import {
   toast,
   updateCommunityBlock,
   updatePersonBlock,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { HtmlTags } from "../common/html-tags";
 import { Icon, Spinner } from "../common/icon";
@@ -59,6 +51,9 @@ import { CommunityLink } from "../community/community-link";
 import { PersonListing } from "./person-listing";
 
 interface SettingsState {
+  saveRes: RequestState<LoginResponse>;
+  changePasswordRes: RequestState<LoginResponse>;
+  deleteAccountRes: RequestState<DeleteAccountResponse>;
   // TODO redo these forms
   saveUserSettingsForm: {
     show_nsfw?: boolean;
@@ -94,9 +89,6 @@ interface SettingsState {
   communityBlocks: CommunityBlockView[];
   currentTab: string;
   themeList: string[];
-  saveUserSettingsLoading: boolean;
-  changePasswordLoading: boolean;
-  deleteAccountLoading: boolean;
   deleteAccountShowConfirm: boolean;
   siteRes: GetSiteResponse;
   searchCommunityLoading: boolean;
@@ -143,13 +135,12 @@ const Filter = ({
 
 export class Settings extends Component<any, SettingsState> {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
   state: SettingsState = {
+    saveRes: { state: "empty" },
+    deleteAccountRes: { state: "empty" },
+    changePasswordRes: { state: "empty" },
     saveUserSettingsForm: {},
     changePasswordForm: {},
-    saveUserSettingsLoading: false,
-    changePasswordLoading: false,
-    deleteAccountLoading: false,
     deleteAccountShowConfirm: false,
     deleteAccountForm: {},
     personBlocks: [],
@@ -180,8 +171,8 @@ export class Settings extends Component<any, SettingsState> {
     this.userSettings = this.userSettings.bind(this);
     this.blockCards = this.blockCards.bind(this);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
+    this.handleBlockPerson = this.handleBlockPerson.bind(this);
+    this.handleBlockCommunity = this.handleBlockCommunity.bind(this);
 
     const mui = UserService.Instance.myUserInfo;
     if (mui) {
@@ -245,10 +236,6 @@ export class Settings extends Component<any, SettingsState> {
     this.setState({ themeList: await fetchThemeList() });
   }
 
-  componentWillUnmount() {
-    this.subscription?.unsubscribe();
-  }
-
   get documentTitle(): string {
     return i18n.t("settings");
   }
@@ -375,7 +362,7 @@ export class Settings extends Component<any, SettingsState> {
           </div>
           <div className="form-group">
             <button type="submit" className="btn btn-block btn-secondary mr-4">
-              {this.state.changePasswordLoading ? (
+              {this.state.changePasswordRes.state === "loading" ? (
                 <Spinner />
               ) : (
                 capitalizeFirstLetter(i18n.t("save"))
@@ -791,7 +778,7 @@ export class Settings extends Component<any, SettingsState> {
           {this.totpSection()}
           <div className="form-group">
             <button type="submit" className="btn btn-block btn-secondary mr-4">
-              {this.state.saveUserSettingsLoading ? (
+              {this.state.saveRes.state === "loading" ? (
                 <Spinner />
               ) : (
                 capitalizeFirstLetter(i18n.t("save"))
@@ -830,7 +817,7 @@ export class Settings extends Component<any, SettingsState> {
                   disabled={!this.state.deleteAccountForm.password}
                   onClick={linkEvent(this, this.handleDeleteAccount)}
                 >
-                  {this.state.deleteAccountLoading ? (
+                  {this.state.deleteAccountRes.state === "loading" ? (
                     <Spinner />
                   ) : (
                     capitalizeFirstLetter(i18n.t("delete"))
@@ -911,9 +898,7 @@ export class Settings extends Component<any, SettingsState> {
     const searchPersonOptions: Choice[] = [];
 
     if (text.length > 0) {
-      searchPersonOptions.push(
-        ...(await fetchUsers(text)).users.map(personToChoice)
-      );
+      searchPersonOptions.push(...(await fetchUsers(text)).map(personToChoice));
     }
 
     this.setState({
@@ -929,7 +914,7 @@ export class Settings extends Component<any, SettingsState> {
 
     if (text.length > 0) {
       searchCommunityOptions.push(
-        ...(await fetchCommunities(text)).communities.map(communityToChoice)
+        ...(await fetchCommunities(text)).map(communityToChoice)
       );
     }
 
@@ -939,100 +924,107 @@ export class Settings extends Component<any, SettingsState> {
     });
   });
 
-  handleBlockPerson({ value }: Choice) {
-    const auth = myAuth();
-    if (auth && value !== "0") {
-      const blockUserForm: BlockPerson = {
+  async handleBlockPerson({ value }: Choice) {
+    if (value !== "0") {
+      const res = await HttpService.client.blockPerson({
         person_id: Number(value),
         block: true,
-        auth,
-      };
-
-      WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
+        auth: myAuthRequired(),
+      });
+      this.personBlock(res);
     }
   }
 
-  handleUnblockPerson(i: { ctx: Settings; recipientId: number }) {
-    const auth = myAuth();
-    if (auth) {
-      const blockUserForm: BlockPerson = {
-        person_id: i.recipientId,
-        block: false,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
-    }
+  async handleUnblockPerson({
+    ctx,
+    recipientId,
+  }: {
+    ctx: Settings;
+    recipientId: number;
+  }) {
+    const res = await HttpService.client.blockPerson({
+      person_id: recipientId,
+      block: false,
+      auth: myAuthRequired(),
+    });
+    ctx.personBlock(res);
   }
 
-  handleBlockCommunity({ value }: Choice) {
-    const auth = myAuth();
-    if (auth && value !== "0") {
-      const blockCommunityForm: BlockCommunity = {
+  async handleBlockCommunity({ value }: Choice) {
+    if (value !== "0") {
+      const res = await HttpService.client.blockCommunity({
         community_id: Number(value),
         block: true,
-        auth,
-      };
-      WebSocketService.Instance.send(
-        wsClient.blockCommunity(blockCommunityForm)
-      );
+        auth: myAuthRequired(),
+      });
+      this.communityBlock(res);
     }
   }
 
-  handleUnblockCommunity(i: { ctx: Settings; communityId: number }) {
+  async handleUnblockCommunity(i: { ctx: Settings; communityId: number }) {
     const auth = myAuth();
     if (auth) {
-      const blockCommunityForm: BlockCommunity = {
+      const res = await HttpService.client.blockCommunity({
         community_id: i.communityId,
         block: false,
-        auth,
-      };
-      WebSocketService.Instance.send(
-        wsClient.blockCommunity(blockCommunityForm)
-      );
+        auth: myAuthRequired(),
+      });
+      i.ctx.communityBlock(res);
     }
   }
 
   handleShowNsfwChange(i: Settings, event: any) {
-    i.state.saveUserSettingsForm.show_nsfw = event.target.checked;
-    i.setState(i.state);
+    i.setState(
+      s => ((s.saveUserSettingsForm.show_nsfw = event.target.checked), s)
+    );
   }
 
   handleShowAvatarsChange(i: Settings, event: any) {
-    i.state.saveUserSettingsForm.show_avatars = event.target.checked;
     const mui = UserService.Instance.myUserInfo;
     if (mui) {
       mui.local_user_view.local_user.show_avatars = event.target.checked;
     }
-    i.setState(i.state);
+    i.setState(
+      s => ((s.saveUserSettingsForm.show_avatars = event.target.checked), s)
+    );
   }
 
   handleBotAccount(i: Settings, event: any) {
-    i.state.saveUserSettingsForm.bot_account = event.target.checked;
-    i.setState(i.state);
+    i.setState(
+      s => ((s.saveUserSettingsForm.bot_account = event.target.checked), s)
+    );
   }
 
   handleShowBotAccounts(i: Settings, event: any) {
-    i.state.saveUserSettingsForm.show_bot_accounts = event.target.checked;
-    i.setState(i.state);
+    i.setState(
+      s => (
+        (s.saveUserSettingsForm.show_bot_accounts = event.target.checked), s
+      )
+    );
   }
 
   handleReadPosts(i: Settings, event: any) {
-    i.state.saveUserSettingsForm.show_read_posts = event.target.checked;
-    i.setState(i.state);
+    i.setState(
+      s => ((s.saveUserSettingsForm.show_read_posts = event.target.checked), s)
+    );
   }
 
   handleShowNewPostNotifs(i: Settings, event: any) {
-    i.state.saveUserSettingsForm.show_new_post_notifs = event.target.checked;
-    i.setState(i.state);
+    i.setState(
+      s => (
+        (s.saveUserSettingsForm.show_new_post_notifs = event.target.checked), s
+      )
+    );
   }
 
   handleShowScoresChange(i: Settings, event: any) {
-    i.state.saveUserSettingsForm.show_scores = event.target.checked;
     const mui = UserService.Instance.myUserInfo;
     if (mui) {
       mui.local_user_view.local_user.show_scores = event.target.checked;
     }
-    i.setState(i.state);
+    i.setState(
+      s => ((s.saveUserSettingsForm.show_scores = event.target.checked), s)
+    );
   }
 
   handleGenerateTotp(i: Settings, event: any) {
@@ -1041,35 +1033,37 @@ export class Settings extends Component<any, SettingsState> {
     if (checked) {
       toast(i18n.t("two_factor_setup_instructions"));
     }
-    i.state.saveUserSettingsForm.generate_totp_2fa = checked;
-    i.setState(i.state);
+    i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
   }
 
   handleRemoveTotp(i: Settings, event: any) {
     // Coerce true to undefined here, so it won't generate it.
     const checked: boolean | undefined = !event.target.checked && undefined;
-    i.state.saveUserSettingsForm.generate_totp_2fa = checked;
-    i.setState(i.state);
+    i.setState(s => ((s.saveUserSettingsForm.generate_totp_2fa = checked), s));
   }
 
   handleSendNotificationsToEmailChange(i: Settings, event: any) {
-    i.state.saveUserSettingsForm.send_notifications_to_email =
-      event.target.checked;
-    i.setState(i.state);
+    i.setState(
+      s => (
+        (s.saveUserSettingsForm.send_notifications_to_email =
+          event.target.checked),
+        s
+      )
+    );
   }
 
   handleThemeChange(i: Settings, event: any) {
-    i.state.saveUserSettingsForm.theme = event.target.value;
+    i.setState(s => ((s.saveUserSettingsForm.theme = event.target.value), s));
     setTheme(event.target.value, true);
-    i.setState(i.state);
   }
 
   handleInterfaceLangChange(i: Settings, event: any) {
-    i.state.saveUserSettingsForm.interface_language = event.target.value;
+    i.setState(
+      s => ((s.saveUserSettingsForm.interface_language = event.target.value), s)
+    );
     i18n.changeLanguage(
       getLanguages(i.state.saveUserSettingsForm.interface_language).at(0)
     );
-    i.setState(i.state);
   }
 
   handleDiscussionLanguageChange(val: number[]) {
@@ -1089,8 +1083,7 @@ export class Settings extends Component<any, SettingsState> {
   }
 
   handleEmailChange(i: Settings, event: any) {
-    i.state.saveUserSettingsForm.email = event.target.value;
-    i.setState(i.state);
+    i.setState(s => ((s.saveUserSettingsForm.email = event.target.value), s));
   }
 
   handleBioChange(val: string) {
@@ -1114,90 +1107,100 @@ export class Settings extends Component<any, SettingsState> {
   }
 
   handleDisplayNameChange(i: Settings, event: any) {
-    i.state.saveUserSettingsForm.display_name = event.target.value;
-    i.setState(i.state);
+    i.setState(
+      s => ((s.saveUserSettingsForm.display_name = event.target.value), s)
+    );
   }
 
   handleMatrixUserIdChange(i: Settings, event: any) {
-    i.state.saveUserSettingsForm.matrix_user_id = event.target.value;
-    i.setState(i.state);
+    i.setState(
+      s => ((s.saveUserSettingsForm.matrix_user_id = event.target.value), s)
+    );
   }
 
   handleNewPasswordChange(i: Settings, event: any) {
-    i.state.changePasswordForm.new_password = event.target.value;
-    if (i.state.changePasswordForm.new_password == "") {
-      i.state.changePasswordForm.new_password = undefined;
-    }
-    i.setState(i.state);
+    const newPass: string | undefined =
+      event.target.value == "" ? undefined : event.target.value;
+    i.setState(s => ((s.changePasswordForm.new_password = newPass), s));
   }
 
   handleNewPasswordVerifyChange(i: Settings, event: any) {
-    i.state.changePasswordForm.new_password_verify = event.target.value;
-    if (i.state.changePasswordForm.new_password_verify == "") {
-      i.state.changePasswordForm.new_password_verify = undefined;
-    }
-    i.setState(i.state);
+    const newPassVerify: string | undefined =
+      event.target.value == "" ? undefined : event.target.value;
+    i.setState(
+      s => ((s.changePasswordForm.new_password_verify = newPassVerify), s)
+    );
   }
 
   handleOldPasswordChange(i: Settings, event: any) {
-    i.state.changePasswordForm.old_password = event.target.value;
-    if (i.state.changePasswordForm.old_password == "") {
-      i.state.changePasswordForm.old_password = undefined;
-    }
-    i.setState(i.state);
+    const oldPass: string | undefined =
+      event.target.value == "" ? undefined : event.target.value;
+    i.setState(s => ((s.changePasswordForm.old_password = oldPass), s));
   }
 
-  handleSaveSettingsSubmit(i: Settings, event: any) {
+  async handleSaveSettingsSubmit(i: Settings, event: any) {
     event.preventDefault();
-    i.setState({ saveUserSettingsLoading: true });
-    const auth = myAuth();
-    if (auth) {
-      const form: SaveUserSettings = { ...i.state.saveUserSettingsForm, auth };
-      WebSocketService.Instance.send(wsClient.saveUserSettings(form));
+    i.setState({ saveRes: { state: "loading" } });
+
+    const saveRes = await HttpService.client.saveUserSettings({
+      ...i.state.saveUserSettingsForm,
+      auth: myAuthRequired(),
+    });
+    if (saveRes.state === "success") {
+      UserService.Instance.login(saveRes.data);
+      location.reload();
+      toast(i18n.t("saved"));
+      window.scrollTo(0, 0);
     }
+
+    i.setState({ saveRes });
   }
 
-  handleChangePasswordSubmit(i: Settings, event: any) {
+  async handleChangePasswordSubmit(i: Settings, event: any) {
     event.preventDefault();
-    i.setState({ changePasswordLoading: true });
-    const auth = myAuth();
-    const pForm = i.state.changePasswordForm;
-    const new_password = pForm.new_password;
-    const new_password_verify = pForm.new_password_verify;
-    const old_password = pForm.old_password;
-    if (auth && new_password && old_password && new_password_verify) {
-      const form: ChangePassword = {
+    const { new_password, new_password_verify, old_password } =
+      i.state.changePasswordForm;
+
+    if (new_password && old_password && new_password_verify) {
+      i.setState({ changePasswordRes: { state: "loading" } });
+      const changePasswordRes = await HttpService.client.changePassword({
         new_password,
         new_password_verify,
         old_password,
-        auth,
-      };
+        auth: myAuthRequired(),
+      });
+      if (changePasswordRes.state === "success") {
+        UserService.Instance.login(changePasswordRes.data);
+        window.scrollTo(0, 0);
+        toast(i18n.t("password_changed"));
+      }
 
-      WebSocketService.Instance.send(wsClient.changePassword(form));
+      i.setState({ changePasswordRes });
     }
   }
 
-  handleDeleteAccountShowConfirmToggle(i: Settings, event: any) {
-    event.preventDefault();
+  handleDeleteAccountShowConfirmToggle(i: Settings) {
     i.setState({ deleteAccountShowConfirm: !i.state.deleteAccountShowConfirm });
   }
 
   handleDeleteAccountPasswordChange(i: Settings, event: any) {
-    i.state.deleteAccountForm.password = event.target.value;
-    i.setState(i.state);
+    i.setState(s => ((s.deleteAccountForm.password = event.target.value), s));
   }
 
-  handleDeleteAccount(i: Settings, event: any) {
-    event.preventDefault();
-    i.setState({ deleteAccountLoading: true });
-    const auth = myAuth();
+  async handleDeleteAccount(i: Settings) {
     const password = i.state.deleteAccountForm.password;
-    if (auth && password) {
-      const form: DeleteAccount = {
+    if (password) {
+      i.setState({ deleteAccountRes: { state: "loading" } });
+      const deleteAccountRes = await HttpService.client.deleteAccount({
         password,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.deleteAccount(form));
+        auth: myAuthRequired(),
+      });
+      if (deleteAccountRes.state === "success") {
+        UserService.Instance.logout();
+        this.context.router.history.replace("/");
+      }
+
+      i.setState({ deleteAccountRes });
     }
   }
 
@@ -1205,44 +1208,19 @@ export class Settings extends Component<any, SettingsState> {
     i.ctx.setState({ currentTab: i.tab });
   }
 
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      this.setState({
-        saveUserSettingsLoading: false,
-        changePasswordLoading: false,
-        deleteAccountLoading: false,
-      });
-      toast(i18n.t(msg.error), "danger");
-      return;
-    } else if (op == UserOperation.SaveUserSettings) {
-      this.setState({ saveUserSettingsLoading: false });
-      toast(i18n.t("saved"));
-      window.scrollTo(0, 0);
-    } else if (op == UserOperation.ChangePassword) {
-      const data = wsJsonToRes<LoginResponse>(msg);
-      UserService.Instance.login(data);
-      this.setState({ changePasswordLoading: false });
-      window.scrollTo(0, 0);
-      toast(i18n.t("password_changed"));
-    } else if (op == UserOperation.DeleteAccount) {
-      this.setState({
-        deleteAccountLoading: false,
-        deleteAccountShowConfirm: false,
-      });
-      UserService.Instance.logout();
-      window.location.href = "/";
-    } else if (op == UserOperation.BlockPerson) {
-      const data = wsJsonToRes<BlockPersonResponse>(msg);
-      updatePersonBlock(data);
+  personBlock(res: RequestState<BlockPersonResponse>) {
+    if (res.state === "success") {
+      updatePersonBlock(res.data);
       const mui = UserService.Instance.myUserInfo;
       if (mui) {
         this.setState({ personBlocks: mui.person_blocks });
       }
-    } else if (op == UserOperation.BlockCommunity) {
-      const data = wsJsonToRes<BlockCommunityResponse>(msg);
-      updateCommunityBlock(data);
+    }
+  }
+
+  communityBlock(res: RequestState<BlockCommunityResponse>) {
+    if (res.state === "success") {
+      updateCommunityBlock(res.data);
       const mui = UserService.Instance.myUserInfo;
       if (mui) {
         this.setState({ communityBlocks: mui.community_blocks });
index fdc82102cd03c41efb6ef328abf30e456bca0731..da6eb91736174fc50bee5a3f164909fef8336b84 100644 (file)
@@ -1,58 +1,49 @@
 import { Component } from "inferno";
-import {
-  GetSiteResponse,
-  UserOperation,
-  VerifyEmail as VerifyEmailForm,
-  wsJsonToRes,
-  wsUserOp,
-} from "lemmy-js-client";
-import { Subscription } from "rxjs";
+import { GetSiteResponse, VerifyEmailResponse } from "lemmy-js-client";
 import { i18n } from "../../i18next";
-import { WebSocketService } from "../../services";
-import {
-  isBrowser,
-  setIsoData,
-  toast,
-  wsClient,
-  wsSubscribe,
-} from "../../utils";
+import { HttpService, RequestState } from "../../services/HttpService";
+import { setIsoData, toast } from "../../utils";
 import { HtmlTags } from "../common/html-tags";
+import { Spinner } from "../common/icon";
 
 interface State {
-  verifyEmailForm: VerifyEmailForm;
+  verifyRes: RequestState<VerifyEmailResponse>;
   siteRes: GetSiteResponse;
 }
 
 export class VerifyEmail extends Component<any, State> {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
 
   state: State = {
-    verifyEmailForm: {
-      token: this.props.match.params.token,
-    },
+    verifyRes: { state: "empty" },
     siteRes: this.isoData.site_res,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
-
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
   }
 
-  componentDidMount() {
-    WebSocketService.Instance.send(
-      wsClient.verifyEmail(this.state.verifyEmailForm)
-    );
-  }
+  async verify() {
+    this.setState({
+      verifyRes: { state: "loading" },
+    });
 
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
+    this.setState({
+      verifyRes: await HttpService.client.verifyEmail({
+        token: this.props.match.params.token,
+      }),
+    });
+
+    if (this.state.verifyRes.state == "success") {
+      toast(i18n.t("email_verified"));
+      this.props.history.push("/login");
     }
   }
 
+  async componentDidMount() {
+    await this.verify();
+  }
+
   get documentTitle(): string {
     return `${i18n.t("verify_email")} - ${
       this.state.siteRes.site_view.site.name
@@ -69,26 +60,14 @@ export class VerifyEmail extends Component<any, State> {
         <div className="row">
           <div className="col-12 col-lg-6 offset-lg-3 mb-4">
             <h5>{i18n.t("verify_email")}</h5>
+            {this.state.verifyRes.state == "loading" && (
+              <h5>
+                <Spinner large />
+              </h5>
+            )}
           </div>
         </div>
       </div>
     );
   }
-
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      this.setState(this.state);
-      this.props.history.push("/");
-      return;
-    } else if (op == UserOperation.VerifyEmail) {
-      const data = wsJsonToRes(msg);
-      if (data) {
-        toast(i18n.t("email_verified"));
-        this.props.history.push("/login");
-      }
-    }
-  }
 }
index f7cfcf15a652cfa5f4f7c746ad3e87cf980cb813..71fac79aed8668bc9f694df463ee884628eb8419 100644 (file)
@@ -1,18 +1,19 @@
 import { Component } from "inferno";
 import { RouteComponentProps } from "inferno-router/dist/Route";
 import {
+  CreatePost as CreatePostI,
   GetCommunity,
-  GetCommunityResponse,
   GetSiteResponse,
-  PostView,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
+  ListCommunitiesResponse,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
 import { InitialFetchRequest, PostFormParams } from "../../interfaces";
-import { WebSocketService } from "../../services";
+import { FirstLoadService } from "../../services/FirstLoadService";
+import {
+  HttpService,
+  RequestState,
+  WrappedLemmyHttp,
+} from "../../services/HttpService";
 import {
   Choice,
   QueryParams,
@@ -20,12 +21,8 @@ import {
   enableNsfw,
   getIdFromString,
   getQueryParams,
-  isBrowser,
   myAuth,
   setIsoData,
-  toast,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { HtmlTags } from "../common/html-tags";
 import { Spinner } from "../common/icon";
@@ -41,10 +38,16 @@ function getCreatePostQueryParams() {
   });
 }
 
+function fetchCommunitiesForOptions(client: WrappedLemmyHttp) {
+  return client.listCommunities({ limit: 30, sort: "TopMonth", type_: "All" });
+}
+
 interface CreatePostState {
   siteRes: GetSiteResponse;
   loading: boolean;
   selectedCommunityChoice?: Choice;
+  initialCommunitiesRes: RequestState<ListCommunitiesResponse>;
+  isIsomorphic: boolean;
 }
 
 export class CreatePost extends Component<
@@ -52,10 +55,11 @@ export class CreatePost extends Component<
   CreatePostState
 > {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
   state: CreatePostState = {
     siteRes: this.isoData.site_res,
     loading: true,
+    initialCommunitiesRes: { state: "empty" },
+    isIsomorphic: false,
   };
 
   constructor(props: RouteComponentProps<Record<string, never>>, context: any) {
@@ -65,19 +69,14 @@ export class CreatePost extends Component<
     this.handleSelectedCommunityChange =
       this.handleSelectedCommunityChange.bind(this);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
-
     // Only fetch the data if coming from another route
-    if (this.isoData.path === this.context.router.route.match.url) {
-      const communityRes = this.isoData.routeData[0] as
-        | GetCommunityResponse
-        | undefined;
+    if (FirstLoadService.isFirstLoad) {
+      const [communityRes, listCommunitiesRes] = this.isoData.routeData;
 
-      if (communityRes) {
+      if (communityRes?.state === "success") {
         const communityChoice: Choice = {
-          label: communityRes.community_view.community.title,
-          value: communityRes.community_view.community.id.toString(),
+          label: communityRes.data.community_view.community.title,
+          value: communityRes.data.community_view.community.id.toString(),
         };
 
         this.state = {
@@ -89,42 +88,56 @@ export class CreatePost extends Component<
       this.state = {
         ...this.state,
         loading: false,
+        initialCommunitiesRes: listCommunitiesRes,
+        isIsomorphic: true,
       };
-    } else {
-      this.fetchCommunity();
     }
   }
 
-  fetchCommunity() {
+  async fetchCommunity() {
     const { communityId } = getCreatePostQueryParams();
-    const auth = myAuth(false);
+    const auth = myAuth();
 
     if (communityId) {
-      const form: GetCommunity = {
+      const res = await HttpService.client.getCommunity({
         id: communityId,
         auth,
-      };
-
-      WebSocketService.Instance.send(wsClient.getCommunity(form));
+      });
+      if (res.state === "success") {
+        this.setState({
+          selectedCommunityChoice: {
+            label: res.data.community_view.community.name,
+            value: res.data.community_view.community.id.toString(),
+          },
+          loading: false,
+        });
+      }
     }
   }
 
-  componentDidMount(): void {
-    const { communityId } = getCreatePostQueryParams();
+  async componentDidMount() {
+    // TODO test this
+    if (!this.state.isIsomorphic) {
+      const { communityId } = getCreatePostQueryParams();
+
+      const initialCommunitiesRes = await fetchCommunitiesForOptions(
+        HttpService.client
+      );
 
-    if (communityId?.toString() !== this.state.selectedCommunityChoice?.value) {
-      this.fetchCommunity();
-    } else if (!communityId) {
       this.setState({
-        selectedCommunityChoice: undefined,
-        loading: false,
+        initialCommunitiesRes,
       });
-    }
-  }
 
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
+      if (
+        communityId?.toString() !== this.state.selectedCommunityChoice?.value
+      ) {
+        await this.fetchCommunity();
+      } else if (!communityId) {
+        this.setState({
+          selectedCommunityChoice: undefined,
+          loading: false,
+        });
+      }
     }
   }
 
@@ -164,6 +177,11 @@ export class CreatePost extends Component<
                 siteLanguages={this.state.siteRes.discussion_languages}
                 selectedCommunityChoice={selectedCommunityChoice}
                 onSelectCommunity={this.handleSelectedCommunityChange}
+                initialCommunities={
+                  this.state.initialCommunitiesRes.state === "success"
+                    ? this.state.initialCommunitiesRes.data.communities
+                    : []
+                }
               />
             </div>
           </div>
@@ -172,7 +190,7 @@ export class CreatePost extends Component<
     );
   }
 
-  updateUrl({ communityId }: Partial<CreatePostProps>) {
+  async updateUrl({ communityId }: Partial<CreatePostProps>) {
     const { communityId: urlCommunityId } = getCreatePostQueryParams();
 
     const locationState = this.props.history.location.state as
@@ -191,7 +209,7 @@ export class CreatePost extends Component<
 
     history.replaceState(locationState, "", url);
 
-    this.fetchCommunity();
+    await this.fetchCommunity();
   }
 
   handleSelectedCommunityChange(choice: Choice) {
@@ -200,16 +218,23 @@ export class CreatePost extends Component<
     });
   }
 
-  handlePostCreate(post_view: PostView) {
-    this.props.history.replace(`/post/${post_view.post.id}`);
+  async handlePostCreate(form: CreatePostI) {
+    const res = await HttpService.client.createPost(form);
+
+    if (res.state === "success") {
+      const postId = res.data.post_view.post.id;
+      this.props.history.replace(`/post/${postId}`);
+    }
   }
 
   static fetchInitialData({
     client,
     query: { communityId },
     auth,
-  }: InitialFetchRequest<QueryParams<CreatePostProps>>): Promise<any>[] {
-    const promises: Promise<any>[] = [];
+  }: InitialFetchRequest<QueryParams<CreatePostProps>>): Promise<
+    RequestState<any>
+  >[] {
+    const promises: Promise<RequestState<any>>[] = [];
 
     if (communityId) {
       const form: GetCommunity = {
@@ -219,31 +244,11 @@ export class CreatePost extends Component<
 
       promises.push(client.getCommunity(form));
     } else {
-      promises.push(Promise.resolve());
-    }
-
-    return promises;
-  }
-
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      return;
+      promises.push(Promise.resolve({ state: "empty" }));
     }
 
-    if (op === UserOperation.GetCommunity) {
-      const {
-        community_view: {
-          community: { title, id },
-        },
-      } = wsJsonToRes<GetCommunityResponse>(msg);
+    promises.push(fetchCommunitiesForOptions(client));
 
-      this.setState({
-        selectedCommunityChoice: { label: title, value: id.toString() },
-        loading: false,
-      });
-    }
+    return promises;
   }
 }
index 739a9e4417ce7beba9a2abb59efd8d2e4a448c83..3ce96bb000be784b9e5bbf4083cd002a5800c648 100644 (file)
@@ -1,21 +1,18 @@
 import autosize from "autosize";
-import { Component, linkEvent } from "inferno";
+import { Component, InfernoNode, linkEvent } from "inferno";
 import {
+  CommunityView,
   CreatePost,
   EditPost,
+  GetSiteMetadataResponse,
   Language,
-  PostResponse,
   PostView,
-  Search,
   SearchResponse,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
 import { PostFormParams } from "../../interfaces";
-import { UserService, WebSocketService } from "../../services";
+import { UserService } from "../../services";
+import { HttpService, RequestState } from "../../services/HttpService";
 import {
   Choice,
   archiveTodayUrl,
@@ -24,21 +21,18 @@ import {
   debounce,
   fetchCommunities,
   getIdFromString,
-  getSiteMetadata,
   ghostArchiveUrl,
   isImage,
   myAuth,
+  myAuthRequired,
   pictrsDeleteToast,
   relTags,
   setupTippy,
   toast,
   trendingFetchLimit,
-  uploadImage,
   validTitle,
   validURL,
   webArchiveUrl,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { Icon, Spinner } from "../common/icon";
 import { LanguageSelect } from "../common/language-select";
@@ -51,16 +45,18 @@ const MAX_POST_TITLE_LENGTH = 200;
 
 interface PostFormProps {
   post_view?: PostView; // If a post is given, that means this is an edit
+  crossPosts?: PostView[];
   allLanguages: Language[];
   siteLanguages: number[];
   params?: PostFormParams;
-  onCancel?(): any;
-  onCreate?(post: PostView): any;
-  onEdit?(post: PostView): any;
+  onCancel?(): void;
+  onCreate?(form: CreatePost): void;
+  onEdit?(form: EditPost): void;
   enableNsfw?: boolean;
   enableDownvotes?: boolean;
   selectedCommunityChoice?: Choice;
   onSelectCommunity?: (choice: Choice) => void;
+  initialCommunities?: CommunityView[];
 }
 
 interface PostFormState {
@@ -73,25 +69,27 @@ interface PostFormState {
     community_id?: number;
     honeypot?: string;
   };
-  suggestedTitle?: string;
-  suggestedPosts?: PostView[];
-  crossPosts?: PostView[];
   loading: boolean;
+  suggestedPostsRes: RequestState<SearchResponse>;
+  metadataRes: RequestState<GetSiteMetadataResponse>;
   imageLoading: boolean;
   communitySearchLoading: boolean;
   communitySearchOptions: Choice[];
   previewMode: boolean;
+  submitted: boolean;
 }
 
 export class PostForm extends Component<PostFormProps, PostFormState> {
-  private subscription?: Subscription;
   state: PostFormState = {
+    suggestedPostsRes: { state: "empty" },
+    metadataRes: { state: "empty" },
     form: {},
     loading: false,
     imageLoading: false,
     communitySearchLoading: false,
     previewMode: false,
     communitySearchOptions: [],
+    submitted: false,
   };
 
   constructor(props: PostFormProps, context: any) {
@@ -102,39 +100,52 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
     this.handleLanguageChange = this.handleLanguageChange.bind(this);
     this.handleCommunitySelect = this.handleCommunitySelect.bind(this);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
+    const { post_view, selectedCommunityChoice, params } = this.props;
 
     // Means its an edit
-    const pv = this.props.post_view;
-    if (pv) {
+    if (post_view) {
       this.state = {
         ...this.state,
         form: {
-          body: pv.post.body,
-          name: pv.post.name,
-          community_id: pv.community.id,
-          url: pv.post.url,
-          nsfw: pv.post.nsfw,
-          language_id: pv.post.language_id,
+          body: post_view.post.body,
+          name: post_view.post.name,
+          community_id: post_view.community.id,
+          url: post_view.post.url,
+          nsfw: post_view.post.nsfw,
+          language_id: post_view.post.language_id,
         },
       };
-    }
-
-    const selectedCommunityChoice = this.props.selectedCommunityChoice;
-
-    if (selectedCommunityChoice) {
+    } else if (selectedCommunityChoice) {
       this.state = {
         ...this.state,
         form: {
           ...this.state.form,
           community_id: getIdFromString(selectedCommunityChoice.value),
         },
-        communitySearchOptions: [selectedCommunityChoice],
+        communitySearchOptions: [selectedCommunityChoice]
+          .concat(
+            this.props.initialCommunities?.map(
+              ({ community: { id, title } }) => ({
+                label: title,
+                value: id.toString(),
+              })
+            ) ?? []
+          )
+          .filter(option => option.value !== selectedCommunityChoice.value),
+      };
+    } else {
+      this.state = {
+        ...this.state,
+        communitySearchOptions:
+          this.props.initialCommunities?.map(
+            ({ community: { id, title } }) => ({
+              label: title,
+              value: id.toString(),
+            })
+          ) ?? [],
       };
     }
 
-    const params = this.props.params;
     if (params) {
       this.state = {
         ...this.state,
@@ -155,341 +166,385 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
     }
   }
 
-  componentDidUpdate() {
-    if (
-      !this.state.loading &&
-      (this.state.form.name || this.state.form.url || this.state.form.body)
-    ) {
-      window.onbeforeunload = () => true;
-    } else {
-      window.onbeforeunload = null;
+  componentWillReceiveProps(
+    nextProps: Readonly<{ children?: InfernoNode } & PostFormProps>
+  ): void {
+    if (this.props != nextProps) {
+      this.setState(
+        s => (
+          (s.form.community_id = getIdFromString(
+            nextProps.selectedCommunityChoice?.value
+          )),
+          s
+        )
+      );
     }
   }
 
-  componentWillUnmount() {
-    this.subscription?.unsubscribe();
-    /* this.choices && this.choices.destroy(); */
-    window.onbeforeunload = null;
-  }
-
-  static getDerivedStateFromProps(
-    { selectedCommunityChoice }: PostFormProps,
-    { form, ...restState }: PostFormState
-  ) {
-    return {
-      ...restState,
-      form: {
-        ...form,
-        community_id: getIdFromString(selectedCommunityChoice?.value),
-      },
-    };
-  }
-
   render() {
     const firstLang = this.state.form.language_id;
     const selectedLangs = firstLang ? Array.of(firstLang) : undefined;
 
     const url = this.state.form.url;
+
+    // TODO
+    // const promptCheck =
+    // !!this.state.form.name || !!this.state.form.url || !!this.state.form.body;
+    // <Prompt when={promptCheck} message={i18n.t("block_leaving")} />
     return (
-      <div>
+      <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
         <NavigationPrompt
           when={
-            !this.state.loading &&
             !!(
               this.state.form.name ||
               this.state.form.url ||
               this.state.form.body
-            )
+            ) && !this.state.submitted
           }
         />
-        <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
-          <div className="form-group row">
-            <label className="col-sm-2 col-form-label" htmlFor="post-url">
-              {i18n.t("url")}
-            </label>
-            <div className="col-sm-10">
+        <div className="form-group row">
+          <label className="col-sm-2 col-form-label" htmlFor="post-url">
+            {i18n.t("url")}
+          </label>
+          <div className="col-sm-10">
+            <input
+              type="url"
+              id="post-url"
+              className="form-control"
+              value={this.state.form.url}
+              onInput={linkEvent(this, this.handlePostUrlChange)}
+              onPaste={linkEvent(this, this.handleImageUploadPaste)}
+            />
+            {this.renderSuggestedTitleCopy()}
+            <form>
+              <label
+                htmlFor="file-upload"
+                className={`${
+                  UserService.Instance.myUserInfo && "pointer"
+                } d-inline-block float-right text-muted font-weight-bold`}
+                data-tippy-content={i18n.t("upload_image")}
+              >
+                <Icon icon="image" classes="icon-inline" />
+              </label>
               <input
-                type="url"
-                id="post-url"
-                className="form-control"
-                value={this.state.form.url}
-                onInput={linkEvent(this, this.handlePostUrlChange)}
-                onPaste={linkEvent(this, this.handleImageUploadPaste)}
+                id="file-upload"
+                type="file"
+                accept="image/*,video/*"
+                name="file"
+                className="d-none"
+                disabled={!UserService.Instance.myUserInfo}
+                onChange={linkEvent(this, this.handleImageUpload)}
               />
-              {this.state.suggestedTitle && (
-                <div
-                  className="mt-1 text-muted small font-weight-bold pointer"
-                  role="button"
-                  onClick={linkEvent(this, this.copySuggestedTitle)}
+            </form>
+            {url && validURL(url) && (
+              <div>
+                <a
+                  href={`${webArchiveUrl}/save/${encodeURIComponent(url)}`}
+                  className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
+                  rel={relTags}
                 >
-                  {i18n.t("copy_suggested_title", { title: "" })}{" "}
-                  {this.state.suggestedTitle}
-                </div>
-              )}
-              <form>
-                <label
-                  htmlFor="file-upload"
-                  className={`${
-                    UserService.Instance.myUserInfo && "pointer"
-                  } d-inline-block float-right text-muted font-weight-bold`}
-                  data-tippy-content={i18n.t("upload_image")}
+                  archive.org {i18n.t("archive_link")}
+                </a>
+                <a
+                  href={`${ghostArchiveUrl}/search?term=${encodeURIComponent(
+                    url
+                  )}`}
+                  className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
+                  rel={relTags}
                 >
-                  <Icon icon="image" classes="icon-inline" />
-                </label>
-                <input
-                  id="file-upload"
-                  type="file"
-                  accept="image/*,video/*"
-                  name="file"
-                  className="d-none"
-                  disabled={!UserService.Instance.myUserInfo}
-                  onChange={linkEvent(this, this.handleImageUpload)}
-                />
-              </form>
-              {url && validURL(url) && (
-                <div>
-                  <a
-                    href={`${webArchiveUrl}/save/${encodeURIComponent(url)}`}
-                    className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
-                    rel={relTags}
-                  >
-                    archive.org {i18n.t("archive_link")}
-                  </a>
-                  <a
-                    href={`${ghostArchiveUrl}/search?term=${encodeURIComponent(
-                      url
-                    )}`}
-                    className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
-                    rel={relTags}
-                  >
-                    ghostarchive.org {i18n.t("archive_link")}
-                  </a>
-                  <a
-                    href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent(
-                      url
-                    )}`}
-                    className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
-                    rel={relTags}
-                  >
-                    archive.today {i18n.t("archive_link")}
-                  </a>
+                  ghostarchive.org {i18n.t("archive_link")}
+                </a>
+                <a
+                  href={`${archiveTodayUrl}/?run=1&url=${encodeURIComponent(
+                    url
+                  )}`}
+                  className="mr-2 d-inline-block float-right text-muted small font-weight-bold"
+                  rel={relTags}
+                >
+                  archive.today {i18n.t("archive_link")}
+                </a>
+              </div>
+            )}
+            {this.state.imageLoading && <Spinner />}
+            {url && isImage(url) && (
+              <img src={url} className="img-fluid" alt="" />
+            )}
+            {this.props.crossPosts && this.props.crossPosts.length > 0 && (
+              <>
+                <div className="my-1 text-muted small font-weight-bold">
+                  {i18n.t("cross_posts")}
                 </div>
-              )}
-              {this.state.imageLoading && <Spinner />}
-              {url && isImage(url) && (
-                <img src={url} className="img-fluid" alt="" />
-              )}
-              {this.state.crossPosts && this.state.crossPosts.length > 0 && (
-                <>
-                  <div className="my-1 text-muted small font-weight-bold">
-                    {i18n.t("cross_posts")}
-                  </div>
-                  <PostListings
-                    showCommunity
-                    posts={this.state.crossPosts}
-                    enableDownvotes={this.props.enableDownvotes}
-                    enableNsfw={this.props.enableNsfw}
-                    allLanguages={this.props.allLanguages}
-                    siteLanguages={this.props.siteLanguages}
-                  />
-                </>
-              )}
-            </div>
+                <PostListings
+                  showCommunity
+                  posts={this.props.crossPosts}
+                  enableDownvotes={this.props.enableDownvotes}
+                  enableNsfw={this.props.enableNsfw}
+                  allLanguages={this.props.allLanguages}
+                  siteLanguages={this.props.siteLanguages}
+                  viewOnly
+                  // All of these are unused, since its view only
+                  onPostEdit={() => {}}
+                  onPostVote={() => {}}
+                  onPostReport={() => {}}
+                  onBlockPerson={() => {}}
+                  onLockPost={() => {}}
+                  onDeletePost={() => {}}
+                  onRemovePost={() => {}}
+                  onSavePost={() => {}}
+                  onFeaturePost={() => {}}
+                  onPurgePerson={() => {}}
+                  onPurgePost={() => {}}
+                  onBanPersonFromCommunity={() => {}}
+                  onBanPerson={() => {}}
+                  onAddModToCommunity={() => {}}
+                  onAddAdmin={() => {}}
+                  onTransferCommunity={() => {}}
+                />
+              </>
+            )}
+          </div>
+        </div>
+        <div className="form-group row">
+          <label className="col-sm-2 col-form-label" htmlFor="post-title">
+            {i18n.t("title")}
+          </label>
+          <div className="col-sm-10">
+            <textarea
+              value={this.state.form.name}
+              id="post-title"
+              onInput={linkEvent(this, this.handlePostNameChange)}
+              className={`form-control ${
+                !validTitle(this.state.form.name) && "is-invalid"
+              }`}
+              required
+              rows={1}
+              minLength={3}
+              maxLength={MAX_POST_TITLE_LENGTH}
+            />
+            {!validTitle(this.state.form.name) && (
+              <div className="invalid-feedback">
+                {i18n.t("invalid_post_title")}
+              </div>
+            )}
+            {this.renderSuggestedPosts()}
           </div>
+        </div>
+
+        <div className="form-group row">
+          <label className="col-sm-2 col-form-label">{i18n.t("body")}</label>
+          <div className="col-sm-10">
+            <MarkdownTextArea
+              initialContent={this.state.form.body}
+              onContentChange={this.handlePostBodyChange}
+              allLanguages={this.props.allLanguages}
+              siteLanguages={this.props.siteLanguages}
+              hideNavigationWarnings
+            />
+          </div>
+        </div>
+        {!this.props.post_view && (
           <div className="form-group row">
-            <label className="col-sm-2 col-form-label" htmlFor="post-title">
-              {i18n.t("title")}
+            <label className="col-sm-2 col-form-label" htmlFor="post-community">
+              {i18n.t("community")}
             </label>
             <div className="col-sm-10">
-              <textarea
-                value={this.state.form.name}
-                id="post-title"
-                onInput={linkEvent(this, this.handlePostNameChange)}
-                className={`form-control ${
-                  !validTitle(this.state.form.name) && "is-invalid"
-                }`}
-                required
-                rows={1}
-                minLength={3}
-                maxLength={MAX_POST_TITLE_LENGTH}
+              <SearchableSelect
+                id="post-community"
+                value={this.state.form.community_id}
+                options={[
+                  {
+                    label: i18n.t("select_a_community"),
+                    value: "",
+                    disabled: true,
+                  } as Choice,
+                ].concat(this.state.communitySearchOptions)}
+                loading={this.state.communitySearchLoading}
+                onChange={this.handleCommunitySelect}
+                onSearch={this.handleCommunitySearch}
               />
-              {!validTitle(this.state.form.name) && (
-                <div className="invalid-feedback">
-                  {i18n.t("invalid_post_title")}
-                </div>
-              )}
-              {this.state.suggestedPosts &&
-                this.state.suggestedPosts.length > 0 && (
-                  <>
-                    <div className="my-1 text-muted small font-weight-bold">
-                      {i18n.t("related_posts")}
-                    </div>
-                    <PostListings
-                      showCommunity
-                      posts={this.state.suggestedPosts}
-                      enableDownvotes={this.props.enableDownvotes}
-                      enableNsfw={this.props.enableNsfw}
-                      allLanguages={this.props.allLanguages}
-                      siteLanguages={this.props.siteLanguages}
-                    />
-                  </>
-                )}
             </div>
           </div>
-
+        )}
+        {this.props.enableNsfw && (
           <div className="form-group row">
-            <label className="col-sm-2 col-form-label">{i18n.t("body")}</label>
+            <legend className="col-form-label col-sm-2 pt-0">
+              {i18n.t("nsfw")}
+            </legend>
             <div className="col-sm-10">
-              <MarkdownTextArea
-                initialContent={this.state.form.body}
-                onContentChange={this.handlePostBodyChange}
-                allLanguages={this.props.allLanguages}
-                siteLanguages={this.props.siteLanguages}
-              />
-            </div>
-          </div>
-          {!this.props.post_view && (
-            <div className="form-group row">
-              <label
-                className="col-sm-2 col-form-label"
-                htmlFor="post-community"
-              >
-                {i18n.t("community")}
-              </label>
-              <div className="col-sm-10">
-                <SearchableSelect
-                  id="post-community"
-                  value={this.state.form.community_id}
-                  options={[
-                    {
-                      label: i18n.t("select_a_community"),
-                      value: "",
-                      disabled: true,
-                    } as Choice,
-                  ].concat(this.state.communitySearchOptions)}
-                  loading={this.state.communitySearchLoading}
-                  onChange={this.handleCommunitySelect}
-                  onSearch={this.handleCommunitySearch}
+              <div className="form-check">
+                <input
+                  className="form-check-input position-static"
+                  id="post-nsfw"
+                  type="checkbox"
+                  checked={this.state.form.nsfw}
+                  onChange={linkEvent(this, this.handlePostNsfwChange)}
                 />
               </div>
             </div>
-          )}
-          {this.props.enableNsfw && (
-            <div className="form-group row">
-              <legend className="col-form-label col-sm-2 pt-0">
-                {i18n.t("nsfw")}
-              </legend>
-              <div className="col-sm-10">
-                <div className="form-check">
-                  <input
-                    className="form-check-input position-static"
-                    id="post-nsfw"
-                    type="checkbox"
-                    checked={this.state.form.nsfw}
-                    onChange={linkEvent(this, this.handlePostNsfwChange)}
-                  />
-                </div>
-              </div>
-            </div>
-          )}
-          <LanguageSelect
-            allLanguages={this.props.allLanguages}
-            siteLanguages={this.props.siteLanguages}
-            selectedLanguageIds={selectedLangs}
-            multiple={false}
-            onChange={this.handleLanguageChange}
-          />
-          <input
-            tabIndex={-1}
-            autoComplete="false"
-            name="a_password"
-            type="text"
-            className="form-control honeypot"
-            id="register-honey"
-            value={this.state.form.honeypot}
-            onInput={linkEvent(this, this.handleHoneyPotChange)}
-          />
-          <div className="form-group row">
-            <div className="col-sm-10">
+          </div>
+        )}
+        <LanguageSelect
+          allLanguages={this.props.allLanguages}
+          siteLanguages={this.props.siteLanguages}
+          selectedLanguageIds={selectedLangs}
+          multiple={false}
+          onChange={this.handleLanguageChange}
+        />
+        <input
+          tabIndex={-1}
+          autoComplete="false"
+          name="a_password"
+          type="text"
+          className="form-control honeypot"
+          id="register-honey"
+          value={this.state.form.honeypot}
+          onInput={linkEvent(this, this.handleHoneyPotChange)}
+        />
+        <div className="form-group row">
+          <div className="col-sm-10">
+            <button
+              disabled={!this.state.form.community_id || this.state.loading}
+              type="submit"
+              className="btn btn-secondary mr-2"
+            >
+              {this.state.loading ? (
+                <Spinner />
+              ) : this.props.post_view ? (
+                capitalizeFirstLetter(i18n.t("save"))
+              ) : (
+                capitalizeFirstLetter(i18n.t("create"))
+              )}
+            </button>
+            {this.props.post_view && (
               <button
-                disabled={!this.state.form.community_id || this.state.loading}
-                type="submit"
-                className="btn btn-secondary mr-2"
+                type="button"
+                className="btn btn-secondary"
+                onClick={linkEvent(this, this.handleCancel)}
               >
-                {this.state.loading ? (
-                  <Spinner />
-                ) : this.props.post_view ? (
-                  capitalizeFirstLetter(i18n.t("save"))
-                ) : (
-                  capitalizeFirstLetter(i18n.t("create"))
-                )}
+                {i18n.t("cancel")}
               </button>
-              {this.props.post_view && (
-                <button
-                  type="button"
-                  className="btn btn-secondary"
-                  onClick={linkEvent(this, this.handleCancel)}
-                >
-                  {i18n.t("cancel")}
-                </button>
-              )}
-            </div>
+            )}
           </div>
-        </form>
-      </div>
+        </div>
+      </form>
     );
   }
 
-  handlePostSubmit(i: PostForm, event: any) {
-    event.preventDefault();
+  renderSuggestedTitleCopy() {
+    switch (this.state.metadataRes.state) {
+      case "loading":
+        return <Spinner />;
+      case "success": {
+        const suggestedTitle = this.state.metadataRes.data.metadata.title;
+
+        return (
+          suggestedTitle && (
+            <div
+              className="mt-1 text-muted small font-weight-bold pointer"
+              role="button"
+              onClick={linkEvent(
+                { i: this, suggestedTitle },
+                this.copySuggestedTitle
+              )}
+            >
+              {i18n.t("copy_suggested_title", { title: "" })} {suggestedTitle}
+            </div>
+          )
+        );
+      }
+    }
+  }
 
-    i.setState({ loading: true });
+  renderSuggestedPosts() {
+    switch (this.state.suggestedPostsRes.state) {
+      case "loading":
+        return <Spinner />;
+      case "success": {
+        const suggestedPosts = this.state.suggestedPostsRes.data.posts;
+
+        return (
+          suggestedPosts &&
+          suggestedPosts.length > 0 && (
+            <>
+              <div className="my-1 text-muted small font-weight-bold">
+                {i18n.t("related_posts")}
+              </div>
+              <PostListings
+                showCommunity
+                posts={suggestedPosts}
+                enableDownvotes={this.props.enableDownvotes}
+                enableNsfw={this.props.enableNsfw}
+                allLanguages={this.props.allLanguages}
+                siteLanguages={this.props.siteLanguages}
+                viewOnly
+                // All of these are unused, since its view only
+                onPostEdit={() => {}}
+                onPostVote={() => {}}
+                onPostReport={() => {}}
+                onBlockPerson={() => {}}
+                onLockPost={() => {}}
+                onDeletePost={() => {}}
+                onRemovePost={() => {}}
+                onSavePost={() => {}}
+                onFeaturePost={() => {}}
+                onPurgePerson={() => {}}
+                onPurgePost={() => {}}
+                onBanPersonFromCommunity={() => {}}
+                onBanPerson={() => {}}
+                onAddModToCommunity={() => {}}
+                onAddAdmin={() => {}}
+                onTransferCommunity={() => {}}
+              />
+            </>
+          )
+        );
+      }
+    }
+  }
 
+  handlePostSubmit(i: PostForm, event: any) {
+    event.preventDefault();
     // Coerce empty url string to undefined
-    if ((i.state.form.url ?? "blank") === "") {
+    if ((i.state.form.url ?? "") === "") {
       i.setState(s => ((s.form.url = undefined), s));
     }
+    i.setState({ loading: true, submitted: true });
+    const auth = myAuthRequired();
 
     const pForm = i.state.form;
     const pv = i.props.post_view;
-    const auth = myAuth();
-    if (auth) {
-      if (pv) {
-        const form: EditPost = {
-          name: pForm.name,
-          url: pForm.url,
-          body: pForm.body,
-          nsfw: pForm.nsfw,
-          post_id: pv.post.id,
-          language_id: pForm.language_id,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.editPost(form));
-      } else {
-        if (pForm.name && pForm.community_id) {
-          const form: CreatePost = {
-            name: pForm.name,
-            community_id: pForm.community_id,
-            url: pForm.url,
-            body: pForm.body,
-            nsfw: pForm.nsfw,
-            language_id: pForm.language_id,
-            honeypot: pForm.honeypot,
-            auth,
-          };
-          WebSocketService.Instance.send(wsClient.createPost(form));
-        }
-      }
+
+    if (pv) {
+      i.props.onEdit?.({
+        name: pForm.name,
+        url: pForm.url,
+        body: pForm.body,
+        nsfw: pForm.nsfw,
+        post_id: pv.post.id,
+        language_id: pForm.language_id,
+        auth,
+      });
+    } else if (pForm.name && pForm.community_id) {
+      i.props.onCreate?.({
+        name: pForm.name,
+        community_id: pForm.community_id,
+        url: pForm.url,
+        body: pForm.body,
+        nsfw: pForm.nsfw,
+        language_id: pForm.language_id,
+        honeypot: pForm.honeypot,
+        auth,
+      });
     }
   }
 
-  copySuggestedTitle(i: PostForm) {
-    const sTitle = i.state.suggestedTitle;
+  copySuggestedTitle(d: { i: PostForm; suggestedTitle?: string }) {
+    const sTitle = d.suggestedTitle;
     if (sTitle) {
-      i.setState(
+      d.i.setState(
         s => ((s.form.name = sTitle?.substring(0, MAX_POST_TITLE_LENGTH)), s)
       );
-      i.setState({ suggestedTitle: undefined });
+      d.i.setState({ suggestedPostsRes: { state: "empty" } });
       setTimeout(() => {
         const textarea: any = document.getElementById("post-title");
         autosize.update(textarea);
@@ -502,27 +557,13 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
     i.fetchPageTitle();
   }
 
-  fetchPageTitle() {
+  async fetchPageTitle() {
     const url = this.state.form.url;
     if (url && validURL(url)) {
-      const form: Search = {
-        q: url,
-        type_: "Url",
-        sort: "TopAll",
-        listing_type: "All",
-        page: 1,
-        limit: trendingFetchLimit,
-        auth: myAuth(false),
-      };
-
-      WebSocketService.Instance.send(wsClient.search(form));
-
-      // Fetch the page title
-      getSiteMetadata(url).then(d => {
-        this.setState({ suggestedTitle: d.metadata.title });
+      this.setState({ metadataRes: { state: "loading" } });
+      this.setState({
+        metadataRes: await HttpService.client.getSiteMetadata({ url }),
       });
-    } else {
-      this.setState({ suggestedTitle: undefined, crossPosts: undefined });
     }
   }
 
@@ -531,23 +572,22 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
     i.fetchSimilarPosts();
   }
 
-  fetchSimilarPosts() {
+  async fetchSimilarPosts() {
     const q = this.state.form.name;
     if (q && q !== "") {
-      const form: Search = {
-        q,
-        type_: "Posts",
-        sort: "TopAll",
-        listing_type: "All",
-        community_id: this.state.form.community_id,
-        page: 1,
-        limit: trendingFetchLimit,
-        auth: myAuth(false),
-      };
-
-      WebSocketService.Instance.send(wsClient.search(form));
-    } else {
-      this.setState({ suggestedPosts: undefined });
+      this.setState({ suggestedPostsRes: { state: "loading" } });
+      this.setState({
+        suggestedPostsRes: await HttpService.client.search({
+          q,
+          type_: "Posts",
+          sort: "TopAll",
+          listing_type: "All",
+          community_id: this.state.form.community_id,
+          page: 1,
+          limit: trendingFetchLimit,
+          auth: myAuth(),
+        }),
+      });
     }
   }
 
@@ -598,24 +638,22 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
 
     i.setState({ imageLoading: true });
 
-    uploadImage(file)
-      .then(res => {
-        console.log("pictrs upload:");
-        console.log(res);
-        if (res.msg === "ok") {
-          i.state.form.url = res.url;
+    HttpService.client.uploadImage({ image: file }).then(res => {
+      console.log("pictrs upload:");
+      console.log(res);
+      if (res.state === "success") {
+        if (res.data.msg === "ok") {
+          i.state.form.url = res.data.url;
+          pictrsDeleteToast(file.name, res.data.delete_url as string);
           i.setState({ imageLoading: false });
-          pictrsDeleteToast(file.name, res.delete_url as string);
         } else {
-          i.setState({ imageLoading: false });
           toast(JSON.stringify(res), "danger");
         }
-      })
-      .catch(error => {
-        i.setState({ imageLoading: false });
-        console.error(error);
-        toast(error, "danger");
-      });
+      } else if (res.state === "failed") {
+        console.error(res.msg);
+        toast(res.msg, "danger");
+      }
+    });
   }
 
   handleCommunitySearch = debounce(async (text: string) => {
@@ -629,9 +667,7 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
     }
 
     if (text.length > 0) {
-      newOptions.push(
-        ...(await fetchCommunities(text)).communities.map(communityToChoice)
-      );
+      newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
 
       this.setState({
         communitySearchOptions: newOptions,
@@ -648,35 +684,4 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
       this.props.onSelectCommunity(choice);
     }
   }
-
-  parseMessage(msg: any) {
-    const mui = UserService.Instance.myUserInfo;
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      // Errors handled by top level pages
-      // toast(i18n.t(msg.error), "danger");
-      this.setState({ loading: false });
-      return;
-    } else if (op == UserOperation.CreatePost) {
-      const data = wsJsonToRes<PostResponse>(msg);
-      if (data.post_view.creator.id == mui?.local_user_view.person.id) {
-        this.props.onCreate?.(data.post_view);
-      }
-    } else if (op == UserOperation.EditPost) {
-      const data = wsJsonToRes<PostResponse>(msg);
-      if (data.post_view.creator.id == mui?.local_user_view.person.id) {
-        this.setState({ loading: false });
-        this.props.onEdit?.(data.post_view);
-      }
-    } else if (op == UserOperation.Search) {
-      const data = wsJsonToRes<SearchResponse>(msg);
-
-      if (data.type_ == "Posts") {
-        this.setState({ suggestedPosts: data.posts });
-      } else if (data.type_ == "Url") {
-        this.setState({ crossPosts: data.posts });
-      }
-    }
-  }
 }
index 7eb289c81d1704399e24002dba78d64997e1118d..f1f06c5869f123641f84edb82c449da788170702 100644 (file)
@@ -11,6 +11,7 @@ import {
   CreatePostLike,
   CreatePostReport,
   DeletePost,
+  EditPost,
   FeaturePost,
   Language,
   LockPost,
@@ -24,8 +25,8 @@ import {
 } from "lemmy-js-client";
 import { getExternalHost, getHttpBase } from "../../env";
 import { i18n } from "../../i18next";
-import { BanType, PostFormParams, PurgeType } from "../../interfaces";
-import { UserService, WebSocketService } from "../../services";
+import { BanType, PostFormParams, PurgeType, VoteType } from "../../interfaces";
+import { UserService } from "../../services";
 import {
   amAdmin,
   amCommunityCreator,
@@ -43,13 +44,13 @@ import {
   mdNoImages,
   mdToHtml,
   mdToHtmlInline,
-  myAuth,
+  myAuthRequired,
+  newVote,
   numToSI,
   relTags,
   setupTippy,
   share,
   showScores,
-  wsClient,
 } from "../../utils";
 import { Icon, PurgeWarning, Spinner } from "../common/icon";
 import { MomentTime } from "../common/moment-time";
@@ -81,15 +82,25 @@ interface PostListingState {
   showBody: boolean;
   showReportDialog: boolean;
   reportReason?: string;
-  my_vote?: number;
-  score: number;
-  upvotes: number;
-  downvotes: number;
+  upvoteLoading: boolean;
+  downvoteLoading: boolean;
+  reportLoading: boolean;
+  blockLoading: boolean;
+  lockLoading: boolean;
+  deleteLoading: boolean;
+  removeLoading: boolean;
+  saveLoading: boolean;
+  featureCommunityLoading: boolean;
+  featureLocalLoading: boolean;
+  banLoading: boolean;
+  addModLoading: boolean;
+  addAdminLoading: boolean;
+  transferLoading: boolean;
 }
 
 interface PostListingProps {
   post_view: PostView;
-  duplicates?: PostView[];
+  crossPosts?: PostView[];
   moderators?: CommunityModeratorView[];
   admins?: PersonView[];
   allLanguages: Language[];
@@ -100,6 +111,22 @@ interface PostListingProps {
   enableDownvotes?: boolean;
   enableNsfw?: boolean;
   viewOnly?: boolean;
+  onPostEdit(form: EditPost): void;
+  onPostVote(form: CreatePostLike): void;
+  onPostReport(form: CreatePostReport): void;
+  onBlockPerson(form: BlockPerson): void;
+  onLockPost(form: LockPost): void;
+  onDeletePost(form: DeletePost): void;
+  onRemovePost(form: RemovePost): void;
+  onSavePost(form: SavePost): void;
+  onFeaturePost(form: FeaturePost): void;
+  onPurgePerson(form: PurgePerson): void;
+  onPurgePost(form: PurgePost): void;
+  onBanPersonFromCommunity(form: BanFromCommunity): void;
+  onBanPerson(form: BanPerson): void;
+  onAddModToCommunity(form: AddModToCommunity): void;
+  onAddAdmin(form: AddAdmin): void;
+  onTransferCommunity(form: TransferCommunity): void;
 }
 
 export class PostListing extends Component<PostListingProps, PostListingState> {
@@ -108,7 +135,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     showRemoveDialog: false,
     showPurgeDialog: false,
     purgeType: PurgeType.Person,
-    purgeLoading: false,
     showBanDialog: false,
     banType: BanType.Community,
     removeData: false,
@@ -120,35 +146,59 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     showMoreMobile: false,
     showBody: false,
     showReportDialog: false,
-    my_vote: this.props.post_view.my_vote,
-    score: this.props.post_view.counts.score,
-    upvotes: this.props.post_view.counts.upvotes,
-    downvotes: this.props.post_view.counts.downvotes,
+    upvoteLoading: false,
+    downvoteLoading: false,
+    purgeLoading: false,
+    reportLoading: false,
+    blockLoading: false,
+    lockLoading: false,
+    deleteLoading: false,
+    removeLoading: false,
+    saveLoading: false,
+    featureCommunityLoading: false,
+    featureLocalLoading: false,
+    banLoading: false,
+    addModLoading: false,
+    addAdminLoading: false,
+    transferLoading: false,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
 
-    this.handlePostLike = this.handlePostLike.bind(this);
-    this.handlePostDisLike = this.handlePostDisLike.bind(this);
     this.handleEditPost = this.handleEditPost.bind(this);
     this.handleEditCancel = this.handleEditCancel.bind(this);
   }
 
   componentWillReceiveProps(nextProps: PostListingProps) {
-    this.setState({
-      my_vote: nextProps.post_view.my_vote,
-      upvotes: nextProps.post_view.counts.upvotes,
-      downvotes: nextProps.post_view.counts.downvotes,
-      score: nextProps.post_view.counts.score,
-    });
-    if (this.props.post_view.post.id !== nextProps.post_view.post.id) {
-      this.setState({ imageExpanded: false });
+    if (this.props !== nextProps) {
+      this.setState({
+        upvoteLoading: false,
+        downvoteLoading: false,
+        purgeLoading: false,
+        reportLoading: false,
+        blockLoading: false,
+        lockLoading: false,
+        deleteLoading: false,
+        removeLoading: false,
+        saveLoading: false,
+        featureCommunityLoading: false,
+        featureLocalLoading: false,
+        banLoading: false,
+        addModLoading: false,
+        addAdminLoading: false,
+        transferLoading: false,
+        imageExpanded: false,
+      });
     }
   }
 
+  get postView(): PostView {
+    return this.props.post_view;
+  }
+
   render() {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
 
     return (
       <div className="post-listing">
@@ -156,30 +206,29 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
           <>
             {this.listing()}
             {this.state.imageExpanded && !this.props.hideImage && this.img}
-            {post.url && this.showBody && post.embed_title && (
+            {post.url && this.state.showBody && post.embed_title && (
               <MetadataCard post={post} />
             )}
             {this.showBody && this.body()}
           </>
         ) : (
-          <div className="col-12">
-            <PostForm
-              post_view={this.props.post_view}
-              onEdit={this.handleEditPost}
-              onCancel={this.handleEditCancel}
-              enableNsfw={this.props.enableNsfw}
-              enableDownvotes={this.props.enableDownvotes}
-              allLanguages={this.props.allLanguages}
-              siteLanguages={this.props.siteLanguages}
-            />
-          </div>
+          <PostForm
+            post_view={this.postView}
+            crossPosts={this.props.crossPosts}
+            onEdit={this.handleEditPost}
+            onCancel={this.handleEditCancel}
+            enableNsfw={this.props.enableNsfw}
+            enableDownvotes={this.props.enableDownvotes}
+            allLanguages={this.props.allLanguages}
+            siteLanguages={this.props.siteLanguages}
+          />
         )}
       </div>
     );
   }
 
   body() {
-    const body = this.props.post_view.post.body;
+    const body = this.postView.post.body;
     return body ? (
       <div className="col-12 card my-2 p-2">
         {this.state.viewSource ? (
@@ -194,12 +243,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get img() {
-    const src = this.imageSrc;
-    return src ? (
+    return this.imageSrc ? (
       <>
         <div className="offset-sm-3 my-2 d-none d-sm-block">
-          <a href={src} className="d-inline-block">
-            <PictrsImage src={src} />
+          <a href={this.imageSrc} className="d-inline-block">
+            <PictrsImage src={this.imageSrc} />
           </a>
         </div>
         <div className="my-2 d-block d-sm-none">
@@ -207,7 +255,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
             className="d-inline-block"
             onClick={linkEvent(this, this.handleImageExpandClick)}
           >
-            <PictrsImage src={src} />
+            <PictrsImage src={this.imageSrc} />
           </a>
         </div>
       </>
@@ -217,7 +265,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   imgThumb(src: string) {
-    const post_view = this.props.post_view;
+    const post_view = this.postView;
     return (
       <PictrsImage
         src={src}
@@ -229,7 +277,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get imageSrc(): string | undefined {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
     const url = post.url;
     const thumbnail = post.thumbnail_url;
 
@@ -249,7 +297,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   thumbnail() {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
     const url = post.url;
     const thumbnail = post.thumbnail_url;
 
@@ -318,7 +366,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   createdLine() {
-    const post_view = this.props.post_view;
+    const post_view = this.postView;
     const url = post_view.post.url;
     const body = post_view.post.body;
     return (
@@ -401,21 +449,25 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
       <div className={`vote-bar col-1 pr-0 small text-center`}>
         <button
           className={`btn-animate btn btn-link p-0 ${
-            this.state.my_vote === 1 ? "text-info" : "text-muted"
+            this.postView.my_vote == 1 ? "text-info" : "text-muted"
           }`}
-          onClick={this.handlePostLike}
+          onClick={linkEvent(this, this.handleUpvote)}
           data-tippy-content={i18n.t("upvote")}
           aria-label={i18n.t("upvote")}
-          aria-pressed={this.state.my_vote === 1}
+          aria-pressed={this.postView.my_vote === 1}
         >
-          <Icon icon="arrow-up1" classes="upvote" />
+          {this.state.upvoteLoading ? (
+            <Spinner />
+          ) : (
+            <Icon icon="arrow-up1" classes="upvote" />
+          )}
         </button>
         {showScores() ? (
           <div
             className={`unselectable pointer font-weight-bold text-muted px-1`}
             data-tippy-content={this.pointsTippy}
           >
-            {numToSI(this.state.score)}
+            {numToSI(this.postView.counts.score)}
           </div>
         ) : (
           <div className="p-1"></div>
@@ -423,14 +475,18 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
         {this.props.enableDownvotes && (
           <button
             className={`btn-animate btn btn-link p-0 ${
-              this.state.my_vote === -1 ? "text-danger" : "text-muted"
+              this.postView.my_vote == -1 ? "text-danger" : "text-muted"
             }`}
-            onClick={this.handlePostDisLike}
+            onClick={linkEvent(this, this.handleDownvote)}
             data-tippy-content={i18n.t("downvote")}
             aria-label={i18n.t("downvote")}
-            aria-pressed={this.state.my_vote === -1}
+            aria-pressed={this.postView.my_vote === -1}
           >
-            <Icon icon="arrow-down1" classes="downvote" />
+            {this.state.downvoteLoading ? (
+              <Spinner />
+            ) : (
+              <Icon icon="arrow-down1" classes="downvote" />
+            )}
           </button>
         )}
       </div>
@@ -438,7 +494,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get postLink() {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
     return (
       <Link
         className={`d-inline-block ${
@@ -458,7 +514,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   postTitleLine() {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
     const url = post.url;
 
     return (
@@ -550,7 +606,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   duplicatesLine() {
-    const dupes = this.props.duplicates;
+    const dupes = this.props.crossPosts;
     return dupes && dupes.length > 0 ? (
       <ul className="list-inline mb-1 small text-muted">
         <>
@@ -572,7 +628,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   commentsLine(mobile = false) {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
 
     return (
       <div className="d-flex justify-content-start flex-wrap text-muted font-weight-bold mb-1">
@@ -606,7 +662,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   postActions(mobile = false) {
     // Possible enhancement: Priority+ pattern instead of just hard coding which get hidden behind the show more button.
     // Possible enhancement: Make each button a component.
-    const post_view = this.props.post_view;
+    const post_view = this.postView;
     return (
       <>
         {this.saveButton}
@@ -647,7 +703,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get commentsButton() {
-    const post_view = this.props.post_view;
+    const post_view = this.postView;
     return (
       <Link
         className="btn btn-link text-muted py-0 pl-0 text-muted"
@@ -674,7 +730,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get unreadCount(): number | undefined {
-    const pv = this.props.post_view;
+    const pv = this.postView;
     return pv.unread_comments == pv.counts.comments || pv.unread_comments == 0
       ? undefined
       : pv.unread_comments;
@@ -690,37 +746,51 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
         <div>
           <button
             className={`btn-animate btn py-0 px-1 ${
-              this.state.my_vote === 1 ? "text-info" : "text-muted"
+              this.postView.my_vote === 1 ? "text-info" : "text-muted"
             }`}
             {...tippy}
-            onClick={this.handlePostLike}
+            onClick={linkEvent(this, this.handleUpvote)}
             aria-label={i18n.t("upvote")}
-            aria-pressed={this.state.my_vote === 1}
+            aria-pressed={this.postView.my_vote === 1}
           >
-            <Icon icon="arrow-up1" classes="icon-inline small" />
-            {showScores() && (
-              <span className="ml-2">{numToSI(this.state.upvotes)}</span>
+            {this.state.upvoteLoading ? (
+              <Spinner />
+            ) : (
+              <>
+                <Icon icon="arrow-up1" classes="icon-inline small" />
+                {showScores() && (
+                  <span className="ml-2">
+                    {numToSI(this.postView.counts.upvotes)}
+                  </span>
+                )}
+              </>
             )}
           </button>
           {this.props.enableDownvotes && (
             <button
               className={`ml-2 btn-animate btn py-0 px-1 ${
-                this.state.my_vote === -1 ? "text-danger" : "text-muted"
+                this.postView.my_vote === -1 ? "text-danger" : "text-muted"
               }`}
-              onClick={this.handlePostDisLike}
+              onClick={linkEvent(this, this.handleDownvote)}
               {...tippy}
               aria-label={i18n.t("downvote")}
-              aria-pressed={this.state.my_vote === -1}
+              aria-pressed={this.postView.my_vote === -1}
             >
-              <Icon icon="arrow-down1" classes="icon-inline small" />
-              {showScores() && (
-                <span
-                  className={classNames("ml-2", {
-                    invisible: this.state.downvotes === 0,
-                  })}
-                >
-                  {numToSI(this.state.downvotes)}
-                </span>
+              {this.state.downvoteLoading ? (
+                <Spinner />
+              ) : (
+                <>
+                  <Icon icon="arrow-down1" classes="icon-inline small" />
+                  {showScores() && (
+                    <span
+                      className={classNames("ml-2", {
+                        invisible: this.postView.counts.downvotes === 0,
+                      })}
+                    >
+                      {numToSI(this.postView.counts.downvotes)}
+                    </span>
+                  )}
+                </>
               )}
             </button>
           )}
@@ -730,7 +800,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get saveButton() {
-    const saved = this.props.post_view.saved;
+    const saved = this.postView.saved;
     const label = saved ? i18n.t("unsave") : i18n.t("save");
     return (
       <button
@@ -739,11 +809,15 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
         data-tippy-content={label}
         aria-label={label}
       >
-        <Icon
-          icon="star"
-          classes={classNames({ "text-warning": saved })}
-          inline
-        />
+        {this.state.saveLoading ? (
+          <Spinner />
+        ) : (
+          <Icon
+            icon="star"
+            classes={classNames({ "text-warning": saved })}
+            inline
+          />
+        )}
       </button>
     );
   }
@@ -784,11 +858,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     return (
       <button
         className="btn btn-link btn-animate text-muted py-0"
-        onClick={linkEvent(this, this.handleBlockUserClick)}
+        onClick={linkEvent(this, this.handleBlockPersonClick)}
         data-tippy-content={i18n.t("block_user")}
         aria-label={i18n.t("block_user")}
       >
-        <Icon icon="slash" inline />
+        {this.state.blockLoading ? <Spinner /> : <Icon icon="slash" inline />}
       </button>
     );
   }
@@ -807,7 +881,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get deleteButton() {
-    const deleted = this.props.post_view.post.deleted;
+    const deleted = this.postView.post.deleted;
     const label = !deleted ? i18n.t("delete") : i18n.t("restore");
     return (
       <button
@@ -816,11 +890,15 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
         data-tippy-content={label}
         aria-label={label}
       >
-        <Icon
-          icon="trash"
-          classes={classNames({ "text-danger": deleted })}
-          inline
-        />
+        {this.state.deleteLoading ? (
+          <Spinner />
+        ) : (
+          <Icon
+            icon="trash"
+            classes={classNames({ "text-danger": deleted })}
+            inline
+          />
+        )}
       </button>
     );
   }
@@ -856,7 +934,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get lockButton() {
-    const locked = this.props.post_view.post.locked;
+    const locked = this.postView.post.locked;
     const label = locked ? i18n.t("unlock") : i18n.t("lock");
     return (
       <button
@@ -865,22 +943,26 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
         data-tippy-content={label}
         aria-label={label}
       >
-        <Icon
-          icon="lock"
-          classes={classNames({ "text-danger": locked })}
-          inline
-        />
+        {this.state.lockLoading ? (
+          <Spinner />
+        ) : (
+          <Icon
+            icon="lock"
+            classes={classNames({ "text-danger": locked })}
+            inline
+          />
+        )}
       </button>
     );
   }
 
   get featureButton() {
-    const featuredCommunity = this.props.post_view.post.featured_community;
+    const featuredCommunity = this.postView.post.featured_community;
     const labelCommunity = featuredCommunity
       ? i18n.t("unfeature_from_community")
       : i18n.t("feature_in_community");
 
-    const featuredLocal = this.props.post_view.post.featured_local;
+    const featuredLocal = this.postView.post.featured_local;
     const labelLocal = featuredLocal
       ? i18n.t("unfeature_from_local")
       : i18n.t("feature_in_local");
@@ -892,12 +974,18 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
           data-tippy-content={labelCommunity}
           aria-label={labelCommunity}
         >
-          <Icon
-            icon="pin"
-            classes={classNames({ "text-success": featuredCommunity })}
-            inline
-          />{" "}
-          Community
+          {this.state.featureCommunityLoading ? (
+            <Spinner />
+          ) : (
+            <span>
+              <Icon
+                icon="pin"
+                classes={classNames({ "text-success": featuredCommunity })}
+                inline
+              />
+              {i18n.t("community")}
+            </span>
+          )}
         </button>
         {amAdmin() && (
           <button
@@ -906,12 +994,18 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
             data-tippy-content={labelLocal}
             aria-label={labelLocal}
           >
-            <Icon
-              icon="pin"
-              classes={classNames({ "text-success": featuredLocal })}
-              inline
-            />{" "}
-            Local
+            {this.state.featureLocalLoading ? (
+              <Spinner />
+            ) : (
+              <span>
+                <Icon
+                  icon="pin"
+                  classes={classNames({ "text-success": featuredLocal })}
+                  inline
+                />
+                {i18n.t("local")}
+              </span>
+            )}
           </button>
         )}
       </span>
@@ -919,7 +1013,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   get modRemoveButton() {
-    const removed = this.props.post_view.post.removed;
+    const removed = this.postView.post.removed;
     return (
       <button
         className="btn btn-link btn-animate text-muted py-0"
@@ -929,7 +1023,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
         )}
       >
         {/* TODO: Find an icon for this. */}
-        {!removed ? i18n.t("remove") : i18n.t("restore")}
+        {this.state.removeLoading ? (
+          <Spinner />
+        ) : !removed ? (
+          i18n.t("remove")
+        ) : (
+          i18n.t("restore")
+        )}
       </button>
     );
   }
@@ -939,7 +1039,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
    */
   userActionsLine() {
     // TODO: make nicer
-    const post_view = this.props.post_view;
+    const post_view = this.postView;
     return (
       this.state.showAdvanced && (
         <>
@@ -966,7 +1066,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                     )}
                     aria-label={i18n.t("unban")}
                   >
-                    {i18n.t("unban")}
+                    {this.state.banLoading ? <Spinner /> : i18n.t("unban")}
                   </button>
                 ))}
               {!post_view.creator_banned_from_community && (
@@ -979,9 +1079,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                       : i18n.t("appoint_as_mod")
                   }
                 >
-                  {this.creatorIsMod_
-                    ? i18n.t("remove_as_mod")
-                    : i18n.t("appoint_as_mod")}
+                  {this.state.addModLoading ? (
+                    <Spinner />
+                  ) : this.creatorIsMod_ ? (
+                    i18n.t("remove_as_mod")
+                  ) : (
+                    i18n.t("appoint_as_mod")
+                  )}
                 </button>
               )}
             </>
@@ -1014,7 +1118,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                   aria-label={i18n.t("yes")}
                   onClick={linkEvent(this, this.handleTransferCommunity)}
                 >
-                  {i18n.t("yes")}
+                  {this.state.transferLoading ? <Spinner /> : i18n.t("yes")}
                 </button>
                 <button
                   className="btn btn-link btn-animate text-muted py-0 d-inline-block"
@@ -1047,7 +1151,11 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                       onClick={linkEvent(this, this.handleModBanSubmit)}
                       aria-label={i18n.t("unban_from_site")}
                     >
-                      {i18n.t("unban_from_site")}
+                      {this.state.banLoading ? (
+                        <Spinner />
+                      ) : (
+                        i18n.t("unban_from_site")
+                      )}
                     </button>
                   )}
                   <button
@@ -1076,9 +1184,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                       : i18n.t("appoint_as_admin")
                   }
                 >
-                  {this.creatorIsAdmin_
-                    ? i18n.t("remove_as_admin")
-                    : i18n.t("appoint_as_admin")}
+                  {this.state.addAdminLoading ? (
+                    <Spinner />
+                  ) : this.creatorIsAdmin_ ? (
+                    i18n.t("remove_as_admin")
+                  ) : (
+                    i18n.t("appoint_as_admin")
+                  )}
                 </button>
               )}
             </>
@@ -1089,7 +1201,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   removeAndBanDialogs() {
-    const post = this.props.post_view;
+    const post = this.postView;
     const purgeTypeText =
       this.state.purgeType == PurgeType.Post
         ? i18n.t("purge_post")
@@ -1117,7 +1229,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
               className="btn btn-secondary"
               aria-label={i18n.t("remove_post")}
             >
-              {i18n.t("remove_post")}
+              {this.state.removeLoading ? <Spinner /> : i18n.t("remove_post")}
             </button>
           </form>
         )}
@@ -1179,7 +1291,13 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 className="btn btn-secondary"
                 aria-label={i18n.t("ban")}
               >
-                {i18n.t("ban")} {post.creator.name}
+                {this.state.banLoading ? (
+                  <Spinner />
+                ) : (
+                  <span>
+                    {i18n.t("ban")} {post.creator.name}
+                  </span>
+                )}
               </button>
             </div>
           </form>
@@ -1206,7 +1324,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
               className="btn btn-secondary"
               aria-label={i18n.t("create_report")}
             >
-              {i18n.t("create_report")}
+              {this.state.reportLoading ? <Spinner /> : i18n.t("create_report")}
             </button>
           </form>
         )}
@@ -1235,7 +1353,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
                 className="btn btn-secondary"
                 aria-label={purgeTypeText}
               >
-                {purgeTypeText}
+                {this.state.purgeLoading ? <Spinner /> : { purgeTypeText }}
               </button>
             )}
           </form>
@@ -1245,7 +1363,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   mobileThumbnail() {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
     return post.thumbnail_url || (post.url && isImage(post.url)) ? (
       <div className="row">
         <div className={`${this.state.imageExpanded ? "col-12" : "col-8"}`}>
@@ -1262,7 +1380,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   showMobilePreview() {
-    const body = this.props.post_view.post.body;
+    const body = this.postView.post.body;
     return !this.showBody && body ? (
       <div className="md-div mb-1 preview-lines">{body}</div>
     ) : (
@@ -1320,97 +1438,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
 
   private get myPost(): boolean {
     return (
-      this.props.post_view.creator.id ==
+      this.postView.creator.id ==
       UserService.Instance.myUserInfo?.local_user_view.person.id
     );
   }
-
-  handlePostLike(event: any) {
-    event.preventDefault();
-    if (!UserService.Instance.myUserInfo) {
-      this.context.router.history.push(`/login`);
-    }
-
-    const myVote = this.state.my_vote;
-    const newVote = myVote == 1 ? 0 : 1;
-
-    if (myVote == 1) {
-      this.setState({
-        score: this.state.score - 1,
-        upvotes: this.state.upvotes - 1,
-      });
-    } else if (myVote == -1) {
-      this.setState({
-        score: this.state.score + 2,
-        upvotes: this.state.upvotes + 1,
-        downvotes: this.state.downvotes - 1,
-      });
-    } else {
-      this.setState({
-        score: this.state.score + 1,
-        upvotes: this.state.upvotes + 1,
-      });
-    }
-
-    this.setState({ my_vote: newVote });
-
-    const auth = myAuth();
-    if (auth) {
-      const form: CreatePostLike = {
-        post_id: this.props.post_view.post.id,
-        score: newVote,
-        auth,
-      };
-
-      WebSocketService.Instance.send(wsClient.likePost(form));
-      this.setState(this.state);
-    }
-    setupTippy();
-  }
-
-  handlePostDisLike(event: any) {
-    event.preventDefault();
-    if (!UserService.Instance.myUserInfo) {
-      this.context.router.history.push(`/login`);
-    }
-
-    const myVote = this.state.my_vote;
-    const newVote = myVote == -1 ? 0 : -1;
-
-    if (myVote == 1) {
-      this.setState({
-        score: this.state.score - 2,
-        upvotes: this.state.upvotes - 1,
-        downvotes: this.state.downvotes + 1,
-      });
-    } else if (myVote == -1) {
-      this.setState({
-        score: this.state.score + 1,
-        downvotes: this.state.downvotes - 1,
-      });
-    } else {
-      this.setState({
-        score: this.state.score - 1,
-        downvotes: this.state.downvotes + 1,
-      });
-    }
-
-    this.setState({ my_vote: newVote });
-
-    const auth = myAuth();
-    if (auth) {
-      const form: CreatePostLike = {
-        post_id: this.props.post_view.post.id,
-        score: newVote,
-        auth,
-      };
-
-      WebSocketService.Instance.send(wsClient.likePost(form));
-      this.setState(this.state);
-    }
-    setupTippy();
-  }
-
   handleEditClick(i: PostListing) {
     i.setState({ showEdit: true });
   }
@@ -1420,8 +1451,9 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   // The actual editing is done in the receive for post
-  handleEditPost() {
+  handleEditPost(form: EditPost) {
     this.setState({ showEdit: false });
+    this.props.onPostEdit(form);
   }
 
   handleShare(i: PostListing) {
@@ -1443,61 +1475,44 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
 
   handleReportSubmit(i: PostListing, event: any) {
     event.preventDefault();
-    const auth = myAuth();
-    const reason = i.state.reportReason;
-    if (auth && reason) {
-      const form: CreatePostReport = {
-        post_id: i.props.post_view.post.id,
-        reason,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.createPostReport(form));
-
-      i.setState({ showReportDialog: false });
-    }
+    i.setState({ reportLoading: true });
+    i.props.onPostReport({
+      post_id: i.postView.post.id,
+      reason: i.state.reportReason ?? "",
+      auth: myAuthRequired(),
+    });
   }
 
-  handleBlockUserClick(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const blockUserForm: BlockPerson = {
-        person_id: i.props.post_view.creator.id,
-        block: true,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
-    }
+  handleBlockPersonClick(i: PostListing) {
+    i.setState({ blockLoading: true });
+    i.props.onBlockPerson({
+      person_id: i.postView.creator.id,
+      block: true,
+      auth: myAuthRequired(),
+    });
   }
 
   handleDeleteClick(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const deleteForm: DeletePost = {
-        post_id: i.props.post_view.post.id,
-        deleted: !i.props.post_view.post.deleted,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.deletePost(deleteForm));
-    }
+    i.setState({ deleteLoading: true });
+    i.props.onDeletePost({
+      post_id: i.postView.post.id,
+      deleted: !i.postView.post.deleted,
+      auth: myAuthRequired(),
+    });
   }
 
   handleSavePostClick(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const saved =
-        i.props.post_view.saved == undefined ? true : !i.props.post_view.saved;
-      const form: SavePost = {
-        post_id: i.props.post_view.post.id,
-        save: saved,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.savePost(form));
-    }
+    i.setState({ saveLoading: true });
+    i.props.onSavePost({
+      post_id: i.postView.post.id,
+      save: !i.postView.saved,
+      auth: myAuthRequired(),
+    });
   }
 
   get crossPostParams(): PostFormParams {
     const queryParams: PostFormParams = {};
-    const { name, url } = this.props.post_view.post;
+    const { name, url } = this.postView.post;
 
     queryParams.name = name;
 
@@ -1514,7 +1529,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   crossPostBody(): string | undefined {
-    const post = this.props.post_view.post;
+    const post = this.postView.post;
     const body = post.body;
 
     return body
@@ -1546,56 +1561,41 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
 
   handleModRemoveSubmit(i: PostListing, event: any) {
     event.preventDefault();
-
-    const auth = myAuth();
-    if (auth) {
-      const form: RemovePost = {
-        post_id: i.props.post_view.post.id,
-        removed: !i.props.post_view.post.removed,
-        reason: i.state.removeReason,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.removePost(form));
-      i.setState({ showRemoveDialog: false });
-    }
+    i.setState({ removeLoading: true });
+    i.props.onRemovePost({
+      post_id: i.postView.post.id,
+      removed: !i.postView.post.removed,
+      auth: myAuthRequired(),
+    });
   }
 
   handleModLock(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const form: LockPost = {
-        post_id: i.props.post_view.post.id,
-        locked: !i.props.post_view.post.locked,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.lockPost(form));
-    }
+    i.setState({ lockLoading: true });
+    i.props.onLockPost({
+      post_id: i.postView.post.id,
+      locked: !i.postView.post.locked,
+      auth: myAuthRequired(),
+    });
   }
 
   handleModFeaturePostLocal(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const form: FeaturePost = {
-        post_id: i.props.post_view.post.id,
-        feature_type: "Local",
-        featured: !i.props.post_view.post.featured_local,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.featurePost(form));
-    }
+    i.setState({ featureLocalLoading: true });
+    i.props.onFeaturePost({
+      post_id: i.postView.post.id,
+      featured: !i.postView.post.featured_local,
+      feature_type: "Local",
+      auth: myAuthRequired(),
+    });
   }
 
   handleModFeaturePostCommunity(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const form: FeaturePost = {
-        post_id: i.props.post_view.post.id,
-        feature_type: "Community",
-        featured: !i.props.post_view.post.featured_community,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.featurePost(form));
-    }
+    i.setState({ featureCommunityLoading: true });
+    i.props.onFeaturePost({
+      post_id: i.postView.post.id,
+      featured: !i.postView.post.featured_community,
+      feature_type: "Community",
+      auth: myAuthRequired(),
+    });
   }
 
   handleModBanFromCommunityShow(i: PostListing) {
@@ -1636,26 +1636,19 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
 
   handlePurgeSubmit(i: PostListing, event: any) {
     event.preventDefault();
-
-    const auth = myAuth();
-    if (auth) {
-      if (i.state.purgeType == PurgeType.Person) {
-        const form: PurgePerson = {
-          person_id: i.props.post_view.creator.id,
-          reason: i.state.purgeReason,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.purgePerson(form));
-      } else if (i.state.purgeType == PurgeType.Post) {
-        const form: PurgePost = {
-          post_id: i.props.post_view.post.id,
-          reason: i.state.purgeReason,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.purgePost(form));
-      }
-
-      i.setState({ purgeLoading: true });
+    i.setState({ purgeLoading: true });
+    if (i.state.purgeType == PurgeType.Person) {
+      i.props.onPurgePerson({
+        person_id: i.postView.creator.id,
+        reason: i.state.purgeReason,
+        auth: myAuthRequired(),
+      });
+    } else if (i.state.purgeType == PurgeType.Post) {
+      i.props.onPurgePost({
+        post_id: i.postView.post.id,
+        reason: i.state.purgeReason,
+        auth: myAuthRequired(),
+      });
     }
   }
 
@@ -1667,88 +1660,70 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     i.setState({ banExpireDays: event.target.value });
   }
 
-  handleModBanFromCommunitySubmit(i: PostListing) {
+  handleModBanFromCommunitySubmit(i: PostListing, event: any) {
     i.setState({ banType: BanType.Community });
-    i.handleModBanBothSubmit(i);
+    i.handleModBanBothSubmit(i, event);
   }
 
-  handleModBanSubmit(i: PostListing) {
+  handleModBanSubmit(i: PostListing, event: any) {
     i.setState({ banType: BanType.Site });
-    i.handleModBanBothSubmit(i);
-  }
-
-  handleModBanBothSubmit(i: PostListing, event?: any) {
-    if (event) event.preventDefault();
-    const auth = myAuth();
-    if (auth) {
-      const ban = !i.props.post_view.creator_banned_from_community;
-      const person_id = i.props.post_view.creator.id;
-      const remove_data = i.state.removeData;
-      const reason = i.state.banReason;
-      const expires = futureDaysToUnixTime(i.state.banExpireDays);
-
-      if (i.state.banType == BanType.Community) {
-        // If its an unban, restore all their data
-        if (ban == false) {
-          i.setState({ removeData: false });
-        }
-
-        const form: BanFromCommunity = {
-          person_id,
-          community_id: i.props.post_view.community.id,
-          ban,
-          remove_data,
-          reason,
-          expires,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.banFromCommunity(form));
-      } else {
-        // If its an unban, restore all their data
-        const ban = !i.props.post_view.creator.banned;
-        if (ban == false) {
-          i.setState({ removeData: false });
-        }
-        const form: BanPerson = {
-          person_id,
-          ban,
-          remove_data,
-          reason,
-          expires,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.banPerson(form));
-      }
+    i.handleModBanBothSubmit(i, event);
+  }
+
+  handleModBanBothSubmit(i: PostListing, event: any) {
+    event.preventDefault();
+    i.setState({ banLoading: true });
 
-      i.setState({ showBanDialog: false });
+    const ban = !i.props.post_view.creator_banned_from_community;
+    // If its an unban, restore all their data
+    if (ban == false) {
+      i.setState({ removeData: false });
+    }
+    const person_id = i.props.post_view.creator.id;
+    const remove_data = i.state.removeData;
+    const reason = i.state.banReason;
+    const expires = futureDaysToUnixTime(i.state.banExpireDays);
+
+    if (i.state.banType == BanType.Community) {
+      const community_id = i.postView.community.id;
+      i.props.onBanPersonFromCommunity({
+        community_id,
+        person_id,
+        ban,
+        remove_data,
+        reason,
+        expires,
+        auth: myAuthRequired(),
+      });
+    } else {
+      i.props.onBanPerson({
+        person_id,
+        ban,
+        remove_data,
+        reason,
+        expires,
+        auth: myAuthRequired(),
+      });
     }
   }
 
   handleAddModToCommunity(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const form: AddModToCommunity = {
-        person_id: i.props.post_view.creator.id,
-        community_id: i.props.post_view.community.id,
-        added: !i.creatorIsMod_,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.addModToCommunity(form));
-      i.setState(i.state);
-    }
+    i.setState({ addModLoading: true });
+    i.props.onAddModToCommunity({
+      community_id: i.postView.community.id,
+      person_id: i.postView.creator.id,
+      added: !i.creatorIsMod_,
+      auth: myAuthRequired(),
+    });
   }
 
   handleAddAdmin(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const form: AddAdmin = {
-        person_id: i.props.post_view.creator.id,
-        added: !i.creatorIsAdmin_,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.addAdmin(form));
-      i.setState(i.state);
-    }
+    i.setState({ addAdminLoading: true });
+    i.props.onAddAdmin({
+      person_id: i.postView.creator.id,
+      added: !i.creatorIsAdmin_,
+      auth: myAuthRequired(),
+    });
   }
 
   handleShowConfirmTransferCommunity(i: PostListing) {
@@ -1760,16 +1735,12 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   handleTransferCommunity(i: PostListing) {
-    const auth = myAuth();
-    if (auth) {
-      const form: TransferCommunity = {
-        community_id: i.props.post_view.community.id,
-        person_id: i.props.post_view.creator.id,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.transferCommunity(form));
-      i.setState({ showConfirmTransferCommunity: false });
-    }
+    i.setState({ transferLoading: true });
+    i.props.onTransferCommunity({
+      community_id: i.postView.community.id,
+      person_id: i.postView.creator.id,
+      auth: myAuthRequired(),
+    });
   }
 
   handleShowConfirmTransferSite(i: PostListing) {
@@ -1808,20 +1779,38 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     setupTippy();
   }
 
+  handleUpvote(i: PostListing) {
+    i.setState({ upvoteLoading: true });
+    i.props.onPostVote({
+      post_id: i.postView.post.id,
+      score: newVote(VoteType.Upvote, i.props.post_view.my_vote),
+      auth: myAuthRequired(),
+    });
+  }
+
+  handleDownvote(i: PostListing) {
+    i.setState({ downvoteLoading: true });
+    i.props.onPostVote({
+      post_id: i.postView.post.id,
+      score: newVote(VoteType.Downvote, i.props.post_view.my_vote),
+      auth: myAuthRequired(),
+    });
+  }
+
   get pointsTippy(): string {
     const points = i18n.t("number_of_points", {
-      count: Number(this.state.score),
-      formattedCount: Number(this.state.score),
+      count: Number(this.postView.counts.score),
+      formattedCount: Number(this.postView.counts.score),
     });
 
     const upvotes = i18n.t("number_of_upvotes", {
-      count: Number(this.state.upvotes),
-      formattedCount: Number(this.state.upvotes),
+      count: Number(this.postView.counts.upvotes),
+      formattedCount: Number(this.postView.counts.upvotes),
     });
 
     const downvotes = i18n.t("number_of_downvotes", {
-      count: Number(this.state.downvotes),
-      formattedCount: Number(this.state.downvotes),
+      count: Number(this.postView.counts.downvotes),
+      formattedCount: Number(this.postView.counts.downvotes),
     });
 
     return `${points} â€¢ ${upvotes} â€¢ ${downvotes}`;
@@ -1829,7 +1818,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
 
   get canModOnSelf_(): boolean {
     return canMod(
-      this.props.post_view.creator.id,
+      this.postView.creator.id,
       this.props.moderators,
       this.props.admins,
       undefined,
@@ -1839,21 +1828,21 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
 
   get canMod_(): boolean {
     return canMod(
-      this.props.post_view.creator.id,
+      this.postView.creator.id,
       this.props.moderators,
       this.props.admins
     );
   }
 
   get canAdmin_(): boolean {
-    return canAdmin(this.props.post_view.creator.id, this.props.admins);
+    return canAdmin(this.postView.creator.id, this.props.admins);
   }
 
   get creatorIsMod_(): boolean {
-    return isMod(this.props.post_view.creator.id, this.props.moderators);
+    return isMod(this.postView.creator.id, this.props.moderators);
   }
 
   get creatorIsAdmin_(): boolean {
-    return isAdmin(this.props.post_view.creator.id, this.props.admins);
+    return isAdmin(this.postView.creator.id, this.props.admins);
   }
 }
index 3e82c89583a2baebae03ddf639b0d53516d46c12..098a015d1beecf9cdfcf0f25e2eed27092955450 100644 (file)
@@ -1,7 +1,26 @@
 import { Component } from "inferno";
 import { T } from "inferno-i18next-dess";
 import { Link } from "inferno-router";
-import { Language, PostView } from "lemmy-js-client";
+import {
+  AddAdmin,
+  AddModToCommunity,
+  BanFromCommunity,
+  BanPerson,
+  BlockPerson,
+  CreatePostLike,
+  CreatePostReport,
+  DeletePost,
+  EditPost,
+  FeaturePost,
+  Language,
+  LockPost,
+  PostView,
+  PurgePerson,
+  PurgePost,
+  RemovePost,
+  SavePost,
+  TransferCommunity,
+} from "lemmy-js-client";
 import { i18n } from "../../i18next";
 import { PostListing } from "./post-listing";
 
@@ -13,6 +32,23 @@ interface PostListingsProps {
   removeDuplicates?: boolean;
   enableDownvotes?: boolean;
   enableNsfw?: boolean;
+  viewOnly?: boolean;
+  onPostEdit(form: EditPost): void;
+  onPostVote(form: CreatePostLike): void;
+  onPostReport(form: CreatePostReport): void;
+  onBlockPerson(form: BlockPerson): void;
+  onLockPost(form: LockPost): void;
+  onDeletePost(form: DeletePost): void;
+  onRemovePost(form: RemovePost): void;
+  onSavePost(form: SavePost): void;
+  onFeaturePost(form: FeaturePost): void;
+  onPurgePerson(form: PurgePerson): void;
+  onPurgePost(form: PurgePost): void;
+  onBanPersonFromCommunity(form: BanFromCommunity): void;
+  onBanPerson(form: BanPerson): void;
+  onAddModToCommunity(form: AddModToCommunity): void;
+  onAddAdmin(form: AddAdmin): void;
+  onTransferCommunity(form: TransferCommunity): void;
 }
 
 export class PostListings extends Component<PostListingsProps, any> {
@@ -36,12 +72,29 @@ export class PostListings extends Component<PostListingsProps, any> {
             <>
               <PostListing
                 post_view={post_view}
-                duplicates={this.duplicatesMap.get(post_view.post.id)}
+                crossPosts={this.duplicatesMap.get(post_view.post.id)}
                 showCommunity={this.props.showCommunity}
                 enableDownvotes={this.props.enableDownvotes}
                 enableNsfw={this.props.enableNsfw}
+                viewOnly={this.props.viewOnly}
                 allLanguages={this.props.allLanguages}
                 siteLanguages={this.props.siteLanguages}
+                onPostEdit={this.props.onPostEdit}
+                onPostVote={this.props.onPostVote}
+                onPostReport={this.props.onPostReport}
+                onBlockPerson={this.props.onBlockPerson}
+                onLockPost={this.props.onLockPost}
+                onDeletePost={this.props.onDeletePost}
+                onRemovePost={this.props.onRemovePost}
+                onSavePost={this.props.onSavePost}
+                onFeaturePost={this.props.onFeaturePost}
+                onPurgePerson={this.props.onPurgePerson}
+                onPurgePost={this.props.onPurgePost}
+                onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
+                onBanPerson={this.props.onBanPerson}
+                onAddModToCommunity={this.props.onAddModToCommunity}
+                onAddAdmin={this.props.onAddAdmin}
+                onTransferCommunity={this.props.onTransferCommunity}
               />
               <hr className="my-3" />
             </>
@@ -62,7 +115,7 @@ export class PostListings extends Component<PostListingsProps, any> {
 
   removeDuplicates(): PostView[] {
     // Must use a spread to clone the props, because splice will fail below otherwise.
-    const posts = [...this.props.posts];
+    const posts = [...this.props.posts].filter(empty => empty);
 
     // A map from post url to list of posts (dupes)
     const urlMap = new Map<string, PostView[]>();
index 8ad7b8e437eaeed35fd9979f8f354c2334e96139..7854c0ce1bdc90e034d7bb5c257daba7c6407a75 100644 (file)
@@ -1,22 +1,38 @@
-import { Component, linkEvent } from "inferno";
+import { Component, InfernoNode, linkEvent } from "inferno";
 import { T } from "inferno-i18next-dess";
 import { PostReportView, PostView, ResolvePostReport } from "lemmy-js-client";
 import { i18n } from "../../i18next";
-import { WebSocketService } from "../../services";
-import { myAuth, wsClient } from "../../utils";
-import { Icon } from "../common/icon";
+import { myAuthRequired } from "../../utils";
+import { Icon, Spinner } from "../common/icon";
 import { PersonListing } from "../person/person-listing";
 import { PostListing } from "./post-listing";
 
 interface PostReportProps {
   report: PostReportView;
+  onResolveReport(form: ResolvePostReport): void;
 }
 
-export class PostReport extends Component<PostReportProps, any> {
+interface PostReportState {
+  loading: boolean;
+}
+
+export class PostReport extends Component<PostReportProps, PostReportState> {
+  state: PostReportState = {
+    loading: false,
+  };
+
   constructor(props: any, context: any) {
     super(props, context);
   }
 
+  componentWillReceiveProps(
+    nextProps: Readonly<{ children?: InfernoNode } & PostReportProps>
+  ): void {
+    if (this.props != nextProps) {
+      this.setState({ loading: false });
+    }
+  }
+
   render() {
     const r = this.props.report;
     const resolver = r.resolver;
@@ -54,6 +70,23 @@ export class PostReport extends Component<PostReportProps, any> {
           allLanguages={[]}
           siteLanguages={[]}
           hideImage
+          // All of these are unused, since its view only
+          onPostEdit={() => {}}
+          onPostVote={() => {}}
+          onPostReport={() => {}}
+          onBlockPerson={() => {}}
+          onLockPost={() => {}}
+          onDeletePost={() => {}}
+          onRemovePost={() => {}}
+          onSavePost={() => {}}
+          onFeaturePost={() => {}}
+          onPurgePerson={() => {}}
+          onPurgePost={() => {}}
+          onBanPersonFromCommunity={() => {}}
+          onBanPerson={() => {}}
+          onAddModToCommunity={() => {}}
+          onAddAdmin={() => {}}
+          onTransferCommunity={() => {}}
         />
         <div>
           {i18n.t("reporter")}: <PersonListing person={r.creator} />
@@ -82,26 +115,27 @@ export class PostReport extends Component<PostReportProps, any> {
           data-tippy-content={tippyContent}
           aria-label={tippyContent}
         >
-          <Icon
-            icon="check"
-            classes={`icon-inline ${
-              r.post_report.resolved ? "text-success" : "text-danger"
-            }`}
-          />
+          {this.state.loading ? (
+            <Spinner />
+          ) : (
+            <Icon
+              icon="check"
+              classes={`icon-inline ${
+                r.post_report.resolved ? "text-success" : "text-danger"
+              }`}
+            />
+          )}
         </button>
       </div>
     );
   }
 
   handleResolveReport(i: PostReport) {
-    const auth = myAuth();
-    if (auth) {
-      const form: ResolvePostReport = {
-        report_id: i.props.report.post_report.id,
-        resolved: !i.props.report.post_report.resolved,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.resolvePostReport(form));
-    }
+    i.setState({ loading: true });
+    i.props.onResolveReport({
+      report_id: i.props.report.post_report.id,
+      resolved: !i.props.report.post_report.resolved,
+      auth: myAuthRequired(),
+    });
   }
 }
index e391832342b3b0b8efea1fe594affd6b72963435..9c68532bee5122c816ae12d4a48f12375b7c265e 100644 (file)
@@ -1,67 +1,88 @@
 import autosize from "autosize";
 import { Component, createRef, linkEvent, RefObject } from "inferno";
 import {
-  AddAdminResponse,
+  AddAdmin,
+  AddModToCommunity,
   AddModToCommunityResponse,
+  BanFromCommunity,
   BanFromCommunityResponse,
+  BanPerson,
   BanPersonResponse,
-  BlockPersonResponse,
-  CommentReportResponse,
+  BlockCommunity,
+  BlockPerson,
+  CommentId,
+  CommentReplyResponse,
   CommentResponse,
   CommentSortType,
   CommunityResponse,
+  CreateComment,
+  CreateCommentLike,
+  CreateCommentReport,
+  CreatePostLike,
+  CreatePostReport,
+  DeleteComment,
+  DeleteCommunity,
+  DeletePost,
+  DistinguishComment,
+  EditComment,
+  EditCommunity,
+  EditPost,
+  FeaturePost,
+  FollowCommunity,
   GetComments,
   GetCommentsResponse,
   GetCommunityResponse,
   GetPost,
   GetPostResponse,
   GetSiteResponse,
-  PostReportResponse,
+  LockPost,
+  MarkCommentReplyAsRead,
+  MarkPersonMentionAsRead,
   PostResponse,
-  PostView,
+  PurgeComment,
+  PurgeCommunity,
   PurgeItemResponse,
-  Search,
-  SearchResponse,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
+  PurgePerson,
+  PurgePost,
+  RemoveComment,
+  RemoveCommunity,
+  RemovePost,
+  SaveComment,
+  SavePost,
+  TransferCommunity,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
 import {
   CommentNodeI,
   CommentViewType,
   InitialFetchRequest,
 } from "../../interfaces";
-import { UserService, WebSocketService } from "../../services";
+import { UserService } from "../../services";
+import { FirstLoadService } from "../../services/FirstLoadService";
+import { HttpService, RequestState } from "../../services/HttpService";
 import {
   buildCommentsTree,
   commentsToFlatNodes,
   commentTreeMaxDepth,
-  createCommentLikeRes,
-  createPostLikeRes,
   debounce,
-  editCommentRes,
+  editComment,
+  editWith,
   enableDownvotes,
   enableNsfw,
   getCommentIdFromProps,
   getCommentParentId,
   getDepthFromComment,
   getIdFromProps,
-  insertCommentIntoTree,
   isBrowser,
   isImage,
   myAuth,
   restoreScrollPosition,
-  saveCommentRes,
   saveScrollPosition,
   setIsoData,
   setupTippy,
   toast,
-  trendingFetchLimit,
+  updateCommunityBlock,
   updatePersonBlock,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { CommentForm } from "../comment/comment-form";
 import { CommentNodes } from "../comment/comment-nodes";
@@ -75,135 +96,140 @@ const commentsShownInterval = 15;
 interface PostState {
   postId?: number;
   commentId?: number;
-  postRes?: GetPostResponse;
-  commentsRes?: GetCommentsResponse;
-  commentTree: CommentNodeI[];
+  postRes: RequestState<GetPostResponse>;
+  commentsRes: RequestState<GetCommentsResponse>;
   commentSort: CommentSortType;
   commentViewType: CommentViewType;
   scrolled?: boolean;
-  loading: boolean;
-  crossPosts?: PostView[];
   siteRes: GetSiteResponse;
   commentSectionRef?: RefObject<HTMLDivElement>;
   showSidebarMobile: boolean;
   maxCommentsShown: number;
+  finished: Map<CommentId, boolean | undefined>;
+  isIsomorphic: boolean;
 }
 
 export class Post extends Component<any, PostState> {
-  private subscription?: Subscription;
   private isoData = setIsoData(this.context);
   private commentScrollDebounced: () => void;
   state: PostState = {
+    postRes: { state: "empty" },
+    commentsRes: { state: "empty" },
     postId: getIdFromProps(this.props),
     commentId: getCommentIdFromProps(this.props),
-    commentTree: [],
     commentSort: "Hot",
     commentViewType: CommentViewType.Tree,
     scrolled: false,
-    loading: true,
     siteRes: this.isoData.site_res,
     showSidebarMobile: false,
     maxCommentsShown: commentsShownInterval,
+    finished: new Map(),
+    isIsomorphic: false,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
+    this.handleDeleteCommunityClick =
+      this.handleDeleteCommunityClick.bind(this);
+    this.handleEditCommunity = this.handleEditCommunity.bind(this);
+    this.handleFollow = this.handleFollow.bind(this);
+    this.handleModRemoveCommunity = this.handleModRemoveCommunity.bind(this);
+    this.handleCreateComment = this.handleCreateComment.bind(this);
+    this.handleEditComment = this.handleEditComment.bind(this);
+    this.handleSaveComment = this.handleSaveComment.bind(this);
+    this.handleBlockCommunity = this.handleBlockCommunity.bind(this);
+    this.handleBlockPerson = this.handleBlockPerson.bind(this);
+    this.handleDeleteComment = this.handleDeleteComment.bind(this);
+    this.handleRemoveComment = this.handleRemoveComment.bind(this);
+    this.handleCommentVote = this.handleCommentVote.bind(this);
+    this.handleAddModToCommunity = this.handleAddModToCommunity.bind(this);
+    this.handleAddAdmin = this.handleAddAdmin.bind(this);
+    this.handlePurgePerson = this.handlePurgePerson.bind(this);
+    this.handlePurgeComment = this.handlePurgeComment.bind(this);
+    this.handleCommentReport = this.handleCommentReport.bind(this);
+    this.handleDistinguishComment = this.handleDistinguishComment.bind(this);
+    this.handleTransferCommunity = this.handleTransferCommunity.bind(this);
+    this.handleFetchChildren = this.handleFetchChildren.bind(this);
+    this.handleCommentReplyRead = this.handleCommentReplyRead.bind(this);
+    this.handlePersonMentionRead = this.handlePersonMentionRead.bind(this);
+    this.handleBanFromCommunity = this.handleBanFromCommunity.bind(this);
+    this.handleBanPerson = this.handleBanPerson.bind(this);
+    this.handlePostEdit = this.handlePostEdit.bind(this);
+    this.handlePostVote = this.handlePostVote.bind(this);
+    this.handlePostReport = this.handlePostReport.bind(this);
+    this.handleLockPost = this.handleLockPost.bind(this);
+    this.handleDeletePost = this.handleDeletePost.bind(this);
+    this.handleRemovePost = this.handleRemovePost.bind(this);
+    this.handleSavePost = this.handleSavePost.bind(this);
+    this.handlePurgePost = this.handlePurgePost.bind(this);
+    this.handleFeaturePost = this.handleFeaturePost.bind(this);
 
     this.state = { ...this.state, commentSectionRef: createRef() };
 
     // Only fetch the data if coming from another route
-    if (this.isoData.path == this.context.router.route.match.url) {
+    if (FirstLoadService.isFirstLoad) {
+      const [postRes, commentsRes] = this.isoData.routeData;
+
       this.state = {
         ...this.state,
-        postRes: this.isoData.routeData[0] as GetPostResponse,
-        commentsRes: this.isoData.routeData[1] as GetCommentsResponse,
+        postRes,
+        commentsRes,
+        isIsomorphic: true,
       };
 
-      if (this.state.commentsRes) {
-        this.state = {
-          ...this.state,
-          commentTree: buildCommentsTree(
-            this.state.commentsRes.comments,
-            !!this.state.commentId
-          ),
-        };
-      }
-
-      this.state = { ...this.state, loading: false };
-
       if (isBrowser()) {
-        if (this.state.postRes) {
-          WebSocketService.Instance.send(
-            wsClient.communityJoin({
-              community_id: this.state.postRes.community_view.community.id,
-            })
-          );
-        }
-
-        if (this.state.postId) {
-          WebSocketService.Instance.send(
-            wsClient.postJoin({ post_id: this.state.postId })
-          );
-        }
-
-        this.fetchCrossPosts();
-
         if (this.checkScrollIntoCommentsParam) {
           this.scrollIntoCommentSection();
         }
       }
-    } else {
-      this.fetchPost();
     }
   }
 
-  fetchPost() {
-    const auth = myAuth(false);
-    const postForm: GetPost = {
-      id: this.state.postId,
-      comment_id: this.state.commentId,
-      auth,
-    };
-    WebSocketService.Instance.send(wsClient.getPost(postForm));
+  async fetchPost() {
+    this.setState({
+      postRes: { state: "loading" },
+      commentsRes: { state: "loading" },
+    });
 
-    const commentsForm: GetComments = {
-      post_id: this.state.postId,
-      parent_id: this.state.commentId,
-      max_depth: commentTreeMaxDepth,
-      sort: this.state.commentSort,
-      type_: "All",
-      saved_only: false,
-      auth,
-    };
-    WebSocketService.Instance.send(wsClient.getComments(commentsForm));
-  }
-
-  fetchCrossPosts() {
-    const q = this.state.postRes?.post_view.post.url;
-    if (q) {
-      const form: Search = {
-        q,
-        type_: "Url",
-        sort: "TopAll",
-        listing_type: "All",
-        page: 1,
-        limit: trendingFetchLimit,
-        auth: myAuth(false),
-      };
-      WebSocketService.Instance.send(wsClient.search(form));
+    const auth = myAuth();
+
+    this.setState({
+      postRes: await HttpService.client.getPost({
+        id: this.state.postId,
+        comment_id: this.state.commentId,
+        auth,
+      }),
+      commentsRes: await HttpService.client.getComments({
+        post_id: this.state.postId,
+        parent_id: this.state.commentId,
+        max_depth: commentTreeMaxDepth,
+        sort: this.state.commentSort,
+        type_: "All",
+        saved_only: false,
+        auth,
+      }),
+    });
+
+    setupTippy();
+
+    if (!this.state.commentId) restoreScrollPosition(this.context);
+
+    if (this.checkScrollIntoCommentsParam) {
+      this.scrollIntoCommentSection();
     }
   }
 
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
-    const pathSplit = req.path.split("/");
-    const promises: Promise<any>[] = [];
+  static fetchInitialData({
+    auth,
+    client,
+    path,
+  }: InitialFetchRequest): Promise<any>[] {
+    const pathSplit = path.split("/");
+    const promises: Promise<RequestState<any>>[] = [];
 
     const pathType = pathSplit.at(1);
     const id = pathSplit.at(2) ? Number(pathSplit.at(2)) : undefined;
-    const auth = req.auth;
 
     const postForm: GetPost = {
       auth,
@@ -218,7 +244,7 @@ export class Post extends Component<any, PostState> {
     };
 
     // Set the correct id based on the path type
-    if (pathType == "post") {
+    if (pathType === "post") {
       postForm.id = id;
       commentsForm.post_id = id;
     } else {
@@ -226,36 +252,33 @@ export class Post extends Component<any, PostState> {
       commentsForm.parent_id = id;
     }
 
-    promises.push(req.client.getPost(postForm));
-    promises.push(req.client.getComments(commentsForm));
+    promises.push(client.getPost(postForm));
+    promises.push(client.getComments(commentsForm));
 
     return promises;
   }
 
   componentWillUnmount() {
-    this.subscription?.unsubscribe();
     document.removeEventListener("scroll", this.commentScrollDebounced);
 
     saveScrollPosition(this.context);
   }
 
-  componentDidMount() {
+  async componentDidMount() {
+    if (!this.state.isIsomorphic) {
+      await this.fetchPost();
+    }
+
     autosize(document.querySelectorAll("textarea"));
 
     this.commentScrollDebounced = debounce(this.trackCommentsBoxScrolling, 100);
     document.addEventListener("scroll", this.commentScrollDebounced);
   }
 
-  componentDidUpdate(_lastProps: any) {
+  async componentDidUpdate(_lastProps: any) {
     // Necessary if you are on a post and you click another post (same route)
     if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
-      // TODO Couldnt get a refresh working. This does for now.
-      location.reload();
-
-      // let currentId = this.props.match.params.id;
-      // WebSocketService.Instance.getPost(currentId);
-      // this.context.refresh();
-      // this.context.router.history.push(_lastProps.location.pathname);
+      await this.fetchPost();
     }
   }
 
@@ -279,92 +302,123 @@ export class Post extends Component<any, PostState> {
   trackCommentsBoxScrolling = () => {
     const wrappedElement = document.getElementsByClassName("comments")[0];
     if (wrappedElement && this.isBottom(wrappedElement)) {
-      this.setState({
-        maxCommentsShown: this.state.maxCommentsShown + commentsShownInterval,
-      });
+      const commentCount =
+        this.state.commentsRes.state == "success"
+          ? this.state.commentsRes.data.comments.length
+          : 0;
+
+      if (this.state.maxCommentsShown < commentCount) {
+        this.setState({
+          maxCommentsShown: this.state.maxCommentsShown + commentsShownInterval,
+        });
+      }
     }
   };
 
   get documentTitle(): string {
-    const name_ = this.state.postRes?.post_view.post.name;
     const siteName = this.state.siteRes.site_view.site.name;
-    return name_ ? `${name_} - ${siteName}` : "";
+    return this.state.postRes.state == "success"
+      ? `${this.state.postRes.data.post_view.post.name} - ${siteName}`
+      : siteName;
   }
 
   get imageTag(): string | undefined {
-    const post = this.state.postRes?.post_view.post;
-    const thumbnail = post?.thumbnail_url;
-    const url = post?.url;
-    return thumbnail || (url && isImage(url) ? url : undefined);
+    if (this.state.postRes.state == "success") {
+      const post = this.state.postRes.data.post_view.post;
+      const thumbnail = post.thumbnail_url;
+      const url = post.url;
+      return thumbnail || (url && isImage(url) ? url : undefined);
+    } else return undefined;
   }
 
-  render() {
-    const res = this.state.postRes;
-    const description = res?.post_view.post.body;
-    return (
-      <div className="container-lg">
-        {this.state.loading ? (
+  renderPostRes() {
+    switch (this.state.postRes.state) {
+      case "loading":
+        return (
           <h5>
             <Spinner large />
           </h5>
-        ) : (
-          res && (
-            <div className="row">
-              <div className="col-12 col-md-8 mb-3">
-                <HtmlTags
-                  title={this.documentTitle}
-                  path={this.context.router.route.match.url}
-                  image={this.imageTag}
-                  description={description}
-                />
-                <PostListing
-                  post_view={res.post_view}
-                  duplicates={this.state.crossPosts}
-                  showBody
-                  showCommunity
-                  moderators={res.moderators}
-                  admins={this.state.siteRes.admins}
-                  enableDownvotes={enableDownvotes(this.state.siteRes)}
-                  enableNsfw={enableNsfw(this.state.siteRes)}
-                  allLanguages={this.state.siteRes.all_languages}
-                  siteLanguages={this.state.siteRes.discussion_languages}
-                />
-                <div ref={this.state.commentSectionRef} className="mb-2" />
-                <CommentForm
-                  node={res.post_view.post.id}
-                  disabled={res.post_view.post.locked}
-                  allLanguages={this.state.siteRes.all_languages}
-                  siteLanguages={this.state.siteRes.discussion_languages}
-                />
-                <div className="d-block d-md-none">
-                  <button
-                    className="btn btn-secondary d-inline-block mb-2 mr-3"
-                    onClick={linkEvent(this, this.handleShowSidebarMobile)}
-                  >
-                    {i18n.t("sidebar")}{" "}
-                    <Icon
-                      icon={
-                        this.state.showSidebarMobile
-                          ? `minus-square`
-                          : `plus-square`
-                      }
-                      classes="icon-inline"
-                    />
-                  </button>
-                  {this.state.showSidebarMobile && this.sidebar()}
-                </div>
-                {this.sortRadios()}
-                {this.state.commentViewType == CommentViewType.Tree &&
-                  this.commentsTree()}
-                {this.state.commentViewType == CommentViewType.Flat &&
-                  this.commentsFlat()}
+        );
+      case "success": {
+        const res = this.state.postRes.data;
+        return (
+          <div className="row">
+            <div className="col-12 col-md-8 mb-3">
+              <HtmlTags
+                title={this.documentTitle}
+                path={this.context.router.route.match.url}
+                image={this.imageTag}
+                description={res.post_view.post.body}
+              />
+              <PostListing
+                post_view={res.post_view}
+                crossPosts={res.cross_posts}
+                showBody
+                showCommunity
+                moderators={res.moderators}
+                admins={this.state.siteRes.admins}
+                enableDownvotes={enableDownvotes(this.state.siteRes)}
+                enableNsfw={enableNsfw(this.state.siteRes)}
+                allLanguages={this.state.siteRes.all_languages}
+                siteLanguages={this.state.siteRes.discussion_languages}
+                onBlockPerson={this.handleBlockPerson}
+                onPostEdit={this.handlePostEdit}
+                onPostVote={this.handlePostVote}
+                onPostReport={this.handlePostReport}
+                onLockPost={this.handleLockPost}
+                onDeletePost={this.handleDeletePost}
+                onRemovePost={this.handleRemovePost}
+                onSavePost={this.handleSavePost}
+                onPurgePerson={this.handlePurgePerson}
+                onPurgePost={this.handlePurgePost}
+                onBanPerson={this.handleBanPerson}
+                onBanPersonFromCommunity={this.handleBanFromCommunity}
+                onAddModToCommunity={this.handleAddModToCommunity}
+                onAddAdmin={this.handleAddAdmin}
+                onTransferCommunity={this.handleTransferCommunity}
+                onFeaturePost={this.handleFeaturePost}
+              />
+              <div ref={this.state.commentSectionRef} className="mb-2" />
+              <CommentForm
+                node={res.post_view.post.id}
+                disabled={res.post_view.post.locked}
+                allLanguages={this.state.siteRes.all_languages}
+                siteLanguages={this.state.siteRes.discussion_languages}
+                onUpsertComment={this.handleCreateComment}
+                finished={this.state.finished.get(0)}
+              />
+              <div className="d-block d-md-none">
+                <button
+                  className="btn btn-secondary d-inline-block mb-2 mr-3"
+                  onClick={linkEvent(this, this.handleShowSidebarMobile)}
+                >
+                  {i18n.t("sidebar")}{" "}
+                  <Icon
+                    icon={
+                      this.state.showSidebarMobile
+                        ? `minus-square`
+                        : `plus-square`
+                    }
+                    classes="icon-inline"
+                  />
+                </button>
+                {this.state.showSidebarMobile && this.sidebar()}
               </div>
-              <div className="d-none d-md-block col-md-4">{this.sidebar()}</div>
+              {this.sortRadios()}
+              {this.state.commentViewType == CommentViewType.Tree &&
+                this.commentsTree()}
+              {this.state.commentViewType == CommentViewType.Flat &&
+                this.commentsFlat()}
             </div>
-          )
-        )}
-      </div>
-    );
+            <div className="d-none d-md-block col-md-4">{this.sidebar()}</div>
+          </div>
+        );
+      }
+    }
+  }
+
+  render() {
+    return <div className="container-lg">{this.renderPostRes()}</div>;
   }
 
   sortRadios() {
@@ -447,97 +501,83 @@ export class Post extends Component<any, PostState> {
     // These are already sorted by new
     const commentsRes = this.state.commentsRes;
     const postRes = this.state.postRes;
-    return (
-      commentsRes &&
-      postRes && (
+
+    if (commentsRes.state == "success" && postRes.state == "success") {
+      return (
         <div>
           <CommentNodes
-            nodes={commentsToFlatNodes(commentsRes.comments)}
+            nodes={commentsToFlatNodes(commentsRes.data.comments)}
             viewType={this.state.commentViewType}
             maxCommentsShown={this.state.maxCommentsShown}
             noIndent
-            locked={postRes.post_view.post.locked}
-            moderators={postRes.moderators}
+            locked={postRes.data.post_view.post.locked}
+            moderators={postRes.data.moderators}
             admins={this.state.siteRes.admins}
             enableDownvotes={enableDownvotes(this.state.siteRes)}
             showContext
+            finished={this.state.finished}
             allLanguages={this.state.siteRes.all_languages}
             siteLanguages={this.state.siteRes.discussion_languages}
+            onSaveComment={this.handleSaveComment}
+            onBlockPerson={this.handleBlockPerson}
+            onDeleteComment={this.handleDeleteComment}
+            onRemoveComment={this.handleRemoveComment}
+            onCommentVote={this.handleCommentVote}
+            onCommentReport={this.handleCommentReport}
+            onDistinguishComment={this.handleDistinguishComment}
+            onAddModToCommunity={this.handleAddModToCommunity}
+            onAddAdmin={this.handleAddAdmin}
+            onTransferCommunity={this.handleTransferCommunity}
+            onFetchChildren={this.handleFetchChildren}
+            onPurgeComment={this.handlePurgeComment}
+            onPurgePerson={this.handlePurgePerson}
+            onCommentReplyRead={this.handleCommentReplyRead}
+            onPersonMentionRead={this.handlePersonMentionRead}
+            onBanPersonFromCommunity={this.handleBanFromCommunity}
+            onBanPerson={this.handleBanPerson}
+            onCreateComment={this.handleCreateComment}
+            onEditComment={this.handleEditComment}
           />
         </div>
-      )
-    );
+      );
+    }
   }
 
   sidebar() {
     const res = this.state.postRes;
-    return (
-      res && (
+    if (res.state === "success") {
+      return (
         <div className="mb-3">
           <Sidebar
-            community_view={res.community_view}
-            moderators={res.moderators}
+            community_view={res.data.community_view}
+            moderators={res.data.moderators}
             admins={this.state.siteRes.admins}
-            online={res.online}
+            online={res.data.online}
             enableNsfw={enableNsfw(this.state.siteRes)}
             showIcon
             allLanguages={this.state.siteRes.all_languages}
             siteLanguages={this.state.siteRes.discussion_languages}
+            onDeleteCommunity={this.handleDeleteCommunityClick}
+            onLeaveModTeam={this.handleAddModToCommunity}
+            onFollowCommunity={this.handleFollow}
+            onRemoveCommunity={this.handleModRemoveCommunity}
+            onPurgeCommunity={this.handlePurgeCommunity}
+            onBlockCommunity={this.handleBlockCommunity}
+            onEditCommunity={this.handleEditCommunity}
           />
         </div>
-      )
-    );
-  }
-
-  handleCommentSortChange(i: Post, event: any) {
-    i.setState({
-      commentSort: event.target.value as CommentSortType,
-      commentViewType: CommentViewType.Tree,
-      commentsRes: undefined,
-      postRes: undefined,
-    });
-    i.fetchPost();
-  }
-
-  handleCommentViewTypeChange(i: Post, event: any) {
-    const comments = i.state.commentsRes?.comments;
-    if (comments) {
-      i.setState({
-        commentViewType: Number(event.target.value),
-        commentSort: "New",
-        commentTree: buildCommentsTree(comments, !!i.state.commentId),
-      });
-    }
-  }
-
-  handleShowSidebarMobile(i: Post) {
-    i.setState({ showSidebarMobile: !i.state.showSidebarMobile });
-  }
-
-  handleViewPost(i: Post) {
-    const id = i.state.postRes?.post_view.post.id;
-    if (id) {
-      i.context.router.history.push(`/post/${id}`);
-    }
-  }
-
-  handleViewContext(i: Post) {
-    const parentId = getCommentParentId(
-      i.state.commentsRes?.comments?.at(0)?.comment
-    );
-    if (parentId) {
-      i.context.router.history.push(`/comment/${parentId}`);
+      );
     }
   }
 
   commentsTree() {
     const res = this.state.postRes;
-    const firstComment = this.state.commentTree.at(0)?.comment_view.comment;
+    const firstComment = this.commentTree().at(0)?.comment_view.comment;
     const depth = getDepthFromComment(firstComment);
     const showContextButton = depth ? depth > 0 : false;
 
     return (
-      res && (
+      res.state == "success" && (
         <div>
           {!!this.state.commentId && (
             <>
@@ -558,238 +598,437 @@ export class Post extends Component<any, PostState> {
             </>
           )}
           <CommentNodes
-            nodes={this.state.commentTree}
+            nodes={this.commentTree()}
             viewType={this.state.commentViewType}
             maxCommentsShown={this.state.maxCommentsShown}
-            locked={res.post_view.post.locked}
-            moderators={res.moderators}
+            locked={res.data.post_view.post.locked}
+            moderators={res.data.moderators}
             admins={this.state.siteRes.admins}
             enableDownvotes={enableDownvotes(this.state.siteRes)}
+            finished={this.state.finished}
             allLanguages={this.state.siteRes.all_languages}
             siteLanguages={this.state.siteRes.discussion_languages}
+            onSaveComment={this.handleSaveComment}
+            onBlockPerson={this.handleBlockPerson}
+            onDeleteComment={this.handleDeleteComment}
+            onRemoveComment={this.handleRemoveComment}
+            onCommentVote={this.handleCommentVote}
+            onCommentReport={this.handleCommentReport}
+            onDistinguishComment={this.handleDistinguishComment}
+            onAddModToCommunity={this.handleAddModToCommunity}
+            onAddAdmin={this.handleAddAdmin}
+            onTransferCommunity={this.handleTransferCommunity}
+            onFetchChildren={this.handleFetchChildren}
+            onPurgeComment={this.handlePurgeComment}
+            onPurgePerson={this.handlePurgePerson}
+            onCommentReplyRead={this.handleCommentReplyRead}
+            onPersonMentionRead={this.handlePersonMentionRead}
+            onBanPersonFromCommunity={this.handleBanFromCommunity}
+            onBanPerson={this.handleBanPerson}
+            onCreateComment={this.handleCreateComment}
+            onEditComment={this.handleEditComment}
           />
         </div>
       )
     );
   }
 
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      return;
-    } else if (msg.reconnect) {
-      const post_id = this.state.postRes?.post_view.post.id;
-      if (post_id) {
-        WebSocketService.Instance.send(wsClient.postJoin({ post_id }));
-        WebSocketService.Instance.send(
-          wsClient.getPost({
-            id: post_id,
-            auth: myAuth(false),
-          })
-        );
-      }
-    } else if (op == UserOperation.GetPost) {
-      const data = wsJsonToRes<GetPostResponse>(msg);
-      this.setState({ postRes: data });
-
-      // join the rooms
-      WebSocketService.Instance.send(
-        wsClient.postJoin({ post_id: data.post_view.post.id })
-      );
-      WebSocketService.Instance.send(
-        wsClient.communityJoin({
-          community_id: data.community_view.community.id,
-        })
+  commentTree(): CommentNodeI[] {
+    if (this.state.commentsRes.state == "success") {
+      return buildCommentsTree(
+        this.state.commentsRes.data.comments,
+        !!this.state.commentId
       );
+    } else {
+      return [];
+    }
+  }
 
-      // Get cross-posts
-      // TODO move this into initial fetch and refetch
-      this.fetchCrossPosts();
-      setupTippy();
-      if (!this.state.commentId) restoreScrollPosition(this.context);
+  async handleCommentSortChange(i: Post, event: any) {
+    i.setState({
+      commentSort: event.target.value as CommentSortType,
+      commentViewType: CommentViewType.Tree,
+      commentsRes: { state: "loading" },
+      postRes: { state: "loading" },
+    });
+    await i.fetchPost();
+  }
+
+  handleCommentViewTypeChange(i: Post, event: any) {
+    i.setState({
+      commentViewType: Number(event.target.value),
+      commentSort: "New",
+    });
+  }
 
-      if (this.checkScrollIntoCommentsParam) {
-        this.scrollIntoCommentSection();
+  handleShowSidebarMobile(i: Post) {
+    i.setState({ showSidebarMobile: !i.state.showSidebarMobile });
+  }
+
+  handleViewPost(i: Post) {
+    if (i.state.postRes.state == "success") {
+      const id = i.state.postRes.data.post_view.post.id;
+      i.context.router.history.push(`/post/${id}`);
+    }
+  }
+
+  handleViewContext(i: Post) {
+    if (i.state.commentsRes.state == "success") {
+      const parentId = getCommentParentId(
+        i.state.commentsRes.data.comments.at(0)?.comment
+      );
+      if (parentId) {
+        i.context.router.history.push(`/comment/${parentId}`);
       }
-    } else if (op == UserOperation.GetComments) {
-      const data = wsJsonToRes<GetCommentsResponse>(msg);
-      // This section sets the comments res
-      const comments = this.state.commentsRes?.comments;
-      if (comments) {
-        // You might need to append here, since this could be building more comments from a tree fetch
-        // Remove the first comment, since it is the parent
-        const newComments = data.comments;
-        newComments.shift();
-        comments.push(...newComments);
-      } else {
-        this.setState({ commentsRes: data });
+    }
+  }
+
+  async handleDeleteCommunityClick(form: DeleteCommunity) {
+    const deleteCommunityRes = await HttpService.client.deleteCommunity(form);
+    this.updateCommunity(deleteCommunityRes);
+  }
+
+  async handleAddModToCommunity(form: AddModToCommunity) {
+    const addModRes = await HttpService.client.addModToCommunity(form);
+    this.updateModerators(addModRes);
+  }
+
+  async handleFollow(form: FollowCommunity) {
+    const followCommunityRes = await HttpService.client.followCommunity(form);
+    this.updateCommunity(followCommunityRes);
+
+    // Update myUserInfo
+    if (followCommunityRes.state === "success") {
+      const communityId = followCommunityRes.data.community_view.community.id;
+      const mui = UserService.Instance.myUserInfo;
+      if (mui) {
+        mui.follows = mui.follows.filter(i => i.community.id != communityId);
       }
+    }
+  }
 
-      const cComments = this.state.commentsRes?.comments ?? [];
-      this.setState({
-        commentTree: buildCommentsTree(cComments, !!this.state.commentId),
-        loading: false,
-      });
-    } else if (op == UserOperation.CreateComment) {
-      const data = wsJsonToRes<CommentResponse>(msg);
+  async handlePurgeCommunity(form: PurgeCommunity) {
+    const purgeCommunityRes = await HttpService.client.purgeCommunity(form);
+    this.purgeItem(purgeCommunityRes);
+  }
+
+  async handlePurgePerson(form: PurgePerson) {
+    const purgePersonRes = await HttpService.client.purgePerson(form);
+    this.purgeItem(purgePersonRes);
+  }
+
+  async handlePurgeComment(form: PurgeComment) {
+    const purgeCommentRes = await HttpService.client.purgeComment(form);
+    this.purgeItem(purgeCommentRes);
+  }
 
-      // Don't get comments from the post room, if the creator is blocked
-      const creatorBlocked = UserService.Instance.myUserInfo?.person_blocks
-        .map(pb => pb.target.id)
-        .includes(data.comment_view.creator.id);
+  async handlePurgePost(form: PurgePost) {
+    const purgeRes = await HttpService.client.purgePost(form);
+    this.purgeItem(purgeRes);
+  }
 
-      // Necessary since it might be a user reply, which has the recipients, to avoid double
-      const postRes = this.state.postRes;
-      const commentsRes = this.state.commentsRes;
+  async handleBlockCommunity(form: BlockCommunity) {
+    const blockCommunityRes = await HttpService.client.blockCommunity(form);
+    // TODO Probably isn't necessary
+    this.setState(s => {
       if (
-        data.recipient_ids.length == 0 &&
-        !creatorBlocked &&
-        postRes &&
-        data.comment_view.post.id == postRes.post_view.post.id &&
-        commentsRes
+        s.postRes.state == "success" &&
+        blockCommunityRes.state == "success"
       ) {
-        commentsRes.comments.unshift(data.comment_view);
-        insertCommentIntoTree(
-          this.state.commentTree,
-          data.comment_view,
-          !!this.state.commentId
-        );
-        postRes.post_view.counts.comments++;
-
-        this.setState(this.state);
-        setupTippy();
+        s.postRes.data.community_view = blockCommunityRes.data.community_view;
       }
-    } else if (
-      op == UserOperation.EditComment ||
-      op == UserOperation.DeleteComment ||
-      op == UserOperation.RemoveComment
-    ) {
-      const data = wsJsonToRes<CommentResponse>(msg);
-      editCommentRes(data.comment_view, this.state.commentsRes?.comments);
-      this.setState(this.state);
-      setupTippy();
-    } else if (op == UserOperation.SaveComment) {
-      const data = wsJsonToRes<CommentResponse>(msg);
-      saveCommentRes(data.comment_view, this.state.commentsRes?.comments);
-      this.setState(this.state);
-      setupTippy();
-    } else if (op == UserOperation.CreateCommentLike) {
-      const data = wsJsonToRes<CommentResponse>(msg);
-      createCommentLikeRes(data.comment_view, this.state.commentsRes?.comments);
-      this.setState(this.state);
-    } else if (op == UserOperation.CreatePostLike) {
-      const data = wsJsonToRes<PostResponse>(msg);
-      createPostLikeRes(data.post_view, this.state.postRes?.post_view);
-      this.setState(this.state);
-    } else if (
-      op == UserOperation.EditPost ||
-      op == UserOperation.DeletePost ||
-      op == UserOperation.RemovePost ||
-      op == UserOperation.LockPost ||
-      op == UserOperation.FeaturePost ||
-      op == UserOperation.SavePost
-    ) {
-      const data = wsJsonToRes<PostResponse>(msg);
-      const res = this.state.postRes;
-      if (res) {
-        res.post_view = data.post_view;
-        this.setState(this.state);
-        setupTippy();
-      }
-    } else if (
-      op == UserOperation.EditCommunity ||
-      op == UserOperation.DeleteCommunity ||
-      op == UserOperation.RemoveCommunity ||
-      op == UserOperation.FollowCommunity
+      return s;
+    });
+
+    if (blockCommunityRes.state == "success") {
+      updateCommunityBlock(blockCommunityRes.data);
+    }
+  }
+
+  async handleBlockPerson(form: BlockPerson) {
+    const blockPersonRes = await HttpService.client.blockPerson(form);
+    if (blockPersonRes.state == "success") {
+      updatePersonBlock(blockPersonRes.data);
+    }
+  }
+
+  async handleModRemoveCommunity(form: RemoveCommunity) {
+    const removeCommunityRes = await HttpService.client.removeCommunity(form);
+    this.updateCommunity(removeCommunityRes);
+  }
+
+  async handleEditCommunity(form: EditCommunity) {
+    const res = await HttpService.client.editCommunity(form);
+    this.updateCommunity(res);
+
+    return res;
+  }
+
+  async handleCreateComment(form: CreateComment) {
+    const createCommentRes = await HttpService.client.createComment(form);
+    this.createAndUpdateComments(createCommentRes);
+
+    return createCommentRes;
+  }
+
+  async handleEditComment(form: EditComment) {
+    const editCommentRes = await HttpService.client.editComment(form);
+    this.findAndUpdateComment(editCommentRes);
+
+    return editCommentRes;
+  }
+
+  async handleDeleteComment(form: DeleteComment) {
+    const deleteCommentRes = await HttpService.client.deleteComment(form);
+    this.findAndUpdateComment(deleteCommentRes);
+  }
+
+  async handleDeletePost(form: DeletePost) {
+    const deleteRes = await HttpService.client.deletePost(form);
+    this.updatePost(deleteRes);
+  }
+
+  async handleRemovePost(form: RemovePost) {
+    const removeRes = await HttpService.client.removePost(form);
+    this.updatePost(removeRes);
+  }
+
+  async handleRemoveComment(form: RemoveComment) {
+    const removeCommentRes = await HttpService.client.removeComment(form);
+    this.findAndUpdateComment(removeCommentRes);
+  }
+
+  async handleSaveComment(form: SaveComment) {
+    const saveCommentRes = await HttpService.client.saveComment(form);
+    this.findAndUpdateComment(saveCommentRes);
+  }
+
+  async handleSavePost(form: SavePost) {
+    const saveRes = await HttpService.client.savePost(form);
+    this.updatePost(saveRes);
+  }
+
+  async handleFeaturePost(form: FeaturePost) {
+    const featureRes = await HttpService.client.featurePost(form);
+    this.updatePost(featureRes);
+  }
+
+  async handleCommentVote(form: CreateCommentLike) {
+    const voteRes = await HttpService.client.likeComment(form);
+    this.findAndUpdateComment(voteRes);
+  }
+
+  async handlePostVote(form: CreatePostLike) {
+    const voteRes = await HttpService.client.likePost(form);
+    this.updatePost(voteRes);
+  }
+
+  async handlePostEdit(form: EditPost) {
+    const res = await HttpService.client.editPost(form);
+    this.updatePost(res);
+  }
+
+  async handleCommentReport(form: CreateCommentReport) {
+    const reportRes = await HttpService.client.createCommentReport(form);
+    if (reportRes.state == "success") {
+      toast(i18n.t("report_created"));
+    }
+  }
+
+  async handlePostReport(form: CreatePostReport) {
+    const reportRes = await HttpService.client.createPostReport(form);
+    if (reportRes.state == "success") {
+      toast(i18n.t("report_created"));
+    }
+  }
+
+  async handleLockPost(form: LockPost) {
+    const lockRes = await HttpService.client.lockPost(form);
+    this.updatePost(lockRes);
+  }
+
+  async handleDistinguishComment(form: DistinguishComment) {
+    const distinguishRes = await HttpService.client.distinguishComment(form);
+    this.findAndUpdateComment(distinguishRes);
+  }
+
+  async handleAddAdmin(form: AddAdmin) {
+    const addAdminRes = await HttpService.client.addAdmin(form);
+
+    if (addAdminRes.state === "success") {
+      this.setState(s => ((s.siteRes.admins = addAdminRes.data.admins), s));
+    }
+  }
+
+  async handleTransferCommunity(form: TransferCommunity) {
+    const transferCommunityRes = await HttpService.client.transferCommunity(
+      form
+    );
+    this.updateCommunityFull(transferCommunityRes);
+  }
+
+  async handleFetchChildren(form: GetComments) {
+    const moreCommentsRes = await HttpService.client.getComments(form);
+    if (
+      this.state.commentsRes.state == "success" &&
+      moreCommentsRes.state == "success"
     ) {
-      const data = wsJsonToRes<CommunityResponse>(msg);
-      const res = this.state.postRes;
-      if (res) {
-        res.community_view = data.community_view;
-        res.post_view.community = data.community_view.community;
-        this.setState(this.state);
-      }
-    } else if (op == UserOperation.BanFromCommunity) {
-      const data = wsJsonToRes<BanFromCommunityResponse>(msg);
+      const newComments = moreCommentsRes.data.comments;
+      // Remove the first comment, since it is the parent
+      newComments.shift();
+      const newRes = this.state.commentsRes;
+      newRes.data.comments.push(...newComments);
+      this.setState({ commentsRes: newRes });
+    }
+  }
+
+  async handleCommentReplyRead(form: MarkCommentReplyAsRead) {
+    const readRes = await HttpService.client.markCommentReplyAsRead(form);
+    this.findAndUpdateCommentReply(readRes);
+  }
+
+  async handlePersonMentionRead(form: MarkPersonMentionAsRead) {
+    // TODO not sure what to do here. Maybe it is actually optional, because post doesn't need it.
+    await HttpService.client.markPersonMentionAsRead(form);
+  }
 
-      const res = this.state.postRes;
-      if (res) {
-        if (res.post_view.creator.id == data.person_view.person.id) {
-          res.post_view.creator_banned_from_community = data.banned;
+  async handleBanFromCommunity(form: BanFromCommunity) {
+    const banRes = await HttpService.client.banFromCommunity(form);
+    this.updateBan(banRes);
+  }
+
+  async handleBanPerson(form: BanPerson) {
+    const banRes = await HttpService.client.banPerson(form);
+    this.updateBan(banRes);
+  }
+
+  updateBanFromCommunity(banRes: RequestState<BanFromCommunityResponse>) {
+    // Maybe not necessary
+    if (banRes.state == "success") {
+      this.setState(s => {
+        if (
+          s.postRes.state == "success" &&
+          s.postRes.data.post_view.creator.id ==
+            banRes.data.person_view.person.id
+        ) {
+          s.postRes.data.post_view.creator_banned_from_community =
+            banRes.data.banned;
         }
+        if (s.commentsRes.state == "success") {
+          s.commentsRes.data.comments
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(
+              c => (c.creator_banned_from_community = banRes.data.banned)
+            );
+        }
+        return s;
+      });
+    }
+  }
+
+  updateBan(banRes: RequestState<BanPersonResponse>) {
+    // Maybe not necessary
+    if (banRes.state == "success") {
+      this.setState(s => {
+        if (
+          s.postRes.state == "success" &&
+          s.postRes.data.post_view.creator.id ==
+            banRes.data.person_view.person.id
+        ) {
+          s.postRes.data.post_view.creator.banned = banRes.data.banned;
+        }
+        if (s.commentsRes.state == "success") {
+          s.commentsRes.data.comments
+            .filter(c => c.creator.id == banRes.data.person_view.person.id)
+            .forEach(c => (c.creator.banned = banRes.data.banned));
+        }
+        return s;
+      });
+    }
+  }
+
+  updateCommunity(communityRes: RequestState<CommunityResponse>) {
+    this.setState(s => {
+      if (s.postRes.state == "success" && communityRes.state == "success") {
+        s.postRes.data.community_view = communityRes.data.community_view;
       }
+      return s;
+    });
+  }
 
-      this.state.commentsRes?.comments
-        .filter(c => c.creator.id == data.person_view.person.id)
-        .forEach(c => (c.creator_banned_from_community = data.banned));
-      this.setState(this.state);
-    } else if (op == UserOperation.AddModToCommunity) {
-      const data = wsJsonToRes<AddModToCommunityResponse>(msg);
-      const res = this.state.postRes;
-      if (res) {
-        res.moderators = data.moderators;
-        this.setState(this.state);
+  updateCommunityFull(res: RequestState<GetCommunityResponse>) {
+    this.setState(s => {
+      if (s.postRes.state == "success" && res.state == "success") {
+        s.postRes.data.community_view = res.data.community_view;
+        s.postRes.data.moderators = res.data.moderators;
       }
-    } else if (op == UserOperation.BanPerson) {
-      const data = wsJsonToRes<BanPersonResponse>(msg);
-      this.state.commentsRes?.comments
-        .filter(c => c.creator.id == data.person_view.person.id)
-        .forEach(c => (c.creator.banned = data.banned));
-
-      const res = this.state.postRes;
-      if (res) {
-        if (res.post_view.creator.id == data.person_view.person.id) {
-          res.post_view.creator.banned = data.banned;
-        }
+      return s;
+    });
+  }
+
+  updatePost(post: RequestState<PostResponse>) {
+    this.setState(s => {
+      if (s.postRes.state == "success" && post.state == "success") {
+        s.postRes.data.post_view = post.data.post_view;
       }
-      this.setState(this.state);
-    } else if (op == UserOperation.AddAdmin) {
-      const data = wsJsonToRes<AddAdminResponse>(msg);
-      this.setState(s => ((s.siteRes.admins = data.admins), s));
-    } else if (op == UserOperation.Search) {
-      const data = wsJsonToRes<SearchResponse>(msg);
-      const xPosts = data.posts.filter(
-        p => p.post.ap_id != this.state.postRes?.post_view.post.ap_id
-      );
-      this.setState({ crossPosts: xPosts.length > 0 ? xPosts : undefined });
-    } else if (op == UserOperation.LeaveAdmin) {
-      const data = wsJsonToRes<GetSiteResponse>(msg);
-      this.setState({ siteRes: data });
-    } else if (op == UserOperation.TransferCommunity) {
-      const data = wsJsonToRes<GetCommunityResponse>(msg);
-      const res = this.state.postRes;
-      if (res) {
-        res.community_view = data.community_view;
-        res.post_view.community = data.community_view.community;
-        res.moderators = data.moderators;
-        this.setState(this.state);
+      return s;
+    });
+  }
+
+  purgeItem(purgeRes: RequestState<PurgeItemResponse>) {
+    if (purgeRes.state == "success") {
+      toast(i18n.t("purge_success"));
+      this.context.router.history.push(`/`);
+    }
+  }
+
+  createAndUpdateComments(res: RequestState<CommentResponse>) {
+    this.setState(s => {
+      if (s.commentsRes.state === "success" && res.state === "success") {
+        s.commentsRes.data.comments.unshift(res.data.comment_view);
+
+        // Set finished for the parent
+        s.finished.set(
+          getCommentParentId(res.data.comment_view.comment) ?? 0,
+          true
+        );
       }
-    } else if (op == UserOperation.BlockPerson) {
-      const data = wsJsonToRes<BlockPersonResponse>(msg);
-      updatePersonBlock(data);
-    } else if (op == UserOperation.CreatePostReport) {
-      const data = wsJsonToRes<PostReportResponse>(msg);
-      if (data) {
-        toast(i18n.t("report_created"));
+      return s;
+    });
+  }
+
+  findAndUpdateComment(res: RequestState<CommentResponse>) {
+    this.setState(s => {
+      if (s.commentsRes.state == "success" && res.state == "success") {
+        s.commentsRes.data.comments = editComment(
+          res.data.comment_view,
+          s.commentsRes.data.comments
+        );
+        s.finished.set(res.data.comment_view.comment.id, true);
       }
-    } else if (op == UserOperation.CreateCommentReport) {
-      const data = wsJsonToRes<CommentReportResponse>(msg);
-      if (data) {
-        toast(i18n.t("report_created"));
+      return s;
+    });
+  }
+
+  findAndUpdateCommentReply(res: RequestState<CommentReplyResponse>) {
+    this.setState(s => {
+      if (s.commentsRes.state == "success" && res.state == "success") {
+        s.commentsRes.data.comments = editWith(
+          res.data.comment_reply_view,
+          s.commentsRes.data.comments
+        );
       }
-    } else if (
-      op == UserOperation.PurgePerson ||
-      op == UserOperation.PurgePost ||
-      op == UserOperation.PurgeComment ||
-      op == UserOperation.PurgeCommunity
-    ) {
-      const data = wsJsonToRes<PurgeItemResponse>(msg);
-      if (data.success) {
-        toast(i18n.t("purge_success"));
-        this.context.router.history.push(`/`);
+      return s;
+    });
+  }
+
+  updateModerators(res: RequestState<AddModToCommunityResponse>) {
+    // Update the moderators
+    this.setState(s => {
+      if (s.postRes.state == "success" && res.state == "success") {
+        s.postRes.data.moderators = res.data.moderators;
       }
-    }
+      return s;
+    });
   }
 }
index 473f957a52ed2658f3a45c71c15de79ef310fcc0..817cfd880d001181958b56edce97c340af245040 100644 (file)
@@ -1,24 +1,19 @@
 import { Component } from "inferno";
 import {
+  CreatePrivateMessage as CreatePrivateMessageI,
   GetPersonDetails,
   GetPersonDetailsResponse,
   GetSiteResponse,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
 import { InitialFetchRequest } from "../../interfaces";
-import { WebSocketService } from "../../services";
+import { FirstLoadService } from "../../services/FirstLoadService";
+import { HttpService, RequestState } from "../../services/HttpService";
 import {
   getRecipientIdFromProps,
-  isBrowser,
   myAuth,
   setIsoData,
   toast,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { HtmlTags } from "../common/html-tags";
 import { Spinner } from "../common/icon";
@@ -26,9 +21,9 @@ import { PrivateMessageForm } from "./private-message-form";
 
 interface CreatePrivateMessageState {
   siteRes: GetSiteResponse;
-  recipientDetailsRes?: GetPersonDetailsResponse;
-  recipient_id: number;
-  loading: boolean;
+  recipientRes: RequestState<GetPersonDetailsResponse>;
+  recipientId: number;
+  isIsomorphic: boolean;
 }
 
 export class CreatePrivateMessage extends Component<
@@ -36,11 +31,11 @@ export class CreatePrivateMessage extends Component<
   CreatePrivateMessageState
 > {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
   state: CreatePrivateMessageState = {
     siteRes: this.isoData.site_res,
-    recipient_id: getRecipientIdFromProps(this.props),
-    loading: true,
+    recipientRes: { state: "empty" },
+    recipientId: getRecipientIdFromProps(this.props),
+    isIsomorphic: false,
   };
 
   constructor(props: any, context: any) {
@@ -48,33 +43,40 @@ export class CreatePrivateMessage extends Component<
     this.handlePrivateMessageCreate =
       this.handlePrivateMessageCreate.bind(this);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
-
     // Only fetch the data if coming from another route
-    if (this.isoData.path == this.context.router.route.match.url) {
+    if (FirstLoadService.isFirstLoad) {
       this.state = {
         ...this.state,
-        recipientDetailsRes: this.isoData
-          .routeData[0] as GetPersonDetailsResponse,
-        loading: false,
+        recipientRes: this.isoData.routeData[0],
+        isIsomorphic: true,
       };
-    } else {
-      this.fetchPersonDetails();
     }
   }
 
-  fetchPersonDetails() {
-    const form: GetPersonDetails = {
-      person_id: this.state.recipient_id,
-      sort: "New",
-      saved_only: false,
-      auth: myAuth(false),
-    };
-    WebSocketService.Instance.send(wsClient.getPersonDetails(form));
+  async componentDidMount() {
+    if (!this.state.isIsomorphic) {
+      await this.fetchPersonDetails();
+    }
   }
 
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
+  async fetchPersonDetails() {
+    this.setState({
+      recipientRes: { state: "loading" },
+    });
+
+    this.setState({
+      recipientRes: await HttpService.client.getPersonDetails({
+        person_id: this.state.recipientId,
+        sort: "New",
+        saved_only: false,
+        auth: myAuth(),
+      }),
+    });
+  }
+
+  static fetchInitialData(
+    req: InitialFetchRequest
+  ): Promise<RequestState<any>>[] {
     const person_id = Number(req.path.split("/").pop());
     const form: GetPersonDetails = {
       person_id,
@@ -86,62 +88,59 @@ export class CreatePrivateMessage extends Component<
   }
 
   get documentTitle(): string {
-    const name_ = this.state.recipientDetailsRes?.person_view.person.name;
-    return name_ ? `${i18n.t("create_private_message")} - ${name_}` : "";
+    if (this.state.recipientRes.state == "success") {
+      const name_ = this.state.recipientRes.data.person_view.person.name;
+      return `${i18n.t("create_private_message")} - ${name_}`;
+    } else {
+      return "";
+    }
   }
 
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
+  renderRecipientRes() {
+    switch (this.state.recipientRes.state) {
+      case "loading":
+        return (
+          <h5>
+            <Spinner large />
+          </h5>
+        );
+      case "success": {
+        const res = this.state.recipientRes.data;
+        return (
+          <div className="row">
+            <div className="col-12 col-lg-6 offset-lg-3 mb-4">
+              <h5>{i18n.t("create_private_message")}</h5>
+              <PrivateMessageForm
+                onCreate={this.handlePrivateMessageCreate}
+                recipient={res.person_view.person}
+              />
+            </div>
+          </div>
+        );
+      }
     }
   }
 
   render() {
-    const res = this.state.recipientDetailsRes;
     return (
       <div className="container-lg">
         <HtmlTags
           title={this.documentTitle}
           path={this.context.router.route.match.url}
         />
-        {this.state.loading ? (
-          <h5>
-            <Spinner large />
-          </h5>
-        ) : (
-          res && (
-            <div className="row">
-              <div className="col-12 col-lg-6 offset-lg-3 mb-4">
-                <h5>{i18n.t("create_private_message")}</h5>
-                <PrivateMessageForm
-                  onCreate={this.handlePrivateMessageCreate}
-                  recipient={res.person_view.person}
-                />
-              </div>
-            </div>
-          )
-        )}
+        {this.renderRecipientRes()}
       </div>
     );
   }
 
-  handlePrivateMessageCreate() {
-    toast(i18n.t("message_sent"));
+  async handlePrivateMessageCreate(form: CreatePrivateMessageI) {
+    const res = await HttpService.client.createPrivateMessage(form);
 
-    // Navigate to the front
-    this.context.router.history.push("/");
-  }
+    if (res.state == "success") {
+      toast(i18n.t("message_sent"));
 
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      this.setState({ loading: false });
-      return;
-    } else if (op == UserOperation.GetPersonDetails) {
-      const data = wsJsonToRes<GetPersonDetailsResponse>(msg);
-      this.setState({ recipientDetailsRes: data, loading: false });
+      // Navigate to the front
+      this.context.router.history.push("/");
     }
   }
 }
index 469009a4fddea0afb2a75ee0e09825bd8ea1cd0b..6cbf363ee2a314cb0a261f804d7cf8e9f50c209d 100644 (file)
@@ -1,27 +1,17 @@
-import { Component, linkEvent } from "inferno";
+import { Component, InfernoNode, linkEvent } from "inferno";
 import { T } from "inferno-i18next-dess";
 import {
   CreatePrivateMessage,
   EditPrivateMessage,
   Person,
-  PrivateMessageResponse,
   PrivateMessageView,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../../i18next";
-import { WebSocketService } from "../../services";
 import {
   capitalizeFirstLetter,
-  isBrowser,
-  myAuth,
+  myAuthRequired,
   relTags,
   setupTippy,
-  toast,
-  wsClient,
-  wsSubscribe,
 } from "../../utils";
 import { Icon, Spinner } from "../common/icon";
 import { MarkdownTextArea } from "../common/markdown-textarea";
@@ -32,8 +22,8 @@ interface PrivateMessageFormProps {
   recipient: Person;
   privateMessageView?: PrivateMessageView; // If a pm is given, that means this is an edit
   onCancel?(): any;
-  onCreate?(message: PrivateMessageView): any;
-  onEdit?(message: PrivateMessageView): any;
+  onCreate?(form: CreatePrivateMessage): void;
+  onEdit?(form: EditPrivateMessage): void;
 }
 
 interface PrivateMessageFormState {
@@ -41,165 +31,157 @@ interface PrivateMessageFormState {
   loading: boolean;
   previewMode: boolean;
   showDisclaimer: boolean;
+  submitted: boolean;
 }
 
 export class PrivateMessageForm extends Component<
   PrivateMessageFormProps,
   PrivateMessageFormState
 > {
-  private subscription?: Subscription;
   state: PrivateMessageFormState = {
     loading: false,
     previewMode: false,
     showDisclaimer: false,
+    content: this.props.privateMessageView
+      ? this.props.privateMessageView.private_message.content
+      : undefined,
+    submitted: false,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
 
     this.handleContentChange = this.handleContentChange.bind(this);
-
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
-
-    // Its an edit
-    if (this.props.privateMessageView) {
-      this.state.content =
-        this.props.privateMessageView.private_message.content;
-    }
   }
 
   componentDidMount() {
     setupTippy();
   }
 
-  componentDidUpdate() {
-    if (!this.state.loading && this.state.content) {
-      window.onbeforeunload = () => true;
-    } else {
-      window.onbeforeunload = null;
-    }
-  }
-
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
-      window.onbeforeunload = null;
+  componentWillReceiveProps(
+    nextProps: Readonly<{ children?: InfernoNode } & PrivateMessageFormProps>
+  ): void {
+    if (this.props != nextProps) {
+      this.setState({ loading: false, content: undefined, previewMode: false });
     }
   }
+  // TODO
+  // <Prompt
+  //   when={!this.state.loading && this.state.content}
+  //   message={i18n.t("block_leaving")}
+  // />
 
   render() {
     return (
-      <div>
-        <NavigationPrompt when={!this.state.loading && !!this.state.content} />
-        <form onSubmit={linkEvent(this, this.handlePrivateMessageSubmit)}>
-          {!this.props.privateMessageView && (
-            <div className="form-group row">
-              <label className="col-sm-2 col-form-label">
-                {capitalizeFirstLetter(i18n.t("to"))}
-              </label>
-
-              <div className="col-sm-10 form-control-plaintext">
-                <PersonListing person={this.props.recipient} />
-              </div>
-            </div>
-          )}
+      <form onSubmit={linkEvent(this, this.handlePrivateMessageSubmit)}>
+        <NavigationPrompt
+          when={
+            !this.state.loading && !!this.state.content && !this.state.submitted
+          }
+        />
+        {!this.props.privateMessageView && (
           <div className="form-group row">
             <label className="col-sm-2 col-form-label">
-              {i18n.t("message")}
-              <button
-                className="btn btn-link text-warning d-inline-block"
-                onClick={linkEvent(this, this.handleShowDisclaimer)}
-                data-tippy-content={i18n.t("private_message_disclaimer")}
-                aria-label={i18n.t("private_message_disclaimer")}
-              >
-                <Icon icon="alert-triangle" classes="icon-inline" />
-              </button>
+              {capitalizeFirstLetter(i18n.t("to"))}
             </label>
-            <div className="col-sm-10">
-              <MarkdownTextArea
-                initialContent={this.state.content}
-                onContentChange={this.handleContentChange}
-                allLanguages={[]}
-                siteLanguages={[]}
-              />
+
+            <div className="col-sm-10 form-control-plaintext">
+              <PersonListing person={this.props.recipient} />
             </div>
           </div>
+        )}
+        <div className="form-group row">
+          <label className="col-sm-2 col-form-label">
+            {i18n.t("message")}
+            <button
+              className="btn btn-link text-warning d-inline-block"
+              onClick={linkEvent(this, this.handleShowDisclaimer)}
+              data-tippy-content={i18n.t("private_message_disclaimer")}
+              aria-label={i18n.t("private_message_disclaimer")}
+            >
+              <Icon icon="alert-triangle" classes="icon-inline" />
+            </button>
+          </label>
+          <div className="col-sm-10">
+            <MarkdownTextArea
+              initialContent={this.state.content}
+              onContentChange={this.handleContentChange}
+              allLanguages={[]}
+              siteLanguages={[]}
+              hideNavigationWarnings
+            />
+          </div>
+        </div>
 
-          {this.state.showDisclaimer && (
-            <div className="form-group row">
-              <div className="offset-sm-2 col-sm-10">
-                <div className="alert alert-danger" role="alert">
-                  <T i18nKey="private_message_disclaimer">
+        {this.state.showDisclaimer && (
+          <div className="form-group row">
+            <div className="offset-sm-2 col-sm-10">
+              <div className="alert alert-danger" role="alert">
+                <T i18nKey="private_message_disclaimer">
+                  #
+                  <a
+                    className="alert-link"
+                    rel={relTags}
+                    href="https://element.io/get-started"
+                  >
                     #
-                    <a
-                      className="alert-link"
-                      rel={relTags}
-                      href="https://element.io/get-started"
-                    >
-                      #
-                    </a>
-                  </T>
-                </div>
+                  </a>
+                </T>
               </div>
             </div>
-          )}
-          <div className="form-group row">
-            <div className="offset-sm-2 col-sm-10">
+          </div>
+        )}
+        <div className="form-group row">
+          <div className="offset-sm-2 col-sm-10">
+            <button
+              type="submit"
+              className="btn btn-secondary mr-2"
+              disabled={this.state.loading}
+            >
+              {this.state.loading ? (
+                <Spinner />
+              ) : this.props.privateMessageView ? (
+                capitalizeFirstLetter(i18n.t("save"))
+              ) : (
+                capitalizeFirstLetter(i18n.t("send_message"))
+              )}
+            </button>
+            {this.props.privateMessageView && (
               <button
-                type="submit"
-                className="btn btn-secondary mr-2"
-                disabled={this.state.loading}
+                type="button"
+                className="btn btn-secondary"
+                onClick={linkEvent(this, this.handleCancel)}
               >
-                {this.state.loading ? (
-                  <Spinner />
-                ) : this.props.privateMessageView ? (
-                  capitalizeFirstLetter(i18n.t("save"))
-                ) : (
-                  capitalizeFirstLetter(i18n.t("send_message"))
-                )}
+                {i18n.t("cancel")}
               </button>
-              {this.props.privateMessageView && (
-                <button
-                  type="button"
-                  className="btn btn-secondary"
-                  onClick={linkEvent(this, this.handleCancel)}
-                >
-                  {i18n.t("cancel")}
-                </button>
-              )}
-              <ul className="d-inline-block float-right list-inline mb-1 text-muted font-weight-bold">
-                <li className="list-inline-item"></li>
-              </ul>
-            </div>
+            )}
+            <ul className="d-inline-block float-right list-inline mb-1 text-muted font-weight-bold">
+              <li className="list-inline-item"></li>
+            </ul>
           </div>
-        </form>
-      </div>
+        </div>
+      </form>
     );
   }
 
   handlePrivateMessageSubmit(i: PrivateMessageForm, event: any) {
     event.preventDefault();
+    i.setState({ loading: true, submitted: true });
     const pm = i.props.privateMessageView;
-    const auth = myAuth();
-    const content = i.state.content;
-    if (auth && content) {
-      if (pm) {
-        const form: EditPrivateMessage = {
-          private_message_id: pm.private_message.id,
-          content,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.editPrivateMessage(form));
-      } else {
-        const form: CreatePrivateMessage = {
-          content,
-          recipient_id: i.props.recipient.id,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.createPrivateMessage(form));
-      }
-      i.setState({ loading: true });
+    const auth = myAuthRequired();
+    const content = i.state.content ?? "";
+    if (pm) {
+      i.props.onEdit?.({
+        private_message_id: pm.private_message.id,
+        content,
+        auth,
+      });
+    } else {
+      i.props.onCreate?.({
+        content,
+        recipient_id: i.props.recipient.id,
+        auth,
+      });
     }
   }
 
@@ -219,25 +201,4 @@ export class PrivateMessageForm extends Component<
   handleShowDisclaimer(i: PrivateMessageForm) {
     i.setState({ showDisclaimer: !i.state.showDisclaimer });
   }
-
-  parseMessage(msg: any) {
-    const op = wsUserOp(msg);
-    console.log(msg);
-    if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      this.setState({ loading: false });
-      return;
-    } else if (
-      op == UserOperation.EditPrivateMessage ||
-      op == UserOperation.DeletePrivateMessage ||
-      op == UserOperation.MarkPrivateMessageAsRead
-    ) {
-      const data = wsJsonToRes<PrivateMessageResponse>(msg);
-      this.setState({ loading: false });
-      this.props.onEdit?.(data.private_message_view);
-    } else if (op == UserOperation.CreatePrivateMessage) {
-      const data = wsJsonToRes<PrivateMessageResponse>(msg);
-      this.props.onCreate?.(data.private_message_view);
-    }
-  }
 }
index dfa1850ed3c7bdae6e1e2dac47a3a8259d63690d..602654816b90ed5fa7c675f8aebd04dadfbda734 100644 (file)
@@ -1,24 +1,40 @@
-import { Component, linkEvent } from "inferno";
+import { Component, InfernoNode, linkEvent } from "inferno";
 import { T } from "inferno-i18next-dess";
 import {
   PrivateMessageReportView,
   ResolvePrivateMessageReport,
 } from "lemmy-js-client";
 import { i18n } from "../../i18next";
-import { WebSocketService } from "../../services";
-import { mdToHtml, myAuth, wsClient } from "../../utils";
-import { Icon } from "../common/icon";
+import { mdToHtml, myAuthRequired } from "../../utils";
+import { Icon, Spinner } from "../common/icon";
 import { PersonListing } from "../person/person-listing";
 
 interface Props {
   report: PrivateMessageReportView;
+  onResolveReport(form: ResolvePrivateMessageReport): void;
 }
 
-export class PrivateMessageReport extends Component<Props, any> {
+interface State {
+  loading: boolean;
+}
+
+export class PrivateMessageReport extends Component<Props, State> {
+  state: State = {
+    loading: false,
+  };
+
   constructor(props: any, context: any) {
     super(props, context);
   }
 
+  componentWillReceiveProps(
+    nextProps: Readonly<{ children?: InfernoNode } & Props>
+  ): void {
+    if (this.props != nextProps) {
+      this.setState({ loading: false });
+    }
+  }
+
   render() {
     const r = this.props.report;
     const pmr = r.private_message_report;
@@ -66,29 +82,28 @@ export class PrivateMessageReport extends Component<Props, any> {
           data-tippy-content={tippyContent}
           aria-label={tippyContent}
         >
-          <Icon
-            icon="check"
-            classes={`icon-inline ${
-              pmr.resolved ? "text-success" : "text-danger"
-            }`}
-          />
+          {this.state.loading ? (
+            <Spinner />
+          ) : (
+            <Icon
+              icon="check"
+              classes={`icon-inline ${
+                pmr.resolved ? "text-success" : "text-danger"
+              }`}
+            />
+          )}
         </button>
       </div>
     );
   }
 
   handleResolveReport(i: PrivateMessageReport) {
+    i.setState({ loading: true });
     const pmr = i.props.report.private_message_report;
-    const auth = myAuth();
-    if (auth) {
-      const form: ResolvePrivateMessageReport = {
-        report_id: pmr.id,
-        resolved: !pmr.resolved,
-        auth,
-      };
-      WebSocketService.Instance.send(
-        wsClient.resolvePrivateMessageReport(form)
-      );
-    }
+    i.props.onResolveReport({
+      report_id: pmr.id,
+      resolved: !pmr.resolved,
+      auth: myAuthRequired(),
+    });
   }
 }
index a9bd69702bfd181445c328ce9d8b2499aeabe2bd..89571aca54922951ca01e042420aeefad0701407 100644 (file)
@@ -1,15 +1,17 @@
-import { Component, linkEvent } from "inferno";
+import { Component, InfernoNode, linkEvent } from "inferno";
 import {
+  CreatePrivateMessage,
   CreatePrivateMessageReport,
   DeletePrivateMessage,
+  EditPrivateMessage,
   MarkPrivateMessageAsRead,
   Person,
   PrivateMessageView,
 } from "lemmy-js-client";
 import { i18n } from "../../i18next";
-import { UserService, WebSocketService } from "../../services";
-import { mdToHtml, myAuth, toast, wsClient } from "../../utils";
-import { Icon } from "../common/icon";
+import { UserService } from "../../services";
+import { mdToHtml, myAuthRequired } from "../../utils";
+import { Icon, Spinner } from "../common/icon";
 import { MomentTime } from "../common/moment-time";
 import { PersonListing } from "../person/person-listing";
 import { PrivateMessageForm } from "./private-message-form";
@@ -21,10 +23,18 @@ interface PrivateMessageState {
   viewSource: boolean;
   showReportDialog: boolean;
   reportReason?: string;
+  deleteLoading: boolean;
+  readLoading: boolean;
+  reportLoading: boolean;
 }
 
 interface PrivateMessageProps {
   private_message_view: PrivateMessageView;
+  onDelete(form: DeletePrivateMessage): void;
+  onMarkRead(form: MarkPrivateMessageAsRead): void;
+  onReport(form: CreatePrivateMessageReport): void;
+  onCreate(form: CreatePrivateMessage): void;
+  onEdit(form: EditPrivateMessage): void;
 }
 
 export class PrivateMessage extends Component<
@@ -37,15 +47,14 @@ export class PrivateMessage extends Component<
     collapsed: false,
     viewSource: false,
     showReportDialog: false,
+    deleteLoading: false,
+    readLoading: false,
+    reportLoading: false,
   };
 
   constructor(props: any, context: any) {
     super(props, context);
-
     this.handleReplyCancel = this.handleReplyCancel.bind(this);
-    this.handlePrivateMessageCreate =
-      this.handlePrivateMessageCreate.bind(this);
-    this.handlePrivateMessageEdit = this.handlePrivateMessageEdit.bind(this);
   }
 
   get mine(): boolean {
@@ -55,6 +64,23 @@ export class PrivateMessage extends Component<
     );
   }
 
+  componentWillReceiveProps(
+    nextProps: Readonly<{ children?: InfernoNode } & PrivateMessageProps>
+  ): void {
+    if (this.props != nextProps) {
+      this.setState({
+        showReply: false,
+        showEdit: false,
+        collapsed: false,
+        viewSource: false,
+        showReportDialog: false,
+        deleteLoading: false,
+        readLoading: false,
+        reportLoading: false,
+      });
+    }
+  }
+
   render() {
     const message_view = this.props.private_message_view;
     const otherPerson: Person = this.mine
@@ -98,8 +124,7 @@ export class PrivateMessage extends Component<
             <PrivateMessageForm
               recipient={otherPerson}
               privateMessageView={message_view}
-              onEdit={this.handlePrivateMessageEdit}
-              onCreate={this.handlePrivateMessageCreate}
+              onEdit={this.props.onEdit}
               onCancel={this.handleReplyCancel}
             />
           )}
@@ -131,12 +156,17 @@ export class PrivateMessage extends Component<
                             : i18n.t("mark_as_read")
                         }
                       >
-                        <Icon
-                          icon="check"
-                          classes={`icon-inline ${
-                            message_view.private_message.read && "text-success"
-                          }`}
-                        />
+                        {this.state.readLoading ? (
+                          <Spinner />
+                        ) : (
+                          <Icon
+                            icon="check"
+                            classes={`icon-inline ${
+                              message_view.private_message.read &&
+                              "text-success"
+                            }`}
+                          />
+                        )}
                       </button>
                     </li>
                     <li className="list-inline-item">{this.reportButton}</li>
@@ -179,13 +209,17 @@ export class PrivateMessage extends Component<
                             : i18n.t("restore")
                         }
                       >
-                        <Icon
-                          icon="trash"
-                          classes={`icon-inline ${
-                            message_view.private_message.deleted &&
-                            "text-danger"
-                          }`}
-                        />
+                        {this.state.deleteLoading ? (
+                          <Spinner />
+                        ) : (
+                          <Icon
+                            icon="trash"
+                            classes={`icon-inline ${
+                              message_view.private_message.deleted &&
+                              "text-danger"
+                            }`}
+                          />
+                        )}
                       </button>
                     </li>
                   </>
@@ -231,14 +265,14 @@ export class PrivateMessage extends Component<
               className="btn btn-secondary"
               aria-label={i18n.t("create_report")}
             >
-              {i18n.t("create_report")}
+              {this.state.reportLoading ? <Spinner /> : i18n.t("create_report")}
             </button>
           </form>
         )}
         {this.state.showReply && (
           <PrivateMessageForm
             recipient={otherPerson}
-            onCreate={this.handlePrivateMessageCreate}
+            onCreate={this.props.onCreate}
           />
         )}
         {/* A collapsed clearfix */}
@@ -275,15 +309,12 @@ export class PrivateMessage extends Component<
   }
 
   handleDeleteClick(i: PrivateMessage) {
-    const auth = myAuth();
-    if (auth) {
-      const form: DeletePrivateMessage = {
-        private_message_id: i.props.private_message_view.private_message.id,
-        deleted: !i.props.private_message_view.private_message.deleted,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.deletePrivateMessage(form));
-    }
+    i.setState({ deleteLoading: true });
+    i.props.onDelete({
+      private_message_id: i.props.private_message_view.private_message.id,
+      deleted: !i.props.private_message_view.private_message.deleted,
+      auth: myAuthRequired(),
+    });
   }
 
   handleReplyCancel() {
@@ -291,15 +322,12 @@ export class PrivateMessage extends Component<
   }
 
   handleMarkRead(i: PrivateMessage) {
-    const auth = myAuth();
-    if (auth) {
-      const form: MarkPrivateMessageAsRead = {
-        private_message_id: i.props.private_message_view.private_message.id,
-        read: !i.props.private_message_view.private_message.read,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.markPrivateMessageAsRead(form));
-    }
+    i.setState({ readLoading: true });
+    i.props.onMarkRead({
+      private_message_id: i.props.private_message_view.private_message.id,
+      read: !i.props.private_message_view.private_message.read,
+      auth: myAuthRequired(),
+    });
   }
 
   handleMessageCollapse(i: PrivateMessage) {
@@ -320,31 +348,11 @@ export class PrivateMessage extends Component<
 
   handleReportSubmit(i: PrivateMessage, event: any) {
     event.preventDefault();
-    const auth = myAuth();
-    const reason = i.state.reportReason;
-    if (auth && reason) {
-      const form: CreatePrivateMessageReport = {
-        private_message_id: i.props.private_message_view.private_message.id,
-        reason,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.createPrivateMessageReport(form));
-
-      i.setState({ showReportDialog: false });
-    }
-  }
-
-  handlePrivateMessageEdit() {
-    this.setState({ showEdit: false });
-  }
-
-  handlePrivateMessageCreate(message: PrivateMessageView) {
-    if (
-      message.creator.id ==
-      UserService.Instance.myUserInfo?.local_user_view.person.id
-    ) {
-      this.setState({ showReply: false });
-      toast(i18n.t("message_sent"));
-    }
+    i.setState({ reportLoading: true });
+    i.props.onReport({
+      private_message_id: i.props.private_message_view.private_message.id,
+      reason: i.state.reportReason ?? "",
+      auth: myAuthRequired(),
+    });
   }
 }
index 9f762785ac8b40994a4a0e0e8cd0245ba1a19d70..8097dbde433b7ac7ef3811e47cc44cd6d4ffe76b 100644 (file)
@@ -1,7 +1,6 @@
 import type { NoOptionI18nKeys } from "i18next";
 import { Component, linkEvent } from "inferno";
 import {
-  CommentResponse,
   CommentView,
   CommunityView,
   GetCommunity,
@@ -13,7 +12,6 @@ import {
   ListCommunitiesResponse,
   ListingType,
   PersonView,
-  PostResponse,
   PostView,
   ResolveObject,
   ResolveObjectResponse,
@@ -21,22 +19,17 @@ import {
   SearchResponse,
   SearchType,
   SortType,
-  UserOperation,
-  wsJsonToRes,
-  wsUserOp,
 } from "lemmy-js-client";
-import { Subscription } from "rxjs";
 import { i18n } from "../i18next";
 import { CommentViewType, InitialFetchRequest } from "../interfaces";
-import { WebSocketService } from "../services";
+import { FirstLoadService } from "../services/FirstLoadService";
+import { HttpService, RequestState } from "../services/HttpService";
 import {
   Choice,
   QueryParams,
   capitalizeFirstLetter,
   commentsToFlatNodes,
   communityToChoice,
-  createCommentLikeRes,
-  createPostLikeFindRes,
   debounce,
   enableDownvotes,
   enableNsfw,
@@ -55,9 +48,6 @@ import {
   saveScrollPosition,
   setIsoData,
   showLocal,
-  toast,
-  wsClient,
-  wsSubscribe,
 } from "../utils";
 import { CommentNodes } from "./comment/comment-nodes";
 import { HtmlTags } from "./common/html-tags";
@@ -83,17 +73,18 @@ interface SearchProps {
 type FilterType = "creator" | "community";
 
 interface SearchState {
-  searchResponse?: SearchResponse;
-  communities: CommunityView[];
-  creatorDetails?: GetPersonDetailsResponse;
-  searchLoading: boolean;
-  searchCommunitiesLoading: boolean;
-  searchCreatorLoading: boolean;
+  searchRes: RequestState<SearchResponse>;
+  resolveObjectRes: RequestState<ResolveObjectResponse>;
+  creatorDetailsRes: RequestState<GetPersonDetailsResponse>;
+  communitiesRes: RequestState<ListCommunitiesResponse>;
+  communityRes: RequestState<GetCommunityResponse>;
   siteRes: GetSiteResponse;
   searchText?: string;
-  resolveObjectResponse?: ResolveObjectResponse;
   communitySearchOptions: Choice[];
   creatorSearchOptions: Choice[];
+  searchCreatorLoading: boolean;
+  searchCommunitiesLoading: boolean;
+  isIsomorphic: boolean;
 }
 
 interface Combined {
@@ -238,15 +229,18 @@ function getListing(
 
 export class Search extends Component<any, SearchState> {
   private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
   state: SearchState = {
-    searchLoading: false,
+    resolveObjectRes: { state: "empty" },
+    creatorDetailsRes: { state: "empty" },
+    communitiesRes: { state: "empty" },
+    communityRes: { state: "empty" },
     siteRes: this.isoData.site_res,
-    communities: [],
-    searchCommunitiesLoading: false,
-    searchCreatorLoading: false,
     creatorSearchOptions: [],
     communitySearchOptions: [],
+    searchRes: { state: "empty" },
+    searchCreatorLoading: false,
+    searchCommunitiesLoading: false,
+    isIsomorphic: false,
   };
 
   constructor(props: any, context: any) {
@@ -259,9 +253,6 @@ export class Search extends Component<any, SearchState> {
       this.handleCommunityFilterChange.bind(this);
     this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
-
     const { q } = getSearchQueryParams();
 
     this.state = {
@@ -270,71 +261,70 @@ export class Search extends Component<any, SearchState> {
     };
 
     // Only fetch the data if coming from another route
-    if (this.isoData.path === this.context.router.route.match.url) {
-      const communityRes = this.isoData.routeData[0] as
-        | GetCommunityResponse
-        | undefined;
-      const communitiesRes = this.isoData.routeData[1] as
-        | ListCommunitiesResponse
-        | undefined;
-      // This can be single or multiple communities given
-      if (communitiesRes) {
-        this.state = {
-          ...this.state,
-          communities: communitiesRes.communities,
-        };
-      }
-      if (communityRes) {
+    if (FirstLoadService.isFirstLoad) {
+      const [
+        communityRes,
+        communitiesRes,
+        creatorDetailsRes,
+        searchRes,
+        resolveObjectRes,
+      ] = this.isoData.routeData;
+
+      this.state = {
+        ...this.state,
+        communitiesRes,
+        communityRes,
+        creatorDetailsRes,
+        creatorSearchOptions:
+          creatorDetailsRes.state == "success"
+            ? [personToChoice(creatorDetailsRes.data.person_view)]
+            : [],
+        isIsomorphic: true,
+      };
+
+      if (communityRes.state === "success") {
         this.state = {
           ...this.state,
-          communities: [communityRes.community_view],
           communitySearchOptions: [
-            communityToChoice(communityRes.community_view),
+            communityToChoice(communityRes.data.community_view),
           ],
         };
       }
 
-      const creatorRes = this.isoData.routeData[2] as GetPersonDetailsResponse;
-
-      this.state = {
-        ...this.state,
-        creatorDetails: creatorRes,
-        creatorSearchOptions: creatorRes
-          ? [personToChoice(creatorRes.person_view)]
-          : [],
-      };
-
-      if (q !== "") {
+      if (q) {
         this.state = {
           ...this.state,
-          searchResponse: this.isoData.routeData[3] as SearchResponse,
-          resolveObjectResponse: this.isoData
-            .routeData[4] as ResolveObjectResponse,
-          searchLoading: false,
+          searchRes,
+          resolveObjectRes,
         };
-      } else {
-        this.search();
       }
-    } else {
-      const listCommunitiesForm: ListCommunities = {
-        type_: defaultListingType,
-        sort: defaultSortType,
-        limit: fetchLimit,
-        auth: myAuth(false),
-      };
-
-      WebSocketService.Instance.send(
-        wsClient.listCommunities(listCommunitiesForm)
-      );
+    }
+  }
 
-      if (q) {
-        this.search();
+  async componentDidMount() {
+    if (!this.state.isIsomorphic) {
+      const promises = [this.fetchCommunities()];
+      if (this.state.searchText) {
+        promises.push(this.search());
       }
+
+      await Promise.all(promises);
     }
   }
 
+  async fetchCommunities() {
+    this.setState({ communitiesRes: { state: "loading" } });
+    this.setState({
+      communitiesRes: await HttpService.client.listCommunities({
+        type_: defaultListingType,
+        sort: defaultSortType,
+        limit: fetchLimit,
+        auth: myAuth(),
+      }),
+    });
+  }
+
   componentWillUnmount() {
-    this.subscription?.unsubscribe();
     saveScrollPosition(this.context);
   }
 
@@ -342,8 +332,10 @@ export class Search extends Component<any, SearchState> {
     client,
     auth,
     query: { communityId, creatorId, q, type, sort, listingType, page },
-  }: InitialFetchRequest<QueryParams<SearchProps>>): Promise<any>[] {
-    const promises: Promise<any>[] = [];
+  }: InitialFetchRequest<QueryParams<SearchProps>>): Promise<
+    RequestState<any>
+  >[] {
+    const promises: Promise<RequestState<any>>[] = [];
 
     const community_id = getIdFromString(communityId);
     if (community_id) {
@@ -352,7 +344,7 @@ export class Search extends Component<any, SearchState> {
         auth,
       };
       promises.push(client.getCommunity(getCommunityForm));
-      promises.push(Promise.resolve());
+      promises.push(Promise.resolve({ state: "empty" }));
     } else {
       const listCommunitiesForm: ListCommunities = {
         type_: defaultListingType,
@@ -360,7 +352,7 @@ export class Search extends Component<any, SearchState> {
         limit: fetchLimit,
         auth,
       };
-      promises.push(Promise.resolve());
+      promises.push(Promise.resolve({ state: "empty" }));
       promises.push(client.listCommunities(listCommunitiesForm));
     }
 
@@ -372,7 +364,7 @@ export class Search extends Component<any, SearchState> {
       };
       promises.push(client.getPersonDetails(getCreatorForm));
     } else {
-      promises.push(Promise.resolve());
+      promises.push(Promise.resolve({ state: "empty" }));
     }
 
     const query = getSearchQueryFromQuery(q);
@@ -400,8 +392,8 @@ export class Search extends Component<any, SearchState> {
           promises.push(client.resolveObject(resolveObjectForm));
         }
       } else {
-        promises.push(Promise.resolve());
-        promises.push(Promise.resolve());
+        promises.push(Promise.resolve({ state: "empty" }));
+        promises.push(Promise.resolve({ state: "empty" }));
       }
     }
 
@@ -427,9 +419,10 @@ export class Search extends Component<any, SearchState> {
         {this.selects}
         {this.searchForm}
         {this.displayResults(type)}
-        {this.resultsCount === 0 && !this.state.searchLoading && (
-          <span>{i18n.t("no_results")}</span>
-        )}
+        {this.resultsCount === 0 &&
+          this.state.searchRes.state === "success" && (
+            <span>{i18n.t("no_results")}</span>
+          )}
         <Paginator page={page} onChange={this.handlePageChange} />
       </div>
     );
@@ -470,7 +463,7 @@ export class Search extends Component<any, SearchState> {
           minLength={1}
         />
         <button type="submit" className="btn btn-secondary mr-2 mb-2">
-          {this.state.searchLoading ? (
+          {this.state.searchRes.state == "loading" ? (
             <Spinner />
           ) : (
             <span>{i18n.t("search")}</span>
@@ -488,8 +481,13 @@ export class Search extends Component<any, SearchState> {
       creatorSearchOptions,
       searchCommunitiesLoading,
       searchCreatorLoading,
+      communitiesRes,
     } = this.state;
 
+    const hasCommunities =
+      communitiesRes.state == "success" &&
+      communitiesRes.data.communities.length > 0;
+
     return (
       <div className="mb-2">
         <select
@@ -524,14 +522,14 @@ export class Search extends Component<any, SearchState> {
           />
         </span>
         <div className="form-row">
-          {this.state.communities.length > 0 && (
+          {hasCommunities && (
             <Filter
               filterType="community"
               onChange={this.handleCommunityFilterChange}
               onSearch={this.handleCommunitySearch}
               options={communitySearchOptions}
-              loading={searchCommunitiesLoading}
               value={communityId}
+              loading={searchCommunitiesLoading}
             />
           )}
           <Filter
@@ -539,8 +537,8 @@ export class Search extends Component<any, SearchState> {
             onChange={this.handleCreatorFilterChange}
             onSearch={this.handleCreatorSearch}
             options={creatorSearchOptions}
-            loading={searchCreatorLoading}
             value={creatorId}
+            loading={searchCreatorLoading}
           />
         </div>
       </div>
@@ -549,11 +547,14 @@ export class Search extends Component<any, SearchState> {
 
   buildCombined(): Combined[] {
     const combined: Combined[] = [];
-    const { resolveObjectResponse, searchResponse } = this.state;
+    const {
+      resolveObjectRes: resolveObjectResponse,
+      searchRes: searchResponse,
+    } = this.state;
 
     // Push the possible resolve / federated objects first
-    if (resolveObjectResponse) {
-      const { comment, post, community, person } = resolveObjectResponse;
+    if (resolveObjectResponse.state == "success") {
+      const { comment, post, community, person } = resolveObjectResponse.data;
 
       if (comment) {
         combined.push(commentViewToCombined(comment));
@@ -570,8 +571,8 @@ export class Search extends Component<any, SearchState> {
     }
 
     // Push the search results
-    if (searchResponse) {
-      const { comments, posts, communities, users } = searchResponse;
+    if (searchResponse.state === "success") {
+      const { comments, posts, communities, users } = searchResponse.data;
 
       combined.push(
         ...[
@@ -622,6 +623,23 @@ export class Search extends Component<any, SearchState> {
                   allLanguages={this.state.siteRes.all_languages}
                   siteLanguages={this.state.siteRes.discussion_languages}
                   viewOnly
+                  // All of these are unused, since its view only
+                  onPostEdit={() => {}}
+                  onPostVote={() => {}}
+                  onPostReport={() => {}}
+                  onBlockPerson={() => {}}
+                  onLockPost={() => {}}
+                  onDeletePost={() => {}}
+                  onRemovePost={() => {}}
+                  onSavePost={() => {}}
+                  onFeaturePost={() => {}}
+                  onPurgePerson={() => {}}
+                  onPurgePost={() => {}}
+                  onBanPersonFromCommunity={() => {}}
+                  onBanPerson={() => {}}
+                  onAddModToCommunity={() => {}}
+                  onAddAdmin={() => {}}
+                  onTransferCommunity={() => {}}
                 />
               )}
               {i.type_ === "comments" && (
@@ -641,6 +659,26 @@ export class Search extends Component<any, SearchState> {
                   enableDownvotes={enableDownvotes(this.state.siteRes)}
                   allLanguages={this.state.siteRes.all_languages}
                   siteLanguages={this.state.siteRes.discussion_languages}
+                  // All of these are unused, since its viewonly
+                  finished={new Map()}
+                  onSaveComment={() => {}}
+                  onBlockPerson={() => {}}
+                  onDeleteComment={() => {}}
+                  onRemoveComment={() => {}}
+                  onCommentVote={() => {}}
+                  onCommentReport={() => {}}
+                  onDistinguishComment={() => {}}
+                  onAddModToCommunity={() => {}}
+                  onAddAdmin={() => {}}
+                  onTransferCommunity={() => {}}
+                  onPurgeComment={() => {}}
+                  onPurgePerson={() => {}}
+                  onCommentReplyRead={() => {}}
+                  onPersonMentionRead={() => {}}
+                  onBanPersonFromCommunity={() => {}}
+                  onBanPerson={() => {}}
+                  onCreateComment={() => Promise.resolve({ state: "empty" })}
+                  onEditComment={() => Promise.resolve({ state: "empty" })}
                 />
               )}
               {i.type_ === "communities" && (
@@ -657,11 +695,19 @@ export class Search extends Component<any, SearchState> {
   }
 
   get comments() {
-    const { searchResponse, resolveObjectResponse, siteRes } = this.state;
-    const comments = searchResponse?.comments ?? [];
-
-    if (resolveObjectResponse?.comment) {
-      comments.unshift(resolveObjectResponse?.comment);
+    const {
+      searchRes: searchResponse,
+      resolveObjectRes: resolveObjectResponse,
+      siteRes,
+    } = this.state;
+    const comments =
+      searchResponse.state === "success" ? searchResponse.data.comments : [];
+
+    if (
+      resolveObjectResponse.state === "success" &&
+      resolveObjectResponse.data.comment
+    ) {
+      comments.unshift(resolveObjectResponse.data.comment);
     }
 
     return (
@@ -674,16 +720,44 @@ export class Search extends Component<any, SearchState> {
         enableDownvotes={enableDownvotes(siteRes)}
         allLanguages={siteRes.all_languages}
         siteLanguages={siteRes.discussion_languages}
+        // All of these are unused, since its viewonly
+        finished={new Map()}
+        onSaveComment={() => {}}
+        onBlockPerson={() => {}}
+        onDeleteComment={() => {}}
+        onRemoveComment={() => {}}
+        onCommentVote={() => {}}
+        onCommentReport={() => {}}
+        onDistinguishComment={() => {}}
+        onAddModToCommunity={() => {}}
+        onAddAdmin={() => {}}
+        onTransferCommunity={() => {}}
+        onPurgeComment={() => {}}
+        onPurgePerson={() => {}}
+        onCommentReplyRead={() => {}}
+        onPersonMentionRead={() => {}}
+        onBanPersonFromCommunity={() => {}}
+        onBanPerson={() => {}}
+        onCreateComment={() => Promise.resolve({ state: "empty" })}
+        onEditComment={() => Promise.resolve({ state: "empty" })}
       />
     );
   }
 
   get posts() {
-    const { searchResponse, resolveObjectResponse, siteRes } = this.state;
-    const posts = searchResponse?.posts ?? [];
-
-    if (resolveObjectResponse?.post) {
-      posts.unshift(resolveObjectResponse.post);
+    const {
+      searchRes: searchResponse,
+      resolveObjectRes: resolveObjectResponse,
+      siteRes,
+    } = this.state;
+    const posts =
+      searchResponse.state === "success" ? searchResponse.data.posts : [];
+
+    if (
+      resolveObjectResponse.state === "success" &&
+      resolveObjectResponse.data.post
+    ) {
+      posts.unshift(resolveObjectResponse.data.post);
     }
 
     return (
@@ -699,6 +773,23 @@ export class Search extends Component<any, SearchState> {
                 allLanguages={siteRes.all_languages}
                 siteLanguages={siteRes.discussion_languages}
                 viewOnly
+                // All of these are unused, since its view only
+                onPostEdit={() => {}}
+                onPostVote={() => {}}
+                onPostReport={() => {}}
+                onBlockPerson={() => {}}
+                onLockPost={() => {}}
+                onDeletePost={() => {}}
+                onRemovePost={() => {}}
+                onSavePost={() => {}}
+                onFeaturePost={() => {}}
+                onPurgePerson={() => {}}
+                onPurgePost={() => {}}
+                onBanPersonFromCommunity={() => {}}
+                onBanPerson={() => {}}
+                onAddModToCommunity={() => {}}
+                onAddAdmin={() => {}}
+                onTransferCommunity={() => {}}
               />
             </div>
           </div>
@@ -708,11 +799,18 @@ export class Search extends Component<any, SearchState> {
   }
 
   get communities() {
-    const { searchResponse, resolveObjectResponse } = this.state;
-    const communities = searchResponse?.communities ?? [];
-
-    if (resolveObjectResponse?.community) {
-      communities.unshift(resolveObjectResponse.community);
+    const {
+      searchRes: searchResponse,
+      resolveObjectRes: resolveObjectResponse,
+    } = this.state;
+    const communities =
+      searchResponse.state === "success" ? searchResponse.data.communities : [];
+
+    if (
+      resolveObjectResponse.state === "success" &&
+      resolveObjectResponse.data.community
+    ) {
+      communities.unshift(resolveObjectResponse.data.community);
     }
 
     return (
@@ -727,11 +825,18 @@ export class Search extends Component<any, SearchState> {
   }
 
   get users() {
-    const { searchResponse, resolveObjectResponse } = this.state;
-    const users = searchResponse?.users ?? [];
-
-    if (resolveObjectResponse?.person) {
-      users.unshift(resolveObjectResponse.person);
+    const {
+      searchRes: searchResponse,
+      resolveObjectRes: resolveObjectResponse,
+    } = this.state;
+    const users =
+      searchResponse.state === "success" ? searchResponse.data.users : [];
+
+    if (
+      resolveObjectResponse.state === "success" &&
+      resolveObjectResponse.data.person
+    ) {
+      users.unshift(resolveObjectResponse.data.person);
     }
 
     return (
@@ -746,75 +851,72 @@ export class Search extends Component<any, SearchState> {
   }
 
   get resultsCount(): number {
-    const { searchResponse: r, resolveObjectResponse: resolveRes } = this.state;
-
-    const searchCount = r
-      ? r.posts.length +
-        r.comments.length +
-        r.communities.length +
-        r.users.length
-      : 0;
-
-    const resObjCount = resolveRes
-      ? resolveRes.post ||
-        resolveRes.person ||
-        resolveRes.community ||
-        resolveRes.comment
-        ? 1
-        : 0
-      : 0;
+    const { searchRes: r, resolveObjectRes: resolveRes } = this.state;
+
+    const searchCount =
+      r.state === "success"
+        ? r.data.posts.length +
+          r.data.comments.length +
+          r.data.communities.length +
+          r.data.users.length
+        : 0;
+
+    const resObjCount =
+      resolveRes.state === "success"
+        ? resolveRes.data.post ||
+          resolveRes.data.person ||
+          resolveRes.data.community ||
+          resolveRes.data.comment
+          ? 1
+          : 0
+        : 0;
 
     return resObjCount + searchCount;
   }
 
-  search() {
-    const auth = myAuth(false);
+  async search() {
+    const auth = myAuth();
     const { searchText: q } = this.state;
     const { communityId, creatorId, type, sort, listingType, page } =
       getSearchQueryParams();
 
-    if (q && q !== "") {
-      const form: SearchForm = {
-        q,
-        community_id: communityId ?? undefined,
-        creator_id: creatorId ?? undefined,
-        type_: type,
-        sort,
-        listing_type: listingType,
-        page,
-        limit: fetchLimit,
-        auth,
-      };
-
-      if (auth) {
-        const resolveObjectForm: ResolveObject = {
+    if (q) {
+      this.setState({ searchRes: { state: "loading" } });
+      this.setState({
+        searchRes: await HttpService.client.search({
           q,
+          community_id: communityId ?? undefined,
+          creator_id: creatorId ?? undefined,
+          type_: type,
+          sort,
+          listing_type: listingType,
+          page,
+          limit: fetchLimit,
           auth,
-        };
-        WebSocketService.Instance.send(
-          wsClient.resolveObject(resolveObjectForm)
-        );
-      }
-
-      this.setState({
-        searchResponse: undefined,
-        resolveObjectResponse: undefined,
-        searchLoading: true,
+        }),
       });
+      window.scrollTo(0, 0);
+      restoreScrollPosition(this.context);
 
-      WebSocketService.Instance.send(wsClient.search(form));
+      if (auth) {
+        this.setState({ resolveObjectRes: { state: "loading" } });
+        this.setState({
+          resolveObjectRes: await HttpService.client.resolveObject({
+            q,
+            auth,
+          }),
+        });
+      }
     }
   }
 
   handleCreatorSearch = debounce(async (text: string) => {
     const { creatorId } = getSearchQueryParams();
     const { creatorSearchOptions } = this.state;
-    this.setState({
-      searchCreatorLoading: true,
-    });
-
     const newOptions: Choice[] = [];
 
+    this.setState({ searchCreatorLoading: true });
+
     const selectedChoice = creatorSearchOptions.find(
       choice => getIdFromString(choice.value) === creatorId
     );
@@ -824,7 +926,7 @@ export class Search extends Component<any, SearchState> {
     }
 
     if (text.length > 0) {
-      newOptions.push(...(await fetchUsers(text)).users.map(personToChoice));
+      newOptions.push(...(await fetchUsers(text)).map(personToChoice));
     }
 
     this.setState({
@@ -851,9 +953,7 @@ export class Search extends Component<any, SearchState> {
     }
 
     if (text.length > 0) {
-      newOptions.push(
-        ...(await fetchCommunities(text)).communities.map(communityToChoice)
-      );
+      newOptions.push(...(await fetchCommunities(text)).map(communityToChoice));
     }
 
     this.setState({
@@ -913,7 +1013,7 @@ export class Search extends Component<any, SearchState> {
     i.setState({ searchText: event.target.value });
   }
 
-  updateUrl({
+  async updateUrl({
     q,
     type,
     listingType,
@@ -950,71 +1050,6 @@ export class Search extends Component<any, SearchState> {
 
     this.props.history.push(`/search${getQueryString(queryParams)}`);
 
-    this.search();
-  }
-
-  parseMessage(msg: any) {
-    console.log(msg);
-    const op = wsUserOp(msg);
-    if (msg.error) {
-      if (msg.error === "couldnt_find_object") {
-        this.setState({
-          resolveObjectResponse: {},
-        });
-        this.checkFinishedLoading();
-      } else {
-        toast(i18n.t(msg.error), "danger");
-      }
-    } else {
-      switch (op) {
-        case UserOperation.Search: {
-          const searchResponse = wsJsonToRes<SearchResponse>(msg);
-          this.setState({ searchResponse });
-          window.scrollTo(0, 0);
-          this.checkFinishedLoading();
-          restoreScrollPosition(this.context);
-
-          break;
-        }
-
-        case UserOperation.CreateCommentLike: {
-          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
-          createCommentLikeRes(
-            comment_view,
-            this.state.searchResponse?.comments
-          );
-
-          break;
-        }
-
-        case UserOperation.CreatePostLike: {
-          const { post_view } = wsJsonToRes<PostResponse>(msg);
-          createPostLikeFindRes(post_view, this.state.searchResponse?.posts);
-
-          break;
-        }
-
-        case UserOperation.ListCommunities: {
-          const { communities } = wsJsonToRes<ListCommunitiesResponse>(msg);
-          this.setState({ communities });
-
-          break;
-        }
-
-        case UserOperation.ResolveObject: {
-          const resolveObjectResponse = wsJsonToRes<ResolveObjectResponse>(msg);
-          this.setState({ resolveObjectResponse });
-          this.checkFinishedLoading();
-
-          break;
-        }
-      }
-    }
-  }
-
-  checkFinishedLoading() {
-    if (this.state.searchResponse || this.state.resolveObjectResponse) {
-      this.setState({ searchLoading: false });
-    }
+    await this.search();
   }
 }
index ee61f086ff03e47c21fd85afb849e203c65fa817..576c6c58e916f9d9274bd40200555a9892a8b19c 100644 (file)
@@ -34,12 +34,6 @@ function getHost() {
   return isBrowser() ? getExternalHost() : getInternalHost();
 }
 
-function getWsHost() {
-  return isBrowser()
-    ? window.lemmyConfig?.wsHost ?? getHost()
-    : process.env.LEMMY_UI_LEMMY_WS_HOST ?? getExternalHost();
-}
-
 function getBaseLocal(s = "") {
   return `http${s}://${getHost()}`;
 }
@@ -47,18 +41,20 @@ function getBaseLocal(s = "") {
 export function getHttpBaseInternal() {
   return getBaseLocal(); // Don't use secure here
 }
+
+export function getHttpBaseExternal() {
+  return `http${getSecure()}://${getExternalHost()}`;
+}
+
 export function getHttpBase() {
   return getBaseLocal(getSecure());
 }
-export function getWsUri() {
-  return `ws${getSecure()}://${getWsHost()}/api/v3/ws`;
-}
+
 export function isHttps() {
   return getSecure() === "s";
 }
 
 console.log(`httpbase: ${getHttpBase()}`);
-console.log(`wsUri: ${getWsUri()}`);
 console.log(`isHttps: ${isHttps()}`);
 
 // This is for html tags, don't include port
index a6b2ae47efb8676ffa0cda741377388d1d0062bb..3b64f60533dc246b6fd6fe74f5dfb2387ef29fed 100644 (file)
@@ -1,5 +1,6 @@
-import { CommentView, GetSiteResponse, LemmyHttp } from "lemmy-js-client";
+import { CommentView, GetSiteResponse } from "lemmy-js-client";
 import type { ParsedQs } from "qs";
+import { RequestState, WrappedLemmyHttp } from "./services/HttpService";
 import { ErrorPageData } from "./utils";
 
 /**
@@ -7,7 +8,7 @@ import { ErrorPageData } from "./utils";
  */
 export interface IsoData {
   path: string;
-  routeData: any[];
+  routeData: RequestState<any>[];
   site_res: GetSiteResponse;
   errorPageData?: ErrorPageData;
 }
@@ -28,7 +29,7 @@ declare global {
 
 export interface InitialFetchRequest<T extends ParsedQs = ParsedQs> {
   auth?: string;
-  client: LemmyHttp;
+  client: WrappedLemmyHttp;
   path: string;
   query: T;
   site: GetSiteResponse;
@@ -69,6 +70,11 @@ export enum PurgeType {
   Comment,
 }
 
+export enum VoteType {
+  Upvote,
+  Downvote,
+}
+
 export interface CommentNodeI {
   comment_view: CommentView;
   children: Array<CommentNodeI>;
index b5c28189d23f96f52079d7e9ae75308114d491e5..4973bec794dbfb26a34b7d20e2cd848dcab52fd7 100644 (file)
@@ -22,10 +22,11 @@ import { Post } from "./components/post/post";
 import { CreatePrivateMessage } from "./components/private_message/create-private-message";
 import { Search } from "./components/search";
 import { InitialFetchRequest } from "./interfaces";
+import { RequestState } from "./services/HttpService";
 
 interface IRoutePropsWithFetch extends IRouteProps {
   // TODO Make sure this one is good.
-  fetchInitialData?(req: InitialFetchRequest): Promise<any>[];
+  fetchInitialData?(req: InitialFetchRequest): Promise<RequestState<any>>[];
 }
 
 export const routes: IRoutePropsWithFetch[] = [
diff --git a/src/shared/services/FirstLoadService.ts b/src/shared/services/FirstLoadService.ts
new file mode 100644 (file)
index 0000000..b7558ef
--- /dev/null
@@ -0,0 +1,25 @@
+export class FirstLoadService {
+  #isFirstLoad: boolean;
+  static #instance: FirstLoadService;
+
+  private constructor() {
+    this.#isFirstLoad = true;
+  }
+
+  get isFirstLoad() {
+    const isFirst = this.#isFirstLoad;
+    if (isFirst) {
+      this.#isFirstLoad = false;
+    }
+
+    return isFirst;
+  }
+
+  static get #Instance() {
+    return this.#instance ?? (this.#instance = new this());
+  }
+
+  static get isFirstLoad() {
+    return this.#Instance.isFirstLoad;
+  }
+}
diff --git a/src/shared/services/HistoryService.ts b/src/shared/services/HistoryService.ts
new file mode 100644 (file)
index 0000000..0e7ec53
--- /dev/null
@@ -0,0 +1,18 @@
+import { History, createBrowserHistory } from "history";
+
+export class HistoryService {
+  static #_instance: HistoryService;
+  #history: History;
+
+  private constructor() {
+    this.#history = createBrowserHistory();
+  }
+
+  static get #Instance() {
+    return this.#_instance ?? (this.#_instance = new this());
+  }
+
+  public static get history() {
+    return this.#Instance.#history;
+  }
+}
diff --git a/src/shared/services/HttpService.ts b/src/shared/services/HttpService.ts
new file mode 100644 (file)
index 0000000..b0e476e
--- /dev/null
@@ -0,0 +1,96 @@
+import { LemmyHttp } from "lemmy-js-client";
+import { getHttpBase } from "../../shared/env";
+import { i18n } from "../../shared/i18next";
+import { toast } from "../../shared/utils";
+
+type EmptyRequestState = {
+  state: "empty";
+};
+
+type LoadingRequestState = {
+  state: "loading";
+};
+
+type FailedRequestState = {
+  state: "failed";
+  msg: string;
+};
+
+type SuccessRequestState<T> = {
+  state: "success";
+  data: T;
+};
+
+/**
+ * Shows the state of an API request.
+ *
+ * Can be empty, loading, failed, or success
+ */
+export type RequestState<T> =
+  | EmptyRequestState
+  | LoadingRequestState
+  | FailedRequestState
+  | SuccessRequestState<T>;
+
+export type WrappedLemmyHttp = {
+  [K in keyof LemmyHttp]: LemmyHttp[K] extends (...args: any[]) => any
+    ? ReturnType<LemmyHttp[K]> extends Promise<infer U>
+      ? (...args: Parameters<LemmyHttp[K]>) => Promise<RequestState<U>>
+      : (
+          ...args: Parameters<LemmyHttp[K]>
+        ) => Promise<RequestState<LemmyHttp[K]>>
+    : LemmyHttp[K];
+};
+
+class WrappedLemmyHttpClient {
+  #client: LemmyHttp;
+
+  constructor(client: LemmyHttp) {
+    this.#client = client;
+
+    for (const key of Object.getOwnPropertyNames(
+      Object.getPrototypeOf(this.#client)
+    )) {
+      if (key !== "constructor") {
+        WrappedLemmyHttpClient.prototype[key] = async (...args) => {
+          try {
+            const res = await this.#client[key](...args);
+
+            return {
+              data: res,
+              state: "success",
+            };
+          } catch (error) {
+            console.error(`API error: ${error}`);
+            toast(i18n.t(error), "danger");
+            return {
+              state: "failed",
+              msg: error,
+            };
+          }
+        };
+      }
+    }
+  }
+}
+
+export function wrapClient(client: LemmyHttp) {
+  return new WrappedLemmyHttpClient(client) as unknown as WrappedLemmyHttp; // unfortunately, this verbose cast is necessary
+}
+
+export class HttpService {
+  static #_instance: HttpService;
+  #client: WrappedLemmyHttp;
+
+  private constructor() {
+    this.#client = wrapClient(new LemmyHttp(getHttpBase()));
+  }
+
+  static get #Instance() {
+    return this.#_instance ?? (this.#_instance = new this());
+  }
+
+  public static get client() {
+    return this.#Instance.#client;
+  }
+}
index 2737ab8047d20267d7e78e688222401011a29a44..57c8aecf710c4717f58de4b3b1adeffe84957adb 100644 (file)
@@ -2,7 +2,6 @@
 import IsomorphicCookie from "isomorphic-cookie";
 import jwt_decode from "jwt-decode";
 import { LoginResponse, MyUserInfo } from "lemmy-js-client";
-import { BehaviorSubject } from "rxjs";
 import { isHttps } from "../env";
 import { i18n } from "../i18next";
 import { isAuthPath, isBrowser, toast } from "../utils";
@@ -19,18 +18,12 @@ interface JwtInfo {
 }
 
 export class UserService {
-  private static _instance: UserService;
+  static #instance: UserService;
   public myUserInfo?: MyUserInfo;
   public jwtInfo?: JwtInfo;
-  public unreadInboxCountSub: BehaviorSubject<number> =
-    new BehaviorSubject<number>(0);
-  public unreadReportCountSub: BehaviorSubject<number> =
-    new BehaviorSubject<number>(0);
-  public unreadApplicationCountSub: BehaviorSubject<number> =
-    new BehaviorSubject<number>(0);
 
   private constructor() {
-    this.setJwtInfo();
+    this.#setJwtInfo();
   }
 
   public login(res: LoginResponse) {
@@ -39,7 +32,7 @@ export class UserService {
     if (res.jwt) {
       toast(i18n.t("logged_in"));
       IsomorphicCookie.save("jwt", res.jwt, { expires, secure: isHttps() });
-      this.setJwtInfo();
+      this.#setJwtInfo();
     }
   }
 
@@ -55,7 +48,7 @@ export class UserService {
     }
   }
 
-  public auth(throwErr = true): string | undefined {
+  public auth(throwErr = false): string | undefined {
     const jwt = this.jwtInfo?.jwt;
     if (jwt) {
       return jwt;
@@ -70,7 +63,7 @@ export class UserService {
     }
   }
 
-  private setJwtInfo() {
+  #setJwtInfo() {
     const jwt: string | undefined = IsomorphicCookie.load("jwt");
 
     if (jwt) {
@@ -79,6 +72,6 @@ export class UserService {
   }
 
   public static get Instance() {
-    return this._instance || (this._instance = new this());
+    return this.#instance || (this.#instance = new this());
   }
 }
diff --git a/src/shared/services/WebSocketService.ts b/src/shared/services/WebSocketService.ts
deleted file mode 100644 (file)
index 6e2fd87..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-import { Observable } from "rxjs";
-import { share } from "rxjs/operators";
-import {
-  ExponentialBackoff,
-  LRUBuffer,
-  Websocket as WS,
-  WebsocketBuilder,
-} from "websocket-ts";
-import { getWsUri } from "../env";
-import { isBrowser } from "../utils";
-
-export class WebSocketService {
-  private static _instance: WebSocketService;
-  private ws: WS;
-  public subject: Observable<any>;
-
-  private constructor() {
-    let firstConnect = true;
-
-    this.subject = new Observable((obs: any) => {
-      this.ws = new WebsocketBuilder(getWsUri())
-        .onMessage((_i, e) => {
-          try {
-            obs.next(JSON.parse(e.data.toString()));
-          } catch (err) {
-            console.error(err);
-          }
-        })
-        .onOpen(() => {
-          console.log(`Connected to ${getWsUri()}`);
-
-          if (!firstConnect) {
-            const res = {
-              reconnect: true,
-            };
-            obs.next(res);
-          }
-          firstConnect = false;
-        })
-        .onRetry(() => {
-          console.log("Retrying websocket connection...");
-        })
-        .onClose(() => {
-          console.error("Websocket closed.");
-        })
-        .withBackoff(new ExponentialBackoff(100, 7))
-        .withBuffer(new LRUBuffer(1000))
-        .build();
-    }).pipe(share());
-
-    if (isBrowser()) {
-      window.onunload = () => {
-        this.ws.close();
-
-        // Clears out scroll positions.
-        sessionStorage.clear();
-      };
-    }
-  }
-
-  public send(data: string) {
-    this.ws.send(data);
-  }
-
-  public static get Instance() {
-    return this._instance || (this._instance = new this());
-  }
-}
index 4ffa6ec99616b59eb02a10098d6cb034a6d0c4fc..f63a56be7be61b763dc5962bf4fda5721dde372f 100644 (file)
@@ -1,2 +1,2 @@
+export { HttpService } from "./HttpService";
 export { UserService } from "./UserService";
-export { WebSocketService } from "./WebSocketService";
index c7a5da039d8d42cdbe69e333f53202359757ab46..46e8601be08e5ff4895d6b9335f24fe765cb7168 100644 (file)
@@ -3,7 +3,9 @@ import emojiShortName from "emoji-short-name";
 import {
   BlockCommunityResponse,
   BlockPersonResponse,
+  CommentAggregates,
   Comment as CommentI,
+  CommentReplyView,
   CommentReportView,
   CommentSortType,
   CommentView,
@@ -14,9 +16,9 @@ import {
   GetSiteResponse,
   Language,
   LemmyHttp,
-  LemmyWebsocket,
   MyUserInfo,
   Person,
+  PersonMentionView,
   PersonView,
   PostReportView,
   PostView,
@@ -24,8 +26,8 @@ import {
   PrivateMessageView,
   RegistrationApplicationView,
   Search,
+  SearchType,
   SortType,
-  UploadImageResponse,
 } from "lemmy-js-client";
 import { default as MarkdownIt } from "markdown-it";
 import markdown_it_container from "markdown-it-container";
@@ -37,22 +39,18 @@ import markdown_it_sup from "markdown-it-sup";
 import Renderer from "markdown-it/lib/renderer";
 import Token from "markdown-it/lib/token";
 import moment from "moment";
-import { Subscription } from "rxjs";
-import { delay, retryWhen, take } from "rxjs/operators";
 import tippy from "tippy.js";
 import Toastify from "toastify-js";
 import { getHttpBase } from "./env";
 import { i18n, languages } from "./i18next";
-import { CommentNodeI, DataType, IsoData } from "./interfaces";
-import { UserService, WebSocketService } from "./services";
+import { CommentNodeI, DataType, IsoData, VoteType } from "./interfaces";
+import { HttpService, UserService } from "./services";
 
 let Tribute: any;
 if (isBrowser()) {
   Tribute = require("tributejs");
 }
 
-export const wsClient = new LemmyWebsocket();
-
 export const favIconUrl = "/static/assets/icons/favicon.svg";
 export const favIconPngUrl = "/static/assets/icons/apple-touch-icon.png";
 // TODO
@@ -206,12 +204,10 @@ export function hotRank(score: number, timeStr: string): number {
 }
 
 export function mdToHtml(text: string) {
-  // restore '>' character to fix quotes
   return { __html: md.render(text) };
 }
 
 export function mdToHtmlNoImages(text: string) {
-  // restore '>' character to fix quotes
   return { __html: mdNoImages.render(text) };
 }
 
@@ -561,86 +557,6 @@ export function pictrsDeleteToast(filename: string, deleteUrl: string) {
   }
 }
 
-interface NotifyInfo {
-  name: string;
-  icon?: string;
-  link: string;
-  body?: string;
-}
-
-export function messageToastify(info: NotifyInfo, router: any) {
-  if (isBrowser()) {
-    const htmlBody = info.body ? md.render(info.body) : "";
-    const backgroundColor = `var(--light)`;
-
-    const toast = Toastify({
-      text: `${htmlBody}<br />${info.name}`,
-      avatar: info.icon,
-      backgroundColor: backgroundColor,
-      className: "text-dark",
-      close: true,
-      gravity: "top",
-      position: "right",
-      duration: 5000,
-      escapeMarkup: false,
-      onClick: () => {
-        if (toast) {
-          toast.hideToast();
-          router.history.push(info.link);
-        }
-      },
-    });
-    toast.showToast();
-  }
-}
-
-export function notifyPost(post_view: PostView, router: any) {
-  const info: NotifyInfo = {
-    name: post_view.community.name,
-    icon: post_view.community.icon,
-    link: `/post/${post_view.post.id}`,
-    body: post_view.post.name,
-  };
-  notify(info, router);
-}
-
-export function notifyComment(comment_view: CommentView, router: any) {
-  const info: NotifyInfo = {
-    name: comment_view.creator.name,
-    icon: comment_view.creator.avatar,
-    link: `/comment/${comment_view.comment.id}`,
-    body: comment_view.comment.content,
-  };
-  notify(info, router);
-}
-
-export function notifyPrivateMessage(pmv: PrivateMessageView, router: any) {
-  const info: NotifyInfo = {
-    name: pmv.creator.name,
-    icon: pmv.creator.avatar,
-    link: `/inbox`,
-    body: pmv.private_message.content,
-  };
-  notify(info, router);
-}
-
-function notify(info: NotifyInfo, router: any) {
-  messageToastify(info, router);
-
-  if (Notification.permission !== "granted") Notification.requestPermission();
-  else {
-    var notification = new Notification(info.name, {
-      ...{ body: info.body },
-      ...(info.icon && { icon: info.icon }),
-    });
-
-    notification.onclick = (ev: Event): any => {
-      ev.preventDefault();
-      router.history.push(info.link);
-    };
-  }
-}
-
 export function setupTribute() {
   return new Tribute({
     noMatchTemplate: function () {
@@ -877,15 +793,12 @@ interface PersonTribute {
 }
 
 async function personSearch(text: string): Promise<PersonTribute[]> {
-  const users = (await fetchUsers(text)).users;
-  const persons: PersonTribute[] = users.map(pv => {
-    const tribute: PersonTribute = {
-      key: `@${pv.person.name}@${hostname(pv.person.actor_id)}`,
-      view: pv,
-    };
-    return tribute;
-  });
-  return persons;
+  const usersResponse = await fetchUsers(text);
+
+  return usersResponse.map(pv => ({
+    key: `@${pv.person.name}@${hostname(pv.person.actor_id)}`,
+    view: pv,
+  }));
 }
 
 interface CommunityTribute {
@@ -894,15 +807,12 @@ interface CommunityTribute {
 }
 
 async function communitySearch(text: string): Promise<CommunityTribute[]> {
-  const comms = (await fetchCommunities(text)).communities;
-  const communities: CommunityTribute[] = comms.map(cv => {
-    const tribute: CommunityTribute = {
-      key: `!${cv.community.name}@${hostname(cv.community.actor_id)}`,
-      view: cv,
-    };
-    return tribute;
-  });
-  return communities;
+  const communitiesResponse = await fetchCommunities(text);
+
+  return communitiesResponse.map(cv => ({
+    key: `!${cv.community.name}@${hostname(cv.community.actor_id)}`,
+    view: cv,
+  }));
 }
 
 export function getRecipientIdFromProps(props: any): number {
@@ -921,42 +831,128 @@ export function getCommentIdFromProps(props: any): number | undefined {
   return id ? Number(id) : undefined;
 }
 
-export function editCommentRes(data: CommentView, comments?: CommentView[]) {
-  const found = comments?.find(c => c.comment.id == data.comment.id);
-  if (found) {
-    found.comment.content = data.comment.content;
-    found.comment.distinguished = data.comment.distinguished;
-    found.comment.updated = data.comment.updated;
-    found.comment.removed = data.comment.removed;
-    found.comment.deleted = data.comment.deleted;
-    found.counts.upvotes = data.counts.upvotes;
-    found.counts.downvotes = data.counts.downvotes;
-    found.counts.score = data.counts.score;
-  }
+type ImmutableListKey =
+  | "comment"
+  | "comment_reply"
+  | "person_mention"
+  | "community"
+  | "private_message"
+  | "post"
+  | "post_report"
+  | "comment_report"
+  | "private_message_report"
+  | "registration_application";
+
+function editListImmutable<
+  T extends { [key in F]: { id: number } },
+  F extends ImmutableListKey
+>(fieldName: F, data: T, list: T[]): T[] {
+  return [
+    ...list.map(c => (c[fieldName].id === data[fieldName].id ? data : c)),
+  ];
+}
+
+export function editComment(
+  data: CommentView,
+  comments: CommentView[]
+): CommentView[] {
+  return editListImmutable("comment", data, comments);
 }
 
-export function saveCommentRes(data: CommentView, comments?: CommentView[]) {
-  const found = comments?.find(c => c.comment.id == data.comment.id);
-  if (found) {
-    found.saved = data.saved;
-  }
+export function editCommentReply(
+  data: CommentReplyView,
+  replies: CommentReplyView[]
+): CommentReplyView[] {
+  return editListImmutable("comment_reply", data, replies);
+}
+
+interface WithComment {
+  comment: CommentI;
+  counts: CommentAggregates;
+  my_vote?: number;
+  saved: boolean;
+}
+
+export function editMention(
+  data: PersonMentionView,
+  comments: PersonMentionView[]
+): PersonMentionView[] {
+  return editListImmutable("person_mention", data, comments);
+}
+
+export function editCommunity(
+  data: CommunityView,
+  communities: CommunityView[]
+): CommunityView[] {
+  return editListImmutable("community", data, communities);
+}
+
+export function editPrivateMessage(
+  data: PrivateMessageView,
+  messages: PrivateMessageView[]
+): PrivateMessageView[] {
+  return editListImmutable("private_message", data, messages);
+}
+
+export function editPost(data: PostView, posts: PostView[]): PostView[] {
+  return editListImmutable("post", data, posts);
+}
+
+export function editPostReport(
+  data: PostReportView,
+  reports: PostReportView[]
+) {
+  return editListImmutable("post_report", data, reports);
+}
+
+export function editCommentReport(
+  data: CommentReportView,
+  reports: CommentReportView[]
+): CommentReportView[] {
+  return editListImmutable("comment_report", data, reports);
+}
+
+export function editPrivateMessageReport(
+  data: PrivateMessageReportView,
+  reports: PrivateMessageReportView[]
+): PrivateMessageReportView[] {
+  return editListImmutable("private_message_report", data, reports);
+}
+
+export function editRegistrationApplication(
+  data: RegistrationApplicationView,
+  apps: RegistrationApplicationView[]
+): RegistrationApplicationView[] {
+  return editListImmutable("registration_application", data, apps);
+}
+
+export function editWith<D extends WithComment, L extends WithComment>(
+  { comment, counts, saved, my_vote }: D,
+  list: L[]
+) {
+  return [
+    ...list.map(c =>
+      c.comment.id === comment.id
+        ? { ...c, comment, counts, saved, my_vote }
+        : c
+    ),
+  ];
 }
 
 export function updatePersonBlock(
   data: BlockPersonResponse,
   myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo
 ) {
-  const mui = myUserInfo;
-  if (mui) {
+  if (myUserInfo) {
     if (data.blocked) {
-      mui.person_blocks.push({
-        person: mui.local_user_view.person,
+      myUserInfo.person_blocks.push({
+        person: myUserInfo.local_user_view.person,
         target: data.person_view.person,
       });
       toast(`${i18n.t("blocked")} ${data.person_view.person.name}`);
     } else {
-      mui.person_blocks = mui.person_blocks.filter(
-        i => i.target.id != data.person_view.person.id
+      myUserInfo.person_blocks = myUserInfo.person_blocks.filter(
+        i => i.target.id !== data.person_view.person.id
       );
       toast(`${i18n.t("unblocked")} ${data.person_view.person.name}`);
     }
@@ -967,127 +963,22 @@ export function updateCommunityBlock(
   data: BlockCommunityResponse,
   myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo
 ) {
-  const mui = myUserInfo;
-  if (mui) {
+  if (myUserInfo) {
     if (data.blocked) {
-      mui.community_blocks.push({
-        person: mui.local_user_view.person,
+      myUserInfo.community_blocks.push({
+        person: myUserInfo.local_user_view.person,
         community: data.community_view.community,
       });
       toast(`${i18n.t("blocked")} ${data.community_view.community.name}`);
     } else {
-      mui.community_blocks = mui.community_blocks.filter(
-        i => i.community.id != data.community_view.community.id
+      myUserInfo.community_blocks = myUserInfo.community_blocks.filter(
+        i => i.community.id !== data.community_view.community.id
       );
       toast(`${i18n.t("unblocked")} ${data.community_view.community.name}`);
     }
   }
 }
 
-export function createCommentLikeRes(
-  data: CommentView,
-  comments?: CommentView[]
-) {
-  const found = comments?.find(c => c.comment.id === data.comment.id);
-  if (found) {
-    found.counts.score = data.counts.score;
-    found.counts.upvotes = data.counts.upvotes;
-    found.counts.downvotes = data.counts.downvotes;
-    if (data.my_vote !== null) {
-      found.my_vote = data.my_vote;
-    }
-  }
-}
-
-export function createPostLikeFindRes(data: PostView, posts?: PostView[]) {
-  const found = posts?.find(p => p.post.id == data.post.id);
-  if (found) {
-    createPostLikeRes(data, found);
-  }
-}
-
-export function createPostLikeRes(data: PostView, post_view?: PostView) {
-  if (post_view) {
-    post_view.counts.score = data.counts.score;
-    post_view.counts.upvotes = data.counts.upvotes;
-    post_view.counts.downvotes = data.counts.downvotes;
-    if (data.my_vote !== null) {
-      post_view.my_vote = data.my_vote;
-    }
-  }
-}
-
-export function editPostFindRes(data: PostView, posts?: PostView[]) {
-  const found = posts?.find(p => p.post.id == data.post.id);
-  if (found) {
-    editPostRes(data, found);
-  }
-}
-
-export function editPostRes(data: PostView, post: PostView) {
-  if (post) {
-    post.post.url = data.post.url;
-    post.post.name = data.post.name;
-    post.post.nsfw = data.post.nsfw;
-    post.post.deleted = data.post.deleted;
-    post.post.removed = data.post.removed;
-    post.post.featured_community = data.post.featured_community;
-    post.post.featured_local = data.post.featured_local;
-    post.post.body = data.post.body;
-    post.post.locked = data.post.locked;
-    post.saved = data.saved;
-  }
-}
-
-// TODO possible to make these generic?
-export function updatePostReportRes(
-  data: PostReportView,
-  reports?: PostReportView[]
-) {
-  const found = reports?.find(p => p.post_report.id == data.post_report.id);
-  if (found) {
-    found.post_report = data.post_report;
-  }
-}
-
-export function updateCommentReportRes(
-  data: CommentReportView,
-  reports?: CommentReportView[]
-) {
-  const found = reports?.find(
-    c => c.comment_report.id == data.comment_report.id
-  );
-  if (found) {
-    found.comment_report = data.comment_report;
-  }
-}
-
-export function updatePrivateMessageReportRes(
-  data: PrivateMessageReportView,
-  reports?: PrivateMessageReportView[]
-) {
-  const found = reports?.find(
-    c => c.private_message_report.id == data.private_message_report.id
-  );
-  if (found) {
-    found.private_message_report = data.private_message_report;
-  }
-}
-
-export function updateRegistrationApplicationRes(
-  data: RegistrationApplicationView,
-  applications?: RegistrationApplicationView[]
-) {
-  const found = applications?.find(
-    ra => ra.registration_application.id == data.registration_application.id
-  );
-  if (found) {
-    found.registration_application = data.registration_application;
-    found.admin = data.admin;
-    found.creator_local_user = data.creator_local_user;
-  }
-}
-
 export function commentsToFlatNodes(comments: CommentView[]): CommentNodeI[] {
   const nodes: CommentNodeI[] = [];
   for (const comment of comments) {
@@ -1180,6 +1071,7 @@ export function getDepthFromComment(comment?: CommentI): number | undefined {
   return len ? len - 2 : undefined;
 }
 
+// TODO make immutable
 export function insertCommentIntoTree(
   tree: CommentNodeI[],
   cv: CommentView,
@@ -1276,20 +1168,6 @@ export function setIsoData(context: any): IsoData {
   } else return context.router.staticContext;
 }
 
-export function wsSubscribe(parseMessage: any): Subscription | undefined {
-  if (isBrowser()) {
-    return WebSocketService.Instance.subject
-      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
-      .subscribe(
-        msg => parseMessage(msg),
-        err => console.error(err),
-        () => console.log("complete")
-      );
-  } else {
-    return undefined;
-  }
-}
-
 moment.updateLocale("en", {
   relativeTime: {
     future: "in %s",
@@ -1353,32 +1231,30 @@ export function personToChoice(pvs: PersonView): Choice {
   };
 }
 
-export async function fetchCommunities(q: string) {
+function fetchSearchResults(q: string, type_: SearchType) {
   const form: Search = {
     q,
-    type_: "Communities",
+    type_,
     sort: "TopAll",
     listing_type: "All",
     page: 1,
     limit: fetchLimit,
-    auth: myAuth(false),
+    auth: myAuth(),
   };
-  const client = new LemmyHttp(getHttpBase());
-  return client.search(form);
+
+  return HttpService.client.search(form);
+}
+
+export async function fetchCommunities(q: string) {
+  const res = await fetchSearchResults(q, "Communities");
+
+  return res.state === "success" ? res.data.communities : [];
 }
 
 export async function fetchUsers(q: string) {
-  const form: Search = {
-    q,
-    type_: "Users",
-    sort: "TopAll",
-    listing_type: "All",
-    page: 1,
-    limit: fetchLimit,
-    auth: myAuth(false),
-  };
-  const client = new LemmyHttp(getHttpBase());
-  return client.search(form);
+  const res = await fetchSearchResults(q, "Users");
+
+  return res.state === "success" ? res.data.users : [];
 }
 
 export function communitySelectName(cv: CommunityView): string {
@@ -1398,7 +1274,7 @@ export function initializeSite(site?: GetSiteResponse) {
   UserService.Instance.myUserInfo = site?.my_user;
   i18n.changeLanguage(getLanguages()[0]);
   if (site) {
-    setupEmojiDataModel(site.custom_emojis);
+    setupEmojiDataModel(site.custom_emojis ?? []);
   }
   setupMarkdown();
 }
@@ -1429,8 +1305,12 @@ export function isBanned(ps: Person): boolean {
   }
 }
 
-export function myAuth(throwErr = true): string | undefined {
-  return UserService.Instance.auth(throwErr);
+export function myAuth(): string | undefined {
+  return UserService.Instance.auth();
+}
+
+export function myAuthRequired(): string {
+  return UserService.Instance.auth(true) ?? "";
 }
 
 export function enableDownvotes(siteRes: GetSiteResponse): boolean {
@@ -1528,12 +1408,6 @@ export function selectableLanguages(
   }
 }
 
-export function uploadImage(image: File): Promise<UploadImageResponse> {
-  const client = new LemmyHttp(getHttpBase());
-
-  return client.uploadImage({ image });
-}
-
 interface EmojiMartCategory {
   id: string;
   name: string;
@@ -1594,7 +1468,7 @@ export function getQueryString<T extends Record<string, string | undefined>>(
 }
 
 export function isAuthPath(pathname: string) {
-  return /create_.*|inbox|settings|setup|admin|reports|registration_applications/g.test(
+  return /create_.*|inbox|settings|admin|reports|registration_applications/g.test(
     pathname
   );
 }
@@ -1608,3 +1482,11 @@ export function share(shareData: ShareData) {
     navigator.share(shareData);
   }
 }
+
+export function newVote(voteType: VoteType, myVote?: number): number {
+  if (voteType == VoteType.Upvote) {
+    return myVote == 1 ? 0 : 1;
+  } else {
+    return myVote == -1 ? 0 : -1;
+  }
+}
index 51ca81db3e6ce11c21b2d1075f5ccbb515479f58..67b10a986fdbd5691e158febe36e9b6ab5185668 100644 (file)
@@ -69,10 +69,10 @@ const createServerConfig = (_env, mode) => {
   });
 
   if (mode === "development") {
-    config.cache = {
-      type: "filesystem",
-      name: "server",
-    };
+    // config.cache = {
+    //   type: "filesystem",
+    //   name: "server",
+    // };
 
     config.plugins.push(
       new RunNodeWebpackPlugin({
@@ -94,7 +94,7 @@ const createClientConfig = (_env, mode) => {
     plugins: [
       ...base.plugins,
       new ServiceWorkerPlugin({
-        enableInDevelopment: true,
+        enableInDevelopment: mode !== "development", // this may seem counterintuitive, but it is correct
         workbox: {
           modifyURLPrefix: {
             "/": "/static/",
@@ -149,10 +149,10 @@ const createClientConfig = (_env, mode) => {
   });
 
   if (mode === "development") {
-    config.cache = {
-      type: "filesystem",
-      name: "client",
-    };
+    // config.cache = {
+    //   type: "filesystem",
+    //   name: "client",
+    // };
   }
 
   return config;
index 7bd1fe2a2bece7058e98e5b6990a14ec5afa4291..f783f07f84ce5007655e700c23fcd2c67c295de5 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
     jsonpointer "^5.0.0"
     leven "^3.1.0"
 
-"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.21.4":
+"@babel/code-frame@^7.10.4", "@babel/code-frame@^7.21.4":
   version "7.21.4"
   resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.21.4.tgz#d0fa9e4413aca81f2b23b9442797bda1826edb39"
   integrity sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==
   dependencies:
     "@babel/highlight" "^7.18.6"
 
-"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.20.5", "@babel/compat-data@^7.21.5":
-  version "7.21.7"
-  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.21.7.tgz#61caffb60776e49a57ba61a88f02bedd8714f6bc"
-  integrity sha512-KYMqFYTaenzMK4yUtf4EW9wc4N9ef80FsbMtkwool5zpwl4YrT1SdWYSTRcT94KO4hannogdS+LxY7L+arP3gA==
+"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.20.5", "@babel/compat-data@^7.21.5", "@babel/compat-data@^7.22.0", "@babel/compat-data@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.22.3.tgz#cd502a6a0b6e37d7ad72ce7e71a7160a3ae36f7e"
+  integrity sha512-aNtko9OPOwVESUFp3MZfD8Uzxl7JzSeJpd7npIoxCasU37PFbAQRpKglkaKwlHOyeJdrREpo8TW8ldrkYWwvIQ==
 
 "@babel/core@^7.11.1", "@babel/core@^7.2.2", "@babel/core@^7.21.8":
-  version "7.21.8"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.21.8.tgz#2a8c7f0f53d60100ba4c32470ba0281c92aa9aa4"
-  integrity sha512-YeM22Sondbo523Sz0+CirSPnbj9bG3P0CdHcBZdqUuaeOaYEFbOLoGU7lebvGP6P5J/WE9wOn7u7C4J9HvS1xQ==
+  version "7.22.1"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.22.1.tgz#5de51c5206f4c6f5533562838337a603c1033cfd"
+  integrity sha512-Hkqu7J4ynysSXxmAahpN1jjRwVJ+NdpraFLIWflgjpVob3KNyK3/tIUc7Q7szed8WMp0JNa7Qtd1E9Oo22F9gA==
   dependencies:
     "@ampproject/remapping" "^2.2.0"
     "@babel/code-frame" "^7.21.4"
-    "@babel/generator" "^7.21.5"
-    "@babel/helper-compilation-targets" "^7.21.5"
-    "@babel/helper-module-transforms" "^7.21.5"
-    "@babel/helpers" "^7.21.5"
-    "@babel/parser" "^7.21.8"
-    "@babel/template" "^7.20.7"
-    "@babel/traverse" "^7.21.5"
-    "@babel/types" "^7.21.5"
+    "@babel/generator" "^7.22.0"
+    "@babel/helper-compilation-targets" "^7.22.1"
+    "@babel/helper-module-transforms" "^7.22.1"
+    "@babel/helpers" "^7.22.0"
+    "@babel/parser" "^7.22.0"
+    "@babel/template" "^7.21.9"
+    "@babel/traverse" "^7.22.1"
+    "@babel/types" "^7.22.0"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
     gensync "^1.0.0-beta.2"
     json5 "^2.2.2"
     semver "^6.3.0"
 
-"@babel/generator@^7.21.5":
-  version "7.21.5"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.21.5.tgz#c0c0e5449504c7b7de8236d99338c3e2a340745f"
-  integrity sha512-SrKK/sRv8GesIW1bDagf9cCG38IOMYZusoe1dfg0D8aiUe3Amvoj1QtjTPAWcfrZFvIwlleLb0gxzQidL9w14w==
+"@babel/generator@^7.22.0", "@babel/generator@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.22.3.tgz#0ff675d2edb93d7596c5f6728b52615cfc0df01e"
+  integrity sha512-C17MW4wlk//ES/CJDL51kPNwl+qiBQyN7b9SKyVp11BLGFeSPoVaHrv+MNt8jwQFhQWowW88z1eeBx3pFz9v8A==
   dependencies:
-    "@babel/types" "^7.21.5"
+    "@babel/types" "^7.22.3"
     "@jridgewell/gen-mapping" "^0.3.2"
     "@jridgewell/trace-mapping" "^0.3.17"
     jsesc "^2.5.1"
     "@babel/types" "^7.18.6"
 
 "@babel/helper-builder-binary-assignment-operator-visitor@^7.18.6":
-  version "7.21.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.21.5.tgz#817f73b6c59726ab39f6ba18c234268a519e5abb"
-  integrity sha512-uNrjKztPLkUk7bpCNC0jEKDJzzkvel/W+HguzbN8krA+LPfC1CEobJEvAvGka2A/M+ViOqXdcRL0GqPUJSjx9g==
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.22.3.tgz#c9b83d1ba74e163e023f008a3d3204588a7ceb60"
+  integrity sha512-ahEoxgqNoYXm0k22TvOke48i1PkavGu0qGCmcq9ugi6gnmvKNaMjKBSrZTnWUi1CFEeNAUiVba0Wtzm03aSkJg==
   dependencies:
-    "@babel/types" "^7.21.5"
+    "@babel/types" "^7.22.3"
 
-"@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9", "@babel/helper-compilation-targets@^7.20.7", "@babel/helper-compilation-targets@^7.21.5":
-  version "7.21.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.21.5.tgz#631e6cc784c7b660417421349aac304c94115366"
-  integrity sha512-1RkbFGUKex4lvsB9yhIfWltJM5cZKUftB2eNajaDv3dCMEp49iBG0K14uH8NnX9IPux2+mK7JGEOB0jn48/J6w==
+"@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.18.9", "@babel/helper-compilation-targets@^7.20.7", "@babel/helper-compilation-targets@^7.21.5", "@babel/helper-compilation-targets@^7.22.1":
+  version "7.22.1"
+  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.22.1.tgz#bfcd6b7321ffebe33290d68550e2c9d7eb7c7a58"
+  integrity sha512-Rqx13UM3yVB5q0D/KwQ8+SPfX/+Rnsy1Lw1k/UwOC4KC6qrzIQoY3lYnBu5EHKBlEHHcj0M0W8ltPSkD8rqfsQ==
   dependencies:
-    "@babel/compat-data" "^7.21.5"
+    "@babel/compat-data" "^7.22.0"
     "@babel/helper-validator-option" "^7.21.0"
     browserslist "^4.21.3"
     lru-cache "^5.1.1"
     semver "^6.3.0"
 
-"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.21.0":
-  version "7.21.8"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.21.8.tgz#205b26330258625ef8869672ebca1e0dee5a0f02"
-  integrity sha512-+THiN8MqiH2AczyuZrnrKL6cAxFRRQDKW9h1YkBvbgKmAm6mwiacig1qT73DHIWMGo40GRnsEfN3LA+E6NtmSw==
+"@babel/helper-create-class-features-plugin@^7.18.6", "@babel/helper-create-class-features-plugin@^7.21.0", "@babel/helper-create-class-features-plugin@^7.22.1":
+  version "7.22.1"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.22.1.tgz#ae3de70586cc757082ae3eba57240d42f468c41b"
+  integrity sha512-SowrZ9BWzYFgzUMwUmowbPSGu6CXL5MSuuCkG3bejahSpSymioPmuLdhPxNOc9MjuNGjy7M/HaXvJ8G82Lywlw==
   dependencies:
     "@babel/helper-annotate-as-pure" "^7.18.6"
-    "@babel/helper-environment-visitor" "^7.21.5"
+    "@babel/helper-environment-visitor" "^7.22.1"
     "@babel/helper-function-name" "^7.21.0"
-    "@babel/helper-member-expression-to-functions" "^7.21.5"
+    "@babel/helper-member-expression-to-functions" "^7.22.0"
     "@babel/helper-optimise-call-expression" "^7.18.6"
-    "@babel/helper-replace-supers" "^7.21.5"
+    "@babel/helper-replace-supers" "^7.22.1"
     "@babel/helper-skip-transparent-expression-wrappers" "^7.20.0"
     "@babel/helper-split-export-declaration" "^7.18.6"
     semver "^6.3.0"
 
-"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.20.5":
-  version "7.21.8"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.21.8.tgz#a7886f61c2e29e21fd4aaeaf1e473deba6b571dc"
-  integrity sha512-zGuSdedkFtsFHGbexAvNuipg1hbtitDLo2XE8/uf6Y9sOQV1xsYX/2pNbtedp/X0eU1pIt+kGvaqHCowkRbS5g==
+"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.22.1":
+  version "7.22.1"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.22.1.tgz#a7ed9a8488b45b467fca353cd1a44dc5f0cf5c70"
+  integrity sha512-WWjdnfR3LPIe+0EY8td7WmjhytxXtjKAEpnAxun/hkNiyOaPlvGK+NZaBFIdi9ndYV3Gav7BpFvtUwnaJlwi1w==
   dependencies:
     "@babel/helper-annotate-as-pure" "^7.18.6"
     regexpu-core "^5.3.1"
     resolve "^1.14.2"
     semver "^6.1.2"
 
-"@babel/helper-environment-visitor@^7.18.9", "@babel/helper-environment-visitor@^7.21.5":
-  version "7.21.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.21.5.tgz#c769afefd41d171836f7cb63e295bedf689d48ba"
-  integrity sha512-IYl4gZ3ETsWocUWgsFZLM5i1BYx9SoemminVEXadgLBa9TdeorzgLKm8wWLA6J1N/kT3Kch8XIk1laNzYoHKvQ==
+"@babel/helper-define-polyfill-provider@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.4.0.tgz#487053f103110f25b9755c5980e031e93ced24d8"
+  integrity sha512-RnanLx5ETe6aybRi1cO/edaRH+bNYWaryCEmjDDYyNr4wnSzyOp8T0dWipmqVHKEY3AbVKUom50AKSlj1zmKbg==
+  dependencies:
+    "@babel/helper-compilation-targets" "^7.17.7"
+    "@babel/helper-plugin-utils" "^7.16.7"
+    debug "^4.1.1"
+    lodash.debounce "^4.0.8"
+    resolve "^1.14.2"
+    semver "^6.1.2"
+
+"@babel/helper-environment-visitor@^7.18.9", "@babel/helper-environment-visitor@^7.22.1":
+  version "7.22.1"
+  resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.1.tgz#ac3a56dbada59ed969d712cf527bd8271fe3eba8"
+  integrity sha512-Z2tgopurB/kTbidvzeBrc2To3PUP/9i5MUe+fU6QJCQDyPwSH2oRapkLw3KGECDYSjhQZCNxEvNvZlLw8JjGwA==
 
 "@babel/helper-function-name@^7.18.9", "@babel/helper-function-name@^7.19.0", "@babel/helper-function-name@^7.21.0":
   version "7.21.0"
   dependencies:
     "@babel/types" "^7.18.6"
 
-"@babel/helper-member-expression-to-functions@^7.21.5":
-  version "7.21.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.21.5.tgz#3b1a009af932e586af77c1030fba9ee0bde396c0"
-  integrity sha512-nIcGfgwpH2u4n9GG1HpStW5Ogx7x7ekiFHbjjFRKXbn5zUvqO9ZgotCO4x1aNbKn/x/xOUaXEhyNHCwtFCpxWg==
+"@babel/helper-member-expression-to-functions@^7.22.0":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.22.3.tgz#4b77a12c1b4b8e9e28736ed47d8b91f00976911f"
+  integrity sha512-Gl7sK04b/2WOb6OPVeNy9eFKeD3L6++CzL3ykPOWqTn08xgYYK0wz4TUh2feIImDXxcVW3/9WQ1NMKY66/jfZA==
   dependencies:
-    "@babel/types" "^7.21.5"
+    "@babel/types" "^7.22.3"
 
 "@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.18.6", "@babel/helper-module-imports@^7.21.4":
   version "7.21.4"
   dependencies:
     "@babel/types" "^7.21.4"
 
-"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.20.11", "@babel/helper-module-transforms@^7.21.5":
-  version "7.21.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.21.5.tgz#d937c82e9af68d31ab49039136a222b17ac0b420"
-  integrity sha512-bI2Z9zBGY2q5yMHoBvJ2a9iX3ZOAzJPm7Q8Yz6YeoUjU/Cvhmi2G4QyTNyPBqqXSgTjUxRg3L0xV45HvkNWWBw==
+"@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.20.11", "@babel/helper-module-transforms@^7.21.5", "@babel/helper-module-transforms@^7.22.1":
+  version "7.22.1"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.22.1.tgz#e0cad47fedcf3cae83c11021696376e2d5a50c63"
+  integrity sha512-dxAe9E7ySDGbQdCVOY/4+UcD8M9ZFqZcZhSPsPacvCG4M+9lwtDDQfI2EoaSvmf7W/8yCBkGU0m7Pvt1ru3UZw==
   dependencies:
-    "@babel/helper-environment-visitor" "^7.21.5"
+    "@babel/helper-environment-visitor" "^7.22.1"
     "@babel/helper-module-imports" "^7.21.4"
     "@babel/helper-simple-access" "^7.21.5"
     "@babel/helper-split-export-declaration" "^7.18.6"
     "@babel/helper-validator-identifier" "^7.19.1"
-    "@babel/template" "^7.20.7"
-    "@babel/traverse" "^7.21.5"
-    "@babel/types" "^7.21.5"
+    "@babel/template" "^7.21.9"
+    "@babel/traverse" "^7.22.1"
+    "@babel/types" "^7.22.0"
 
 "@babel/helper-optimise-call-expression@^7.18.6":
   version "7.18.6"
     "@babel/helper-wrap-function" "^7.18.9"
     "@babel/types" "^7.18.9"
 
-"@babel/helper-replace-supers@^7.18.6", "@babel/helper-replace-supers@^7.20.7", "@babel/helper-replace-supers@^7.21.5":
-  version "7.21.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.21.5.tgz#a6ad005ba1c7d9bc2973dfde05a1bba7065dde3c"
-  integrity sha512-/y7vBgsr9Idu4M6MprbOVUfH3vs7tsIfnVWv/Ml2xgwvyH6LTngdfbf5AdsKwkJy4zgy1X/kuNrEKvhhK28Yrg==
+"@babel/helper-replace-supers@^7.18.6", "@babel/helper-replace-supers@^7.20.7", "@babel/helper-replace-supers@^7.22.1":
+  version "7.22.1"
+  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.22.1.tgz#38cf6e56f7dc614af63a21b45565dd623f0fdc95"
+  integrity sha512-ut4qrkE4AuSfrwHSps51ekR1ZY/ygrP1tp0WFm8oVq6nzc/hvfV/22JylndIbsf2U2M9LOMwiSddr6y+78j+OQ==
   dependencies:
-    "@babel/helper-environment-visitor" "^7.21.5"
-    "@babel/helper-member-expression-to-functions" "^7.21.5"
+    "@babel/helper-environment-visitor" "^7.22.1"
+    "@babel/helper-member-expression-to-functions" "^7.22.0"
     "@babel/helper-optimise-call-expression" "^7.18.6"
-    "@babel/template" "^7.20.7"
-    "@babel/traverse" "^7.21.5"
-    "@babel/types" "^7.21.5"
+    "@babel/template" "^7.21.9"
+    "@babel/traverse" "^7.22.1"
+    "@babel/types" "^7.22.0"
 
 "@babel/helper-simple-access@^7.21.5":
   version "7.21.5"
     "@babel/traverse" "^7.20.5"
     "@babel/types" "^7.20.5"
 
-"@babel/helpers@^7.21.5":
-  version "7.21.5"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.21.5.tgz#5bac66e084d7a4d2d9696bdf0175a93f7fb63c08"
-  integrity sha512-BSY+JSlHxOmGsPTydUkPf1MdMQ3M81x5xGCOVgWM3G8XH77sJ292Y2oqcp0CbbgxhqBuI46iUz1tT7hqP7EfgA==
+"@babel/helpers@^7.22.0":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.22.3.tgz#53b74351da9684ea2f694bf0877998da26dd830e"
+  integrity sha512-jBJ7jWblbgr7r6wYZHMdIqKc73ycaTcCaWRq4/2LpuPHcx7xMlZvpGQkOYc9HeSjn6rcx15CPlgVcBtZ4WZJ2w==
   dependencies:
-    "@babel/template" "^7.20.7"
-    "@babel/traverse" "^7.21.5"
-    "@babel/types" "^7.21.5"
+    "@babel/template" "^7.21.9"
+    "@babel/traverse" "^7.22.1"
+    "@babel/types" "^7.22.3"
 
 "@babel/highlight@^7.18.6":
   version "7.18.6"
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.0.0-beta.54", "@babel/parser@^7.20.7", "@babel/parser@^7.21.5", "@babel/parser@^7.21.8":
-  version "7.21.8"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.21.8.tgz#642af7d0333eab9c0ad70b14ac5e76dbde7bfdf8"
-  integrity sha512-6zavDGdzG3gUqAdWvlLFfk+36RilI+Pwyuuh7HItyeScCWP3k6i8vKclAQ0bM/0y/Kz/xiwvxhMv9MgTJP5gmA==
+"@babel/parser@^7.0.0-beta.54", "@babel/parser@^7.21.9", "@babel/parser@^7.22.0", "@babel/parser@^7.22.4":
+  version "7.22.4"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.22.4.tgz#a770e98fd785c231af9d93f6459d36770993fb32"
+  integrity sha512-VLLsx06XkEYqBtE5YGPwfSGwfrjnyPP5oiGty3S8pQLFDFLaS8VwWSIxkTXpcvr5zeYLE6+MBNl2npl/YnfofA==
 
 "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6":
   version "7.18.6"
   dependencies:
     "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.20.7":
-  version "7.20.7"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.20.7.tgz#d9c85589258539a22a901033853101a6198d4ef1"
-  integrity sha512-sbr9+wNE5aXMBBFBICk01tt7sBf2Oc9ikRFEcem/ZORup9IMUdNhW7/wVLEbbtlWOsEubJet46mHAL2C8+2jKQ==
+"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.20.7", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.22.3.tgz#a75be1365c0c3188c51399a662168c1c98108659"
+  integrity sha512-6r4yRwEnorYByILoDRnEqxtojYKuiIv9FojW2E8GUKo9eWBwbKcd9IiZOZpdyXc64RmyGGyPu3/uAcrz/dq2kQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.20.2"
+    "@babel/helper-plugin-utils" "^7.21.5"
     "@babel/helper-skip-transparent-expression-wrappers" "^7.20.0"
-    "@babel/plugin-proposal-optional-chaining" "^7.20.7"
+    "@babel/plugin-transform-optional-chaining" "^7.22.3"
 
 "@babel/plugin-proposal-async-generator-functions@^7.20.7":
   version "7.20.7"
     "@babel/plugin-syntax-class-static-block" "^7.14.5"
 
 "@babel/plugin-proposal-decorators@^7.21.0":
-  version "7.21.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.21.0.tgz#70e0c89fdcd7465c97593edb8f628ba6e4199d63"
-  integrity sha512-MfgX49uRrFUTL/HvWtmx3zmpyzMMr4MTj3d527MLlr/4RTT9G/ytFFP7qet2uM2Ve03b+BkpWUpK+lRXnQ+v9w==
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.22.3.tgz#3502c0f8cfe0cdb79b62102c9c9b111309d942b7"
+  integrity sha512-XjTKH3sHr6pPqG+hR1NCdVupwiosfdKM2oSMyKQVQ5Bym9l/p7BuLAqT5U32zZzRCfPq/TPRPzMiiTE9bOXU4w==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.21.0"
-    "@babel/helper-plugin-utils" "^7.20.2"
-    "@babel/helper-replace-supers" "^7.20.7"
+    "@babel/helper-create-class-features-plugin" "^7.22.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/helper-replace-supers" "^7.22.1"
     "@babel/helper-split-export-declaration" "^7.18.6"
-    "@babel/plugin-syntax-decorators" "^7.21.0"
+    "@babel/plugin-syntax-decorators" "^7.22.3"
 
 "@babel/plugin-proposal-dynamic-import@^7.18.6":
   version "7.18.6"
     "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/plugin-syntax-optional-catch-binding" "^7.8.3"
 
-"@babel/plugin-proposal-optional-chaining@^7.20.7", "@babel/plugin-proposal-optional-chaining@^7.21.0":
+"@babel/plugin-proposal-optional-chaining@^7.21.0":
   version "7.21.0"
   resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.21.0.tgz#886f5c8978deb7d30f678b2e24346b287234d3ea"
   integrity sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==
     "@babel/helper-plugin-utils" "^7.18.6"
 
 "@babel/plugin-proposal-private-property-in-object@^7.21.0":
-  version "7.21.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0.tgz#19496bd9883dd83c23c7d7fc45dcd9ad02dfa1dc"
-  integrity sha512-ha4zfehbJjc5MmXBlHec1igel5TJXXLDDRbuJ4+XT2TJcyD9/V1919BA8gMvsdHcNMBy4WBUBiRb3nw/EQUtBw==
+  version "7.21.11"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz#69d597086b6760c4126525cfa154f34631ff272c"
+  integrity sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw==
   dependencies:
     "@babel/helper-annotate-as-pure" "^7.18.6"
     "@babel/helper-create-class-features-plugin" "^7.21.0"
   dependencies:
     "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-syntax-decorators@^7.21.0":
-  version "7.21.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.21.0.tgz#d2b3f31c3e86fa86e16bb540b7660c55bd7d0e78"
-  integrity sha512-tIoPpGBR8UuM4++ccWN3gifhVvQu7ZizuR1fklhRJrd5ewgbkUS+0KVFeWWxELtn18NTLoW32XV7zyOgIAiz+w==
+"@babel/plugin-syntax-decorators@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.22.3.tgz#760f2d812d56c1d05970d01cdcd3c05e3d87d6ca"
+  integrity sha512-R16Zuge73+8/nLcDjkIpyhi5wIbN7i7fiuLJR8yQX7vPAa/ltUKtd3iLbb4AgP5nrLi91HnNUNosELIGUGH1bg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.20.2"
+    "@babel/helper-plugin-utils" "^7.21.5"
 
 "@babel/plugin-syntax-dynamic-import@^7.8.3":
   version "7.8.3"
   dependencies:
     "@babel/helper-plugin-utils" "^7.19.0"
 
+"@babel/plugin-syntax-import-attributes@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.22.3.tgz#d7168f22b9b49a6cc1792cec78e06a18ad2e7b4b"
+  integrity sha512-i35jZJv6aO7hxEbIWQ41adVfOzjm9dcYDNeWlBMd8p0ZQRtNUCBrmGwZt+H5lb+oOC9a3svp956KP0oWGA1YsA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.21.5"
+
 "@babel/plugin-syntax-import-meta@^7.10.4":
   version "7.10.4"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51"
   dependencies:
     "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-syntax-typescript@^7.20.0":
+"@babel/plugin-syntax-typescript@^7.21.4":
   version "7.21.4"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.21.4.tgz#2751948e9b7c6d771a8efa59340c15d4a2891ff8"
   integrity sha512-xz0D39NvhQn4t4RNsHmDnnsaQizIlUkdtYvLs8La1BlfjQ6JEwxkJGeqJMW2tAXx+q6H+WFuUTXNdYVpEya0YA==
   dependencies:
     "@babel/helper-plugin-utils" "^7.20.2"
 
+"@babel/plugin-syntax-unicode-sets-regex@^7.18.6":
+  version "7.18.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz#d49a3b3e6b52e5be6740022317580234a6a47357"
+  integrity sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.18.6"
+
 "@babel/plugin-transform-arrow-functions@^7.21.5":
   version "7.21.5"
   resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.21.5.tgz#9bb42a53de447936a57ba256fbf537fc312b6929"
   dependencies:
     "@babel/helper-plugin-utils" "^7.21.5"
 
+"@babel/plugin-transform-async-generator-functions@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.22.3.tgz#3ed99924c354fb9e80dabb2cc8d002c702e94527"
+  integrity sha512-36A4Aq48t66btydbZd5Fk0/xJqbpg/v4QWI4AH4cYHBXy9Mu42UOupZpebKFiCFNT9S9rJFcsld0gsv0ayLjtA==
+  dependencies:
+    "@babel/helper-environment-visitor" "^7.22.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/helper-remap-async-to-generator" "^7.18.9"
+    "@babel/plugin-syntax-async-generators" "^7.8.4"
+
 "@babel/plugin-transform-async-to-generator@^7.20.7":
   version "7.20.7"
   resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.20.7.tgz#dfee18623c8cb31deb796aa3ca84dda9cea94354"
   dependencies:
     "@babel/helper-plugin-utils" "^7.20.2"
 
+"@babel/plugin-transform-class-properties@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.22.3.tgz#3407145e513830df77f0cef828b8b231c166fe4c"
+  integrity sha512-mASLsd6rhOrLZ5F3WbCxkzl67mmOnqik0zrg5W6D/X0QMW7HtvnoL1dRARLKIbMP3vXwkwziuLesPqWVGIl6Bw==
+  dependencies:
+    "@babel/helper-create-class-features-plugin" "^7.22.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
+
+"@babel/plugin-transform-class-static-block@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.22.3.tgz#e352cf33567385c731a8f21192efeba760358773"
+  integrity sha512-5BirgNWNOx7cwbTJCOmKFJ1pZjwk5MUfMIwiBBvsirCJMZeQgs5pk6i1OlkVg+1Vef5LfBahFOrdCnAWvkVKMw==
+  dependencies:
+    "@babel/helper-create-class-features-plugin" "^7.22.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/plugin-syntax-class-static-block" "^7.14.5"
+
 "@babel/plugin-transform-classes@^7.21.0":
   version "7.21.0"
   resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.21.0.tgz#f469d0b07a4c5a7dbb21afad9e27e57b47031665"
   dependencies:
     "@babel/helper-plugin-utils" "^7.18.9"
 
+"@babel/plugin-transform-dynamic-import@^7.22.1":
+  version "7.22.1"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.22.1.tgz#6c56afaf896a07026330cf39714532abed8d9ed1"
+  integrity sha512-rlhWtONnVBPdmt+jeewS0qSnMz/3yLFrqAP8hHC6EDcrYRSyuz9f9yQhHvVn2Ad6+yO9fHXac5piudeYrInxwQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/plugin-syntax-dynamic-import" "^7.8.3"
+
 "@babel/plugin-transform-exponentiation-operator@^7.18.6":
   version "7.18.6"
   resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz#421c705f4521888c65e91fdd1af951bfefd4dacd"
     "@babel/helper-builder-binary-assignment-operator-visitor" "^7.18.6"
     "@babel/helper-plugin-utils" "^7.18.6"
 
+"@babel/plugin-transform-export-namespace-from@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.22.3.tgz#9b8700aa495007d3bebac8358d1c562434b680b9"
+  integrity sha512-5Ti1cHLTDnt3vX61P9KZ5IG09bFXp4cDVFJIAeCZuxu9OXXJJZp5iP0n/rzM2+iAutJY+KWEyyHcRaHlpQ/P5g==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
+
 "@babel/plugin-transform-for-of@^7.21.5":
   version "7.21.5"
   resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.21.5.tgz#e890032b535f5a2e237a18535f56a9fdaa7b83fc"
     "@babel/helper-function-name" "^7.18.9"
     "@babel/helper-plugin-utils" "^7.18.9"
 
+"@babel/plugin-transform-json-strings@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.22.3.tgz#a181b8679cf7c93e9d0e3baa5b1776d65be601a9"
+  integrity sha512-IuvOMdeOOY2X4hRNAT6kwbePtK21BUyrAEgLKviL8pL6AEEVUVcqtRdN/HJXBLGIbt9T3ETmXRnFedRRmQNTYw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/plugin-syntax-json-strings" "^7.8.3"
+
 "@babel/plugin-transform-literals@^7.18.9":
   version "7.18.9"
   resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz#72796fdbef80e56fba3c6a699d54f0de557444bc"
   dependencies:
     "@babel/helper-plugin-utils" "^7.18.9"
 
+"@babel/plugin-transform-logical-assignment-operators@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.22.3.tgz#9e021455810f33b0baccb82fb759b194f5dc36f0"
+  integrity sha512-CbayIfOw4av2v/HYZEsH+Klks3NC2/MFIR3QR8gnpGNNPEaq2fdlVCRYG/paKs7/5hvBLQ+H70pGWOHtlNEWNA==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4"
+
 "@babel/plugin-transform-member-expression-literals@^7.18.6":
   version "7.18.6"
   resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz#ac9fdc1a118620ac49b7e7a5d2dc177a1bfee88e"
     "@babel/helper-plugin-utils" "^7.21.5"
     "@babel/helper-simple-access" "^7.21.5"
 
-"@babel/plugin-transform-modules-systemjs@^7.20.11":
-  version "7.20.11"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.20.11.tgz#467ec6bba6b6a50634eea61c9c232654d8a4696e"
-  integrity sha512-vVu5g9BPQKSFEmvt2TA4Da5N+QVS66EX21d8uoOihC+OCpUoGvzVsXeqFdtAEfVa5BILAeFt+U7yVmLbQnAJmw==
+"@babel/plugin-transform-modules-systemjs@^7.20.11", "@babel/plugin-transform-modules-systemjs@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.22.3.tgz#cc507e03e88d87b016feaeb5dae941e6ef50d91e"
+  integrity sha512-V21W3bKLxO3ZjcBJZ8biSvo5gQ85uIXW2vJfh7JSWf/4SLUSr1tOoHX3ruN4+Oqa2m+BKfsxTR1I+PsvkIWvNw==
   dependencies:
     "@babel/helper-hoist-variables" "^7.18.6"
-    "@babel/helper-module-transforms" "^7.20.11"
-    "@babel/helper-plugin-utils" "^7.20.2"
+    "@babel/helper-module-transforms" "^7.22.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
     "@babel/helper-validator-identifier" "^7.19.1"
 
 "@babel/plugin-transform-modules-umd@^7.18.6":
     "@babel/helper-module-transforms" "^7.18.6"
     "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/plugin-transform-named-capturing-groups-regex@^7.20.5":
-  version "7.20.5"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.20.5.tgz#626298dd62ea51d452c3be58b285d23195ba69a8"
-  integrity sha512-mOW4tTzi5iTLnw+78iEq3gr8Aoq4WNRGpmSlrogqaiCBoR1HFhpU4JkpQFOHfeYx3ReVIFWOQJS4aZBRvuZ6mA==
+"@babel/plugin-transform-named-capturing-groups-regex@^7.20.5", "@babel/plugin-transform-named-capturing-groups-regex@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.22.3.tgz#db6fb77e6b3b53ec3b8d370246f0b7cf67d35ab4"
+  integrity sha512-c6HrD/LpUdNNJsISQZpds3TXvfYIAbo+efE9aWmY/PmSRD0agrJ9cPMt4BmArwUQ7ZymEWTFjTyp+yReLJZh0Q==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.20.5"
-    "@babel/helper-plugin-utils" "^7.20.2"
+    "@babel/helper-create-regexp-features-plugin" "^7.22.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
 
-"@babel/plugin-transform-new-target@^7.18.6":
-  version "7.18.6"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz#d128f376ae200477f37c4ddfcc722a8a1b3246a8"
-  integrity sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw==
+"@babel/plugin-transform-new-target@^7.18.6", "@babel/plugin-transform-new-target@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.22.3.tgz#deb0377d741cbee2f45305868b9026dcd6dd96e2"
+  integrity sha512-5RuJdSo89wKdkRTqtM9RVVJzHum9c2s0te9rB7vZC1zKKxcioWIy+xcu4OoIAjyFZhb/bp5KkunuLin1q7Ct+w==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.18.6"
+    "@babel/helper-plugin-utils" "^7.21.5"
+
+"@babel/plugin-transform-nullish-coalescing-operator@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.22.3.tgz#8c519f8bf5af94a9ca6f65cf422a9d3396e542b9"
+  integrity sha512-CpaoNp16nX7ROtLONNuCyenYdY/l7ZsR6aoVa7rW7nMWisoNoQNIH5Iay/4LDyRjKMuElMqXiBoOQCDLTMGZiw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
+
+"@babel/plugin-transform-numeric-separator@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.22.3.tgz#02493070ca6685884b0eee705363ee4da2132ab0"
+  integrity sha512-+AF88fPDJrnseMh5vD9+SH6wq4ZMvpiTMHh58uLs+giMEyASFVhcT3NkoyO+NebFCNnpHJEq5AXO2txV4AGPDQ==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/plugin-syntax-numeric-separator" "^7.10.4"
+
+"@babel/plugin-transform-object-rest-spread@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.22.3.tgz#da6fba693effb8c203d8c3bdf7bf4e2567e802e9"
+  integrity sha512-38bzTsqMMCI46/TQnJwPPpy33EjLCc1Gsm2hRTF6zTMWnKsN61vdrpuzIEGQyKEhDSYDKyZHrrd5FMj4gcUHhw==
+  dependencies:
+    "@babel/compat-data" "^7.22.3"
+    "@babel/helper-compilation-targets" "^7.22.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/plugin-syntax-object-rest-spread" "^7.8.3"
+    "@babel/plugin-transform-parameters" "^7.22.3"
 
 "@babel/plugin-transform-object-super@^7.18.6":
   version "7.18.6"
     "@babel/helper-plugin-utils" "^7.18.6"
     "@babel/helper-replace-supers" "^7.18.6"
 
-"@babel/plugin-transform-parameters@^7.20.7", "@babel/plugin-transform-parameters@^7.21.3":
-  version "7.21.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.21.3.tgz#18fc4e797cf6d6d972cb8c411dbe8a809fa157db"
-  integrity sha512-Wxc+TvppQG9xWFYatvCGPvZ6+SIUxQ2ZdiBP+PHYMIjnPXD+uThCshaz4NZOnODAtBjjcVQQ/3OKs9LW28purQ==
+"@babel/plugin-transform-optional-catch-binding@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.22.3.tgz#e971a083fc7d209d9cd18253853af1db6d8dc42f"
+  integrity sha512-bnDFWXFzWY0BsOyqaoSXvMQ2F35zutQipugog/rqotL2S4ciFOKlRYUu9djt4iq09oh2/34hqfRR2k1dIvuu4g==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.20.2"
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/plugin-syntax-optional-catch-binding" "^7.8.3"
+
+"@babel/plugin-transform-optional-chaining@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.22.3.tgz#5fd24a4a7843b76da6aeec23c7f551da5d365290"
+  integrity sha512-63v3/UFFxhPKT8j8u1jTTGVyITxl7/7AfOqK8C5gz1rHURPUGe3y5mvIf68eYKGoBNahtJnTxBKug4BQOnzeJg==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.20.0"
+    "@babel/plugin-syntax-optional-chaining" "^7.8.3"
+
+"@babel/plugin-transform-parameters@^7.20.7", "@babel/plugin-transform-parameters@^7.21.3", "@babel/plugin-transform-parameters@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.22.3.tgz#24477acfd2fd2bc901df906c9bf17fbcfeee900d"
+  integrity sha512-x7QHQJHPuD9VmfpzboyGJ5aHEr9r7DsAsdxdhJiTB3J3j8dyl+NFZ+rX5Q2RWFDCs61c06qBfS4ys2QYn8UkMw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.21.5"
+
+"@babel/plugin-transform-private-methods@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.22.3.tgz#adac38020bab5047482d3297107c1f58e9c574f6"
+  integrity sha512-fC7jtjBPFqhqpPAE+O4LKwnLq7gGkD3ZmC2E3i4qWH34mH3gOg2Xrq5YMHUq6DM30xhqM1DNftiRaSqVjEG+ug==
+  dependencies:
+    "@babel/helper-create-class-features-plugin" "^7.22.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
+
+"@babel/plugin-transform-private-property-in-object@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.22.3.tgz#031621b02c7b7d95389de1a3dba2fe9e8c548e56"
+  integrity sha512-C7MMl4qWLpgVCbXfj3UW8rR1xeCnisQ0cU7YJHV//8oNBS0aCIVg1vFnZXxOckHhEpQyqNNkWmvSEWnMLlc+Vw==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.18.6"
+    "@babel/helper-create-class-features-plugin" "^7.22.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/plugin-syntax-private-property-in-object" "^7.14.5"
 
 "@babel/plugin-transform-property-literals@^7.18.6":
   version "7.18.6"
     "@babel/helper-plugin-utils" "^7.18.6"
 
 "@babel/plugin-transform-runtime@^7.21.4":
-  version "7.21.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.21.4.tgz#2e1da21ca597a7d01fc96b699b21d8d2023191aa"
-  integrity sha512-1J4dhrw1h1PqnNNpzwxQ2UBymJUF8KuPjAAnlLwZcGhHAIqUigFW7cdK6GHoB64ubY4qXQNYknoUeks4Wz7CUA==
+  version "7.22.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.22.4.tgz#f8353f313f18c3ce1315688631ec48657b97af42"
+  integrity sha512-Urkiz1m4zqiRo17klj+l3nXgiRTFQng91Bc1eiLF7BMQu1e7wE5Gcq9xSv062IF068NHjcutSbIMev60gXxAvA==
   dependencies:
     "@babel/helper-module-imports" "^7.21.4"
-    "@babel/helper-plugin-utils" "^7.20.2"
-    babel-plugin-polyfill-corejs2 "^0.3.3"
-    babel-plugin-polyfill-corejs3 "^0.6.0"
-    babel-plugin-polyfill-regenerator "^0.4.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
+    babel-plugin-polyfill-corejs2 "^0.4.3"
+    babel-plugin-polyfill-corejs3 "^0.8.1"
+    babel-plugin-polyfill-regenerator "^0.5.0"
     semver "^6.3.0"
 
 "@babel/plugin-transform-shorthand-properties@^7.18.6":
     "@babel/helper-plugin-utils" "^7.18.9"
 
 "@babel/plugin-transform-typescript@^7.21.3":
-  version "7.21.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.21.3.tgz#316c5be579856ea890a57ebc5116c5d064658f2b"
-  integrity sha512-RQxPz6Iqt8T0uw/WsJNReuBpWpBqs/n7mNo18sKLoTbMp+UrEekhH+pKSVC7gWz+DNjo9gryfV8YzCiT45RgMw==
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.22.3.tgz#8f662cec8ba88c873f1c7663c0c94e3f68592f09"
+  integrity sha512-pyjnCIniO5PNaEuGxT28h0HbMru3qCVrMqVgVOz/krComdIrY9W6FCLBq9NWHY8HDGaUlan+UhmZElDENIfCcw==
   dependencies:
     "@babel/helper-annotate-as-pure" "^7.18.6"
-    "@babel/helper-create-class-features-plugin" "^7.21.0"
-    "@babel/helper-plugin-utils" "^7.20.2"
-    "@babel/plugin-syntax-typescript" "^7.20.0"
+    "@babel/helper-create-class-features-plugin" "^7.22.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/plugin-syntax-typescript" "^7.21.4"
 
 "@babel/plugin-transform-unicode-escapes@^7.21.5":
   version "7.21.5"
   dependencies:
     "@babel/helper-plugin-utils" "^7.21.5"
 
+"@babel/plugin-transform-unicode-property-regex@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.22.3.tgz#597b6a614dc93eaae605ee293e674d79d32eb380"
+  integrity sha512-5ScJ+OmdX+O6HRuMGW4kv7RL9vIKdtdAj9wuWUKy1wbHY3jaM/UlyIiC1G7J6UJiiyMukjjK0QwL3P0vBd0yYg==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.22.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
+
 "@babel/plugin-transform-unicode-regex@^7.18.6":
   version "7.18.6"
   resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz#194317225d8c201bbae103364ffe9e2cea36cdca"
     "@babel/helper-create-regexp-features-plugin" "^7.18.6"
     "@babel/helper-plugin-utils" "^7.18.6"
 
-"@babel/preset-env@7.21.5", "@babel/preset-env@^7.11.0":
+"@babel/plugin-transform-unicode-sets-regex@^7.22.3":
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.22.3.tgz#7c14ee33fa69782b0101d0f7143d3fc73ce00700"
+  integrity sha512-hNufLdkF8vqywRp+P55j4FHXqAX2LRUccoZHH7AFn1pq5ZOO2ISKW9w13bFZVjBoTqeve2HOgoJCcaziJVhGNw==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.22.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
+
+"@babel/preset-env@7.21.5":
   version "7.21.5"
   resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.21.5.tgz#db2089d99efd2297716f018aeead815ac3decffb"
   integrity sha512-wH00QnTTldTbf/IefEVyChtRdw5RJvODT/Vb4Vcxq1AZvtXj6T0YeX0cAcXhI6/BdGuiP3GcNIL4OQbI2DVNxg==
     core-js-compat "^3.25.1"
     semver "^6.3.0"
 
+"@babel/preset-env@^7.11.0":
+  version "7.22.4"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.22.4.tgz#c86a82630f0e8c61d9bb9327b7b896732028cbed"
+  integrity sha512-c3lHOjbwBv0TkhYCr+XCR6wKcSZ1QbQTVdSkZUaVpLv8CVWotBMArWUi5UAJrcrQaEnleVkkvaV8F/pmc/STZQ==
+  dependencies:
+    "@babel/compat-data" "^7.22.3"
+    "@babel/helper-compilation-targets" "^7.22.1"
+    "@babel/helper-plugin-utils" "^7.21.5"
+    "@babel/helper-validator-option" "^7.21.0"
+    "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.18.6"
+    "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.22.3"
+    "@babel/plugin-proposal-private-property-in-object" "^7.21.0"
+    "@babel/plugin-syntax-async-generators" "^7.8.4"
+    "@babel/plugin-syntax-class-properties" "^7.12.13"
+    "@babel/plugin-syntax-class-static-block" "^7.14.5"
+    "@babel/plugin-syntax-dynamic-import" "^7.8.3"
+    "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
+    "@babel/plugin-syntax-import-assertions" "^7.20.0"
+    "@babel/plugin-syntax-import-attributes" "^7.22.3"
+    "@babel/plugin-syntax-import-meta" "^7.10.4"
+    "@babel/plugin-syntax-json-strings" "^7.8.3"
+    "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4"
+    "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
+    "@babel/plugin-syntax-numeric-separator" "^7.10.4"
+    "@babel/plugin-syntax-object-rest-spread" "^7.8.3"
+    "@babel/plugin-syntax-optional-catch-binding" "^7.8.3"
+    "@babel/plugin-syntax-optional-chaining" "^7.8.3"
+    "@babel/plugin-syntax-private-property-in-object" "^7.14.5"
+    "@babel/plugin-syntax-top-level-await" "^7.14.5"
+    "@babel/plugin-syntax-unicode-sets-regex" "^7.18.6"
+    "@babel/plugin-transform-arrow-functions" "^7.21.5"
+    "@babel/plugin-transform-async-generator-functions" "^7.22.3"
+    "@babel/plugin-transform-async-to-generator" "^7.20.7"
+    "@babel/plugin-transform-block-scoped-functions" "^7.18.6"
+    "@babel/plugin-transform-block-scoping" "^7.21.0"
+    "@babel/plugin-transform-class-properties" "^7.22.3"
+    "@babel/plugin-transform-class-static-block" "^7.22.3"
+    "@babel/plugin-transform-classes" "^7.21.0"
+    "@babel/plugin-transform-computed-properties" "^7.21.5"
+    "@babel/plugin-transform-destructuring" "^7.21.3"
+    "@babel/plugin-transform-dotall-regex" "^7.18.6"
+    "@babel/plugin-transform-duplicate-keys" "^7.18.9"
+    "@babel/plugin-transform-dynamic-import" "^7.22.1"
+    "@babel/plugin-transform-exponentiation-operator" "^7.18.6"
+    "@babel/plugin-transform-export-namespace-from" "^7.22.3"
+    "@babel/plugin-transform-for-of" "^7.21.5"
+    "@babel/plugin-transform-function-name" "^7.18.9"
+    "@babel/plugin-transform-json-strings" "^7.22.3"
+    "@babel/plugin-transform-literals" "^7.18.9"
+    "@babel/plugin-transform-logical-assignment-operators" "^7.22.3"
+    "@babel/plugin-transform-member-expression-literals" "^7.18.6"
+    "@babel/plugin-transform-modules-amd" "^7.20.11"
+    "@babel/plugin-transform-modules-commonjs" "^7.21.5"
+    "@babel/plugin-transform-modules-systemjs" "^7.22.3"
+    "@babel/plugin-transform-modules-umd" "^7.18.6"
+    "@babel/plugin-transform-named-capturing-groups-regex" "^7.22.3"
+    "@babel/plugin-transform-new-target" "^7.22.3"
+    "@babel/plugin-transform-nullish-coalescing-operator" "^7.22.3"
+    "@babel/plugin-transform-numeric-separator" "^7.22.3"
+    "@babel/plugin-transform-object-rest-spread" "^7.22.3"
+    "@babel/plugin-transform-object-super" "^7.18.6"
+    "@babel/plugin-transform-optional-catch-binding" "^7.22.3"
+    "@babel/plugin-transform-optional-chaining" "^7.22.3"
+    "@babel/plugin-transform-parameters" "^7.22.3"
+    "@babel/plugin-transform-private-methods" "^7.22.3"
+    "@babel/plugin-transform-private-property-in-object" "^7.22.3"
+    "@babel/plugin-transform-property-literals" "^7.18.6"
+    "@babel/plugin-transform-regenerator" "^7.21.5"
+    "@babel/plugin-transform-reserved-words" "^7.18.6"
+    "@babel/plugin-transform-shorthand-properties" "^7.18.6"
+    "@babel/plugin-transform-spread" "^7.20.7"
+    "@babel/plugin-transform-sticky-regex" "^7.18.6"
+    "@babel/plugin-transform-template-literals" "^7.18.9"
+    "@babel/plugin-transform-typeof-symbol" "^7.18.9"
+    "@babel/plugin-transform-unicode-escapes" "^7.21.5"
+    "@babel/plugin-transform-unicode-property-regex" "^7.22.3"
+    "@babel/plugin-transform-unicode-regex" "^7.18.6"
+    "@babel/plugin-transform-unicode-sets-regex" "^7.22.3"
+    "@babel/preset-modules" "^0.1.5"
+    "@babel/types" "^7.22.4"
+    babel-plugin-polyfill-corejs2 "^0.4.3"
+    babel-plugin-polyfill-corejs3 "^0.8.1"
+    babel-plugin-polyfill-regenerator "^0.5.0"
+    core-js-compat "^3.30.2"
+    semver "^6.3.0"
+
 "@babel/preset-modules@^0.1.5":
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.5.tgz#ef939d6e7f268827e1841638dc6ff95515e115d9"
   integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
 
 "@babel/runtime@^7.11.2", "@babel/runtime@^7.20.6", "@babel/runtime@^7.21.5", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4":
-  version "7.21.5"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200"
-  integrity sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==
+  version "7.22.3"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.3.tgz#0a7fce51d43adbf0f7b517a71f4c3aaca92ebcbb"
+  integrity sha512-XsDuspWKLUsxwCp6r7EhsExHtYfbe5oAGQ19kqngTdCPUoPQzOPdUbD/pB9PJiwb2ptYKQDjSJT3R6dC+EPqfQ==
   dependencies:
     regenerator-runtime "^0.13.11"
 
-"@babel/template@^7.18.10", "@babel/template@^7.20.7":
-  version "7.20.7"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8"
-  integrity sha512-8SegXApWe6VoNw0r9JHpSteLKTpTiLZ4rMlGIm9JQ18KiCtyQiAMEazujAHrUS5flrcqYZa75ukev3P6QmUwUw==
+"@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.21.9":
+  version "7.21.9"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.21.9.tgz#bf8dad2859130ae46088a99c1f265394877446fb"
+  integrity sha512-MK0X5k8NKOuWRamiEfc3KEJiHMTkGZNUjzMipqCGDDc6ijRl/B7RGSKVGncu4Ro/HdyzzY6cmoXuKI2Gffk7vQ==
   dependencies:
-    "@babel/code-frame" "^7.18.6"
-    "@babel/parser" "^7.20.7"
-    "@babel/types" "^7.20.7"
+    "@babel/code-frame" "^7.21.4"
+    "@babel/parser" "^7.21.9"
+    "@babel/types" "^7.21.5"
 
-"@babel/traverse@^7.0.0-beta.54", "@babel/traverse@^7.20.5", "@babel/traverse@^7.21.5":
-  version "7.21.5"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.21.5.tgz#ad22361d352a5154b498299d523cf72998a4b133"
-  integrity sha512-AhQoI3YjWi6u/y/ntv7k48mcrCXmus0t79J9qPNlk/lAsFlCiJ047RmbfMOawySTHtywXhbXgpx/8nXMYd+oFw==
+"@babel/traverse@^7.0.0-beta.54", "@babel/traverse@^7.20.5", "@babel/traverse@^7.22.1":
+  version "7.22.4"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.22.4.tgz#c3cf96c5c290bd13b55e29d025274057727664c0"
+  integrity sha512-Tn1pDsjIcI+JcLKq1AVlZEr4226gpuAQTsLMorsYg9tuS/kG7nuwwJ4AB8jfQuEgb/COBwR/DqJxmoiYFu5/rQ==
   dependencies:
     "@babel/code-frame" "^7.21.4"
-    "@babel/generator" "^7.21.5"
-    "@babel/helper-environment-visitor" "^7.21.5"
+    "@babel/generator" "^7.22.3"
+    "@babel/helper-environment-visitor" "^7.22.1"
     "@babel/helper-function-name" "^7.21.0"
     "@babel/helper-hoist-variables" "^7.18.6"
     "@babel/helper-split-export-declaration" "^7.18.6"
-    "@babel/parser" "^7.21.5"
-    "@babel/types" "^7.21.5"
+    "@babel/parser" "^7.22.4"
+    "@babel/types" "^7.22.4"
     debug "^4.1.0"
     globals "^11.1.0"
 
-"@babel/types@^7", "@babel/types@^7.0.0-beta.54", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.20.0", "@babel/types@^7.20.5", "@babel/types@^7.20.7", "@babel/types@^7.21.0", "@babel/types@^7.21.4", "@babel/types@^7.21.5", "@babel/types@^7.4.4":
-  version "7.21.5"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.21.5.tgz#18dfbd47c39d3904d5db3d3dc2cc80bedb60e5b6"
-  integrity sha512-m4AfNvVF2mVC/F7fDEdH2El3HzUg9It/XsCxZiOTTA3m3qYfcSVSbTfM6Q9xG+hYDniZssYhlXKKUMD5m8tF4Q==
+"@babel/types@^7", "@babel/types@^7.0.0-beta.54", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.20.0", "@babel/types@^7.20.5", "@babel/types@^7.21.0", "@babel/types@^7.21.4", "@babel/types@^7.21.5", "@babel/types@^7.22.0", "@babel/types@^7.22.3", "@babel/types@^7.22.4", "@babel/types@^7.4.4":
+  version "7.22.4"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.22.4.tgz#56a2653ae7e7591365dabf20b76295410684c071"
+  integrity sha512-Tx9x3UBHTTsMSW85WB2kphxYQVvrZ/t1FxD88IpSgIjiUJlCm9z+xWIDwyo1vffTwSqteqyznB8ZE9vYYk16zA==
   dependencies:
     "@babel/helper-string-parser" "^7.21.5"
     "@babel/helper-validator-identifier" "^7.19.1"
     minimatch "^3.1.2"
     strip-json-comments "^3.1.1"
 
-"@eslint/js@8.40.0":
-  version "8.40.0"
-  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.40.0.tgz#3ba73359e11f5a7bd3e407f70b3528abfae69cec"
-  integrity sha512-ElyB54bJIhXQYVKjDSvCkPO1iU1tSAeVQJbllWJq1XQSmmA4dgFk8CbiBGpiOPxleE48vDogxCtmMYku4HSVLA==
+"@eslint/js@8.42.0":
+  version "8.42.0"
+  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.42.0.tgz#484a1d638de2911e6f5a30c12f49c7e4a3270fb6"
+  integrity sha512-6SWlXpWU5AvId8Ac7zjzmIOqMOba/JWY8XZ4A7q7Gn1Vlfg/SFFIlrtHXt9nPn4op9ZPAkl91Jao+QQv3r/ukw==
 
-"@humanwhocodes/config-array@^0.11.8":
-  version "0.11.8"
-  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.8.tgz#03595ac2075a4dc0f191cc2131de14fbd7d410b9"
-  integrity sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==
+"@humanwhocodes/config-array@^0.11.10":
+  version "0.11.10"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2"
+  integrity sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==
   dependencies:
     "@humanwhocodes/object-schema" "^1.2.1"
     debug "^4.1.1"
   resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72"
   integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==
 
-"@jridgewell/source-map@^0.3.2":
+"@jridgewell/source-map@^0.3.3":
   version "0.3.3"
   resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.3.tgz#8108265659d4c33e72ffe14e33d6cc5eb59f2fda"
   integrity sha512-b+fsZXeLYi9fEULmfBrhxn4IrPlINf8fiNarzTof004v3lFdntdwa9PF7vFJqm3mg7s+ScJMxXaE3Acp1irZcg==
   integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
 
 "@pkgr/utils@^2.3.1":
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/@pkgr/utils/-/utils-2.4.0.tgz#b6373d2504aedaf2fc7cdf2d13ab1f48fa5f12d5"
-  integrity sha512-2OCURAmRtdlL8iUDTypMrrxfwe8frXTeXaxGsVOaYtc/wrUyk8Z/0OBetM7cdlsy7ZFWlMX72VogKeh+A4Xcjw==
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@pkgr/utils/-/utils-2.4.1.tgz#adf291d0357834c410ce80af16e711b56c7b1cd3"
+  integrity sha512-JOqwkgFEyi+OROIyq7l4Jy28h/WwhDnG/cPkXG2Z1iFbubB6jsHW1NDvmyOzTBxHr3yg68YGirmh1JUgMqa+9w==
   dependencies:
     cross-spawn "^7.0.3"
     fast-glob "^3.2.12"
     tslib "^2.5.0"
 
 "@popperjs/core@^2.9.0", "@popperjs/core@^2.9.2":
-  version "2.11.7"
-  resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.7.tgz#ccab5c8f7dc557a52ca3288c10075c9ccd37fff7"
-  integrity sha512-Cr4OjIkipTtcXKjAsm8agyleBuDHvxzeBoa1v543lbv1YaIwQjESsVcmjiWiPEbC1FIeHOG/Op9kdCmAmiS3Kw==
+  version "2.11.8"
+  resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
+  integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
 
 "@rollup/plugin-babel@^5.2.0":
   version "5.3.1"
     "@types/estree" "*"
 
 "@types/eslint@*":
-  version "8.37.0"
-  resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.37.0.tgz#29cebc6c2a3ac7fea7113207bf5a828fdf4d7ef1"
-  integrity sha512-Piet7dG2JBuDIfohBngQ3rCt7MgO9xCO4xIMKxBThCq5PNRB91IjlJ10eJVwfoNtvTErmxLzwBZ7rHZtbOMmFQ==
+  version "8.40.0"
+  resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.40.0.tgz#ae73dc9ec5237f2794c4f79efd6a4c73b13daf23"
+  integrity sha512-nbq2mvc/tBrK9zQQuItvjJl++GTN5j06DaPtp3hZCpngmG6Q3xoyEmd0TwZI0gAy/G1X0zhGBbr2imsGFdFV0g==
   dependencies:
     "@types/estree" "*"
     "@types/json-schema" "*"
     "@types/node" "*"
 
 "@types/html-to-text@^9.0.0":
-  version "9.0.0"
-  resolved "https://registry.yarnpkg.com/@types/html-to-text/-/html-to-text-9.0.0.tgz#28a676984c281a67478773519da6b5c2bdfb22ed"
-  integrity sha512-FnF3p2FJZ1kJT/0C/lmBzw7HSlH3RhtACVYyrwUsJoCmFNuiLpusWT2FWWB7P9A48CaYpvD6Q2fprn7sZeffpw==
+  version "9.0.1"
+  resolved "https://registry.yarnpkg.com/@types/html-to-text/-/html-to-text-9.0.1.tgz#d4f8b1844464df3a13ca14134f5970c847de6751"
+  integrity sha512-sHu702QGb0SP2F0Zt+CxdCmGZIZ0gEaaCjqOh/V4iba1wTxPVntEPOM/vHm5bel08TILhB3+OxUTkDJWnr/zHQ==
 
 "@types/http-proxy@^1.17.8":
   version "1.17.11"
     "@types/node" "*"
 
 "@types/json-schema@*", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9":
-  version "7.0.11"
-  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
-  integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
+  version "7.0.12"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.12.tgz#d70faba7039d5fca54c83c7dbab41051d2b6f6cb"
+  integrity sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==
 
 "@types/linkify-it@*":
   version "3.0.2"
   integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==
 
 "@types/node@*", "@types/node@^20.1.2":
-  version "20.1.4"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.1.4.tgz#83f148d2d1f5fe6add4c53358ba00d97fc4cdb71"
-  integrity sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q==
+  version "20.2.5"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.2.5.tgz#26d295f3570323b2837d322180dfbf1ba156fefb"
+  integrity sha512-JJulVEQXmiY9Px5axXHeYGLSjhkZEnD+MDPDGbCbIAbMslkKwmygtZFy1X6s/075Yo94sf8GuSlFfPzysQrWZQ==
 
 "@types/qs@*":
   version "6.9.7"
     "@types/node" "*"
 
 "@typescript-eslint/eslint-plugin@^5.59.5":
-  version "5.59.5"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.5.tgz#f156827610a3f8cefc56baeaa93cd4a5f32966b4"
-  integrity sha512-feA9xbVRWJZor+AnLNAr7A8JRWeZqHUf4T9tlP+TN04b05pFVhO5eN7/O93Y/1OUlLMHKbnJisgDURs/qvtqdg==
+  version "5.59.9"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.9.tgz#2604cfaf2b306e120044f901e20c8ed926debf15"
+  integrity sha512-4uQIBq1ffXd2YvF7MAvehWKW3zVv/w+mSfRAu+8cKbfj3nwzyqJLNcZJpQ/WZ1HLbJDiowwmQ6NO+63nCA+fqA==
   dependencies:
     "@eslint-community/regexpp" "^4.4.0"
-    "@typescript-eslint/scope-manager" "5.59.5"
-    "@typescript-eslint/type-utils" "5.59.5"
-    "@typescript-eslint/utils" "5.59.5"
+    "@typescript-eslint/scope-manager" "5.59.9"
+    "@typescript-eslint/type-utils" "5.59.9"
+    "@typescript-eslint/utils" "5.59.9"
     debug "^4.3.4"
     grapheme-splitter "^1.0.4"
     ignore "^5.2.0"
     tsutils "^3.21.0"
 
 "@typescript-eslint/parser@^5.59.5":
-  version "5.59.5"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.59.5.tgz#63064f5eafbdbfb5f9dfbf5c4503cdf949852981"
-  integrity sha512-NJXQC4MRnF9N9yWqQE2/KLRSOLvrrlZb48NGVfBa+RuPMN6B7ZcK5jZOvhuygv4D64fRKnZI4L4p8+M+rfeQuw==
+  version "5.59.9"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.59.9.tgz#a85c47ccdd7e285697463da15200f9a8561dd5fa"
+  integrity sha512-FsPkRvBtcLQ/eVK1ivDiNYBjn3TGJdXy2fhXX+rc7czWl4ARwnpArwbihSOHI2Peg9WbtGHrbThfBUkZZGTtvQ==
   dependencies:
-    "@typescript-eslint/scope-manager" "5.59.5"
-    "@typescript-eslint/types" "5.59.5"
-    "@typescript-eslint/typescript-estree" "5.59.5"
+    "@typescript-eslint/scope-manager" "5.59.9"
+    "@typescript-eslint/types" "5.59.9"
+    "@typescript-eslint/typescript-estree" "5.59.9"
     debug "^4.3.4"
 
-"@typescript-eslint/scope-manager@5.59.5":
-  version "5.59.5"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.59.5.tgz#33ffc7e8663f42cfaac873de65ebf65d2bce674d"
-  integrity sha512-jVecWwnkX6ZgutF+DovbBJirZcAxgxC0EOHYt/niMROf8p4PwxxG32Qdhj/iIQQIuOflLjNkxoXyArkcIP7C3A==
+"@typescript-eslint/scope-manager@5.59.9":
+  version "5.59.9"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.59.9.tgz#eadce1f2733389cdb58c49770192c0f95470d2f4"
+  integrity sha512-8RA+E+w78z1+2dzvK/tGZ2cpGigBZ58VMEHDZtpE1v+LLjzrYGc8mMaTONSxKyEkz3IuXFM0IqYiGHlCsmlZxQ==
   dependencies:
-    "@typescript-eslint/types" "5.59.5"
-    "@typescript-eslint/visitor-keys" "5.59.5"
+    "@typescript-eslint/types" "5.59.9"
+    "@typescript-eslint/visitor-keys" "5.59.9"
 
-"@typescript-eslint/type-utils@5.59.5":
-  version "5.59.5"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.59.5.tgz#485b0e2c5b923460bc2ea6b338c595343f06fc9b"
-  integrity sha512-4eyhS7oGym67/pSxA2mmNq7X164oqDYNnZCUayBwJZIRVvKpBCMBzFnFxjeoDeShjtO6RQBHBuwybuX3POnDqg==
+"@typescript-eslint/type-utils@5.59.9":
+  version "5.59.9"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.59.9.tgz#53bfaae2e901e6ac637ab0536d1754dfef4dafc2"
+  integrity sha512-ksEsT0/mEHg9e3qZu98AlSrONAQtrSTljL3ow9CGej8eRo7pe+yaC/mvTjptp23Xo/xIf2mLZKC6KPv4Sji26Q==
   dependencies:
-    "@typescript-eslint/typescript-estree" "5.59.5"
-    "@typescript-eslint/utils" "5.59.5"
+    "@typescript-eslint/typescript-estree" "5.59.9"
+    "@typescript-eslint/utils" "5.59.9"
     debug "^4.3.4"
     tsutils "^3.21.0"
 
-"@typescript-eslint/types@5.59.5":
-  version "5.59.5"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.5.tgz#e63c5952532306d97c6ea432cee0981f6d2258c7"
-  integrity sha512-xkfRPHbqSH4Ggx4eHRIO/eGL8XL4Ysb4woL8c87YuAo8Md7AUjyWKa9YMwTL519SyDPrfEgKdewjkxNCVeJW7w==
+"@typescript-eslint/types@5.59.9":
+  version "5.59.9"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.59.9.tgz#3b4e7ae63718ce1b966e0ae620adc4099a6dcc52"
+  integrity sha512-uW8H5NRgTVneSVTfiCVffBb8AbwWSKg7qcA4Ot3JI3MPCJGsB4Db4BhvAODIIYE5mNj7Q+VJkK7JxmRhk2Lyjw==
 
-"@typescript-eslint/typescript-estree@5.59.5":
-  version "5.59.5"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.5.tgz#9b252ce55dd765e972a7a2f99233c439c5101e42"
-  integrity sha512-+XXdLN2CZLZcD/mO7mQtJMvCkzRfmODbeSKuMY/yXbGkzvA9rJyDY5qDYNoiz2kP/dmyAxXquL2BvLQLJFPQIg==
+"@typescript-eslint/typescript-estree@5.59.9":
+  version "5.59.9"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.9.tgz#6bfea844e468427b5e72034d33c9fffc9557392b"
+  integrity sha512-pmM0/VQ7kUhd1QyIxgS+aRvMgw+ZljB3eDb+jYyp6d2bC0mQWLzUDF+DLwCTkQ3tlNyVsvZRXjFyV0LkU/aXjA==
   dependencies:
-    "@typescript-eslint/types" "5.59.5"
-    "@typescript-eslint/visitor-keys" "5.59.5"
+    "@typescript-eslint/types" "5.59.9"
+    "@typescript-eslint/visitor-keys" "5.59.9"
     debug "^4.3.4"
     globby "^11.1.0"
     is-glob "^4.0.3"
     semver "^7.3.7"
     tsutils "^3.21.0"
 
-"@typescript-eslint/utils@5.59.5":
-  version "5.59.5"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.59.5.tgz#15b3eb619bb223302e60413adb0accd29c32bcae"
-  integrity sha512-sCEHOiw+RbyTii9c3/qN74hYDPNORb8yWCoPLmB7BIflhplJ65u2PBpdRla12e3SSTJ2erRkPjz7ngLHhUegxA==
+"@typescript-eslint/utils@5.59.9":
+  version "5.59.9"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.59.9.tgz#adee890107b5ffe02cd46fdaa6c2125fb3c6c7c4"
+  integrity sha512-1PuMYsju/38I5Ggblaeb98TOoUvjhRvLpLa1DoTOFaLWqaXl/1iQ1eGurTXgBY58NUdtfTXKP5xBq7q9NDaLKg==
   dependencies:
     "@eslint-community/eslint-utils" "^4.2.0"
     "@types/json-schema" "^7.0.9"
     "@types/semver" "^7.3.12"
-    "@typescript-eslint/scope-manager" "5.59.5"
-    "@typescript-eslint/types" "5.59.5"
-    "@typescript-eslint/typescript-estree" "5.59.5"
+    "@typescript-eslint/scope-manager" "5.59.9"
+    "@typescript-eslint/types" "5.59.9"
+    "@typescript-eslint/typescript-estree" "5.59.9"
     eslint-scope "^5.1.1"
     semver "^7.3.7"
 
-"@typescript-eslint/visitor-keys@5.59.5":
-  version "5.59.5"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.5.tgz#ba5b8d6791a13cf9fea6716af1e7626434b29b9b"
-  integrity sha512-qL+Oz+dbeBRTeyJTIy0eniD3uvqU7x+y1QceBismZ41hd4aBSRh8UAw4pZP0+XzLuPZmx4raNMq/I+59W2lXKA==
+"@typescript-eslint/visitor-keys@5.59.9":
+  version "5.59.9"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.9.tgz#9f86ef8e95aca30fb5a705bb7430f95fc58b146d"
+  integrity sha512-bT7s0td97KMaLwpEBckbzj/YohnvXtqbe2XgqNvTl6RJVakY5mvENOTPvw5u66nljfZxthESpDozs86U+oLY8Q==
   dependencies:
-    "@typescript-eslint/types" "5.59.5"
+    "@typescript-eslint/types" "5.59.9"
     eslint-visitor-keys "^3.3.0"
 
 "@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5":
     "@webassemblyjs/ast" "1.11.6"
     "@xtuc/long" "4.2.2"
 
-"@webpack-cli/configtest@^2.1.0":
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-2.1.0.tgz#b59b33377b1b896a9a7357cfc643b39c1524b1e6"
-  integrity sha512-K/vuv72vpfSEZoo5KIU0a2FsEoYdW0DUMtMpB5X3LlUwshetMZRZRxB7sCsVji/lFaSxtQQ3aM9O4eMolXkU9w==
+"@webpack-cli/configtest@^2.1.1":
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-2.1.1.tgz#3b2f852e91dac6e3b85fb2a314fb8bef46d94646"
+  integrity sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==
 
-"@webpack-cli/info@^2.0.1":
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-2.0.1.tgz#eed745799c910d20081e06e5177c2b2569f166c0"
-  integrity sha512-fE1UEWTwsAxRhrJNikE7v4EotYflkEhBL7EbajfkPlf6E37/2QshOy/D48Mw8G5XMFlQtS6YV42vtbG9zBpIQA==
+"@webpack-cli/info@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-2.0.2.tgz#cc3fbf22efeb88ff62310cf885c5b09f44ae0fdd"
+  integrity sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==
 
-"@webpack-cli/serve@^2.0.4":
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.4.tgz#3982ee6f8b42845437fc4d391e93ac5d9da52f0f"
-  integrity sha512-0xRgjgDLdz6G7+vvDLlaRpFatJaJ69uTalZLRSMX5B3VUrDmXcrVA3+6fXXQgmYz7bY9AAgs348XQdmtLsK41A==
+"@webpack-cli/serve@^2.0.5":
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.5.tgz#325db42395cd49fe6c14057f9a900e427df8810e"
+  integrity sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==
 
 "@xtuc/ieee754@^1.2.0":
   version "1.2.0"
@@ -1720,7 +1970,7 @@ acorn-jsx@^5.3.2:
   resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
   integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
 
-acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8.0:
+acorn@^8.7.1, acorn@^8.8.0, acorn@^8.8.2:
   version "8.8.2"
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a"
   integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==
@@ -2059,6 +2309,15 @@ babel-plugin-polyfill-corejs2@^0.3.3:
     "@babel/helper-define-polyfill-provider" "^0.3.3"
     semver "^6.1.1"
 
+babel-plugin-polyfill-corejs2@^0.4.3:
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.3.tgz#75044d90ba5043a5fb559ac98496f62f3eb668fd"
+  integrity sha512-bM3gHc337Dta490gg+/AseNB9L4YLHxq1nGKZZSHbhXv4aTYU2MD2cjza1Ru4S6975YLTaL1K8uJf6ukJhhmtw==
+  dependencies:
+    "@babel/compat-data" "^7.17.7"
+    "@babel/helper-define-polyfill-provider" "^0.4.0"
+    semver "^6.1.1"
+
 babel-plugin-polyfill-corejs3@^0.6.0:
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz#56ad88237137eade485a71b52f72dbed57c6230a"
@@ -2067,6 +2326,14 @@ babel-plugin-polyfill-corejs3@^0.6.0:
     "@babel/helper-define-polyfill-provider" "^0.3.3"
     core-js-compat "^3.25.1"
 
+babel-plugin-polyfill-corejs3@^0.8.1:
+  version "0.8.1"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.8.1.tgz#39248263c38191f0d226f928d666e6db1b4b3a8a"
+  integrity sha512-ikFrZITKg1xH6pLND8zT14UPgjKHiGLqex7rGEZCH2EvhsneJaJPemmpQaIZV5AL03II+lXylw3UmddDK8RU5Q==
+  dependencies:
+    "@babel/helper-define-polyfill-provider" "^0.4.0"
+    core-js-compat "^3.30.1"
+
 babel-plugin-polyfill-regenerator@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz#390f91c38d90473592ed43351e801a9d3e0fd747"
@@ -2074,6 +2341,13 @@ babel-plugin-polyfill-regenerator@^0.4.1:
   dependencies:
     "@babel/helper-define-polyfill-provider" "^0.3.3"
 
+babel-plugin-polyfill-regenerator@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.5.0.tgz#e7344d88d9ef18a3c47ded99362ae4a757609380"
+  integrity sha512-hDJtKjMLVa7Z+LwnTCxoDLQj6wdc+B8dun7ayF2fYieI6OzfuvcLMB32ihJZ4UhCBwNYGl5bg/x/P9cMdnkc2g==
+  dependencies:
+    "@babel/helper-define-polyfill-provider" "^0.4.0"
+
 balanced-match@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
@@ -2173,14 +2447,14 @@ bonjour-service@^1.0.11:
     multicast-dns "^7.2.5"
 
 bootstrap@^5.2.3:
-  version "5.2.3"
-  resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.2.3.tgz#54739f4414de121b9785c5da3c87b37ff008322b"
-  integrity sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.0.tgz#0718a7cc29040ee8dbf1bd652b896f3436a87c29"
+  integrity sha512-UnBV3E3v4STVNQdms6jSGO2CvOkjUMdDAVR2V5N4uCMdaIkaQjbcEAMqRimDHIs4uqBYzDAKCQwCB+97tJgHQw==
 
 bootswatch@^5.2.3:
-  version "5.2.3"
-  resolved "https://registry.yarnpkg.com/bootswatch/-/bootswatch-5.2.3.tgz#a12bef6ea1a54f1b5b55b472c11a846d1cb77239"
-  integrity sha512-tvnW15WoOY2sEp1uT1ITDQiJy2TekQa+K+Q28WDXibleIxsY0nAoC9IylbnUPD7Q5vkCIclOuBHLVBblJYYPSA==
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/bootswatch/-/bootswatch-5.3.0.tgz#7c7dd50bbe8519b0c6dbe01f4f9c3100b60228bd"
+  integrity sha512-ga2hHognDrh5h3+CaBBug6ktx3MTlnDzH57s+Mvjt9ZcNxqwpK+m3sE3YIUSr8zf2iG05elOb1mnqqcdbce2ow==
 
 boxen@^1.2.1:
   version "1.3.0"
@@ -2225,14 +2499,14 @@ braces@^3.0.2, braces@~3.0.2:
     fill-range "^7.0.1"
 
 browserslist@^4.14.5, browserslist@^4.21.3, browserslist@^4.21.5:
-  version "4.21.5"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.5.tgz#75c5dae60063ee641f977e00edd3cfb2fb7af6a7"
-  integrity sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==
+  version "4.21.7"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.7.tgz#e2b420947e5fb0a58e8f4668ae6e23488127e551"
+  integrity sha512-BauCXrQ7I2ftSqd2mvKHGo85XR0u7Ru3C/Hxsy/0TkfCtjrmAbPdzLGasmoiBxplpDXlPvdjX9u7srIMfgasNA==
   dependencies:
-    caniuse-lite "^1.0.30001449"
-    electron-to-chromium "^1.4.284"
-    node-releases "^2.0.8"
-    update-browserslist-db "^1.0.10"
+    caniuse-lite "^1.0.30001489"
+    electron-to-chromium "^1.4.411"
+    node-releases "^2.0.12"
+    update-browserslist-db "^1.0.11"
 
 buffer-from@^1.0.0:
   version "1.1.2"
@@ -2375,10 +2649,10 @@ camelcase@^5.0.0:
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
   integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
 
-caniuse-lite@^1.0.30001449:
-  version "1.0.30001487"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001487.tgz#d882d1a34d89c11aea53b8cdc791931bdab5fe1b"
-  integrity sha512-83564Z3yWGqXsh2vaH/mhXfEM0wX+NlBCm1jYHOb97TrTWJEmPTccZgeLTPBUUb0PNVo+oomb7wkimZBIERClA==
+caniuse-lite@^1.0.30001489:
+  version "1.0.30001495"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001495.tgz#64a0ccef1911a9dcff647115b4430f8eff1ef2d9"
+  integrity sha512-F6x5IEuigtUfU5ZMQK2jsy5JqUUlEFRVZq8bO2a+ysq5K7jD6PPc9YXZj78xDNS3uNchesp1Jw47YXEqr+Viyg==
 
 capture-stack-trace@^1.0.0:
   version "1.0.2"
@@ -2780,7 +3054,7 @@ copy-webpack-plugin@^11.0.0:
     schema-utils "^4.0.0"
     serialize-javascript "^6.0.0"
 
-core-js-compat@^3.25.1:
+core-js-compat@^3.25.1, core-js-compat@^3.30.1, core-js-compat@^3.30.2:
   version "3.30.2"
   resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.30.2.tgz#83f136e375babdb8c80ad3c22d67c69098c1dd8b"
   integrity sha512-nriW1nuJjUgvkEjIot1Spwakz52V9YkYHZAQG6A1eCgC8AA1p0zngrQEP9R0+V6hji5XilWKG1Bd0YRppmGimA==
@@ -2815,11 +3089,11 @@ create-error-class@^3.0.0:
     capture-stack-trace "^1.0.0"
 
 cross-fetch@^3.1.5:
-  version "3.1.5"
-  resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f"
-  integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==
+  version "3.1.6"
+  resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.6.tgz#bae05aa31a4da760969756318feeee6e70f15d6c"
+  integrity sha512-riRvo06crlE8HiqOwIpQhxwdOk4fOeR7FVM/wXoxchFEqMNUjvbs3bfo4OTgMEMHzppd4DxFBDbyySj8Cv781g==
   dependencies:
-    node-fetch "2.6.7"
+    node-fetch "^2.6.11"
 
 cross-spawn@^5.0.1:
   version "5.1.0"
@@ -2850,14 +3124,14 @@ crypto-random-string@^2.0.0:
   integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
 
 css-loader@^6.7.3:
-  version "6.7.3"
-  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.7.3.tgz#1e8799f3ccc5874fdd55461af51137fcc5befbcd"
-  integrity sha512-qhOH1KlBMnZP8FzRO6YCH9UHXQhVMcEGLyNdb7Hv2cpcmJbW0YrddO+tG1ab5nT41KpHIYGsbeHqxB9xPu1pKQ==
+  version "6.8.1"
+  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.8.1.tgz#0f8f52699f60f5e679eab4ec0fcd68b8e8a50a88"
+  integrity sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==
   dependencies:
     icss-utils "^5.1.0"
-    postcss "^8.4.19"
+    postcss "^8.4.21"
     postcss-modules-extract-imports "^3.0.0"
-    postcss-modules-local-by-default "^4.0.0"
+    postcss-modules-local-by-default "^4.0.3"
     postcss-modules-scope "^3.0.0"
     postcss-modules-values "^4.0.0"
     postcss-value-parser "^4.2.0"
@@ -2874,9 +3148,9 @@ csstype@^3.1.1:
   integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==
 
 cyclist@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
-  integrity sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A==
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.2.tgz#673b5f233bf34d8e602b949429f8171d9121bea3"
+  integrity sha512-0sVXIohTfLqVIW3kb/0n6IiWF3Ifj5nm2XaSrLq2DI6fKIGa2fYAZdk917rUneaeLVpYfFcyXE2ft0fe3remsA==
 
 dashdash@^1.12.0:
   version "1.14.1"
@@ -3201,10 +3475,10 @@ ejs@^3.1.6:
   dependencies:
     jake "^10.8.5"
 
-electron-to-chromium@^1.4.284:
-  version "1.4.394"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.394.tgz#989abe104a40366755648876cde2cdeda9f31133"
-  integrity sha512-0IbC2cfr8w5LxTz+nmn2cJTGafsK9iauV2r5A5scfzyovqLrxuLoxOHE5OBobP3oVIggJT+0JfKnw9sm87c8Hw==
+electron-to-chromium@^1.4.411:
+  version "1.4.421"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.421.tgz#2b8c0ef98ba00d4aef4c664933d570922da52161"
+  integrity sha512-wZOyn3s/aQOtLGAwXMZfteQPN68kgls2wDAnYOA8kCjBvKVrW5RwmWVspxJYTqrcN7Y263XJVsC66VCIGzDO3g==
 
 emoji-mart@^5.4.0:
   version "5.5.2"
@@ -3251,9 +3525,9 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1:
     once "^1.4.0"
 
 enhanced-resolve@^5.14.0:
-  version "5.14.0"
-  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.14.0.tgz#0b6c676c8a3266c99fa281e4433a706f5c0c61c4"
-  integrity sha512-+DCows0XNwLDcUhbFJPdlQEVnT2zXlCv7hPxemTz86/O+B/hCQ+mb7ydkPKiflpVraqLPCAfu7lDy+hBXueojw==
+  version "5.14.1"
+  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.14.1.tgz#de684b6803724477a4af5d74ccae5de52c25f6b3"
+  integrity sha512-Vklwq2vDKtl0y/vtwjSesgJ5MYS7Etuk5txS8VdKL4AOS1aUlD96zqIfsOSLQsdv3xgMRbtkWM8eG9XDfKUPow==
   dependencies:
     graceful-fs "^4.2.4"
     tapable "^2.2.0"
@@ -3451,15 +3725,15 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1:
   integrity sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==
 
 eslint@^8.40.0:
-  version "8.40.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.40.0.tgz#a564cd0099f38542c4e9a2f630fa45bf33bc42a4"
-  integrity sha512-bvR+TsP9EHL3TqNtj9sCNJVAFK3fBN8Q7g5waghxyRsPLIMwL73XSKnZFK0hk/O2ANC+iAoq6PWMQ+IfBAJIiQ==
+  version "8.42.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.42.0.tgz#7bebdc3a55f9ed7167251fe7259f75219cade291"
+  integrity sha512-ulg9Ms6E1WPf67PHaEY4/6E2tEn5/f7FXGzr3t9cBMugOmf1INYvuUwwh1aXQN4MfJ6a5K2iNwP3w4AColvI9A==
   dependencies:
     "@eslint-community/eslint-utils" "^4.2.0"
     "@eslint-community/regexpp" "^4.4.0"
     "@eslint/eslintrc" "^2.0.3"
-    "@eslint/js" "8.40.0"
-    "@humanwhocodes/config-array" "^0.11.8"
+    "@eslint/js" "8.42.0"
+    "@humanwhocodes/config-array" "^0.11.10"
     "@humanwhocodes/module-importer" "^1.0.1"
     "@nodelib/fs.walk" "^1.2.8"
     ajv "^6.10.0"
@@ -3478,13 +3752,12 @@ eslint@^8.40.0:
     find-up "^5.0.0"
     glob-parent "^6.0.2"
     globals "^13.19.0"
-    grapheme-splitter "^1.0.4"
+    graphemer "^1.4.0"
     ignore "^5.2.0"
     import-fresh "^3.0.0"
     imurmurhash "^0.1.4"
     is-glob "^4.0.0"
     is-path-inside "^3.0.3"
-    js-sdsl "^4.1.4"
     js-yaml "^4.1.0"
     json-stable-stringify-without-jsonify "^1.0.1"
     levn "^0.4.1"
@@ -3670,9 +3943,9 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
   integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
 
 fast-diff@^1.1.2:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
-  integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0"
+  integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==
 
 fast-glob@^3.2.11, fast-glob@^3.2.12, fast-glob@^3.2.9:
   version "3.2.12"
@@ -3726,7 +3999,7 @@ file-entry-cache@^6.0.1:
   dependencies:
     flat-cache "^3.0.4"
 
-filelist@^1.0.1:
+filelist@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.4.tgz#f78978a1e944775ff9e62e744424f215e58352b5"
   integrity sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==
@@ -3918,9 +4191,9 @@ fs-minipass@^1.2.7:
     minipass "^2.6.0"
 
 fs-monkey@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.3.tgz#ae3ac92d53bb328efe0e9a1d9541f6ad8d48e2d3"
-  integrity sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.4.tgz#ee8c1b53d3fe8bb7e5d2c5c5dfc0168afdd2f747"
+  integrity sha512-INM/fWAxMICjttnD0DX1rBvinKskj5G1w+oy/pnm9u/tSlnBrzFonJMcalKJ30P8RRsPzKcCG7Q8l0jx5Fh9YQ==
 
 fs-vacuum@^1.2.10, fs-vacuum@~1.2.10:
   version "1.2.10"
@@ -4028,12 +4301,13 @@ get-caller-file@^2.0.1:
   integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
 get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3, get-intrinsic@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.0.tgz#7ad1dc0535f3a2904bba075772763e5051f6d05f"
-  integrity sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.1.tgz#d295644fed4505fc9cde952c37ee12b477a83d82"
+  integrity sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==
   dependencies:
     function-bind "^1.1.1"
     has "^1.0.3"
+    has-proto "^1.0.1"
     has-symbols "^1.0.3"
 
 get-own-enumerable-property-symbols@^3.0.0:
@@ -4095,15 +4369,15 @@ glob-to-regexp@^0.4.1:
   resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
   integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
 
-glob@^10.0.0:
-  version "10.2.3"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-10.2.3.tgz#aa6765963fe6c5936d5c2e00943e7af06302a1a7"
-  integrity sha512-Kb4rfmBVE3eQTAimgmeqc2LwSnN0wIOkkUL6HmxEFxNJ4fHghYHVbFba/HcGcRjE6s9KoMNK3rSOwkL4PioZjg==
+glob@^10.2.5:
+  version "10.2.6"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-10.2.6.tgz#1e27edbb3bbac055cb97113e27a066c100a4e5e1"
+  integrity sha512-U/rnDpXJGF414QQQZv5uVsabTVxMSwzS5CH0p3DRCIV6ownl4f7PzGnkGmvlum2wB+9RlJWJZ6ACU1INnBqiPA==
   dependencies:
     foreground-child "^3.1.0"
     jackspeak "^2.0.3"
-    minimatch "^9.0.0"
-    minipass "^5.0.0"
+    minimatch "^9.0.1"
+    minipass "^5.0.0 || ^6.0.2"
     path-scurry "^1.7.0"
 
 glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
@@ -4229,6 +4503,11 @@ grapheme-splitter@^1.0.4:
   resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
   integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==
 
+graphemer@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6"
+  integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
+
 handle-thing@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e"
@@ -4326,9 +4605,9 @@ hpack.js@^2.1.6:
     wbuf "^1.1.0"
 
 html-entities@^2.3.2:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46"
-  integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==
+  version "2.3.5"
+  resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.5.tgz#9f117bf6a5962efc31e094f6c6dad3cf3b95e33e"
+  integrity sha512-72TJlcMkYsEJASa/3HnX7VT59htM7iSHbH59NSZbtc+22Ap0Txnlx91sfeB+/A7wNZg7UxtZdhAW4y+/jimrdg==
 
 html-parse-stringify2@^2.0.1:
   version "2.0.1"
@@ -4462,9 +4741,9 @@ husky@^8.0.3:
   integrity sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==
 
 i18next@^22.4.15:
-  version "22.4.15"
-  resolved "https://registry.yarnpkg.com/i18next/-/i18next-22.4.15.tgz#951882b751872994f8502b5a6ef6f796e6a7d7f8"
-  integrity sha512-yYudtbFrrmWKLEhl6jvKUYyYunj4bTBCe2qIUYAxbXoPusY7YmdwPvOE6fx6UIfWvmlbCWDItr7wIs8KEBZ5Zg==
+  version "22.5.1"
+  resolved "https://registry.yarnpkg.com/i18next/-/i18next-22.5.1.tgz#99df0b318741a506000c243429a7352e5f44d424"
+  integrity sha512-8TGPgM3pAD+VRsMtUMNknRz3kzqwp/gPALrWMsDnmC1mKqJwpWyooQRLMcbTwq8z8YwSmuj+ZYvc+xCuEpkssA==
   dependencies:
     "@babel/runtime" "^7.20.6"
 
@@ -4618,18 +4897,18 @@ infer-owner@^1.0.4:
   integrity sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==
 
 inferno-clone-vnode@^8.0.3:
-  version "8.1.1"
-  resolved "https://registry.yarnpkg.com/inferno-clone-vnode/-/inferno-clone-vnode-8.1.1.tgz#433dea414f94e99f823cdba1adec4904bc9e880f"
-  integrity sha512-AssRHxyrNaw17lUzVqqp7ATySMAaCRAGZAwJNmmUdmfqnpEvcjeKGmagbjiWAhkCGRiWOTr1wAbQuyInxr3jNg==
+  version "8.2.1"
+  resolved "https://registry.yarnpkg.com/inferno-clone-vnode/-/inferno-clone-vnode-8.2.1.tgz#6206083abb16647f177cb36db219593da43d5955"
+  integrity sha512-TEkNH+1vzMz11ohw/Tjn+ULdlxSMxUOC/9XF7BWl2T+p0BXwXf8oO0WALrwIUMmZLROoBKnEnyiDVg5U0H/LqA==
   dependencies:
-    inferno "8.1.1"
+    inferno "8.2.1"
 
 inferno-create-element@^8.0.3, inferno-create-element@^8.1.1:
-  version "8.1.1"
-  resolved "https://registry.yarnpkg.com/inferno-create-element/-/inferno-create-element-8.1.1.tgz#f135ea6fb784d2024148845eeb4f752da6252f0d"
-  integrity sha512-RzoRK/gl6kADO9hzviChjCLvD5whsUJE5k71ordYuq/6aixdLaiizebthNkE+18ZKoN6VUXH1PlczaMPu/YIpQ==
+  version "8.2.1"
+  resolved "https://registry.yarnpkg.com/inferno-create-element/-/inferno-create-element-8.2.1.tgz#b1883363d5603226e0209fb17a06be4610669eea"
+  integrity sha512-UhRjt0r6PA4e3OaloMIWoR1xmC6FZA9yWN7ZCI2v3xcchukHfmLOHnZjEhr0MnYDvXuH7yR8ut1evsHa1UFm4w==
   dependencies:
-    inferno "8.1.1"
+    inferno "8.2.1"
 
 inferno-helmet@^5.2.1:
   version "5.2.1"
@@ -4641,11 +4920,11 @@ inferno-helmet@^5.2.1:
     object-assign "^4.1.1"
 
 inferno-hydrate@^8.1.1:
-  version "8.1.1"
-  resolved "https://registry.yarnpkg.com/inferno-hydrate/-/inferno-hydrate-8.1.1.tgz#b3e20b123df31c0787f2d16fe915f30045c984a4"
-  integrity sha512-u4hXW+mgdmVV3EkEU+0DzrtEBkCtpCE5w8N7mEkxnH5o4A1GfAaiWN1iJKETcS5yEJEg1j1RipLfSteSbKo7jA==
+  version "8.2.1"
+  resolved "https://registry.yarnpkg.com/inferno-hydrate/-/inferno-hydrate-8.2.1.tgz#15c26e4b046220f40b04f8e27b539654c73b030d"
+  integrity sha512-gQ4q/qklV69tiaCLLH1IjVYJnvvUuSdh2RNapqynPu7HFcV6QzIRRS97xjX967Fzw86wOnU+/IVyQFmOofiXXg==
   dependencies:
-    inferno "8.1.1"
+    inferno "8.2.1"
 
 inferno-i18next-dess@0.0.2:
   version "0.0.2"
@@ -4660,26 +4939,26 @@ inferno-i18next-dess@0.0.2:
     inferno-vnode-flags "^8.0.3"
 
 inferno-router@^8.1.1:
-  version "8.1.1"
-  resolved "https://registry.yarnpkg.com/inferno-router/-/inferno-router-8.1.1.tgz#254b5a123a7c91965cf2c55ef801605c2dea5f7a"
-  integrity sha512-ntjwvVWbRFIpD8NqU+9KXuL4rc4BuhlXZbzigP4o4KgQlF6D78eTYznWyztqYA39Qz2Sg0gAKf7GJoDJUc2+ig==
+  version "8.2.1"
+  resolved "https://registry.yarnpkg.com/inferno-router/-/inferno-router-8.2.1.tgz#4bcecc7f7f665c54b152cb94ffc4491e1d7aef1f"
+  integrity sha512-vFm04mEXoOVcvHOHP5PWfydDM4kzlkettwFK5/apY0g4XXPjIvleHclrAaEzOjG7KIw05zTbt/Do8tacxZ3ELA==
   dependencies:
     history "^5.3.0"
     hoist-non-inferno-statics "^1.1.3"
-    inferno "8.1.1"
+    inferno "8.2.1"
     path-to-regexp-es6 "1.7.0"
 
 inferno-server@^8.1.1:
-  version "8.1.1"
-  resolved "https://registry.yarnpkg.com/inferno-server/-/inferno-server-8.1.1.tgz#e5e547761cfa85606e74f3bb4253ee5d09c6443e"
-  integrity sha512-qgQc23csgHllF46i0hEJUq1mO+ObG5vis8B7vR732gjcm+K1p9jQ17dtbuLiLlQHciMItVdqnG3zLdoNCWtc2Q==
+  version "8.2.1"
+  resolved "https://registry.yarnpkg.com/inferno-server/-/inferno-server-8.2.1.tgz#ea4b04b3afcd2c18810cf521b1a0fb7291c82eac"
+  integrity sha512-M+c7FH3pv8/VIh8lEqs/1Tt89GHIUNlu9gkAbPXXO7YpJgrFY6BXhShZBP8cshJamee756XnTYY0v5hpo/jDMQ==
   dependencies:
-    inferno "8.1.1"
+    inferno "8.2.1"
 
 inferno-shared@^8.0.3:
-  version "8.1.1"
-  resolved "https://registry.yarnpkg.com/inferno-shared/-/inferno-shared-8.1.1.tgz#1c6d60fd503eb403efc080762b9ec874dfeedcc6"
-  integrity sha512-5qcPBnNdIi/4/ICGOWG7rrV6E/r0RgJfgQckDaNbj8gBGdlzlhs/tR+c6VrwBWSb/IR0Pho2m2Mf2eUPiVAUmQ==
+  version "8.2.1"
+  resolved "https://registry.yarnpkg.com/inferno-shared/-/inferno-shared-8.2.1.tgz#48b64a7fd8f7ca0e8358fc5c35ebc05402cc3d46"
+  integrity sha512-94+DR84MFfdSepwCYfxRTpqWa6xkoiOZxy8kBowkyDlZCnAt9VofjVMknwebgVP4XXxk3D5nlrV1quwkrSQNDw==
 
 inferno-side-effect@^1.1.5:
   version "1.1.5"
@@ -4690,18 +4969,18 @@ inferno-side-effect@^1.1.5:
     npm "^5.8.0"
     shallowequal "^1.0.1"
 
-inferno-vnode-flags@8.1.1, inferno-vnode-flags@^8.0.3:
-  version "8.1.1"
-  resolved "https://registry.yarnpkg.com/inferno-vnode-flags/-/inferno-vnode-flags-8.1.1.tgz#b405edf33bf12c850b253b6c26700b8368f338d7"
-  integrity sha512-Az2vuT5FRF6zGW8e13ye5Mg0YoKvk/XL2LVJr4i+LrZWn9fb9AqyCDuCSvMe9ZBWKw2pnsbsUaDlQYhNh0tjkA==
+inferno-vnode-flags@8.2.1, inferno-vnode-flags@^8.0.3:
+  version "8.2.1"
+  resolved "https://registry.yarnpkg.com/inferno-vnode-flags/-/inferno-vnode-flags-8.2.1.tgz#cd8dc52096f6c0c7ca30f65fb30c620c4bfe1030"
+  integrity sha512-gdxF0zya4kYAoY5BUQ9LPWfEZ2nlLsaREyQn0ohzPihnsw91L7CW2ShLtBlgvGtAkt3khVczG9OAGPZTeQEDdg==
 
-inferno@8.1.1, inferno@^8.0.3, inferno@^8.1.1:
-  version "8.1.1"
-  resolved "https://registry.yarnpkg.com/inferno/-/inferno-8.1.1.tgz#f2e7b02eb32e7fb3da0f8b4a5a38640979546c0b"
-  integrity sha512-PjpQkS1uYLeK8FHpMBgJi0qE5traFZs/jpgk+Ddx1Y7HGp5kneyus02eajgWzPM4imGYf6S3h+qbjwSXGutveA==
+inferno@8.2.1, inferno@^8.0.3, inferno@^8.1.1:
+  version "8.2.1"
+  resolved "https://registry.yarnpkg.com/inferno/-/inferno-8.2.1.tgz#b374b7578166f45239edc72d3e2b76761325f9df"
+  integrity sha512-cg4CYcIhBQoMOWpazLBGvJV3J7TCHCG2RTfQDVDFVtn6BIwDFp7xqpI0XSCVuo4GAUfMG9G8/cA78pBoe4PuKQ==
   dependencies:
     csstype "^3.1.1"
-    inferno-vnode-flags "8.1.1"
+    inferno-vnode-flags "8.2.1"
     opencollective-postinstall "^2.0.3"
 
 inflight@^1.0.4, inflight@~1.0.6:
@@ -4771,9 +5050,9 @@ ipaddr.js@1.9.1:
   integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
 
 ipaddr.js@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0"
-  integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.1.0.tgz#2119bc447ff8c257753b196fc5f1ce08a4cdf39f"
+  integrity sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==
 
 is-arguments@^1.0.4:
   version "1.1.1"
@@ -4858,9 +5137,9 @@ is-cidr@~1.0.0:
     cidr-regex "1.0.6"
 
 is-core-module@^2.11.0, is-core-module@^2.9.0:
-  version "2.12.0"
-  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.12.0.tgz#36ad62f6f73c8253fd6472517a12483cf03e7ec4"
-  integrity sha512-RECHCBCd/viahWmwj6enj19sKbHfJrddi/6cBDsNTKbNq0f7VeaUkBo60BqzvPqo/W54ChS62Z5qyun7cfOMqQ==
+  version "2.12.1"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.12.1.tgz#0c0b6885b6f80011c71541ce15c8d66cf5a4f9fd"
+  integrity sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==
   dependencies:
     has "^1.0.3"
 
@@ -5148,23 +5427,23 @@ isstream@~0.1.2:
   integrity sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==
 
 jackspeak@^2.0.3:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.2.0.tgz#497cbaedc902ec3f31d5d61be804d2364ff9ddad"
-  integrity sha512-r5XBrqIJfwRIjRt/Xr5fv9Wh09qyhHfKnYddDlpM+ibRR20qrYActpCAgU6U+d53EOEjzkvxPMVHSlgR7leXrQ==
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.2.1.tgz#655e8cf025d872c9c03d3eb63e8f0c024fef16a6"
+  integrity sha512-MXbxovZ/Pm42f6cDIDkl3xpwv1AGwObKwfmjs2nQePiy85tP3fatofl3FC1aBsOtP/6fq5SbtgHwWcMsLP+bDw==
   dependencies:
     "@isaacs/cliui" "^8.0.2"
   optionalDependencies:
     "@pkgjs/parseargs" "^0.11.0"
 
 jake@^10.8.5:
-  version "10.8.5"
-  resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46"
-  integrity sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==
+  version "10.8.7"
+  resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.7.tgz#63a32821177940c33f356e0ba44ff9d34e1c7d8f"
+  integrity sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==
   dependencies:
     async "^3.2.3"
     chalk "^4.0.2"
-    filelist "^1.0.1"
-    minimatch "^3.0.4"
+    filelist "^1.0.4"
+    minimatch "^3.1.2"
 
 jest-worker@^26.2.1:
   version "26.6.2"
@@ -5184,11 +5463,6 @@ jest-worker@^27.4.5:
     merge-stream "^2.0.0"
     supports-color "^8.0.0"
 
-js-sdsl@^4.1.4:
-  version "4.4.0"
-  resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.4.0.tgz#8b437dbe642daa95760400b602378ed8ffea8430"
-  integrity sha512-FfVSdx6pJ41Oa+CF7RDaFmTnCaFhua+SNYQX74riGOpl96x+2jQCqEfQ2bnXu/5DPCqlRuiqyvTJM0Qjz26IVg==
-
 "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@@ -5341,10 +5615,10 @@ leac@^0.6.0:
   resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912"
   integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==
 
-lemmy-js-client@0.17.2-rc.17:
-  version "0.17.2-rc.17"
-  resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.17.2-rc.17.tgz#91a167c3b61db39fab2e977685a42a77aeae519a"
-  integrity sha512-DBzQjVRo89co7Wppl72/xlNdJfAnXrUE0UgWZxO3v2I8axK9JUD4XmodpRe33thpfPmsURQ1W7dOUX60rcQPQg==
+lemmy-js-client@0.17.2-rc.24:
+  version "0.17.2-rc.24"
+  resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.17.2-rc.24.tgz#3b09233a6d89286e559be2e840d81c0c549562ad"
+  integrity sha512-aSHz7UTcwnwnNd9poY8tEXP7RA9ieZm9MAfSljcbCNU5ds9CASXYNodmraUVJiqCmT4HWnj7IeVmBC9r7nTHnw==
   dependencies:
     cross-fetch "^3.1.5"
     form-data "^4.0.0"
@@ -5603,9 +5877,9 @@ lru-cache@^6.0.0:
     yallist "^4.0.0"
 
 lru-cache@^9.1.1:
-  version "9.1.1"
-  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-9.1.1.tgz#c58a93de58630b688de39ad04ef02ef26f1902f1"
-  integrity sha512-65/Jky17UwSb0BuB9V+MyDpsOtXKmYwzhyl+cOa9XUiI4uV2Ouy/2voFP3+al0BjZbJgMBD8FojMpAf+Z+qn4A==
+  version "9.1.2"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-9.1.2.tgz#255fdbc14b75589d6d0e73644ca167a8db506835"
+  integrity sha512-ERJq3FOzJTxBbFjZ7iDs+NiK4VI9Wz+RdrrAB8dio1oV+YvdPzUEE4QNiT2VD51DkIbCYRUUzCRkssXCHqSnKQ==
 
 magic-string@^0.25.0, magic-string@^0.25.7:
   version "0.25.9"
@@ -5750,9 +6024,9 @@ media-typer@0.3.0:
   integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
 
 memfs@^3.4.3:
-  version "3.5.1"
-  resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.5.1.tgz#f0cd1e2bfaef58f6fe09bfb9c2288f07fea099ec"
-  integrity sha512-UWbFJKvj5k+nETdteFndTpYxdeTMox/ULeqX5k/dpaQJCCFmj5EeKv3dBcyO2xmkRAx2vppRu5dVG7SOtsGOzA==
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.5.2.tgz#3367cb58940e45224a7e377015b37f55a831b3ac"
+  integrity sha512-4kbWXbVZ+LU4XFDS2CuA7frnwz2HxCMB/0yOXc86q7aCQrfWKkL11t6al1e2CsVC7uhnBNTQ1TfUsAxVauO9IQ==
   dependencies:
     fs-monkey "^1.0.3"
 
@@ -5824,9 +6098,9 @@ mimoza@~1.0.0:
     mime-db "^1.6.0"
 
 mini-css-extract-plugin@^2.7.5:
-  version "2.7.5"
-  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.5.tgz#afbb344977659ec0f1f6e050c7aea456b121cfc5"
-  integrity sha512-9HaR++0mlgom81s95vvNjxkg52n2b5s//3ZTI1EtzFb98awsLSivs2LMsVqnQ3ay0PVhqWcGNyDaTE961FOcjQ==
+  version "2.7.6"
+  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.6.tgz#282a3d38863fddcd2e0c220aaed5b90bc156564d"
+  integrity sha512-Qk7HcgaPkGG6eD77mLvZS1nmxlao3j+9PkrT9Uc7HAE1id3F41+DdBRYRYkbyfNRGzm8/YWtzhw7nVPmwhqTQw==
   dependencies:
     schema-utils "^4.0.0"
 
@@ -5856,10 +6130,10 @@ minimatch@^8.0.3:
   dependencies:
     brace-expansion "^2.0.1"
 
-minimatch@^9.0.0:
-  version "9.0.0"
-  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.0.tgz#bfc8e88a1c40ffd40c172ddac3decb8451503b56"
-  integrity sha512-0jJj8AvgKqWN05mrwuqi8QYKx1WmYSUoKSxu5Qhs9prezTz10sxAHGNZe9J9cqIJzta8DWsleh2KaVaLl6Ru2w==
+minimatch@^9.0.1:
+  version "9.0.1"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253"
+  integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==
   dependencies:
     brace-expansion "^2.0.1"
 
@@ -5876,10 +6150,10 @@ minipass@^2.3.3, minipass@^2.6.0, minipass@^2.9.0:
     safe-buffer "^5.1.2"
     yallist "^3.0.0"
 
-minipass@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d"
-  integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==
+"minipass@^5.0.0 || ^6.0.2":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-6.0.2.tgz#542844b6c4ce95b202c0995b0a471f1229de4c81"
+  integrity sha512-MzWSV5nYVT7mVyWCwn2o7JH13w2TBRmmSqSRCKzTw+lmft9X4z+3wjvs06Tzijo5z4W/kahUCDpRXTF+ZrmF/w==
 
 minizlib@^1.3.3:
   version "1.3.3"
@@ -6024,9 +6298,9 @@ neo-async@^2.6.2:
   integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
 
 node-abi@^3.3.0:
-  version "3.40.0"
-  resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.40.0.tgz#51d8ed44534f70ff1357dfbc3a89717b1ceac1b4"
-  integrity sha512-zNy02qivjjRosswoYmPi8hIKJRr8MpQyeKT6qlcq/OnOgA3Rhoae+IYOqsM9V5+JnHWmxKnWOT2GxvtqdtOCXA==
+  version "3.43.0"
+  resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.43.0.tgz#468dc09af3c262ef2fb3a0d2ff34cf8fba61952a"
+  integrity sha512-QB0MMv+tn9Ur2DtJrc8y09n0n6sw88CyDniWSX2cHW10goQXYPK9ZpFJOktDS4ron501edPX6h9i7Pg+RnH5nQ==
   dependencies:
     semver "^7.3.5"
 
@@ -6044,10 +6318,10 @@ node-fetch-npm@^2.0.2:
     json-parse-better-errors "^1.0.0"
     safe-buffer "^5.1.1"
 
-node-fetch@2.6.7:
-  version "2.6.7"
-  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
-  integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
+node-fetch@^2.6.11:
+  version "2.6.11"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.11.tgz#cde7fc71deef3131ef80a738919f999e6edfff25"
+  integrity sha512-4I6pdBY1EthSqDmJkiNk3JIT8cswwR9nfeW/cPdUagJYEQG7R95WRH74wpz7ma8Gh/9dI9FP+OU+0E4FvtA55w==
   dependencies:
     whatwg-url "^5.0.0"
 
@@ -6091,10 +6365,10 @@ node-gyp@^4.0.0:
     tar "^4.4.8"
     which "1"
 
-node-releases@^2.0.8:
-  version "2.0.10"
-  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.10.tgz#c311ebae3b6a148c89b1813fd7c4d3c024ef537f"
-  integrity sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==
+node-releases@^2.0.12:
+  version "2.0.12"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.12.tgz#35627cc224a23bfb06fb3380f2b3afaaa7eb1039"
+  integrity sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==
 
 "nopt@2 || 3":
   version "3.0.6"
@@ -6816,12 +7090,12 @@ path-parse@^1.0.7:
   integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
 
 path-scurry@^1.7.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.8.0.tgz#809e09690c63817c76d0183f19a5b21b530ff7d2"
-  integrity sha512-IjTrKseM404/UAWA8bBbL3Qp6O2wXkanuIE3seCxBH7ctRuvH1QRawy1N3nVDHGkdeZsjOsSe/8AQBL/VQCy2g==
+  version "1.9.2"
+  resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.9.2.tgz#90f9d296ac5e37e608028e28a447b11d385b3f63"
+  integrity sha512-qSDLy2aGFPm8i4rsbHd4MNyTcrzHFsLQykrtbuGRknZZCBBVXSv2tSCDN2Cg6Rt/GFRw8GoW9y9Ecw5rIPG1sg==
   dependencies:
     lru-cache "^9.1.1"
-    minipass "^5.0.0"
+    minipass "^5.0.0 || ^6.0.2"
 
 path-to-regexp-es6@1.7.0:
   version "1.7.0"
@@ -6911,10 +7185,10 @@ postcss-modules-extract-imports@^3.0.0:
   resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d"
   integrity sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==
 
-postcss-modules-local-by-default@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz#ebbb54fae1598eecfdf691a02b3ff3b390a5a51c"
-  integrity sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==
+postcss-modules-local-by-default@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.3.tgz#b08eb4f083050708998ba2c6061b50c2870ca524"
+  integrity sha512-2/u2zraspoACtrbFRnTijMiQtb4GW4BvatjaG/bCjYQo8kLTdevCUlwuBHx2sCnSyrI3x3qj4ZK1j5LQBgzmwA==
   dependencies:
     icss-utils "^5.0.0"
     postcss-selector-parser "^6.0.2"
@@ -6935,9 +7209,9 @@ postcss-modules-values@^4.0.0:
     icss-utils "^5.0.0"
 
 postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4:
-  version "6.0.12"
-  resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.12.tgz#2efae5ffab3c8bfb2b7fbf0c426e3bca616c4abb"
-  integrity sha512-NdxGCAZdRrwVI1sy59+Wzrh+pMMHxapGnpfenDVlMEXoOcvt4pGE0JLK9YY2F5dLxcFYA/YbVQKhcGU+FtSYQg==
+  version "6.0.13"
+  resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz#d05d8d76b1e8e173257ef9d60b706a8e5e99bf1b"
+  integrity sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==
   dependencies:
     cssesc "^3.0.0"
     util-deprecate "^1.0.2"
@@ -6947,10 +7221,10 @@ postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0:
   resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
   integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
 
-postcss@^8.3.11, postcss@^8.4.19:
-  version "8.4.23"
-  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.23.tgz#df0aee9ac7c5e53e1075c24a3613496f9e6552ab"
-  integrity sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==
+postcss@^8.3.11, postcss@^8.4.21:
+  version "8.4.24"
+  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.24.tgz#f714dba9b2284be3cc07dbd2fc57ee4dc972d2df"
+  integrity sha512-M0RzbcI0sO/XJNucsGjvWU9ERWxb/ytp1w6dKtxTKgixdtQDq4rmx/g8W1hnaheq9jgwL/oyEdH5Bc4WwJKMqg==
   dependencies:
     nanoid "^3.3.6"
     picocolors "^1.0.0"
@@ -7514,11 +7788,11 @@ rimraf@^3.0.2:
     glob "^7.1.3"
 
 rimraf@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.0.tgz#5bda14e410d7e4dd522154891395802ce032c2cb"
-  integrity sha512-Jf9llaP+RvaEVS5nPShYFhtXIrb3LRKP281ib3So0KkeZKo2wIKyq0Re7TOSwanasA423PSr6CCIL4bP6T040g==
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.1.tgz#0881323ab94ad45fec7c0221f27ea1a142f3f0d0"
+  integrity sha512-OfFZdwtd3lZ+XZzYP/6gTACubwFcHdLRqS9UX3UwpU2dnGQYkPFISRwvM3w9IiB2w7bW5qGo/uAwE4SmXXSKvg==
   dependencies:
-    glob "^10.0.0"
+    glob "^10.2.5"
 
 rimraf@~2.6.2:
   version "2.6.3"
@@ -7570,7 +7844,7 @@ run-queue@^1.0.0, run-queue@^1.0.3:
   dependencies:
     aproba "^1.1.1"
 
-rxjs@^7.8.0, rxjs@^7.8.1:
+rxjs@^7.8.0:
   version "7.8.1"
   resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543"
   integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==
@@ -7624,9 +7898,9 @@ sanitize-html@^2.10.0:
     postcss "^8.3.11"
 
 sass-loader@^13.2.2:
-  version "13.2.2"
-  resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-13.2.2.tgz#f97e803993b24012c10d7ba9676548bf7a6b18b9"
-  integrity sha512-nrIdVAAte3B9icfBiGWvmMhT/D+eCDwnk+yA7VE/76dp/WkHX+i44Q/pfo71NYbwj0Ap+PGsn0ekOuU1WFJ2AA==
+  version "13.3.1"
+  resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-13.3.1.tgz#32ee5791434b9b4dbd1adcce76fcb4cea49cc12c"
+  integrity sha512-cBTxmgyVA1nXPvIK4brjJMXOMJ2v2YrQEuHqLw3LylGb3gsR6jAvdjHMcy/+JGTmmIF9SauTrLLR7bsWDMWqgg==
   dependencies:
     klona "^2.0.6"
     neo-async "^2.6.2"
@@ -8326,9 +8600,9 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
     ansi-regex "^4.1.0"
 
 strip-ansi@^7.0.1:
-  version "7.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2"
-  integrity sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
+  integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==
   dependencies:
     ansi-regex "^6.0.1"
 
@@ -8363,9 +8637,9 @@ strip-json-comments@~2.0.1:
   integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==
 
 style-loader@^3.3.2:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.2.tgz#eaebca714d9e462c19aa1e3599057bc363924899"
-  integrity sha512-RHs/vcrKdQK8wZliteNK4NKzxvLBzpuHMqYmUVWeKa6MkaIQ97ZTOS0b+zapZhy6GcrgWnvWYCMHRirC3FsUmw==
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/style-loader/-/style-loader-3.3.3.tgz#bba8daac19930169c0c9c96706749a597ae3acff"
+  integrity sha512-53BiGLXAcll9maCYtZi2RCQZKa8NQQai5C4horqKyRmHj9H7QmcUyucrH+4KW/gBQbXM2AsB0axoEcFZPlfPcw==
 
 supports-color@^5.3.0:
   version "5.5.0"
@@ -8472,9 +8746,9 @@ term-size@^1.2.0:
     execa "^0.7.0"
 
 terser-webpack-plugin@^5.3.7:
-  version "5.3.8"
-  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.8.tgz#415e03d2508f7de63d59eca85c5d102838f06610"
-  integrity sha512-WiHL3ElchZMsK27P8uIUh4604IgJyAW47LVXGbEoB21DbQcZ+OuMpGjVYnEUaqcWM6dO8uS2qUbA7LSCWqvsbg==
+  version "5.3.9"
+  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.9.tgz#832536999c51b46d468067f9e37662a3b96adfe1"
+  integrity sha512-ZuXsqE07EcggTWQjXUj+Aot/OMcD0bMKGgF63f7UxYcu5/AJF53aIpK1YoP5xR9l6s/Hy2b+t1AM0bLNPRuhwA==
   dependencies:
     "@jridgewell/trace-mapping" "^0.3.17"
     jest-worker "^27.4.5"
@@ -8483,12 +8757,12 @@ terser-webpack-plugin@^5.3.7:
     terser "^5.16.8"
 
 terser@^5.0.0, terser@^5.16.8, terser@^5.17.3:
-  version "5.17.3"
-  resolved "https://registry.yarnpkg.com/terser/-/terser-5.17.3.tgz#7f908f16b3cdf3f6c0f8338e6c1c674837f90d25"
-  integrity sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==
+  version "5.17.7"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-5.17.7.tgz#2a8b134826fe179b711969fd9d9a0c2479b2a8c3"
+  integrity sha512-/bi0Zm2C6VAexlGgLlVxA0P2lru/sdLyfCVaRMfKVo9nWxbmz7f/sD8VPybPeSUJaJcwmCJis9pBIhcVcG1QcQ==
   dependencies:
-    "@jridgewell/source-map" "^0.3.2"
-    acorn "^8.5.0"
+    "@jridgewell/source-map" "^0.3.3"
+    acorn "^8.8.2"
     commander "^2.20.0"
     source-map-support "~0.5.20"
 
@@ -8590,9 +8864,9 @@ tslib@^1.8.1:
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
 tslib@^2.1.0, tslib@^2.5.0:
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.0.tgz#42bfed86f5787aeb41d031866c8f402429e0fddf"
-  integrity sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==
+  version "2.5.3"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.3.tgz#24944ba2d990940e6e982c4bea147aba80209913"
+  integrity sha512-mSxlJJwl3BMEQCUNnxXBU9jP4JBktcEGhURcPR6VQVlnP0FdDEsIaz0C35dXNGLyRfrATNofF0F5p2KPxQgB+w==
 
 tsutils@^3.21.0:
   version "3.21.0"
@@ -8663,9 +8937,9 @@ typescript@^3.2.4:
   integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==
 
 typescript@^5.0.4:
-  version "5.0.4"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b"
-  integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==
+  version "5.1.3"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.1.3.tgz#8d84219244a6b40b6fb2b33cc1c062f715b9e826"
+  integrity sha512-XH627E9vkeqhlZFQuL+UsyAXEnibT0kWR2FWONlr4sTjvxyJYnyefgrkyECLzM5NenmKzRAy2rR/OlYLA1HkZw==
 
 uc.micro@^1.0.1, uc.micro@^1.0.5:
   version "1.0.6"
@@ -8768,7 +9042,7 @@ upath@^1.2.0:
   resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894"
   integrity sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==
 
-update-browserslist-db@^1.0.10:
+update-browserslist-db@^1.0.11:
   version "1.0.11"
   resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz#9a2a641ad2907ae7b3616506f4b977851db5b940"
   integrity sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==
@@ -8905,14 +9179,14 @@ webidl-conversions@^4.0.2:
   integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
 
 webpack-cli@^5.1.1:
-  version "5.1.1"
-  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-5.1.1.tgz#c211ac6d911e77c512978f7132f0d735d4a97ace"
-  integrity sha512-OLJwVMoXnXYH2ncNGU8gxVpUtm3ybvdioiTvHgUyBuyMLKiVvWy+QObzBsMtp5pH7qQoEuWgeEUQ/sU3ZJFzAw==
+  version "5.1.3"
+  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-5.1.3.tgz#6b6186270efec62394f6fefeebed0872a779f345"
+  integrity sha512-MTuk7NUMvEHQUSXCpvUrF1q2p0FJS40dPFfqQvG3jTWcgv/8plBNz2Kv2HXZiLGPnfmSAA5uCtCILO1JBmmkfw==
   dependencies:
     "@discoveryjs/json-ext" "^0.5.0"
-    "@webpack-cli/configtest" "^2.1.0"
-    "@webpack-cli/info" "^2.0.1"
-    "@webpack-cli/serve" "^2.0.4"
+    "@webpack-cli/configtest" "^2.1.1"
+    "@webpack-cli/info" "^2.0.2"
+    "@webpack-cli/serve" "^2.0.5"
     colorette "^2.0.14"
     commander "^10.0.1"
     cross-spawn "^7.0.3"
@@ -8978,9 +9252,9 @@ webpack-inject-entry-plugin@^0.0.4:
     schema-utils "^4.0.0"
 
 webpack-merge@^5.7.3:
-  version "5.8.0"
-  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.8.0.tgz#2b39dbf22af87776ad744c390223731d30a68f61"
-  integrity sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q==
+  version "5.9.0"
+  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.9.0.tgz#dc160a1c4cf512ceca515cc231669e9ddb133826"
+  integrity sha512-6NbRQw4+Sy50vYNTw7EyOn41OZItPiXB8GNv3INSoe3PSFaHJEz3SHTrYVaRm2LilNGnFUzh0FAwqPEmU/CwDg==
   dependencies:
     clone-deep "^4.0.1"
     wildcard "^2.0.0"
@@ -9047,11 +9321,6 @@ websocket-extensions@>=0.1.1:
   resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42"
   integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==
 
-websocket-ts@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/websocket-ts/-/websocket-ts-1.1.1.tgz#de482da5e0c714ebc58a43fe94157e5a855f2828"
-  integrity sha512-rm+S60J74Ckw5iizzgID12ju+OfaHAa6dhXhULIOrXkl0e05RzxfY42/vMStpz5jWL3iz9mkyjPcFUY1IgI0fw==
-
 whatwg-url@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
@@ -9135,25 +9404,25 @@ word-wrap@^1.2.3:
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
-workbox-background-sync@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-6.5.4.tgz#3141afba3cc8aa2ae14c24d0f6811374ba8ff6a9"
-  integrity sha512-0r4INQZMyPky/lj4Ou98qxcThrETucOde+7mRGJl13MPJugQNKeZQOdIJe/1AchOP23cTqHcN/YVpD6r8E6I8g==
+workbox-background-sync@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-background-sync/-/workbox-background-sync-6.6.1.tgz#08d603a33717ce663e718c30cc336f74909aff2f"
+  integrity sha512-trJd3ovpWCvzu4sW0E8rV3FUyIcC0W8G+AZ+VcqzzA890AsWZlUGOTSxIMmIHVusUw/FDq1HFWfy/kC/WTRqSg==
   dependencies:
     idb "^7.0.1"
-    workbox-core "6.5.4"
+    workbox-core "6.6.1"
 
-workbox-broadcast-update@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-broadcast-update/-/workbox-broadcast-update-6.5.4.tgz#8441cff5417cd41f384ba7633ca960a7ffe40f66"
-  integrity sha512-I/lBERoH1u3zyBosnpPEtcAVe5lwykx9Yg1k6f8/BGEPGaMMgZrwVrqL1uA9QZ1NGGFoyE6t9i7lBjOlDhFEEw==
+workbox-broadcast-update@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-broadcast-update/-/workbox-broadcast-update-6.6.1.tgz#0fad9454cf8e4ace0c293e5617c64c75d8a8c61e"
+  integrity sha512-fBhffRdaANdeQ1V8s692R9l/gzvjjRtydBOvR6WCSB0BNE2BacA29Z4r9/RHd9KaXCPl6JTdI9q0bR25YKP8TQ==
   dependencies:
-    workbox-core "6.5.4"
+    workbox-core "6.6.1"
 
-workbox-build@6.5.4, workbox-build@^6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-build/-/workbox-build-6.5.4.tgz#7d06d31eb28a878817e1c991c05c5b93409f0389"
-  integrity sha512-kgRevLXEYvUW9WS4XoziYqZ8Q9j/2ziJYEtTrjdz5/L/cTUa2XfyMP2i7c3p34lgqJ03+mTiz13SdFef2POwbA==
+workbox-build@6.6.1, workbox-build@^6.5.4:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-build/-/workbox-build-6.6.1.tgz#6010e9ce550910156761448f2dbea8cfcf759cb0"
+  integrity sha512-INPgDx6aRycAugUixbKgiEQBWD0MPZqU5r0jyr24CehvNuLPSXp/wGOpdRJmts656lNiXwqV7dC2nzyrzWEDnw==
   dependencies:
     "@apideck/better-ajv-errors" "^0.3.1"
     "@babel/core" "^7.11.1"
@@ -9177,132 +9446,132 @@ workbox-build@6.5.4, workbox-build@^6.5.4:
     strip-comments "^2.0.1"
     tempy "^0.6.0"
     upath "^1.2.0"
-    workbox-background-sync "6.5.4"
-    workbox-broadcast-update "6.5.4"
-    workbox-cacheable-response "6.5.4"
-    workbox-core "6.5.4"
-    workbox-expiration "6.5.4"
-    workbox-google-analytics "6.5.4"
-    workbox-navigation-preload "6.5.4"
-    workbox-precaching "6.5.4"
-    workbox-range-requests "6.5.4"
-    workbox-recipes "6.5.4"
-    workbox-routing "6.5.4"
-    workbox-strategies "6.5.4"
-    workbox-streams "6.5.4"
-    workbox-sw "6.5.4"
-    workbox-window "6.5.4"
-
-workbox-cacheable-response@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-cacheable-response/-/workbox-cacheable-response-6.5.4.tgz#a5c6ec0c6e2b6f037379198d4ef07d098f7cf137"
-  integrity sha512-DCR9uD0Fqj8oB2TSWQEm1hbFs/85hXXoayVwFKLVuIuxwJaihBsLsp4y7J9bvZbqtPJ1KlCkmYVGQKrBU4KAug==
-  dependencies:
-    workbox-core "6.5.4"
-
-workbox-core@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-6.5.4.tgz#df48bf44cd58bb1d1726c49b883fb1dffa24c9ba"
-  integrity sha512-OXYb+m9wZm8GrORlV2vBbE5EC1FKu71GGp0H4rjmxmF4/HLbMCoTFws87M3dFwgpmg0v00K++PImpNQ6J5NQ6Q==
-
-workbox-expiration@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-6.5.4.tgz#501056f81e87e1d296c76570bb483ce5e29b4539"
-  integrity sha512-jUP5qPOpH1nXtjGGh1fRBa1wJL2QlIb5mGpct3NzepjGG2uFFBn4iiEBiI9GUmfAFR2ApuRhDydjcRmYXddiEQ==
+    workbox-background-sync "6.6.1"
+    workbox-broadcast-update "6.6.1"
+    workbox-cacheable-response "6.6.1"
+    workbox-core "6.6.1"
+    workbox-expiration "6.6.1"
+    workbox-google-analytics "6.6.1"
+    workbox-navigation-preload "6.6.1"
+    workbox-precaching "6.6.1"
+    workbox-range-requests "6.6.1"
+    workbox-recipes "6.6.1"
+    workbox-routing "6.6.1"
+    workbox-strategies "6.6.1"
+    workbox-streams "6.6.1"
+    workbox-sw "6.6.1"
+    workbox-window "6.6.1"
+
+workbox-cacheable-response@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-cacheable-response/-/workbox-cacheable-response-6.6.1.tgz#284c2b86be3f4fd191970ace8c8e99797bcf58e9"
+  integrity sha512-85LY4veT2CnTCDxaVG7ft3NKaFbH6i4urZXgLiU4AiwvKqS2ChL6/eILiGRYXfZ6gAwDnh5RkuDbr/GMS4KSag==
+  dependencies:
+    workbox-core "6.6.1"
+
+workbox-core@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-core/-/workbox-core-6.6.1.tgz#7184776d4134c5ed2f086878c882728fc9084265"
+  integrity sha512-ZrGBXjjaJLqzVothoE12qTbVnOAjFrHDXpZe7coCb6q65qI/59rDLwuFMO4PcZ7jcbxY+0+NhUVztzR/CbjEFw==
+
+workbox-expiration@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-expiration/-/workbox-expiration-6.6.1.tgz#a841fa36676104426dbfb9da1ef6a630b4f93739"
+  integrity sha512-qFiNeeINndiOxaCrd2DeL1Xh1RFug3JonzjxUHc5WkvkD2u5abY3gZL1xSUNt3vZKsFFGGORItSjVTVnWAZO4A==
   dependencies:
     idb "^7.0.1"
-    workbox-core "6.5.4"
+    workbox-core "6.6.1"
 
-workbox-google-analytics@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-google-analytics/-/workbox-google-analytics-6.5.4.tgz#c74327f80dfa4c1954cbba93cd7ea640fe7ece7d"
-  integrity sha512-8AU1WuaXsD49249Wq0B2zn4a/vvFfHkpcFfqAFHNHwln3jK9QUYmzdkKXGIZl9wyKNP+RRX30vcgcyWMcZ9VAg==
+workbox-google-analytics@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-google-analytics/-/workbox-google-analytics-6.6.1.tgz#a07a6655ab33d89d1b0b0a935ffa5dea88618c5d"
+  integrity sha512-1TjSvbFSLmkpqLcBsF7FuGqqeDsf+uAXO/pjiINQKg3b1GN0nBngnxLcXDYo1n/XxK4N7RaRrpRlkwjY/3ocuA==
   dependencies:
-    workbox-background-sync "6.5.4"
-    workbox-core "6.5.4"
-    workbox-routing "6.5.4"
-    workbox-strategies "6.5.4"
+    workbox-background-sync "6.6.1"
+    workbox-core "6.6.1"
+    workbox-routing "6.6.1"
+    workbox-strategies "6.6.1"
 
-workbox-navigation-preload@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-navigation-preload/-/workbox-navigation-preload-6.5.4.tgz#ede56dd5f6fc9e860a7e45b2c1a8f87c1c793212"
-  integrity sha512-IIwf80eO3cr8h6XSQJF+Hxj26rg2RPFVUmJLUlM0+A2GzB4HFbQyKkrgD5y2d84g2IbJzP4B4j5dPBRzamHrng==
+workbox-navigation-preload@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-navigation-preload/-/workbox-navigation-preload-6.6.1.tgz#61a34fe125558dd88cf09237f11bd966504ea059"
+  integrity sha512-DQCZowCecO+wRoIxJI2V6bXWK6/53ff+hEXLGlQL4Rp9ZaPDLrgV/32nxwWIP7QpWDkVEtllTAK5h6cnhxNxDA==
   dependencies:
-    workbox-core "6.5.4"
+    workbox-core "6.6.1"
 
-workbox-precaching@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-6.5.4.tgz#740e3561df92c6726ab5f7471e6aac89582cab72"
-  integrity sha512-hSMezMsW6btKnxHB4bFy2Qfwey/8SYdGWvVIKFaUm8vJ4E53JAY+U2JwLTRD8wbLWoP6OVUdFlXsTdKu9yoLTg==
+workbox-precaching@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-precaching/-/workbox-precaching-6.6.1.tgz#dedeeba10a2d163d990bf99f1c2066ac0d1a19e2"
+  integrity sha512-K4znSJ7IKxCnCYEdhNkMr7X1kNh8cz+mFgx9v5jFdz1MfI84pq8C2zG+oAoeE5kFrUf7YkT5x4uLWBNg0DVZ5A==
   dependencies:
-    workbox-core "6.5.4"
-    workbox-routing "6.5.4"
-    workbox-strategies "6.5.4"
+    workbox-core "6.6.1"
+    workbox-routing "6.6.1"
+    workbox-strategies "6.6.1"
 
-workbox-range-requests@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-6.5.4.tgz#86b3d482e090433dab38d36ae031b2bb0bd74399"
-  integrity sha512-Je2qR1NXCFC8xVJ/Lux6saH6IrQGhMpDrPXWZWWS8n/RD+WZfKa6dSZwU+/QksfEadJEr/NfY+aP/CXFFK5JFg==
+workbox-range-requests@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-range-requests/-/workbox-range-requests-6.6.1.tgz#ddaf7e73af11d362fbb2f136a9063a4c7f507a39"
+  integrity sha512-4BDzk28govqzg2ZpX0IFkthdRmCKgAKreontYRC5YsAPB2jDtPNxqx3WtTXgHw1NZalXpcH/E4LqUa9+2xbv1g==
   dependencies:
-    workbox-core "6.5.4"
+    workbox-core "6.6.1"
 
-workbox-recipes@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-recipes/-/workbox-recipes-6.5.4.tgz#cca809ee63b98b158b2702dcfb741b5cc3e24acb"
-  integrity sha512-QZNO8Ez708NNwzLNEXTG4QYSKQ1ochzEtRLGaq+mr2PyoEIC1xFW7MrWxrONUxBFOByksds9Z4//lKAX8tHyUA==
+workbox-recipes@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-recipes/-/workbox-recipes-6.6.1.tgz#ea70d2b2b0b0bce8de0a9d94f274d4a688e69fae"
+  integrity sha512-/oy8vCSzromXokDA+X+VgpeZJvtuf8SkQ8KL0xmRivMgJZrjwM3c2tpKTJn6PZA6TsbxGs3Sc7KwMoZVamcV2g==
   dependencies:
-    workbox-cacheable-response "6.5.4"
-    workbox-core "6.5.4"
-    workbox-expiration "6.5.4"
-    workbox-precaching "6.5.4"
-    workbox-routing "6.5.4"
-    workbox-strategies "6.5.4"
+    workbox-cacheable-response "6.6.1"
+    workbox-core "6.6.1"
+    workbox-expiration "6.6.1"
+    workbox-precaching "6.6.1"
+    workbox-routing "6.6.1"
+    workbox-strategies "6.6.1"
 
-workbox-routing@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-6.5.4.tgz#6a7fbbd23f4ac801038d9a0298bc907ee26fe3da"
-  integrity sha512-apQswLsbrrOsBUWtr9Lf80F+P1sHnQdYodRo32SjiByYi36IDyL2r7BH1lJtFX8fwNHDa1QOVY74WKLLS6o5Pg==
+workbox-routing@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-routing/-/workbox-routing-6.6.1.tgz#cba9a1c7e0d1ea11e24b6f8c518840efdc94f581"
+  integrity sha512-j4ohlQvfpVdoR8vDYxTY9rA9VvxTHogkIDwGdJ+rb2VRZQ5vt1CWwUUZBeD/WGFAni12jD1HlMXvJ8JS7aBWTg==
   dependencies:
-    workbox-core "6.5.4"
+    workbox-core "6.6.1"
 
-workbox-strategies@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-6.5.4.tgz#4edda035b3c010fc7f6152918370699334cd204d"
-  integrity sha512-DEtsxhx0LIYWkJBTQolRxG4EI0setTJkqR4m7r4YpBdxtWJH1Mbg01Cj8ZjNOO8etqfA3IZaOPHUxCs8cBsKLw==
+workbox-strategies@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-strategies/-/workbox-strategies-6.6.1.tgz#38d0f0fbdddba97bd92e0c6418d0b1a2ccd5b8bf"
+  integrity sha512-WQLXkRnsk4L81fVPkkgon1rZNxnpdO5LsO+ws7tYBC6QQQFJVI6v98klrJEjFtZwzw/mB/HT5yVp7CcX0O+mrw==
   dependencies:
-    workbox-core "6.5.4"
+    workbox-core "6.6.1"
 
-workbox-streams@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-6.5.4.tgz#1cb3c168a6101df7b5269d0353c19e36668d7d69"
-  integrity sha512-FXKVh87d2RFXkliAIheBojBELIPnWbQdyDvsH3t74Cwhg0fDheL1T8BqSM86hZvC0ZESLsznSYWw+Va+KVbUzg==
+workbox-streams@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-streams/-/workbox-streams-6.6.1.tgz#b2f7ba7b315c27a6e3a96a476593f99c5d227d26"
+  integrity sha512-maKG65FUq9e4BLotSKWSTzeF0sgctQdYyTMq529piEN24Dlu9b6WhrAfRpHdCncRS89Zi2QVpW5V33NX8PgH3Q==
   dependencies:
-    workbox-core "6.5.4"
-    workbox-routing "6.5.4"
+    workbox-core "6.6.1"
+    workbox-routing "6.6.1"
 
-workbox-sw@6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-sw/-/workbox-sw-6.5.4.tgz#d93e9c67924dd153a61367a4656ff4d2ae2ed736"
-  integrity sha512-vo2RQo7DILVRoH5LjGqw3nphavEjK4Qk+FenXeUsknKn14eCNedHOXWbmnvP4ipKhlE35pvJ4yl4YYf6YsJArA==
+workbox-sw@6.6.1:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-sw/-/workbox-sw-6.6.1.tgz#d4c4ca3125088e8b9fd7a748ed537fa0247bd72c"
+  integrity sha512-R7whwjvU2abHH/lR6kQTTXLHDFU2izht9kJOvBRYK65FbwutT4VvnUAJIgHvfWZ/fokrOPhfoWYoPCMpSgUKHQ==
 
 workbox-webpack-plugin@^6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-webpack-plugin/-/workbox-webpack-plugin-6.5.4.tgz#baf2d3f4b8f435f3469887cf4fba2b7fac3d0fd7"
-  integrity sha512-LmWm/zoaahe0EGmMTrSLUi+BjyR3cdGEfU3fS6PN1zKFYbqAKuQ+Oy/27e4VSXsyIwAw8+QDfk1XHNGtZu9nQg==
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-webpack-plugin/-/workbox-webpack-plugin-6.6.1.tgz#4f81cc1ad4e5d2cd7477a86ba83c84ee2d187531"
+  integrity sha512-zpZ+ExFj9NmiI66cFEApyjk7hGsfJ1YMOaLXGXBoZf0v7Iu6hL0ZBe+83mnDq3YYWAfA3fnyFejritjOHkFcrA==
   dependencies:
     fast-json-stable-stringify "^2.1.0"
     pretty-bytes "^5.4.1"
     upath "^1.2.0"
     webpack-sources "^1.4.3"
-    workbox-build "6.5.4"
+    workbox-build "6.6.1"
 
-workbox-window@6.5.4, workbox-window@^6.5.4:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/workbox-window/-/workbox-window-6.5.4.tgz#d991bc0a94dff3c2dbb6b84558cff155ca878e91"
-  integrity sha512-HnLZJDwYBE+hpG25AQBO8RUWBJRaCsI9ksQJEp3aCOFCaG5kqaToAYXFRAHxzRluM2cQbGzdQF5rjKPWPA1fug==
+workbox-window@6.6.1, workbox-window@^6.5.4:
+  version "6.6.1"
+  resolved "https://registry.yarnpkg.com/workbox-window/-/workbox-window-6.6.1.tgz#f22a394cbac36240d0dadcbdebc35f711bb7b89e"
+  integrity sha512-wil4nwOY58nTdCvif/KEZjQ2NP8uk3gGeRNy2jPBbzypU4BT4D9L8xiwbmDBpZlSgJd2xsT9FvSNU0gsxV51JQ==
   dependencies:
     "@types/trusted-types" "^2.0.2"
-    workbox-core "6.5.4"
+    workbox-core "6.6.1"
 
 worker-farm@^1.6.0:
   version "1.7.0"
@@ -9397,9 +9666,9 @@ yallist@^4.0.0:
   integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
 
 yaml@^2.2.2:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.2.2.tgz#ec551ef37326e6d42872dad1970300f8eb83a073"
-  integrity sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b"
+  integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==
 
 yargs-parser@^15.0.1:
   version "15.0.3"