]> Untitled Git - lemmy-ui.git/commitdiff
Make pages use query params instead of route params where appropriate (#977)
authorSleeplessOne1917 <abias1122@gmail.com>
Sat, 15 Apr 2023 14:47:10 +0000 (14:47 +0000)
committerGitHub <noreply@github.com>
Sat, 15 Apr 2023 14:47:10 +0000 (10:47 -0400)
* feat: Add multiple image upload

* refactor: Slight cleanup

* feat: Add progress bar for multi-image upload

* fix: Fix progress bar

* fix: Messed up fix last time

* refactor: Use await where possible

* Add query params to search page

* Update translation logic

* Did suggested PR changes

* Updating translations

* Fix i18 issue

* Make prettier actually check src in hopes it will fix CI issue

* Make home page use query params in URL

* Remove unnecessary part of private message url

* Make communities page use query params

* Make community page use query params

* Make user profile use query params

* Make modlog use query params

* Replace choices.js searchable select entirely

* Make 404 screen show up when expected

* Refactor query params code

* Remove unnecessary boolean literal

* Fix query param bug

* Address bug with searchable select and initial fetch

* Only import what is needed from bootstrap

* Undo change to comment nodes component

* Convert closure style functions to normal functions

* Updated translations

* Use translation for loading

* Fix create post select community bug

* Fix community query params bug

25 files changed:
lemmy-translations
package.json
src/client/index.tsx
src/server/index.tsx
src/shared/components/app/app.tsx
src/shared/components/app/navbar.tsx
src/shared/components/app/styles.scss
src/shared/components/comment/comment-node.tsx
src/shared/components/comment/comment-nodes.tsx
src/shared/components/common/searchable-select.tsx [new file with mode: 0644]
src/shared/components/community/communities.tsx
src/shared/components/community/community.tsx
src/shared/components/community/sidebar.tsx
src/shared/components/home/home.tsx
src/shared/components/modlog.tsx
src/shared/components/person/profile.tsx
src/shared/components/person/settings.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/search.tsx
src/shared/interfaces.ts
src/shared/routes.ts
src/shared/utils.ts
yarn.lock

index d2b85d582071d84b559f7b9db1ab623f6596c586..5c50ce3ebaf058ad5d4e9bcd445653960cbc98b1 160000 (submodule)
@@ -1 +1 @@
-Subproject commit d2b85d582071d84b559f7b9db1ab623f6596c586
+Subproject commit 5c50ce3ebaf058ad5d4e9bcd445653960cbc98b1
index 573858bff532aaadcbc5f67b1732452db815960f..a78fc347985333ce99ab24daceff1821532427cb 100644 (file)
@@ -36,8 +36,8 @@
     "autosize": "^6.0.1",
     "babel-loader": "^9.1.2",
     "babel-plugin-inferno": "^6.6.0",
+    "bootstrap": "^5.2.3",
     "check-password-strength": "^2.0.7",
-    "choices.js": "^10.2.0",
     "classnames": "^2.3.1",
     "clean-webpack-plugin": "^4.0.0",
     "copy-webpack-plugin": "^11.0.0",
@@ -66,7 +66,6 @@
     "markdown-it-sup": "^1.0.0",
     "mini-css-extract-plugin": "^2.7.2",
     "moment": "^2.29.4",
-    "node-fetch": "^2.6.1",
     "register-service-worker": "^1.7.2",
     "run-node-webpack-plugin": "^1.3.0",
     "rxjs": "^7.8.0",
     "@types/markdown-it": "^12.2.3",
     "@types/markdown-it-container": "^2.0.5",
     "@types/node": "^18.14.0",
-    "@types/node-fetch": "^2.6.2",
     "@types/sanitize-html": "^2.8.0",
     "@types/serialize-javascript": "^5.0.1",
     "@types/toastify-js": "^1.11.1",
     "@typescript-eslint/eslint-plugin": "^5.53.0",
     "@typescript-eslint/parser": "^5.53.0",
-    "bootstrap": "^5.2.3",
     "bootswatch": "^5.2.3",
     "eslint": "^8.34.0",
     "eslint-plugin-inferno": "^7.32.1",
index a96e6bdec85b72bbb066efc3e325f1dc10ddfa3e..d3a5b625b1212fc44bf4343eee42ef6f596da2f0 100644 (file)
@@ -3,6 +3,8 @@ import { BrowserRouter } from "inferno-router";
 import { App } from "../shared/components/app/app";
 import { initializeSite } from "../shared/utils";
 
+import "bootstrap/js/dist/dropdown";
+
 const site = window.isoData.site_res;
 initializeSite(site);
 
@@ -12,7 +14,7 @@ const wrapper = (
   </BrowserRouter>
 );
 
-let root = document.getElementById("root");
+const root = document.getElementById("root");
 if (root) {
   hydrate(wrapper, root);
 }
index 29c7294480878a32eae83809201bf77a6e23e541..c8726f24c7a7a139dd0ad40b5a6dfa9bac70911a 100644 (file)
@@ -105,51 +105,54 @@ server.get("/*", async (req, res) => {
     const context = {} as any;
     let auth: string | undefined = IsomorphicCookie.load("jwt", req);
 
-    let getSiteForm: GetSite = { auth };
+    const getSiteForm: GetSite = { auth };
 
-    let promises: Promise<any>[] = [];
+    const promises: Promise<any>[] = [];
 
-    let headers = setForwardedHeaders(req.headers);
-
-    let initialFetchReq: InitialFetchRequest = {
-      client: new LemmyHttp(httpBaseInternal, headers),
-      auth,
-      path: req.path,
-    };
+    const headers = setForwardedHeaders(req.headers);
+    const client = new LemmyHttp(httpBaseInternal, headers);
 
     // Get site data first
     // 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 try_site: any = await initialFetchReq.client.getSite(getSiteForm);
+    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;
-      initialFetchReq.auth = undefined;
-      try_site = await initialFetchReq.client.getSite(getSiteForm);
+      auth = undefined;
+      try_site = await client.getSite(getSiteForm);
     }
-    let site: GetSiteResponse = try_site;
+    const site: GetSiteResponse = try_site;
     initializeSite(site);
 
+    const initialFetchReq: InitialFetchRequest = {
+      client,
+      auth,
+      path: req.path,
+      query: req.query,
+      site,
+    };
+
     if (activeRoute?.fetchInitialData) {
       promises.push(...activeRoute.fetchInitialData(initialFetchReq));
     }
 
-    let routeData = await Promise.all(promises);
+    const routeData = await Promise.all(promises);
 
     // Redirect to the 404 if there's an API error
     if (routeData[0] && routeData[0].error) {
-      let errCode = routeData[0].error;
-      console.error(errCode);
-      if (errCode == "instance_is_private") {
+      const error = routeData[0].error;
+      console.error(error);
+      if (error === "instance_is_private") {
         return res.redirect(`/signup`);
       } else {
-        return res.send(`404: ${removeAuthParam(errCode)}`);
+        return res.send(`404: ${removeAuthParam(error)}`);
       }
     }
 
-    let isoData: IsoData = {
+    const isoData: IsoData = {
       path: req.path,
       site_res: site,
       routeData,
@@ -170,6 +173,7 @@ server.get("/*", async (req, res) => {
         <script>eruda.init();</script>
       </>
     );
+
     const erudaStr = process.env["LEMMY_UI_DEBUG"] ? renderToString(eruda) : "";
     const root = renderToString(wrapper);
     const helmet = Helmet.renderStatic();
index 45c67b3294729eac5b8a3e1272267876d8a90c98..e0c4f15b81c2db32e92d1533f125dd4450b6f08b 100644 (file)
@@ -40,17 +40,10 @@ export class App extends Component<any, any> {
             <Navbar siteRes={siteRes} />
             <div className="mt-4 p-0 fl-1">
               <Switch>
-                {routes.map(
-                  ({ path, exact, component: Component, ...rest }) => (
-                    <Route
-                      key={path}
-                      path={path}
-                      exact={exact}
-                      render={props => <Component {...props} {...rest} />}
-                    />
-                  )
-                )}
-                <Route render={props => <NoMatch {...props} />} />
+                {routes.map(({ path, component }) => (
+                  <Route key={path} path={path} exact component={component} />
+                ))}
+                <Route component={NoMatch} />
               </Switch>
             </div>
             <Footer site={siteRes} />
index 4d0b88fdc2d5893986f7d30516aefbb6bae69389..44345cc9311655e2923c56055e00f0eae73505bc 100644 (file)
@@ -43,7 +43,6 @@ interface NavbarState {
   unreadInboxCount: number;
   unreadReportCount: number;
   unreadApplicationCount: number;
-  searchParam: string;
   showDropdown: boolean;
   onSiteBanner?(url: string): any;
 }
@@ -59,7 +58,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
     unreadReportCount: 0,
     unreadApplicationCount: 0,
     expanded: false,
-    searchParam: "",
     showDropdown: false,
   };
   subscription: any;
@@ -115,20 +113,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
     this.unreadApplicationCountSub.unsubscribe();
   }
 
-  updateUrl() {
-    const searchParam = this.state.searchParam;
-    this.setState({ searchParam: "" });
-    this.setState({ showDropdown: false, expanded: false });
-    if (searchParam === "") {
-      this.context.router.history.push(`/search/`);
-    } else {
-      const searchParamEncoded = encodeURIComponent(searchParam);
-      this.context.router.history.push(
-        `/search/q/${searchParamEncoded}/type/All/sort/TopAll/listing_type/All/community_id/0/creator_id/0/page/1`
-      );
-    }
-  }
-
   render() {
     return this.navbar();
   }
@@ -488,10 +472,6 @@ export class Navbar extends Component<NavbarProps, NavbarState> {
     i.setState({ expanded: false, showDropdown: false });
   }
 
-  handleSearchParam(i: Navbar, event: any) {
-    i.setState({ searchParam: event.target.value });
-  }
-
   handleLogoutClick(i: Navbar) {
     i.setState({ showDropdown: false, expanded: false });
     UserService.Instance.logout();
index 76006818f0abf36182ca4915c7a071ba387b76c7..559f619ff7467faef82106a0eba908aa3f658702 100644 (file)
@@ -1,6 +1,6 @@
 // Custom css
 @import "../../../../node_modules/tributejs/dist/tribute.css";
 @import "../../../../node_modules/toastify-js/src/toastify.css";
-@import "../../../../node_modules/choices.js/src/styles/choices.scss";
 @import "../../../../node_modules/tippy.js/dist/tippy.css";
+@import "../../../../node_modules/bootstrap/dist/css/bootstrap-utilities.min.css";
 @import "../../../assets/css/main.css";
index 418986d762b496c28e99ca2110e7dadeac894613..99db143688573727882ca078d7daa73e53bd392e 100644 (file)
@@ -430,7 +430,7 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
                               <button className="btn btn-link btn-animate">
                                 <Link
                                   className="text-muted"
-                                  to={`/create_private_message/recipient/${cv.creator.id}`}
+                                  to={`/create_private_message/${cv.creator.id}`}
                                   title={i18n.t("message").toLowerCase()}
                                 >
                                   <Icon icon="mail" />
index a053f9929d2f1c193a0201c260ebdcd9d58d0410..c8fd1cfc689d74d8454dcd7727466e1bff67e7e5 100644 (file)
@@ -28,7 +28,7 @@ interface CommentNodesProps {
 }
 
 export class CommentNodes extends Component<CommentNodesProps, any> {
-  constructor(props: any, context: any) {
+  constructor(props: CommentNodesProps, context: any) {
     super(props, context);
   }
 
diff --git a/src/shared/components/common/searchable-select.tsx b/src/shared/components/common/searchable-select.tsx
new file mode 100644 (file)
index 0000000..a5a75f2
--- /dev/null
@@ -0,0 +1,204 @@
+import classNames from "classnames";
+import {
+  ChangeEvent,
+  Component,
+  createRef,
+  linkEvent,
+  RefObject,
+} from "inferno";
+import { i18n } from "../../i18next";
+import { Choice } from "../../utils";
+import { Icon, Spinner } from "./icon";
+
+interface SearchableSelectProps {
+  id: string;
+  value?: number | string;
+  options: Choice[];
+  onChange?: (option: Choice) => void;
+  onSearch?: (text: string) => void;
+  loading?: boolean;
+}
+
+interface SearchableSelectState {
+  selectedIndex: number;
+  searchText: string;
+  loadingEllipses: string;
+}
+
+function handleSearch(i: SearchableSelect, e: ChangeEvent<HTMLInputElement>) {
+  const { onSearch } = i.props;
+  const searchText = e.target.value;
+
+  if (onSearch) {
+    onSearch(searchText);
+  }
+
+  i.setState({
+    searchText,
+  });
+}
+
+export class SearchableSelect extends Component<
+  SearchableSelectProps,
+  SearchableSelectState
+> {
+  private searchInputRef: RefObject<HTMLInputElement> = createRef();
+  private toggleButtonRef: RefObject<HTMLButtonElement> = createRef();
+  private loadingEllipsesInterval?: NodeJS.Timer = undefined;
+
+  state: SearchableSelectState = {
+    selectedIndex: 0,
+    searchText: "",
+    loadingEllipses: "...",
+  };
+
+  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()
+      );
+
+      if (selectedIndex < 0) {
+        selectedIndex = 0;
+      }
+
+      this.state = {
+        ...this.state,
+        selectedIndex,
+      };
+    }
+  }
+
+  render() {
+    const { id, options, onSearch, loading } = this.props;
+    const { searchText, selectedIndex, loadingEllipses } = this.state;
+
+    return (
+      <div className="dropdown">
+        <button
+          id={id}
+          type="button"
+          className="custom-select text-start"
+          aria-haspopup="listbox"
+          data-bs-toggle="dropdown"
+          onClick={this.focusSearch}
+        >
+          {loading
+            ? `${i18n.t("loading")}${loadingEllipses}`
+            : options[selectedIndex].label}
+        </button>
+        <div
+          role="combobox"
+          aria-activedescendant={options[selectedIndex].label}
+          className="modlog-choices-font-size dropdown-menu w-100 p-2"
+        >
+          <div className="input-group">
+            <span className="input-group-text">
+              {loading ? <Spinner /> : <Icon icon="search" />}
+            </span>
+            <input
+              type="text"
+              className="form-control"
+              ref={this.searchInputRef}
+              onInput={linkEvent(this, handleSearch)}
+              value={searchText}
+              placeholder={`${i18n.t("search")}...`}
+            />
+          </div>
+          {!loading &&
+            // If onSearch is provided, it is assumed that the parent component is doing it's own sorting logic.
+            (onSearch || searchText.length === 0
+              ? options
+              : options.filter(({ label }) =>
+                  label.toLowerCase().includes(searchText.toLowerCase())
+                )
+            ).map((option, index) => (
+              <button
+                key={option.value}
+                className={classNames("dropdown-item", {
+                  active: selectedIndex === index,
+                })}
+                role="option"
+                aria-disabled={option.disabled}
+                disabled={option.disabled}
+                aria-selected={selectedIndex === index}
+                onClick={() => this.handleChange(option)}
+                type="button"
+              >
+                {option.label}
+              </button>
+            ))}
+        </div>
+      </div>
+    );
+  }
+
+  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,
+  }: SearchableSelectProps): Partial<SearchableSelectState> {
+    let selectedIndex =
+      value || value === 0
+        ? options.findIndex(option => option.value === value.toString())
+        : 0;
+
+    if (selectedIndex < 0) {
+      selectedIndex = 0;
+    }
+
+    return {
+      selectedIndex,
+    };
+  }
+
+  componentDidUpdate() {
+    const { loading } = this.props;
+    if (loading && !this.loadingEllipsesInterval) {
+      this.loadingEllipsesInterval = setInterval(() => {
+        this.setState(({ loadingEllipses }) => ({
+          loadingEllipses:
+            loadingEllipses.length === 3 ? "" : loadingEllipses + ".",
+        }));
+      }, 750);
+    } else if (!loading && this.loadingEllipsesInterval) {
+      clearInterval(this.loadingEllipsesInterval);
+    }
+  }
+
+  componentWillUnmount() {
+    if (this.loadingEllipsesInterval) {
+      clearInterval(this.loadingEllipsesInterval);
+    }
+  }
+
+  handleChange(option: Choice) {
+    const { onChange, value } = this.props;
+
+    if (option.value !== value?.toString()) {
+      if (onChange) {
+        onChange(option);
+      }
+
+      this.setState({ searchText: "" });
+    }
+  }
+}
index e6cdc454eaf7b267dc74d4ff9d88dee60ef52e9b..2589d5e8c5c99424b54d57e82c85d8544a2ffae2 100644 (file)
@@ -17,11 +17,14 @@ import { InitialFetchRequest } from "shared/interfaces";
 import { i18n } from "../../i18next";
 import { WebSocketService } from "../../services";
 import {
-  getListingTypeFromPropsNoDefault,
-  getPageFromProps,
+  getPageFromString,
+  getQueryParams,
+  getQueryString,
   isBrowser,
   myAuth,
   numToSI,
+  QueryParams,
+  routeListingTypeToEnum,
   setIsoData,
   showLocal,
   toast,
@@ -38,16 +41,52 @@ const communityLimit = 50;
 
 interface CommunitiesState {
   listCommunitiesResponse?: ListCommunitiesResponse;
-  page: number;
   loading: boolean;
   siteRes: GetSiteResponse;
   searchText: string;
-  listingType: ListingType;
 }
 
 interface CommunitiesProps {
-  listingType?: ListingType;
-  page?: number;
+  listingType: ListingType;
+  page: number;
+}
+
+function getCommunitiesQueryParams() {
+  return getQueryParams<CommunitiesProps>({
+    listingType: getListingTypeFromQuery,
+    page: getPageFromString,
+  });
+}
+
+function getListingTypeFromQuery(listingType?: string): ListingType {
+  return routeListingTypeToEnum(listingType ?? "", 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: SortType.TopMonth,
+    limit: communityLimit,
+    page,
+    auth: myAuth(false),
+  };
+
+  WebSocketService.Instance.send(wsClient.listCommunities(listCommunitiesForm));
 }
 
 export class Communities extends Component<any, CommunitiesState> {
@@ -55,8 +94,6 @@ export class Communities extends Component<any, CommunitiesState> {
   private isoData = setIsoData(this.context);
   state: CommunitiesState = {
     loading: true,
-    page: getPageFromProps(this.props),
-    listingType: getListingTypeFromPropsNoDefault(this.props),
     siteRes: this.isoData.site_res,
     searchText: "",
   };
@@ -70,15 +107,15 @@ export class Communities extends Component<any, CommunitiesState> {
     this.subscription = wsSubscribe(this.parseMessage);
 
     // Only fetch the data if coming from another route
-    if (this.isoData.path == this.context.router.route.match.url) {
-      let listRes = this.isoData.routeData[0] as ListCommunitiesResponse;
+    if (this.isoData.path === this.context.router.route.match.url) {
+      const listRes = this.isoData.routeData[0] as ListCommunitiesResponse;
       this.state = {
         ...this.state,
         listCommunitiesResponse: listRes,
         loading: false,
       };
     } else {
-      this.refetch();
+      refetch();
     }
   }
 
@@ -88,23 +125,6 @@ export class Communities extends Component<any, CommunitiesState> {
     }
   }
 
-  static getDerivedStateFromProps(props: any): CommunitiesProps {
-    return {
-      listingType: getListingTypeFromPropsNoDefault(props),
-      page: getPageFromProps(props),
-    };
-  }
-
-  componentDidUpdate(_: any, lastState: CommunitiesState) {
-    if (
-      lastState.page !== this.state.page ||
-      lastState.listingType !== this.state.listingType
-    ) {
-      this.setState({ loading: true });
-      this.refetch();
-    }
-  }
-
   get documentTitle(): string {
     return `${i18n.t("communities")} - ${
       this.state.siteRes.site_view.site.name
@@ -112,6 +132,8 @@ export class Communities extends Component<any, CommunitiesState> {
   }
 
   render() {
+    const { listingType, page } = getCommunitiesQueryParams();
+
     return (
       <div className="container-lg">
         <HtmlTags
@@ -129,7 +151,7 @@ export class Communities extends Component<any, CommunitiesState> {
                 <h4>{i18n.t("list_of_communities")}</h4>
                 <span className="mb-2">
                   <ListingTypeSelect
-                    type_={this.state.listingType}
+                    type_={listingType}
                     showLocal={showLocal(this.isoData)}
                     showSubscribed
                     onChange={this.handleListingTypeChange}
@@ -192,7 +214,7 @@ export class Communities extends Component<any, CommunitiesState> {
                             {i18n.t("unsubscribe")}
                           </button>
                         )}
-                        {cv.subscribed == SubscribedType.NotSubscribed && (
+                        {cv.subscribed === SubscribedType.NotSubscribed && (
                           <button
                             className="btn btn-link d-inline-block"
                             onClick={linkEvent(
@@ -203,7 +225,7 @@ export class Communities extends Component<any, CommunitiesState> {
                             {i18n.t("subscribe")}
                           </button>
                         )}
-                        {cv.subscribed == SubscribedType.Pending && (
+                        {cv.subscribed === SubscribedType.Pending && (
                           <div className="text-warning d-inline-block">
                             {i18n.t("subscribe_pending")}
                           </div>
@@ -214,10 +236,7 @@ export class Communities extends Component<any, CommunitiesState> {
                 </tbody>
               </table>
             </div>
-            <Paginator
-              page={this.state.page}
-              onChange={this.handlePageChange}
-            />
+            <Paginator page={page} onChange={this.handlePageChange} />
           </div>
         )}
       </div>
@@ -250,12 +269,18 @@ export class Communities extends Component<any, CommunitiesState> {
     );
   }
 
-  updateUrl(paramUpdates: CommunitiesProps) {
-    const page = paramUpdates.page || this.state.page;
-    const listingTypeStr = paramUpdates.listingType || this.state.listingType;
-    this.props.history.push(
-      `/communities/listing_type/${listingTypeStr}/page/${page}`
-    );
+  updateUrl({ listingType, page }: Partial<CommunitiesProps>) {
+    const { listingType: urlListingType, page: urlPage } =
+      getCommunitiesQueryParams();
+
+    const queryParams: QueryParams<CommunitiesProps> = {
+      listingType: listingType ?? urlListingType,
+      page: (page ?? urlPage)?.toString(),
+    };
+
+    this.props.history.push(`/communities${getQueryString(queryParams)}`);
+
+    refetch();
   }
 
   handlePageChange(page: number) {
@@ -270,27 +295,11 @@ export class Communities extends Component<any, CommunitiesState> {
   }
 
   handleUnsubscribe(communityId: number) {
-    let auth = myAuth();
-    if (auth) {
-      let form: FollowCommunity = {
-        community_id: communityId,
-        follow: false,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.followCommunity(form));
-    }
+    toggleSubscribe(communityId, false);
   }
 
   handleSubscribe(communityId: number) {
-    let auth = myAuth();
-    if (auth) {
-      let form: FollowCommunity = {
-        community_id: communityId,
-        follow: true,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.followCommunity(form));
-    }
+    toggleSubscribe(communityId, true);
   }
 
   handleSearchChange(i: Communities, event: any) {
@@ -299,61 +308,50 @@ export class Communities extends Component<any, CommunitiesState> {
 
   handleSearchSubmit(i: Communities) {
     const searchParamEncoded = encodeURIComponent(i.state.searchText);
-    i.context.router.history.push(
-      `/search/q/${searchParamEncoded}/type/Communities/sort/TopAll/listing_type/All/community_id/0/creator_id/0/page/1`
-    );
-  }
-
-  refetch() {
-    let listCommunitiesForm: ListCommunities = {
-      type_: this.state.listingType,
-      sort: SortType.TopMonth,
-      limit: communityLimit,
-      page: this.state.page,
-      auth: myAuth(false),
-    };
-
-    WebSocketService.Instance.send(
-      wsClient.listCommunities(listCommunitiesForm)
-    );
+    i.context.router.history.push(`/search?q=${searchParamEncoded}`);
   }
 
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
-    let pathSplit = req.path.split("/");
-    let type_: ListingType = pathSplit[3]
-      ? ListingType[pathSplit[3]]
-      : ListingType.Local;
-    let page = pathSplit[5] ? Number(pathSplit[5]) : 1;
-    let listCommunitiesForm: ListCommunities = {
-      type_,
+  static fetchInitialData({
+    query: { listingType, page },
+    client,
+    auth,
+  }: InitialFetchRequest<QueryParams<CommunitiesProps>>): Promise<any>[] {
+    const listCommunitiesForm: ListCommunities = {
+      type_: getListingTypeFromQuery(listingType),
       sort: SortType.TopMonth,
       limit: communityLimit,
-      page,
-      auth: req.auth,
+      page: getPageFromString(page),
+      auth: auth,
     };
 
-    return [req.client.listCommunities(listCommunitiesForm)];
+    return [client.listCommunities(listCommunitiesForm)];
   }
 
   parseMessage(msg: any) {
-    let op = wsUserOp(msg);
+    const op = wsUserOp(msg);
     console.log(msg);
     if (msg.error) {
       toast(i18n.t(msg.error), "danger");
-      return;
-    } else if (op == UserOperation.ListCommunities) {
-      let data = wsJsonToRes<ListCommunitiesResponse>(msg);
+    } 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) {
-      let data = wsJsonToRes<CommunityResponse>(msg);
-      let res = this.state.listCommunitiesResponse;
-      let found = res?.communities.find(
-        c => c.community.id == data.community_view.community.id
+    } 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
       );
+
       if (found) {
-        found.subscribed = data.community_view.subscribed;
-        found.counts.subscribers = data.community_view.counts.subscribers;
+        found.subscribed = subscribed;
+        found.counts.subscribers = subscribers;
         this.setState(this.state);
       }
     }
index 5880ee7f7c7da8d6baf7637d972c06b8e25f2e12..f6da7a6cf10f3476431b55807bda3a04b28550f1 100644 (file)
@@ -1,10 +1,10 @@
 import { Component, linkEvent } from "inferno";
+import { RouteComponentProps } from "inferno-router/dist/Route";
 import {
   AddModToCommunityResponse,
   BanFromCommunityResponse,
   BlockCommunityResponse,
   BlockPersonResponse,
-  CommentReportResponse,
   CommentResponse,
   CommentView,
   CommunityResponse,
@@ -14,7 +14,6 @@ import {
   GetCommunityResponse,
   GetPosts,
   GetPostsResponse,
-  GetSiteResponse,
   ListingType,
   PostReportResponse,
   PostResponse,
@@ -43,16 +42,20 @@ import {
   enableDownvotes,
   enableNsfw,
   fetchLimit,
-  getDataTypeFromProps,
-  getPageFromProps,
-  getSortTypeFromProps,
+  getDataTypeString,
+  getPageFromString,
+  getQueryParams,
+  getQueryString,
   isPostBlocked,
   myAuth,
   notifyPost,
   nsfwCheck,
   postToCommentSortType,
+  QueryParams,
   relTags,
   restoreScrollPosition,
+  routeDataTypeToEnum,
+  routeSortTypeToEnum,
   saveCommentRes,
   saveScrollPosition,
   setIsoData,
@@ -78,16 +81,10 @@ import { CommunityLink } from "./community-link";
 
 interface State {
   communityRes?: GetCommunityResponse;
-  siteRes: GetSiteResponse;
-  communityName: string;
   communityLoading: boolean;
-  postsLoading: boolean;
-  commentsLoading: boolean;
+  listingsLoading: boolean;
   posts: PostView[];
   comments: CommentView[];
-  dataType: DataType;
-  sort: SortType;
-  page: number;
   showSidebarMobile: boolean;
 }
 
@@ -97,30 +94,43 @@ interface CommunityProps {
   page: number;
 }
 
-interface UrlParams {
-  dataType?: string;
-  sort?: SortType;
-  page?: number;
+function getCommunityQueryParams() {
+  return getQueryParams<CommunityProps>({
+    dataType: getDataTypeFromQuery,
+    page: getPageFromString,
+    sort: getSortTypeFromQuery,
+  });
+}
+
+const getDataTypeFromQuery = (type?: string): DataType =>
+  routeDataTypeToEnum(type ?? "", DataType.Post);
+
+function getSortTypeFromQuery(type?: string): SortType {
+  const mySortType =
+    UserService.Instance.myUserInfo?.local_user_view.local_user
+      .default_sort_type;
+
+  return routeSortTypeToEnum(
+    type ?? "",
+    mySortType ? Object.values(SortType)[mySortType] : SortType.Active
+  );
 }
 
-export class Community extends Component<any, State> {
+export class Community extends Component<
+  RouteComponentProps<{ name: string }>,
+  State
+> {
   private isoData = setIsoData(this.context);
   private subscription?: Subscription;
   state: State = {
-    communityName: this.props.match.params.name,
     communityLoading: true,
-    postsLoading: true,
-    commentsLoading: true,
+    listingsLoading: true,
     posts: [],
     comments: [],
-    dataType: getDataTypeFromProps(this.props),
-    sort: getSortTypeFromProps(this.props),
-    page: getPageFromProps(this.props),
-    siteRes: this.isoData.site_res,
     showSidebarMobile: false,
   };
 
-  constructor(props: any, context: any) {
+  constructor(props: RouteComponentProps<{ name: string }>, context: any) {
     super(props, context);
 
     this.handleSortChange = this.handleSortChange.bind(this);
@@ -136,8 +146,10 @@ export class Community extends Component<any, State> {
         ...this.state,
         communityRes: this.isoData.routeData[0] as GetCommunityResponse,
       };
-      let postsRes = this.isoData.routeData[1] as GetPostsResponse | undefined;
-      let commentsRes = this.isoData.routeData[2] as
+      const postsRes = this.isoData.routeData[1] as
+        | GetPostsResponse
+        | undefined;
+      const commentsRes = this.isoData.routeData[2] as
         | GetCommentsResponse
         | undefined;
 
@@ -152,8 +164,7 @@ export class Community extends Component<any, State> {
       this.state = {
         ...this.state,
         communityLoading: false,
-        postsLoading: false,
-        commentsLoading: false,
+        listingsLoading: false,
       };
     } else {
       this.fetchCommunity();
@@ -162,8 +173,8 @@ export class Community extends Component<any, State> {
   }
 
   fetchCommunity() {
-    let form: GetCommunity = {
-      name: this.state.communityName,
+    const form: GetCommunity = {
+      name: this.props.match.params.name,
       auth: myAuth(false),
     };
     WebSocketService.Instance.send(wsClient.getCommunity(form));
@@ -178,95 +189,67 @@ export class Community extends Component<any, State> {
     this.subscription?.unsubscribe();
   }
 
-  static getDerivedStateFromProps(props: any): CommunityProps {
-    return {
-      dataType: getDataTypeFromProps(props),
-      sort: getSortTypeFromProps(props),
-      page: getPageFromProps(props),
-    };
-  }
-
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
-    let pathSplit = req.path.split("/");
-    let promises: Promise<any>[] = [];
-
-    let communityName = pathSplit[2];
-    let communityForm: GetCommunity = {
+  static fetchInitialData({
+    client,
+    path,
+    query: { dataType: urlDataType, page: urlPage, sort: urlSort },
+    auth,
+  }: InitialFetchRequest<QueryParams<CommunityProps>>): Promise<any>[] {
+    const pathSplit = path.split("/");
+    const promises: Promise<any>[] = [];
+
+    const communityName = pathSplit[2];
+    const communityForm: GetCommunity = {
       name: communityName,
-      auth: req.auth,
+      auth,
     };
-    promises.push(req.client.getCommunity(communityForm));
+    promises.push(client.getCommunity(communityForm));
 
-    let dataType: DataType = pathSplit[4]
-      ? DataType[pathSplit[4]]
-      : DataType.Post;
+    const dataType = getDataTypeFromQuery(urlDataType);
 
-    let mui = UserService.Instance.myUserInfo;
+    const sort = getSortTypeFromQuery(urlSort);
 
-    let sort: SortType = pathSplit[6]
-      ? SortType[pathSplit[6]]
-      : mui
-      ? Object.values(SortType)[
-          mui.local_user_view.local_user.default_sort_type
-        ]
-      : SortType.Active;
+    const page = getPageFromString(urlPage);
 
-    let page = pathSplit[8] ? Number(pathSplit[8]) : 1;
-
-    if (dataType == DataType.Post) {
-      let getPostsForm: GetPosts = {
+    if (dataType === DataType.Post) {
+      const getPostsForm: GetPosts = {
         community_name: communityName,
         page,
         limit: fetchLimit,
         sort,
         type_: ListingType.All,
         saved_only: false,
-        auth: req.auth,
+        auth,
       };
-      promises.push(req.client.getPosts(getPostsForm));
+      promises.push(client.getPosts(getPostsForm));
       promises.push(Promise.resolve());
     } else {
-      let getCommentsForm: GetComments = {
+      const getCommentsForm: GetComments = {
         community_name: communityName,
         page,
         limit: fetchLimit,
         sort: postToCommentSortType(sort),
         type_: ListingType.All,
         saved_only: false,
-        auth: req.auth,
+        auth,
       };
       promises.push(Promise.resolve());
-      promises.push(req.client.getComments(getCommentsForm));
+      promises.push(client.getComments(getCommentsForm));
     }
 
     return promises;
   }
 
-  componentDidUpdate(_: any, lastState: State) {
-    if (
-      lastState.dataType !== this.state.dataType ||
-      lastState.sort !== this.state.sort ||
-      lastState.page !== this.state.page
-    ) {
-      this.setState({ postsLoading: true, commentsLoading: true });
-      this.fetchData();
-    }
-  }
-
   get documentTitle(): string {
-    let cRes = this.state.communityRes;
+    const cRes = this.state.communityRes;
     return cRes
-      ? `${cRes.community_view.community.title} - ${this.state.siteRes.site_view.site.name}`
+      ? `${cRes.community_view.community.title} - ${this.isoData.site_res.site_view.site.name}`
       : "";
   }
 
   render() {
-    // For some reason, this returns an empty vec if it matches the site langs
-    let res = this.state.communityRes;
-    let communityLangs =
-      res?.discussion_languages.length == 0
-        ? this.state.siteRes.all_languages.map(l => l.id)
-        : res?.discussion_languages;
+    const res = this.state.communityRes;
+    const { page } = getCommunityQueryParams();
 
     return (
       <div className="container-lg">
@@ -286,7 +269,7 @@ export class Community extends Component<any, State> {
 
               <div className="row">
                 <div className="col-12 col-md-8">
-                  {this.communityInfo()}
+                  {this.communityInfo}
                   <div className="d-block d-md-none">
                     <button
                       className="btn btn-secondary d-inline-block mb-2 mr-3"
@@ -302,55 +285,14 @@ export class Community extends Component<any, State> {
                         classes="icon-inline"
                       />
                     </button>
-                    {this.state.showSidebarMobile && (
-                      <>
-                        <Sidebar
-                          community_view={res.community_view}
-                          moderators={res.moderators}
-                          admins={this.state.siteRes.admins}
-                          online={res.online}
-                          enableNsfw={enableNsfw(this.state.siteRes)}
-                          editable
-                          allLanguages={this.state.siteRes.all_languages}
-                          siteLanguages={
-                            this.state.siteRes.discussion_languages
-                          }
-                          communityLanguages={communityLangs}
-                        />
-                        {!res.community_view.community.local && res.site && (
-                          <SiteSidebar
-                            site={res.site}
-                            showLocal={showLocal(this.isoData)}
-                          />
-                        )}
-                      </>
-                    )}
+                    {this.state.showSidebarMobile && this.sidebar(res)}
                   </div>
-                  {this.selects()}
-                  {this.listings()}
-                  <Paginator
-                    page={this.state.page}
-                    onChange={this.handlePageChange}
-                  />
+                  {this.selects}
+                  {this.listings}
+                  <Paginator page={page} onChange={this.handlePageChange} />
                 </div>
                 <div className="d-none d-md-block col-md-4">
-                  <Sidebar
-                    community_view={res.community_view}
-                    moderators={res.moderators}
-                    admins={this.state.siteRes.admins}
-                    online={res.online}
-                    enableNsfw={enableNsfw(this.state.siteRes)}
-                    editable
-                    allLanguages={this.state.siteRes.all_languages}
-                    siteLanguages={this.state.siteRes.discussion_languages}
-                    communityLanguages={communityLangs}
-                  />
-                  {!res.community_view.community.local && res.site && (
-                    <SiteSidebar
-                      site={res.site}
-                      showLocal={showLocal(this.isoData)}
-                    />
-                  )}
+                  {this.sidebar(res)}
                 </div>
               </div>
             </>
@@ -360,43 +302,82 @@ export class Community extends Component<any, State> {
     );
   }
 
-  listings() {
-    return this.state.dataType == DataType.Post ? (
-      this.state.postsLoading ? (
+  sidebar({
+    community_view,
+    moderators,
+    online,
+    discussion_languages,
+    site,
+  }: 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
+        ? site_res.all_languages.map(({ id }) => id)
+        : discussion_languages;
+
+    return (
+      <>
+        <Sidebar
+          community_view={community_view}
+          moderators={moderators}
+          admins={site_res.admins}
+          online={online}
+          enableNsfw={enableNsfw(site_res)}
+          editable
+          allLanguages={site_res.all_languages}
+          siteLanguages={site_res.discussion_languages}
+          communityLanguages={communityLangs}
+        />
+        {!community_view.community.local && site && (
+          <SiteSidebar site={site} showLocal={showLocal(this.isoData)} />
+        )}
+      </>
+    );
+  }
+
+  get listings() {
+    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={this.state.posts}
+          posts={posts}
           removeDuplicates
-          enableDownvotes={enableDownvotes(this.state.siteRes)}
-          enableNsfw={enableNsfw(this.state.siteRes)}
-          allLanguages={this.state.siteRes.all_languages}
-          siteLanguages={this.state.siteRes.discussion_languages}
+          enableDownvotes={enableDownvotes(site_res)}
+          enableNsfw={enableNsfw(site_res)}
+          allLanguages={site_res.all_languages}
+          siteLanguages={site_res.discussion_languages}
         />
-      )
-    ) : this.state.commentsLoading ? (
-      <h5>
-        <Spinner large />
-      </h5>
-    ) : (
-      <CommentNodes
-        nodes={commentsToFlatNodes(this.state.comments)}
-        viewType={CommentViewType.Flat}
-        noIndent
-        showContext
-        enableDownvotes={enableDownvotes(this.state.siteRes)}
-        moderators={this.state.communityRes?.moderators}
-        admins={this.state.siteRes.admins}
-        allLanguages={this.state.siteRes.all_languages}
-        siteLanguages={this.state.siteRes.discussion_languages}
-      />
-    );
+      );
+    } 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}
+        />
+      );
+    }
   }
 
-  communityInfo() {
-    let community = this.state.communityRes?.community_view.community;
+  get communityInfo() {
+    const community = this.state.communityRes?.community_view.community;
+
     return (
       community && (
         <div className="mb-2">
@@ -414,25 +395,26 @@ export class Community extends Component<any, State> {
     );
   }
 
-  selects() {
+  get selects() {
     // let communityRss = this.state.communityRes.map(r =>
     //   communityRSSUrl(r.community_view.community.actor_id, this.state.sort)
     // );
-    let res = this.state.communityRes;
-    let communityRss = res
-      ? communityRSSUrl(res.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;
 
     return (
       <div className="mb-3">
         <span className="mr-3">
           <DataTypeSelect
-            type_={this.state.dataType}
+            type_={dataType}
             onChange={this.handleDataTypeChange}
           />
         </span>
         <span className="mr-2">
-          <SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
+          <SortSelect sort={sort} onChange={this.handleSortChange} />
         </span>
         {communityRss && (
           <>
@@ -455,66 +437,90 @@ export class Community extends Component<any, State> {
     window.scrollTo(0, 0);
   }
 
-  handleSortChange(val: SortType) {
-    this.updateUrl({ sort: val, page: 1 });
+  handleSortChange(sort: SortType) {
+    this.updateUrl({ sort, page: 1 });
     window.scrollTo(0, 0);
   }
 
-  handleDataTypeChange(val: DataType) {
-    this.updateUrl({ dataType: DataType[val], page: 1 });
+  handleDataTypeChange(dataType: DataType) {
+    this.updateUrl({ dataType, page: 1 });
     window.scrollTo(0, 0);
   }
 
   handleShowSidebarMobile(i: Community) {
-    i.setState({ showSidebarMobile: !i.state.showSidebarMobile });
+    i.setState(({ showSidebarMobile }) => ({
+      showSidebarMobile: !showSidebarMobile,
+    }));
   }
 
-  updateUrl(paramUpdates: UrlParams) {
-    const dataTypeStr = paramUpdates.dataType || DataType[this.state.dataType];
-    const sortStr = paramUpdates.sort || this.state.sort;
-    const page = paramUpdates.page || this.state.page;
-
-    let typeView = `/c/${this.state.communityName}`;
+  updateUrl({ dataType, page, sort }: Partial<CommunityProps>) {
+    const {
+      dataType: urlDataType,
+      page: urlPage,
+      sort: urlSort,
+    } = getCommunityQueryParams();
+
+    const queryParams: QueryParams<CommunityProps> = {
+      dataType: getDataTypeString(dataType ?? urlDataType),
+      page: (page ?? urlPage).toString(),
+      sort: sort ?? urlSort,
+    };
 
     this.props.history.push(
-      `${typeView}/data_type/${dataTypeStr}/sort/${sortStr}/page/${page}`
+      `/c/${this.props.match.params.name}${getQueryString(queryParams)}`
     );
+
+    this.setState({
+      comments: [],
+      posts: [],
+      listingsLoading: true,
+    });
+
+    this.fetchData();
   }
 
   fetchData() {
-    if (this.state.dataType == DataType.Post) {
-      let form: GetPosts = {
-        page: this.state.page,
+    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: this.state.sort,
+        sort,
         type_: ListingType.All,
-        community_name: this.state.communityName,
+        community_name: name,
         saved_only: false,
         auth: myAuth(false),
       };
-      WebSocketService.Instance.send(wsClient.getPosts(form));
+      req = wsClient.getPosts(form);
     } else {
-      let form: GetComments = {
-        page: this.state.page,
+      const form: GetComments = {
+        page,
         limit: fetchLimit,
-        sort: postToCommentSortType(this.state.sort),
+        sort: postToCommentSortType(sort),
         type_: ListingType.All,
-        community_name: this.state.communityName,
+        community_name: name,
         saved_only: false,
         auth: myAuth(false),
       };
-      WebSocketService.Instance.send(wsClient.getComments(form));
+
+      req = wsClient.getComments(form);
     }
+
+    WebSocketService.Instance.send(req);
   }
 
   parseMessage(msg: any) {
-    let op = wsUserOp(msg);
+    const { page } = getCommunityQueryParams();
+    const op = wsUserOp(msg);
     console.log(msg);
-    let res = this.state.communityRes;
+    const res = this.state.communityRes;
+
     if (msg.error) {
       toast(i18n.t(msg.error), "danger");
       this.context.router.history.push("/");
-      return;
     } else if (msg.reconnect) {
       if (res) {
         WebSocketService.Instance.send(
@@ -523,143 +529,225 @@ export class Community extends Component<any, State> {
           })
         );
       }
+
       this.fetchData();
-    } else if (op == UserOperation.GetCommunity) {
-      let 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,
-        })
-      );
-    } else if (
-      op == UserOperation.EditCommunity ||
-      op == UserOperation.DeleteCommunity ||
-      op == UserOperation.RemoveCommunity
-    ) {
-      let data = wsJsonToRes<CommunityResponse>(msg);
-      if (res) {
-        res.community_view = data.community_view;
-        res.discussion_languages = data.discussion_languages;
-      }
-      this.setState(this.state);
-    } else if (op == UserOperation.FollowCommunity) {
-      let data = wsJsonToRes<CommunityResponse>(msg);
-      if (res) {
-        res.community_view.subscribed = data.community_view.subscribed;
-        res.community_view.counts.subscribers =
-          data.community_view.counts.subscribers;
-      }
-      this.setState(this.state);
-    } else if (op == UserOperation.GetPosts) {
-      let data = wsJsonToRes<GetPostsResponse>(msg);
-      this.setState({ posts: data.posts, postsLoading: false });
-      restoreScrollPosition(this.context);
-      setupTippy();
-    } else if (
-      op == UserOperation.EditPost ||
-      op == UserOperation.DeletePost ||
-      op == UserOperation.RemovePost ||
-      op == UserOperation.LockPost ||
-      op == UserOperation.FeaturePost ||
-      op == UserOperation.SavePost
-    ) {
-      let data = wsJsonToRes<PostResponse>(msg);
-      editPostFindRes(data.post_view, this.state.posts);
-      this.setState(this.state);
-    } else if (op == UserOperation.CreatePost) {
-      let data = wsJsonToRes<PostResponse>(msg);
-
-      let showPostNotifs =
-        UserService.Instance.myUserInfo?.local_user_view.local_user
-          .show_new_post_notifs;
-
-      // Only push these if you're on the first page, you pass the nsfw check, and it isn't blocked
-      //
-      if (
-        this.state.page == 1 &&
-        nsfwCheck(data.post_view) &&
-        !isPostBlocked(data.post_view)
-      ) {
-        this.state.posts.unshift(data.post_view);
-        if (showPostNotifs) {
-          notifyPost(data.post_view, this.context.router);
+    } 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,
+            })
+          );
+
+          break;
+        }
+
+        case UserOperation.EditCommunity:
+        case UserOperation.DeleteCommunity:
+        case UserOperation.RemoveCommunity: {
+          const { community_view, discussion_languages } =
+            wsJsonToRes<CommunityResponse>(msg);
+
+          if (res) {
+            res.community_view = community_view;
+            res.discussion_languages = discussion_languages;
+            this.setState(this.state);
+          }
+
+          break;
+        }
+
+        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;
+        }
+
+        case UserOperation.GetPosts: {
+          const { posts } = wsJsonToRes<GetPostsResponse>(msg);
+
+          this.setState({ posts, listingsLoading: false });
+          restoreScrollPosition(this.context);
+          setupTippy();
+
+          break;
+        }
+
+        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;
+        }
+
+        case UserOperation.CreatePost: {
+          const { post_view } = wsJsonToRes<PostResponse>(msg);
+
+          const showPostNotifs =
+            UserService.Instance.myUserInfo?.local_user_view.local_user
+              .show_new_post_notifs;
+
+          // 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);
+          }
+
+          break;
+        }
+
+        case UserOperation.CreatePostLike: {
+          const { post_view } = wsJsonToRes<PostResponse>(msg);
+
+          createPostLikeFindRes(post_view, this.state.posts);
+          this.setState(this.state);
+
+          break;
+        }
+
+        case UserOperation.AddModToCommunity: {
+          const { moderators } = wsJsonToRes<AddModToCommunityResponse>(msg);
+
+          if (res) {
+            res.moderators = moderators;
+            this.setState(this.state);
+          }
+
+          break;
+        }
+
+        case UserOperation.BanFromCommunity: {
+          const {
+            person_view: {
+              person: { id: personId },
+            },
+            banned,
+          } = wsJsonToRes<BanFromCommunityResponse>(msg);
+
+          // TODO this might be incorrect
+          this.state.posts
+            .filter(p => p.creator.id === personId)
+            .forEach(p => (p.creator_banned_from_community = banned));
+
+          this.setState(this.state);
+
+          break;
+        }
+
+        case UserOperation.GetComments: {
+          const { comments } = wsJsonToRes<GetCommentsResponse>(msg);
+          this.setState({ comments, listingsLoading: false });
+
+          break;
+        }
+
+        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);
+
+          break;
+        }
+
+        case UserOperation.CreateComment: {
+          const { form_id, comment_view } = wsJsonToRes<CommentResponse>(msg);
+
+          // Necessary since it might be a user reply
+          if (form_id) {
+            this.setState(({ comments }) => ({
+              comments: [comment_view].concat(comments),
+            }));
+          }
+
+          break;
+        }
+
+        case UserOperation.SaveComment: {
+          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
+
+          saveCommentRes(comment_view, this.state.comments);
+          this.setState(this.state);
+
+          break;
+        }
+
+        case UserOperation.CreateCommentLike: {
+          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
+
+          createCommentLikeRes(comment_view, this.state.comments);
+          this.setState(this.state);
+
+          break;
+        }
+
+        case UserOperation.BlockPerson: {
+          const data = wsJsonToRes<BlockPersonResponse>(msg);
+          updatePersonBlock(data);
+
+          break;
+        }
+
+        case UserOperation.CreatePostReport:
+        case UserOperation.CreateCommentReport: {
+          const data = wsJsonToRes<PostReportResponse>(msg);
+
+          if (data) {
+            toast(i18n.t("report_created"));
+          }
+
+          break;
+        }
+
+        case UserOperation.PurgeCommunity: {
+          const { success } = wsJsonToRes<PurgeItemResponse>(msg);
+
+          if (success) {
+            toast(i18n.t("purge_success"));
+            this.context.router.history.push(`/`);
+          }
+
+          break;
+        }
+
+        case UserOperation.BlockCommunity: {
+          const data = wsJsonToRes<BlockCommunityResponse>(msg);
+          if (res) {
+            res.community_view.blocked = data.blocked;
+            this.setState(this.state);
+          }
+          updateCommunityBlock(data);
+
+          break;
         }
-        this.setState(this.state);
-      }
-    } else if (op == UserOperation.CreatePostLike) {
-      let data = wsJsonToRes<PostResponse>(msg);
-      createPostLikeFindRes(data.post_view, this.state.posts);
-      this.setState(this.state);
-    } else if (op == UserOperation.AddModToCommunity) {
-      let data = wsJsonToRes<AddModToCommunityResponse>(msg);
-      if (res) {
-        res.moderators = data.moderators;
-      }
-      this.setState(this.state);
-    } else if (op == UserOperation.BanFromCommunity) {
-      let data = wsJsonToRes<BanFromCommunityResponse>(msg);
-
-      // TODO this might be incorrect
-      this.state.posts
-        .filter(p => p.creator.id == data.person_view.person.id)
-        .forEach(p => (p.creator_banned_from_community = data.banned));
-
-      this.setState(this.state);
-    } else if (op == UserOperation.GetComments) {
-      let data = wsJsonToRes<GetCommentsResponse>(msg);
-      this.setState({ comments: data.comments, commentsLoading: false });
-    } else if (
-      op == UserOperation.EditComment ||
-      op == UserOperation.DeleteComment ||
-      op == UserOperation.RemoveComment
-    ) {
-      let data = wsJsonToRes<CommentResponse>(msg);
-      editCommentRes(data.comment_view, this.state.comments);
-      this.setState(this.state);
-    } else if (op == UserOperation.CreateComment) {
-      let data = wsJsonToRes<CommentResponse>(msg);
-
-      // Necessary since it might be a user reply
-      if (data.form_id) {
-        this.state.comments.unshift(data.comment_view);
-        this.setState(this.state);
-      }
-    } else if (op == UserOperation.SaveComment) {
-      let data = wsJsonToRes<CommentResponse>(msg);
-      saveCommentRes(data.comment_view, this.state.comments);
-      this.setState(this.state);
-    } else if (op == UserOperation.CreateCommentLike) {
-      let data = wsJsonToRes<CommentResponse>(msg);
-      createCommentLikeRes(data.comment_view, this.state.comments);
-      this.setState(this.state);
-    } else if (op == UserOperation.BlockPerson) {
-      let data = wsJsonToRes<BlockPersonResponse>(msg);
-      updatePersonBlock(data);
-    } else if (op == UserOperation.CreatePostReport) {
-      let data = wsJsonToRes<PostReportResponse>(msg);
-      if (data) {
-        toast(i18n.t("report_created"));
-      }
-    } else if (op == UserOperation.CreateCommentReport) {
-      let data = wsJsonToRes<CommentReportResponse>(msg);
-      if (data) {
-        toast(i18n.t("report_created"));
-      }
-    } else if (op == UserOperation.PurgeCommunity) {
-      let data = wsJsonToRes<PurgeItemResponse>(msg);
-      if (data.success) {
-        toast(i18n.t("purge_success"));
-        this.context.router.history.push(`/`);
-      }
-    } else if (op == UserOperation.BlockCommunity) {
-      let data = wsJsonToRes<BlockCommunityResponse>(msg);
-      if (res) {
-        res.community_view.blocked = data.blocked;
       }
-      updateCommunityBlock(data);
-      this.setState(this.state);
     }
   }
 }
index 688a8e8c57d99ea6f18569f88aaab047c332485d..003f529b72801bf39f3e6407fb71e431ab249012 100644 (file)
@@ -280,7 +280,7 @@ export class Sidebar extends Component<SidebarProps, SidebarState> {
         className={`btn btn-secondary btn-block mb-2 ${
           cv.community.deleted || cv.community.removed ? "no-click" : ""
         }`}
-        to={`/create_post?community_id=${cv.community.id}`}
+        to={`/create_post?communityId=${cv.community.id}`}
       >
         {i18n.t("create_a_post")}
       </Link>
index 1cd231e7d22935f4ec7eb4623f39960350360bdc..c23586a6d5b7d8ae65742f9ccaf212ec3a8b4bac 100644 (file)
@@ -1,4 +1,5 @@
-import { Component, linkEvent } from "inferno";
+import { NoOptionI18nKeys } from "i18next";
+import { Component, linkEvent, MouseEventHandler } from "inferno";
 import { T } from "inferno-i18next-dess";
 import { Link } from "inferno-router";
 import {
@@ -45,11 +46,11 @@ import {
   enableDownvotes,
   enableNsfw,
   fetchLimit,
-  getDataTypeFromProps,
-  getListingTypeFromProps,
-  getPageFromProps,
+  getDataTypeString,
+  getPageFromString,
+  getQueryParams,
+  getQueryString,
   getRandomFromList,
-  getSortTypeFromProps,
   isBrowser,
   isPostBlocked,
   mdToHtml,
@@ -57,8 +58,12 @@ import {
   notifyPost,
   nsfwCheck,
   postToCommentSortType,
+  QueryParams,
   relTags,
   restoreScrollPosition,
+  routeDataTypeToEnum,
+  routeListingTypeToEnum,
+  routeSortTypeToEnum,
   saveCommentRes,
   saveScrollPosition,
   setIsoData,
@@ -86,10 +91,6 @@ interface HomeState {
   siteRes: GetSiteResponse;
   posts: PostView[];
   comments: CommentView[];
-  listingType: ListingType;
-  dataType: DataType;
-  sort: SortType;
-  page: number;
   showSubscribedMobile: boolean;
   showTrendingMobile: boolean;
   showSidebarMobile: boolean;
@@ -105,11 +106,146 @@ interface HomeProps {
   page: number;
 }
 
-interface UrlParams {
-  listingType?: ListingType;
-  dataType?: string;
-  sort?: SortType;
-  page?: number;
+const getDataTypeFromQuery = (type?: string) =>
+  routeDataTypeToEnum(type ?? "", DataType.Post);
+
+function getListingTypeFromQuery(type?: string) {
+  const mui = UserService.Instance.myUserInfo;
+
+  return routeListingTypeToEnum(
+    type ?? "",
+    mui
+      ? Object.values(ListingType)[
+          mui.local_user_view.local_user.default_listing_type
+        ]
+      : ListingType.Local
+  );
+}
+
+function getSortTypeFromQuery(type?: string) {
+  const mui = UserService.Instance.myUserInfo;
+
+  return routeSortTypeToEnum(
+    type ?? "",
+    mui
+      ? Object.values(SortType)[
+          mui.local_user_view.local_user.default_listing_type
+        ]
+      : SortType.Active
+  );
+}
+
+const getHomeQueryParams = () =>
+  getQueryParams<HomeProps>({
+    sort: getSortTypeFromQuery,
+    listingType: getListingTypeFromQuery,
+    page: getPageFromString,
+    dataType: getDataTypeFromQuery,
+  });
+
+function fetchTrendingCommunities() {
+  const listCommunitiesForm: ListCommunities = {
+    type_: ListingType.Local,
+    sort: SortType.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,
+  onClick,
+}: {
+  textKey: NoOptionI18nKeys;
+  show: boolean;
+  onClick: MouseEventHandler<HTMLButtonElement>;
+}) => (
+  <button
+    className="btn btn-secondary d-inline-block mb-2 mr-3"
+    onClick={onClick}
+  >
+    {i18n.t(textKey)}{" "}
+    <Icon icon={show ? `minus-square` : `plus-square`} classes="icon-inline" />
+  </button>
+);
+
+const LinkButton = ({
+  path,
+  translationKey,
+}: {
+  path: string;
+  translationKey: NoOptionI18nKeys;
+}) => (
+  <Link className="btn btn-secondary btn-block" to={path}>
+    {i18n.t(translationKey)}
+  </Link>
+);
+
+function getRss(listingType: ListingType) {
+  const { sort } = getHomeQueryParams();
+  const auth = myAuth(false);
+
+  let rss: string | undefined = undefined;
+
+  switch (listingType) {
+    case ListingType.All: {
+      rss = `/feeds/all.xml?sort=${sort}`;
+      break;
+    }
+    case ListingType.Local: {
+      rss = `/feeds/local.xml?sort=${sort}`;
+      break;
+    }
+    case ListingType.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> {
@@ -125,15 +261,6 @@ export class Home extends Component<any, HomeState> {
     loading: true,
     posts: [],
     comments: [],
-    listingType: getListingTypeFromProps(
-      this.props,
-      ListingType[
-        this.isoData.site_res.site_view.local_site.default_post_listing_type
-      ]
-    ),
-    dataType: getDataTypeFromProps(this.props),
-    sort: getSortTypeFromProps(this.props),
-    page: getPageFromProps(this.props),
   };
 
   constructor(props: any, context: any) {
@@ -148,12 +275,14 @@ export class Home extends Component<any, HomeState> {
     this.subscription = wsSubscribe(this.parseMessage);
 
     // Only fetch the data if coming from another route
-    if (this.isoData.path == this.context.router.route.match.url) {
-      let postsRes = this.isoData.routeData[0] as GetPostsResponse | undefined;
-      let commentsRes = this.isoData.routeData[1] as
+    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;
-      let trendingRes = this.isoData.routeData[2] as
+      const trendingRes = this.isoData.routeData[2] as
         | ListCommunitiesResponse
         | undefined;
 
@@ -178,23 +307,11 @@ export class Home extends Component<any, HomeState> {
         tagline: getRandomFromList(taglines)?.content,
       };
     } else {
-      this.fetchTrendingCommunities();
-      this.fetchData();
+      fetchTrendingCommunities();
+      fetchData();
     }
   }
 
-  fetchTrendingCommunities() {
-    let listCommunitiesForm: ListCommunities = {
-      type_: ListingType.Local,
-      sort: SortType.Hot,
-      limit: trendingFetchLimit,
-      auth: myAuth(false),
-    };
-    WebSocketService.Instance.send(
-      wsClient.listCommunities(listCommunitiesForm)
-    );
-  }
-
   componentDidMount() {
     // This means it hasn't been set up yet
     if (!this.state.siteRes.site_view.local_site.site_setup) {
@@ -208,48 +325,23 @@ export class Home extends Component<any, HomeState> {
     this.subscription?.unsubscribe();
   }
 
-  static getDerivedStateFromProps(
-    props: HomeProps,
-    state: HomeState
-  ): HomeProps {
-    return {
-      listingType: getListingTypeFromProps(props, state.listingType),
-      dataType: getDataTypeFromProps(props),
-      sort: getSortTypeFromProps(props),
-      page: getPageFromProps(props),
-    };
-  }
-
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
-    let pathSplit = req.path.split("/");
-    let dataType: DataType = pathSplit[3]
-      ? DataType[pathSplit[3]]
-      : DataType.Post;
-    let mui = UserService.Instance.myUserInfo;
-    let auth = req.auth;
+  static fetchInitialData({
+    client,
+    auth,
+    query: { dataType: urlDataType, listingType, page: urlPage, sort: urlSort },
+  }: InitialFetchRequest<QueryParams<HomeProps>>): Promise<any>[] {
+    const dataType = getDataTypeFromQuery(urlDataType);
 
     // TODO figure out auth default_listingType, default_sort_type
-    let type_: ListingType = pathSplit[5]
-      ? ListingType[pathSplit[5]]
-      : mui
-      ? Object.values(ListingType)[
-          mui.local_user_view.local_user.default_listing_type
-        ]
-      : ListingType.Local;
-    let sort: SortType = pathSplit[7]
-      ? SortType[pathSplit[7]]
-      : mui
-      ? (Object.values(SortType)[
-          mui.local_user_view.local_user.default_sort_type
-        ] as SortType)
-      : SortType.Active;
+    const type_ = getListingTypeFromQuery(listingType);
+    const sort = getSortTypeFromQuery(urlSort);
 
-    let page = pathSplit[9] ? Number(pathSplit[9]) : 1;
+    const page = urlPage ? Number(urlPage) : 1;
 
-    let promises: Promise<any>[] = [];
+    const promises: Promise<any>[] = [];
 
-    if (dataType == DataType.Post) {
-      let getPostsForm: GetPosts = {
+    if (dataType === DataType.Post) {
+      const getPostsForm: GetPosts = {
         type_,
         page,
         limit: fetchLimit,
@@ -258,10 +350,10 @@ export class Home extends Component<any, HomeState> {
         auth,
       };
 
-      promises.push(req.client.getPosts(getPostsForm));
+      promises.push(client.getPosts(getPostsForm));
       promises.push(Promise.resolve());
     } else {
-      let getCommentsForm: GetComments = {
+      const getCommentsForm: GetComments = {
         page,
         limit: fetchLimit,
         sort: postToCommentSortType(sort),
@@ -270,40 +362,35 @@ export class Home extends Component<any, HomeState> {
         auth,
       };
       promises.push(Promise.resolve());
-      promises.push(req.client.getComments(getCommentsForm));
+      promises.push(client.getComments(getCommentsForm));
     }
 
-    let trendingCommunitiesForm: ListCommunities = {
+    const trendingCommunitiesForm: ListCommunities = {
       type_: ListingType.Local,
       sort: SortType.Hot,
       limit: trendingFetchLimit,
       auth,
     };
-    promises.push(req.client.listCommunities(trendingCommunitiesForm));
+    promises.push(client.listCommunities(trendingCommunitiesForm));
 
     return promises;
   }
 
-  componentDidUpdate(_: any, lastState: HomeState) {
-    if (
-      lastState.listingType !== this.state.listingType ||
-      lastState.dataType !== this.state.dataType ||
-      lastState.sort !== this.state.sort ||
-      lastState.page !== this.state.page
-    ) {
-      this.setState({ loading: true });
-      this.fetchData();
-    }
-  }
-
   get documentTitle(): string {
-    let siteView = this.state.siteRes.site_view;
-    let desc = this.state.siteRes.site_view.site.description;
-    return desc ? `${siteView.site.name} - ${desc}` : siteView.site.name;
+    const { name, description } = this.state.siteRes.site_view.site;
+
+    return description ? `${name} - ${description}` : name;
   }
 
   render() {
-    let tagline = this.state.tagline;
+    const {
+      tagline,
+      siteRes: {
+        site_view: {
+          local_site: { site_setup },
+        },
+      },
+    } = this.state;
 
     return (
       <div className="container-lg">
@@ -311,7 +398,7 @@ export class Home extends Component<any, HomeState> {
           title={this.documentTitle}
           path={this.context.router.route.match.url}
         />
-        {this.state.siteRes.site_view.local_site.site_setup && (
+        {site_setup && (
           <div className="row">
             <main role="main" className="col-12 col-md-8">
               {tagline && (
@@ -320,11 +407,11 @@ export class Home extends Component<any, HomeState> {
                   dangerouslySetInnerHTML={mdToHtml(tagline)}
                 ></div>
               )}
-              <div className="d-block d-md-none">{this.mobileView()}</div>
+              <div className="d-block d-md-none">{this.mobileView}</div>
               {this.posts()}
             </main>
             <aside className="d-none d-md-block col-md-4">
-              {this.mySidebar()}
+              {this.mySidebar}
             </aside>
           </div>
         )}
@@ -333,73 +420,59 @@ export class Home extends Component<any, HomeState> {
   }
 
   get hasFollows(): boolean {
-    let mui = UserService.Instance.myUserInfo;
+    const mui = UserService.Instance.myUserInfo;
     return !!mui && mui.follows.length > 0;
   }
 
-  mobileView() {
-    let siteRes = this.state.siteRes;
-    let siteView = siteRes.site_view;
+  get mobileView() {
+    const {
+      siteRes: {
+        site_view: { counts, site },
+        admins,
+        online,
+      },
+      showSubscribedMobile,
+      showTrendingMobile,
+      showSidebarMobile,
+    } = this.state;
+
     return (
       <div className="row">
         <div className="col-12">
           {this.hasFollows && (
-            <button
-              className="btn btn-secondary d-inline-block mb-2 mr-3"
+            <MobileButton
+              textKey="subscribed"
+              show={showSubscribedMobile}
               onClick={linkEvent(this, this.handleShowSubscribedMobile)}
-            >
-              {i18n.t("subscribed")}{" "}
-              <Icon
-                icon={
-                  this.state.showSubscribedMobile
-                    ? `minus-square`
-                    : `plus-square`
-                }
-                classes="icon-inline"
-              />
-            </button>
+            />
           )}
-          <button
-            className="btn btn-secondary d-inline-block mb-2 mr-3"
+          <MobileButton
+            textKey="trending"
+            show={showTrendingMobile}
             onClick={linkEvent(this, this.handleShowTrendingMobile)}
-          >
-            {i18n.t("trending")}{" "}
-            <Icon
-              icon={
-                this.state.showTrendingMobile ? `minus-square` : `plus-square`
-              }
-              classes="icon-inline"
-            />
-          </button>
-          <button
-            className="btn btn-secondary d-inline-block mb-2 mr-3"
+          />
+          <MobileButton
+            textKey="sidebar"
+            show={showSidebarMobile}
             onClick={linkEvent(this, this.handleShowSidebarMobile)}
-          >
-            {i18n.t("sidebar")}{" "}
-            <Icon
-              icon={
-                this.state.showSidebarMobile ? `minus-square` : `plus-square`
-              }
-              classes="icon-inline"
-            />
-          </button>
-          {this.state.showSidebarMobile && (
+          />
+          {showSidebarMobile && (
             <SiteSidebar
-              site={siteView.site}
-              admins={siteRes.admins}
-              counts={siteView.counts}
-              online={siteRes.online}
+              site={site}
+              admins={admins}
+              counts={counts}
+              online={online}
               showLocal={showLocal(this.isoData)}
             />
           )}
-          {this.state.showTrendingMobile && (
+          {showTrendingMobile && (
             <div className="col-12 card border-secondary mb-3">
-              <div className="card-body">{this.trendingCommunities()}</div>
+              <div className="card-body">{this.trendingCommunities(true)}</div>
             </div>
           )}
-          {this.state.showSubscribedMobile && (
+          {showSubscribedMobile && (
             <div className="col-12 card border-secondary mb-3">
-              <div className="card-body">{this.subscribedCommunities()}</div>
+              <div className="card-body">{this.subscribedCommunities}</div>
             </div>
           )}
         </div>
@@ -407,31 +480,45 @@ export class Home extends Component<any, HomeState> {
     );
   }
 
-  mySidebar() {
-    let siteRes = this.state.siteRes;
-    let siteView = siteRes.site_view;
+  get mySidebar() {
+    const {
+      siteRes: {
+        site_view: { counts, site },
+        admins,
+        online,
+      },
+      loading,
+    } = this.state;
+
     return (
       <div>
-        {!this.state.loading && (
+        {!loading && (
           <div>
             <div className="card border-secondary mb-3">
               <div className="card-body">
                 {this.trendingCommunities()}
-                {canCreateCommunity(this.state.siteRes) &&
-                  this.createCommunityButton()}
-                {this.exploreCommunitiesButton()}
+                {canCreateCommunity(this.state.siteRes) && (
+                  <LinkButton
+                    path="/create_community"
+                    translationKey="create_a_community"
+                  />
+                )}
+                <LinkButton
+                  path="/communities"
+                  translationKey="explore_communities"
+                />
               </div>
             </div>
             <SiteSidebar
-              site={siteView.site}
-              admins={siteRes.admins}
-              counts={siteView.counts}
-              online={siteRes.online}
+              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 className="card-body">{this.subscribedCommunities}</div>
               </div>
             )}
           </div>
@@ -440,25 +527,9 @@ export class Home extends Component<any, HomeState> {
     );
   }
 
-  createCommunityButton() {
+  trendingCommunities(isMobile = false) {
     return (
-      <Link className="mt-2 btn btn-secondary btn-block" to="/create_community">
-        {i18n.t("create_a_community")}
-      </Link>
-    );
-  }
-
-  exploreCommunitiesButton() {
-    return (
-      <Link className="btn btn-secondary btn-block" to="/communities">
-        {i18n.t("explore_communities")}
-      </Link>
-    );
-  }
-
-  trendingCommunities() {
-    return (
-      <div>
+      <div className={!isMobile ? "mb-2" : ""}>
         <h5>
           <T i18nKey="trending_communities">
             #
@@ -481,7 +552,9 @@ export class Home extends Component<any, HomeState> {
     );
   }
 
-  subscribedCommunities() {
+  get subscribedCommunities() {
+    const { subscribedCollapsed } = this.state;
+
     return (
       <div>
         <h5>
@@ -497,14 +570,13 @@ export class Home extends Component<any, HomeState> {
             aria-label={i18n.t("collapse")}
             data-tippy-content={i18n.t("collapse")}
           >
-            {this.state.subscribedCollapsed ? (
-              <Icon icon="plus-square" classes="icon-inline" />
-            ) : (
-              <Icon icon="minus-square" classes="icon-inline" />
-            )}
+            <Icon
+              icon={`${subscribedCollapsed ? "plus" : "minus"}-square`}
+              classes="icon-inline"
+            />
           </button>
         </h5>
-        {!this.state.subscribedCollapsed && (
+        {!subscribedCollapsed && (
           <ul className="list-inline mb-0">
             {UserService.Instance.myUserInfo?.follows.map(cfv => (
               <li
@@ -520,17 +592,38 @@ export class Home extends Component<any, HomeState> {
     );
   }
 
-  updateUrl(paramUpdates: UrlParams) {
-    const listingTypeStr = paramUpdates.listingType || this.state.listingType;
-    const dataTypeStr = paramUpdates.dataType || DataType[this.state.dataType];
-    const sortStr = paramUpdates.sort || this.state.sort;
-    const page = paramUpdates.page || this.state.page;
-    this.props.history.push(
-      `/home/data_type/${dataTypeStr}/listing_type/${listingTypeStr}/sort/${sortStr}/page/${page}`
-    );
+  updateUrl({ dataType, listingType, page, sort }: Partial<HomeProps>) {
+    const {
+      dataType: urlDataType,
+      listingType: urlListingType,
+      page: urlPage,
+      sort: urlSort,
+    } = getHomeQueryParams();
+
+    const queryParams: QueryParams<HomeProps> = {
+      dataType: getDataTypeString(dataType ?? urlDataType),
+      listingType: listingType ?? urlListingType,
+      page: (page ?? urlPage).toString(),
+      sort: sort ?? urlSort,
+    };
+
+    this.props.history.push({
+      pathname: "/",
+      search: getQueryString(queryParams),
+    });
+
+    this.setState({
+      loading: true,
+      posts: [],
+      comments: [],
+    });
+
+    fetchData();
   }
 
   posts() {
+    const { page } = getHomeQueryParams();
+
     return (
       <div className="main-content-wrapper">
         {this.state.loading ? (
@@ -540,93 +633,65 @@ export class Home extends Component<any, HomeState> {
         ) : (
           <div>
             {this.selects()}
-            {this.listings()}
-            <Paginator
-              page={this.state.page}
-              onChange={this.handlePageChange}
-            />
+            {this.listings}
+            <Paginator page={page} onChange={this.handlePageChange} />
           </div>
         )}
       </div>
     );
   }
 
-  listings() {
-    return this.state.dataType == DataType.Post ? (
+  get listings() {
+    const { dataType } = getHomeQueryParams();
+    const { siteRes, posts, comments } = this.state;
+
+    return dataType === DataType.Post ? (
       <PostListings
-        posts={this.state.posts}
+        posts={posts}
         showCommunity
         removeDuplicates
-        enableDownvotes={enableDownvotes(this.state.siteRes)}
-        enableNsfw={enableNsfw(this.state.siteRes)}
-        allLanguages={this.state.siteRes.all_languages}
-        siteLanguages={this.state.siteRes.discussion_languages}
+        enableDownvotes={enableDownvotes(siteRes)}
+        enableNsfw={enableNsfw(siteRes)}
+        allLanguages={siteRes.all_languages}
+        siteLanguages={siteRes.discussion_languages}
       />
     ) : (
       <CommentNodes
-        nodes={commentsToFlatNodes(this.state.comments)}
+        nodes={commentsToFlatNodes(comments)}
         viewType={CommentViewType.Flat}
         noIndent
         showCommunity
         showContext
-        enableDownvotes={enableDownvotes(this.state.siteRes)}
-        allLanguages={this.state.siteRes.all_languages}
-        siteLanguages={this.state.siteRes.discussion_languages}
+        enableDownvotes={enableDownvotes(siteRes)}
+        allLanguages={siteRes.all_languages}
+        siteLanguages={siteRes.discussion_languages}
       />
     );
   }
 
   selects() {
-    let allRss = `/feeds/all.xml?sort=${this.state.sort}`;
-    let localRss = `/feeds/local.xml?sort=${this.state.sort}`;
-    let auth = myAuth(false);
-    let frontRss = auth
-      ? `/feeds/front/${auth}.xml?sort=${this.state.sort}`
-      : undefined;
+    const { listingType, dataType, sort } = getHomeQueryParams();
 
     return (
       <div className="mb-3">
         <span className="mr-3">
           <DataTypeSelect
-            type_={this.state.dataType}
+            type_={dataType}
             onChange={this.handleDataTypeChange}
           />
         </span>
         <span className="mr-3">
           <ListingTypeSelect
-            type_={this.state.listingType}
+            type_={listingType}
             showLocal={showLocal(this.isoData)}
             showSubscribed
             onChange={this.handleListingTypeChange}
           />
         </span>
         <span className="mr-2">
-          <SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
+          <SortSelect sort={sort} onChange={this.handleSortChange} />
         </span>
-        {this.state.listingType == ListingType.All && (
-          <>
-            <a href={allRss} rel={relTags} title="RSS">
-              <Icon icon="rss" classes="text-muted small" />
-            </a>
-            <link rel="alternate" type="application/atom+xml" href={allRss} />
-          </>
-        )}
-        {this.state.listingType == ListingType.Local && (
-          <>
-            <a href={localRss} rel={relTags} title="RSS">
-              <Icon icon="rss" classes="text-muted small" />
-            </a>
-            <link rel="alternate" type="application/atom+xml" href={localRss} />
-          </>
-        )}
-        {this.state.listingType == ListingType.Subscribed && frontRss && (
-          <>
-            <a href={frontRss} title="RSS" rel={relTags}>
-              <Icon icon="rss" classes="text-muted small" />
-            </a>
-            <link rel="alternate" type="application/atom+xml" href={frontRss} />
-          </>
-        )}
+        {getRss(listingType)}
       </div>
     );
   }
@@ -663,188 +728,232 @@ export class Home extends Component<any, HomeState> {
   }
 
   handleDataTypeChange(val: DataType) {
-    this.updateUrl({ dataType: DataType[val], page: 1 });
+    this.updateUrl({ dataType: val, page: 1 });
     window.scrollTo(0, 0);
   }
 
-  fetchData() {
-    let auth = myAuth(false);
-    if (this.state.dataType == DataType.Post) {
-      let getPostsForm: GetPosts = {
-        page: this.state.page,
-        limit: fetchLimit,
-        sort: this.state.sort,
-        saved_only: false,
-        type_: this.state.listingType,
-        auth,
-      };
-
-      WebSocketService.Instance.send(wsClient.getPosts(getPostsForm));
-    } else {
-      let getCommentsForm: GetComments = {
-        page: this.state.page,
-        limit: fetchLimit,
-        sort: postToCommentSortType(this.state.sort),
-        saved_only: false,
-        type_: this.state.listingType,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.getComments(getCommentsForm));
-    }
-  }
-
   parseMessage(msg: any) {
-    let op = wsUserOp(msg);
+    const op = wsUserOp(msg);
     console.log(msg);
+
     if (msg.error) {
       toast(i18n.t(msg.error), "danger");
-      return;
     } else if (msg.reconnect) {
       WebSocketService.Instance.send(
         wsClient.communityJoin({ community_id: 0 })
       );
-      this.fetchData();
-    } else if (op == UserOperation.ListCommunities) {
-      let data = wsJsonToRes<ListCommunitiesResponse>(msg);
-      this.setState({ trendingCommunities: data.communities });
-    } else if (op == UserOperation.EditSite) {
-      let data = wsJsonToRes<SiteResponse>(msg);
-      this.setState(s => ((s.siteRes.site_view = data.site_view), s));
-      toast(i18n.t("site_saved"));
-    } else if (op == UserOperation.GetPosts) {
-      let data = wsJsonToRes<GetPostsResponse>(msg);
-      this.setState({ posts: data.posts, loading: false });
-      WebSocketService.Instance.send(
-        wsClient.communityJoin({ community_id: 0 })
-      );
-      restoreScrollPosition(this.context);
-      setupTippy();
-    } else if (op == UserOperation.CreatePost) {
-      let data = wsJsonToRes<PostResponse>(msg);
-      let mui = UserService.Instance.myUserInfo;
-
-      let showPostNotifs = mui?.local_user_view.local_user.show_new_post_notifs;
-
-      // Only push these if you're on the first page, you pass the nsfw check, and it isn't blocked
-      if (
-        this.state.page == 1 &&
-        nsfwCheck(data.post_view) &&
-        !isPostBlocked(data.post_view)
-      ) {
-        // If you're on subscribed, only push it if you're subscribed.
-        if (this.state.listingType == ListingType.Subscribed) {
-          if (
-            mui?.follows
-              .map(c => c.community.id)
-              .includes(data.post_view.community.id)
-          ) {
-            this.state.posts.unshift(data.post_view);
-            if (showPostNotifs) {
-              notifyPost(data.post_view, this.context.router);
+      fetchData();
+    } else {
+      switch (op) {
+        case UserOperation.ListCommunities: {
+          const { communities } = wsJsonToRes<ListCommunitiesResponse>(msg);
+          this.setState({ trendingCommunities: communities });
+
+          break;
+        }
+
+        case UserOperation.EditSite: {
+          const { site_view } = wsJsonToRes<SiteResponse>(msg);
+          this.setState(s => ((s.siteRes.site_view = site_view), s));
+          toast(i18n.t("site_saved"));
+
+          break;
+        }
+
+        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();
+
+          break;
+        }
+
+        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 ListingType.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 ListingType.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);
+              }
             }
           }
-        } else if (this.state.listingType == ListingType.Local) {
-          // If you're on the local view, only push it if its local
-          if (data.post_view.post.local) {
-            this.state.posts.unshift(data.post_view);
-            if (showPostNotifs) {
-              notifyPost(data.post_view, this.context.router);
+
+          break;
+        }
+
+        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;
+        }
+
+        case UserOperation.CreatePostLike: {
+          const { post_view } = wsJsonToRes<PostResponse>(msg);
+          createPostLikeFindRes(post_view, this.state.posts);
+          this.setState(this.state);
+
+          break;
+        }
+
+        case UserOperation.AddAdmin: {
+          const { admins } = wsJsonToRes<AddAdminResponse>(msg);
+          this.setState(s => ((s.siteRes.admins = admins), s));
+
+          break;
+        }
+
+        case UserOperation.BanPerson: {
+          const {
+            banned,
+            person_view: {
+              person: { id },
+            },
+          } = wsJsonToRes<BanPersonResponse>(msg);
+
+          this.state.posts
+            .filter(p => p.creator.id == id)
+            .forEach(p => (p.creator.banned = banned));
+          this.setState(this.state);
+
+          break;
+        }
+
+        case UserOperation.GetComments: {
+          const { comments } = wsJsonToRes<GetCommentsResponse>(msg);
+          this.setState({ comments, loading: false });
+
+          break;
+        }
+
+        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);
+
+          break;
+        }
+
+        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 === 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),
+              }));
             }
           }
-        } else {
-          this.state.posts.unshift(data.post_view);
-          if (showPostNotifs) {
-            notifyPost(data.post_view, this.context.router);
+
+          break;
+        }
+
+        case UserOperation.SaveComment: {
+          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
+          saveCommentRes(comment_view, this.state.comments);
+          this.setState(this.state);
+
+          break;
+        }
+
+        case UserOperation.CreateCommentLike: {
+          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
+          createCommentLikeRes(comment_view, this.state.comments);
+          this.setState(this.state);
+
+          break;
+        }
+
+        case UserOperation.BlockPerson: {
+          const data = wsJsonToRes<BlockPersonResponse>(msg);
+          updatePersonBlock(data);
+
+          break;
+        }
+
+        case UserOperation.CreatePostReport: {
+          const data = wsJsonToRes<PostReportResponse>(msg);
+          if (data) {
+            toast(i18n.t("report_created"));
           }
+
+          break;
         }
-        this.setState(this.state);
-      }
-    } else if (
-      op == UserOperation.EditPost ||
-      op == UserOperation.DeletePost ||
-      op == UserOperation.RemovePost ||
-      op == UserOperation.LockPost ||
-      op == UserOperation.FeaturePost ||
-      op == UserOperation.SavePost
-    ) {
-      let data = wsJsonToRes<PostResponse>(msg);
-      editPostFindRes(data.post_view, this.state.posts);
-      this.setState(this.state);
-    } else if (op == UserOperation.CreatePostLike) {
-      let data = wsJsonToRes<PostResponse>(msg);
-      createPostLikeFindRes(data.post_view, this.state.posts);
-      this.setState(this.state);
-    } else if (op == UserOperation.AddAdmin) {
-      let data = wsJsonToRes<AddAdminResponse>(msg);
-      this.setState(s => ((s.siteRes.admins = data.admins), s));
-    } else if (op == UserOperation.BanPerson) {
-      let data = wsJsonToRes<BanPersonResponse>(msg);
-      this.state.posts
-        .filter(p => p.creator.id == data.person_view.person.id)
-        .forEach(p => (p.creator.banned = data.banned));
-
-      this.setState(this.state);
-    } else if (op == UserOperation.GetComments) {
-      let data = wsJsonToRes<GetCommentsResponse>(msg);
-      this.setState({ comments: data.comments, loading: false });
-    } else if (
-      op == UserOperation.EditComment ||
-      op == UserOperation.DeleteComment ||
-      op == UserOperation.RemoveComment
-    ) {
-      let data = wsJsonToRes<CommentResponse>(msg);
-      editCommentRes(data.comment_view, this.state.comments);
-      this.setState(this.state);
-    } else if (op == UserOperation.CreateComment) {
-      let data = wsJsonToRes<CommentResponse>(msg);
-
-      // Necessary since it might be a user reply
-      if (data.form_id) {
-        // If you're on subscribed, only push it if you're subscribed.
-        if (this.state.listingType == ListingType.Subscribed) {
-          if (
-            UserService.Instance.myUserInfo?.follows
-              .map(c => c.community.id)
-              .includes(data.comment_view.community.id)
-          ) {
-            this.state.comments.unshift(data.comment_view);
+
+        case UserOperation.CreateCommentReport: {
+          const data = wsJsonToRes<CommentReportResponse>(msg);
+          if (data) {
+            toast(i18n.t("report_created"));
           }
-        } else {
-          this.state.comments.unshift(data.comment_view);
+
+          break;
+        }
+
+        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;
         }
-        this.setState(this.state);
-      }
-    } else if (op == UserOperation.SaveComment) {
-      let data = wsJsonToRes<CommentResponse>(msg);
-      saveCommentRes(data.comment_view, this.state.comments);
-      this.setState(this.state);
-    } else if (op == UserOperation.CreateCommentLike) {
-      let data = wsJsonToRes<CommentResponse>(msg);
-      createCommentLikeRes(data.comment_view, this.state.comments);
-      this.setState(this.state);
-    } else if (op == UserOperation.BlockPerson) {
-      let data = wsJsonToRes<BlockPersonResponse>(msg);
-      updatePersonBlock(data);
-    } else if (op == UserOperation.CreatePostReport) {
-      let data = wsJsonToRes<PostReportResponse>(msg);
-      if (data) {
-        toast(i18n.t("report_created"));
-      }
-    } else if (op == UserOperation.CreateCommentReport) {
-      let data = wsJsonToRes<CommentReportResponse>(msg);
-      if (data) {
-        toast(i18n.t("report_created"));
-      }
-    } else if (
-      op == UserOperation.PurgePerson ||
-      op == UserOperation.PurgePost ||
-      op == UserOperation.PurgeComment ||
-      op == UserOperation.PurgeCommunity
-    ) {
-      let data = wsJsonToRes<PurgeItemResponse>(msg);
-      if (data.success) {
-        toast(i18n.t("purge_success"));
-        this.context.router.history.push(`/`);
       }
     }
   }
index 140d56a8024c089e11a8c6c3366dbb23fd26c291..af717fc5b870a5f9ff7d015f3863d6993353cd50 100644 (file)
@@ -1,5 +1,7 @@
+import { NoOptionI18nKeys } from "i18next";
 import { Component, linkEvent } from "inferno";
 import { Link } from "inferno-router";
+import { RouteComponentProps } from "inferno-router/dist/Route";
 import {
   AdminPurgeCommentView,
   AdminPurgeCommunityView,
@@ -10,7 +12,8 @@ import {
   GetCommunityResponse,
   GetModlog,
   GetModlogResponse,
-  GetSiteResponse,
+  GetPersonDetails,
+  GetPersonDetailsResponse,
   ModAddCommunityView,
   ModAddView,
   ModBanFromCommunityView,
@@ -35,12 +38,19 @@ import { WebSocketService } from "../services";
 import {
   amAdmin,
   amMod,
-  choicesConfig,
+  Choice,
   debounce,
   fetchLimit,
   fetchUsers,
+  getIdFromString,
+  getPageFromString,
+  getQueryParams,
+  getQueryString,
+  getUpdatedSearchId,
   isBrowser,
   myAuth,
+  personToChoice,
+  QueryParams,
   setIsoData,
   toast,
   wsClient,
@@ -50,514 +60,670 @@ import { HtmlTags } from "./common/html-tags";
 import { Spinner } from "./common/icon";
 import { MomentTime } from "./common/moment-time";
 import { Paginator } from "./common/paginator";
+import { SearchableSelect } from "./common/searchable-select";
 import { CommunityLink } from "./community/community-link";
 import { PersonListing } from "./person/person-listing";
-type ModlogType = {
+
+type FilterType = "mod" | "user";
+
+type View =
+  | ModRemovePostView
+  | ModLockPostView
+  | ModFeaturePostView
+  | ModRemoveCommentView
+  | ModRemoveCommunityView
+  | ModBanFromCommunityView
+  | ModBanView
+  | ModAddCommunityView
+  | ModTransferCommunityView
+  | ModAddView
+  | AdminPurgePersonView
+  | AdminPurgeCommunityView
+  | AdminPurgePostView
+  | AdminPurgeCommentView;
+
+interface ModlogType {
   id: number;
   type_: ModlogActionType;
   moderator?: PersonSafe;
-  view:
-    | ModRemovePostView
-    | ModLockPostView
-    | ModFeaturePostView
-    | ModRemoveCommentView
-    | ModRemoveCommunityView
-    | ModBanFromCommunityView
-    | ModBanView
-    | ModAddCommunityView
-    | ModTransferCommunityView
-    | ModAddView
-    | AdminPurgePersonView
-    | AdminPurgeCommunityView
-    | AdminPurgePostView
-    | AdminPurgeCommentView;
+  view: View;
   when_: string;
-};
-var Choices: any;
-if (isBrowser()) {
-  Choices = require("choices.js");
 }
 
+const getModlogQueryParams = () =>
+  getQueryParams<ModlogProps>({
+    actionType: getActionFromString,
+    modId: getIdFromString,
+    userId: getIdFromString,
+    page: getPageFromString,
+  });
+
 interface ModlogState {
   res?: GetModlogResponse;
-  communityId?: number;
   communityMods?: CommunityModeratorView[];
   communityName?: string;
-  page: number;
-  siteRes: GetSiteResponse;
-  loading: boolean;
-  filter_action: ModlogActionType;
-  filter_user?: number;
-  filter_mod?: number;
+  loadingModlog: boolean;
+  loadingModSearch: boolean;
+  loadingUserSearch: boolean;
+  modSearchOptions: Choice[];
+  userSearchOptions: Choice[];
 }
 
-export class Modlog extends Component<any, ModlogState> {
-  private isoData = setIsoData(this.context);
-  private subscription?: Subscription;
-  private userChoices: any;
-  private modChoices: any;
-  state: ModlogState = {
-    page: 1,
-    loading: true,
-    siteRes: this.isoData.site_res,
-    filter_action: ModlogActionType.All,
-  };
+interface ModlogProps {
+  page: number;
+  userId?: number | null;
+  modId?: number | null;
+  actionType: ModlogActionType;
+}
 
-  constructor(props: any, context: any) {
-    super(props, context);
-    this.handlePageChange = this.handlePageChange.bind(this);
+const getActionFromString = (action?: string) =>
+  action
+    ? ModlogActionType[action] ?? ModlogActionType.All
+    : ModlogActionType.All;
 
-    this.parseMessage = this.parseMessage.bind(this);
-    this.subscription = wsSubscribe(this.parseMessage);
+const getModlogActionMapper =
+  (
+    actionType: ModlogActionType,
+    getAction: (view: View) => { id: number; when_: string }
+  ) =>
+  (view: View & { moderator?: PersonSafe; admin?: PersonSafe }): ModlogType => {
+    const { id, when_ } = getAction(view);
 
-    this.state = {
-      ...this.state,
-      communityId: this.props.match.params.community_id
-        ? Number(this.props.match.params.community_id)
-        : undefined,
+    return {
+      id,
+      type_: actionType,
+      view,
+      when_,
+      moderator: view.moderator ?? view.admin,
     };
+  };
 
-    // Only fetch the data if coming from another route
-    if (this.isoData.path == this.context.router.route.match.url) {
-      this.state = {
-        ...this.state,
-        res: this.isoData.routeData[0] as GetModlogResponse,
-      };
+function buildCombined({
+  removed_comments,
+  locked_posts,
+  featured_posts,
+  removed_communities,
+  removed_posts,
+  added,
+  added_to_community,
+  admin_purged_comments,
+  admin_purged_communities,
+  admin_purged_persons,
+  admin_purged_posts,
+  banned,
+  banned_from_community,
+  transferred_to_community,
+}: GetModlogResponse): ModlogType[] {
+  const combined = removed_posts
+    .map(
+      getModlogActionMapper(
+        ModlogActionType.ModRemovePost,
+        ({ mod_remove_post }: ModRemovePostView) => mod_remove_post
+      )
+    )
+    .concat(
+      locked_posts.map(
+        getModlogActionMapper(
+          ModlogActionType.ModLockPost,
+          ({ mod_lock_post }: ModLockPostView) => mod_lock_post
+        )
+      )
+    )
+    .concat(
+      featured_posts.map(
+        getModlogActionMapper(
+          ModlogActionType.ModFeaturePost,
+          ({ mod_feature_post }: ModFeaturePostView) => mod_feature_post
+        )
+      )
+    )
+    .concat(
+      removed_comments.map(
+        getModlogActionMapper(
+          ModlogActionType.ModRemoveComment,
+          ({ mod_remove_comment }: ModRemoveCommentView) => mod_remove_comment
+        )
+      )
+    )
+    .concat(
+      removed_communities.map(
+        getModlogActionMapper(
+          ModlogActionType.ModRemoveCommunity,
+          ({ mod_remove_community }: ModRemoveCommunityView) =>
+            mod_remove_community
+        )
+      )
+    )
+    .concat(
+      banned_from_community.map(
+        getModlogActionMapper(
+          ModlogActionType.ModBanFromCommunity,
+          ({ mod_ban_from_community }: ModBanFromCommunityView) =>
+            mod_ban_from_community
+        )
+      )
+    )
+    .concat(
+      added_to_community.map(
+        getModlogActionMapper(
+          ModlogActionType.ModAddCommunity,
+          ({ mod_add_community }: ModAddCommunityView) => mod_add_community
+        )
+      )
+    )
+    .concat(
+      transferred_to_community.map(
+        getModlogActionMapper(
+          ModlogActionType.ModTransferCommunity,
+          ({ mod_transfer_community }: ModTransferCommunityView) =>
+            mod_transfer_community
+        )
+      )
+    )
+    .concat(
+      added.map(
+        getModlogActionMapper(
+          ModlogActionType.ModAdd,
+          ({ mod_add }: ModAddView) => mod_add
+        )
+      )
+    )
+    .concat(
+      banned.map(
+        getModlogActionMapper(
+          ModlogActionType.ModBan,
+          ({ mod_ban }: ModBanView) => mod_ban
+        )
+      )
+    )
+    .concat(
+      admin_purged_persons.map(
+        getModlogActionMapper(
+          ModlogActionType.AdminPurgePerson,
+          ({ admin_purge_person }: AdminPurgePersonView) => admin_purge_person
+        )
+      )
+    )
+    .concat(
+      admin_purged_communities.map(
+        getModlogActionMapper(
+          ModlogActionType.AdminPurgeCommunity,
+          ({ admin_purge_community }: AdminPurgeCommunityView) =>
+            admin_purge_community
+        )
+      )
+    )
+    .concat(
+      admin_purged_posts.map(
+        getModlogActionMapper(
+          ModlogActionType.AdminPurgePost,
+          ({ admin_purge_post }: AdminPurgePostView) => admin_purge_post
+        )
+      )
+    )
+    .concat(
+      admin_purged_comments.map(
+        getModlogActionMapper(
+          ModlogActionType.AdminPurgeComment,
+          ({ admin_purge_comment }: AdminPurgeCommentView) =>
+            admin_purge_comment
+        )
+      )
+    );
 
-      let communityRes: GetCommunityResponse | undefined =
-        this.isoData.routeData[1];
+  // Sort them by time
+  combined.sort((a, b) => b.when_.localeCompare(a.when_));
 
-      // Getting the moderators
-      this.state = {
-        ...this.state,
-        communityMods: communityRes?.moderators,
-      };
+  return combined;
+}
 
-      this.state = { ...this.state, loading: false };
-    } else {
-      this.refetch();
+function renderModlogType({ type_, view }: ModlogType) {
+  switch (type_) {
+    case ModlogActionType.ModRemovePost: {
+      const mrpv = view as ModRemovePostView;
+      const {
+        mod_remove_post: { reason, removed },
+        post: { name, id },
+      } = mrpv;
+
+      return (
+        <>
+          <span>{removed ? "Removed " : "Restored "}</span>
+          <span>
+            Post <Link to={`/post/${id}`}>{name}</Link>
+          </span>
+          {reason && (
+            <span>
+              <div>reason: {reason}</div>
+            </span>
+          )}
+        </>
+      );
     }
-  }
 
-  componentDidMount() {
-    this.setupUserFilter();
-    this.setupModFilter();
-  }
+    case ModlogActionType.ModLockPost: {
+      const {
+        mod_lock_post: { locked },
+        post: { id, name },
+      } = view as ModLockPostView;
 
-  componentWillUnmount() {
-    if (isBrowser()) {
-      this.subscription?.unsubscribe();
+      return (
+        <>
+          <span>{locked ? "Locked " : "Unlocked "}</span>
+          <span>
+            Post <Link to={`/post/${id}`}>{name}</Link>
+          </span>
+        </>
+      );
     }
-  }
-
-  buildCombined(res: GetModlogResponse): ModlogType[] {
-    let removed_posts: ModlogType[] = res.removed_posts.map(r => ({
-      id: r.mod_remove_post.id,
-      type_: ModlogActionType.ModRemovePost,
-      view: r,
-      moderator: r.moderator,
-      when_: r.mod_remove_post.when_,
-    }));
-
-    let locked_posts: ModlogType[] = res.locked_posts.map(r => ({
-      id: r.mod_lock_post.id,
-      type_: ModlogActionType.ModLockPost,
-      view: r,
-      moderator: r.moderator,
-      when_: r.mod_lock_post.when_,
-    }));
-
-    let featured_posts: ModlogType[] = res.featured_posts.map(r => ({
-      id: r.mod_feature_post.id,
-      type_: ModlogActionType.ModFeaturePost,
-      view: r,
-      moderator: r.moderator,
-      when_: r.mod_feature_post.when_,
-    }));
-
-    let removed_comments: ModlogType[] = res.removed_comments.map(r => ({
-      id: r.mod_remove_comment.id,
-      type_: ModlogActionType.ModRemoveComment,
-      view: r,
-      moderator: r.moderator,
-      when_: r.mod_remove_comment.when_,
-    }));
-
-    let removed_communities: ModlogType[] = res.removed_communities.map(r => ({
-      id: r.mod_remove_community.id,
-      type_: ModlogActionType.ModRemoveCommunity,
-      view: r,
-      moderator: r.moderator,
-      when_: r.mod_remove_community.when_,
-    }));
-
-    let banned_from_community: ModlogType[] = res.banned_from_community.map(
-      r => ({
-        id: r.mod_ban_from_community.id,
-        type_: ModlogActionType.ModBanFromCommunity,
-        view: r,
-        moderator: r.moderator,
-        when_: r.mod_ban_from_community.when_,
-      })
-    );
 
-    let added_to_community: ModlogType[] = res.added_to_community.map(r => ({
-      id: r.mod_add_community.id,
-      type_: ModlogActionType.ModAddCommunity,
-      view: r,
-      moderator: r.moderator,
-      when_: r.mod_add_community.when_,
-    }));
-
-    let transferred_to_community: ModlogType[] =
-      res.transferred_to_community.map(r => ({
-        id: r.mod_transfer_community.id,
-        type_: ModlogActionType.ModTransferCommunity,
-        view: r,
-        moderator: r.moderator,
-        when_: r.mod_transfer_community.when_,
-      }));
-
-    let added: ModlogType[] = res.added.map(r => ({
-      id: r.mod_add.id,
-      type_: ModlogActionType.ModAdd,
-      view: r,
-      moderator: r.moderator,
-      when_: r.mod_add.when_,
-    }));
-
-    let banned: ModlogType[] = res.banned.map(r => ({
-      id: r.mod_ban.id,
-      type_: ModlogActionType.ModBan,
-      view: r,
-      moderator: r.moderator,
-      when_: r.mod_ban.when_,
-    }));
-
-    let purged_persons: ModlogType[] = res.admin_purged_persons.map(r => ({
-      id: r.admin_purge_person.id,
-      type_: ModlogActionType.AdminPurgePerson,
-      view: r,
-      moderator: r.admin,
-      when_: r.admin_purge_person.when_,
-    }));
-
-    let purged_communities: ModlogType[] = res.admin_purged_communities.map(
-      r => ({
-        id: r.admin_purge_community.id,
-        type_: ModlogActionType.AdminPurgeCommunity,
-        view: r,
-        moderator: r.admin,
-        when_: r.admin_purge_community.when_,
-      })
-    );
+    case ModlogActionType.ModFeaturePost: {
+      const {
+        mod_feature_post: { featured, is_featured_community },
+        post: { id, name },
+      } = view as ModFeaturePostView;
 
-    let purged_posts: ModlogType[] = res.admin_purged_posts.map(r => ({
-      id: r.admin_purge_post.id,
-      type_: ModlogActionType.AdminPurgePost,
-      view: r,
-      moderator: r.admin,
-      when_: r.admin_purge_post.when_,
-    }));
-
-    let purged_comments: ModlogType[] = res.admin_purged_comments.map(r => ({
-      id: r.admin_purge_comment.id,
-      type_: ModlogActionType.AdminPurgeComment,
-      view: r,
-      moderator: r.admin,
-      when_: r.admin_purge_comment.when_,
-    }));
-
-    let combined: ModlogType[] = [];
-
-    combined.push(...removed_posts);
-    combined.push(...locked_posts);
-    combined.push(...featured_posts);
-    combined.push(...removed_comments);
-    combined.push(...removed_communities);
-    combined.push(...banned_from_community);
-    combined.push(...added_to_community);
-    combined.push(...transferred_to_community);
-    combined.push(...added);
-    combined.push(...banned);
-    combined.push(...purged_persons);
-    combined.push(...purged_communities);
-    combined.push(...purged_posts);
-    combined.push(...purged_comments);
-
-    // Sort them by time
-    combined.sort((a, b) => b.when_.localeCompare(a.when_));
-
-    return combined;
-  }
+      return (
+        <>
+          <span>{featured ? "Featured " : "Unfeatured "}</span>
+          <span>
+            Post <Link to={`/post/${id}`}>{name}</Link>
+          </span>
+          <span>{is_featured_community ? " In Community" : " In Local"}</span>
+        </>
+      );
+    }
+    case ModlogActionType.ModRemoveComment: {
+      const mrc = view as ModRemoveCommentView;
+      const {
+        mod_remove_comment: { reason, removed },
+        comment: { id, content },
+        commenter,
+      } = mrc;
 
-  renderModlogType(i: ModlogType) {
-    switch (i.type_) {
-      case ModlogActionType.ModRemovePost: {
-        let mrpv = i.view as ModRemovePostView;
-        let reason = mrpv.mod_remove_post.reason;
-        return (
-          <>
-            <span>
-              {mrpv.mod_remove_post.removed ? "Removed " : "Restored "}
-            </span>
-            <span>
-              Post <Link to={`/post/${mrpv.post.id}`}>{mrpv.post.name}</Link>
-            </span>
-            {reason && (
-              <span>
-                <div>reason: {reason}</div>
-              </span>
-            )}
-          </>
-        );
-      }
-      case ModlogActionType.ModLockPost: {
-        let mlpv = i.view as ModLockPostView;
-        return (
-          <>
-            <span>{mlpv.mod_lock_post.locked ? "Locked " : "Unlocked "}</span>
-            <span>
-              Post <Link to={`/post/${mlpv.post.id}`}>{mlpv.post.name}</Link>
-            </span>
-          </>
-        );
-      }
-      case ModlogActionType.ModFeaturePost: {
-        let mspv = i.view as ModFeaturePostView;
-        return (
-          <>
-            <span>
-              {mspv.mod_feature_post.featured ? "Featured " : "Unfeatured "}
-            </span>
-            <span>
-              Post <Link to={`/post/${mspv.post.id}`}>{mspv.post.name}</Link>
-            </span>
-            <span>
-              {mspv.mod_feature_post.is_featured_community
-                ? " In Community"
-                : " In Local"}
-            </span>
-          </>
-        );
-      }
-      case ModlogActionType.ModRemoveComment: {
-        let mrc = i.view as ModRemoveCommentView;
-        let reason = mrc.mod_remove_comment.reason;
-        return (
-          <>
+      return (
+        <>
+          <span>{removed ? "Removed " : "Restored "}</span>
+          <span>
+            Comment <Link to={`/comment/${id}`}>{content}</Link>
+          </span>
+          <span>
+            {" "}
+            by <PersonListing person={commenter} />
+          </span>
+          {reason && (
             <span>
-              {mrc.mod_remove_comment.removed ? "Removed " : "Restored "}
-            </span>
-            <span>
-              Comment{" "}
-              <Link to={`/post/${mrc.post.id}/comment/${mrc.comment.id}`}>
-                {mrc.comment.content}
-              </Link>
-            </span>
-            <span>
-              {" "}
-              by <PersonListing person={mrc.commenter} />
-            </span>
-            {reason && (
-              <span>
-                <div>reason: {reason}</div>
-              </span>
-            )}
-          </>
-        );
-      }
-      case ModlogActionType.ModRemoveCommunity: {
-        let mrco = i.view as ModRemoveCommunityView;
-        let reason = mrco.mod_remove_community.reason;
-        let expires = mrco.mod_remove_community.expires;
-        return (
-          <>
-            <span>
-              {mrco.mod_remove_community.removed ? "Removed " : "Restored "}
-            </span>
-            <span>
-              Community <CommunityLink community={mrco.community} />
-            </span>
-            {reason && (
-              <span>
-                <div>reason: {reason}</div>
-              </span>
-            )}
-            {expires && (
-              <span>
-                <div>expires: {moment.utc(expires).fromNow()}</div>
-              </span>
-            )}
-          </>
-        );
-      }
-      case ModlogActionType.ModBanFromCommunity: {
-        let mbfc = i.view as ModBanFromCommunityView;
-        let reason = mbfc.mod_ban_from_community.reason;
-        let expires = mbfc.mod_ban_from_community.expires;
-        return (
-          <>
-            <span>
-              {mbfc.mod_ban_from_community.banned ? "Banned " : "Unbanned "}{" "}
+              <div>reason: {reason}</div>
             </span>
+          )}
+        </>
+      );
+    }
+
+    case ModlogActionType.ModRemoveCommunity: {
+      const mrco = view as ModRemoveCommunityView;
+      const {
+        mod_remove_community: { reason, expires, removed },
+        community,
+      } = mrco;
+
+      return (
+        <>
+          <span>{removed ? "Removed " : "Restored "}</span>
+          <span>
+            Community <CommunityLink community={community} />
+          </span>
+          {reason && (
             <span>
-              <PersonListing person={mbfc.banned_person} />
+              <div>reason: {reason}</div>
             </span>
-            <span> from the community </span>
+          )}
+          {expires && (
             <span>
-              <CommunityLink community={mbfc.community} />
+              <div>expires: {moment.utc(expires).fromNow()}</div>
             </span>
-            {reason && (
-              <span>
-                <div>reason: {reason}</div>
-              </span>
-            )}
-            {expires && (
-              <span>
-                <div>expires: {moment.utc(expires).fromNow()}</div>
-              </span>
-            )}
-          </>
-        );
-      }
-      case ModlogActionType.ModAddCommunity: {
-        let mac = i.view as ModAddCommunityView;
-        return (
-          <>
+          )}
+        </>
+      );
+    }
+
+    case ModlogActionType.ModBanFromCommunity: {
+      const mbfc = view as ModBanFromCommunityView;
+      const {
+        mod_ban_from_community: { reason, expires, banned },
+        banned_person,
+        community,
+      } = mbfc;
+
+      return (
+        <>
+          <span>{banned ? "Banned " : "Unbanned "}</span>
+          <span>
+            <PersonListing person={banned_person} />
+          </span>
+          <span> from the community </span>
+          <span>
+            <CommunityLink community={community} />
+          </span>
+          {reason && (
             <span>
-              {mac.mod_add_community.removed ? "Removed " : "Appointed "}{" "}
+              <div>reason: {reason}</div>
             </span>
+          )}
+          {expires && (
             <span>
-              <PersonListing person={mac.modded_person} />
+              <div>expires: {moment.utc(expires).fromNow()}</div>
             </span>
-            <span> as a mod to the community </span>
+          )}
+        </>
+      );
+    }
+
+    case ModlogActionType.ModAddCommunity: {
+      const {
+        mod_add_community: { removed },
+        modded_person,
+        community,
+      } = view as ModAddCommunityView;
+
+      return (
+        <>
+          <span>{removed ? "Removed " : "Appointed "}</span>
+          <span>
+            <PersonListing person={modded_person} />
+          </span>
+          <span> as a mod to the community </span>
+          <span>
+            <CommunityLink community={community} />
+          </span>
+        </>
+      );
+    }
+
+    case ModlogActionType.ModTransferCommunity: {
+      const {
+        mod_transfer_community: { removed },
+        community,
+        modded_person,
+      } = view as ModTransferCommunityView;
+
+      return (
+        <>
+          <span>{removed ? "Removed " : "Transferred "}</span>
+          <span>
+            <CommunityLink community={community} />
+          </span>
+          <span> to </span>
+          <span>
+            <PersonListing person={modded_person} />
+          </span>
+        </>
+      );
+    }
+
+    case ModlogActionType.ModBan: {
+      const {
+        mod_ban: { reason, expires, banned },
+        banned_person,
+      } = view as ModBanView;
+
+      return (
+        <>
+          <span>{banned ? "Banned " : "Unbanned "}</span>
+          <span>
+            <PersonListing person={banned_person} />
+          </span>
+          {reason && (
             <span>
-              <CommunityLink community={mac.community} />
+              <div>reason: {reason}</div>
             </span>
-          </>
-        );
-      }
-      case ModlogActionType.ModTransferCommunity: {
-        let mtc = i.view as ModTransferCommunityView;
-        return (
-          <>
+          )}
+          {expires && (
             <span>
-              {mtc.mod_transfer_community.removed ? "Removed " : "Transferred "}{" "}
+              <div>expires: {moment.utc(expires).fromNow()}</div>
             </span>
+          )}
+        </>
+      );
+    }
+
+    case ModlogActionType.ModAdd: {
+      const {
+        mod_add: { removed },
+        modded_person,
+      } = view as ModAddView;
+
+      return (
+        <>
+          <span>{removed ? "Removed " : "Appointed "}</span>
+          <span>
+            <PersonListing person={modded_person} />
+          </span>
+          <span> as an admin </span>
+        </>
+      );
+    }
+    case ModlogActionType.AdminPurgePerson: {
+      const {
+        admin_purge_person: { reason },
+      } = view as AdminPurgePersonView;
+
+      return (
+        <>
+          <span>Purged a Person</span>
+          {reason && (
             <span>
-              <CommunityLink community={mtc.community} />
+              <div>reason: {reason}</div>
             </span>
-            <span> to </span>
+          )}
+        </>
+      );
+    }
+
+    case ModlogActionType.AdminPurgeCommunity: {
+      const {
+        admin_purge_community: { reason },
+      } = view as AdminPurgeCommunityView;
+
+      return (
+        <>
+          <span>Purged a Community</span>
+          {reason && (
             <span>
-              <PersonListing person={mtc.modded_person} />
+              <div>reason: {reason}</div>
             </span>
-          </>
-        );
-      }
-      case ModlogActionType.ModBan: {
-        let mb = i.view as ModBanView;
-        let reason = mb.mod_ban.reason;
-        let expires = mb.mod_ban.expires;
-        return (
-          <>
-            <span>{mb.mod_ban.banned ? "Banned " : "Unbanned "} </span>
+          )}
+        </>
+      );
+    }
+
+    case ModlogActionType.AdminPurgePost: {
+      const {
+        admin_purge_post: { reason },
+        community,
+      } = view as AdminPurgePostView;
+
+      return (
+        <>
+          <span>Purged a Post from from </span>
+          <CommunityLink community={community} />
+          {reason && (
             <span>
-              <PersonListing person={mb.banned_person} />
+              <div>reason: {reason}</div>
             </span>
-            {reason && (
-              <span>
-                <div>reason: {reason}</div>
-              </span>
-            )}
-            {expires && (
-              <span>
-                <div>expires: {moment.utc(expires).fromNow()}</div>
-              </span>
-            )}
-          </>
-        );
-      }
-      case ModlogActionType.ModAdd: {
-        let ma = i.view as ModAddView;
-        return (
-          <>
-            <span>{ma.mod_add.removed ? "Removed " : "Appointed "} </span>
+          )}
+        </>
+      );
+    }
+
+    case ModlogActionType.AdminPurgeComment: {
+      const {
+        admin_purge_comment: { reason },
+        post: { id, name },
+      } = view as AdminPurgeCommentView;
+
+      return (
+        <>
+          <span>
+            Purged a Comment from <Link to={`/post/${id}`}>{name}</Link>
+          </span>
+          {reason && (
             <span>
-              <PersonListing person={ma.modded_person} />
+              <div>reason: {reason}</div>
             </span>
-            <span> as an admin </span>
-          </>
-        );
-      }
-      case ModlogActionType.AdminPurgePerson: {
-        let ap = i.view as AdminPurgePersonView;
-        let reason = ap.admin_purge_person.reason;
-        return (
-          <>
-            <span>Purged a Person</span>
-            {reason && (
-              <span>
-                <div>reason: {reason}</div>
-              </span>
-            )}
-          </>
-        );
-      }
-      case ModlogActionType.AdminPurgeCommunity: {
-        let ap = i.view as AdminPurgeCommunityView;
-        let reason = ap.admin_purge_community.reason;
-        return (
-          <>
-            <span>Purged a Community</span>
-            {reason && (
-              <span>
-                <div>reason: {reason}</div>
-              </span>
-            )}
-          </>
-        );
-      }
-      case ModlogActionType.AdminPurgePost: {
-        let ap = i.view as AdminPurgePostView;
-        let reason = ap.admin_purge_post.reason;
-        return (
-          <>
-            <span>Purged a Post from from </span>
-            <CommunityLink community={ap.community} />
-            {reason && (
-              <span>
-                <div>reason: {reason}</div>
-              </span>
-            )}
-          </>
-        );
+          )}
+        </>
+      );
+    }
+
+    default:
+      return <></>;
+  }
+}
+
+const Filter = ({
+  filterType,
+  onChange,
+  value,
+  onSearch,
+  options,
+  loading,
+}: {
+  filterType: FilterType;
+  onChange: (option: Choice) => void;
+  value?: number | null;
+  onSearch: (text: string) => void;
+  options: Choice[];
+  loading: boolean;
+}) => (
+  <div className="col-sm-6 form-group">
+    <label className="col-form-label" htmlFor={`filter-${filterType}`}>
+      {i18n.t(`filter_by_${filterType}` as NoOptionI18nKeys)}
+    </label>
+    <SearchableSelect
+      id={`filter-${filterType}`}
+      value={value ?? 0}
+      options={[
+        {
+          label: i18n.t("all"),
+          value: "0",
+        },
+      ].concat(options)}
+      onChange={onChange}
+      onSearch={onSearch}
+      loading={loading}
+    />
+  </div>
+);
+
+async function createNewOptions({
+  id,
+  oldOptions,
+  text,
+}: {
+  id?: number | null;
+  oldOptions: Choice[];
+  text: string;
+}) {
+  const newOptions: Choice[] = [];
+
+  if (id) {
+    const selectedUser = oldOptions.find(
+      ({ value }) => value === id.toString()
+    );
+
+    if (selectedUser) {
+      newOptions.push(selectedUser);
+    }
+  }
+
+  if (text.length > 0) {
+    newOptions.push(
+      ...(await fetchUsers(text)).users
+        .slice(0, fetchLimit)
+        .map<Choice>(personToChoice)
+    );
+  }
+
+  return newOptions;
+}
+
+export class Modlog extends Component<
+  RouteComponentProps<{ communityId?: string }>,
+  ModlogState
+> {
+  private isoData = setIsoData(this.context);
+  private subscription?: Subscription;
+
+  state: ModlogState = {
+    loadingModlog: true,
+    loadingModSearch: false,
+    loadingUserSearch: false,
+    userSearchOptions: [],
+    modSearchOptions: [],
+  };
+
+  constructor(
+    props: RouteComponentProps<{ communityId?: string }>,
+    context: any
+  ) {
+    super(props, context);
+    this.handlePageChange = this.handlePageChange.bind(this);
+    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) {
+      this.state = {
+        ...this.state,
+        res: this.isoData.routeData[0] as GetModlogResponse,
+      };
+
+      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) {
+        this.state = {
+          ...this.state,
+          modSearchOptions: [personToChoice(filteredModRes.person_view)],
+        };
       }
-      case ModlogActionType.AdminPurgeComment: {
-        let ap = i.view as AdminPurgeCommentView;
-        let reason = ap.admin_purge_comment.reason;
-        return (
-          <>
-            <span>
-              Purged a Comment from{" "}
-              <Link to={`/post/${ap.post.id}`}>{ap.post.name}</Link>
-            </span>
-            {reason && (
-              <span>
-                <div>reason: {reason}</div>
-              </span>
-            )}
-          </>
-        );
+
+      const filteredUserRes: GetPersonDetailsResponse | undefined =
+        this.isoData.routeData[3];
+      if (filteredUserRes) {
+        this.state = {
+          ...this.state,
+          userSearchOptions: [personToChoice(filteredUserRes.person_view)],
+        };
       }
-      default:
-        return <div />;
+
+      this.state = { ...this.state, loadingModlog: false };
+    } else {
+      this.refetch();
+    }
+  }
+
+  componentWillUnmount() {
+    if (isBrowser()) {
+      this.subscription?.unsubscribe();
     }
   }
 
-  combined() {
-    let res = this.state.res;
-    let combined = res ? this.buildCombined(res) : [];
+  get combined() {
+    const res = this.state.res;
+    const combined = res ? buildCombined(res) : [];
 
     return (
       <tbody>
@@ -573,7 +739,7 @@ export class Modlog extends Component<any, ModlogState> {
                 <div>{this.modOrAdminText(i.moderator)}</div>
               )}
             </td>
-            <td>{this.renderModlogType(i)}</td>
+            <td>{renderModlogType(i)}</td>
           </tr>
         ))}
       </tbody>
@@ -585,106 +751,113 @@ export class Modlog extends Component<any, ModlogState> {
   }
 
   modOrAdminText(person?: PersonSafe): string {
-    return person
-      ? this.isoData.site_res.admins.map(a => a.person.id).includes(person.id)
-        ? i18n.t("admin")
-        : i18n.t("mod")
+    return person &&
+      this.isoData.site_res.admins.some(
+        ({ person: { id } }) => id === person.id
+      )
+      ? i18n.t("admin")
       : i18n.t("mod");
   }
 
   get documentTitle(): string {
-    return `Modlog - ${this.state.siteRes.site_view.site.name}`;
+    return `Modlog - ${this.isoData.site_res.site_view.site.name}`;
   }
 
   render() {
-    let communityName = this.state.communityName;
+    const {
+      communityName,
+      loadingModlog,
+      loadingModSearch,
+      loadingUserSearch,
+      userSearchOptions,
+      modSearchOptions,
+    } = this.state;
+    const { actionType, page, modId, userId } = getModlogQueryParams();
+
     return (
       <div className="container-lg">
         <HtmlTags
           title={this.documentTitle}
           path={this.context.router.route.match.url}
         />
-        {this.state.loading ? (
+
+        <div>
           <h5>
-            <Spinner large />
+            {communityName && (
+              <Link className="text-body" to={`/c/${communityName}`}>
+                /c/{communityName}{" "}
+              </Link>
+            )}
+            <span>{i18n.t("modlog")}</span>
           </h5>
-        ) : (
-          <div>
-            <h5>
-              {communityName && (
-                <Link className="text-body" to={`/c/${communityName}`}>
-                  /c/{communityName}{" "}
-                </Link>
-              )}
-              <span>{i18n.t("modlog")}</span>
-            </h5>
-            <div className="form-row">
-              <div className="form-group col-sm-6">
-                <select
-                  value={this.state.filter_action}
-                  onChange={linkEvent(this, this.handleFilterActionChange)}
-                  className="custom-select mb-2"
-                  aria-label="action"
-                >
-                  <option disabled aria-hidden="true">
-                    {i18n.t("filter_by_action")}
-                  </option>
-                  <option value={ModlogActionType.All}>{i18n.t("all")}</option>
-                  <option value={ModlogActionType.ModRemovePost}>
-                    Removing Posts
-                  </option>
-                  <option value={ModlogActionType.ModLockPost}>
-                    Locking Posts
-                  </option>
-                  <option value={ModlogActionType.ModFeaturePost}>
-                    Featuring Posts
-                  </option>
-                  <option value={ModlogActionType.ModRemoveComment}>
-                    Removing Comments
-                  </option>
-                  <option value={ModlogActionType.ModRemoveCommunity}>
-                    Removing Communities
-                  </option>
-                  <option value={ModlogActionType.ModBanFromCommunity}>
-                    Banning From Communities
-                  </option>
-                  <option value={ModlogActionType.ModAddCommunity}>
-                    Adding Mod to Community
-                  </option>
-                  <option value={ModlogActionType.ModTransferCommunity}>
-                    Transfering Communities
-                  </option>
-                  <option value={ModlogActionType.ModAdd}>
-                    Adding Mod to Site
-                  </option>
-                  <option value={ModlogActionType.ModBan}>
-                    Banning From Site
-                  </option>
-                </select>
-              </div>
-              {!this.state.siteRes.site_view.local_site
-                .hide_modlog_mod_names && (
-                <div className="form-group col-sm-6">
-                  <select
-                    id="filter-mod"
-                    className="form-control"
-                    value={this.state.filter_mod}
-                  >
-                    <option>{i18n.t("filter_by_mod")}</option>
-                  </select>
-                </div>
-              )}
-              <div className="form-group col-sm-6">
-                <select
-                  id="filter-user"
-                  className="form-control"
-                  value={this.state.filter_user}
-                >
-                  <option>{i18n.t("filter_by_user")}</option>
-                </select>
-              </div>
-            </div>
-            <div className="table-responsive">
+          <div className="form-row">
+            <select
+              value={actionType}
+              onChange={linkEvent(this, this.handleFilterActionChange)}
+              className="custom-select col-sm-6"
+              aria-label="action"
+            >
+              <option disabled aria-hidden="true">
+                {i18n.t("filter_by_action")}
+              </option>
+              <option value={ModlogActionType.All}>{i18n.t("all")}</option>
+              <option value={ModlogActionType.ModRemovePost}>
+                Removing Posts
+              </option>
+              <option value={ModlogActionType.ModLockPost}>
+                Locking Posts
+              </option>
+              <option value={ModlogActionType.ModFeaturePost}>
+                Featuring Posts
+              </option>
+              <option value={ModlogActionType.ModRemoveComment}>
+                Removing Comments
+              </option>
+              <option value={ModlogActionType.ModRemoveCommunity}>
+                Removing Communities
+              </option>
+              <option value={ModlogActionType.ModBanFromCommunity}>
+                Banning From Communities
+              </option>
+              <option value={ModlogActionType.ModAddCommunity}>
+                Adding Mod to Community
+              </option>
+              <option value={ModlogActionType.ModTransferCommunity}>
+                Transferring Communities
+              </option>
+              <option value={ModlogActionType.ModAdd}>
+                Adding Mod to Site
+              </option>
+              <option value={ModlogActionType.ModBan}>Banning From Site</option>
+            </select>
+          </div>
+          <div className="form-row mb-2">
+            <Filter
+              filterType="user"
+              onChange={this.handleUserChange}
+              onSearch={this.handleSearchUsers}
+              value={userId}
+              options={userSearchOptions}
+              loading={loadingUserSearch}
+            />
+            {!this.isoData.site_res.site_view.local_site
+              .hide_modlog_mod_names && (
+              <Filter
+                filterType="mod"
+                onChange={this.handleModChange}
+                onSearch={this.handleSearchMods}
+                value={modId}
+                options={modSearchOptions}
+                loading={loadingModSearch}
+              />
+            )}
+          </div>
+          <div className="table-responsive">
+            {loadingModlog ? (
+              <h5>
+                <Spinner large />
+              </h5>
+            ) : (
               <table id="modlog_table" className="table table-sm table-hover">
                 <thead className="pointer">
                   <tr>
@@ -693,176 +866,224 @@ export class Modlog extends Component<any, ModlogState> {
                     <th>{i18n.t("action")}</th>
                   </tr>
                 </thead>
-                {this.combined()}
+                {this.combined}
               </table>
-              <Paginator
-                page={this.state.page}
-                onChange={this.handlePageChange}
-              />
-            </div>
+            )}
+            <Paginator page={page} onChange={this.handlePageChange} />
           </div>
-        )}
+        </div>
       </div>
     );
   }
 
   handleFilterActionChange(i: Modlog, event: any) {
-    i.setState({ filter_action: event.target.value });
-    i.refetch();
+    i.updateUrl({
+      actionType: ModlogActionType[event.target.value],
+      page: 1,
+    });
+  }
+
+  handlePageChange(page: number) {
+    this.updateUrl({ page });
+  }
+
+  handleUserChange(option: Choice) {
+    this.updateUrl({ userId: getIdFromString(option.value) ?? null, page: 1 });
+  }
+
+  handleModChange(option: Choice) {
+    this.updateUrl({ modId: getIdFromString(option.value) ?? null, page: 1 });
   }
 
-  handlePageChange(val: number) {
-    this.setState({ page: val });
+  handleSearchUsers = debounce(async (text: string) => {
+    const { userId } = getModlogQueryParams();
+    const { userSearchOptions } = this.state;
+    this.setState({ loadingUserSearch: true });
+
+    const newOptions = await createNewOptions({
+      id: userId,
+      text,
+      oldOptions: userSearchOptions,
+    });
+
+    this.setState({
+      userSearchOptions: newOptions,
+      loadingUserSearch: false,
+    });
+  });
+
+  handleSearchMods = debounce(async (text: string) => {
+    const { modId } = getModlogQueryParams();
+    const { modSearchOptions } = this.state;
+    this.setState({ loadingModSearch: true });
+
+    const newOptions = await createNewOptions({
+      id: modId,
+      text,
+      oldOptions: modSearchOptions,
+    });
+
+    this.setState({
+      modSearchOptions: newOptions,
+      loadingModSearch: false,
+    });
+  });
+
+  updateUrl({ actionType, modId, page, userId }: Partial<ModlogProps>) {
+    const {
+      page: urlPage,
+      actionType: urlActionType,
+      modId: urlModId,
+      userId: urlUserId,
+    } = getModlogQueryParams();
+
+    const queryParams: QueryParams<ModlogProps> = {
+      page: (page ?? urlPage).toString(),
+      actionType: actionType ?? urlActionType,
+      modId: getUpdatedSearchId(modId, urlModId),
+      userId: getUpdatedSearchId(userId, urlUserId),
+    };
+
+    const communityId = this.props.match.params.communityId;
+
+    this.props.history.push(
+      `/modlog${communityId ? `/${communityId}` : ""}${getQueryString(
+        queryParams
+      )}`
+    );
+
+    this.setState({
+      loadingModlog: true,
+      res: undefined,
+    });
+
     this.refetch();
   }
 
   refetch() {
-    let auth = myAuth(false);
-    let modlogForm: GetModlog = {
-      community_id: this.state.communityId,
-      page: this.state.page,
+    const auth = myAuth(false);
+    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_: this.state.filter_action,
-      other_person_id: this.state.filter_user,
-      mod_person_id: this.state.filter_mod,
+      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));
 
-    let communityId = this.state.communityId;
     if (communityId) {
-      let communityForm: GetCommunity = {
+      const communityForm: GetCommunity = {
         id: communityId,
         auth,
       };
-      WebSocketService.Instance.send(wsClient.getCommunity(communityForm));
-    }
-  }
 
-  setupUserFilter() {
-    if (isBrowser()) {
-      let selectId: any = document.getElementById("filter-user");
-      if (selectId) {
-        this.userChoices = new Choices(selectId, choicesConfig);
-        this.userChoices.passedElement.element.addEventListener(
-          "choice",
-          (e: any) => {
-            this.setState({ filter_user: Number(e.detail.choice.value) });
-            this.refetch();
-          },
-          false
-        );
-        this.userChoices.passedElement.element.addEventListener(
-          "search",
-          debounce(async (e: any) => {
-            try {
-              let users = (await fetchUsers(e.detail.value)).users;
-              this.userChoices.setChoices(
-                users.map(u => {
-                  return {
-                    value: u.person.id.toString(),
-                    label: u.person.name,
-                  };
-                }),
-                "value",
-                "label",
-                true
-              );
-            } catch (err) {
-              console.log(err);
-            }
-          }),
-          false
-        );
-      }
-    }
-  }
-
-  setupModFilter() {
-    if (isBrowser()) {
-      let selectId: any = document.getElementById("filter-mod");
-      if (selectId) {
-        this.modChoices = new Choices(selectId, choicesConfig);
-        this.modChoices.passedElement.element.addEventListener(
-          "choice",
-          (e: any) => {
-            this.setState({ filter_mod: Number(e.detail.choice.value) });
-            this.refetch();
-          },
-          false
-        );
-        this.modChoices.passedElement.element.addEventListener(
-          "search",
-          debounce(async (e: any) => {
-            try {
-              let mods = (await fetchUsers(e.detail.value)).users;
-              this.modChoices.setChoices(
-                mods.map(u => {
-                  return {
-                    value: u.person.id.toString(),
-                    label: u.person.name,
-                  };
-                }),
-                "value",
-                "label",
-                true
-              );
-            } catch (err) {
-              console.log(err);
-            }
-          }),
-          false
-        );
-      }
+      WebSocketService.Instance.send(wsClient.getCommunity(communityForm));
     }
   }
 
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
-    let pathSplit = req.path.split("/");
-    let communityId = pathSplit[3] ? Number(pathSplit[3]) : undefined;
-    let auth = req.auth;
-    let promises: Promise<any>[] = [];
+  static fetchInitialData({
+    client,
+    path,
+    query: { modId: urlModId, page, userId: urlUserId, actionType },
+    auth,
+    site,
+  }: InitialFetchRequest<QueryParams<ModlogProps>>): Promise<any>[] {
+    const pathSplit = path.split("/");
+    const promises: Promise<any>[] = [];
+    const communityId = getIdFromString(pathSplit[2]);
+    const modId = !site.site_view.local_site.hide_modlog_mod_names
+      ? getIdFromString(urlModId)
+      : undefined;
+    const userId = getIdFromString(urlUserId);
 
-    let modlogForm: GetModlog = {
-      page: 1,
+    const modlogForm: GetModlog = {
+      page: getPageFromString(page),
       limit: fetchLimit,
       community_id: communityId,
-      type_: ModlogActionType.All,
+      type_: getActionFromString(actionType),
+      mod_person_id: modId,
+      other_person_id: userId,
       auth,
     };
 
-    promises.push(req.client.getModlog(modlogForm));
+    promises.push(client.getModlog(modlogForm));
 
     if (communityId) {
-      let communityForm: GetCommunity = {
+      const communityForm: GetCommunity = {
         id: communityId,
-        auth: req.auth,
+        auth,
       };
-      promises.push(req.client.getCommunity(communityForm));
+      promises.push(client.getCommunity(communityForm));
     } else {
       promises.push(Promise.resolve());
     }
+
+    if (modId) {
+      const getPersonForm: GetPersonDetails = {
+        person_id: modId,
+        auth,
+      };
+
+      promises.push(client.getPersonDetails(getPersonForm));
+    } else {
+      promises.push(Promise.resolve());
+    }
+
+    if (userId) {
+      const getPersonForm: GetPersonDetails = {
+        person_id: userId,
+        auth,
+      };
+
+      promises.push(client.getPersonDetails(getPersonForm));
+    } else {
+      promises.push(Promise.resolve());
+    }
+
     return promises;
   }
 
   parseMessage(msg: any) {
-    let op = wsUserOp(msg);
+    const op = wsUserOp(msg);
     console.log(msg);
+
     if (msg.error) {
       toast(i18n.t(msg.error), "danger");
-      return;
-    } else if (op == UserOperation.GetModlog) {
-      let data = wsJsonToRes<GetModlogResponse>(msg);
-      window.scrollTo(0, 0);
-      this.setState({ res: data, loading: false });
-      this.setupUserFilter();
-      this.setupModFilter();
-    } else if (op == UserOperation.GetCommunity) {
-      let data = wsJsonToRes<GetCommunityResponse>(msg);
-      this.setState({
-        communityMods: data.moderators,
-        communityName: data.community_view.community.name,
-      });
+    } 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 0195cb8716f6f7a70b612ba314acbb083b87aaea..4065df127cf8651027949074766338c677b67d57 100644 (file)
@@ -1,5 +1,8 @@
+import classNames from "classnames";
+import { NoOptionI18nKeys } from "i18next";
 import { Component, linkEvent } from "inferno";
 import { Link } from "inferno-router";
+import { RouteComponentProps } from "inferno-router/dist/Route";
 import {
   AddAdminResponse,
   BanPerson,
@@ -7,6 +10,8 @@ import {
   BlockPerson,
   BlockPersonResponse,
   CommentResponse,
+  CommunityModeratorView,
+  CommunitySafe,
   GetPersonDetails,
   GetPersonDetailsResponse,
   GetSiteResponse,
@@ -33,12 +38,15 @@ import {
   enableNsfw,
   fetchLimit,
   futureDaysToUnixTime,
-  getUsernameFromProps,
+  getPageFromString,
+  getQueryParams,
+  getQueryString,
   isAdmin,
   isBanned,
   mdToHtml,
   myAuth,
   numToSI,
+  QueryParams,
   relTags,
   restoreScrollPosition,
   routeSortTypeToEnum,
@@ -62,10 +70,6 @@ import { PersonListing } from "./person-listing";
 
 interface ProfileState {
   personRes?: GetPersonDetailsResponse;
-  userName: string;
-  view: PersonDetailsView;
-  sort: SortType;
-  page: number;
   loading: boolean;
   personBlocked: boolean;
   banReason?: string;
@@ -79,32 +83,84 @@ interface ProfileProps {
   view: PersonDetailsView;
   sort: SortType;
   page: number;
-  person_id?: number;
-  username: string;
 }
 
-interface UrlParams {
-  view?: string;
-  sort?: SortType;
-  page?: number;
+const getProfileQueryParams = () =>
+  getQueryParams<ProfileProps>({
+    view: getViewFromProps,
+    page: getPageFromString,
+    sort: getSortTypeFromQuery,
+  });
+
+const getSortTypeFromQuery = (sort?: string): SortType =>
+  sort ? routeSortTypeToEnum(sort, SortType.New) : SortType.New;
+
+const getViewFromProps = (view?: string): PersonDetailsView =>
+  view
+    ? PersonDetailsView[view] ?? PersonDetailsView.Overview
+    : 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));
+  }
 }
 
-export class Profile extends Component<any, ProfileState> {
+const handleUnblockPerson = (personId: number) =>
+  toggleBlockPerson(personId, false);
+
+const handleBlockPerson = (personId: number) =>
+  toggleBlockPerson(personId, true);
+
+const getCommunitiesListing = (
+  translationKey: NoOptionI18nKeys,
+  communityViews?: { community: CommunitySafe }[]
+) =>
+  communityViews &&
+  communityViews.length > 0 && (
+    <div className="card border-secondary mb-3">
+      <div className="card-body">
+        <h5>{i18n.t(translationKey)}</h5>
+        <ul className="list-unstyled mb-0">
+          {communityViews.map(({ community }) => (
+            <li key={community.id}>
+              <CommunityLink community={community} />
+            </li>
+          ))}
+        </ul>
+      </div>
+    </div>
+  );
+
+const Moderates = ({ moderates }: { moderates?: CommunityModeratorView[] }) =>
+  getCommunitiesListing("moderates", moderates);
+
+const Follows = () =>
+  getCommunitiesListing("subscribed", UserService.Instance.myUserInfo?.follows);
+
+export class Profile extends Component<
+  RouteComponentProps<{ username: string }>,
+  ProfileState
+> {
   private isoData = setIsoData(this.context);
   private subscription?: Subscription;
   state: ProfileState = {
-    userName: getUsernameFromProps(this.props),
     loading: true,
-    view: Profile.getViewFromProps(this.props.match.view),
-    sort: Profile.getSortTypeFromProps(this.props.match.sort),
-    page: Profile.getPageFromProps(this.props.match.page),
     personBlocked: false,
     siteRes: this.isoData.site_res,
     showBanDialog: false,
     removeData: false,
   };
 
-  constructor(props: any, context: any) {
+  constructor(props: RouteComponentProps<{ username: string }>, context: any) {
     super(props, context);
 
     this.handleSortChange = this.handleSortChange.bind(this);
@@ -114,7 +170,7 @@ export class Profile extends Component<any, ProfileState> {
     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 (this.isoData.path === this.context.router.route.match.url) {
       this.state = {
         ...this.state,
         personRes: this.isoData.routeData[0] as GetPersonDetailsResponse,
@@ -126,65 +182,61 @@ export class Profile extends Component<any, ProfileState> {
   }
 
   fetchUserData() {
-    let form: GetPersonDetails = {
-      username: this.state.userName,
-      sort: this.state.sort,
-      saved_only: this.state.view === PersonDetailsView.Saved,
-      page: this.state.page,
+    const { page, sort, view } = getProfileQueryParams();
+
+    const form: GetPersonDetails = {
+      username: this.props.match.params.username,
+      sort,
+      saved_only: view === PersonDetailsView.Saved,
+      page,
       limit: fetchLimit,
       auth: myAuth(false),
     };
+
     WebSocketService.Instance.send(wsClient.getPersonDetails(form));
   }
 
   get amCurrentUser() {
     return (
-      UserService.Instance.myUserInfo?.local_user_view.person.id ==
+      UserService.Instance.myUserInfo?.local_user_view.person.id ===
       this.state.personRes?.person_view.person.id
     );
   }
 
   setPersonBlock() {
-    let mui = UserService.Instance.myUserInfo;
-    let res = this.state.personRes;
+    const mui = UserService.Instance.myUserInfo;
+    const res = this.state.personRes;
+
     if (mui && res) {
       this.setState({
-        personBlocked: mui.person_blocks
-          .map(a => a.target.id)
-          .includes(res.person_view.person.id),
+        personBlocked: mui.person_blocks.some(
+          ({ target: { id } }) => id === res.person_view.person.id
+        ),
       });
     }
   }
 
-  static getViewFromProps(view: string): PersonDetailsView {
-    return view ? PersonDetailsView[view] : PersonDetailsView.Overview;
-  }
-
-  static getSortTypeFromProps(sort: string): SortType {
-    return sort ? routeSortTypeToEnum(sort) : SortType.New;
-  }
-
-  static getPageFromProps(page: number): number {
-    return page ? Number(page) : 1;
-  }
-
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
-    let pathSplit = req.path.split("/");
+  static fetchInitialData({
+    client,
+    path,
+    query: { page, sort, view: urlView },
+    auth,
+  }: InitialFetchRequest<QueryParams<ProfileProps>>): Promise<any>[] {
+    const pathSplit = path.split("/");
 
-    let username = pathSplit[2];
-    let view = this.getViewFromProps(pathSplit[4]);
-    let sort = this.getSortTypeFromProps(pathSplit[6]);
-    let page = this.getPageFromProps(Number(pathSplit[8]));
+    const username = pathSplit[2];
+    const view = getViewFromProps(urlView);
 
-    let form: GetPersonDetails = {
+    const form: GetPersonDetails = {
       username: username,
-      sort,
+      sort: getSortTypeFromQuery(sort),
       saved_only: view === PersonDetailsView.Saved,
-      page,
+      page: getPageFromString(page),
       limit: fetchLimit,
-      auth: req.auth,
+      auth,
     };
-    return [req.client.getPersonDetails(form)];
+
+    return [client.getPersonDetails(form)];
   }
 
   componentDidMount() {
@@ -197,78 +249,59 @@ export class Profile extends Component<any, ProfileState> {
     saveScrollPosition(this.context);
   }
 
-  static getDerivedStateFromProps(props: any): ProfileProps {
-    return {
-      view: this.getViewFromProps(props.match.params.view),
-      sort: this.getSortTypeFromProps(props.match.params.sort),
-      page: this.getPageFromProps(props.match.params.page),
-      person_id: Number(props.match.params.id),
-      username: props.match.params.username,
-    };
-  }
-
-  componentDidUpdate(lastProps: any) {
-    // Necessary if you are on a post and you click another post (same route)
-    if (
-      lastProps.location.pathname.split("/")[2] !==
-      lastProps.history.location.pathname.split("/")[2]
-    ) {
-      // Couldnt get a refresh working. This does for now.
-      location.reload();
-    }
-  }
-
   get documentTitle(): string {
-    let res = this.state.personRes;
+    const res = this.state.personRes;
     return res
       ? `@${res.person_view.person.name} - ${this.state.siteRes.site_view.site.name}`
       : "";
   }
 
   render() {
-    let res = this.state.personRes;
+    const { personRes, loading, siteRes } = this.state;
+    const { page, sort, view } = getProfileQueryParams();
+
     return (
       <div className="container-lg">
-        {this.state.loading ? (
+        {loading ? (
           <h5>
             <Spinner large />
           </h5>
         ) : (
-          res && (
+          personRes && (
             <div className="row">
               <div className="col-12 col-md-8">
-                <>
-                  <HtmlTags
-                    title={this.documentTitle}
-                    path={this.context.router.route.match.url}
-                    description={res.person_view.person.bio}
-                    image={res.person_view.person.avatar}
-                  />
-                  {this.userInfo()}
-                  <hr />
-                </>
-                {!this.state.loading && this.selects()}
+                <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={res}
-                  admins={this.state.siteRes.admins}
-                  sort={this.state.sort}
-                  page={this.state.page}
+                  personRes={personRes}
+                  admins={siteRes.admins}
+                  sort={sort}
+                  page={page}
                   limit={fetchLimit}
-                  enableDownvotes={enableDownvotes(this.state.siteRes)}
-                  enableNsfw={enableNsfw(this.state.siteRes)}
-                  view={this.state.view}
+                  enableDownvotes={enableDownvotes(siteRes)}
+                  enableNsfw={enableNsfw(siteRes)}
+                  view={view}
                   onPageChange={this.handlePageChange}
-                  allLanguages={this.state.siteRes.all_languages}
-                  siteLanguages={this.state.siteRes.discussion_languages}
+                  allLanguages={siteRes.all_languages}
+                  siteLanguages={siteRes.discussion_languages}
                 />
               </div>
 
-              {!this.state.loading && (
-                <div className="col-12 col-md-4">
-                  {this.moderates()}
-                  {this.amCurrentUser && this.follows()}
-                </div>
-              )}
+              <div className="col-12 col-md-4">
+                <Moderates moderates={personRes.moderates} />
+                {this.amCurrentUser && <Follows />}
+              </div>
             </div>
           )
         )}
@@ -276,73 +309,49 @@ export class Profile extends Component<any, ProfileState> {
     );
   }
 
-  viewRadios() {
+  get viewRadios() {
     return (
       <div className="btn-group btn-group-toggle flex-wrap mb-2">
-        <label
-          className={`btn btn-outline-secondary pointer
-            ${this.state.view == PersonDetailsView.Overview && "active"}
-          `}
-        >
-          <input
-            type="radio"
-            value={PersonDetailsView.Overview}
-            checked={this.state.view === PersonDetailsView.Overview}
-            onChange={linkEvent(this, this.handleViewChange)}
-          />
-          {i18n.t("overview")}
-        </label>
-        <label
-          className={`btn btn-outline-secondary pointer
-            ${this.state.view == PersonDetailsView.Comments && "active"}
-          `}
-        >
-          <input
-            type="radio"
-            value={PersonDetailsView.Comments}
-            checked={this.state.view == PersonDetailsView.Comments}
-            onChange={linkEvent(this, this.handleViewChange)}
-          />
-          {i18n.t("comments")}
-        </label>
-        <label
-          className={`btn btn-outline-secondary pointer
-            ${this.state.view == PersonDetailsView.Posts && "active"}
-          `}
-        >
-          <input
-            type="radio"
-            value={PersonDetailsView.Posts}
-            checked={this.state.view == PersonDetailsView.Posts}
-            onChange={linkEvent(this, this.handleViewChange)}
-          />
-          {i18n.t("posts")}
-        </label>
-        <label
-          className={`btn btn-outline-secondary pointer
-            ${this.state.view == PersonDetailsView.Saved && "active"}
-          `}
-        >
-          <input
-            type="radio"
-            value={PersonDetailsView.Saved}
-            checked={this.state.view == PersonDetailsView.Saved}
-            onChange={linkEvent(this, this.handleViewChange)}
-          />
-          {i18n.t("saved")}
-        </label>
+        {this.getRadio(PersonDetailsView.Overview)}
+        {this.getRadio(PersonDetailsView.Comments)}
+        {this.getRadio(PersonDetailsView.Posts)}
+        {this.getRadio(PersonDetailsView.Saved)}
       </div>
     );
   }
 
-  selects() {
-    let profileRss = `/feeds/u/${this.state.userName}.xml?sort=${this.state.sort}`;
+  getRadio(view: PersonDetailsView) {
+    const { view: urlView } = getProfileQueryParams();
+    const active = view === urlView;
+
+    return (
+      <label
+        className={classNames("btn btn-outline-secondary pointer", {
+          active,
+        })}
+      >
+        <input
+          type="radio"
+          value={view}
+          checked={active}
+          onChange={linkEvent(this, this.handleViewChange)}
+        />
+        {i18n.t(view.toLowerCase() as NoOptionI18nKeys)}
+      </label>
+    );
+  }
+
+  get selects() {
+    const { sort } = getProfileQueryParams();
+    const { username } = this.props.match.params;
+
+    const profileRss = `/feeds/u/${username}.xml?sort=${sort}`;
 
     return (
       <div className="mb-2">
-        <span className="mr-3">{this.viewRadios()}</span>
+        <span className="mr-3">{this.viewRadios}</span>
         <SortSelect
-          sort={this.state.sort}
+          sort={sort}
           onChange={this.handleSortChange}
           hideHot
           hideMostComments
@@ -354,33 +363,15 @@ export class Profile extends Component<any, ProfileState> {
       </div>
     );
   }
-  handleBlockPerson(personId: number) {
-    let auth = myAuth();
-    if (auth) {
-      if (personId != 0) {
-        let blockUserForm: BlockPerson = {
-          person_id: personId,
-          block: true,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
-      }
-    }
-  }
-  handleUnblockPerson(recipientId: number) {
-    let auth = myAuth();
-    if (auth) {
-      let blockUserForm: BlockPerson = {
-        person_id: recipientId,
-        block: false,
-        auth,
-      };
-      WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
-    }
-  }
 
-  userInfo() {
-    let pv = this.state.personRes?.person_view;
+  get userInfo() {
+    const pv = this.state.personRes?.person_view;
+    const {
+      personBlocked,
+      siteRes: { admins },
+      showBanDialog,
+    } = this.state;
+
     return (
       pv && (
         <div>
@@ -429,7 +420,7 @@ export class Profile extends Component<any, ProfileState> {
                     )}
                   </ul>
                 </div>
-                {this.banDialog()}
+                {this.banDialog}
                 <div className="flex-grow-1 unselectable pointer mx-2"></div>
                 {!this.amCurrentUser && UserService.Instance.myUserInfo && (
                   <>
@@ -446,19 +437,16 @@ export class Profile extends Component<any, ProfileState> {
                       className={
                         "d-flex align-self-start btn btn-secondary mr-2"
                       }
-                      to={`/create_private_message/recipient/${pv.person.id}`}
+                      to={`/create_private_message/${pv.person.id}`}
                     >
                       {i18n.t("send_message")}
                     </Link>
-                    {this.state.personBlocked ? (
+                    {personBlocked ? (
                       <button
                         className={
                           "d-flex align-self-start btn btn-secondary mr-2"
                         }
-                        onClick={linkEvent(
-                          pv.person.id,
-                          this.handleUnblockPerson
-                        )}
+                        onClick={linkEvent(pv.person.id, handleUnblockPerson)}
                       >
                         {i18n.t("unblock_user")}
                       </button>
@@ -467,10 +455,7 @@ export class Profile extends Component<any, ProfileState> {
                         className={
                           "d-flex align-self-start btn btn-secondary mr-2"
                         }
-                        onClick={linkEvent(
-                          pv.person.id,
-                          this.handleBlockPerson
-                        )}
+                        onClick={linkEvent(pv.person.id, handleBlockPerson)}
                       >
                         {i18n.t("block_user")}
                       </button>
@@ -478,9 +463,9 @@ export class Profile extends Component<any, ProfileState> {
                   </>
                 )}
 
-                {canMod(pv.person.id, undefined, this.state.siteRes.admins) &&
-                  !isAdmin(pv.person.id, this.state.siteRes.admins) &&
-                  !this.state.showBanDialog &&
+                {canMod(pv.person.id, undefined, admins) &&
+                  !isAdmin(pv.person.id, admins) &&
+                  !showBanDialog &&
                   (!isBanned(pv.person) ? (
                     <button
                       className={
@@ -552,12 +537,14 @@ export class Profile extends Component<any, ProfileState> {
     );
   }
 
-  banDialog() {
-    let pv = this.state.personRes?.person_view;
+  get banDialog() {
+    const pv = this.state.personRes?.person_view;
+    const { showBanDialog } = this.state;
+
     return (
       pv && (
         <>
-          {this.state.showBanDialog && (
+          {showBanDialog && (
             <form onSubmit={linkEvent(this, this.handleModBanSubmit)}>
               <div className="form-group row col-12">
                 <label className="col-form-label" htmlFor="profile-ban-reason">
@@ -630,73 +617,38 @@ export class Profile extends Component<any, ProfileState> {
     );
   }
 
-  moderates() {
-    let moderates = this.state.personRes?.moderates;
-    return (
-      moderates &&
-      moderates.length > 0 && (
-        <div className="card border-secondary mb-3">
-          <div className="card-body">
-            <h5>{i18n.t("moderates")}</h5>
-            <ul className="list-unstyled mb-0">
-              {moderates.map(cmv => (
-                <li key={cmv.community.id}>
-                  <CommunityLink community={cmv.community} />
-                </li>
-              ))}
-            </ul>
-          </div>
-        </div>
-      )
-    );
-  }
+  updateUrl({ page, sort, view }: Partial<ProfileProps>) {
+    const {
+      page: urlPage,
+      sort: urlSort,
+      view: urlView,
+    } = getProfileQueryParams();
 
-  follows() {
-    let follows = UserService.Instance.myUserInfo?.follows;
-    return (
-      follows &&
-      follows.length > 0 && (
-        <div className="card border-secondary mb-3">
-          <div className="card-body">
-            <h5>{i18n.t("subscribed")}</h5>
-            <ul className="list-unstyled mb-0">
-              {follows.map(cfv => (
-                <li key={cfv.community.id}>
-                  <CommunityLink community={cfv.community} />
-                </li>
-              ))}
-            </ul>
-          </div>
-        </div>
-      )
-    );
-  }
+    const queryParams: QueryParams<ProfileProps> = {
+      page: (page ?? urlPage).toString(),
+      sort: sort ?? urlSort,
+      view: view ?? urlView,
+    };
 
-  updateUrl(paramUpdates: UrlParams) {
-    const page = paramUpdates.page || this.state.page;
-    const viewStr = paramUpdates.view || PersonDetailsView[this.state.view];
-    const sortStr = paramUpdates.sort || this.state.sort;
+    const { username } = this.props.match.params;
 
-    let typeView = `/u/${this.state.userName}`;
+    this.props.history.push(`/u/${username}${getQueryString(queryParams)}`);
 
-    this.props.history.push(
-      `${typeView}/view/${viewStr}/sort/${sortStr}/page/${page}`
-    );
     this.setState({ loading: true });
     this.fetchUserData();
   }
 
   handlePageChange(page: number) {
-    this.updateUrl({ page: page });
+    this.updateUrl({ page });
   }
 
-  handleSortChange(val: SortType) {
-    this.updateUrl({ sort: val, page: 1 });
+  handleSortChange(sort: SortType) {
+    this.updateUrl({ sort, page: 1 });
   }
 
   handleViewChange(i: Profile, event: any) {
     i.updateUrl({
-      view: PersonDetailsView[Number(event.target.value)],
+      view: PersonDetailsView[event.target.value],
       page: 1,
     });
   }
@@ -724,20 +676,25 @@ export class Profile extends Component<any, ProfileState> {
 
   handleModBanSubmit(i: Profile, event?: any) {
     if (event) event.preventDefault();
-    let person = i.state.personRes?.person_view.person;
-    let auth = myAuth();
+    const { personRes, removeData, banReason, banExpireDays } = i.state;
+
+    const person = personRes?.person_view.person;
+    const auth = myAuth();
+
     if (person && auth) {
+      const ban = !person.banned;
+
       // If its an unban, restore all their data
-      let ban = !person.banned;
-      if (ban == false) {
+      if (!ban) {
         i.setState({ removeData: false });
       }
-      let form: BanPerson = {
+
+      const form: BanPerson = {
         person_id: person.id,
         ban,
-        remove_data: i.state.removeData,
-        reason: i.state.banReason,
-        expires: futureDaysToUnixTime(i.state.banExpireDays),
+        remove_data: removeData,
+        reason: banReason,
+        expires: futureDaysToUnixTime(banExpireDays),
         auth,
       };
       WebSocketService.Instance.send(wsClient.banPerson(form));
@@ -747,94 +704,138 @@ export class Profile extends Component<any, ProfileState> {
   }
 
   parseMessage(msg: any) {
-    let op = wsUserOp(msg);
+    const op = wsUserOp(msg);
     console.log(msg);
+
     if (msg.error) {
       toast(i18n.t(msg.error), "danger");
-      if (msg.error == "couldnt_find_that_username_or_email") {
+
+      if (msg.error === "couldnt_find_that_username_or_email") {
         this.context.router.history.push("/");
       }
-      return;
     } else if (msg.reconnect) {
       this.fetchUserData();
-    } else if (op == 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
-      let data = wsJsonToRes<GetPersonDetailsResponse>(msg);
-      this.setState({ personRes: data, loading: false });
-      this.setPersonBlock();
-      restoreScrollPosition(this.context);
-    } else if (op == UserOperation.AddAdmin) {
-      let data = wsJsonToRes<AddAdminResponse>(msg);
-      this.setState(s => ((s.siteRes.admins = data.admins), s));
-    } else if (op == UserOperation.CreateCommentLike) {
-      let data = wsJsonToRes<CommentResponse>(msg);
-      createCommentLikeRes(data.comment_view, this.state.personRes?.comments);
-      this.setState(this.state);
-    } else if (
-      op == UserOperation.EditComment ||
-      op == UserOperation.DeleteComment ||
-      op == UserOperation.RemoveComment
-    ) {
-      let data = wsJsonToRes<CommentResponse>(msg);
-      editCommentRes(data.comment_view, this.state.personRes?.comments);
-      this.setState(this.state);
-    } else if (op == UserOperation.CreateComment) {
-      let data = wsJsonToRes<CommentResponse>(msg);
-      let mui = UserService.Instance.myUserInfo;
-      if (data.comment_view.creator.id == mui?.local_user_view.person.id) {
-        toast(i18n.t("reply_sent"));
-      }
-    } else if (op == UserOperation.SaveComment) {
-      let data = wsJsonToRes<CommentResponse>(msg);
-      saveCommentRes(data.comment_view, this.state.personRes?.comments);
-      this.setState(this.state);
-    } else if (
-      op == UserOperation.EditPost ||
-      op == UserOperation.DeletePost ||
-      op == UserOperation.RemovePost ||
-      op == UserOperation.LockPost ||
-      op == UserOperation.FeaturePost ||
-      op == UserOperation.SavePost
-    ) {
-      let data = wsJsonToRes<PostResponse>(msg);
-      editPostFindRes(data.post_view, this.state.personRes?.posts);
-      this.setState(this.state);
-    } else if (op == UserOperation.CreatePostLike) {
-      let data = wsJsonToRes<PostResponse>(msg);
-      createPostLikeFindRes(data.post_view, this.state.personRes?.posts);
-      this.setState(this.state);
-    } else if (op == UserOperation.BanPerson) {
-      let data = wsJsonToRes<BanPersonResponse>(msg);
-      let 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));
-      let pv = res?.person_view;
-
-      if (pv?.person.id == data.person_view.person.id) {
-        pv.person.banned = data.banned;
-      }
-      this.setState(this.state);
-    } else if (op == UserOperation.BlockPerson) {
-      let data = wsJsonToRes<BlockPersonResponse>(msg);
-      updatePersonBlock(data);
-      this.setPersonBlock();
-      this.setState(this.state);
-    } else if (
-      op == UserOperation.PurgePerson ||
-      op == UserOperation.PurgePost ||
-      op == UserOperation.PurgeComment ||
-      op == UserOperation.PurgeCommunity
-    ) {
-      let data = wsJsonToRes<PurgeItemResponse>(msg);
-      if (data.success) {
-        toast(i18n.t("purge_success"));
-        this.context.router.history.push(`/`);
+    } 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;
+        }
+
+        case UserOperation.AddAdmin: {
+          const { admins } = wsJsonToRes<AddAdminResponse>(msg);
+          this.setState(s => ((s.siteRes.admins = admins), s));
+
+          break;
+        }
+
+        case UserOperation.CreateCommentLike: {
+          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
+          createCommentLikeRes(comment_view, this.state.personRes?.comments);
+          this.setState(this.state);
+
+          break;
+        }
+
+        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);
+
+          break;
+        }
+
+        case UserOperation.CreateComment: {
+          const {
+            comment_view: {
+              creator: { id },
+            },
+          } = wsJsonToRes<CommentResponse>(msg);
+          const mui = UserService.Instance.myUserInfo;
+
+          if (id === mui?.local_user_view.person.id) {
+            toast(i18n.t("reply_sent"));
+          }
+
+          break;
+        }
+
+        case UserOperation.SaveComment: {
+          const { comment_view } = wsJsonToRes<CommentResponse>(msg);
+          saveCommentRes(comment_view, this.state.personRes?.comments);
+          this.setState(this.state);
+
+          break;
+        }
+
+        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;
+        }
+
+        case UserOperation.CreatePostLike: {
+          const { post_view } = wsJsonToRes<PostResponse>(msg);
+          createPostLikeFindRes(post_view, this.state.personRes?.posts);
+          this.setState(this.state);
+
+          break;
+        }
+
+        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;
+        }
+
+        case UserOperation.BlockPerson: {
+          const data = wsJsonToRes<BlockPersonResponse>(msg);
+          updatePersonBlock(data);
+          this.setPersonBlock();
+
+          break;
+        }
+
+        case UserOperation.PurgePerson:
+        case UserOperation.PurgePost:
+        case UserOperation.PurgeComment:
+        case UserOperation.PurgeCommunity: {
+          const { success } = wsJsonToRes<PurgeItemResponse>(msg);
+
+          if (success) {
+            toast(i18n.t("purge_success"));
+            this.context.router.history.push(`/`);
+          }
+        }
       }
     }
   }
index a349d1cdc673187dde4f4bd704a032a1fb508633..a6f038b2545f481b9d2deb3cf1d772a7f834da07 100644 (file)
@@ -1,3 +1,4 @@
+import { NoOptionI18nKeys } from "i18next";
 import { Component, linkEvent } from "inferno";
 import {
   BlockCommunity,
@@ -6,13 +7,11 @@ import {
   BlockPersonResponse,
   ChangePassword,
   CommunityBlockView,
-  CommunityView,
   DeleteAccount,
   GetSiteResponse,
   ListingType,
   LoginResponse,
   PersonBlockView,
-  PersonViewSafe,
   SaveUserSettings,
   SortType,
   UserOperation,
@@ -24,19 +23,17 @@ import { i18n, languages } from "../../i18next";
 import { UserService, WebSocketService } from "../../services";
 import {
   capitalizeFirstLetter,
-  choicesConfig,
-  communitySelectName,
+  Choice,
   communityToChoice,
   debounce,
   elementUrl,
+  emDash,
   enableNsfw,
   fetchCommunities,
   fetchThemeList,
   fetchUsers,
   getLanguages,
-  isBrowser,
   myAuth,
-  personSelectName,
   personToChoice,
   relTags,
   setIsoData,
@@ -55,15 +52,11 @@ import { ImageUploadForm } from "../common/image-upload-form";
 import { LanguageSelect } from "../common/language-select";
 import { ListingTypeSelect } from "../common/listing-type-select";
 import { MarkdownTextArea } from "../common/markdown-textarea";
+import { SearchableSelect } from "../common/searchable-select";
 import { SortSelect } from "../common/sort-select";
 import { CommunityLink } from "../community/community-link";
 import { PersonListing } from "./person-listing";
 
-var Choices: any;
-if (isBrowser()) {
-  Choices = require("choices.js");
-}
-
 interface SettingsState {
   // TODO redo these forms
   saveUserSettingsForm: {
@@ -97,10 +90,7 @@ interface SettingsState {
     password?: string;
   };
   personBlocks: PersonBlockView[];
-  blockPerson?: PersonViewSafe;
   communityBlocks: CommunityBlockView[];
-  blockCommunityId: number;
-  blockCommunity?: CommunityView;
   currentTab: string;
   themeList: string[];
   saveUserSettingsLoading: boolean;
@@ -108,12 +98,50 @@ interface SettingsState {
   deleteAccountLoading: boolean;
   deleteAccountShowConfirm: boolean;
   siteRes: GetSiteResponse;
+  searchCommunityLoading: boolean;
+  searchCommunityOptions: Choice[];
+  searchPersonLoading: boolean;
+  searchPersonOptions: Choice[];
 }
 
+type FilterType = "user" | "community";
+
+const Filter = ({
+  filterType,
+  options,
+  onChange,
+  onSearch,
+  loading,
+}: {
+  filterType: FilterType;
+  options: Choice[];
+  onSearch: (text: string) => void;
+  onChange: (choice: Choice) => void;
+  loading: boolean;
+}) => (
+  <div className="form-group row">
+    <label
+      className="col-md-4 col-form-label"
+      htmlFor={`block-${filterType}-filter`}
+    >
+      {i18n.t(`block_${filterType}` as NoOptionI18nKeys)}
+    </label>
+    <div className="col-md-8">
+      <SearchableSelect
+        id={`block-${filterType}-filter`}
+        options={[
+          { label: emDash, value: "0", disabled: true } as Choice,
+        ].concat(options)}
+        loading={loading}
+        onChange={onChange}
+        onSearch={onSearch}
+      />
+    </div>
+  </div>
+);
+
 export class Settings extends Component<any, SettingsState> {
   private isoData = setIsoData(this.context);
-  private blockPersonChoices: any;
-  private blockCommunityChoices: any;
   private subscription?: Subscription;
   state: SettingsState = {
     saveUserSettingsForm: {},
@@ -125,10 +153,13 @@ export class Settings extends Component<any, SettingsState> {
     deleteAccountForm: {},
     personBlocks: [],
     communityBlocks: [],
-    blockCommunityId: 0,
     currentTab: "settings",
     siteRes: this.isoData.site_res,
     themeList: [],
+    searchCommunityLoading: false,
+    searchCommunityOptions: [],
+    searchPersonLoading: false,
+    searchPersonOptions: [],
   };
 
   constructor(props: any, context: any) {
@@ -149,35 +180,58 @@ export class Settings extends Component<any, SettingsState> {
     this.parseMessage = this.parseMessage.bind(this);
     this.subscription = wsSubscribe(this.parseMessage);
 
-    let mui = UserService.Instance.myUserInfo;
+    const mui = UserService.Instance.myUserInfo;
     if (mui) {
-      let luv = mui.local_user_view;
+      const {
+        local_user: {
+          show_nsfw,
+          theme,
+          default_sort_type,
+          default_listing_type,
+          interface_language,
+          show_avatars,
+          show_bot_accounts,
+          show_scores,
+          show_read_posts,
+          show_new_post_notifs,
+          send_notifications_to_email,
+          email,
+        },
+        person: {
+          avatar,
+          banner,
+          display_name,
+          bot_account,
+          bio,
+          matrix_user_id,
+        },
+      } = mui.local_user_view;
+
       this.state = {
         ...this.state,
         personBlocks: mui.person_blocks,
         communityBlocks: mui.community_blocks,
         saveUserSettingsForm: {
           ...this.state.saveUserSettingsForm,
-          show_nsfw: luv.local_user.show_nsfw,
-          theme: luv.local_user.theme ? luv.local_user.theme : "browser",
-          default_sort_type: luv.local_user.default_sort_type,
-          default_listing_type: luv.local_user.default_listing_type,
-          interface_language: luv.local_user.interface_language,
+          show_nsfw,
+          theme: theme ?? "browser",
+          default_sort_type,
+          default_listing_type,
+          interface_language,
           discussion_languages: mui.discussion_languages,
-          avatar: luv.person.avatar,
-          banner: luv.person.banner,
-          display_name: luv.person.display_name,
-          show_avatars: luv.local_user.show_avatars,
-          bot_account: luv.person.bot_account,
-          show_bot_accounts: luv.local_user.show_bot_accounts,
-          show_scores: luv.local_user.show_scores,
-          show_read_posts: luv.local_user.show_read_posts,
-          show_new_post_notifs: luv.local_user.show_new_post_notifs,
-          email: luv.local_user.email,
-          bio: luv.person.bio,
-          send_notifications_to_email:
-            luv.local_user.send_notifications_to_email,
-          matrix_user_id: luv.person.matrix_user_id,
+          avatar,
+          banner,
+          display_name,
+          show_avatars,
+          bot_account,
+          show_bot_accounts,
+          show_scores,
+          show_read_posts,
+          show_new_post_notifs,
+          email,
+          bio,
+          send_notifications_to_email,
+          matrix_user_id,
         },
       };
     }
@@ -349,9 +403,17 @@ export class Settings extends Component<any, SettingsState> {
   }
 
   blockUserCard() {
+    const { searchPersonLoading, searchPersonOptions } = this.state;
+
     return (
       <div>
-        {this.blockUserForm()}
+        <Filter
+          filterType="user"
+          loading={searchPersonLoading}
+          onChange={this.handleBlockPerson}
+          onSearch={this.handlePersonSearch}
+          options={searchPersonOptions}
+        />
         {this.blockedUsersList()}
       </div>
     );
@@ -384,38 +446,18 @@ export class Settings extends Component<any, SettingsState> {
     );
   }
 
-  blockUserForm() {
-    let blockPerson = this.state.blockPerson;
-    return (
-      <div className="form-group row">
-        <label
-          className="col-md-4 col-form-label"
-          htmlFor="block-person-filter"
-        >
-          {i18n.t("block_user")}
-        </label>
-        <div className="col-md-8">
-          <select
-            className="form-control"
-            id="block-person-filter"
-            value={blockPerson?.person.id ?? 0}
-          >
-            <option value="0">—</option>
-            {blockPerson && (
-              <option value={blockPerson.person.id}>
-                {personSelectName(blockPerson)}
-              </option>
-            )}
-          </select>
-        </div>
-      </div>
-    );
-  }
-
   blockCommunityCard() {
+    const { searchCommunityLoading, searchCommunityOptions } = this.state;
+
     return (
       <div>
-        {this.blockCommunityForm()}
+        <Filter
+          filterType="community"
+          loading={searchCommunityLoading}
+          onChange={this.handleBlockCommunity}
+          onSearch={this.handleCommunitySearch}
+          options={searchCommunityOptions}
+        />
         {this.blockedCommunitiesList()}
       </div>
     );
@@ -448,33 +490,6 @@ export class Settings extends Component<any, SettingsState> {
     );
   }
 
-  blockCommunityForm() {
-    return (
-      <div className="form-group row">
-        <label
-          className="col-md-4 col-form-label"
-          htmlFor="block-community-filter"
-        >
-          {i18n.t("block_community")}
-        </label>
-        <div className="col-md-8">
-          <select
-            className="form-control"
-            id="block-community-filter"
-            value={this.state.blockCommunityId}
-          >
-            <option value="0">—</option>
-            {this.state.blockCommunity && (
-              <option value={this.state.blockCommunity.community.id}>
-                {communitySelectName(this.state.blockCommunity)}
-              </option>
-            )}
-          </select>
-        </div>
-      </div>
-    );
-  }
-
   saveUserSettingsHtmlForm() {
     let selectedLangs = this.state.saveUserSettingsForm.discussion_languages;
 
@@ -907,91 +922,57 @@ export class Settings extends Component<any, SettingsState> {
     );
   }
 
-  setupBlockPersonChoices() {
-    if (isBrowser()) {
-      let selectId: any = document.getElementById("block-person-filter");
-      if (selectId) {
-        this.blockPersonChoices = new Choices(selectId, choicesConfig);
-        this.blockPersonChoices.passedElement.element.addEventListener(
-          "choice",
-          (e: any) => {
-            this.handleBlockPerson(Number(e.detail.choice.value));
-          },
-          false
-        );
-        this.blockPersonChoices.passedElement.element.addEventListener(
-          "search",
-          debounce(async (e: any) => {
-            try {
-              let persons = (await fetchUsers(e.detail.value)).users;
-              let choices = persons.map(pvs => personToChoice(pvs));
-              this.blockPersonChoices.setChoices(
-                choices,
-                "value",
-                "label",
-                true
-              );
-            } catch (err) {
-              console.error(err);
-            }
-          }),
-          false
-        );
-      }
+  handlePersonSearch = debounce(async (text: string) => {
+    this.setState({ searchPersonLoading: true });
+
+    const searchPersonOptions: Choice[] = [];
+
+    if (text.length > 0) {
+      searchPersonOptions.push(
+        ...(await fetchUsers(text)).users.map(personToChoice)
+      );
     }
-  }
 
-  setupBlockCommunityChoices() {
-    if (isBrowser()) {
-      let selectId: any = document.getElementById("block-community-filter");
-      if (selectId) {
-        this.blockCommunityChoices = new Choices(selectId, choicesConfig);
-        this.blockCommunityChoices.passedElement.element.addEventListener(
-          "choice",
-          (e: any) => {
-            this.handleBlockCommunity(Number(e.detail.choice.value));
-          },
-          false
-        );
-        this.blockCommunityChoices.passedElement.element.addEventListener(
-          "search",
-          debounce(async (e: any) => {
-            try {
-              let communities = (await fetchCommunities(e.detail.value))
-                .communities;
-              let choices = communities.map(cv => communityToChoice(cv));
-              this.blockCommunityChoices.setChoices(
-                choices,
-                "value",
-                "label",
-                true
-              );
-            } catch (err) {
-              console.log(err);
-            }
-          }),
-          false
-        );
-      }
+    this.setState({
+      searchPersonLoading: false,
+      searchPersonOptions,
+    });
+  });
+
+  handleCommunitySearch = debounce(async (text: string) => {
+    this.setState({ searchCommunityLoading: true });
+
+    const searchCommunityOptions: Choice[] = [];
+
+    if (text.length > 0) {
+      searchCommunityOptions.push(
+        ...(await fetchCommunities(text)).communities.map(communityToChoice)
+      );
     }
-  }
 
-  handleBlockPerson(personId: number) {
-    let auth = myAuth();
-    if (auth && personId != 0) {
-      let blockUserForm: BlockPerson = {
-        person_id: personId,
+    this.setState({
+      searchCommunityLoading: false,
+      searchCommunityOptions,
+    });
+  });
+
+  handleBlockPerson({ value }: Choice) {
+    const auth = myAuth();
+    if (auth && value !== "0") {
+      const blockUserForm: BlockPerson = {
+        person_id: Number(value),
         block: true,
         auth,
       };
+
       WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
     }
   }
 
   handleUnblockPerson(i: { ctx: Settings; recipientId: number }) {
-    let auth = myAuth();
+    const auth = myAuth();
     if (auth) {
-      let blockUserForm: BlockPerson = {
+      const blockUserForm: BlockPerson = {
         person_id: i.recipientId,
         block: false,
         auth,
@@ -1000,11 +981,11 @@ export class Settings extends Component<any, SettingsState> {
     }
   }
 
-  handleBlockCommunity(community_id: number) {
-    let auth = myAuth();
-    if (auth && community_id != 0) {
-      let blockCommunityForm: BlockCommunity = {
-        community_id,
+  handleBlockCommunity({ value }: Choice) {
+    const auth = myAuth();
+    if (auth && value !== "0") {
+      const blockCommunityForm: BlockCommunity = {
+        community_id: Number(value),
         block: true,
         auth,
       };
@@ -1015,9 +996,9 @@ export class Settings extends Component<any, SettingsState> {
   }
 
   handleUnblockCommunity(i: { ctx: Settings; communityId: number }) {
-    let auth = myAuth();
+    const auth = myAuth();
     if (auth) {
-      let blockCommunityForm: BlockCommunity = {
+      const blockCommunityForm: BlockCommunity = {
         community_id: i.communityId,
         block: false,
         auth,
@@ -1249,11 +1230,6 @@ export class Settings extends Component<any, SettingsState> {
 
   handleSwitchTab(i: { ctx: Settings; tab: string }) {
     i.ctx.setState({ currentTab: i.tab });
-
-    if (i.ctx.state.currentTab == "blocks") {
-      i.ctx.setupBlockPersonChoices();
-      i.ctx.setupBlockCommunityChoices();
-    }
   }
 
   parseMessage(msg: any) {
index c1d282e92576c205ff0b1c6f24df7897c17b5418..e40ca48102f242b3fceecd4858cdf0ba56818d12 100644 (file)
@@ -1,13 +1,10 @@
 import { Component } from "inferno";
+import { RouteComponentProps } from "inferno-router/dist/Route";
 import {
   GetCommunity,
   GetCommunityResponse,
   GetSiteResponse,
-  ListCommunities,
-  ListCommunitiesResponse,
-  ListingType,
   PostView,
-  SortType,
   UserOperation,
   wsJsonToRes,
   wsUserOp,
@@ -17,11 +14,15 @@ import { InitialFetchRequest, PostFormParams } from "shared/interfaces";
 import { i18n } from "../../i18next";
 import { UserService, WebSocketService } from "../../services";
 import {
+  Choice,
   enableDownvotes,
   enableNsfw,
-  fetchLimit,
+  getIdFromString,
+  getQueryParams,
+  getQueryString,
   isBrowser,
   myAuth,
+  QueryParams,
   setIsoData,
   toast,
   wsClient,
@@ -31,13 +32,26 @@ import { HtmlTags } from "../common/html-tags";
 import { Spinner } from "../common/icon";
 import { PostForm } from "./post-form";
 
+export interface CreatePostProps {
+  communityId?: number;
+}
+
+function getCreatePostQueryParams() {
+  return getQueryParams<CreatePostProps>({
+    communityId: getIdFromString,
+  });
+}
+
 interface CreatePostState {
-  listCommunitiesResponse?: ListCommunitiesResponse;
   siteRes: GetSiteResponse;
   loading: boolean;
+  selectedCommunityChoice?: Choice;
 }
 
-export class CreatePost extends Component<any, CreatePostState> {
+export class CreatePost extends Component<
+  RouteComponentProps<Record<string, never>>,
+  CreatePostState
+> {
   private isoData = setIsoData(this.context);
   private subscription?: Subscription;
   state: CreatePostState = {
@@ -45,10 +59,12 @@ export class CreatePost extends Component<any, CreatePostState> {
     loading: true,
   };
 
-  constructor(props: any, context: any) {
+  constructor(props: RouteComponentProps<Record<string, never>>, context: any) {
     super(props, context);
 
     this.handlePostCreate = this.handlePostCreate.bind(this);
+    this.handleSelectedCommunityChange =
+      this.handleSelectedCommunityChange.bind(this);
 
     this.parseMessage = this.parseMessage.bind(this);
     this.subscription = wsSubscribe(this.parseMessage);
@@ -59,45 +75,56 @@ export class CreatePost extends Component<any, CreatePostState> {
     }
 
     // Only fetch the data if coming from another route
-    if (this.isoData.path == this.context.router.route.match.url) {
+    if (this.isoData.path === this.context.router.route.match.url) {
+      const communityRes = this.isoData.routeData[0] as
+        | GetCommunityResponse
+        | undefined;
+
+      if (communityRes) {
+        const communityChoice: Choice = {
+          label: communityRes.community_view.community.name,
+          value: communityRes.community_view.community.id.toString(),
+        };
+
+        this.state = {
+          ...this.state,
+          selectedCommunityChoice: communityChoice,
+        };
+      }
+
       this.state = {
         ...this.state,
-        listCommunitiesResponse: this.isoData
-          .routeData[0] as ListCommunitiesResponse,
         loading: false,
       };
     } else {
-      this.refetch();
+      this.fetchCommunity();
     }
   }
 
-  refetch() {
-    let nameOrId = this.params.nameOrId;
-    let auth = myAuth(false);
-    if (nameOrId) {
-      if (typeof nameOrId === "string") {
-        let form: GetCommunity = {
-          name: nameOrId,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.getCommunity(form));
-      } else {
-        let form: GetCommunity = {
-          id: nameOrId,
-          auth,
-        };
-        WebSocketService.Instance.send(wsClient.getCommunity(form));
-      }
-    } else {
-      let listCommunitiesForm: ListCommunities = {
-        type_: ListingType.All,
-        sort: SortType.TopAll,
-        limit: fetchLimit,
+  fetchCommunity() {
+    const { communityId } = getCreatePostQueryParams();
+    const auth = myAuth(false);
+
+    if (communityId) {
+      const form: GetCommunity = {
+        id: communityId,
         auth,
       };
-      WebSocketService.Instance.send(
-        wsClient.listCommunities(listCommunitiesForm)
-      );
+
+      WebSocketService.Instance.send(wsClient.getCommunity(form));
+    }
+  }
+
+  componentDidMount(): void {
+    const { communityId } = getCreatePostQueryParams();
+
+    if (communityId?.toString() !== this.state.selectedCommunityChoice?.value) {
+      this.fetchCommunity();
+    } else if (!communityId) {
+      this.setState({
+        selectedCommunityChoice: undefined,
+        loading: false,
+      });
     }
   }
 
@@ -114,7 +141,12 @@ export class CreatePost extends Component<any, CreatePostState> {
   }
 
   render() {
-    let res = this.state.listCommunitiesResponse;
+    const { selectedCommunityChoice } = this.state;
+
+    const locationState = this.props.history.location.state as
+      | PostFormParams
+      | undefined;
+
     return (
       <div className="container-lg">
         <HtmlTags
@@ -126,96 +158,93 @@ export class CreatePost extends Component<any, CreatePostState> {
             <Spinner large />
           </h5>
         ) : (
-          res && (
-            <div className="row">
-              <div className="col-12 col-lg-6 offset-lg-3 mb-4">
-                <h5>{i18n.t("create_post")}</h5>
-                <PostForm
-                  communities={res.communities}
-                  onCreate={this.handlePostCreate}
-                  params={this.params}
-                  enableDownvotes={enableDownvotes(this.state.siteRes)}
-                  enableNsfw={enableNsfw(this.state.siteRes)}
-                  allLanguages={this.state.siteRes.all_languages}
-                  siteLanguages={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_post")}</h5>
+              <PostForm
+                onCreate={this.handlePostCreate}
+                params={locationState}
+                enableDownvotes={enableDownvotes(this.state.siteRes)}
+                enableNsfw={enableNsfw(this.state.siteRes)}
+                allLanguages={this.state.siteRes.all_languages}
+                siteLanguages={this.state.siteRes.discussion_languages}
+                selectedCommunityChoice={selectedCommunityChoice}
+                onSelectCommunity={this.handleSelectedCommunityChange}
+              />
             </div>
-          )
+          </div>
         )}
       </div>
     );
   }
 
-  get params(): PostFormParams {
-    let urlParams = new URLSearchParams(this.props.location.search);
-    let name = urlParams.get("community_name") ?? this.prevCommunityName;
-    let communityIdParam = urlParams.get("community_id");
-    let id = communityIdParam ? Number(communityIdParam) : this.prevCommunityId;
-    let nameOrId: string | number | undefined;
-    if (name) {
-      nameOrId = name;
-    } else if (id) {
-      nameOrId = id;
-    }
+  updateUrl({ communityId }: Partial<CreatePostProps>) {
+    const { communityId: urlCommunityId } = getCreatePostQueryParams();
 
-    let params: PostFormParams = {
-      name: urlParams.get("title") ?? undefined,
-      nameOrId,
-      body: urlParams.get("body") ?? undefined,
-      url: urlParams.get("url") ?? undefined,
+    const queryParams: QueryParams<CreatePostProps> = {
+      communityId: (communityId ?? urlCommunityId)?.toString(),
     };
 
-    return params;
-  }
+    const locationState = this.props.history.location.state as
+      | PostFormParams
+      | undefined;
 
-  get prevCommunityName(): string | undefined {
-    if (this.props.match.params.name) {
-      return this.props.match.params.name;
-    } else if (this.props.location.state) {
-      let lastLocation = this.props.location.state.prevPath;
-      if (lastLocation.includes("/c/")) {
-        return lastLocation.split("/c/").at(1);
-      }
-    }
-    return undefined;
+    this.props.history.push(
+      `/create_post${getQueryString(queryParams)}`,
+      locationState
+    );
+
+    this.fetchCommunity();
   }
 
-  get prevCommunityId(): number | undefined {
-    // TODO is this actually a number? Whats the real return type
-    let id = this.props.match.params.id;
-    return id ?? undefined;
+  handleSelectedCommunityChange(choice: Choice) {
+    this.updateUrl({
+      communityId: getIdFromString(choice?.value),
+    });
   }
 
   handlePostCreate(post_view: PostView) {
-    this.props.history.push(`/post/${post_view.post.id}`);
+    this.props.history.replace(`/post/${post_view.post.id}`);
   }
 
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
-    let listCommunitiesForm: ListCommunities = {
-      type_: ListingType.All,
-      sort: SortType.TopAll,
-      limit: fetchLimit,
-      auth: req.auth,
-    };
-    return [req.client.listCommunities(listCommunitiesForm)];
+  static fetchInitialData({
+    client,
+    query: { communityId },
+    auth,
+  }: InitialFetchRequest<QueryParams<CreatePostProps>>): Promise<any>[] {
+    const promises: Promise<any>[] = [];
+
+    if (communityId) {
+      const form: GetCommunity = {
+        auth,
+        id: getIdFromString(communityId),
+      };
+
+      promises.push(client.getCommunity(form));
+    } else {
+      promises.push(Promise.resolve());
+    }
+
+    return promises;
   }
 
   parseMessage(msg: any) {
-    let op = wsUserOp(msg);
+    const op = wsUserOp(msg);
     console.log(msg);
     if (msg.error) {
       toast(i18n.t(msg.error), "danger");
       return;
-    } else if (op == UserOperation.ListCommunities) {
-      let data = wsJsonToRes<ListCommunitiesResponse>(msg);
-      this.setState({ listCommunitiesResponse: data, loading: false });
-    } else if (op == UserOperation.GetCommunity) {
-      let data = wsJsonToRes<GetCommunityResponse>(msg);
-      this.setState({
-        listCommunitiesResponse: {
-          communities: [data.community_view],
+    }
+
+    if (op === UserOperation.GetCommunity) {
+      const {
+        community_view: {
+          community: { name, id },
         },
+      } = wsJsonToRes<GetCommunityResponse>(msg);
+
+      this.setState({
+        selectedCommunityChoice: { label: name, value: id.toString() },
         loading: false,
       });
     }
index db9955808668184bae2229ee69e15599b7cb5f1f..c24e890a6eba52a9aa1b56dbd2d07872ec227e4e 100644 (file)
@@ -2,7 +2,6 @@ import autosize from "autosize";
 import { Component, linkEvent } from "inferno";
 import { Prompt } from "inferno-router";
 import {
-  CommunityView,
   CreatePost,
   EditPost,
   Language,
@@ -24,14 +23,13 @@ import { UserService, WebSocketService } from "../../services";
 import {
   archiveTodayUrl,
   capitalizeFirstLetter,
-  choicesConfig,
-  communitySelectName,
+  Choice,
   communityToChoice,
   debounce,
   fetchCommunities,
+  getIdFromString,
   getSiteMetadata,
   ghostArchiveUrl,
-  isBrowser,
   isImage,
   myAuth,
   myFirstDiscussionLanguageId,
@@ -50,26 +48,23 @@ import {
 import { Icon, Spinner } from "../common/icon";
 import { LanguageSelect } from "../common/language-select";
 import { MarkdownTextArea } from "../common/markdown-textarea";
+import { SearchableSelect } from "../common/searchable-select";
 import { PostListings } from "./post-listings";
 
-var Choices: any;
-if (isBrowser()) {
-  Choices = require("choices.js");
-}
-
 const MAX_POST_TITLE_LENGTH = 200;
 
 interface PostFormProps {
   post_view?: PostView; // If a post is given, that means this is an edit
   allLanguages: Language[];
   siteLanguages: number[];
-  communities?: CommunityView[];
   params?: PostFormParams;
   onCancel?(): any;
   onCreate?(post: PostView): any;
   onEdit?(post: PostView): any;
   enableNsfw?: boolean;
   enableDownvotes?: boolean;
+  selectedCommunityChoice?: Choice;
+  onSelectCommunity?: (choice: Choice) => void;
 }
 
 interface PostFormState {
@@ -88,32 +83,34 @@ interface PostFormState {
   loading: boolean;
   imageLoading: boolean;
   communitySearchLoading: boolean;
+  communitySearchOptions: Choice[];
   previewMode: boolean;
 }
 
 export class PostForm extends Component<PostFormProps, PostFormState> {
   private subscription?: Subscription;
-  private choices: any;
   state: PostFormState = {
     form: {},
     loading: false,
     imageLoading: false,
     communitySearchLoading: false,
     previewMode: false,
+    communitySearchOptions: [],
   };
 
-  constructor(props: any, context: any) {
+  constructor(props: PostFormProps, context: any) {
     super(props, context);
     this.fetchSimilarPosts = debounce(this.fetchSimilarPosts.bind(this));
     this.fetchPageTitle = debounce(this.fetchPageTitle.bind(this));
     this.handlePostBodyChange = this.handlePostBodyChange.bind(this);
     this.handleLanguageChange = this.handleLanguageChange.bind(this);
+    this.handleCommunitySelect = this.handleCommunitySelect.bind(this);
 
     this.parseMessage = this.parseMessage.bind(this);
     this.subscription = wsSubscribe(this.parseMessage);
 
     // Means its an edit
-    let pv = this.props.post_view;
+    const pv = this.props.post_view;
     if (pv) {
       this.state = {
         ...this.state,
@@ -128,15 +125,26 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
       };
     }
 
-    let params = this.props.params;
+    const selectedCommunityChoice = this.props.selectedCommunityChoice;
+
+    if (selectedCommunityChoice) {
+      this.state = {
+        ...this.state,
+        form: {
+          ...this.state.form,
+          community_id: getIdFromString(selectedCommunityChoice.value),
+        },
+        communitySearchOptions: [selectedCommunityChoice],
+      };
+    }
+
+    const params = this.props.params;
     if (params) {
       this.state = {
         ...this.state,
         form: {
           ...this.state.form,
-          name: params.name,
-          url: params.url,
-          body: params.body,
+          ...params,
         },
       };
     }
@@ -144,8 +152,8 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
 
   componentDidMount() {
     setupTippy();
-    this.setupCommunities();
-    let textarea: any = document.getElementById("post-title");
+    const textarea: any = document.getElementById("post-title");
+
     if (textarea) {
       autosize(textarea);
     }
@@ -168,6 +176,19 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
     window.onbeforeunload = null;
   }
 
+  static getDerivedStateFromProps(
+    { selectedCommunityChoice }: PostFormProps,
+    { form, ...restState }: PostFormState
+  ) {
+    return {
+      ...restState,
+      form: {
+        ...form,
+        community_id: getIdFromString(selectedCommunityChoice?.value),
+      },
+    };
+  }
+
   render() {
     let firstLang =
       this.state.form.language_id ??
@@ -342,26 +363,23 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
                 className="col-sm-2 col-form-label"
                 htmlFor="post-community"
               >
-                {this.state.communitySearchLoading ? (
-                  <Spinner />
-                ) : (
-                  i18n.t("community")
-                )}
+                {i18n.t("community")}
               </label>
               <div className="col-sm-10">
-                <select
-                  className="form-control"
+                <SearchableSelect
                   id="post-community"
                   value={this.state.form.community_id}
-                  onInput={linkEvent(this, this.handlePostCommunityChange)}
-                >
-                  <option>{i18n.t("select_a_community")}</option>
-                  {this.props.communities?.map(cv => (
-                    <option key={cv.community.id} value={cv.community.id}>
-                      {communitySelectName(cv)}
-                    </option>
-                  ))}
-                </select>
+                  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>
             </div>
           )}
@@ -609,67 +627,41 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
       });
   }
 
-  setupCommunities() {
-    // Set up select searching
-    if (isBrowser()) {
-      let selectId: any = document.getElementById("post-community");
-      if (selectId) {
-        this.choices = new Choices(selectId, choicesConfig);
-        this.choices.passedElement.element.addEventListener(
-          "choice",
-          (e: any) => {
-            this.setState(
-              s => ((s.form.community_id = Number(e.detail.choice.value)), s)
-            );
-          },
-          false
-        );
-        this.choices.passedElement.element.addEventListener("search", () => {
-          this.setState({ communitySearchLoading: true });
-        });
-        this.choices.passedElement.element.addEventListener(
-          "search",
-          debounce(async (e: any) => {
-            try {
-              let communities = (await fetchCommunities(e.detail.value))
-                .communities;
-              this.choices.setChoices(
-                communities.map(cv => communityToChoice(cv)),
-                "value",
-                "label",
-                true
-              );
-              this.setState({ communitySearchLoading: false });
-            } catch (err) {
-              console.log(err);
-            }
-          }),
-          false
-        );
-      }
+  handleCommunitySearch = debounce(async (text: string) => {
+    const { selectedCommunityChoice } = this.props;
+    this.setState({ communitySearchLoading: true });
+
+    const newOptions: Choice[] = [];
+
+    if (selectedCommunityChoice) {
+      newOptions.push(selectedCommunityChoice);
     }
 
-    let pv = this.props.post_view;
-    this.setState(s => ((s.form.community_id = pv?.community.id), s));
-
-    let nameOrId = this.props.params?.nameOrId;
-    if (nameOrId) {
-      if (typeof nameOrId === "string") {
-        let name_ = nameOrId;
-        let foundCommunityId = this.props.communities?.find(
-          r => r.community.name == name_
-        )?.community.id;
-        this.setState(s => ((s.form.community_id = foundCommunityId), s));
-      } else {
-        let id = nameOrId;
-        this.setState(s => ((s.form.community_id = id), s));
-      }
+    if (text.length > 0) {
+      newOptions.push(
+        ...(await fetchCommunities(text)).communities.map(communityToChoice)
+      );
+
+      this.setState({
+        communitySearchOptions: newOptions,
+      });
     }
 
-    if (isBrowser() && this.state.form.community_id) {
-      this.choices.setChoiceByValue(this.state.form.community_id.toString());
+    this.setState({
+      communitySearchLoading: false,
+    });
+  });
+
+  handleCommunitySelect(choice: Choice) {
+    if (this.props.onSelectCommunity) {
+      this.setState({
+        loading: true,
+      });
+
+      this.props.onSelectCommunity(choice);
+
+      this.setState({ loading: false });
     }
-    this.setState(this.state);
   }
 
   parseMessage(msg: any) {
index 4b8d072bf4f9e0061c3af2e7913e054f3e1a8d72..34037aa4e455588357176a0738cfba137e954558 100644 (file)
@@ -25,7 +25,7 @@ import {
 } from "lemmy-js-client";
 import { externalHost } from "../../env";
 import { i18n } from "../../i18next";
-import { BanType, PurgeType } from "../../interfaces";
+import { BanType, PostFormParams, PurgeType } from "../../interfaces";
 import { UserService, WebSocketService } from "../../services";
 import {
   amAdmin,
@@ -147,7 +147,8 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
   }
 
   render() {
-    let post = this.props.post_view.post;
+    const post = this.props.post_view.post;
+
     return (
       <div className="post-listing">
         {!this.state.showEdit ? (
@@ -734,7 +735,14 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     return (
       <Link
         className="btn btn-link btn-animate text-muted py-0"
-        to={`/create_post${this.crossPostParams}`}
+        to={{
+          /* Empty string properties are required to satisfy type*/
+          pathname: "/create_post",
+          state: { ...this.crossPostParams },
+          hash: "",
+          key: "",
+          search: "",
+        }}
         title={i18n.t("cross_post")}
       >
         <Icon icon="copy" inline />
@@ -1461,18 +1469,22 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
     }
   }
 
-  get crossPostParams(): string {
-    let post = this.props.post_view.post;
-    let params = `?title=${encodeURIComponent(post.name)}`;
+  get crossPostParams(): PostFormParams {
+    const queryParams: PostFormParams = {};
+    const { name, url } = this.props.post_view.post;
 
-    if (post.url) {
-      params += `&url=${encodeURIComponent(post.url)}`;
+    queryParams.name = name;
+
+    if (url) {
+      queryParams.url = url;
     }
-    let crossPostBody = this.crossPostBody();
+
+    const crossPostBody = this.crossPostBody();
     if (crossPostBody) {
-      params += `&body=${encodeURIComponent(crossPostBody)}`;
+      queryParams.body = crossPostBody;
     }
-    return params;
+
+    return queryParams;
   }
 
   crossPostBody(): string | undefined {
index e36c9ad18df6df8ba6404eb64e12d496f3da1117..53b56e8feb0269bbd766636c39d5853a4b605439 100644 (file)
@@ -1,3 +1,4 @@
+import type { NoOptionI18nKeys } from "i18next";
 import { Component, linkEvent } from "inferno";
 import {
   CommentResponse,
@@ -30,9 +31,8 @@ import { CommentViewType, InitialFetchRequest } from "../interfaces";
 import { WebSocketService } from "../services";
 import {
   capitalizeFirstLetter,
-  choicesConfig,
+  Choice,
   commentsToFlatNodes,
-  communitySelectName,
   communityToChoice,
   createCommentLikeRes,
   createPostLikeFindRes,
@@ -42,12 +42,15 @@ import {
   fetchCommunities,
   fetchLimit,
   fetchUsers,
-  isBrowser,
+  getIdFromString,
+  getPageFromString,
+  getQueryParams,
+  getQueryString,
+  getUpdatedSearchId,
   myAuth,
   numToSI,
-  personSelectName,
   personToChoice,
-  pushNotNull,
+  QueryParams,
   restoreScrollPosition,
   routeListingTypeToEnum,
   routeSearchTypeToEnum,
@@ -64,51 +67,36 @@ import { HtmlTags } from "./common/html-tags";
 import { Spinner } from "./common/icon";
 import { ListingTypeSelect } from "./common/listing-type-select";
 import { Paginator } from "./common/paginator";
+import { SearchableSelect } from "./common/searchable-select";
 import { SortSelect } from "./common/sort-select";
 import { CommunityLink } from "./community/community-link";
 import { PersonListing } from "./person/person-listing";
 import { PostListing } from "./post/post-listing";
 
-var Choices: any;
-if (isBrowser()) {
-  Choices = require("choices.js");
-}
-
 interface SearchProps {
   q?: string;
-  type_: SearchType;
+  type: SearchType;
   sort: SortType;
   listingType: ListingType;
-  communityId: number;
-  creatorId: number;
+  communityId?: number | null;
+  creatorId?: number | null;
   page: number;
 }
 
+type FilterType = "creator" | "community";
+
 interface SearchState {
-  q?: string;
-  type_: SearchType;
-  sort: SortType;
-  listingType: ListingType;
-  communityId: number;
-  creatorId: number;
-  page: number;
   searchResponse?: SearchResponse;
   communities: CommunityView[];
   creatorDetails?: GetPersonDetailsResponse;
-  loading: boolean;
+  searchLoading: boolean;
+  searchCommunitiesLoading: boolean;
+  searchCreatorLoading: boolean;
   siteRes: GetSiteResponse;
   searchText?: string;
   resolveObjectResponse?: ResolveObjectResponse;
-}
-
-interface UrlParams {
-  q?: string;
-  type_?: SearchType;
-  sort?: SortType;
-  listingType?: ListingType;
-  communityId?: number;
-  creatorId?: number;
-  page?: number;
+  communitySearchOptions: Choice[];
+  creatorSearchOptions: Choice[];
 }
 
 interface Combined {
@@ -117,73 +105,173 @@ interface Combined {
   published: string;
 }
 
+const defaultSearchType = SearchType.All;
+const defaultSortType = SortType.TopAll;
+const defaultListingType = ListingType.All;
+
+const searchTypes = [
+  SearchType.All,
+  SearchType.Comments,
+  SearchType.Posts,
+  SearchType.Communities,
+  SearchType.Users,
+  SearchType.Url,
+];
+
+const getSearchQueryParams = () =>
+  getQueryParams<SearchProps>({
+    q: getSearchQueryFromQuery,
+    type: getSearchTypeFromQuery,
+    sort: getSortTypeFromQuery,
+    listingType: getListingTypeFromQuery,
+    communityId: getIdFromString,
+    creatorId: getIdFromString,
+    page: getPageFromString,
+  });
+
+const getSearchQueryFromQuery = (q?: string): string | undefined =>
+  q ? decodeURIComponent(q) : undefined;
+
+const getSearchTypeFromQuery = (type_?: string): SearchType =>
+  routeSearchTypeToEnum(type_ ?? "", defaultSearchType);
+
+const getSortTypeFromQuery = (sort?: string): SortType =>
+  routeSortTypeToEnum(sort ?? "", defaultSortType);
+
+const getListingTypeFromQuery = (listingType?: string): ListingType =>
+  routeListingTypeToEnum(listingType ?? "", defaultListingType);
+
+const postViewToCombined = (data: PostView): Combined => ({
+  type_: "posts",
+  data,
+  published: data.post.published,
+});
+
+const commentViewToCombined = (data: CommentView): Combined => ({
+  type_: "comments",
+  data,
+  published: data.comment.published,
+});
+
+const communityViewToCombined = (data: CommunityView): Combined => ({
+  type_: "communities",
+  data,
+  published: data.community.published,
+});
+
+const personViewSafeToCombined = (data: PersonViewSafe): Combined => ({
+  type_: "users",
+  data,
+  published: data.person.published,
+});
+
+const Filter = ({
+  filterType,
+  options,
+  onChange,
+  onSearch,
+  value,
+  loading,
+}: {
+  filterType: FilterType;
+  options: Choice[];
+  onSearch: (text: string) => void;
+  onChange: (choice: Choice) => void;
+  value?: number | null;
+  loading: boolean;
+}) => {
+  return (
+    <div className="form-group col-sm-6">
+      <label className="col-form-label" htmlFor={`${filterType}-filter`}>
+        {capitalizeFirstLetter(i18n.t(filterType))}
+      </label>
+      <SearchableSelect
+        id={`${filterType}-filter`}
+        options={[
+          {
+            label: i18n.t("all"),
+            value: "0",
+          },
+        ].concat(options)}
+        value={value ?? 0}
+        onSearch={onSearch}
+        onChange={onChange}
+        loading={loading}
+      />
+    </div>
+  );
+};
+
+const communityListing = ({
+  community,
+  counts: { subscribers },
+}: CommunityView) =>
+  getListing(
+    <CommunityLink community={community} />,
+    subscribers,
+    "number_of_subscribers"
+  );
+
+const personListing = ({ person, counts: { comment_count } }: PersonViewSafe) =>
+  getListing(
+    <PersonListing person={person} showApubName />,
+    comment_count,
+    "number_of_comments"
+  );
+
+const getListing = (
+  listing: JSX.Element,
+  count: number,
+  translationKey: "number_of_comments" | "number_of_subscribers"
+) => (
+  <>
+    <span>{listing}</span>
+    <span>{` - ${i18n.t(translationKey, {
+      count,
+      formattedCount: numToSI(count),
+    })}`}</span>
+  </>
+);
+
 export class Search extends Component<any, SearchState> {
   private isoData = setIsoData(this.context);
-  private communityChoices: any;
-  private creatorChoices: any;
   private subscription?: Subscription;
   state: SearchState = {
-    q: Search.getSearchQueryFromProps(this.props.match.params.q),
-    type_: Search.getSearchTypeFromProps(this.props.match.params.type),
-    sort: Search.getSortTypeFromProps(this.props.match.params.sort),
-    listingType: Search.getListingTypeFromProps(
-      this.props.match.params.listing_type
-    ),
-    page: Search.getPageFromProps(this.props.match.params.page),
-    searchText: Search.getSearchQueryFromProps(this.props.match.params.q),
-    communityId: Search.getCommunityIdFromProps(
-      this.props.match.params.community_id
-    ),
-    creatorId: Search.getCreatorIdFromProps(this.props.match.params.creator_id),
-    loading: false,
+    searchLoading: false,
     siteRes: this.isoData.site_res,
     communities: [],
+    searchCommunitiesLoading: false,
+    searchCreatorLoading: false,
+    creatorSearchOptions: [],
+    communitySearchOptions: [],
   };
 
-  static getSearchQueryFromProps(q?: string): string | undefined {
-    return q ? decodeURIComponent(q) : undefined;
-  }
-
-  static getSearchTypeFromProps(type_: string): SearchType {
-    return type_ ? routeSearchTypeToEnum(type_) : SearchType.All;
-  }
-
-  static getSortTypeFromProps(sort: string): SortType {
-    return sort ? routeSortTypeToEnum(sort) : SortType.TopAll;
-  }
-
-  static getListingTypeFromProps(listingType: string): ListingType {
-    return listingType ? routeListingTypeToEnum(listingType) : ListingType.All;
-  }
-
-  static getCommunityIdFromProps(id: string): number {
-    return id ? Number(id) : 0;
-  }
-
-  static getCreatorIdFromProps(id: string): number {
-    return id ? Number(id) : 0;
-  }
-
-  static getPageFromProps(page: string): number {
-    return page ? Number(page) : 1;
-  }
-
   constructor(props: any, context: any) {
     super(props, context);
 
     this.handleSortChange = this.handleSortChange.bind(this);
     this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
     this.handlePageChange = this.handlePageChange.bind(this);
+    this.handleCommunityFilterChange =
+      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 = {
+      ...this.state,
+      searchText: q,
+    };
+
     // Only fetch the data if coming from another route
-    if (this.isoData.path == this.context.router.route.match.url) {
-      let communityRes = this.isoData.routeData[0] as
+    if (this.isoData.path === this.context.router.route.match.url) {
+      const communityRes = this.isoData.routeData[0] as
         | GetCommunityResponse
         | undefined;
-      let communitiesRes = this.isoData.routeData[1] as
+      const communitiesRes = this.isoData.routeData[1] as
         | ListCommunitiesResponse
         | undefined;
       // This can be single or multiple communities given
@@ -193,34 +281,50 @@ export class Search extends Component<any, SearchState> {
           communities: communitiesRes.communities,
         };
       }
-
       if (communityRes) {
         this.state = {
           ...this.state,
           communities: [communityRes.community_view],
+          communitySearchOptions: [
+            communityToChoice(communityRes.community_view),
+          ],
         };
       }
 
+      const creatorRes = this.isoData.routeData[2] as GetPersonDetailsResponse;
+
       this.state = {
         ...this.state,
-        creatorDetails: this.isoData.routeData[2] as GetPersonDetailsResponse,
+        creatorDetails: creatorRes,
+        creatorSearchOptions: creatorRes
+          ? [personToChoice(creatorRes.person_view)]
+          : [],
       };
 
-      if (this.state.q != "") {
+      if (q !== "") {
         this.state = {
           ...this.state,
           searchResponse: this.isoData.routeData[3] as SearchResponse,
           resolveObjectResponse: this.isoData
             .routeData[4] as ResolveObjectResponse,
-          loading: false,
+          searchLoading: false,
         };
       } else {
         this.search();
       }
     } else {
-      this.fetchCommunities();
+      const listCommunitiesForm: ListCommunities = {
+        type_: defaultListingType,
+        sort: defaultSortType,
+        limit: fetchLimit,
+        auth: myAuth(false),
+      };
 
-      if (this.state.q) {
+      WebSocketService.Instance.send(
+        wsClient.listCommunities(listCommunitiesForm)
+      );
+
+      if (q) {
         this.search();
       }
     }
@@ -231,105 +335,66 @@ export class Search extends Component<any, SearchState> {
     saveScrollPosition(this.context);
   }
 
-  componentDidMount() {
-    this.setupCommunityFilter();
-    this.setupCreatorFilter();
-  }
+  static fetchInitialData({
+    client,
+    auth,
+    query: { communityId, creatorId, q, type, sort, listingType, page },
+  }: InitialFetchRequest<QueryParams<SearchProps>>): Promise<any>[] {
+    const promises: Promise<any>[] = [];
 
-  static getDerivedStateFromProps(
-    props: any,
-    prevState: SearchState
-  ): SearchProps {
-    return {
-      q: Search.getSearchQueryFromProps(props.match.params.q),
-      type_:
-        prevState.type_ ??
-        Search.getSearchTypeFromProps(props.match.params.type),
-      sort:
-        prevState.sort ?? Search.getSortTypeFromProps(props.match.params.sort),
-      listingType:
-        prevState.listingType ??
-        Search.getListingTypeFromProps(props.match.params.listing_type),
-      communityId: Search.getCommunityIdFromProps(
-        props.match.params.community_id
-      ),
-      creatorId: Search.getCreatorIdFromProps(props.match.params.creator_id),
-      page: Search.getPageFromProps(props.match.params.page),
-    };
-  }
-
-  fetchCommunities() {
-    let listCommunitiesForm: ListCommunities = {
-      type_: ListingType.All,
-      sort: SortType.TopAll,
-      limit: fetchLimit,
-      auth: myAuth(false),
-    };
-    WebSocketService.Instance.send(
-      wsClient.listCommunities(listCommunitiesForm)
-    );
-  }
-
-  static fetchInitialData(req: InitialFetchRequest): Promise<any>[] {
-    let pathSplit = req.path.split("/");
-    let promises: Promise<any>[] = [];
-    let auth = req.auth;
-
-    let communityId = this.getCommunityIdFromProps(pathSplit[11]);
-    let community_id = communityId == 0 ? undefined : communityId;
+    const community_id = getIdFromString(communityId);
     if (community_id) {
-      let getCommunityForm: GetCommunity = {
+      const getCommunityForm: GetCommunity = {
         id: community_id,
         auth,
       };
-      promises.push(req.client.getCommunity(getCommunityForm));
+      promises.push(client.getCommunity(getCommunityForm));
       promises.push(Promise.resolve());
     } else {
-      let listCommunitiesForm: ListCommunities = {
-        type_: ListingType.All,
-        sort: SortType.TopAll,
+      const listCommunitiesForm: ListCommunities = {
+        type_: defaultListingType,
+        sort: defaultSortType,
         limit: fetchLimit,
-        auth: req.auth,
+        auth,
       };
       promises.push(Promise.resolve());
-      promises.push(req.client.listCommunities(listCommunitiesForm));
+      promises.push(client.listCommunities(listCommunitiesForm));
     }
 
-    let creatorId = this.getCreatorIdFromProps(pathSplit[13]);
-    let creator_id = creatorId == 0 ? undefined : creatorId;
+    const creator_id = getIdFromString(creatorId);
     if (creator_id) {
-      let getCreatorForm: GetPersonDetails = {
+      const getCreatorForm: GetPersonDetails = {
         person_id: creator_id,
-        auth: req.auth,
+        auth,
       };
-      promises.push(req.client.getPersonDetails(getCreatorForm));
+      promises.push(client.getPersonDetails(getCreatorForm));
     } else {
       promises.push(Promise.resolve());
     }
 
-    let q = this.getSearchQueryFromProps(pathSplit[3]);
+    const query = getSearchQueryFromQuery(q);
 
-    if (q) {
-      let form: SearchForm = {
-        q,
+    if (query) {
+      const form: SearchForm = {
+        q: query,
         community_id,
         creator_id,
-        type_: this.getSearchTypeFromProps(pathSplit[5]),
-        sort: this.getSortTypeFromProps(pathSplit[7]),
-        listing_type: this.getListingTypeFromProps(pathSplit[9]),
-        page: this.getPageFromProps(pathSplit[15]),
+        type_: getSearchTypeFromQuery(type),
+        sort: getSortTypeFromQuery(sort),
+        listing_type: getListingTypeFromQuery(listingType),
+        page: getIdFromString(page),
         limit: fetchLimit,
-        auth: req.auth,
+        auth,
       };
 
-      let resolveObjectForm: ResolveObject = {
-        q,
-        auth: req.auth,
+      const resolveObjectForm: ResolveObject = {
+        q: query,
+        auth,
       };
 
-      if (form.q != "") {
-        promises.push(req.client.search(form));
-        promises.push(req.client.resolveObject(resolveObjectForm));
+      if (query !== "") {
+        promises.push(client.search(form));
+        promises.push(client.resolveObject(resolveObjectForm));
       } else {
         promises.push(Promise.resolve());
         promises.push(Promise.resolve());
@@ -339,34 +404,15 @@ export class Search extends Component<any, SearchState> {
     return promises;
   }
 
-  componentDidUpdate(_: any, lastState: SearchState) {
-    if (
-      lastState.q !== this.state.q ||
-      lastState.type_ !== this.state.type_ ||
-      lastState.sort !== this.state.sort ||
-      lastState.listingType !== this.state.listingType ||
-      lastState.communityId !== this.state.communityId ||
-      lastState.creatorId !== this.state.creatorId ||
-      lastState.page !== this.state.page
-    ) {
-      if (this.state.q) {
-        this.setState({
-          loading: true,
-          searchText: this.state.q,
-        });
-        this.search();
-      }
-    }
-  }
-
   get documentTitle(): string {
-    let siteName = this.state.siteRes.site_view.site.name;
-    return this.state.q
-      ? `${i18n.t("search")} - ${this.state.q} - ${siteName}`
-      : `${i18n.t("search")} - ${siteName}`;
+    const { q } = getSearchQueryParams();
+    const name = this.state.siteRes.site_view.site.name;
+    return `${i18n.t("search")} - ${q ? `${q} - ` : ""}${name}`;
   }
 
   render() {
+    const { type, page } = getSearchQueryParams();
+
     return (
       <div className="container-lg">
         <HtmlTags
@@ -374,21 +420,34 @@ export class Search extends Component<any, SearchState> {
           path={this.context.router.route.match.url}
         />
         <h5>{i18n.t("search")}</h5>
-        {this.selects()}
-        {this.searchForm()}
-        {this.state.type_ == SearchType.All && this.all()}
-        {this.state.type_ == SearchType.Comments && this.comments()}
-        {this.state.type_ == SearchType.Posts && this.posts()}
-        {this.state.type_ == SearchType.Communities && this.communities()}
-        {this.state.type_ == SearchType.Users && this.users()}
-        {this.state.type_ == SearchType.Url && this.posts()}
-        {this.resultsCount() == 0 && <span>{i18n.t("no_results")}</span>}
-        <Paginator page={this.state.page} onChange={this.handlePageChange} />
+        {this.selects}
+        {this.searchForm}
+        {this.displayResults(type)}
+        {this.resultsCount === 0 && <span>{i18n.t("no_results")}</span>}
+        <Paginator page={page} onChange={this.handlePageChange} />
       </div>
     );
   }
 
-  searchForm() {
+  displayResults(type: SearchType) {
+    switch (type) {
+      case SearchType.All:
+        return this.all;
+      case SearchType.Comments:
+        return this.comments;
+      case SearchType.Posts:
+      case SearchType.Url:
+        return this.posts;
+      case SearchType.Communities:
+        return this.communities;
+      case SearchType.Users:
+        return this.users;
+      default:
+        return <></>;
+    }
+  }
+
+  get searchForm() {
     return (
       <form
         className="form-inline"
@@ -405,17 +464,30 @@ export class Search extends Component<any, SearchState> {
           minLength={1}
         />
         <button type="submit" className="btn btn-secondary mr-2 mb-2">
-          {this.state.loading ? <Spinner /> : <span>{i18n.t("search")}</span>}
+          {this.state.searchLoading ? (
+            <Spinner />
+          ) : (
+            <span>{i18n.t("search")}</span>
+          )}
         </button>
       </form>
     );
   }
 
-  selects() {
+  get selects() {
+    const { type, listingType, sort, communityId, creatorId } =
+      getSearchQueryParams();
+    const {
+      communitySearchOptions,
+      creatorSearchOptions,
+      searchCommunitiesLoading,
+      searchCreatorLoading,
+    } = this.state;
+
     return (
       <div className="mb-2">
         <select
-          value={this.state.type_}
+          value={type}
           onChange={linkEvent(this, this.handleTypeChange)}
           className="custom-select w-auto mb-2"
           aria-label={i18n.t("type")}
@@ -423,18 +495,15 @@ export class Search extends Component<any, SearchState> {
           <option disabled aria-hidden="true">
             {i18n.t("type")}
           </option>
-          <option value={SearchType.All}>{i18n.t("all")}</option>
-          <option value={SearchType.Comments}>{i18n.t("comments")}</option>
-          <option value={SearchType.Posts}>{i18n.t("posts")}</option>
-          <option value={SearchType.Communities}>
-            {i18n.t("communities")}
-          </option>
-          <option value={SearchType.Users}>{i18n.t("users")}</option>
-          <option value={SearchType.Url}>{i18n.t("url")}</option>
+          {searchTypes.map(option => (
+            <option value={option} key={option}>
+              {i18n.t(option.toString().toLowerCase() as NoOptionI18nKeys)}
+            </option>
+          ))}
         </select>
         <span className="ml-2">
           <ListingTypeSelect
-            type_={this.state.listingType}
+            type_={listingType}
             showLocal={showLocal(this.isoData)}
             showSubscribed
             onChange={this.handleListingTypeChange}
@@ -442,99 +511,76 @@ export class Search extends Component<any, SearchState> {
         </span>
         <span className="ml-2">
           <SortSelect
-            sort={this.state.sort}
+            sort={sort}
             onChange={this.handleSortChange}
             hideHot
             hideMostComments
           />
         </span>
         <div className="form-row">
-          {this.state.communities.length > 0 && this.communityFilter()}
-          {this.creatorFilter()}
+          {this.state.communities.length > 0 && (
+            <Filter
+              filterType="community"
+              onChange={this.handleCommunityFilterChange}
+              onSearch={this.handleCommunitySearch}
+              options={communitySearchOptions}
+              loading={searchCommunitiesLoading}
+              value={communityId}
+            />
+          )}
+          <Filter
+            filterType="creator"
+            onChange={this.handleCreatorFilterChange}
+            onSearch={this.handleCreatorSearch}
+            options={creatorSearchOptions}
+            loading={searchCreatorLoading}
+            value={creatorId}
+          />
         </div>
       </div>
     );
   }
 
-  postViewToCombined(postView: PostView): Combined {
-    return {
-      type_: "posts",
-      data: postView,
-      published: postView.post.published,
-    };
-  }
-
-  commentViewToCombined(commentView: CommentView): Combined {
-    return {
-      type_: "comments",
-      data: commentView,
-      published: commentView.comment.published,
-    };
-  }
-
-  communityViewToCombined(communityView: CommunityView): Combined {
-    return {
-      type_: "communities",
-      data: communityView,
-      published: communityView.community.published,
-    };
-  }
-
-  personViewSafeToCombined(personViewSafe: PersonViewSafe): Combined {
-    return {
-      type_: "users",
-      data: personViewSafe,
-      published: personViewSafe.person.published,
-    };
-  }
-
   buildCombined(): Combined[] {
-    let combined: Combined[] = [];
+    const combined: Combined[] = [];
+    const { resolveObjectResponse, searchResponse } = this.state;
 
-    let resolveRes = this.state.resolveObjectResponse;
     // Push the possible resolve / federated objects first
-    if (resolveRes) {
-      let resolveComment = resolveRes.comment;
-      if (resolveComment) {
-        combined.push(this.commentViewToCombined(resolveComment));
+    if (resolveObjectResponse) {
+      const { comment, post, community, person } = resolveObjectResponse;
+
+      if (comment) {
+        combined.push(commentViewToCombined(comment));
       }
-      let resolvePost = resolveRes.post;
-      if (resolvePost) {
-        combined.push(this.postViewToCombined(resolvePost));
+      if (post) {
+        combined.push(postViewToCombined(post));
       }
-      let resolveCommunity = resolveRes.community;
-      if (resolveCommunity) {
-        combined.push(this.communityViewToCombined(resolveCommunity));
+      if (community) {
+        combined.push(communityViewToCombined(community));
       }
-      let resolveUser = resolveRes.person;
-      if (resolveUser) {
-        combined.push(this.personViewSafeToCombined(resolveUser));
+      if (person) {
+        combined.push(personViewSafeToCombined(person));
       }
     }
 
     // Push the search results
-    let searchRes = this.state.searchResponse;
-    if (searchRes) {
-      pushNotNull(
-        combined,
-        searchRes.comments?.map(e => this.commentViewToCombined(e))
-      );
-      pushNotNull(
-        combined,
-        searchRes.posts?.map(e => this.postViewToCombined(e))
-      );
-      pushNotNull(
-        combined,
-        searchRes.communities?.map(e => this.communityViewToCombined(e))
-      );
-      pushNotNull(
-        combined,
-        searchRes.users?.map(e => this.personViewSafeToCombined(e))
+    if (searchResponse) {
+      const { comments, posts, communities, users } = searchResponse;
+
+      combined.push(
+        ...[
+          ...(comments?.map(commentViewToCombined) ?? []),
+          ...(posts?.map(postViewToCombined) ?? []),
+          ...(communities?.map(communityViewToCombined) ?? []),
+          ...(users?.map(personViewSafeToCombined) ?? []),
+        ]
       );
     }
 
+    const { sort } = getSearchQueryParams();
+
     // Sort it
-    if (this.state.sort == SortType.New) {
+    if (sort === SortType.New) {
       combined.sort((a, b) => b.published.localeCompare(a.published));
     } else {
       combined.sort(
@@ -547,17 +593,19 @@ export class Search extends Component<any, SearchState> {
             (a.data as PersonViewSafe).counts.comment_score)
       );
     }
+
     return combined;
   }
 
-  all() {
-    let combined = this.buildCombined();
+  get all() {
+    const combined = this.buildCombined();
+
     return (
       <div>
         {combined.map(i => (
           <div key={i.published} className="row">
             <div className="col-12">
-              {i.type_ == "posts" && (
+              {i.type_ === "posts" && (
                 <PostListing
                   key={(i.data as PostView).post.id}
                   post_view={i.data as PostView}
@@ -569,7 +617,7 @@ export class Search extends Component<any, SearchState> {
                   viewOnly
                 />
               )}
-              {i.type_ == "comments" && (
+              {i.type_ === "comments" && (
                 <CommentNodes
                   key={(i.data as CommentView).comment.id}
                   nodes={[
@@ -588,11 +636,11 @@ export class Search extends Component<any, SearchState> {
                   siteLanguages={this.state.siteRes.discussion_languages}
                 />
               )}
-              {i.type_ == "communities" && (
-                <div>{this.communityListing(i.data as CommunityView)}</div>
+              {i.type_ === "communities" && (
+                <div>{communityListing(i.data as CommunityView)}</div>
               )}
-              {i.type_ == "users" && (
-                <div>{this.personListing(i.data as PersonViewSafe)}</div>
+              {i.type_ === "users" && (
+                <div>{personListing(i.data as PersonViewSafe)}</div>
               )}
             </div>
           </div>
@@ -601,10 +649,13 @@ export class Search extends Component<any, SearchState> {
     );
   }
 
-  comments() {
-    let comments: CommentView[] = [];
-    pushNotNull(comments, this.state.resolveObjectResponse?.comment);
-    pushNotNull(comments, this.state.searchResponse?.comments);
+  get comments() {
+    const { searchResponse, resolveObjectResponse, siteRes } = this.state;
+    const comments = searchResponse?.comments ?? [];
+
+    if (resolveObjectResponse?.comment) {
+      comments.unshift(resolveObjectResponse?.comment);
+    }
 
     return (
       <CommentNodes
@@ -613,18 +664,20 @@ export class Search extends Component<any, SearchState> {
         viewOnly
         locked
         noIndent
-        enableDownvotes={enableDownvotes(this.state.siteRes)}
-        allLanguages={this.state.siteRes.all_languages}
-        siteLanguages={this.state.siteRes.discussion_languages}
+        enableDownvotes={enableDownvotes(siteRes)}
+        allLanguages={siteRes.all_languages}
+        siteLanguages={siteRes.discussion_languages}
       />
     );
   }
 
-  posts() {
-    let posts: PostView[] = [];
+  get posts() {
+    const { searchResponse, resolveObjectResponse, siteRes } = this.state;
+    const posts = searchResponse?.posts ?? [];
 
-    pushNotNull(posts, this.state.resolveObjectResponse?.post);
-    pushNotNull(posts, this.state.searchResponse?.posts);
+    if (resolveObjectResponse?.post) {
+      posts.unshift(resolveObjectResponse.post);
+    }
 
     return (
       <>
@@ -634,10 +687,10 @@ export class Search extends Component<any, SearchState> {
               <PostListing
                 post_view={pv}
                 showCommunity
-                enableDownvotes={enableDownvotes(this.state.siteRes)}
-                enableNsfw={enableNsfw(this.state.siteRes)}
-                allLanguages={this.state.siteRes.all_languages}
-                siteLanguages={this.state.siteRes.discussion_languages}
+                enableDownvotes={enableDownvotes(siteRes)}
+                enableNsfw={enableNsfw(siteRes)}
+                allLanguages={siteRes.all_languages}
+                siteLanguages={siteRes.discussion_languages}
                 viewOnly
               />
             </div>
@@ -647,131 +700,55 @@ export class Search extends Component<any, SearchState> {
     );
   }
 
-  communities() {
-    let communities: CommunityView[] = [];
+  get communities() {
+    const { searchResponse, resolveObjectResponse } = this.state;
+    const communities = searchResponse?.communities ?? [];
 
-    pushNotNull(communities, this.state.resolveObjectResponse?.community);
-    pushNotNull(communities, this.state.searchResponse?.communities);
+    if (resolveObjectResponse?.community) {
+      communities.unshift(resolveObjectResponse.community);
+    }
 
     return (
       <>
         {communities.map(cv => (
           <div key={cv.community.id} className="row">
-            <div className="col-12">{this.communityListing(cv)}</div>
+            <div className="col-12">{communityListing(cv)}</div>
           </div>
         ))}
       </>
     );
   }
 
-  users() {
-    let users: PersonViewSafe[] = [];
+  get users() {
+    const { searchResponse, resolveObjectResponse } = this.state;
+    const users = searchResponse?.users ?? [];
 
-    pushNotNull(users, this.state.resolveObjectResponse?.person);
-    pushNotNull(users, this.state.searchResponse?.users);
+    if (resolveObjectResponse?.person) {
+      users.unshift(resolveObjectResponse.person);
+    }
 
     return (
       <>
         {users.map(pvs => (
           <div key={pvs.person.id} className="row">
-            <div className="col-12">{this.personListing(pvs)}</div>
+            <div className="col-12">{personListing(pvs)}</div>
           </div>
         ))}
       </>
     );
   }
 
-  communityListing(community_view: CommunityView) {
-    return (
-      <>
-        <span>
-          <CommunityLink community={community_view.community} />
-        </span>
-        <span>{` -
-        ${i18n.t("number_of_subscribers", {
-          count: community_view.counts.subscribers,
-          formattedCount: numToSI(community_view.counts.subscribers),
-        })}
-      `}</span>
-      </>
-    );
-  }
-
-  personListing(person_view: PersonViewSafe) {
-    return (
-      <>
-        <span>
-          <PersonListing person={person_view.person} showApubName />
-        </span>
-        <span>{` - ${i18n.t("number_of_comments", {
-          count: person_view.counts.comment_count,
-          formattedCount: numToSI(person_view.counts.comment_count),
-        })}`}</span>
-      </>
-    );
-  }
-
-  communityFilter() {
-    return (
-      <div className="form-group col-sm-6">
-        <label className="col-form-label" htmlFor="community-filter">
-          {i18n.t("community")}
-        </label>
-        <div>
-          <select
-            className="form-control"
-            id="community-filter"
-            value={this.state.communityId}
-          >
-            <option value="0">{i18n.t("all")}</option>
-            {this.state.communities.map(cv => (
-              <option key={cv.community.id} value={cv.community.id}>
-                {communitySelectName(cv)}
-              </option>
-            ))}
-          </select>
-        </div>
-      </div>
-    );
-  }
-
-  creatorFilter() {
-    let creatorPv = this.state.creatorDetails?.person_view;
-    return (
-      <div className="form-group col-sm-6">
-        <label className="col-form-label" htmlFor="creator-filter">
-          {capitalizeFirstLetter(i18n.t("creator"))}
-        </label>
-        <div>
-          <select
-            className="form-control"
-            id="creator-filter"
-            value={this.state.creatorId}
-          >
-            <option value="0">{i18n.t("all")}</option>
-            {creatorPv && (
-              <option value={creatorPv.person.id}>
-                {personSelectName(creatorPv)}
-              </option>
-            )}
-          </select>
-        </div>
-      </div>
-    );
-  }
-
-  resultsCount(): number {
-    let r = this.state.searchResponse;
+  get resultsCount(): number {
+    const { searchResponse: r, resolveObjectResponse: resolveRes } = this.state;
 
-    let searchCount = r
-      ? r.posts?.length +
-        r.comments?.length +
-        r.communities?.length +
-        r.users?.length
+    const searchCount = r
+      ? r.posts.length +
+        r.comments.length +
+        r.communities.length +
+        r.users.length
       : 0;
 
-    let resolveRes = this.state.resolveObjectResponse;
-    let resObjCount = resolveRes
+    const resObjCount = resolveRes
       ? resolveRes.post ||
         resolveRes.person ||
         resolveRes.community ||
@@ -783,154 +760,141 @@ export class Search extends Component<any, SearchState> {
     return resObjCount + searchCount;
   }
 
-  handlePageChange(page: number) {
-    this.updateUrl({ page });
-  }
-
   search() {
-    let community_id =
-      this.state.communityId == 0 ? undefined : this.state.communityId;
-    let creator_id =
-      this.state.creatorId == 0 ? undefined : this.state.creatorId;
-
-    let auth = myAuth(false);
-    if (this.state.q && this.state.q != "") {
-      let form: SearchForm = {
-        q: this.state.q,
-        community_id,
-        creator_id,
-        type_: this.state.type_,
-        sort: this.state.sort,
-        listing_type: this.state.listingType,
-        page: this.state.page,
+    const auth = myAuth(false);
+    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,
       };
 
-      let resolveObjectForm: ResolveObject = {
-        q: this.state.q,
+      const resolveObjectForm: ResolveObject = {
+        q,
         auth,
       };
 
       this.setState({
         searchResponse: undefined,
         resolveObjectResponse: undefined,
-        loading: true,
+        searchLoading: true,
       });
+
       WebSocketService.Instance.send(wsClient.search(form));
       WebSocketService.Instance.send(wsClient.resolveObject(resolveObjectForm));
     }
   }
 
-  setupCommunityFilter() {
-    if (isBrowser()) {
-      let selectId: any = document.getElementById("community-filter");
-      if (selectId) {
-        this.communityChoices = new Choices(selectId, choicesConfig);
-        this.communityChoices.passedElement.element.addEventListener(
-          "choice",
-          (e: any) => {
-            this.handleCommunityFilterChange(Number(e.detail.choice.value));
-          },
-          false
-        );
-        this.communityChoices.passedElement.element.addEventListener(
-          "search",
-          debounce(async (e: any) => {
-            try {
-              let communities = (await fetchCommunities(e.detail.value))
-                .communities;
-              let choices = communities.map(cv => communityToChoice(cv));
-              choices.unshift({ value: "0", label: i18n.t("all") });
-              this.communityChoices.setChoices(choices, "value", "label", true);
-            } catch (err) {
-              console.error(err);
-            }
-          }),
-          false
-        );
-      }
+  handleCreatorSearch = debounce(async (text: string) => {
+    const { creatorId } = getSearchQueryParams();
+    const { creatorSearchOptions } = this.state;
+    this.setState({
+      searchCreatorLoading: true,
+    });
+
+    const newOptions: Choice[] = [];
+
+    const selectedChoice = creatorSearchOptions.find(
+      choice => getIdFromString(choice.value) === creatorId
+    );
+
+    if (selectedChoice) {
+      newOptions.push(selectedChoice);
     }
-  }
 
-  setupCreatorFilter() {
-    if (isBrowser()) {
-      let selectId: any = document.getElementById("creator-filter");
-      if (selectId) {
-        this.creatorChoices = new Choices(selectId, choicesConfig);
-        this.creatorChoices.passedElement.element.addEventListener(
-          "choice",
-          (e: any) => {
-            this.handleCreatorFilterChange(Number(e.detail.choice.value));
-          },
-          false
-        );
-        this.creatorChoices.passedElement.element.addEventListener(
-          "search",
-          debounce(async (e: any) => {
-            try {
-              let creators = (await fetchUsers(e.detail.value)).users;
-              let choices = creators.map(pvs => personToChoice(pvs));
-              choices.unshift({ value: "0", label: i18n.t("all") });
-              this.creatorChoices.setChoices(choices, "value", "label", true);
-            } catch (err) {
-              console.log(err);
-            }
-          }),
-          false
-        );
-      }
+    if (text.length > 0) {
+      newOptions.push(...(await fetchUsers(text)).users.map(personToChoice));
     }
-  }
 
-  handleSortChange(val: SortType) {
-    const updateObj = { sort: val, page: 1 };
-    this.setState(updateObj);
-    this.updateUrl(updateObj);
+    this.setState({
+      searchCreatorLoading: false,
+      creatorSearchOptions: newOptions,
+    });
+  });
+
+  handleCommunitySearch = debounce(async (text: string) => {
+    const { communityId } = getSearchQueryParams();
+    const { communitySearchOptions } = this.state;
+    this.setState({
+      searchCommunitiesLoading: true,
+    });
+
+    const newOptions: Choice[] = [];
+
+    const selectedChoice = communitySearchOptions.find(
+      choice => getIdFromString(choice.value) === communityId
+    );
+
+    if (selectedChoice) {
+      newOptions.push(selectedChoice);
+    }
+
+    if (text.length > 0) {
+      newOptions.push(
+        ...(await fetchCommunities(text)).communities.map(communityToChoice)
+      );
+    }
+
+    this.setState({
+      searchCommunitiesLoading: false,
+      communitySearchOptions: newOptions,
+    });
+  });
+
+  handleSortChange(sort: SortType) {
+    this.updateUrl({ sort, page: 1 });
   }
 
   handleTypeChange(i: Search, event: any) {
-    const updateObj = {
-      type_: SearchType[event.target.value],
+    const type = SearchType[event.target.value];
+
+    i.updateUrl({
+      type,
       page: 1,
-    };
-    i.setState(updateObj);
-    i.updateUrl(updateObj);
+    });
   }
 
-  handleListingTypeChange(val: ListingType) {
-    const updateObj = {
-      listingType: val,
+  handlePageChange(page: number) {
+    this.updateUrl({ page });
+  }
+
+  handleListingTypeChange(listingType: ListingType) {
+    this.updateUrl({
+      listingType,
       page: 1,
-    };
-    this.setState(updateObj);
-    this.updateUrl(updateObj);
+    });
   }
 
-  handleCommunityFilterChange(communityId: number) {
+  handleCommunityFilterChange({ value }: Choice) {
     this.updateUrl({
-      communityId,
+      communityId: getIdFromString(value) ?? null,
       page: 1,
     });
   }
 
-  handleCreatorFilterChange(creatorId: number) {
+  handleCreatorFilterChange({ value }: Choice) {
     this.updateUrl({
-      creatorId,
+      creatorId: getIdFromString(value) ?? null,
       page: 1,
     });
   }
 
   handleSearchSubmit(i: Search, event: any) {
     event.preventDefault();
+
     i.updateUrl({
       q: i.state.searchText,
-      type_: i.state.type_,
-      listingType: i.state.listingType,
-      communityId: i.state.communityId,
-      creatorId: i.state.creatorId,
-      sort: i.state.sort,
-      page: i.state.page,
+      page: 1,
     });
   }
 
@@ -938,70 +902,108 @@ export class Search extends Component<any, SearchState> {
     i.setState({ searchText: event.target.value });
   }
 
-  updateUrl(paramUpdates: UrlParams) {
-    const qStr = paramUpdates.q || this.state.q;
-    const qStrEncoded = encodeURIComponent(qStr || "");
-    const typeStr = paramUpdates.type_ || this.state.type_;
-    const listingTypeStr = paramUpdates.listingType || this.state.listingType;
-    const sortStr = paramUpdates.sort || this.state.sort;
-    const communityId =
-      paramUpdates.communityId == 0
-        ? 0
-        : paramUpdates.communityId || this.state.communityId;
-    const creatorId =
-      paramUpdates.creatorId == 0
-        ? 0
-        : paramUpdates.creatorId || this.state.creatorId;
-    const page = paramUpdates.page || this.state.page;
-    this.props.history.push(
-      `/search/q/${qStrEncoded}/type/${typeStr}/sort/${sortStr}/listing_type/${listingTypeStr}/community_id/${communityId}/creator_id/${creatorId}/page/${page}`
-    );
+  updateUrl({
+    q,
+    type,
+    listingType,
+    sort,
+    communityId,
+    creatorId,
+    page,
+  }: Partial<SearchProps>) {
+    const {
+      q: urlQ,
+      type: urlType,
+      listingType: urlListingType,
+      communityId: urlCommunityId,
+      sort: urlSort,
+      creatorId: urlCreatorId,
+      page: urlPage,
+    } = getSearchQueryParams();
+
+    let query = q ?? this.state.searchText ?? urlQ;
+
+    if (query && query.length > 0) {
+      query = encodeURIComponent(query);
+    }
+
+    const queryParams: QueryParams<SearchProps> = {
+      q: query,
+      type: type ?? urlType,
+      listingType: listingType ?? urlListingType,
+      communityId: getUpdatedSearchId(communityId, urlCommunityId),
+      creatorId: getUpdatedSearchId(creatorId, urlCreatorId),
+      page: (page ?? urlPage).toString(),
+      sort: sort ?? urlSort,
+    };
+
+    this.props.history.push(`/search${getQueryString(queryParams)}`);
+
+    this.search();
   }
 
   parseMessage(msg: any) {
     console.log(msg);
-    let op = wsUserOp(msg);
+    const op = wsUserOp(msg);
     if (msg.error) {
-      if (msg.error == "couldnt_find_object") {
+      if (msg.error === "couldnt_find_object") {
         this.setState({
           resolveObjectResponse: {},
         });
         this.checkFinishedLoading();
       } else {
         toast(i18n.t(msg.error), "danger");
-        return;
       }
-    } else if (op == UserOperation.Search) {
-      let data = wsJsonToRes<SearchResponse>(msg);
-      this.setState({ searchResponse: data });
-      window.scrollTo(0, 0);
-      this.checkFinishedLoading();
-      restoreScrollPosition(this.context);
-    } else if (op == UserOperation.CreateCommentLike) {
-      let data = wsJsonToRes<CommentResponse>(msg);
-      createCommentLikeRes(
-        data.comment_view,
-        this.state.searchResponse?.comments
-      );
-      this.setState(this.state);
-    } else if (op == UserOperation.CreatePostLike) {
-      let data = wsJsonToRes<PostResponse>(msg);
-      createPostLikeFindRes(data.post_view, this.state.searchResponse?.posts);
-      this.setState(this.state);
-    } else if (op == UserOperation.ListCommunities) {
-      let data = wsJsonToRes<ListCommunitiesResponse>(msg);
-      this.setState({ communities: data.communities });
-      this.setupCommunityFilter();
-    } else if (op == UserOperation.ResolveObject) {
-      let data = wsJsonToRes<ResolveObjectResponse>(msg);
-      this.setState({ resolveObjectResponse: data });
-      this.checkFinishedLoading();
+    } 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({ loading: false });
+      this.setState({ searchLoading: false });
     }
   }
 }
index 2d24bd53d17cb26dba32066cc8ee30a411d56fed..aeb4678c9fd4f3d18119a99ee77289960a137bb3 100644 (file)
@@ -1,4 +1,5 @@
 import { GetSiteResponse, LemmyHttp } from "lemmy-js-client";
+import type { ParsedQs } from "qs";
 
 /**
  * This contains serialized data, it needs to be deserialized before use.
@@ -20,17 +21,18 @@ declare global {
   }
 }
 
-export interface InitialFetchRequest {
+export interface InitialFetchRequest<T extends ParsedQs = ParsedQs> {
   auth?: string;
   client: LemmyHttp;
   path: string;
+  query: T;
+  site: GetSiteResponse;
 }
 
 export interface PostFormParams {
   name?: string;
   url?: string;
   body?: string;
-  nameOrId?: string | number;
 }
 
 export enum CommentViewType {
@@ -49,10 +51,10 @@ export enum BanType {
 }
 
 export enum PersonDetailsView {
-  Overview,
-  Comments,
-  Posts,
-  Saved,
+  Overview = "Overview",
+  Comments = "Comments",
+  Posts = "Posts",
+  Saved = "Saved",
 }
 
 export enum PurgeType {
index b4404a7983a32b56a749649b39af6f882d2ef7a3..875ba5020487253c828fb813b773355c5de63806 100644 (file)
@@ -1,4 +1,3 @@
-import { Inferno } from "inferno";
 import { IRouteProps } from "inferno-router/dist/Route";
 import { Communities } from "./components/community/communities";
 import { Community } from "./components/community/community";
@@ -26,21 +25,15 @@ import { InitialFetchRequest } from "./interfaces";
 
 interface IRoutePropsWithFetch extends IRouteProps {
   // TODO Make sure this one is good.
-  component: Inferno.ComponentClass;
   fetchInitialData?(req: InitialFetchRequest): Promise<any>[];
 }
 
 export const routes: IRoutePropsWithFetch[] = [
   {
     path: `/`,
-    exact: true,
     component: Home,
-    fetchInitialData: req => Home.fetchInitialData(req),
-  },
-  {
-    path: `/home/data_type/:data_type/listing_type/:listing_type/sort/:sort/page/:page`,
-    component: Home,
-    fetchInitialData: req => Home.fetchInitialData(req),
+    fetchInitialData: Home.fetchInitialData,
+    exact: true,
   },
   {
     path: `/login`,
@@ -53,101 +46,81 @@ export const routes: IRoutePropsWithFetch[] = [
   {
     path: `/create_post`,
     component: CreatePost,
-    fetchInitialData: req => CreatePost.fetchInitialData(req),
+    fetchInitialData: CreatePost.fetchInitialData,
   },
   {
     path: `/create_community`,
     component: CreateCommunity,
   },
   {
-    path: `/create_private_message/recipient/:recipient_id`,
+    path: `/create_private_message/:recipient_id`,
     component: CreatePrivateMessage,
-    fetchInitialData: req => CreatePrivateMessage.fetchInitialData(req),
-  },
-  {
-    path: `/communities/listing_type/:listing_type/page/:page`,
-    component: Communities,
-    fetchInitialData: req => Communities.fetchInitialData(req),
+    fetchInitialData: CreatePrivateMessage.fetchInitialData,
   },
   {
     path: `/communities`,
     component: Communities,
-    fetchInitialData: req => Communities.fetchInitialData(req),
+    fetchInitialData: Communities.fetchInitialData,
   },
   {
     path: `/post/:post_id`,
     component: Post,
-    fetchInitialData: req => Post.fetchInitialData(req),
+    fetchInitialData: Post.fetchInitialData,
   },
   {
     path: `/comment/:comment_id`,
     component: Post,
-    fetchInitialData: req => Post.fetchInitialData(req),
-  },
-  {
-    path: `/c/:name/data_type/:data_type/sort/:sort/page/:page`,
-    component: Community,
-    fetchInitialData: req => Community.fetchInitialData(req),
+    fetchInitialData: Post.fetchInitialData,
   },
   {
     path: `/c/:name`,
     component: Community,
-    fetchInitialData: req => Community.fetchInitialData(req),
-  },
-  {
-    path: `/u/:username/view/:view/sort/:sort/page/:page`,
-    component: Profile,
-    fetchInitialData: req => Profile.fetchInitialData(req),
+    fetchInitialData: Community.fetchInitialData,
   },
   {
     path: `/u/:username`,
     component: Profile,
-    fetchInitialData: req => Profile.fetchInitialData(req),
+    fetchInitialData: Profile.fetchInitialData,
   },
   {
     path: `/inbox`,
     component: Inbox,
-    fetchInitialData: req => Inbox.fetchInitialData(req),
+    fetchInitialData: Inbox.fetchInitialData,
   },
   {
     path: `/settings`,
     component: Settings,
   },
   {
-    path: `/modlog/community/:community_id`,
+    path: `/modlog`,
     component: Modlog,
-    fetchInitialData: req => Modlog.fetchInitialData(req),
+    fetchInitialData: Modlog.fetchInitialData,
   },
   {
-    path: `/modlog`,
+    path: `/modlog/:communityId`,
     component: Modlog,
-    fetchInitialData: req => Modlog.fetchInitialData(req),
+    fetchInitialData: Modlog.fetchInitialData,
   },
   { path: `/setup`, component: Setup },
   {
     path: `/admin`,
     component: AdminSettings,
-    fetchInitialData: req => AdminSettings.fetchInitialData(req),
+    fetchInitialData: AdminSettings.fetchInitialData,
   },
   {
     path: `/reports`,
     component: Reports,
-    fetchInitialData: req => Reports.fetchInitialData(req),
+    fetchInitialData: Reports.fetchInitialData,
   },
   {
     path: `/registration_applications`,
     component: RegistrationApplications,
-    fetchInitialData: req => RegistrationApplications.fetchInitialData(req),
-  },
-  {
-    path: `/search/q/:q/type/:type/sort/:sort/listing_type/:listing_type/community_id/:community_id/creator_id/:creator_id/page/:page`,
-    component: Search,
-    fetchInitialData: req => Search.fetchInitialData(req),
+    fetchInitialData: RegistrationApplications.fetchInitialData,
   },
   {
     path: `/search`,
     component: Search,
-    fetchInitialData: req => Search.fetchInitialData(req),
+    fetchInitialData: Search.fetchInitialData,
   },
   {
     path: `/password_change/:token`,
index b0dca6a9937dbca93a64768a85b3edb9d08a28e7..93cfce3d2a39e2ad9dea537d5a764afc390c761d 100644 (file)
@@ -82,6 +82,8 @@ export const concurrentImageUpload = 4;
 
 export const relTags = "noopener nofollow";
 
+export const emDash = "\u2014";
+
 export type ThemeColor =
   | "primary"
   | "secondary"
@@ -118,6 +120,14 @@ function getRandomCharFromAlphabet(alphabet: string): string {
   return alphabet.charAt(Math.floor(Math.random() * alphabet.length));
 }
 
+export function getIdFromString(id?: string): number | undefined {
+  return id && id !== "0" && !Number.isNaN(Number(id)) ? Number(id) : undefined;
+}
+
+export function getPageFromString(page?: string): number {
+  return page && !Number.isNaN(Number(page)) ? Number(page) : 1;
+}
+
 export function randomStr(
   idDesiredLength = 20,
   alphabet = DEFAULT_ALPHABET
@@ -332,8 +342,11 @@ export function capitalizeFirstLetter(str: string): string {
   return str.charAt(0).toUpperCase() + str.slice(1);
 }
 
-export function routeSortTypeToEnum(sort: string): SortType {
-  return SortType[sort];
+export function routeSortTypeToEnum(
+  sort: string,
+  defaultValue: SortType
+): SortType {
+  return SortType[sort] ?? defaultValue;
 }
 
 export function listingTypeFromNum(type_: number): ListingType {
@@ -344,16 +357,25 @@ export function sortTypeFromNum(type_: number): SortType {
   return Object.values(SortType)[type_];
 }
 
-export function routeListingTypeToEnum(type: string): ListingType {
-  return ListingType[type];
+export function routeListingTypeToEnum(
+  type: string,
+  defaultValue: ListingType
+): ListingType {
+  return ListingType[type] ?? defaultValue;
 }
 
-export function routeDataTypeToEnum(type: string): DataType {
-  return DataType[capitalizeFirstLetter(type)];
+export function routeDataTypeToEnum(
+  type: string,
+  defaultValue: DataType
+): DataType {
+  return DataType[type] ?? defaultValue;
 }
 
-export function routeSearchTypeToEnum(type: string): SearchType {
-  return SearchType[type];
+export function routeSearchTypeToEnum(
+  type: string,
+  defaultValue: SearchType
+): SearchType {
+  return SearchType[type] ?? defaultValue;
 }
 
 export async function getSiteMetadata(url: string) {
@@ -362,26 +384,34 @@ export async function getSiteMetadata(url: string) {
   return client.getSiteMetadata(form);
 }
 
-export function debounce(func: any, wait = 1000, immediate = false) {
+export function getDataTypeString(dt: DataType) {
+  return dt === DataType.Post ? "Post" : "Comment";
+}
+
+export function debounce<T extends any[], R>(
+  func: (...e: T) => R,
+  wait = 1000,
+  immediate = false
+) {
   // 'private' variable for instance
   // The returned function will be able to reference this due to closure.
   // Each call to the returned function will share this common timer.
-  let timeout: any;
+  let timeout: NodeJS.Timeout | null;
 
   // Calling debounce returns a new anonymous function
   return function () {
     // reference the context and args for the setTimeout function
-    var args = arguments;
+    const args = arguments;
 
     // Should the function be called now? If immediate is true
     //   and not already in a timeout then the answer is: Yes
-    var callNow = immediate && !timeout;
+    const callNow = immediate && !timeout;
 
-    // This is the basic debounce behaviour where you can call this
+    // This is the basic debounce behavior where you can call this
     //   function several times, but it will only execute once
     //   [before or after imposing a delay].
     //   Each time the returned function is called, the timer starts over.
-    clearTimeout(timeout);
+    clearTimeout(timeout ?? undefined);
 
     // Set the new timeout
     timeout = setTimeout(function () {
@@ -400,7 +430,7 @@ export function debounce(func: any, wait = 1000, immediate = false) {
 
     // Immediate mode and no wait timer? Execute the function..
     if (callNow) func.apply(this, args);
-  };
+  } as (...e: T) => R;
 }
 
 export function getLanguages(
@@ -903,47 +933,6 @@ async function communitySearch(text: string): Promise<CommunityTribute[]> {
   return communities;
 }
 
-export function getListingTypeFromProps(
-  props: any,
-  defaultListingType: ListingType,
-  myUserInfo = UserService.Instance.myUserInfo
-): ListingType {
-  let myLt = myUserInfo?.local_user_view.local_user.default_listing_type;
-  return props.match.params.listing_type
-    ? routeListingTypeToEnum(props.match.params.listing_type)
-    : myLt
-    ? Object.values(ListingType)[myLt]
-    : defaultListingType;
-}
-
-export function getListingTypeFromPropsNoDefault(props: any): ListingType {
-  return props.match.params.listing_type
-    ? routeListingTypeToEnum(props.match.params.listing_type)
-    : ListingType.Local;
-}
-
-export function getDataTypeFromProps(props: any): DataType {
-  return props.match.params.data_type
-    ? routeDataTypeToEnum(props.match.params.data_type)
-    : DataType.Post;
-}
-
-export function getSortTypeFromProps(
-  props: any,
-  myUserInfo = UserService.Instance.myUserInfo
-): SortType {
-  let mySortType = myUserInfo?.local_user_view.local_user.default_sort_type;
-  return props.match.params.sort
-    ? routeSortTypeToEnum(props.match.params.sort)
-    : mySortType
-    ? Object.values(SortType)[mySortType]
-    : SortType.Active;
-}
-
-export function getPageFromProps(props: any): number {
-  return props.match.params.page ? Number(props.match.params.page) : 1;
-}
-
 export function getRecipientIdFromProps(props: any): number {
   return props.match.params.recipient_id
     ? Number(props.match.params.recipient_id)
@@ -960,10 +949,6 @@ export function getCommentIdFromProps(props: any): number | undefined {
   return id ? Number(id) : undefined;
 }
 
-export function getUsernameFromProps(props: any): string {
-  return props.match.params.username;
-}
-
 export function editCommentRes(data: CommentView, comments?: CommentView[]) {
   let found = comments?.find(c => c.comment.id == data.comment.id);
   if (found) {
@@ -1378,25 +1363,30 @@ export function showLocal(isoData: IsoData): boolean {
   return linked ? linked.length > 0 : false;
 }
 
-export interface ChoicesValue {
+export interface Choice {
   value: string;
   label: string;
+  disabled?: boolean;
+}
+
+export function getUpdatedSearchId(id?: number | null, urlId?: number | null) {
+  return id === null
+    ? undefined
+    : ((id ?? urlId) === 0 ? undefined : id ?? urlId)?.toString();
 }
 
-export function communityToChoice(cv: CommunityView): ChoicesValue {
-  let choice: ChoicesValue = {
+export function communityToChoice(cv: CommunityView): Choice {
+  return {
     value: cv.community.id.toString(),
     label: communitySelectName(cv),
   };
-  return choice;
 }
 
-export function personToChoice(pvs: PersonViewSafe): ChoicesValue {
-  let choice: ChoicesValue = {
+export function personToChoice(pvs: PersonViewSafe): Choice {
+  return {
     value: pvs.person.id.toString(),
     label: personSelectName(pvs),
   };
-  return choice;
 }
 
 export async function fetchCommunities(q: string) {
@@ -1427,49 +1417,17 @@ export async function fetchUsers(q: string) {
   return client.search(form);
 }
 
-export const choicesConfig = {
-  shouldSort: false,
-  searchResultLimit: fetchLimit,
-  classNames: {
-    containerOuter: "choices custom-select px-0",
-    containerInner:
-      "choices__inner bg-secondary border-0 py-0 modlog-choices-font-size",
-    input: "form-control",
-    inputCloned: "choices__input--cloned",
-    list: "choices__list",
-    listItems: "choices__list--multiple",
-    listSingle: "choices__list--single py-0",
-    listDropdown: "choices__list--dropdown",
-    item: "choices__item bg-secondary",
-    itemSelectable: "choices__item--selectable",
-    itemDisabled: "choices__item--disabled",
-    itemChoice: "choices__item--choice",
-    placeholder: "choices__placeholder",
-    group: "choices__group",
-    groupHeading: "choices__heading",
-    button: "choices__button",
-    activeState: "is-active",
-    focusState: "is-focused",
-    openState: "is-open",
-    disabledState: "is-disabled",
-    highlightedState: "text-info",
-    selectedState: "text-info",
-    flippedState: "is-flipped",
-    loadingState: "is-loading",
-    noResults: "has-no-results",
-    noChoices: "has-no-choices",
-  },
-};
-
 export function communitySelectName(cv: CommunityView): string {
   return cv.community.local
     ? cv.community.title
     : `${hostname(cv.community.actor_id)}/${cv.community.title}`;
 }
 
-export function personSelectName(pvs: PersonViewSafe): string {
-  let pName = pvs.person.display_name ?? pvs.person.name;
-  return pvs.person.local ? pName : `${hostname(pvs.person.actor_id)}/${pName}`;
+export function personSelectName({
+  person: { display_name, name, local, actor_id },
+}: PersonViewSafe): string {
+  const pName = display_name ?? name;
+  return local ? pName : `${hostname(actor_id)}/${pName}`;
 }
 
 export function initializeSite(site: GetSiteResponse) {
@@ -1505,12 +1463,6 @@ export function isBanned(ps: PersonSafe): boolean {
   }
 }
 
-export function pushNotNull(array: any[], new_item?: any) {
-  if (new_item) {
-    array.push(...new_item);
-  }
-}
-
 export function myAuth(throwErr = true): string | undefined {
   return UserService.Instance.auth(throwErr);
 }
@@ -1524,14 +1476,17 @@ export function enableNsfw(siteRes: GetSiteResponse): boolean {
 }
 
 export function postToCommentSortType(sort: SortType): CommentSortType {
-  if ([SortType.Active, SortType.Hot].includes(sort)) {
-    return CommentSortType.Hot;
-  } else if ([SortType.New, SortType.NewComments].includes(sort)) {
-    return CommentSortType.New;
-  } else if (sort == SortType.Old) {
-    return CommentSortType.Old;
-  } else {
-    return CommentSortType.Top;
+  switch (sort) {
+    case SortType.Active:
+    case SortType.Hot:
+      return CommentSortType.Hot;
+    case SortType.New:
+    case SortType.NewComments:
+      return CommentSortType.New;
+    case SortType.Old:
+      return CommentSortType.Old;
+    default:
+      return CommentSortType.Top;
   }
 }
 
@@ -1553,7 +1508,8 @@ export function canCreateCommunity(
   siteRes: GetSiteResponse,
   myUserInfo = UserService.Instance.myUserInfo
 ): boolean {
-  let adminOnly = siteRes.site_view.local_site.community_creation_admin_only;
+  const adminOnly = siteRes.site_view.local_site.community_creation_admin_only;
+  // TODO: Make this check if user is logged on as well
   return !adminOnly || amAdmin(myUserInfo);
 }
 
@@ -1651,3 +1607,36 @@ const groupBy = <T>(
     (acc[predicate(value, index, array)] ||= []).push(value);
     return acc;
   }, {} as { [key: string]: T[] });
+
+export type QueryParams<T extends Record<string, any>> = {
+  [key in keyof T]?: string;
+};
+
+export function getQueryParams<T extends Record<string, any>>(processors: {
+  [K in keyof T]: (param: string) => T[K];
+}): T {
+  if (isBrowser()) {
+    const searchParams = new URLSearchParams(window.location.search);
+
+    return Array.from(Object.entries(processors)).reduce(
+      (acc, [key, process]) => ({
+        ...acc,
+        [key]: process(searchParams.get(key)),
+      }),
+      {} as T
+    );
+  }
+
+  return {} as T;
+}
+
+export function getQueryString<T extends Record<string, string | undefined>>(
+  obj: T
+) {
+  return Object.entries(obj)
+    .filter(([, val]) => val !== undefined && val !== null)
+    .reduce(
+      (acc, [key, val], index) => `${acc}${index > 0 ? "&" : ""}${key}=${val}`,
+      "?"
+    );
+}
index b32721db9d11a5de7d86448abf04c7c30e1f7e81..93f621c6a9b86d613e0ced7e1962ec69add8994e 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
   dependencies:
     regenerator-runtime "^0.13.11"
 
-"@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
+"@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4":
   version "7.19.0"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259"
   integrity sha512-eR8Lo9hnDS7tqkO7NsV+mKvCmv5boaXFSZ70DnfhcgiEne8hv9oCEd36Klw74EtizEqLsy4YnW8UWwpBVolHZA==
   resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca"
   integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==
 
-"@types/node-fetch@^2.6.2":
-  version "2.6.2"
-  resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.6.2.tgz#d1a9c5fd049d9415dce61571557104dec3ec81da"
-  integrity sha512-DHqhlq5jeESLy19TYhLakJ07kNumXWjcDdxXsLUMJZ6ue8VZJj4kLPQVE/2mdHh3xZziNF1xppu5lwmS53HR+A==
-  dependencies:
-    "@types/node" "*"
-    form-data "^3.0.0"
-
 "@types/node@*":
   version "18.7.23"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.23.tgz#75c580983846181ebe5f4abc40fe9dfb2d65665f"
@@ -2530,15 +2522,6 @@ check-password-strength@^2.0.7:
   resolved "https://registry.yarnpkg.com/check-password-strength/-/check-password-strength-2.0.7.tgz#d8fd6c1a274267c7ddd9cd15c71a3cfb6ad35baa"
   integrity sha512-VyklBkB6dOKnCIh63zdVr7QKVMN9/npwUqNAXxWrz8HabVZH/n/d+lyNm1O/vbXFJlT/Hytb5ouYKYGkoeZirQ==
 
-choices.js@^10.2.0:
-  version "10.2.0"
-  resolved "https://registry.yarnpkg.com/choices.js/-/choices.js-10.2.0.tgz#3fe915a12b469a87b9552cd7158e413c8f65ab4f"
-  integrity sha512-8PKy6wq7BMjNwDTZwr3+Zry6G2+opJaAJDDA/j3yxvqSCnvkKe7ZIFfIyOhoc7htIWFhsfzF9tJpGUATcpUtPg==
-  dependencies:
-    deepmerge "^4.2.2"
-    fuse.js "^6.6.2"
-    redux "^4.2.0"
-
 "chokidar@>=3.0.0 <4.0.0", chokidar@^3.5.3:
   version "3.5.3"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
@@ -3980,15 +3963,6 @@ forever-agent@~0.6.1:
   resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
   integrity sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==
 
-form-data@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.1.tgz#ebd53791b78356a99af9a300d4282c4d5eb9755f"
-  integrity sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==
-  dependencies:
-    asynckit "^0.4.0"
-    combined-stream "^1.0.8"
-    mime-types "^2.1.12"
-
 form-data@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
@@ -4104,11 +4078,6 @@ functions-have-names@^1.2.2:
   resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834"
   integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==
 
-fuse.js@^6.6.2:
-  version "6.6.2"
-  resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-6.6.2.tgz#fe463fed4b98c0226ac3da2856a415576dc9a111"
-  integrity sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==
-
 gauge@~2.7.3:
   version "2.7.4"
   resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
@@ -6089,7 +6058,7 @@ node-fetch-npm@^2.0.2:
     json-parse-better-errors "^1.0.0"
     safe-buffer "^5.1.1"
 
-node-fetch@2.6.7, node-fetch@^2.6.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==
@@ -7386,13 +7355,6 @@ rechoir@^0.8.0:
   dependencies:
     resolve "^1.20.0"
 
-redux@^4.2.0:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197"
-  integrity sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==
-  dependencies:
-    "@babel/runtime" "^7.9.2"
-
 regenerate-unicode-properties@^10.1.0:
   version "10.1.0"
   resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz#7c3192cab6dd24e21cb4461e5ddd7dd24fa8374c"