]> Untitled Git - lemmy-ui.git/commitdiff
Partly functioning fuse-box, but moving te webpack now.
authorDessalines <tyhou13@gmx.com>
Sun, 6 Sep 2020 16:15:25 +0000 (11:15 -0500)
committerDessalines <tyhou13@gmx.com>
Sun, 6 Sep 2020 16:15:25 +0000 (11:15 -0500)
66 files changed:
.eslintignore [new file with mode: 0644]
.gitignore
fuse.ts
generate_translations.js [new file with mode: 0644]
package.json
src/client/components/About/About.css [deleted file]
src/client/components/About/About.test.tsx [deleted file]
src/client/components/About/About.tsx [deleted file]
src/client/components/App/App.tsx [deleted file]
src/client/components/Home/Home.tsx [deleted file]
src/client/index.tsx
src/server/index.tsx
src/shared/components/admin-settings.tsx [new file with mode: 0644]
src/shared/components/app.tsx [new file with mode: 0644]
src/shared/components/banner-icon-header.tsx [new file with mode: 0644]
src/shared/components/cake-day.tsx [new file with mode: 0644]
src/shared/components/comment-form.tsx [new file with mode: 0644]
src/shared/components/comment-node.tsx [new file with mode: 0644]
src/shared/components/comment-nodes.tsx [new file with mode: 0644]
src/shared/components/communities.tsx [new file with mode: 0644]
src/shared/components/community-form.tsx [new file with mode: 0644]
src/shared/components/community-link.tsx [new file with mode: 0644]
src/shared/components/community.tsx [new file with mode: 0644]
src/shared/components/create-community.tsx [new file with mode: 0644]
src/shared/components/create-post.tsx [new file with mode: 0644]
src/shared/components/create-private-message.tsx [new file with mode: 0644]
src/shared/components/data-type-select.tsx [new file with mode: 0644]
src/shared/components/footer.tsx [new file with mode: 0644]
src/shared/components/iframely-card.tsx [new file with mode: 0644]
src/shared/components/image-upload-form.tsx [new file with mode: 0644]
src/shared/components/inbox.tsx [new file with mode: 0644]
src/shared/components/instances.tsx [new file with mode: 0644]
src/shared/components/listing-type-select.tsx [new file with mode: 0644]
src/shared/components/login.tsx [new file with mode: 0644]
src/shared/components/main.tsx [new file with mode: 0644]
src/shared/components/markdown-textarea.tsx [new file with mode: 0644]
src/shared/components/modlog.tsx [new file with mode: 0644]
src/shared/components/moment-time.tsx [new file with mode: 0644]
src/shared/components/navbar.tsx [new file with mode: 0644]
src/shared/components/password_change.tsx [new file with mode: 0644]
src/shared/components/post-form.tsx [new file with mode: 0644]
src/shared/components/post-listing.tsx [new file with mode: 0644]
src/shared/components/post-listings.tsx [new file with mode: 0644]
src/shared/components/post.tsx [new file with mode: 0644]
src/shared/components/private-message-form.tsx [new file with mode: 0644]
src/shared/components/private-message.tsx [new file with mode: 0644]
src/shared/components/search.tsx [new file with mode: 0644]
src/shared/components/setup.tsx [new file with mode: 0644]
src/shared/components/sidebar.tsx [new file with mode: 0644]
src/shared/components/site-form.tsx [new file with mode: 0644]
src/shared/components/sort-select.tsx [new file with mode: 0644]
src/shared/components/sponsors.tsx [new file with mode: 0644]
src/shared/components/symbols.tsx [new file with mode: 0644]
src/shared/components/user-details.tsx [new file with mode: 0644]
src/shared/components/user-listing.tsx [new file with mode: 0644]
src/shared/components/user.tsx [new file with mode: 0644]
src/shared/env.ts [new file with mode: 0644]
src/shared/i18next.ts [new file with mode: 0644]
src/shared/interfaces.ts [new file with mode: 0644]
src/shared/routes.ts [new file with mode: 0644]
src/shared/services/UserService.ts [new file with mode: 0644]
src/shared/services/WebSocketService.ts [new file with mode: 0644]
src/shared/services/index.ts [new file with mode: 0644]
src/shared/utils.ts [new file with mode: 0644]
tsconfig.json
yarn.lock

diff --git a/.eslintignore b/.eslintignore
new file mode 100644 (file)
index 0000000..54f358e
--- /dev/null
@@ -0,0 +1,3 @@
+fuse.ts
+generate_translations.js
+src/api_tests
index bdd11a35d62235550e745a80d39e6c2603cbe911..34454f3f03512cb58213b5a3054b04286fd41bbb 100644 (file)
@@ -25,3 +25,5 @@ test/data/result.json
 package-lock.json
 *.orig
 
+src/shared/translations
+
diff --git a/fuse.ts b/fuse.ts
index 416ce2e6d301ec831a43e84c83285cb5a0d97838..9de9d564a185e7274f6d5a62c5cd9ae873e5005a 100644 (file)
--- a/fuse.ts
+++ b/fuse.ts
@@ -1,76 +1,82 @@
-import { CSSPlugin, FuseBox, FuseBoxOptions, Sparky } from "fuse-box";\r
-import path = require("path");\r
-import TsTransformClasscat from "ts-transform-classcat";\r
-import TsTransformInferno from "ts-transform-inferno";\r
-/**\r
- * Some of FuseBoxOptions overrides by ts config (module, target, etc)\r
- * https://fuse-box.org/page/working-with-targets\r
- */\r
-let fuse: FuseBox;\r
-const fuseOptions: FuseBoxOptions = {\r
-   homeDir: "./src",\r
-   output: "dist/$name.js",\r
-   sourceMaps: { inline: false, vendor: false },\r
-   /**\r
-    * Custom TypeScript Transformers (compile Inferno tsx to ts)\r
-    */\r
-   transformers: {\r
-      before: [TsTransformClasscat(), TsTransformInferno()]\r
-   }\r
-};\r
-const fuseClientOptions: FuseBoxOptions = {\r
-   ...fuseOptions,\r
-   plugins: [\r
-      /**\r
-       * https://fuse-box.org/page/css-resource-plugin\r
-       * Compile Sass {SassPlugin()}\r
-       * Make .css files modules-like (allow import them like modules) {CSSModules}\r
-       * Make .css files modules like and allow import it from node_modules too {CSSResourcePlugin}\r
-       * Use them all and bundle with {CSSPlugin}\r
-       * */\r
-      CSSPlugin()\r
-   ]\r
-};\r
-const fuseServerOptions: FuseBoxOptions = {\r
-   ...fuseOptions\r
-};\r
-Sparky.task("clean", () => {\r
-   /**Clean distribute (dist) folder */\r
-   Sparky.src("dist")\r
-      .clean("dist")\r
-      .exec();\r
-});\r
-Sparky.task("config", () => {\r
-   fuse = FuseBox.init(fuseOptions);\r
-   fuse.dev();\r
-});\r
-Sparky.task("test", ["&clean", "&config"], () => {\r
-   fuse.bundle("client/bundle").test("[**/**.test.tsx]", null);\r
-});\r
-Sparky.task("client", () => {\r
-   fuse.opts = fuseClientOptions;\r
-   fuse\r
-      .bundle("client/bundle")\r
-      .target("browser@esnext")\r
-      .watch("client/**")\r
-      .hmr()\r
-      .instructions("> client/index.tsx");\r
-});\r
-Sparky.task("server", () => {\r
-   /**Workaround. Should be fixed */\r
-   fuse.opts = fuseServerOptions;\r
-   fuse\r
-      .bundle("server/bundle")\r
-      .watch("**")\r
-      .target("server@esnext")\r
-      .instructions("> [server/index.tsx]")\r
-      .completed(proc => {\r
-         proc.require({\r
-            // tslint:disable-next-line:no-shadowed-variable\r
-            close: ({ FuseBox }) => FuseBox.import(FuseBox.mainFile).shutdown()\r
-         });\r
-      });\r
-});\r
-Sparky.task("dev", ["&clean", "&config", "&client", "&server"], () => {\r
-   fuse.run();\r
-});\r
+import { CSSPlugin, FuseBox, FuseBoxOptions, Sparky } from 'fuse-box';
+import path = require('path');
+import TsTransformClasscat from 'ts-transform-classcat';
+import TsTransformInferno from 'ts-transform-inferno';
+/**
+ * Some of FuseBoxOptions overrides by ts config (module, target, etc)
+ * https://fuse-box.org/page/working-with-targets
+ */
+let fuse: FuseBox;
+const fuseOptions: FuseBoxOptions = {
+  homeDir: './src',
+  output: 'dist/$name.js',
+  sourceMaps: { inline: false, vendor: false },
+  /**
+   * Custom TypeScript Transformers (compile Inferno tsx to ts)
+   */
+  transformers: {
+    before: [TsTransformClasscat(), TsTransformInferno()],
+  },
+};
+const fuseClientOptions: FuseBoxOptions = {
+  ...fuseOptions,
+  plugins: [
+    /**
+     * https://fuse-box.org/page/css-resource-plugin
+     * Compile Sass {SassPlugin()}
+     * Make .css files modules-like (allow import them like modules) {CSSModules}
+     * Make .css files modules like and allow import it from node_modules too {CSSResourcePlugin}
+     * Use them all and bundle with {CSSPlugin}
+     * */
+    CSSPlugin(),
+  ],
+};
+const fuseServerOptions: FuseBoxOptions = {
+  ...fuseOptions,
+};
+
+Sparky.task('clean', () => {
+  /**Clean distribute (dist) folder */
+  Sparky.src('dist/').clean('dist/');
+});
+Sparky.task('config', () => {
+  fuse = FuseBox.init(fuseOptions);
+  fuse.dev();
+});
+Sparky.task('test', ['&clean', '&config'], () => {
+  fuse.bundle('client/bundle').test('[**/**.test.tsx]', null);
+});
+Sparky.task('client', () => {
+  fuse.opts = fuseClientOptions;
+  fuse
+    .bundle('client/bundle')
+    .target('browser@esnext')
+    .watch('client/**')
+    .hmr()
+    .instructions('> client/index.tsx');
+});
+Sparky.task('copy-assets', () =>
+  Sparky.src('**/**.*', { base: 'src/assets' }).dest('dist/assets')
+);
+Sparky.task('server', () => {
+  /**Workaround. Should be fixed */
+  fuse.opts = fuseServerOptions;
+  fuse
+    .bundle('server/bundle')
+    .watch('**')
+    .target('server@esnext')
+    .instructions('> [server/index.tsx]')
+    .completed(proc => {
+      proc.require({
+        // tslint:disable-next-line:no-shadowed-variable
+        close: ({ FuseBox }) => FuseBox.import(FuseBox.mainFile).shutdown(),
+      });
+    });
+});
+Sparky.task(
+  'dev',
+  ['&clean', '&config', '&client', '&server', '&copy-assets'],
+  () => {
+    fuse.run();
+  }
+);
diff --git a/generate_translations.js b/generate_translations.js
new file mode 100644 (file)
index 0000000..e792e6d
--- /dev/null
@@ -0,0 +1,27 @@
+fs = require('fs');
+
+let translationDir = 'translations/';
+let outDir = 'src/shared/translations/';
+fs.mkdirSync(outDir, { recursive: true });
+fs.readdir(translationDir, (err, files) => {
+  files.forEach(filename => {
+    const lang = filename.split('.')[0];
+    try {
+      const json = JSON.parse(
+        fs.readFileSync(translationDir + filename, 'utf8')
+      );
+      var data = `export const ${lang} = {\n  translation: {`;
+      for (var key in json) {
+        if (key in json) {
+          const value = json[key].replace(/"/g, '\\"');
+          data = `${data}\n    ${key}: "${value}",`;
+        }
+      }
+      data += '\n  },\n};';
+      const target = outDir + lang + '.ts';
+      fs.writeFileSync(target, data);
+    } catch (err) {
+      console.error(err);
+    }
+  });
+});
index a619e62ea31833d4bb60c8fc3f91f916dd1c51db..778c68e92779e594d94c257888b5fe3a836cd868 100644 (file)
@@ -4,20 +4,48 @@
   "author": "Dessalines <tyhou13@gmx.com>",
   "license": "AGPL-3.0",
   "scripts": {
-    "dev": "set NODE_ENV=development && node -r ts-node/register --inspect fuse.ts dev",
     "lint": "tsc --noEmit && eslint --report-unused-disable-directives --ext .js,.ts,.tsx src",
+    "prebuild": "node generate_translations.js",
+    "prestart": "node generate_translations.js",
+    "start": "set NODE_ENV=development && node -r ts-node/register --inspect fuse.ts dev",
     "test": "node -r ts-node/register --inspect fuse.ts test"
   },
   "repository": "https://github.com/LemmyNet/lemmy-isomorphic-ui",
   "dependencies": {
+    "@types/autosize": "^3.0.6",
+    "@types/node-fetch": "^2.5.7",
+    "autosize": "^4.0.2",
+    "choices.js": "^9.0.1",
     "cookie-parser": "^1.4.3",
+    "emoji-short-name": "^1.0.0",
     "express": "~4.17.1",
+    "i18next": "^19.4.1",
     "inferno": "^7.4.3",
     "inferno-create-element": "^7.4.3",
+    "inferno-helmet": "^5.2.1",
     "inferno-hydrate": "^7.4.3",
+    "inferno-i18next": "github:nimbusec-oss/inferno-i18next#semver:^7.4.2",
     "inferno-router": "^7.4.3",
     "inferno-server": "^7.4.3",
-    "serialize-javascript": "^4.0.0"
+    "isomorphic-cookie": "^1.2.4",
+    "isomorphic-ws": "^4.0.1",
+    "js-cookie": "^2.2.0",
+    "jwt-decode": "^2.2.0",
+    "markdown-it": "^11.0.0",
+    "markdown-it-container": "^3.0.0",
+    "markdown-it-emoji": "^1.4.0",
+    "markdown-it-sub": "^1.0.0",
+    "markdown-it-sup": "^1.0.0",
+    "moment": "^2.24.0",
+    "node-fetch": "^2.6.0",
+    "reconnecting-websocket": "^4.4.0",
+    "rxjs": "^6.5.5",
+    "serialize-javascript": "^4.0.0",
+    "terser": "^4.6.11",
+    "tippy.js": "^6.1.1",
+    "toastify-js": "^1.7.0",
+    "tributejs": "^5.1.3",
+    "ws": "^7.3.1"
   },
   "devDependencies": {
     "@types/cookie-parser": "^1.4.1",
@@ -26,6 +54,7 @@
     "@types/jest": "^26.0.10",
     "@types/node": "^14.6.0",
     "@types/serialize-javascript": "^4.0.0",
+    "classcat": "^4.1.0",
     "enzyme": "^3.3.0",
     "enzyme-adapter-inferno": "^1.3.0",
     "eslint": "^7.5.0",
     "jest": "^26.4.2",
     "jsdom": "16.4.0",
     "jsdom-global": "3.0.2",
+    "lemmy-js-client": "^1.0.8",
     "lint-staged": "^10.1.3",
     "prettier": "^2.0.4",
     "sortpack": "^2.1.4",
     "ts-node": "^9.0.0",
     "ts-transform-classcat": "^1.0.0",
     "ts-transform-inferno": "^4.0.3",
-    "tslint-react-recommended": "^1.0.15",
     "typescript": "^4.0.2"
   },
+  "engines": {
+    "node": ">=8.9.0"
+  },
+  "engineStrict": true,
   "husky": {
     "hooks": {
       "pre-commit": "lint-staged"
diff --git a/src/client/components/About/About.css b/src/client/components/About/About.css
deleted file mode 100644 (file)
index c59a6bb..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-.text {\r
-   color: brown;\r
-   font-size: 25pt;\r
-}\r
-.count {\r
-   color: blue;\r
-}\r
-.button {\r
-   color: red;\r
-}\r
diff --git a/src/client/components/About/About.test.tsx b/src/client/components/About/About.test.tsx
deleted file mode 100644 (file)
index 8a23729..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-import 'jsdom-global/register';
-import { configure, mount, render, shallow } from 'enzyme';
-import InfernoEnzymeAdapter = require('enzyme-adapter-inferno');
-import { should } from 'fuse-test-runner';
-import { Component } from 'inferno';
-import { renderToSnapshot } from 'inferno-test-utils';
-import About from './About';
-configure({ adapter: new InfernoEnzymeAdapter() });
-
-export class AboutTest {
-  public 'Should be okay'() {
-    const wrapper = mount(<About />);
-    wrapper.find('.button').simulate('click');
-    const countText = wrapper.find('.count').text();
-    should(countText).beString().equal('1');
-  }
-}
diff --git a/src/client/components/About/About.tsx b/src/client/components/About/About.tsx
deleted file mode 100644 (file)
index 152c628..0000000
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Component } from 'inferno';
-import './About.css';
-interface IState {
-  clickCount: number;
-}
-interface IProps {}
-export default class About extends Component<IProps, IState> {
-  constructor(props) {
-    super(props);
-    this.state = {
-      clickCount: 0,
-    };
-    this.increment = this.increment.bind(this);
-  }
-  protected increment() {
-    this.setState({
-      clickCount: this.state.clickCount + 1,
-    });
-  }
-  public render() {
-    return (
-      <div>
-        Simple Inferno SSR template
-        <p className="text">Hello, world!</p>
-        <button onClick={this.increment} className="button">
-          Increment
-        </button>
-        <p className="count">{this.state.clickCount}</p>
-      </div>
-    );
-  }
-}
diff --git a/src/client/components/App/App.tsx b/src/client/components/App/App.tsx
deleted file mode 100644 (file)
index 6783679..0000000
+++ /dev/null
@@ -1,38 +0,0 @@
-import { Component, render } from 'inferno';
-import { Link, Route, StaticRouter, Switch } from 'inferno-router';
-import About from '../About/About';
-import Home from '../Home/Home';
-interface IState {}
-interface IProps {
-  name: string;
-}
-export default class App extends Component<IProps, IState> {
-  constructor(props) {
-    super(props);
-  }
-  public render() {
-    return (
-      <div>
-        <div>
-          <h3>{this.props.name}</h3>
-          <div>
-            <Link to="/Home">
-              <p>Home</p>
-            </Link>
-          </div>
-          <div>
-            <Link to="/About" className="link">
-              <p>About</p>
-            </Link>
-          </div>
-        </div>
-        <div>
-          <Switch>
-            <Route exact path="/Home" component={Home} />
-            <Route exact path="/About" component={About} />
-          </Switch>
-        </div>
-      </div>
-    );
-  }
-}
diff --git a/src/client/components/Home/Home.tsx b/src/client/components/Home/Home.tsx
deleted file mode 100644 (file)
index 8d1febb..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Component } from 'inferno';
-interface IState {}
-interface IProps {}
-export default class Home extends Component<IProps, IState> {
-  constructor(props) {
-    super(props);
-  }
-  protected click() {
-    /**
-     * Try to debug next line
-     */
-    console.log('hi');
-  }
-  public render() {
-    return (
-      <div>
-        Home page
-        <button onClick={this.click}>Click me</button>
-      </div>
-    );
-  }
-}
index 2dde2a77c43f0b743296a6488bb2182ab098bd8c..c2e404d3b95d6548961acc138da4a12f97eed997 100644 (file)
@@ -1,8 +1,8 @@
 import { Component } from 'inferno';
 import { hydrate } from 'inferno-hydrate';
 import { BrowserRouter } from 'inferno-router';
-import App from './components/App/App';
-import { initDevTools } from 'inferno-devtools';
+import { App } from '../shared/components/app';
+/* import { initDevTools } from 'inferno-devtools'; */
 
 declare global {
   interface Window {
@@ -14,8 +14,8 @@ declare global {
 
 const wrapper = (
   <BrowserRouter>
-    <App name={window.isoData.name} />
+    <App />
   </BrowserRouter>
 );
-initDevTools();
+/* initDevTools(); */
 hydrate(wrapper, document.getElementById('root'));
index f100c979e18ee90aaa293b98e6ed9779828bee8f..fe50e00e46793a0b202a4fedcb28109dc59c57d0 100644 (file)
@@ -1,28 +1,35 @@
 import cookieParser = require('cookie-parser');
-import * as serialize from 'serialize-javascript';
-import * as express from 'express';
+import serialize from 'serialize-javascript';
+import express from 'express';
 import { StaticRouter } from 'inferno-router';
 import { renderToString } from 'inferno-server';
+import { matchPath } from 'inferno-router';
 import path = require('path');
-import App from '../client/components/App/App';
+import { App } from '../shared/components/app';
+import { routes } from '../shared/routes';
+import IsomorphicCookie from 'isomorphic-cookie';
 const server = express();
 const port = 1234;
 
 server.use(express.json());
 server.use(express.urlencoded({ extended: false }));
+server.use('/assets', express.static(path.resolve('./dist/assets')));
 server.use('/static', express.static(path.resolve('./dist/client')));
 
 server.use(cookieParser());
 
 server.get('/*', (req, res) => {
+  const activeRoute = routes.find(route => matchPath(req.url, route)) || {};
+  console.log(activeRoute);
   const context = {} as any;
   const isoData = {
     name: 'fishing sux',
   };
+  let auth: string = IsomorphicCookie.load('jwt', req);
 
   const wrapper = (
     <StaticRouter location={req.url} context={context}>
-      <App name={isoData.name} />
+      <App />
     </StaticRouter>
   );
   if (context.url) {
@@ -30,17 +37,38 @@ server.get('/*', (req, res) => {
   }
 
   res.send(`
-   <!doctype html>
-   <html>
-       <head>
-       <title>My Universal App</title>
-       <script>window.isoData = ${serialize(isoData)}</script>      
-       </head>
-       <body>
-           <div id='root'>${renderToString(wrapper)}</div>
-           <script src='./static/bundle.js'></script>
-       </body>
-   </html>
+           <!DOCTYPE html>
+           <html lang="en">
+           <head>
+           <script>window.isoData = ${serialize(isoData)}</script>      
+
+           <!-- Required meta tags -->
+           <meta name="Description" content="Lemmy">
+           <meta charset="utf-8">
+           <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+
+           <!-- Icons -->
+           <link rel="shortcut icon" type="image/svg+xml" href="/assets/favicon.svg" />
+           <link rel="apple-touch-icon" href="/assets/apple-touch-icon.png" />
+
+           <!-- Styles -->
+           <link rel="stylesheet" type="text/css" href="/assets/css/tribute.css" />
+           <link rel="stylesheet" type="text/css" href="/assets/css/toastify.css" />
+           <link rel="stylesheet" type="text/css" href="/assets/css/choices.min.css" />
+           <link rel="stylesheet" type="text/css" href="/assets/css/tippy.css" />
+           <link rel="stylesheet" type="text/css" href="/assets/css/themes/litely.min.css" id="default-light" media="(prefers-color-scheme: light)" />
+           <link rel="stylesheet" type="text/css" href="/assets/css/themes/darkly.min.css" id="default-dark" media="(prefers-color-scheme: no-preference), (prefers-color-scheme: dark)" />
+           <link rel="stylesheet" type="text/css" href="/assets/css/main.css" />
+
+           <!-- Scripts -->
+           <script async src="/assets/libs/sortable/sortable.min.js"></script>
+           </head>
+
+           <body>
+             <div id='root'>${renderToString(wrapper)}</div>
+             <script src='./static/bundle.js'></script>
+           </body>
+         </html>
 `);
 });
 let Server = server.listen(port, () => {
diff --git a/src/shared/components/admin-settings.tsx b/src/shared/components/admin-settings.tsx
new file mode 100644 (file)
index 0000000..a3bfdd8
--- /dev/null
@@ -0,0 +1,260 @@
+import { Component, linkEvent } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  UserOperation,
+  SiteResponse,
+  GetSiteResponse,
+  SiteConfigForm,
+  GetSiteConfigResponse,
+  WebSocketJsonResponse,
+} from 'lemmy-js-client';
+import { WebSocketService } from '../services';
+import { wsJsonToRes, capitalizeFirstLetter, toast, randomStr } from '../utils';
+import autosize from 'autosize';
+import { SiteForm } from './site-form';
+import { UserListing } from './user-listing';
+import { i18n } from '../i18next';
+
+interface AdminSettingsState {
+  siteRes: GetSiteResponse;
+  siteConfigRes: GetSiteConfigResponse;
+  siteConfigForm: SiteConfigForm;
+  loading: boolean;
+  siteConfigLoading: boolean;
+}
+
+export class AdminSettings extends Component<any, AdminSettingsState> {
+  private siteConfigTextAreaId = `site-config-${randomStr()}`;
+  private subscription: Subscription;
+  private emptyState: AdminSettingsState = {
+    siteRes: {
+      site: {
+        id: null,
+        name: null,
+        creator_id: null,
+        creator_name: null,
+        published: null,
+        number_of_users: null,
+        number_of_posts: null,
+        number_of_comments: null,
+        number_of_communities: null,
+        enable_downvotes: null,
+        open_registration: null,
+        enable_nsfw: null,
+      },
+      admins: [],
+      banned: [],
+      online: null,
+      version: null,
+      federated_instances: null,
+    },
+    siteConfigForm: {
+      config_hjson: null,
+      auth: null,
+    },
+    siteConfigRes: {
+      config_hjson: null,
+    },
+    loading: true,
+    siteConfigLoading: null,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    WebSocketService.Instance.getSite();
+    WebSocketService.Instance.getSiteConfig();
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  get documentTitle(): string {
+    if (this.state.siteRes.site.name) {
+      return `${i18n.t('admin_settings')} - ${this.state.siteRes.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle} />
+        {this.state.loading ? (
+          <h5>
+            <svg class="icon icon-spinner spin">
+              <use xlinkHref="#icon-spinner"></use>
+            </svg>
+          </h5>
+        ) : (
+          <div class="row">
+            <div class="col-12 col-md-6">
+              {this.state.siteRes.site.id && (
+                <SiteForm site={this.state.siteRes.site} />
+              )}
+              {this.admins()}
+              {this.bannedUsers()}
+            </div>
+            <div class="col-12 col-md-6">{this.adminSettings()}</div>
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  admins() {
+    return (
+      <>
+        <h5>{capitalizeFirstLetter(i18n.t('admins'))}</h5>
+        <ul class="list-unstyled">
+          {this.state.siteRes.admins.map(admin => (
+            <li class="list-inline-item">
+              <UserListing
+                user={{
+                  name: admin.name,
+                  preferred_username: admin.preferred_username,
+                  avatar: admin.avatar,
+                  id: admin.id,
+                  local: admin.local,
+                  actor_id: admin.actor_id,
+                }}
+              />
+            </li>
+          ))}
+        </ul>
+      </>
+    );
+  }
+
+  bannedUsers() {
+    return (
+      <>
+        <h5>{i18n.t('banned_users')}</h5>
+        <ul class="list-unstyled">
+          {this.state.siteRes.banned.map(banned => (
+            <li class="list-inline-item">
+              <UserListing
+                user={{
+                  name: banned.name,
+                  preferred_username: banned.preferred_username,
+                  avatar: banned.avatar,
+                  id: banned.id,
+                  local: banned.local,
+                  actor_id: banned.actor_id,
+                }}
+              />
+            </li>
+          ))}
+        </ul>
+      </>
+    );
+  }
+
+  adminSettings() {
+    return (
+      <div>
+        <h5>{i18n.t('admin_settings')}</h5>
+        <form onSubmit={linkEvent(this, this.handleSiteConfigSubmit)}>
+          <div class="form-group row">
+            <label
+              class="col-12 col-form-label"
+              htmlFor={this.siteConfigTextAreaId}
+            >
+              {i18n.t('site_config')}
+            </label>
+            <div class="col-12">
+              <textarea
+                id={this.siteConfigTextAreaId}
+                value={this.state.siteConfigForm.config_hjson}
+                onInput={linkEvent(this, this.handleSiteConfigHjsonChange)}
+                class="form-control text-monospace"
+                rows={3}
+              />
+            </div>
+          </div>
+          <div class="form-group row">
+            <div class="col-12">
+              <button type="submit" class="btn btn-secondary mr-2">
+                {this.state.siteConfigLoading ? (
+                  <svg class="icon icon-spinner spin">
+                    <use xlinkHref="#icon-spinner"></use>
+                  </svg>
+                ) : (
+                  capitalizeFirstLetter(i18n.t('save'))
+                )}
+              </button>
+            </div>
+          </div>
+        </form>
+      </div>
+    );
+  }
+
+  handleSiteConfigSubmit(i: AdminSettings, event: any) {
+    event.preventDefault();
+    i.state.siteConfigLoading = true;
+    WebSocketService.Instance.saveSiteConfig(i.state.siteConfigForm);
+    i.setState(i.state);
+  }
+
+  handleSiteConfigHjsonChange(i: AdminSettings, event: any) {
+    i.state.siteConfigForm.config_hjson = event.target.value;
+    i.setState(i.state);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      this.context.router.history.push('/');
+      this.state.loading = false;
+      this.setState(this.state);
+      return;
+    } else if (msg.reconnect) {
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+
+      // This means it hasn't been set up yet
+      if (!data.site) {
+        this.context.router.history.push('/setup');
+      }
+      this.state.siteRes = data;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.EditSite) {
+      let data = res.data as SiteResponse;
+      this.state.siteRes.site = data.site;
+      this.setState(this.state);
+      toast(i18n.t('site_saved'));
+    } else if (res.op == UserOperation.GetSiteConfig) {
+      let data = res.data as GetSiteConfigResponse;
+      this.state.siteConfigRes = data;
+      this.state.loading = false;
+      this.state.siteConfigForm.config_hjson = this.state.siteConfigRes.config_hjson;
+      this.setState(this.state);
+      var textarea: any = document.getElementById(this.siteConfigTextAreaId);
+      autosize(textarea);
+    } else if (res.op == UserOperation.SaveSiteConfig) {
+      let data = res.data as GetSiteConfigResponse;
+      this.state.siteConfigRes = data;
+      this.state.siteConfigForm.config_hjson = this.state.siteConfigRes.config_hjson;
+      this.state.siteConfigLoading = false;
+      toast(i18n.t('site_saved'));
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/app.tsx b/src/shared/components/app.tsx
new file mode 100644 (file)
index 0000000..a6caf9c
--- /dev/null
@@ -0,0 +1,42 @@
+import { Component } from 'inferno';
+import { Route, Switch } from 'inferno-router';
+/* import { Provider } from 'inferno-i18next'; */
+/* import { i18n } from './i18next'; */
+import { routes } from '../../shared/routes';
+import { Navbar } from '../../shared/components/navbar';
+import { Footer } from '../../shared/components/footer';
+import { Symbols } from '../../shared/components/symbols';
+
+export class App extends Component<any, any> {
+  constructor(props: any, context: any) {
+    super(props, context);
+  }
+
+  render() {
+    return (
+      <>
+        <h1>Hi there!</h1>
+        {/* <Provider i18next={i18n}> */}
+        <div>
+          <Navbar />
+          <div class="mt-4 p-0 fl-1">
+            <Switch>
+              {routes.map(({ path, exact, component: C, ...rest }) => (
+                <Route
+                  key={path}
+                  path={path}
+                  exact={exact}
+                  render={props => <C {...props} {...rest} />}
+                />
+              ))}
+              {/* <Route render={(props) => <NoMatch {...props} />} /> */}
+            </Switch>
+            <Symbols />
+          </div>
+          <Footer />
+        </div>
+        {/* </Provider> */}
+      </>
+    );
+  }
+}
diff --git a/src/shared/components/banner-icon-header.tsx b/src/shared/components/banner-icon-header.tsx
new file mode 100644 (file)
index 0000000..8c0eedb
--- /dev/null
@@ -0,0 +1,30 @@
+import { Component } from 'inferno';
+
+interface BannerIconHeaderProps {
+  banner?: string;
+  icon?: string;
+}
+
+export class BannerIconHeader extends Component<BannerIconHeaderProps, any> {
+  constructor(props: any, context: any) {
+    super(props, context);
+  }
+
+  render() {
+    return (
+      <div class="position-relative mb-2">
+        {this.props.banner && (
+          <img src={this.props.banner} class="banner img-fluid" />
+        )}
+        {this.props.icon && (
+          <img
+            src={this.props.icon}
+            className={`ml-2 mb-0 ${
+              this.props.banner ? 'avatar-pushup' : ''
+            } rounded-circle avatar-overlay`}
+          />
+        )}
+      </div>
+    );
+  }
+}
diff --git a/src/shared/components/cake-day.tsx b/src/shared/components/cake-day.tsx
new file mode 100644 (file)
index 0000000..f28be33
--- /dev/null
@@ -0,0 +1,25 @@
+import { Component } from 'inferno';
+import { i18n } from '../i18next';
+
+interface CakeDayProps {
+  creatorName: string;
+}
+
+export class CakeDay extends Component<CakeDayProps, any> {
+  render() {
+    return (
+      <div
+        className={`mx-2 d-inline-block unselectable pointer`}
+        data-tippy-content={this.cakeDayTippy()}
+      >
+        <svg class="icon icon-inline">
+          <use xlinkHref="#icon-cake"></use>
+        </svg>
+      </div>
+    );
+  }
+
+  cakeDayTippy(): string {
+    return i18n.t('cake_day_info', { creator_name: this.props.creatorName });
+  }
+}
diff --git a/src/shared/components/comment-form.tsx b/src/shared/components/comment-form.tsx
new file mode 100644 (file)
index 0000000..dbd14dc
--- /dev/null
@@ -0,0 +1,154 @@
+import { Component } from 'inferno';
+import { Link } from 'inferno-router';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  CommentNode as CommentNodeI,
+  CommentForm as CommentFormI,
+  WebSocketJsonResponse,
+  UserOperation,
+  CommentResponse,
+} from 'lemmy-js-client';
+import { capitalizeFirstLetter, wsJsonToRes } from '../utils';
+import { WebSocketService, UserService } from '../services';
+import { i18n } from '../i18next';
+import { T } from 'inferno-i18next';
+import { MarkdownTextArea } from './markdown-textarea';
+
+interface CommentFormProps {
+  postId?: number;
+  node?: CommentNodeI;
+  onReplyCancel?(): any;
+  edit?: boolean;
+  disabled?: boolean;
+  focus?: boolean;
+}
+
+interface CommentFormState {
+  commentForm: CommentFormI;
+  buttonTitle: string;
+  finished: boolean;
+}
+
+export class CommentForm extends Component<CommentFormProps, CommentFormState> {
+  private subscription: Subscription;
+  private emptyState: CommentFormState = {
+    commentForm: {
+      auth: null,
+      content: null,
+      post_id: this.props.node
+        ? this.props.node.comment.post_id
+        : this.props.postId,
+      creator_id: UserService.Instance.user
+        ? UserService.Instance.user.id
+        : null,
+    },
+    buttonTitle: !this.props.node
+      ? capitalizeFirstLetter(i18n.t('post'))
+      : this.props.edit
+      ? capitalizeFirstLetter(i18n.t('save'))
+      : capitalizeFirstLetter(i18n.t('reply')),
+    finished: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.handleCommentSubmit = this.handleCommentSubmit.bind(this);
+    this.handleReplyCancel = this.handleReplyCancel.bind(this);
+
+    this.state = this.emptyState;
+
+    if (this.props.node) {
+      if (this.props.edit) {
+        this.state.commentForm.edit_id = this.props.node.comment.id;
+        this.state.commentForm.parent_id = this.props.node.comment.parent_id;
+        this.state.commentForm.content = this.props.node.comment.content;
+        this.state.commentForm.creator_id = this.props.node.comment.creator_id;
+      } else {
+        // A reply gets a new parent id
+        this.state.commentForm.parent_id = this.props.node.comment.id;
+      }
+    }
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  render() {
+    return (
+      <div class="mb-3">
+        {UserService.Instance.user ? (
+          <MarkdownTextArea
+            initialContent={this.state.commentForm.content}
+            buttonTitle={this.state.buttonTitle}
+            finished={this.state.finished}
+            replyType={!!this.props.node}
+            focus={this.props.focus}
+            disabled={this.props.disabled}
+            onSubmit={this.handleCommentSubmit}
+            onReplyCancel={this.handleReplyCancel}
+          />
+        ) : (
+          <div class="alert alert-light" role="alert">
+            <svg class="icon icon-inline mr-2">
+              <use xlinkHref="#icon-alert-triangle"></use>
+            </svg>
+            <T i18nKey="must_login" class="d-inline">
+              #
+              <Link class="alert-link" to="/login">
+                #
+              </Link>
+            </T>
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  handleCommentSubmit(msg: { val: string; formId: string }) {
+    this.state.commentForm.content = msg.val;
+    this.state.commentForm.form_id = msg.formId;
+    if (this.props.edit) {
+      WebSocketService.Instance.editComment(this.state.commentForm);
+    } else {
+      WebSocketService.Instance.createComment(this.state.commentForm);
+    }
+    this.setState(this.state);
+  }
+
+  handleReplyCancel() {
+    this.props.onReplyCancel();
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    let res = wsJsonToRes(msg);
+
+    // Only do the showing and hiding if logged in
+    if (UserService.Instance.user) {
+      if (
+        res.op == UserOperation.CreateComment ||
+        res.op == UserOperation.EditComment
+      ) {
+        let data = res.data as CommentResponse;
+
+        // This only finishes this form, if the randomly generated form_id matches the one received
+        if (this.state.commentForm.form_id == data.form_id) {
+          this.setState({ finished: true });
+
+          // Necessary because it broke tribute for some reaso
+          this.setState({ finished: false });
+        }
+      }
+    }
+  }
+}
diff --git a/src/shared/components/comment-node.tsx b/src/shared/components/comment-node.tsx
new file mode 100644 (file)
index 0000000..4a27280
--- /dev/null
@@ -0,0 +1,1208 @@
+import { Component, linkEvent } from 'inferno';
+import { Link } from 'inferno-router';
+import {
+  CommentNode as CommentNodeI,
+  CommentLikeForm,
+  DeleteCommentForm,
+  RemoveCommentForm,
+  MarkCommentAsReadForm,
+  MarkUserMentionAsReadForm,
+  SaveCommentForm,
+  BanFromCommunityForm,
+  BanUserForm,
+  CommunityUser,
+  UserView,
+  AddModToCommunityForm,
+  AddAdminForm,
+  TransferCommunityForm,
+  TransferSiteForm,
+  SortType,
+} from 'lemmy-js-client';
+import { CommentSortType, BanType } from '../interfaces';
+import { WebSocketService, UserService } from '../services';
+import {
+  mdToHtml,
+  getUnixTime,
+  canMod,
+  isMod,
+  setupTippy,
+  colorList,
+} from '../utils';
+import moment from 'moment';
+import { MomentTime } from './moment-time';
+import { CommentForm } from './comment-form';
+import { CommentNodes } from './comment-nodes';
+import { UserListing } from './user-listing';
+import { CommunityLink } from './community-link';
+import { i18n } from '../i18next';
+
+interface CommentNodeState {
+  showReply: boolean;
+  showEdit: boolean;
+  showRemoveDialog: boolean;
+  removeReason: string;
+  showBanDialog: boolean;
+  removeData: boolean;
+  banReason: string;
+  banExpires: string;
+  banType: BanType;
+  showConfirmTransferSite: boolean;
+  showConfirmTransferCommunity: boolean;
+  showConfirmAppointAsMod: boolean;
+  showConfirmAppointAsAdmin: boolean;
+  collapsed: boolean;
+  viewSource: boolean;
+  showAdvanced: boolean;
+  my_vote: number;
+  score: number;
+  upvotes: number;
+  downvotes: number;
+  borderColor: string;
+  readLoading: boolean;
+  saveLoading: boolean;
+}
+
+interface CommentNodeProps {
+  node: CommentNodeI;
+  noBorder?: boolean;
+  noIndent?: boolean;
+  viewOnly?: boolean;
+  locked?: boolean;
+  markable?: boolean;
+  showContext?: boolean;
+  moderators: CommunityUser[];
+  admins: UserView[];
+  // TODO is this necessary, can't I get it from the node itself?
+  postCreatorId?: number;
+  showCommunity?: boolean;
+  sort?: CommentSortType;
+  sortType?: SortType;
+  enableDownvotes: boolean;
+}
+
+export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
+  private emptyState: CommentNodeState = {
+    showReply: false,
+    showEdit: false,
+    showRemoveDialog: false,
+    removeReason: null,
+    showBanDialog: false,
+    removeData: null,
+    banReason: null,
+    banExpires: null,
+    banType: BanType.Community,
+    collapsed: false,
+    viewSource: false,
+    showAdvanced: false,
+    showConfirmTransferSite: false,
+    showConfirmTransferCommunity: false,
+    showConfirmAppointAsMod: false,
+    showConfirmAppointAsAdmin: false,
+    my_vote: this.props.node.comment.my_vote,
+    score: this.props.node.comment.score,
+    upvotes: this.props.node.comment.upvotes,
+    downvotes: this.props.node.comment.downvotes,
+    borderColor: this.props.node.comment.depth
+      ? colorList[this.props.node.comment.depth % colorList.length]
+      : colorList[0],
+    readLoading: false,
+    saveLoading: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+    this.handleReplyCancel = this.handleReplyCancel.bind(this);
+    this.handleCommentUpvote = this.handleCommentUpvote.bind(this);
+    this.handleCommentDownvote = this.handleCommentDownvote.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps: CommentNodeProps) {
+    this.state.my_vote = nextProps.node.comment.my_vote;
+    this.state.upvotes = nextProps.node.comment.upvotes;
+    this.state.downvotes = nextProps.node.comment.downvotes;
+    this.state.score = nextProps.node.comment.score;
+    this.state.readLoading = false;
+    this.state.saveLoading = false;
+    this.setState(this.state);
+  }
+
+  render() {
+    let node = this.props.node;
+    return (
+      <div
+        className={`comment ${
+          node.comment.parent_id && !this.props.noIndent ? 'ml-1' : ''
+        }`}
+      >
+        <div
+          id={`comment-${node.comment.id}`}
+          className={`details comment-node py-2 ${
+            !this.props.noBorder ? 'border-top border-light' : ''
+          } ${this.isCommentNew ? 'mark' : ''}`}
+          style={
+            !this.props.noIndent &&
+            this.props.node.comment.parent_id &&
+            `border-left: 2px ${this.state.borderColor} solid !important`
+          }
+        >
+          <div
+            class={`${
+              !this.props.noIndent &&
+              this.props.node.comment.parent_id &&
+              'ml-2'
+            }`}
+          >
+            <div class="d-flex flex-wrap align-items-center text-muted small">
+              <span class="mr-2">
+                <UserListing
+                  user={{
+                    name: node.comment.creator_name,
+                    preferred_username: node.comment.creator_preferred_username,
+                    avatar: node.comment.creator_avatar,
+                    id: node.comment.creator_id,
+                    local: node.comment.creator_local,
+                    actor_id: node.comment.creator_actor_id,
+                    published: node.comment.creator_published,
+                  }}
+                />
+              </span>
+
+              {this.isMod && (
+                <div className="badge badge-light d-none d-sm-inline mr-2">
+                  {i18n.t('mod')}
+                </div>
+              )}
+              {this.isAdmin && (
+                <div className="badge badge-light d-none d-sm-inline mr-2">
+                  {i18n.t('admin')}
+                </div>
+              )}
+              {this.isPostCreator && (
+                <div className="badge badge-light d-none d-sm-inline mr-2">
+                  {i18n.t('creator')}
+                </div>
+              )}
+              {(node.comment.banned_from_community || node.comment.banned) && (
+                <div className="badge badge-danger mr-2">
+                  {i18n.t('banned')}
+                </div>
+              )}
+              {this.props.showCommunity && (
+                <>
+                  <span class="mx-1">{i18n.t('to')}</span>
+                  <CommunityLink
+                    community={{
+                      name: node.comment.community_name,
+                      id: node.comment.community_id,
+                      local: node.comment.community_local,
+                      actor_id: node.comment.community_actor_id,
+                      icon: node.comment.community_icon,
+                    }}
+                  />
+                  <span class="mx-2">•</span>
+                  <Link class="mr-2" to={`/post/${node.comment.post_id}`}>
+                    {node.comment.post_name}
+                  </Link>
+                </>
+              )}
+              <button
+                class="btn text-muted"
+                onClick={linkEvent(this, this.handleCommentCollapse)}
+              >
+                {this.state.collapsed ? (
+                  <svg class="icon icon-inline">
+                    <use xlinkHref="#icon-plus-square"></use>
+                  </svg>
+                ) : (
+                  <svg class="icon icon-inline">
+                    <use xlinkHref="#icon-minus-square"></use>
+                  </svg>
+                )}
+              </button>
+              {/* This is an expanding spacer for mobile */}
+              <div className="mr-lg-4 flex-grow-1 flex-lg-grow-0 unselectable pointer mx-2"></div>
+              <button
+                className={`btn p-0 unselectable pointer ${this.scoreColor}`}
+                onClick={linkEvent(node, this.handleCommentUpvote)}
+                data-tippy-content={this.pointsTippy}
+              >
+                <svg class="icon icon-inline mr-1">
+                  <use xlinkHref="#icon-zap"></use>
+                </svg>
+                <span class="mr-1">{this.state.score}</span>
+              </button>
+              <span className="mr-1">•</span>
+              <span>
+                <MomentTime data={node.comment} />
+              </span>
+            </div>
+            {/* end of user row */}
+            {this.state.showEdit && (
+              <CommentForm
+                node={node}
+                edit
+                onReplyCancel={this.handleReplyCancel}
+                disabled={this.props.locked}
+                focus
+              />
+            )}
+            {!this.state.showEdit && !this.state.collapsed && (
+              <div>
+                {this.state.viewSource ? (
+                  <pre>{this.commentUnlessRemoved}</pre>
+                ) : (
+                  <div
+                    className="md-div"
+                    dangerouslySetInnerHTML={mdToHtml(
+                      this.commentUnlessRemoved
+                    )}
+                  />
+                )}
+                <div class="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted font-weight-bold">
+                  {this.props.showContext && this.linkBtn}
+                  {this.props.markable && (
+                    <button
+                      class="btn btn-link btn-animate text-muted"
+                      onClick={linkEvent(this, this.handleMarkRead)}
+                      data-tippy-content={
+                        node.comment.read
+                          ? i18n.t('mark_as_unread')
+                          : i18n.t('mark_as_read')
+                      }
+                    >
+                      {this.state.readLoading ? (
+                        this.loadingIcon
+                      ) : (
+                        <svg
+                          class={`icon icon-inline ${
+                            node.comment.read && 'text-success'
+                          }`}
+                        >
+                          <use xlinkHref="#icon-check"></use>
+                        </svg>
+                      )}
+                    </button>
+                  )}
+                  {UserService.Instance.user && !this.props.viewOnly && (
+                    <>
+                      <button
+                        className={`btn btn-link btn-animate ${
+                          this.state.my_vote == 1 ? 'text-info' : 'text-muted'
+                        }`}
+                        onClick={linkEvent(node, this.handleCommentUpvote)}
+                        data-tippy-content={i18n.t('upvote')}
+                      >
+                        <svg class="icon icon-inline">
+                          <use xlinkHref="#icon-arrow-up"></use>
+                        </svg>
+                        {this.state.upvotes !== this.state.score && (
+                          <span class="ml-1">{this.state.upvotes}</span>
+                        )}
+                      </button>
+                      {this.props.enableDownvotes && (
+                        <button
+                          className={`btn btn-link btn-animate ${
+                            this.state.my_vote == -1
+                              ? 'text-danger'
+                              : 'text-muted'
+                          }`}
+                          onClick={linkEvent(node, this.handleCommentDownvote)}
+                          data-tippy-content={i18n.t('downvote')}
+                        >
+                          <svg class="icon icon-inline">
+                            <use xlinkHref="#icon-arrow-down"></use>
+                          </svg>
+                          {this.state.upvotes !== this.state.score && (
+                            <span class="ml-1">{this.state.downvotes}</span>
+                          )}
+                        </button>
+                      )}
+                      <button
+                        class="btn btn-link btn-animate text-muted"
+                        onClick={linkEvent(this, this.handleReplyClick)}
+                        data-tippy-content={i18n.t('reply')}
+                      >
+                        <svg class="icon icon-inline">
+                          <use xlinkHref="#icon-reply1"></use>
+                        </svg>
+                      </button>
+                      {!this.state.showAdvanced ? (
+                        <button
+                          className="btn btn-link btn-animate text-muted"
+                          onClick={linkEvent(this, this.handleShowAdvanced)}
+                          data-tippy-content={i18n.t('more')}
+                        >
+                          <svg class="icon icon-inline">
+                            <use xlinkHref="#icon-more-vertical"></use>
+                          </svg>
+                        </button>
+                      ) : (
+                        <>
+                          {!this.myComment && (
+                            <button class="btn btn-link btn-animate">
+                              <Link
+                                class="text-muted"
+                                to={`/create_private_message?recipient_id=${node.comment.creator_id}`}
+                                title={i18n.t('message').toLowerCase()}
+                              >
+                                <svg class="icon">
+                                  <use xlinkHref="#icon-mail"></use>
+                                </svg>
+                              </Link>
+                            </button>
+                          )}
+                          {!this.props.showContext && this.linkBtn}
+                          <button
+                            class="btn btn-link btn-animate text-muted"
+                            onClick={linkEvent(
+                              this,
+                              this.handleSaveCommentClick
+                            )}
+                            data-tippy-content={
+                              node.comment.saved
+                                ? i18n.t('unsave')
+                                : i18n.t('save')
+                            }
+                          >
+                            {this.state.saveLoading ? (
+                              this.loadingIcon
+                            ) : (
+                              <svg
+                                class={`icon icon-inline ${
+                                  node.comment.saved && 'text-warning'
+                                }`}
+                              >
+                                <use xlinkHref="#icon-star"></use>
+                              </svg>
+                            )}
+                          </button>
+                          <button
+                            className="btn btn-link btn-animate text-muted"
+                            onClick={linkEvent(this, this.handleViewSource)}
+                            data-tippy-content={i18n.t('view_source')}
+                          >
+                            <svg
+                              class={`icon icon-inline ${
+                                this.state.viewSource && 'text-success'
+                              }`}
+                            >
+                              <use xlinkHref="#icon-file-text"></use>
+                            </svg>
+                          </button>
+                          {this.myComment && (
+                            <>
+                              <button
+                                class="btn btn-link btn-animate text-muted"
+                                onClick={linkEvent(this, this.handleEditClick)}
+                                data-tippy-content={i18n.t('edit')}
+                              >
+                                <svg class="icon icon-inline">
+                                  <use xlinkHref="#icon-edit"></use>
+                                </svg>
+                              </button>
+                              <button
+                                class="btn btn-link btn-animate text-muted"
+                                onClick={linkEvent(
+                                  this,
+                                  this.handleDeleteClick
+                                )}
+                                data-tippy-content={
+                                  !node.comment.deleted
+                                    ? i18n.t('delete')
+                                    : i18n.t('restore')
+                                }
+                              >
+                                <svg
+                                  class={`icon icon-inline ${
+                                    node.comment.deleted && 'text-danger'
+                                  }`}
+                                >
+                                  <use xlinkHref="#icon-trash"></use>
+                                </svg>
+                              </button>
+                            </>
+                          )}
+                          {/* Admins and mods can remove comments */}
+                          {(this.canMod || this.canAdmin) && (
+                            <>
+                              {!node.comment.removed ? (
+                                <button
+                                  class="btn btn-link btn-animate text-muted"
+                                  onClick={linkEvent(
+                                    this,
+                                    this.handleModRemoveShow
+                                  )}
+                                >
+                                  {i18n.t('remove')}
+                                </button>
+                              ) : (
+                                <button
+                                  class="btn btn-link btn-animate text-muted"
+                                  onClick={linkEvent(
+                                    this,
+                                    this.handleModRemoveSubmit
+                                  )}
+                                >
+                                  {i18n.t('restore')}
+                                </button>
+                              )}
+                            </>
+                          )}
+                          {/* Mods can ban from community, and appoint as mods to community */}
+                          {this.canMod && (
+                            <>
+                              {!this.isMod &&
+                                (!node.comment.banned_from_community ? (
+                                  <button
+                                    class="btn btn-link btn-animate text-muted"
+                                    onClick={linkEvent(
+                                      this,
+                                      this.handleModBanFromCommunityShow
+                                    )}
+                                  >
+                                    {i18n.t('ban')}
+                                  </button>
+                                ) : (
+                                  <button
+                                    class="btn btn-link btn-animate text-muted"
+                                    onClick={linkEvent(
+                                      this,
+                                      this.handleModBanFromCommunitySubmit
+                                    )}
+                                  >
+                                    {i18n.t('unban')}
+                                  </button>
+                                ))}
+                              {!node.comment.banned_from_community &&
+                                node.comment.creator_local &&
+                                (!this.state.showConfirmAppointAsMod ? (
+                                  <button
+                                    class="btn btn-link btn-animate text-muted"
+                                    onClick={linkEvent(
+                                      this,
+                                      this.handleShowConfirmAppointAsMod
+                                    )}
+                                  >
+                                    {this.isMod
+                                      ? i18n.t('remove_as_mod')
+                                      : i18n.t('appoint_as_mod')}
+                                  </button>
+                                ) : (
+                                  <>
+                                    <button class="btn btn-link btn-animate text-muted">
+                                      {i18n.t('are_you_sure')}
+                                    </button>
+                                    <button
+                                      class="btn btn-link btn-animate text-muted"
+                                      onClick={linkEvent(
+                                        this,
+                                        this.handleAddModToCommunity
+                                      )}
+                                    >
+                                      {i18n.t('yes')}
+                                    </button>
+                                    <button
+                                      class="btn btn-link btn-animate text-muted"
+                                      onClick={linkEvent(
+                                        this,
+                                        this.handleCancelConfirmAppointAsMod
+                                      )}
+                                    >
+                                      {i18n.t('no')}
+                                    </button>
+                                  </>
+                                ))}
+                            </>
+                          )}
+                          {/* Community creators and admins can transfer community to another mod */}
+                          {(this.amCommunityCreator || this.canAdmin) &&
+                            this.isMod &&
+                            node.comment.creator_local &&
+                            (!this.state.showConfirmTransferCommunity ? (
+                              <button
+                                class="btn btn-link btn-animate text-muted"
+                                onClick={linkEvent(
+                                  this,
+                                  this.handleShowConfirmTransferCommunity
+                                )}
+                              >
+                                {i18n.t('transfer_community')}
+                              </button>
+                            ) : (
+                              <>
+                                <button class="btn btn-link btn-animate text-muted">
+                                  {i18n.t('are_you_sure')}
+                                </button>
+                                <button
+                                  class="btn btn-link btn-animate text-muted"
+                                  onClick={linkEvent(
+                                    this,
+                                    this.handleTransferCommunity
+                                  )}
+                                >
+                                  {i18n.t('yes')}
+                                </button>
+                                <button
+                                  class="btn btn-link btn-animate text-muted"
+                                  onClick={linkEvent(
+                                    this,
+                                    this
+                                      .handleCancelShowConfirmTransferCommunity
+                                  )}
+                                >
+                                  {i18n.t('no')}
+                                </button>
+                              </>
+                            ))}
+                          {/* Admins can ban from all, and appoint other admins */}
+                          {this.canAdmin && (
+                            <>
+                              {!this.isAdmin &&
+                                (!node.comment.banned ? (
+                                  <button
+                                    class="btn btn-link btn-animate text-muted"
+                                    onClick={linkEvent(
+                                      this,
+                                      this.handleModBanShow
+                                    )}
+                                  >
+                                    {i18n.t('ban_from_site')}
+                                  </button>
+                                ) : (
+                                  <button
+                                    class="btn btn-link btn-animate text-muted"
+                                    onClick={linkEvent(
+                                      this,
+                                      this.handleModBanSubmit
+                                    )}
+                                  >
+                                    {i18n.t('unban_from_site')}
+                                  </button>
+                                ))}
+                              {!node.comment.banned &&
+                                node.comment.creator_local &&
+                                (!this.state.showConfirmAppointAsAdmin ? (
+                                  <button
+                                    class="btn btn-link btn-animate text-muted"
+                                    onClick={linkEvent(
+                                      this,
+                                      this.handleShowConfirmAppointAsAdmin
+                                    )}
+                                  >
+                                    {this.isAdmin
+                                      ? i18n.t('remove_as_admin')
+                                      : i18n.t('appoint_as_admin')}
+                                  </button>
+                                ) : (
+                                  <>
+                                    <button class="btn btn-link btn-animate text-muted">
+                                      {i18n.t('are_you_sure')}
+                                    </button>
+                                    <button
+                                      class="btn btn-link btn-animate text-muted"
+                                      onClick={linkEvent(
+                                        this,
+                                        this.handleAddAdmin
+                                      )}
+                                    >
+                                      {i18n.t('yes')}
+                                    </button>
+                                    <button
+                                      class="btn btn-link btn-animate text-muted"
+                                      onClick={linkEvent(
+                                        this,
+                                        this.handleCancelConfirmAppointAsAdmin
+                                      )}
+                                    >
+                                      {i18n.t('no')}
+                                    </button>
+                                  </>
+                                ))}
+                            </>
+                          )}
+                          {/* Site Creator can transfer to another admin */}
+                          {this.amSiteCreator &&
+                            this.isAdmin &&
+                            node.comment.creator_local &&
+                            (!this.state.showConfirmTransferSite ? (
+                              <button
+                                class="btn btn-link btn-animate text-muted"
+                                onClick={linkEvent(
+                                  this,
+                                  this.handleShowConfirmTransferSite
+                                )}
+                              >
+                                {i18n.t('transfer_site')}
+                              </button>
+                            ) : (
+                              <>
+                                <button class="btn btn-link btn-animate text-muted">
+                                  {i18n.t('are_you_sure')}
+                                </button>
+                                <button
+                                  class="btn btn-link btn-animate text-muted"
+                                  onClick={linkEvent(
+                                    this,
+                                    this.handleTransferSite
+                                  )}
+                                >
+                                  {i18n.t('yes')}
+                                </button>
+                                <button
+                                  class="btn btn-link btn-animate text-muted"
+                                  onClick={linkEvent(
+                                    this,
+                                    this.handleCancelShowConfirmTransferSite
+                                  )}
+                                >
+                                  {i18n.t('no')}
+                                </button>
+                              </>
+                            ))}
+                        </>
+                      )}
+                    </>
+                  )}
+                </div>
+                {/* end of button group */}
+              </div>
+            )}
+          </div>
+        </div>
+        {/* end of details */}
+        {this.state.showRemoveDialog && (
+          <form
+            class="form-inline"
+            onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
+          >
+            <input
+              type="text"
+              class="form-control mr-2"
+              placeholder={i18n.t('reason')}
+              value={this.state.removeReason}
+              onInput={linkEvent(this, this.handleModRemoveReasonChange)}
+            />
+            <button type="submit" class="btn btn-secondary">
+              {i18n.t('remove_comment')}
+            </button>
+          </form>
+        )}
+        {this.state.showBanDialog && (
+          <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
+            <div class="form-group row">
+              <label class="col-form-label">{i18n.t('reason')}</label>
+              <input
+                type="text"
+                class="form-control mr-2"
+                placeholder={i18n.t('reason')}
+                value={this.state.banReason}
+                onInput={linkEvent(this, this.handleModBanReasonChange)}
+              />
+              <div class="form-group">
+                <div class="form-check">
+                  <input
+                    class="form-check-input"
+                    id="mod-ban-remove-data"
+                    type="checkbox"
+                    checked={this.state.removeData}
+                    onChange={linkEvent(this, this.handleModRemoveDataChange)}
+                  />
+                  <label class="form-check-label" htmlFor="mod-ban-remove-data">
+                    {i18n.t('remove_posts_comments')}
+                  </label>
+                </div>
+              </div>
+            </div>
+            {/* TODO hold off on expires until later */}
+            {/* <div class="form-group row"> */}
+            {/*   <label class="col-form-label">Expires</label> */}
+            {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
+            {/* </div> */}
+            <div class="form-group row">
+              <button type="submit" class="btn btn-secondary">
+                {i18n.t('ban')} {node.comment.creator_name}
+              </button>
+            </div>
+          </form>
+        )}
+        {this.state.showReply && (
+          <CommentForm
+            node={node}
+            onReplyCancel={this.handleReplyCancel}
+            disabled={this.props.locked}
+            focus
+          />
+        )}
+        {node.children && !this.state.collapsed && (
+          <CommentNodes
+            nodes={node.children}
+            locked={this.props.locked}
+            moderators={this.props.moderators}
+            admins={this.props.admins}
+            postCreatorId={this.props.postCreatorId}
+            sort={this.props.sort}
+            sortType={this.props.sortType}
+            enableDownvotes={this.props.enableDownvotes}
+          />
+        )}
+        {/* A collapsed clearfix */}
+        {this.state.collapsed && <div class="row col-12"></div>}
+      </div>
+    );
+  }
+
+  get linkBtn() {
+    let node = this.props.node;
+    return (
+      <Link
+        class="btn btn-link btn-animate text-muted"
+        to={`/post/${node.comment.post_id}/comment/${node.comment.id}`}
+        title={this.props.showContext ? i18n.t('show_context') : i18n.t('link')}
+      >
+        <svg class="icon icon-inline">
+          <use xlinkHref="#icon-link"></use>
+        </svg>
+      </Link>
+    );
+  }
+
+  get loadingIcon() {
+    return (
+      <svg class="icon icon-spinner spin">
+        <use xlinkHref="#icon-spinner"></use>
+      </svg>
+    );
+  }
+
+  get myComment(): boolean {
+    return (
+      UserService.Instance.user &&
+      this.props.node.comment.creator_id == UserService.Instance.user.id
+    );
+  }
+
+  get isMod(): boolean {
+    return (
+      this.props.moderators &&
+      isMod(
+        this.props.moderators.map(m => m.user_id),
+        this.props.node.comment.creator_id
+      )
+    );
+  }
+
+  get isAdmin(): boolean {
+    return (
+      this.props.admins &&
+      isMod(
+        this.props.admins.map(a => a.id),
+        this.props.node.comment.creator_id
+      )
+    );
+  }
+
+  get isPostCreator(): boolean {
+    return this.props.node.comment.creator_id == this.props.postCreatorId;
+  }
+
+  get canMod(): boolean {
+    if (this.props.admins && this.props.moderators) {
+      let adminsThenMods = this.props.admins
+        .map(a => a.id)
+        .concat(this.props.moderators.map(m => m.user_id));
+
+      return canMod(
+        UserService.Instance.user,
+        adminsThenMods,
+        this.props.node.comment.creator_id
+      );
+    } else {
+      return false;
+    }
+  }
+
+  get canAdmin(): boolean {
+    return (
+      this.props.admins &&
+      canMod(
+        UserService.Instance.user,
+        this.props.admins.map(a => a.id),
+        this.props.node.comment.creator_id
+      )
+    );
+  }
+
+  get amCommunityCreator(): boolean {
+    return (
+      this.props.moderators &&
+      UserService.Instance.user &&
+      this.props.node.comment.creator_id != UserService.Instance.user.id &&
+      UserService.Instance.user.id == this.props.moderators[0].user_id
+    );
+  }
+
+  get amSiteCreator(): boolean {
+    return (
+      this.props.admins &&
+      UserService.Instance.user &&
+      this.props.node.comment.creator_id != UserService.Instance.user.id &&
+      UserService.Instance.user.id == this.props.admins[0].id
+    );
+  }
+
+  get commentUnlessRemoved(): string {
+    let node = this.props.node;
+    return node.comment.removed
+      ? `*${i18n.t('removed')}*`
+      : node.comment.deleted
+      ? `*${i18n.t('deleted')}*`
+      : node.comment.content;
+  }
+
+  handleReplyClick(i: CommentNode) {
+    i.state.showReply = true;
+    i.setState(i.state);
+  }
+
+  handleEditClick(i: CommentNode) {
+    i.state.showEdit = true;
+    i.setState(i.state);
+  }
+
+  handleDeleteClick(i: CommentNode) {
+    let deleteForm: DeleteCommentForm = {
+      edit_id: i.props.node.comment.id,
+      deleted: !i.props.node.comment.deleted,
+      auth: null,
+    };
+    WebSocketService.Instance.deleteComment(deleteForm);
+  }
+
+  handleSaveCommentClick(i: CommentNode) {
+    let saved =
+      i.props.node.comment.saved == undefined
+        ? true
+        : !i.props.node.comment.saved;
+    let form: SaveCommentForm = {
+      comment_id: i.props.node.comment.id,
+      save: saved,
+    };
+
+    WebSocketService.Instance.saveComment(form);
+
+    i.state.saveLoading = true;
+    i.setState(this.state);
+  }
+
+  handleReplyCancel() {
+    this.state.showReply = false;
+    this.state.showEdit = false;
+    this.setState(this.state);
+  }
+
+  handleCommentUpvote(i: CommentNodeI) {
+    let new_vote = this.state.my_vote == 1 ? 0 : 1;
+
+    if (this.state.my_vote == 1) {
+      this.state.score--;
+      this.state.upvotes--;
+    } else if (this.state.my_vote == -1) {
+      this.state.downvotes--;
+      this.state.upvotes++;
+      this.state.score += 2;
+    } else {
+      this.state.upvotes++;
+      this.state.score++;
+    }
+
+    this.state.my_vote = new_vote;
+
+    let form: CommentLikeForm = {
+      comment_id: i.comment.id,
+      score: this.state.my_vote,
+    };
+
+    WebSocketService.Instance.likeComment(form);
+    this.setState(this.state);
+    setupTippy();
+  }
+
+  handleCommentDownvote(i: CommentNodeI) {
+    let new_vote = this.state.my_vote == -1 ? 0 : -1;
+
+    if (this.state.my_vote == 1) {
+      this.state.score -= 2;
+      this.state.upvotes--;
+      this.state.downvotes++;
+    } else if (this.state.my_vote == -1) {
+      this.state.downvotes--;
+      this.state.score++;
+    } else {
+      this.state.downvotes++;
+      this.state.score--;
+    }
+
+    this.state.my_vote = new_vote;
+
+    let form: CommentLikeForm = {
+      comment_id: i.comment.id,
+      score: this.state.my_vote,
+    };
+
+    WebSocketService.Instance.likeComment(form);
+    this.setState(this.state);
+    setupTippy();
+  }
+
+  handleModRemoveShow(i: CommentNode) {
+    i.state.showRemoveDialog = true;
+    i.setState(i.state);
+  }
+
+  handleModRemoveReasonChange(i: CommentNode, event: any) {
+    i.state.removeReason = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleModRemoveDataChange(i: CommentNode, event: any) {
+    i.state.removeData = event.target.checked;
+    i.setState(i.state);
+  }
+
+  handleModRemoveSubmit(i: CommentNode) {
+    event.preventDefault();
+    let form: RemoveCommentForm = {
+      edit_id: i.props.node.comment.id,
+      removed: !i.props.node.comment.removed,
+      reason: i.state.removeReason,
+      auth: null,
+    };
+    WebSocketService.Instance.removeComment(form);
+
+    i.state.showRemoveDialog = false;
+    i.setState(i.state);
+  }
+
+  handleMarkRead(i: CommentNode) {
+    // if it has a user_mention_id field, then its a mention
+    if (i.props.node.comment.user_mention_id) {
+      let form: MarkUserMentionAsReadForm = {
+        user_mention_id: i.props.node.comment.user_mention_id,
+        read: !i.props.node.comment.read,
+      };
+      WebSocketService.Instance.markUserMentionAsRead(form);
+    } else {
+      let form: MarkCommentAsReadForm = {
+        edit_id: i.props.node.comment.id,
+        read: !i.props.node.comment.read,
+        auth: null,
+      };
+      WebSocketService.Instance.markCommentAsRead(form);
+    }
+
+    i.state.readLoading = true;
+    i.setState(this.state);
+  }
+
+  handleModBanFromCommunityShow(i: CommentNode) {
+    i.state.showBanDialog = !i.state.showBanDialog;
+    i.state.banType = BanType.Community;
+    i.setState(i.state);
+  }
+
+  handleModBanShow(i: CommentNode) {
+    i.state.showBanDialog = !i.state.showBanDialog;
+    i.state.banType = BanType.Site;
+    i.setState(i.state);
+  }
+
+  handleModBanReasonChange(i: CommentNode, event: any) {
+    i.state.banReason = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleModBanExpiresChange(i: CommentNode, event: any) {
+    i.state.banExpires = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleModBanFromCommunitySubmit(i: CommentNode) {
+    i.state.banType = BanType.Community;
+    i.setState(i.state);
+    i.handleModBanBothSubmit(i);
+  }
+
+  handleModBanSubmit(i: CommentNode) {
+    i.state.banType = BanType.Site;
+    i.setState(i.state);
+    i.handleModBanBothSubmit(i);
+  }
+
+  handleModBanBothSubmit(i: CommentNode) {
+    event.preventDefault();
+
+    if (i.state.banType == BanType.Community) {
+      // If its an unban, restore all their data
+      let ban = !i.props.node.comment.banned_from_community;
+      if (ban == false) {
+        i.state.removeData = false;
+      }
+      let form: BanFromCommunityForm = {
+        user_id: i.props.node.comment.creator_id,
+        community_id: i.props.node.comment.community_id,
+        ban,
+        remove_data: i.state.removeData,
+        reason: i.state.banReason,
+        expires: getUnixTime(i.state.banExpires),
+      };
+      WebSocketService.Instance.banFromCommunity(form);
+    } else {
+      // If its an unban, restore all their data
+      let ban = !i.props.node.comment.banned;
+      if (ban == false) {
+        i.state.removeData = false;
+      }
+      let form: BanUserForm = {
+        user_id: i.props.node.comment.creator_id,
+        ban,
+        remove_data: i.state.removeData,
+        reason: i.state.banReason,
+        expires: getUnixTime(i.state.banExpires),
+      };
+      WebSocketService.Instance.banUser(form);
+    }
+
+    i.state.showBanDialog = false;
+    i.setState(i.state);
+  }
+
+  handleShowConfirmAppointAsMod(i: CommentNode) {
+    i.state.showConfirmAppointAsMod = true;
+    i.setState(i.state);
+  }
+
+  handleCancelConfirmAppointAsMod(i: CommentNode) {
+    i.state.showConfirmAppointAsMod = false;
+    i.setState(i.state);
+  }
+
+  handleAddModToCommunity(i: CommentNode) {
+    let form: AddModToCommunityForm = {
+      user_id: i.props.node.comment.creator_id,
+      community_id: i.props.node.comment.community_id,
+      added: !i.isMod,
+    };
+    WebSocketService.Instance.addModToCommunity(form);
+    i.state.showConfirmAppointAsMod = false;
+    i.setState(i.state);
+  }
+
+  handleShowConfirmAppointAsAdmin(i: CommentNode) {
+    i.state.showConfirmAppointAsAdmin = true;
+    i.setState(i.state);
+  }
+
+  handleCancelConfirmAppointAsAdmin(i: CommentNode) {
+    i.state.showConfirmAppointAsAdmin = false;
+    i.setState(i.state);
+  }
+
+  handleAddAdmin(i: CommentNode) {
+    let form: AddAdminForm = {
+      user_id: i.props.node.comment.creator_id,
+      added: !i.isAdmin,
+    };
+    WebSocketService.Instance.addAdmin(form);
+    i.state.showConfirmAppointAsAdmin = false;
+    i.setState(i.state);
+  }
+
+  handleShowConfirmTransferCommunity(i: CommentNode) {
+    i.state.showConfirmTransferCommunity = true;
+    i.setState(i.state);
+  }
+
+  handleCancelShowConfirmTransferCommunity(i: CommentNode) {
+    i.state.showConfirmTransferCommunity = false;
+    i.setState(i.state);
+  }
+
+  handleTransferCommunity(i: CommentNode) {
+    let form: TransferCommunityForm = {
+      community_id: i.props.node.comment.community_id,
+      user_id: i.props.node.comment.creator_id,
+    };
+    WebSocketService.Instance.transferCommunity(form);
+    i.state.showConfirmTransferCommunity = false;
+    i.setState(i.state);
+  }
+
+  handleShowConfirmTransferSite(i: CommentNode) {
+    i.state.showConfirmTransferSite = true;
+    i.setState(i.state);
+  }
+
+  handleCancelShowConfirmTransferSite(i: CommentNode) {
+    i.state.showConfirmTransferSite = false;
+    i.setState(i.state);
+  }
+
+  handleTransferSite(i: CommentNode) {
+    let form: TransferSiteForm = {
+      user_id: i.props.node.comment.creator_id,
+    };
+    WebSocketService.Instance.transferSite(form);
+    i.state.showConfirmTransferSite = false;
+    i.setState(i.state);
+  }
+
+  get isCommentNew(): boolean {
+    let now = moment.utc().subtract(10, 'minutes');
+    let then = moment.utc(this.props.node.comment.published);
+    return now.isBefore(then);
+  }
+
+  handleCommentCollapse(i: CommentNode) {
+    i.state.collapsed = !i.state.collapsed;
+    i.setState(i.state);
+  }
+
+  handleViewSource(i: CommentNode) {
+    i.state.viewSource = !i.state.viewSource;
+    i.setState(i.state);
+  }
+
+  handleShowAdvanced(i: CommentNode) {
+    i.state.showAdvanced = !i.state.showAdvanced;
+    i.setState(i.state);
+    setupTippy();
+  }
+
+  get scoreColor() {
+    if (this.state.my_vote == 1) {
+      return 'text-info';
+    } else if (this.state.my_vote == -1) {
+      return 'text-danger';
+    } else {
+      return 'text-muted';
+    }
+  }
+
+  get pointsTippy(): string {
+    let points = i18n.t('number_of_points', {
+      count: this.state.score,
+    });
+
+    let upvotes = i18n.t('number_of_upvotes', {
+      count: this.state.upvotes,
+    });
+
+    let downvotes = i18n.t('number_of_downvotes', {
+      count: this.state.downvotes,
+    });
+
+    return `${points} • ${upvotes} • ${downvotes}`;
+  }
+}
diff --git a/src/shared/components/comment-nodes.tsx b/src/shared/components/comment-nodes.tsx
new file mode 100644 (file)
index 0000000..3f99bf3
--- /dev/null
@@ -0,0 +1,74 @@
+import { Component } from 'inferno';
+import { CommentSortType } from '../interfaces';
+import {
+  CommentNode as CommentNodeI,
+  CommunityUser,
+  UserView,
+  SortType,
+} from 'lemmy-js-client';
+import { commentSort, commentSortSortType } from '../utils';
+import { CommentNode } from './comment-node';
+
+interface CommentNodesState {}
+
+interface CommentNodesProps {
+  nodes: CommentNodeI[];
+  moderators?: CommunityUser[];
+  admins?: UserView[];
+  postCreatorId?: number;
+  noBorder?: boolean;
+  noIndent?: boolean;
+  viewOnly?: boolean;
+  locked?: boolean;
+  markable?: boolean;
+  showContext?: boolean;
+  showCommunity?: boolean;
+  sort?: CommentSortType;
+  sortType?: SortType;
+  enableDownvotes: boolean;
+}
+
+export class CommentNodes extends Component<
+  CommentNodesProps,
+  CommentNodesState
+> {
+  constructor(props: any, context: any) {
+    super(props, context);
+  }
+
+  render() {
+    return (
+      <div className="comments">
+        {this.sorter().map(node => (
+          <CommentNode
+            key={node.comment.id}
+            node={node}
+            noBorder={this.props.noBorder}
+            noIndent={this.props.noIndent}
+            viewOnly={this.props.viewOnly}
+            locked={this.props.locked}
+            moderators={this.props.moderators}
+            admins={this.props.admins}
+            postCreatorId={this.props.postCreatorId}
+            markable={this.props.markable}
+            showContext={this.props.showContext}
+            showCommunity={this.props.showCommunity}
+            sort={this.props.sort}
+            sortType={this.props.sortType}
+            enableDownvotes={this.props.enableDownvotes}
+          />
+        ))}
+      </div>
+    );
+  }
+
+  sorter(): CommentNodeI[] {
+    if (this.props.sort !== undefined) {
+      commentSort(this.props.nodes, this.props.sort);
+    } else if (this.props.sortType !== undefined) {
+      commentSortSortType(this.props.nodes, this.props.sortType);
+    }
+
+    return this.props.nodes;
+  }
+}
diff --git a/src/shared/components/communities.tsx b/src/shared/components/communities.tsx
new file mode 100644 (file)
index 0000000..f8d148f
--- /dev/null
@@ -0,0 +1,258 @@
+import { Component, linkEvent } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  UserOperation,
+  Community,
+  ListCommunitiesResponse,
+  CommunityResponse,
+  FollowCommunityForm,
+  ListCommunitiesForm,
+  SortType,
+  WebSocketJsonResponse,
+  GetSiteResponse,
+  Site,
+} from 'lemmy-js-client';
+import { WebSocketService } from '../services';
+import { wsJsonToRes, toast, getPageFromProps } from '../utils';
+import { CommunityLink } from './community-link';
+import { i18n } from '../i18next';
+
+declare const Sortable: any;
+
+const communityLimit = 100;
+
+interface CommunitiesState {
+  communities: Community[];
+  page: number;
+  loading: boolean;
+  site: Site;
+}
+
+interface CommunitiesProps {
+  page: number;
+}
+
+export class Communities extends Component<any, CommunitiesState> {
+  private subscription: Subscription;
+  private emptyState: CommunitiesState = {
+    communities: [],
+    loading: true,
+    page: getPageFromProps(this.props),
+    site: undefined,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.state = this.emptyState;
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    this.refetch();
+    WebSocketService.Instance.getSite();
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  static getDerivedStateFromProps(props: any): CommunitiesProps {
+    return {
+      page: getPageFromProps(props),
+    };
+  }
+
+  componentDidUpdate(_: any, lastState: CommunitiesState) {
+    if (lastState.page !== this.state.page) {
+      this.setState({ loading: true });
+      this.refetch();
+    }
+  }
+
+  get documentTitle(): string {
+    if (this.state.site) {
+      return `${i18n.t('communities')} - ${this.state.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle} />
+        {this.state.loading ? (
+          <h5 class="">
+            <svg class="icon icon-spinner spin">
+              <use xlinkHref="#icon-spinner"></use>
+            </svg>
+          </h5>
+        ) : (
+          <div>
+            <h5>{i18n.t('list_of_communities')}</h5>
+            <div class="table-responsive">
+              <table id="community_table" class="table table-sm table-hover">
+                <thead class="pointer">
+                  <tr>
+                    <th>{i18n.t('name')}</th>
+                    <th>{i18n.t('category')}</th>
+                    <th class="text-right">{i18n.t('subscribers')}</th>
+                    <th class="text-right d-none d-lg-table-cell">
+                      {i18n.t('posts')}
+                    </th>
+                    <th class="text-right d-none d-lg-table-cell">
+                      {i18n.t('comments')}
+                    </th>
+                    <th></th>
+                  </tr>
+                </thead>
+                <tbody>
+                  {this.state.communities.map(community => (
+                    <tr>
+                      <td>
+                        <CommunityLink community={community} />
+                      </td>
+                      <td>{community.category_name}</td>
+                      <td class="text-right">
+                        {community.number_of_subscribers}
+                      </td>
+                      <td class="text-right d-none d-lg-table-cell">
+                        {community.number_of_posts}
+                      </td>
+                      <td class="text-right d-none d-lg-table-cell">
+                        {community.number_of_comments}
+                      </td>
+                      <td class="text-right">
+                        {community.subscribed ? (
+                          <span
+                            class="pointer btn-link"
+                            onClick={linkEvent(
+                              community.id,
+                              this.handleUnsubscribe
+                            )}
+                          >
+                            {i18n.t('unsubscribe')}
+                          </span>
+                        ) : (
+                          <span
+                            class="pointer btn-link"
+                            onClick={linkEvent(
+                              community.id,
+                              this.handleSubscribe
+                            )}
+                          >
+                            {i18n.t('subscribe')}
+                          </span>
+                        )}
+                      </td>
+                    </tr>
+                  ))}
+                </tbody>
+              </table>
+            </div>
+            {this.paginator()}
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  paginator() {
+    return (
+      <div class="mt-2">
+        {this.state.page > 1 && (
+          <button
+            class="btn btn-secondary mr-1"
+            onClick={linkEvent(this, this.prevPage)}
+          >
+            {i18n.t('prev')}
+          </button>
+        )}
+
+        {this.state.communities.length > 0 && (
+          <button
+            class="btn btn-secondary"
+            onClick={linkEvent(this, this.nextPage)}
+          >
+            {i18n.t('next')}
+          </button>
+        )}
+      </div>
+    );
+  }
+
+  updateUrl(paramUpdates: CommunitiesProps) {
+    const page = paramUpdates.page || this.state.page;
+    this.props.history.push(`/communities/page/${page}`);
+  }
+
+  nextPage(i: Communities) {
+    i.updateUrl({ page: i.state.page + 1 });
+  }
+
+  prevPage(i: Communities) {
+    i.updateUrl({ page: i.state.page - 1 });
+  }
+
+  handleUnsubscribe(communityId: number) {
+    let form: FollowCommunityForm = {
+      community_id: communityId,
+      follow: false,
+    };
+    WebSocketService.Instance.followCommunity(form);
+  }
+
+  handleSubscribe(communityId: number) {
+    let form: FollowCommunityForm = {
+      community_id: communityId,
+      follow: true,
+    };
+    WebSocketService.Instance.followCommunity(form);
+  }
+
+  refetch() {
+    let listCommunitiesForm: ListCommunitiesForm = {
+      sort: SortType.TopAll,
+      limit: communityLimit,
+      page: this.state.page,
+    };
+
+    WebSocketService.Instance.listCommunities(listCommunitiesForm);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      return;
+    } else if (res.op == UserOperation.ListCommunities) {
+      let data = res.data as ListCommunitiesResponse;
+      this.state.communities = data.communities;
+      this.state.communities.sort(
+        (a, b) => b.number_of_subscribers - a.number_of_subscribers
+      );
+      this.state.loading = false;
+      window.scrollTo(0, 0);
+      this.setState(this.state);
+      let table = document.querySelector('#community_table');
+      Sortable.initTable(table);
+    } else if (res.op == UserOperation.FollowCommunity) {
+      let data = res.data as CommunityResponse;
+      let found = this.state.communities.find(c => c.id == data.community.id);
+      found.subscribed = data.community.subscribed;
+      found.number_of_subscribers = data.community.number_of_subscribers;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+      this.state.site = data.site;
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/community-form.tsx b/src/shared/components/community-form.tsx
new file mode 100644 (file)
index 0000000..6fa72ec
--- /dev/null
@@ -0,0 +1,364 @@
+import { Component, linkEvent } from 'inferno';
+import { Prompt } from 'inferno-router';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  CommunityForm as CommunityFormI,
+  UserOperation,
+  Category,
+  ListCategoriesResponse,
+  CommunityResponse,
+  WebSocketJsonResponse,
+  Community,
+} from 'lemmy-js-client';
+import { WebSocketService } from '../services';
+import { wsJsonToRes, capitalizeFirstLetter, toast, randomStr } from '../utils';
+import { i18n } from '../i18next';
+
+import { MarkdownTextArea } from './markdown-textarea';
+import { ImageUploadForm } from './image-upload-form';
+
+interface CommunityFormProps {
+  community?: Community; // If a community is given, that means this is an edit
+  onCancel?(): any;
+  onCreate?(community: Community): any;
+  onEdit?(community: Community): any;
+  enableNsfw: boolean;
+}
+
+interface CommunityFormState {
+  communityForm: CommunityFormI;
+  categories: Category[];
+  loading: boolean;
+}
+
+export class CommunityForm extends Component<
+  CommunityFormProps,
+  CommunityFormState
+> {
+  private id = `community-form-${randomStr()}`;
+  private subscription: Subscription;
+
+  private emptyState: CommunityFormState = {
+    communityForm: {
+      name: null,
+      title: null,
+      category_id: null,
+      nsfw: false,
+      icon: null,
+      banner: null,
+    },
+    categories: [],
+    loading: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+
+    this.handleCommunityDescriptionChange = this.handleCommunityDescriptionChange.bind(
+      this
+    );
+
+    this.handleIconUpload = this.handleIconUpload.bind(this);
+    this.handleIconRemove = this.handleIconRemove.bind(this);
+
+    this.handleBannerUpload = this.handleBannerUpload.bind(this);
+    this.handleBannerRemove = this.handleBannerRemove.bind(this);
+
+    if (this.props.community) {
+      this.state.communityForm = {
+        name: this.props.community.name,
+        title: this.props.community.title,
+        category_id: this.props.community.category_id,
+        description: this.props.community.description,
+        edit_id: this.props.community.id,
+        nsfw: this.props.community.nsfw,
+        icon: this.props.community.icon,
+        banner: this.props.community.banner,
+        auth: null,
+      };
+    }
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    WebSocketService.Instance.listCategories();
+  }
+
+  componentDidUpdate() {
+    if (
+      !this.state.loading &&
+      (this.state.communityForm.name ||
+        this.state.communityForm.title ||
+        this.state.communityForm.description)
+    ) {
+      window.onbeforeunload = () => true;
+    } else {
+      window.onbeforeunload = undefined;
+    }
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+    window.onbeforeunload = null;
+  }
+
+  render() {
+    return (
+      <>
+        <Prompt
+          when={
+            !this.state.loading &&
+            (this.state.communityForm.name ||
+              this.state.communityForm.title ||
+              this.state.communityForm.description)
+          }
+          message={i18n.t('block_leaving')}
+        />
+        <form onSubmit={linkEvent(this, this.handleCreateCommunitySubmit)}>
+          {!this.props.community && (
+            <div class="form-group row">
+              <label class="col-12 col-form-label" htmlFor="community-name">
+                {i18n.t('name')}
+                <span
+                  class="pointer unselectable ml-2 text-muted"
+                  data-tippy-content={i18n.t('name_explain')}
+                >
+                  <svg class="icon icon-inline">
+                    <use xlinkHref="#icon-help-circle"></use>
+                  </svg>
+                </span>
+              </label>
+              <div class="col-12">
+                <input
+                  type="text"
+                  id="community-name"
+                  class="form-control"
+                  value={this.state.communityForm.name}
+                  onInput={linkEvent(this, this.handleCommunityNameChange)}
+                  required
+                  minLength={3}
+                  maxLength={20}
+                  pattern="[a-z0-9_]+"
+                  title={i18n.t('community_reqs')}
+                />
+              </div>
+            </div>
+          )}
+          <div class="form-group row">
+            <label class="col-12 col-form-label" htmlFor="community-title">
+              {i18n.t('display_name')}
+              <span
+                class="pointer unselectable ml-2 text-muted"
+                data-tippy-content={i18n.t('display_name_explain')}
+              >
+                <svg class="icon icon-inline">
+                  <use xlinkHref="#icon-help-circle"></use>
+                </svg>
+              </span>
+            </label>
+            <div class="col-12">
+              <input
+                type="text"
+                id="community-title"
+                value={this.state.communityForm.title}
+                onInput={linkEvent(this, this.handleCommunityTitleChange)}
+                class="form-control"
+                required
+                minLength={3}
+                maxLength={100}
+              />
+            </div>
+          </div>
+          <div class="form-group">
+            <label>{i18n.t('icon')}</label>
+            <ImageUploadForm
+              uploadTitle={i18n.t('upload_icon')}
+              imageSrc={this.state.communityForm.icon}
+              onUpload={this.handleIconUpload}
+              onRemove={this.handleIconRemove}
+              rounded
+            />
+          </div>
+          <div class="form-group">
+            <label>{i18n.t('banner')}</label>
+            <ImageUploadForm
+              uploadTitle={i18n.t('upload_banner')}
+              imageSrc={this.state.communityForm.banner}
+              onUpload={this.handleBannerUpload}
+              onRemove={this.handleBannerRemove}
+            />
+          </div>
+          <div class="form-group row">
+            <label class="col-12 col-form-label" htmlFor={this.id}>
+              {i18n.t('sidebar')}
+            </label>
+            <div class="col-12">
+              <MarkdownTextArea
+                initialContent={this.state.communityForm.description}
+                onContentChange={this.handleCommunityDescriptionChange}
+              />
+            </div>
+          </div>
+          <div class="form-group row">
+            <label class="col-12 col-form-label" htmlFor="community-category">
+              {i18n.t('category')}
+            </label>
+            <div class="col-12">
+              <select
+                class="form-control"
+                id="community-category"
+                value={this.state.communityForm.category_id}
+                onInput={linkEvent(this, this.handleCommunityCategoryChange)}
+              >
+                {this.state.categories.map(category => (
+                  <option value={category.id}>{category.name}</option>
+                ))}
+              </select>
+            </div>
+          </div>
+
+          {this.props.enableNsfw && (
+            <div class="form-group row">
+              <div class="col-12">
+                <div class="form-check">
+                  <input
+                    class="form-check-input"
+                    id="community-nsfw"
+                    type="checkbox"
+                    checked={this.state.communityForm.nsfw}
+                    onChange={linkEvent(this, this.handleCommunityNsfwChange)}
+                  />
+                  <label class="form-check-label" htmlFor="community-nsfw">
+                    {i18n.t('nsfw')}
+                  </label>
+                </div>
+              </div>
+            </div>
+          )}
+          <div class="form-group row">
+            <div class="col-12">
+              <button
+                type="submit"
+                class="btn btn-secondary mr-2"
+                disabled={this.state.loading}
+              >
+                {this.state.loading ? (
+                  <svg class="icon icon-spinner spin">
+                    <use xlinkHref="#icon-spinner"></use>
+                  </svg>
+                ) : this.props.community ? (
+                  capitalizeFirstLetter(i18n.t('save'))
+                ) : (
+                  capitalizeFirstLetter(i18n.t('create'))
+                )}
+              </button>
+              {this.props.community && (
+                <button
+                  type="button"
+                  class="btn btn-secondary"
+                  onClick={linkEvent(this, this.handleCancel)}
+                >
+                  {i18n.t('cancel')}
+                </button>
+              )}
+            </div>
+          </div>
+        </form>
+      </>
+    );
+  }
+
+  handleCreateCommunitySubmit(i: CommunityForm, event: any) {
+    event.preventDefault();
+    i.state.loading = true;
+    if (i.props.community) {
+      WebSocketService.Instance.editCommunity(i.state.communityForm);
+    } else {
+      WebSocketService.Instance.createCommunity(i.state.communityForm);
+    }
+    i.setState(i.state);
+  }
+
+  handleCommunityNameChange(i: CommunityForm, event: any) {
+    i.state.communityForm.name = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleCommunityTitleChange(i: CommunityForm, event: any) {
+    i.state.communityForm.title = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleCommunityDescriptionChange(val: string) {
+    this.state.communityForm.description = val;
+    this.setState(this.state);
+  }
+
+  handleCommunityCategoryChange(i: CommunityForm, event: any) {
+    i.state.communityForm.category_id = Number(event.target.value);
+    i.setState(i.state);
+  }
+
+  handleCommunityNsfwChange(i: CommunityForm, event: any) {
+    i.state.communityForm.nsfw = event.target.checked;
+    i.setState(i.state);
+  }
+
+  handleCancel(i: CommunityForm) {
+    i.props.onCancel();
+  }
+
+  handleIconUpload(url: string) {
+    this.state.communityForm.icon = url;
+    this.setState(this.state);
+  }
+
+  handleIconRemove() {
+    this.state.communityForm.icon = '';
+    this.setState(this.state);
+  }
+
+  handleBannerUpload(url: string) {
+    this.state.communityForm.banner = url;
+    this.setState(this.state);
+  }
+
+  handleBannerRemove() {
+    this.state.communityForm.banner = '';
+    this.setState(this.state);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    let res = wsJsonToRes(msg);
+    console.log(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      this.state.loading = false;
+      this.setState(this.state);
+      return;
+    } else if (res.op == UserOperation.ListCategories) {
+      let data = res.data as ListCategoriesResponse;
+      this.state.categories = data.categories;
+      if (!this.props.community) {
+        this.state.communityForm.category_id = data.categories[0].id;
+      }
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreateCommunity) {
+      let data = res.data as CommunityResponse;
+      this.state.loading = false;
+      this.props.onCreate(data.community);
+    } else if (res.op == UserOperation.EditCommunity) {
+      let data = res.data as CommunityResponse;
+      this.state.loading = false;
+      this.props.onEdit(data.community);
+    }
+  }
+}
diff --git a/src/shared/components/community-link.tsx b/src/shared/components/community-link.tsx
new file mode 100644 (file)
index 0000000..003f61e
--- /dev/null
@@ -0,0 +1,60 @@
+import { Component } from 'inferno';
+import { Link } from 'inferno-router';
+import { Community } from 'lemmy-js-client';
+import { hostname, pictrsAvatarThumbnail, showAvatars } from '../utils';
+
+interface CommunityOther {
+  name: string;
+  id?: number; // Necessary if its federated
+  icon?: string;
+  local?: boolean;
+  actor_id?: string;
+}
+
+interface CommunityLinkProps {
+  community: Community | CommunityOther;
+  realLink?: boolean;
+  useApubName?: boolean;
+  muted?: boolean;
+  hideAvatar?: boolean;
+}
+
+export class CommunityLink extends Component<CommunityLinkProps, any> {
+  constructor(props: any, context: any) {
+    super(props, context);
+  }
+
+  render() {
+    let community = this.props.community;
+    let name_: string, link: string;
+    let local = community.local == null ? true : community.local;
+    if (local) {
+      name_ = community.name;
+      link = `/c/${community.name}`;
+    } else {
+      name_ = `${community.name}@${hostname(community.actor_id)}`;
+      link = !this.props.realLink
+        ? `/community/${community.id}`
+        : community.actor_id;
+    }
+
+    let apubName = `!${name_}`;
+    let displayName = this.props.useApubName ? apubName : name_;
+    return (
+      <Link
+        title={apubName}
+        className={`${this.props.muted ? 'text-muted' : ''}`}
+        to={link}
+      >
+        {!this.props.hideAvatar && community.icon && showAvatars() && (
+          <img
+            style="width: 2rem; height: 2rem;"
+            src={pictrsAvatarThumbnail(community.icon)}
+            class="rounded-circle mr-2"
+          />
+        )}
+        <span>{displayName}</span>
+      </Link>
+    );
+  }
+}
diff --git a/src/shared/components/community.tsx b/src/shared/components/community.tsx
new file mode 100644 (file)
index 0000000..afd0cfb
--- /dev/null
@@ -0,0 +1,480 @@
+import { Component, linkEvent } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import { DataType } from '../interfaces';
+import {
+  UserOperation,
+  Community as CommunityI,
+  GetCommunityResponse,
+  CommunityResponse,
+  CommunityUser,
+  UserView,
+  SortType,
+  Post,
+  GetPostsForm,
+  GetCommunityForm,
+  ListingType,
+  GetPostsResponse,
+  PostResponse,
+  AddModToCommunityResponse,
+  BanFromCommunityResponse,
+  Comment,
+  GetCommentsForm,
+  GetCommentsResponse,
+  CommentResponse,
+  WebSocketJsonResponse,
+  GetSiteResponse,
+  Site,
+} from 'lemmy-js-client';
+import { WebSocketService } from '../services';
+import { PostListings } from './post-listings';
+import { CommentNodes } from './comment-nodes';
+import { SortSelect } from './sort-select';
+import { DataTypeSelect } from './data-type-select';
+import { Sidebar } from './sidebar';
+import { CommunityLink } from './community-link';
+import { BannerIconHeader } from './banner-icon-header';
+import {
+  wsJsonToRes,
+  fetchLimit,
+  toast,
+  getPageFromProps,
+  getSortTypeFromProps,
+  getDataTypeFromProps,
+  editCommentRes,
+  saveCommentRes,
+  createCommentLikeRes,
+  createPostLikeFindRes,
+  editPostFindRes,
+  commentsToFlatNodes,
+  setupTippy,
+  favIconUrl,
+  notifyPost,
+} from '../utils';
+import { i18n } from '../i18next';
+
+interface State {
+  community: CommunityI;
+  communityId: number;
+  communityName: string;
+  moderators: CommunityUser[];
+  admins: UserView[];
+  online: number;
+  loading: boolean;
+  posts: Post[];
+  comments: Comment[];
+  dataType: DataType;
+  sort: SortType;
+  page: number;
+  site: Site;
+}
+
+interface CommunityProps {
+  dataType: DataType;
+  sort: SortType;
+  page: number;
+}
+
+interface UrlParams {
+  dataType?: string;
+  sort?: SortType;
+  page?: number;
+}
+
+export class Community extends Component<any, State> {
+  private subscription: Subscription;
+  private emptyState: State = {
+    community: {
+      id: null,
+      name: null,
+      title: null,
+      category_id: null,
+      category_name: null,
+      creator_id: null,
+      creator_name: null,
+      number_of_subscribers: null,
+      number_of_posts: null,
+      number_of_comments: null,
+      published: null,
+      removed: null,
+      nsfw: false,
+      deleted: null,
+      local: null,
+      actor_id: null,
+      last_refreshed_at: null,
+      creator_actor_id: null,
+      creator_local: null,
+    },
+    moderators: [],
+    admins: [],
+    communityId: Number(this.props.match.params.id),
+    communityName: this.props.match.params.name,
+    online: null,
+    loading: true,
+    posts: [],
+    comments: [],
+    dataType: getDataTypeFromProps(this.props),
+    sort: getSortTypeFromProps(this.props),
+    page: getPageFromProps(this.props),
+    site: {
+      id: undefined,
+      name: undefined,
+      creator_id: undefined,
+      published: undefined,
+      creator_name: undefined,
+      number_of_users: undefined,
+      number_of_posts: undefined,
+      number_of_comments: undefined,
+      number_of_communities: undefined,
+      enable_downvotes: undefined,
+      open_registration: undefined,
+      enable_nsfw: undefined,
+      icon: undefined,
+      banner: undefined,
+      creator_preferred_username: undefined,
+    },
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+    this.handleSortChange = this.handleSortChange.bind(this);
+    this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    let form: GetCommunityForm = {
+      id: this.state.communityId ? this.state.communityId : null,
+      name: this.state.communityName ? this.state.communityName : null,
+    };
+    WebSocketService.Instance.getCommunity(form);
+    WebSocketService.Instance.getSite();
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  static getDerivedStateFromProps(props: any): CommunityProps {
+    return {
+      dataType: getDataTypeFromProps(props),
+      sort: getSortTypeFromProps(props),
+      page: getPageFromProps(props),
+    };
+  }
+
+  componentDidUpdate(_: any, lastState: State) {
+    if (
+      lastState.dataType !== this.state.dataType ||
+      lastState.sort !== this.state.sort ||
+      lastState.page !== this.state.page
+    ) {
+      this.setState({ loading: true });
+      this.fetchData();
+    }
+  }
+
+  get documentTitle(): string {
+    if (this.state.community.title) {
+      return `${this.state.community.title} - ${this.state.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  get favIcon(): string {
+    return this.state.site.icon ? this.state.site.icon : favIconUrl;
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle}>
+          <link
+            id="favicon"
+            rel="icon"
+            type="image/x-icon"
+            href={this.favIcon}
+          />
+        </Helmet>
+        {this.state.loading ? (
+          <h5>
+            <svg class="icon icon-spinner spin">
+              <use xlinkHref="#icon-spinner"></use>
+            </svg>
+          </h5>
+        ) : (
+          <div class="row">
+            <div class="col-12 col-md-8">
+              {this.communityInfo()}
+              {this.selects()}
+              {this.listings()}
+              {this.paginator()}
+            </div>
+            <div class="col-12 col-md-4">
+              <Sidebar
+                community={this.state.community}
+                moderators={this.state.moderators}
+                admins={this.state.admins}
+                online={this.state.online}
+                enableNsfw={this.state.site.enable_nsfw}
+              />
+            </div>
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  listings() {
+    return this.state.dataType == DataType.Post ? (
+      <PostListings
+        posts={this.state.posts}
+        removeDuplicates
+        sort={this.state.sort}
+        enableDownvotes={this.state.site.enable_downvotes}
+        enableNsfw={this.state.site.enable_nsfw}
+      />
+    ) : (
+      <CommentNodes
+        nodes={commentsToFlatNodes(this.state.comments)}
+        noIndent
+        sortType={this.state.sort}
+        showContext
+        enableDownvotes={this.state.site.enable_downvotes}
+      />
+    );
+  }
+
+  communityInfo() {
+    return (
+      <div>
+        <BannerIconHeader
+          banner={this.state.community.banner}
+          icon={this.state.community.icon}
+        />
+        <h5 class="mb-0">{this.state.community.title}</h5>
+        <CommunityLink
+          community={this.state.community}
+          realLink
+          useApubName
+          muted
+          hideAvatar
+        />
+        <hr />
+      </div>
+    );
+  }
+
+  selects() {
+    return (
+      <div class="mb-3">
+        <span class="mr-3">
+          <DataTypeSelect
+            type_={this.state.dataType}
+            onChange={this.handleDataTypeChange}
+          />
+        </span>
+        <span class="mr-2">
+          <SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
+        </span>
+        <a
+          href={`/feeds/c/${this.state.communityName}.xml?sort=${this.state.sort}`}
+          target="_blank"
+          title="RSS"
+          rel="noopener"
+        >
+          <svg class="icon text-muted small">
+            <use xlinkHref="#icon-rss">#</use>
+          </svg>
+        </a>
+      </div>
+    );
+  }
+
+  paginator() {
+    return (
+      <div class="my-2">
+        {this.state.page > 1 && (
+          <button
+            class="btn btn-secondary mr-1"
+            onClick={linkEvent(this, this.prevPage)}
+          >
+            {i18n.t('prev')}
+          </button>
+        )}
+        {this.state.posts.length > 0 && (
+          <button
+            class="btn btn-secondary"
+            onClick={linkEvent(this, this.nextPage)}
+          >
+            {i18n.t('next')}
+          </button>
+        )}
+      </div>
+    );
+  }
+
+  nextPage(i: Community) {
+    i.updateUrl({ page: i.state.page + 1 });
+    window.scrollTo(0, 0);
+  }
+
+  prevPage(i: Community) {
+    i.updateUrl({ page: i.state.page - 1 });
+    window.scrollTo(0, 0);
+  }
+
+  handleSortChange(val: SortType) {
+    this.updateUrl({ sort: val, page: 1 });
+    window.scrollTo(0, 0);
+  }
+
+  handleDataTypeChange(val: DataType) {
+    this.updateUrl({ dataType: DataType[val], page: 1 });
+    window.scrollTo(0, 0);
+  }
+
+  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;
+    this.props.history.push(
+      `/c/${this.state.community.name}/data_type/${dataTypeStr}/sort/${sortStr}/page/${page}`
+    );
+  }
+
+  fetchData() {
+    if (this.state.dataType == DataType.Post) {
+      let getPostsForm: GetPostsForm = {
+        page: this.state.page,
+        limit: fetchLimit,
+        sort: this.state.sort,
+        type_: ListingType.Community,
+        community_id: this.state.community.id,
+      };
+      WebSocketService.Instance.getPosts(getPostsForm);
+    } else {
+      let getCommentsForm: GetCommentsForm = {
+        page: this.state.page,
+        limit: fetchLimit,
+        sort: this.state.sort,
+        type_: ListingType.Community,
+        community_id: this.state.community.id,
+      };
+      WebSocketService.Instance.getComments(getCommentsForm);
+    }
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      this.context.router.history.push('/');
+      return;
+    } else if (msg.reconnect) {
+      this.fetchData();
+    } else if (res.op == UserOperation.GetCommunity) {
+      let data = res.data as GetCommunityResponse;
+      this.state.community = data.community;
+      this.state.moderators = data.moderators;
+      this.state.online = data.online;
+      this.setState(this.state);
+      this.fetchData();
+    } else if (
+      res.op == UserOperation.EditCommunity ||
+      res.op == UserOperation.DeleteCommunity ||
+      res.op == UserOperation.RemoveCommunity
+    ) {
+      let data = res.data as CommunityResponse;
+      this.state.community = data.community;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.FollowCommunity) {
+      let data = res.data as CommunityResponse;
+      this.state.community.subscribed = data.community.subscribed;
+      this.state.community.number_of_subscribers =
+        data.community.number_of_subscribers;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.GetPosts) {
+      let data = res.data as GetPostsResponse;
+      this.state.posts = data.posts;
+      this.state.loading = false;
+      this.setState(this.state);
+      setupTippy();
+    } else if (
+      res.op == UserOperation.EditPost ||
+      res.op == UserOperation.DeletePost ||
+      res.op == UserOperation.RemovePost ||
+      res.op == UserOperation.LockPost ||
+      res.op == UserOperation.StickyPost
+    ) {
+      let data = res.data as PostResponse;
+      editPostFindRes(data, this.state.posts);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreatePost) {
+      let data = res.data as PostResponse;
+      this.state.posts.unshift(data.post);
+      notifyPost(data.post, this.context.router);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreatePostLike) {
+      let data = res.data as PostResponse;
+      createPostLikeFindRes(data, this.state.posts);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.AddModToCommunity) {
+      let data = res.data as AddModToCommunityResponse;
+      this.state.moderators = data.moderators;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.BanFromCommunity) {
+      let data = res.data as BanFromCommunityResponse;
+
+      this.state.posts
+        .filter(p => p.creator_id == data.user.id)
+        .forEach(p => (p.banned = data.banned));
+
+      this.setState(this.state);
+    } else if (res.op == UserOperation.GetComments) {
+      let data = res.data as GetCommentsResponse;
+      this.state.comments = data.comments;
+      this.state.loading = false;
+      this.setState(this.state);
+    } else if (
+      res.op == UserOperation.EditComment ||
+      res.op == UserOperation.DeleteComment ||
+      res.op == UserOperation.RemoveComment
+    ) {
+      let data = res.data as CommentResponse;
+      editCommentRes(data, this.state.comments);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreateComment) {
+      let data = res.data as CommentResponse;
+
+      // Necessary since it might be a user reply
+      if (data.recipient_ids.length == 0) {
+        this.state.comments.unshift(data.comment);
+        this.setState(this.state);
+      }
+    } else if (res.op == UserOperation.SaveComment) {
+      let data = res.data as CommentResponse;
+      saveCommentRes(data, this.state.comments);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreateCommentLike) {
+      let data = res.data as CommentResponse;
+      createCommentLikeRes(data, this.state.comments);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+      this.state.site = data.site;
+      this.state.admins = data.admins;
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/create-community.tsx b/src/shared/components/create-community.tsx
new file mode 100644 (file)
index 0000000..6f15621
--- /dev/null
@@ -0,0 +1,105 @@
+import { Component } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import { CommunityForm } from './community-form';
+import {
+  Community,
+  UserOperation,
+  WebSocketJsonResponse,
+  GetSiteResponse,
+  Site,
+} from 'lemmy-js-client';
+import { toast, wsJsonToRes } from '../utils';
+import { WebSocketService, UserService } from '../services';
+import { i18n } from '../i18next';
+
+interface CreateCommunityState {
+  site: Site;
+}
+
+export class CreateCommunity extends Component<any, CreateCommunityState> {
+  private subscription: Subscription;
+  private emptyState: CreateCommunityState = {
+    site: {
+      id: undefined,
+      name: undefined,
+      creator_id: undefined,
+      published: undefined,
+      creator_name: undefined,
+      number_of_users: undefined,
+      number_of_posts: undefined,
+      number_of_comments: undefined,
+      number_of_communities: undefined,
+      enable_downvotes: undefined,
+      open_registration: undefined,
+      enable_nsfw: undefined,
+    },
+  };
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.handleCommunityCreate = this.handleCommunityCreate.bind(this);
+    this.state = this.emptyState;
+
+    if (!UserService.Instance.user) {
+      toast(i18n.t('not_logged_in'), 'danger');
+      this.context.router.history.push(`/login`);
+    }
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    WebSocketService.Instance.getSite();
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  get documentTitle(): string {
+    if (this.state.site.name) {
+      return `${i18n.t('create_community')} - ${this.state.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle} />
+        <div class="row">
+          <div class="col-12 col-lg-6 offset-lg-3 mb-4">
+            <h5>{i18n.t('create_community')}</h5>
+            <CommunityForm
+              onCreate={this.handleCommunityCreate}
+              enableNsfw={this.state.site.enable_nsfw}
+            />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  handleCommunityCreate(community: Community) {
+    this.props.history.push(`/c/${community.name}`);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      // Toast errors are already handled by community-form
+      return;
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+      this.state.site = data.site;
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/create-post.tsx b/src/shared/components/create-post.tsx
new file mode 100644 (file)
index 0000000..f4c03b6
--- /dev/null
@@ -0,0 +1,132 @@
+import { Component } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import { PostForm } from './post-form';
+import { toast, wsJsonToRes } from '../utils';
+import { WebSocketService, UserService } from '../services';
+import {
+  UserOperation,
+  PostFormParams,
+  WebSocketJsonResponse,
+  GetSiteResponse,
+  Site,
+} from 'lemmy-js-client';
+import { i18n } from '../i18next';
+
+interface CreatePostState {
+  site: Site;
+}
+
+export class CreatePost extends Component<any, CreatePostState> {
+  private subscription: Subscription;
+  private emptyState: CreatePostState = {
+    site: {
+      id: undefined,
+      name: undefined,
+      creator_id: undefined,
+      published: undefined,
+      creator_name: undefined,
+      number_of_users: undefined,
+      number_of_posts: undefined,
+      number_of_comments: undefined,
+      number_of_communities: undefined,
+      enable_downvotes: undefined,
+      open_registration: undefined,
+      enable_nsfw: undefined,
+    },
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.handlePostCreate = this.handlePostCreate.bind(this);
+    this.state = this.emptyState;
+
+    if (!UserService.Instance.user) {
+      toast(i18n.t('not_logged_in'), 'danger');
+      this.context.router.history.push(`/login`);
+    }
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    WebSocketService.Instance.getSite();
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  get documentTitle(): string {
+    if (this.state.site.name) {
+      return `${i18n.t('create_post')} - ${this.state.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle} />
+        <div class="row">
+          <div class="col-12 col-lg-6 offset-lg-3 mb-4">
+            <h5>{i18n.t('create_post')}</h5>
+            <PostForm
+              onCreate={this.handlePostCreate}
+              params={this.params}
+              enableDownvotes={this.state.site.enable_downvotes}
+              enableNsfw={this.state.site.enable_nsfw}
+            />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  get params(): PostFormParams {
+    let urlParams = new URLSearchParams(this.props.location.search);
+    let params: PostFormParams = {
+      name: urlParams.get('title'),
+      community: urlParams.get('community') || this.prevCommunityName,
+      body: urlParams.get('body'),
+      url: urlParams.get('url'),
+    };
+
+    return params;
+  }
+
+  get prevCommunityName(): string {
+    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/')[1];
+      }
+    }
+    return;
+  }
+
+  handlePostCreate(id: number) {
+    this.props.history.push(`/post/${id}`);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      return;
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+      this.state.site = data.site;
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/create-private-message.tsx b/src/shared/components/create-private-message.tsx
new file mode 100644 (file)
index 0000000..98c69d5
--- /dev/null
@@ -0,0 +1,109 @@
+import { Component } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import { PrivateMessageForm } from './private-message-form';
+import { WebSocketService, UserService } from '../services';
+import {
+  UserOperation,
+  WebSocketJsonResponse,
+  GetSiteResponse,
+  Site,
+  PrivateMessageFormParams,
+} from 'lemmy-js-client';
+import { toast, wsJsonToRes } from '../utils';
+import { i18n } from '../i18next';
+
+interface CreatePrivateMessageState {
+  site: Site;
+}
+
+export class CreatePrivateMessage extends Component<
+  any,
+  CreatePrivateMessageState
+> {
+  private subscription: Subscription;
+  private emptyState: CreatePrivateMessageState = {
+    site: undefined,
+  };
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.state = this.emptyState;
+    this.handlePrivateMessageCreate = this.handlePrivateMessageCreate.bind(
+      this
+    );
+
+    if (!UserService.Instance.user) {
+      toast(i18n.t('not_logged_in'), 'danger');
+      this.context.router.history.push(`/login`);
+    }
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    WebSocketService.Instance.getSite();
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  get documentTitle(): string {
+    if (this.state.site) {
+      return `${i18n.t('create_private_message')} - ${this.state.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle} />
+        <div class="row">
+          <div class="col-12 col-lg-6 offset-lg-3 mb-4">
+            <h5>{i18n.t('create_private_message')}</h5>
+            <PrivateMessageForm
+              onCreate={this.handlePrivateMessageCreate}
+              params={this.params}
+            />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  get params(): PrivateMessageFormParams {
+    let urlParams = new URLSearchParams(this.props.location.search);
+    let params: PrivateMessageFormParams = {
+      recipient_id: Number(urlParams.get('recipient_id')),
+    };
+
+    return params;
+  }
+
+  handlePrivateMessageCreate() {
+    toast(i18n.t('message_sent'));
+
+    // Navigate to the front
+    this.props.history.push(`/`);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      return;
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+      this.state.site = data.site;
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/data-type-select.tsx b/src/shared/components/data-type-select.tsx
new file mode 100644 (file)
index 0000000..06285f7
--- /dev/null
@@ -0,0 +1,71 @@
+import { Component, linkEvent } from 'inferno';
+import { DataType } from '../interfaces';
+
+import { i18n } from '../i18next';
+
+interface DataTypeSelectProps {
+  type_: DataType;
+  onChange?(val: DataType): any;
+}
+
+interface DataTypeSelectState {
+  type_: DataType;
+}
+
+export class DataTypeSelect extends Component<
+  DataTypeSelectProps,
+  DataTypeSelectState
+> {
+  private emptyState: DataTypeSelectState = {
+    type_: this.props.type_,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.state = this.emptyState;
+    console.log(this.state);
+  }
+
+  static getDerivedStateFromProps(props: any): DataTypeSelectProps {
+    return {
+      type_: props.type_,
+    };
+  }
+
+  render() {
+    return (
+      <div class="btn-group btn-group-toggle flex-wrap mb-2">
+        <label
+          className={`pointer btn btn-outline-secondary 
+            ${this.state.type_ == DataType.Post && 'active'}
+          `}
+        >
+          <input
+            type="radio"
+            value={DataType.Post}
+            checked={this.state.type_ == DataType.Post}
+            onChange={linkEvent(this, this.handleTypeChange)}
+          />
+          {i18n.t('posts')}
+        </label>
+        <label
+          className={`pointer btn btn-outline-secondary ${
+            this.state.type_ == DataType.Comment && 'active'
+          }`}
+        >
+          <input
+            type="radio"
+            value={DataType.Comment}
+            checked={this.state.type_ == DataType.Comment}
+            onChange={linkEvent(this, this.handleTypeChange)}
+          />
+          {i18n.t('comments')}
+        </label>
+      </div>
+    );
+  }
+
+  handleTypeChange(i: DataTypeSelect, event: any) {
+    i.props.onChange(Number(event.target.value));
+  }
+}
diff --git a/src/shared/components/footer.tsx b/src/shared/components/footer.tsx
new file mode 100644 (file)
index 0000000..955cb1b
--- /dev/null
@@ -0,0 +1,89 @@
+import { Component } from 'inferno';
+import { Link } from 'inferno-router';
+import { i18n } from '../i18next';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import { WebSocketService } from '../services';
+import { repoUrl, wsJsonToRes, isBrowser } from '../utils';
+import {
+  UserOperation,
+  WebSocketJsonResponse,
+  GetSiteResponse,
+} from 'lemmy-js-client';
+
+interface FooterState {
+  version: string;
+}
+
+export class Footer extends Component<any, FooterState> {
+  private wsSub: Subscription;
+  emptyState: FooterState = {
+    version: null,
+  };
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+
+    if (isBrowser()) {
+      this.wsSub = WebSocketService.Instance.subject
+        .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+        .subscribe(
+          msg => this.parseMessage(msg),
+          err => console.error(err),
+          () => console.log('complete')
+        );
+    }
+  }
+
+  componentWillUnmount() {
+    this.wsSub.unsubscribe();
+  }
+
+  render() {
+    return (
+      <nav class="container navbar navbar-expand-md navbar-light navbar-bg p-0 px-3 mt-2">
+        <div className="navbar-collapse">
+          <ul class="navbar-nav ml-auto">
+            <li class="nav-item">
+              <span class="navbar-text">{this.state.version}</span>
+            </li>
+            <li class="nav-item">
+              <Link class="nav-link" to="/modlog">
+                {i18n.t('modlog')}
+              </Link>
+            </li>
+            <li class="nav-item">
+              <Link class="nav-link" to="/instances">
+                {i18n.t('instances')}
+              </Link>
+            </li>
+            <li class="nav-item">
+              <a class="nav-link" href={'/docs/index.html'}>
+                {i18n.t('docs')}
+              </a>
+            </li>
+            <li class="nav-item">
+              <Link class="nav-link" to="/sponsors">
+                {i18n.t('donate')}
+              </Link>
+            </li>
+            <li class="nav-item">
+              <a class="nav-link" href={repoUrl}>
+                {i18n.t('code')}
+              </a>
+            </li>
+          </ul>
+        </div>
+      </nav>
+    );
+  }
+  parseMessage(msg: WebSocketJsonResponse) {
+    let res = wsJsonToRes(msg);
+
+    if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+      this.setState({ version: data.version });
+    }
+  }
+}
diff --git a/src/shared/components/iframely-card.tsx b/src/shared/components/iframely-card.tsx
new file mode 100644 (file)
index 0000000..6a604f7
--- /dev/null
@@ -0,0 +1,105 @@
+import { Component, linkEvent } from 'inferno';
+import { Post } from 'lemmy-js-client';
+import { mdToHtml } from '../utils';
+import { i18n } from '../i18next';
+
+interface FramelyCardProps {
+  post: Post;
+}
+
+interface FramelyCardState {
+  expanded: boolean;
+}
+
+export class IFramelyCard extends Component<
+  FramelyCardProps,
+  FramelyCardState
+> {
+  private emptyState: FramelyCardState = {
+    expanded: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.state = this.emptyState;
+  }
+
+  render() {
+    let post = this.props.post;
+    return (
+      <>
+        {post.embed_title && !this.state.expanded && (
+          <div class="card bg-transparent border-secondary mt-3 mb-2">
+            <div class="row">
+              <div class="col-12">
+                <div class="card-body">
+                  <h5 class="card-title d-inline">
+                    {post.embed_html ? (
+                      <span
+                        class="unselectable pointer"
+                        onClick={linkEvent(this, this.handleIframeExpand)}
+                        data-tippy-content={i18n.t('expand_here')}
+                      >
+                        {post.embed_title}
+                      </span>
+                    ) : (
+                      <span>
+                        <a
+                          class="text-body"
+                          target="_blank"
+                          href={post.url}
+                          rel="noopener"
+                        >
+                          {post.embed_title}
+                        </a>
+                      </span>
+                    )}
+                  </h5>
+                  <span class="d-inline-block ml-2 mb-2 small text-muted">
+                    <a
+                      class="text-muted font-italic"
+                      target="_blank"
+                      href={post.url}
+                      rel="noopener"
+                    >
+                      {new URL(post.url).hostname}
+                      <svg class="ml-1 icon">
+                        <use xlinkHref="#icon-external-link"></use>
+                      </svg>
+                    </a>
+                    {post.embed_html && (
+                      <span
+                        class="ml-2 pointer text-monospace"
+                        onClick={linkEvent(this, this.handleIframeExpand)}
+                        data-tippy-content={i18n.t('expand_here')}
+                      >
+                        {this.state.expanded ? '[-]' : '[+]'}
+                      </span>
+                    )}
+                  </span>
+                  {post.embed_description && (
+                    <div
+                      className="card-text small text-muted md-div"
+                      dangerouslySetInnerHTML={mdToHtml(post.embed_description)}
+                    />
+                  )}
+                </div>
+              </div>
+            </div>
+          </div>
+        )}
+        {this.state.expanded && (
+          <div
+            class="mt-3 mb-2"
+            dangerouslySetInnerHTML={{ __html: post.embed_html }}
+          />
+        )}
+      </>
+    );
+  }
+
+  handleIframeExpand(i: IFramelyCard) {
+    i.state.expanded = !i.state.expanded;
+    i.setState(i.state);
+  }
+}
diff --git a/src/shared/components/image-upload-form.tsx b/src/shared/components/image-upload-form.tsx
new file mode 100644 (file)
index 0000000..98206f1
--- /dev/null
@@ -0,0 +1,114 @@
+import { Component, linkEvent } from 'inferno';
+import { UserService } from '../services';
+import { toast, randomStr } from '../utils';
+
+interface ImageUploadFormProps {
+  uploadTitle: string;
+  imageSrc: string;
+  onUpload(url: string): any;
+  onRemove(): any;
+  rounded?: boolean;
+}
+
+interface ImageUploadFormState {
+  loading: boolean;
+}
+
+export class ImageUploadForm extends Component<
+  ImageUploadFormProps,
+  ImageUploadFormState
+> {
+  private id = `image-upload-form-${randomStr()}`;
+  private emptyState: ImageUploadFormState = {
+    loading: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.state = this.emptyState;
+  }
+
+  render() {
+    return (
+      <form class="d-inline">
+        <label
+          htmlFor={this.id}
+          class="pointer ml-4 text-muted small font-weight-bold"
+        >
+          {!this.props.imageSrc ? (
+            <span class="btn btn-secondary">{this.props.uploadTitle}</span>
+          ) : (
+            <span class="d-inline-block position-relative">
+              <img
+                src={this.props.imageSrc}
+                height={this.props.rounded ? 60 : ''}
+                width={this.props.rounded ? 60 : ''}
+                className={`img-fluid ${
+                  this.props.rounded ? 'rounded-circle' : ''
+                }`}
+              />
+              <a onClick={linkEvent(this, this.handleRemoveImage)}>
+                <svg class="icon mini-overlay">
+                  <use xlinkHref="#icon-x"></use>
+                </svg>
+              </a>
+            </span>
+          )}
+        </label>
+        <input
+          id={this.id}
+          type="file"
+          accept="image/*,video/*"
+          name={this.id}
+          class="d-none"
+          disabled={!UserService.Instance.user}
+          onChange={linkEvent(this, this.handleImageUpload)}
+        />
+      </form>
+    );
+  }
+
+  handleImageUpload(i: ImageUploadForm, event: any) {
+    event.preventDefault();
+    let file = event.target.files[0];
+    const imageUploadUrl = `/pictrs/image`;
+    const formData = new FormData();
+    formData.append('images[]', file);
+
+    i.state.loading = true;
+    i.setState(i.state);
+
+    fetch(imageUploadUrl, {
+      method: 'POST',
+      body: formData,
+    })
+      .then(res => res.json())
+      .then(res => {
+        console.log('pictrs upload:');
+        console.log(res);
+        if (res.msg == 'ok') {
+          let hash = res.files[0].file;
+          let url = `${window.location.origin}/pictrs/image/${hash}`;
+          i.state.loading = false;
+          i.setState(i.state);
+          i.props.onUpload(url);
+        } else {
+          i.state.loading = false;
+          i.setState(i.state);
+          toast(JSON.stringify(res), 'danger');
+        }
+      })
+      .catch(error => {
+        i.state.loading = false;
+        i.setState(i.state);
+        toast(error, 'danger');
+      });
+  }
+
+  handleRemoveImage(i: ImageUploadForm, event: any) {
+    event.preventDefault();
+    i.state.loading = true;
+    i.setState(i.state);
+    i.props.onRemove();
+  }
+}
diff --git a/src/shared/components/inbox.tsx b/src/shared/components/inbox.tsx
new file mode 100644 (file)
index 0000000..8292511
--- /dev/null
@@ -0,0 +1,607 @@
+import { Component, linkEvent } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  UserOperation,
+  Comment,
+  SortType,
+  GetRepliesForm,
+  GetRepliesResponse,
+  GetUserMentionsForm,
+  GetUserMentionsResponse,
+  UserMentionResponse,
+  CommentResponse,
+  WebSocketJsonResponse,
+  PrivateMessage as PrivateMessageI,
+  GetPrivateMessagesForm,
+  PrivateMessagesResponse,
+  PrivateMessageResponse,
+  GetSiteResponse,
+  Site,
+} from 'lemmy-js-client';
+import { WebSocketService, UserService } from '../services';
+import {
+  wsJsonToRes,
+  fetchLimit,
+  isCommentType,
+  toast,
+  editCommentRes,
+  saveCommentRes,
+  createCommentLikeRes,
+  commentsToFlatNodes,
+  setupTippy,
+} from '../utils';
+import { CommentNodes } from './comment-nodes';
+import { PrivateMessage } from './private-message';
+import { SortSelect } from './sort-select';
+import { i18n } from '../i18next';
+
+enum UnreadOrAll {
+  Unread,
+  All,
+}
+
+enum MessageType {
+  All,
+  Replies,
+  Mentions,
+  Messages,
+}
+
+type ReplyType = Comment | PrivateMessageI;
+
+interface InboxState {
+  unreadOrAll: UnreadOrAll;
+  messageType: MessageType;
+  replies: Comment[];
+  mentions: Comment[];
+  messages: PrivateMessageI[];
+  sort: SortType;
+  page: number;
+  site: Site;
+}
+
+export class Inbox extends Component<any, InboxState> {
+  private subscription: Subscription;
+  private emptyState: InboxState = {
+    unreadOrAll: UnreadOrAll.Unread,
+    messageType: MessageType.All,
+    replies: [],
+    mentions: [],
+    messages: [],
+    sort: SortType.New,
+    page: 1,
+    site: {
+      id: undefined,
+      name: undefined,
+      creator_id: undefined,
+      published: undefined,
+      creator_name: undefined,
+      number_of_users: undefined,
+      number_of_posts: undefined,
+      number_of_comments: undefined,
+      number_of_communities: undefined,
+      enable_downvotes: undefined,
+      open_registration: undefined,
+      enable_nsfw: undefined,
+    },
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+    this.handleSortChange = this.handleSortChange.bind(this);
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    this.refetch();
+    WebSocketService.Instance.getSite();
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  get documentTitle(): string {
+    if (this.state.site.name) {
+      return `@${UserService.Instance.user.name} ${i18n.t('inbox')} - ${
+        this.state.site.name
+      }`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle} />
+        <div class="row">
+          <div class="col-12">
+            <h5 class="mb-1">
+              {i18n.t('inbox')}
+              <small>
+                <a
+                  href={`/feeds/inbox/${UserService.Instance.auth}.xml`}
+                  target="_blank"
+                  title="RSS"
+                  rel="noopener"
+                >
+                  <svg class="icon ml-2 text-muted small">
+                    <use xlinkHref="#icon-rss">#</use>
+                  </svg>
+                </a>
+              </small>
+            </h5>
+            {this.state.replies.length +
+              this.state.mentions.length +
+              this.state.messages.length >
+              0 &&
+              this.state.unreadOrAll == UnreadOrAll.Unread && (
+                <ul class="list-inline mb-1 text-muted small font-weight-bold">
+                  <li className="list-inline-item">
+                    <span
+                      class="pointer"
+                      onClick={linkEvent(this, this.markAllAsRead)}
+                    >
+                      {i18n.t('mark_all_as_read')}
+                    </span>
+                  </li>
+                </ul>
+              )}
+            {this.selects()}
+            {this.state.messageType == MessageType.All && this.all()}
+            {this.state.messageType == MessageType.Replies && this.replies()}
+            {this.state.messageType == MessageType.Mentions && this.mentions()}
+            {this.state.messageType == MessageType.Messages && this.messages()}
+            {this.paginator()}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  unreadOrAllRadios() {
+    return (
+      <div class="btn-group btn-group-toggle flex-wrap mb-2">
+        <label
+          className={`btn btn-outline-secondary pointer
+            ${this.state.unreadOrAll == UnreadOrAll.Unread && 'active'}
+          `}
+        >
+          <input
+            type="radio"
+            value={UnreadOrAll.Unread}
+            checked={this.state.unreadOrAll == UnreadOrAll.Unread}
+            onChange={linkEvent(this, this.handleUnreadOrAllChange)}
+          />
+          {i18n.t('unread')}
+        </label>
+        <label
+          className={`btn btn-outline-secondary pointer
+            ${this.state.unreadOrAll == UnreadOrAll.All && 'active'}
+          `}
+        >
+          <input
+            type="radio"
+            value={UnreadOrAll.All}
+            checked={this.state.unreadOrAll == UnreadOrAll.All}
+            onChange={linkEvent(this, this.handleUnreadOrAllChange)}
+          />
+          {i18n.t('all')}
+        </label>
+      </div>
+    );
+  }
+
+  messageTypeRadios() {
+    return (
+      <div class="btn-group btn-group-toggle flex-wrap mb-2">
+        <label
+          className={`btn btn-outline-secondary pointer
+            ${this.state.messageType == MessageType.All && 'active'}
+          `}
+        >
+          <input
+            type="radio"
+            value={MessageType.All}
+            checked={this.state.messageType == MessageType.All}
+            onChange={linkEvent(this, this.handleMessageTypeChange)}
+          />
+          {i18n.t('all')}
+        </label>
+        <label
+          className={`btn btn-outline-secondary pointer
+            ${this.state.messageType == MessageType.Replies && 'active'}
+          `}
+        >
+          <input
+            type="radio"
+            value={MessageType.Replies}
+            checked={this.state.messageType == MessageType.Replies}
+            onChange={linkEvent(this, this.handleMessageTypeChange)}
+          />
+          {i18n.t('replies')}
+        </label>
+        <label
+          className={`btn btn-outline-secondary pointer
+            ${this.state.messageType == MessageType.Mentions && 'active'}
+          `}
+        >
+          <input
+            type="radio"
+            value={MessageType.Mentions}
+            checked={this.state.messageType == MessageType.Mentions}
+            onChange={linkEvent(this, this.handleMessageTypeChange)}
+          />
+          {i18n.t('mentions')}
+        </label>
+        <label
+          className={`btn btn-outline-secondary pointer
+            ${this.state.messageType == MessageType.Messages && 'active'}
+          `}
+        >
+          <input
+            type="radio"
+            value={MessageType.Messages}
+            checked={this.state.messageType == MessageType.Messages}
+            onChange={linkEvent(this, this.handleMessageTypeChange)}
+          />
+          {i18n.t('messages')}
+        </label>
+      </div>
+    );
+  }
+
+  selects() {
+    return (
+      <div className="mb-2">
+        <span class="mr-3">{this.unreadOrAllRadios()}</span>
+        <span class="mr-3">{this.messageTypeRadios()}</span>
+        <SortSelect
+          sort={this.state.sort}
+          onChange={this.handleSortChange}
+          hideHot
+        />
+      </div>
+    );
+  }
+
+  combined(): ReplyType[] {
+    return [
+      ...this.state.replies,
+      ...this.state.mentions,
+      ...this.state.messages,
+    ].sort((a, b) => b.published.localeCompare(a.published));
+  }
+
+  all() {
+    return (
+      <div>
+        {this.combined().map(i =>
+          isCommentType(i) ? (
+            <CommentNodes
+              key={i.id}
+              nodes={[{ comment: i }]}
+              noIndent
+              markable
+              showCommunity
+              showContext
+              enableDownvotes={this.state.site.enable_downvotes}
+            />
+          ) : (
+            <PrivateMessage key={i.id} privateMessage={i} />
+          )
+        )}
+      </div>
+    );
+  }
+
+  replies() {
+    return (
+      <div>
+        <CommentNodes
+          nodes={commentsToFlatNodes(this.state.replies)}
+          noIndent
+          markable
+          showCommunity
+          showContext
+          enableDownvotes={this.state.site.enable_downvotes}
+        />
+      </div>
+    );
+  }
+
+  mentions() {
+    return (
+      <div>
+        {this.state.mentions.map(mention => (
+          <CommentNodes
+            key={mention.id}
+            nodes={[{ comment: mention }]}
+            noIndent
+            markable
+            showCommunity
+            showContext
+            enableDownvotes={this.state.site.enable_downvotes}
+          />
+        ))}
+      </div>
+    );
+  }
+
+  messages() {
+    return (
+      <div>
+        {this.state.messages.map(message => (
+          <PrivateMessage key={message.id} privateMessage={message} />
+        ))}
+      </div>
+    );
+  }
+
+  paginator() {
+    return (
+      <div class="mt-2">
+        {this.state.page > 1 && (
+          <button
+            class="btn btn-secondary mr-1"
+            onClick={linkEvent(this, this.prevPage)}
+          >
+            {i18n.t('prev')}
+          </button>
+        )}
+        {this.unreadCount() > 0 && (
+          <button
+            class="btn btn-secondary"
+            onClick={linkEvent(this, this.nextPage)}
+          >
+            {i18n.t('next')}
+          </button>
+        )}
+      </div>
+    );
+  }
+
+  nextPage(i: Inbox) {
+    i.state.page++;
+    i.setState(i.state);
+    i.refetch();
+  }
+
+  prevPage(i: Inbox) {
+    i.state.page--;
+    i.setState(i.state);
+    i.refetch();
+  }
+
+  handleUnreadOrAllChange(i: Inbox, event: any) {
+    i.state.unreadOrAll = Number(event.target.value);
+    i.state.page = 1;
+    i.setState(i.state);
+    i.refetch();
+  }
+
+  handleMessageTypeChange(i: Inbox, event: any) {
+    i.state.messageType = Number(event.target.value);
+    i.state.page = 1;
+    i.setState(i.state);
+    i.refetch();
+  }
+
+  refetch() {
+    let repliesForm: GetRepliesForm = {
+      sort: this.state.sort,
+      unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
+      page: this.state.page,
+      limit: fetchLimit,
+    };
+    WebSocketService.Instance.getReplies(repliesForm);
+
+    let userMentionsForm: GetUserMentionsForm = {
+      sort: this.state.sort,
+      unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
+      page: this.state.page,
+      limit: fetchLimit,
+    };
+    WebSocketService.Instance.getUserMentions(userMentionsForm);
+
+    let privateMessagesForm: GetPrivateMessagesForm = {
+      unread_only: this.state.unreadOrAll == UnreadOrAll.Unread,
+      page: this.state.page,
+      limit: fetchLimit,
+    };
+    WebSocketService.Instance.getPrivateMessages(privateMessagesForm);
+  }
+
+  handleSortChange(val: SortType) {
+    this.state.sort = val;
+    this.state.page = 1;
+    this.setState(this.state);
+    this.refetch();
+  }
+
+  markAllAsRead(i: Inbox) {
+    WebSocketService.Instance.markAllAsRead();
+    i.state.replies = [];
+    i.state.mentions = [];
+    i.state.messages = [];
+    i.sendUnreadCount();
+    window.scrollTo(0, 0);
+    i.setState(i.state);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      return;
+    } else if (msg.reconnect) {
+      this.refetch();
+    } else if (res.op == UserOperation.GetReplies) {
+      let data = res.data as GetRepliesResponse;
+      this.state.replies = data.replies;
+      this.sendUnreadCount();
+      window.scrollTo(0, 0);
+      this.setState(this.state);
+      setupTippy();
+    } else if (res.op == UserOperation.GetUserMentions) {
+      let data = res.data as GetUserMentionsResponse;
+      this.state.mentions = data.mentions;
+      this.sendUnreadCount();
+      window.scrollTo(0, 0);
+      this.setState(this.state);
+      setupTippy();
+    } else if (res.op == UserOperation.GetPrivateMessages) {
+      let data = res.data as PrivateMessagesResponse;
+      this.state.messages = data.messages;
+      this.sendUnreadCount();
+      window.scrollTo(0, 0);
+      this.setState(this.state);
+      setupTippy();
+    } else if (res.op == UserOperation.EditPrivateMessage) {
+      let data = res.data as PrivateMessageResponse;
+      let found: PrivateMessageI = this.state.messages.find(
+        m => m.id === data.message.id
+      );
+      if (found) {
+        found.content = data.message.content;
+        found.updated = data.message.updated;
+      }
+      this.setState(this.state);
+    } else if (res.op == UserOperation.DeletePrivateMessage) {
+      let data = res.data as PrivateMessageResponse;
+      let found: PrivateMessageI = this.state.messages.find(
+        m => m.id === data.message.id
+      );
+      if (found) {
+        found.deleted = data.message.deleted;
+        found.updated = data.message.updated;
+      }
+      this.setState(this.state);
+    } else if (res.op == UserOperation.MarkPrivateMessageAsRead) {
+      let data = res.data as PrivateMessageResponse;
+      let found: PrivateMessageI = this.state.messages.find(
+        m => m.id === data.message.id
+      );
+
+      if (found) {
+        found.updated = data.message.updated;
+
+        // If youre in the unread view, just remove it from the list
+        if (this.state.unreadOrAll == UnreadOrAll.Unread && data.message.read) {
+          this.state.messages = this.state.messages.filter(
+            r => r.id !== data.message.id
+          );
+        } else {
+          let found = this.state.messages.find(c => c.id == data.message.id);
+          found.read = data.message.read;
+        }
+      }
+      this.sendUnreadCount();
+      this.setState(this.state);
+    } else if (res.op == UserOperation.MarkAllAsRead) {
+      // Moved to be instant
+    } else if (
+      res.op == UserOperation.EditComment ||
+      res.op == UserOperation.DeleteComment ||
+      res.op == UserOperation.RemoveComment
+    ) {
+      let data = res.data as CommentResponse;
+      editCommentRes(data, this.state.replies);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.MarkCommentAsRead) {
+      let data = res.data as CommentResponse;
+
+      // If youre in the unread view, just remove it from the list
+      if (this.state.unreadOrAll == UnreadOrAll.Unread && data.comment.read) {
+        this.state.replies = this.state.replies.filter(
+          r => r.id !== data.comment.id
+        );
+      } else {
+        let found = this.state.replies.find(c => c.id == data.comment.id);
+        found.read = data.comment.read;
+      }
+      this.sendUnreadCount();
+      this.setState(this.state);
+      setupTippy();
+    } else if (res.op == UserOperation.MarkUserMentionAsRead) {
+      let data = res.data as UserMentionResponse;
+
+      let found = this.state.mentions.find(c => c.id == data.mention.id);
+      found.content = data.mention.content;
+      found.updated = data.mention.updated;
+      found.removed = data.mention.removed;
+      found.deleted = data.mention.deleted;
+      found.upvotes = data.mention.upvotes;
+      found.downvotes = data.mention.downvotes;
+      found.score = data.mention.score;
+
+      // If youre in the unread view, just remove it from the list
+      if (this.state.unreadOrAll == UnreadOrAll.Unread && data.mention.read) {
+        this.state.mentions = this.state.mentions.filter(
+          r => r.id !== data.mention.id
+        );
+      } else {
+        let found = this.state.mentions.find(c => c.id == data.mention.id);
+        found.read = data.mention.read;
+      }
+      this.sendUnreadCount();
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreateComment) {
+      let data = res.data as CommentResponse;
+
+      if (data.recipient_ids.includes(UserService.Instance.user.id)) {
+        this.state.replies.unshift(data.comment);
+        this.setState(this.state);
+      } else if (data.comment.creator_id == UserService.Instance.user.id) {
+        toast(i18n.t('reply_sent'));
+      }
+    } else if (res.op == UserOperation.CreatePrivateMessage) {
+      let data = res.data as PrivateMessageResponse;
+      if (data.message.recipient_id == UserService.Instance.user.id) {
+        this.state.messages.unshift(data.message);
+        this.setState(this.state);
+      }
+    } else if (res.op == UserOperation.SaveComment) {
+      let data = res.data as CommentResponse;
+      saveCommentRes(data, this.state.replies);
+      this.setState(this.state);
+      setupTippy();
+    } else if (res.op == UserOperation.CreateCommentLike) {
+      let data = res.data as CommentResponse;
+      createCommentLikeRes(data, this.state.replies);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+      this.state.site = data.site;
+      this.setState(this.state);
+    }
+  }
+
+  sendUnreadCount() {
+    UserService.Instance.unreadCountSub.next(this.unreadCount());
+  }
+
+  unreadCount(): number {
+    return (
+      this.state.replies.filter(r => !r.read).length +
+      this.state.mentions.filter(r => !r.read).length +
+      this.state.messages.filter(
+        r =>
+          UserService.Instance.user &&
+          !r.read &&
+          r.creator_id !== UserService.Instance.user.id
+      ).length
+    );
+  }
+}
diff --git a/src/shared/components/instances.tsx b/src/shared/components/instances.tsx
new file mode 100644 (file)
index 0000000..c54b371
--- /dev/null
@@ -0,0 +1,101 @@
+import { Component } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  UserOperation,
+  WebSocketJsonResponse,
+  GetSiteResponse,
+} from 'lemmy-js-client';
+import { WebSocketService } from '../services';
+import { wsJsonToRes, toast, isBrowser } from '../utils';
+import { i18n } from '../i18next';
+
+interface InstancesState {
+  loading: boolean;
+  siteRes: GetSiteResponse;
+}
+
+export class Instances extends Component<any, InstancesState> {
+  private subscription: Subscription;
+  private emptyState: InstancesState = {
+    loading: true,
+    siteRes: undefined,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.state = this.emptyState;
+
+    if (isBrowser()) {
+      this.subscription = WebSocketService.Instance.subject
+        .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+        .subscribe(
+          msg => this.parseMessage(msg),
+          err => console.error(err),
+          () => console.log('complete')
+        );
+
+      WebSocketService.Instance.getSite();
+    }
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  get documentTitle(): string {
+    if (this.state.siteRes) {
+      return `${i18n.t('instances')} - ${this.state.siteRes.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle} />
+        {this.state.loading ? (
+          <h5 class="">
+            <svg class="icon icon-spinner spin">
+              <use xlinkHref="#icon-spinner"></use>
+            </svg>
+          </h5>
+        ) : (
+          <div>
+            <h5>{i18n.t('linked_instances')}</h5>
+            {this.state.siteRes &&
+            this.state.siteRes.federated_instances.length ? (
+              <ul>
+                {this.state.siteRes.federated_instances.map(i => (
+                  <li>
+                    <a href={`https://${i}`} target="_blank" rel="noopener">
+                      {i}
+                    </a>
+                  </li>
+                ))}
+              </ul>
+            ) : (
+              <div>{i18n.t('none_found')}</div>
+            )}
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      return;
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+      this.state.siteRes = data;
+      this.state.loading = false;
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/listing-type-select.tsx b/src/shared/components/listing-type-select.tsx
new file mode 100644 (file)
index 0000000..3d12d43
--- /dev/null
@@ -0,0 +1,77 @@
+import { Component, linkEvent } from 'inferno';
+import { ListingType } from 'lemmy-js-client';
+import { UserService } from '../services';
+import { randomStr } from '../utils';
+import { i18n } from '../i18next';
+
+interface ListingTypeSelectProps {
+  type_: ListingType;
+  onChange?(val: ListingType): any;
+}
+
+interface ListingTypeSelectState {
+  type_: ListingType;
+}
+
+export class ListingTypeSelect extends Component<
+  ListingTypeSelectProps,
+  ListingTypeSelectState
+> {
+  private id = `listing-type-input-${randomStr()}`;
+
+  private emptyState: ListingTypeSelectState = {
+    type_: this.props.type_,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.state = this.emptyState;
+  }
+
+  static getDerivedStateFromProps(props: any): ListingTypeSelectProps {
+    return {
+      type_: props.type_,
+    };
+  }
+
+  render() {
+    return (
+      <div class="btn-group btn-group-toggle flex-wrap mb-2">
+        <label
+          className={`btn btn-outline-secondary 
+            ${this.state.type_ == ListingType.Subscribed && 'active'}
+            ${UserService.Instance.user == undefined ? 'disabled' : 'pointer'}
+          `}
+        >
+          <input
+            id={`${this.id}-subscribed`}
+            type="radio"
+            value={ListingType.Subscribed}
+            checked={this.state.type_ == ListingType.Subscribed}
+            onChange={linkEvent(this, this.handleTypeChange)}
+            disabled={UserService.Instance.user == undefined}
+          />
+          {i18n.t('subscribed')}
+        </label>
+        <label
+          className={`pointer btn btn-outline-secondary ${
+            this.state.type_ == ListingType.All && 'active'
+          }`}
+        >
+          <input
+            id={`${this.id}-all`}
+            type="radio"
+            value={ListingType.All}
+            checked={this.state.type_ == ListingType.All}
+            onChange={linkEvent(this, this.handleTypeChange)}
+          />
+          {i18n.t('all')}
+        </label>
+      </div>
+    );
+  }
+
+  handleTypeChange(i: ListingTypeSelect, event: any) {
+    i.props.onChange(event.target.value);
+  }
+}
diff --git a/src/shared/components/login.tsx b/src/shared/components/login.tsx
new file mode 100644 (file)
index 0000000..caf8c9c
--- /dev/null
@@ -0,0 +1,485 @@
+import { Component, linkEvent } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  LoginForm,
+  RegisterForm,
+  LoginResponse,
+  UserOperation,
+  PasswordResetForm,
+  GetSiteResponse,
+  GetCaptchaResponse,
+  WebSocketJsonResponse,
+  Site,
+} from 'lemmy-js-client';
+import { WebSocketService, UserService } from '../services';
+import { wsJsonToRes, validEmail, toast } from '../utils';
+import { i18n } from '../i18next';
+
+interface State {
+  loginForm: LoginForm;
+  registerForm: RegisterForm;
+  loginLoading: boolean;
+  registerLoading: boolean;
+  captcha: GetCaptchaResponse;
+  captchaPlaying: boolean;
+  site: Site;
+}
+
+export class Login extends Component<any, State> {
+  private subscription: Subscription;
+
+  emptyState: State = {
+    loginForm: {
+      username_or_email: undefined,
+      password: undefined,
+    },
+    registerForm: {
+      username: undefined,
+      password: undefined,
+      password_verify: undefined,
+      admin: false,
+      show_nsfw: false,
+      captcha_uuid: undefined,
+      captcha_answer: undefined,
+    },
+    loginLoading: false,
+    registerLoading: false,
+    captcha: undefined,
+    captchaPlaying: false,
+    site: {
+      id: undefined,
+      name: undefined,
+      creator_id: undefined,
+      published: undefined,
+      creator_name: undefined,
+      number_of_users: undefined,
+      number_of_posts: undefined,
+      number_of_comments: undefined,
+      number_of_communities: undefined,
+      enable_downvotes: undefined,
+      open_registration: undefined,
+      enable_nsfw: undefined,
+    },
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    WebSocketService.Instance.getSite();
+    WebSocketService.Instance.getCaptcha();
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  get documentTitle(): string {
+    if (this.state.site.name) {
+      return `${i18n.t('login')} - ${this.state.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle} />
+        <div class="row">
+          <div class="col-12 col-lg-6 mb-4">{this.loginForm()}</div>
+          <div class="col-12 col-lg-6">{this.registerForm()}</div>
+        </div>
+      </div>
+    );
+  }
+
+  loginForm() {
+    return (
+      <div>
+        <form onSubmit={linkEvent(this, this.handleLoginSubmit)}>
+          <h5>{i18n.t('login')}</h5>
+          <div class="form-group row">
+            <label
+              class="col-sm-2 col-form-label"
+              htmlFor="login-email-or-username"
+            >
+              {i18n.t('email_or_username')}
+            </label>
+            <div class="col-sm-10">
+              <input
+                type="text"
+                class="form-control"
+                id="login-email-or-username"
+                value={this.state.loginForm.username_or_email}
+                onInput={linkEvent(this, this.handleLoginUsernameChange)}
+                required
+                minLength={3}
+              />
+            </div>
+          </div>
+          <div class="form-group row">
+            <label class="col-sm-2 col-form-label" htmlFor="login-password">
+              {i18n.t('password')}
+            </label>
+            <div class="col-sm-10">
+              <input
+                type="password"
+                id="login-password"
+                value={this.state.loginForm.password}
+                onInput={linkEvent(this, this.handleLoginPasswordChange)}
+                class="form-control"
+                required
+              />
+              {validEmail(this.state.loginForm.username_or_email) && (
+                <button
+                  type="button"
+                  onClick={linkEvent(this, this.handlePasswordReset)}
+                  className="btn p-0 btn-link d-inline-block float-right text-muted small font-weight-bold"
+                >
+                  {i18n.t('forgot_password')}
+                </button>
+              )}
+            </div>
+          </div>
+          <div class="form-group row">
+            <div class="col-sm-10">
+              <button type="submit" class="btn btn-secondary">
+                {this.state.loginLoading ? (
+                  <svg class="icon icon-spinner spin">
+                    <use xlinkHref="#icon-spinner"></use>
+                  </svg>
+                ) : (
+                  i18n.t('login')
+                )}
+              </button>
+            </div>
+          </div>
+        </form>
+      </div>
+    );
+  }
+
+  registerForm() {
+    return (
+      <form onSubmit={linkEvent(this, this.handleRegisterSubmit)}>
+        <h5>{i18n.t('sign_up')}</h5>
+
+        <div class="form-group row">
+          <label class="col-sm-2 col-form-label" htmlFor="register-username">
+            {i18n.t('username')}
+          </label>
+
+          <div class="col-sm-10">
+            <input
+              type="text"
+              id="register-username"
+              class="form-control"
+              value={this.state.registerForm.username}
+              onInput={linkEvent(this, this.handleRegisterUsernameChange)}
+              required
+              minLength={3}
+              maxLength={20}
+              pattern="[a-zA-Z0-9_]+"
+            />
+          </div>
+        </div>
+
+        <div class="form-group row">
+          <label class="col-sm-2 col-form-label" htmlFor="register-email">
+            {i18n.t('email')}
+          </label>
+          <div class="col-sm-10">
+            <input
+              type="email"
+              id="register-email"
+              class="form-control"
+              placeholder={i18n.t('optional')}
+              value={this.state.registerForm.email}
+              onInput={linkEvent(this, this.handleRegisterEmailChange)}
+              minLength={3}
+            />
+            {!validEmail(this.state.registerForm.email) && (
+              <div class="mt-2 mb-0 alert alert-light" role="alert">
+                <svg class="icon icon-inline mr-2">
+                  <use xlinkHref="#icon-alert-triangle"></use>
+                </svg>
+                {i18n.t('no_password_reset')}
+              </div>
+            )}
+          </div>
+        </div>
+
+        <div class="form-group row">
+          <label class="col-sm-2 col-form-label" htmlFor="register-password">
+            {i18n.t('password')}
+          </label>
+          <div class="col-sm-10">
+            <input
+              type="password"
+              id="register-password"
+              value={this.state.registerForm.password}
+              autoComplete="new-password"
+              onInput={linkEvent(this, this.handleRegisterPasswordChange)}
+              class="form-control"
+              required
+            />
+          </div>
+        </div>
+
+        <div class="form-group row">
+          <label
+            class="col-sm-2 col-form-label"
+            htmlFor="register-verify-password"
+          >
+            {i18n.t('verify_password')}
+          </label>
+          <div class="col-sm-10">
+            <input
+              type="password"
+              id="register-verify-password"
+              value={this.state.registerForm.password_verify}
+              autoComplete="new-password"
+              onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)}
+              class="form-control"
+              required
+            />
+          </div>
+        </div>
+
+        {this.state.captcha && (
+          <div class="form-group row">
+            <label class="col-sm-2" htmlFor="register-captcha">
+              <span class="mr-2">{i18n.t('enter_code')}</span>
+              <button
+                type="button"
+                class="btn btn-secondary"
+                onClick={linkEvent(this, this.handleRegenCaptcha)}
+              >
+                <svg class="icon icon-refresh-cw">
+                  <use xlinkHref="#icon-refresh-cw"></use>
+                </svg>
+              </button>
+            </label>
+            {this.showCaptcha()}
+            <div class="col-sm-6">
+              <input
+                type="text"
+                class="form-control"
+                id="register-captcha"
+                value={this.state.registerForm.captcha_answer}
+                onInput={linkEvent(
+                  this,
+                  this.handleRegisterCaptchaAnswerChange
+                )}
+                required
+              />
+            </div>
+          </div>
+        )}
+        {this.state.site.enable_nsfw && (
+          <div class="form-group row">
+            <div class="col-sm-10">
+              <div class="form-check">
+                <input
+                  class="form-check-input"
+                  id="register-show-nsfw"
+                  type="checkbox"
+                  checked={this.state.registerForm.show_nsfw}
+                  onChange={linkEvent(this, this.handleRegisterShowNsfwChange)}
+                />
+                <label class="form-check-label" htmlFor="register-show-nsfw">
+                  {i18n.t('show_nsfw')}
+                </label>
+              </div>
+            </div>
+          </div>
+        )}
+        <div class="form-group row">
+          <div class="col-sm-10">
+            <button type="submit" class="btn btn-secondary">
+              {this.state.registerLoading ? (
+                <svg class="icon icon-spinner spin">
+                  <use xlinkHref="#icon-spinner"></use>
+                </svg>
+              ) : (
+                i18n.t('sign_up')
+              )}
+            </button>
+          </div>
+        </div>
+      </form>
+    );
+  }
+
+  showCaptcha() {
+    return (
+      <div class="col-sm-4">
+        {this.state.captcha.ok && (
+          <>
+            <img
+              class="rounded-top img-fluid"
+              src={this.captchaPngSrc()}
+              style="border-bottom-right-radius: 0; border-bottom-left-radius: 0;"
+            />
+            {this.state.captcha.ok.wav && (
+              <button
+                class="rounded-bottom btn btn-sm btn-secondary btn-block"
+                style="border-top-right-radius: 0; border-top-left-radius: 0;"
+                title={i18n.t('play_captcha_audio')}
+                onClick={linkEvent(this, this.handleCaptchaPlay)}
+                type="button"
+                disabled={this.state.captchaPlaying}
+              >
+                <svg class="icon icon-play">
+                  <use xlinkHref="#icon-play"></use>
+                </svg>
+              </button>
+            )}
+          </>
+        )}
+      </div>
+    );
+  }
+
+  handleLoginSubmit(i: Login, event: any) {
+    event.preventDefault();
+    i.state.loginLoading = true;
+    i.setState(i.state);
+    WebSocketService.Instance.login(i.state.loginForm);
+  }
+
+  handleLoginUsernameChange(i: Login, event: any) {
+    i.state.loginForm.username_or_email = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleLoginPasswordChange(i: Login, event: any) {
+    i.state.loginForm.password = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleRegisterSubmit(i: Login, event: any) {
+    event.preventDefault();
+    i.state.registerLoading = true;
+    i.setState(i.state);
+    WebSocketService.Instance.register(i.state.registerForm);
+  }
+
+  handleRegisterUsernameChange(i: Login, event: any) {
+    i.state.registerForm.username = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleRegisterEmailChange(i: Login, event: any) {
+    i.state.registerForm.email = event.target.value;
+    if (i.state.registerForm.email == '') {
+      i.state.registerForm.email = undefined;
+    }
+    i.setState(i.state);
+  }
+
+  handleRegisterPasswordChange(i: Login, event: any) {
+    i.state.registerForm.password = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleRegisterPasswordVerifyChange(i: Login, event: any) {
+    i.state.registerForm.password_verify = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleRegisterShowNsfwChange(i: Login, event: any) {
+    i.state.registerForm.show_nsfw = event.target.checked;
+    i.setState(i.state);
+  }
+
+  handleRegisterCaptchaAnswerChange(i: Login, event: any) {
+    i.state.registerForm.captcha_answer = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleRegenCaptcha(_i: Login, _event: any) {
+    event.preventDefault();
+    WebSocketService.Instance.getCaptcha();
+  }
+
+  handlePasswordReset(i: Login) {
+    event.preventDefault();
+    let resetForm: PasswordResetForm = {
+      email: i.state.loginForm.username_or_email,
+    };
+    WebSocketService.Instance.passwordReset(resetForm);
+  }
+
+  handleCaptchaPlay(i: Login) {
+    event.preventDefault();
+    let snd = new Audio('data:audio/wav;base64,' + i.state.captcha.ok.wav);
+    snd.play();
+    i.state.captchaPlaying = true;
+    i.setState(i.state);
+    snd.addEventListener('ended', () => {
+      snd.currentTime = 0;
+      i.state.captchaPlaying = false;
+      i.setState(this.state);
+    });
+  }
+
+  captchaPngSrc() {
+    return `data:image/png;base64,${this.state.captcha.ok.png}`;
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      this.state = this.emptyState;
+      this.state.registerForm.captcha_answer = undefined;
+      // Refetch another captcha
+      WebSocketService.Instance.getCaptcha();
+      this.setState(this.state);
+      return;
+    } else {
+      if (res.op == UserOperation.Login) {
+        let data = res.data as LoginResponse;
+        this.state = this.emptyState;
+        this.setState(this.state);
+        UserService.Instance.login(data);
+        WebSocketService.Instance.userJoin();
+        toast(i18n.t('logged_in'));
+        this.props.history.push('/');
+      } else if (res.op == UserOperation.Register) {
+        let data = res.data as LoginResponse;
+        this.state = this.emptyState;
+        this.setState(this.state);
+        UserService.Instance.login(data);
+        WebSocketService.Instance.userJoin();
+        this.props.history.push('/communities');
+      } else if (res.op == UserOperation.GetCaptcha) {
+        let data = res.data as GetCaptchaResponse;
+        if (data.ok) {
+          this.state.captcha = data;
+          this.state.registerForm.captcha_uuid = data.ok.uuid;
+          this.setState(this.state);
+        }
+      } else if (res.op == UserOperation.PasswordReset) {
+        toast(i18n.t('reset_password_mail_sent'));
+      } else if (res.op == UserOperation.GetSite) {
+        let data = res.data as GetSiteResponse;
+        this.state.site = data.site;
+        this.setState(this.state);
+      }
+    }
+  }
+}
diff --git a/src/shared/components/main.tsx b/src/shared/components/main.tsx
new file mode 100644 (file)
index 0000000..547e4c8
--- /dev/null
@@ -0,0 +1,803 @@
+import { Component, linkEvent } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Link } from 'inferno-router';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  UserOperation,
+  CommunityUser,
+  GetFollowedCommunitiesResponse,
+  ListCommunitiesForm,
+  ListCommunitiesResponse,
+  Community,
+  SortType,
+  GetSiteResponse,
+  ListingType,
+  SiteResponse,
+  GetPostsResponse,
+  PostResponse,
+  Post,
+  GetPostsForm,
+  Comment,
+  GetCommentsForm,
+  GetCommentsResponse,
+  CommentResponse,
+  AddAdminResponse,
+  BanUserResponse,
+  WebSocketJsonResponse,
+} from 'lemmy-js-client';
+import { DataType } from '../interfaces';
+import { WebSocketService, UserService } from '../services';
+import { PostListings } from './post-listings';
+import { CommentNodes } from './comment-nodes';
+import { SortSelect } from './sort-select';
+import { ListingTypeSelect } from './listing-type-select';
+import { DataTypeSelect } from './data-type-select';
+import { SiteForm } from './site-form';
+import { UserListing } from './user-listing';
+import { CommunityLink } from './community-link';
+import { BannerIconHeader } from './banner-icon-header';
+import {
+  wsJsonToRes,
+  repoUrl,
+  mdToHtml,
+  fetchLimit,
+  toast,
+  getListingTypeFromProps,
+  getPageFromProps,
+  getSortTypeFromProps,
+  getDataTypeFromProps,
+  editCommentRes,
+  saveCommentRes,
+  createCommentLikeRes,
+  createPostLikeFindRes,
+  editPostFindRes,
+  commentsToFlatNodes,
+  setupTippy,
+  favIconUrl,
+  notifyPost,
+  isBrowser,
+} from '../utils';
+import { i18n } from '../i18next';
+import { T } from 'inferno-i18next';
+
+interface MainState {
+  subscribedCommunities: CommunityUser[];
+  trendingCommunities: Community[];
+  siteRes: GetSiteResponse;
+  showEditSite: boolean;
+  loading: boolean;
+  posts: Post[];
+  comments: Comment[];
+  listingType: ListingType;
+  dataType: DataType;
+  sort: SortType;
+  page: number;
+}
+
+interface MainProps {
+  listingType: ListingType;
+  dataType: DataType;
+  sort: SortType;
+  page: number;
+}
+
+interface UrlParams {
+  listingType?: ListingType;
+  dataType?: string;
+  sort?: SortType;
+  page?: number;
+}
+
+export class Main extends Component<any, MainState> {
+  private subscription: Subscription;
+  private emptyState: MainState = {
+    subscribedCommunities: [],
+    trendingCommunities: [],
+    siteRes: {
+      site: {
+        id: null,
+        name: null,
+        creator_id: null,
+        creator_name: null,
+        published: null,
+        number_of_users: null,
+        number_of_posts: null,
+        number_of_comments: null,
+        number_of_communities: null,
+        enable_downvotes: null,
+        open_registration: null,
+        enable_nsfw: null,
+        icon: null,
+        banner: null,
+        creator_preferred_username: null,
+      },
+      admins: [],
+      banned: [],
+      online: null,
+      version: null,
+      federated_instances: null,
+    },
+    showEditSite: false,
+    loading: true,
+    posts: [],
+    comments: [],
+    listingType: getListingTypeFromProps(this.props),
+    dataType: getDataTypeFromProps(this.props),
+    sort: getSortTypeFromProps(this.props),
+    page: getPageFromProps(this.props),
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+    this.handleEditCancel = this.handleEditCancel.bind(this);
+    this.handleSortChange = this.handleSortChange.bind(this);
+    this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
+    this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
+
+    if (isBrowser()) {
+      // TODO
+      /* this.subscription = WebSocketService.Instance.subject */
+      /* .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10)))) */
+      /* .subscribe( */
+      /*   msg => this.parseMessage(msg), */
+      /*     err => console.error(err), */
+      /*     () => console.log('complete') */
+      /* ); */
+      /* WebSocketService.Instance.getSite(); */
+      /* if (UserService.Instance.user) { */
+      /*   WebSocketService.Instance.getFollowedCommunities(); */
+      /* } */
+      /* let listCommunitiesForm: ListCommunitiesForm = { */
+      /*   sort: SortType.Hot, */
+      /*   limit: 6, */
+      /* }; */
+      /* WebSocketService.Instance.listCommunities(listCommunitiesForm); */
+      /* this.fetchData(); */
+    }
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  /* static getDerivedStateFromProps(props: any): MainProps { */
+  /*   return { */
+  /*     listingType: getListingTypeFromProps(props), */
+  /*     dataType: getDataTypeFromProps(props), */
+  /*     sort: getSortTypeFromProps(props), */
+  /*     page: getPageFromProps(props), */
+  /*   }; */
+  /* } */
+
+  /* componentDidUpdate(_: any, lastState: MainState) { */
+  /*   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 {
+    if (this.state.siteRes.site.name) {
+      return `${this.state.siteRes.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  get favIcon(): string {
+    return this.state.siteRes.site.icon
+      ? this.state.siteRes.site.icon
+      : favIconUrl;
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <h1 className={`text-warning`}>u stink main</h1>
+        <Helmet title={this.documentTitle}>
+          <link
+            id="favicon"
+            rel="icon"
+            type="image/x-icon"
+            href={this.favIcon}
+          />
+        </Helmet>
+        <div class="row">
+          <main role="main" class="col-12 col-md-8">
+            {this.posts()}
+          </main>
+          <aside class="col-12 col-md-4">{this.mySidebar()}</aside>
+        </div>
+      </div>
+    );
+  }
+
+  mySidebar() {
+    return (
+      <div>
+        {!this.state.loading && (
+          <div>
+            <div class="card bg-transparent border-secondary mb-3">
+              <div class="card-header bg-transparent border-secondary">
+                <div class="mb-2">
+                  {this.siteName()}
+                  {this.adminButtons()}
+                </div>
+                <BannerIconHeader banner={this.state.siteRes.site.banner} />
+              </div>
+              <div class="card-body">
+                {this.trendingCommunities()}
+                {this.createCommunityButton()}
+                {/*
+                {this.subscribedCommunities()}
+                */}
+              </div>
+            </div>
+
+            <div class="card bg-transparent border-secondary mb-3">
+              <div class="card-body">{this.sidebar()}</div>
+            </div>
+
+            <div class="card bg-transparent border-secondary">
+              <div class="card-body">{this.landing()}</div>
+            </div>
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  createCommunityButton() {
+    return (
+      <Link class="btn btn-secondary btn-block" to="/create_community">
+        {i18n.t('create_a_community')}
+      </Link>
+    );
+  }
+
+  trendingCommunities() {
+    return (
+      <div>
+        <h5>
+          <T i18nKey="trending_communities">
+            #
+            <Link class="text-body" to="/communities">
+              #
+            </Link>
+          </T>
+        </h5>
+        <ul class="list-inline">
+          {this.state.trendingCommunities.map(community => (
+            <li class="list-inline-item">
+              <CommunityLink community={community} />
+            </li>
+          ))}
+        </ul>
+      </div>
+    );
+  }
+
+  subscribedCommunities() {
+    return (
+      UserService.Instance.user &&
+      this.state.subscribedCommunities.length > 0 && (
+        <div>
+          <h5>
+            <T i18nKey="subscribed_to_communities">
+              #
+              <Link class="text-body" to="/communities">
+                #
+              </Link>
+            </T>
+          </h5>
+          <ul class="list-inline">
+            {this.state.subscribedCommunities.map(community => (
+              <li class="list-inline-item">
+                <CommunityLink
+                  community={{
+                    name: community.community_name,
+                    id: community.community_id,
+                    local: community.community_local,
+                    actor_id: community.community_actor_id,
+                    icon: community.community_icon,
+                  }}
+                />
+              </li>
+            ))}
+          </ul>
+        </div>
+      )
+    );
+  }
+
+  sidebar() {
+    return (
+      <div>
+        {!this.state.showEditSite ? (
+          this.siteInfo()
+        ) : (
+          <SiteForm
+            site={this.state.siteRes.site}
+            onCancel={this.handleEditCancel}
+          />
+        )}
+      </div>
+    );
+  }
+
+  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}`
+    );
+  }
+
+  siteInfo() {
+    return (
+      <div>
+        {this.state.siteRes.site.description && this.siteDescription()}
+        {this.badges()}
+        {this.admins()}
+      </div>
+    );
+  }
+
+  siteName() {
+    return <h5 class="mb-0">{`${this.state.siteRes.site.name}`}</h5>;
+  }
+
+  admins() {
+    return (
+      <ul class="mt-1 list-inline small mb-0">
+        <li class="list-inline-item">{i18n.t('admins')}:</li>
+        {this.state.siteRes.admins.map(admin => (
+          <li class="list-inline-item">
+            <UserListing
+              user={{
+                name: admin.name,
+                preferred_username: admin.preferred_username,
+                avatar: admin.avatar,
+                local: admin.local,
+                actor_id: admin.actor_id,
+                id: admin.id,
+              }}
+            />
+          </li>
+        ))}
+      </ul>
+    );
+  }
+
+  badges() {
+    return (
+      <ul class="my-2 list-inline">
+        <li className="list-inline-item badge badge-light">
+          {i18n.t('number_online', { count: this.state.siteRes.online })}
+        </li>
+        <li className="list-inline-item badge badge-light">
+          {i18n.t('number_of_users', {
+            count: this.state.siteRes.site.number_of_users,
+          })}
+        </li>
+        <li className="list-inline-item badge badge-light">
+          {i18n.t('number_of_communities', {
+            count: this.state.siteRes.site.number_of_communities,
+          })}
+        </li>
+        <li className="list-inline-item badge badge-light">
+          {i18n.t('number_of_posts', {
+            count: this.state.siteRes.site.number_of_posts,
+          })}
+        </li>
+        <li className="list-inline-item badge badge-light">
+          {i18n.t('number_of_comments', {
+            count: this.state.siteRes.site.number_of_comments,
+          })}
+        </li>
+        <li className="list-inline-item">
+          <Link className="badge badge-light" to="/modlog">
+            {i18n.t('modlog')}
+          </Link>
+        </li>
+      </ul>
+    );
+  }
+
+  adminButtons() {
+    return (
+      this.canAdmin && (
+        <ul class="list-inline mb-1 text-muted font-weight-bold">
+          <li className="list-inline-item-action">
+            <span
+              class="pointer"
+              onClick={linkEvent(this, this.handleEditClick)}
+              data-tippy-content={i18n.t('edit')}
+            >
+              <svg class="icon icon-inline">
+                <use xlinkHref="#icon-edit"></use>
+              </svg>
+            </span>
+          </li>
+        </ul>
+      )
+    );
+  }
+
+  siteDescription() {
+    return (
+      <div
+        className="md-div"
+        dangerouslySetInnerHTML={mdToHtml(this.state.siteRes.site.description)}
+      />
+    );
+  }
+
+  landing() {
+    return (
+      <>
+        <h5>
+          {i18n.t('powered_by')}
+          <svg class="icon mx-2">
+            <use xlinkHref="#icon-mouse">#</use>
+          </svg>
+          <a href={repoUrl}>
+            Lemmy<sup>beta</sup>
+          </a>
+        </h5>
+        <p class="mb-0">
+          <T i18nKey="landing_0">
+            #
+            <a href="https://en.wikipedia.org/wiki/Social_network_aggregation">
+              #
+            </a>
+            <a href="https://en.wikipedia.org/wiki/Fediverse">#</a>
+            <br class="big"></br>
+            <code>#</code>
+            <br></br>
+            <b>#</b>
+            <br class="big"></br>
+            <a href={repoUrl}>#</a>
+            <br class="big"></br>
+            <a href="https://www.rust-lang.org">#</a>
+            <a href="https://actix.rs/">#</a>
+            <a href="https://infernojs.org">#</a>
+            <a href="https://www.typescriptlang.org/">#</a>
+            <br class="big"></br>
+            <a href="https://github.com/LemmyNet/lemmy/graphs/contributors?type=a">
+              #
+            </a>
+          </T>
+        </p>
+      </>
+    );
+  }
+
+  posts() {
+    return (
+      <div class="main-content-wrapper">
+        {this.state.loading ? (
+          <h5>
+            <svg class="icon icon-spinner spin">
+              <use xlinkHref="#icon-spinner"></use>
+            </svg>
+          </h5>
+        ) : (
+          <div>
+            {this.selects()}
+            {this.listings()}
+            {this.paginator()}
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  listings() {
+    return this.state.dataType == DataType.Post ? (
+      <PostListings
+        posts={this.state.posts}
+        showCommunity
+        removeDuplicates
+        sort={this.state.sort}
+        enableDownvotes={this.state.siteRes.site.enable_downvotes}
+        enableNsfw={this.state.siteRes.site.enable_nsfw}
+      />
+    ) : (
+      <CommentNodes
+        nodes={commentsToFlatNodes(this.state.comments)}
+        noIndent
+        showCommunity
+        sortType={this.state.sort}
+        showContext
+        enableDownvotes={this.state.siteRes.site.enable_downvotes}
+      />
+    );
+  }
+
+  selects() {
+    return (
+      <div className="mb-3">
+        <span class="mr-3">
+          <DataTypeSelect
+            type_={this.state.dataType}
+            onChange={this.handleDataTypeChange}
+          />
+        </span>
+        <span class="mr-3">
+          <ListingTypeSelect
+            type_={this.state.listingType}
+            onChange={this.handleListingTypeChange}
+          />
+        </span>
+        <span class="mr-2">
+          <SortSelect sort={this.state.sort} onChange={this.handleSortChange} />
+        </span>
+        {this.state.listingType == ListingType.All && (
+          <a
+            href={`/feeds/all.xml?sort=${this.state.sort}`}
+            target="_blank"
+            rel="noopener"
+            title="RSS"
+          >
+            <svg class="icon text-muted small">
+              <use xlinkHref="#icon-rss">#</use>
+            </svg>
+          </a>
+        )}
+        {UserService.Instance.user &&
+          this.state.listingType == ListingType.Subscribed && (
+            <a
+              href={`/feeds/front/${UserService.Instance.auth}.xml?sort=${this.state.sort}`}
+              target="_blank"
+              title="RSS"
+              rel="noopener"
+            >
+              <svg class="icon text-muted small">
+                <use xlinkHref="#icon-rss">#</use>
+              </svg>
+            </a>
+          )}
+      </div>
+    );
+  }
+
+  paginator() {
+    return (
+      <div class="my-2">
+        {this.state.page > 1 && (
+          <button
+            class="btn btn-secondary mr-1"
+            onClick={linkEvent(this, this.prevPage)}
+          >
+            {i18n.t('prev')}
+          </button>
+        )}
+        {this.state.posts.length > 0 && (
+          <button
+            class="btn btn-secondary"
+            onClick={linkEvent(this, this.nextPage)}
+          >
+            {i18n.t('next')}
+          </button>
+        )}
+      </div>
+    );
+  }
+
+  get canAdmin(): boolean {
+    return (
+      UserService.Instance.user &&
+      this.state.siteRes.admins
+        .map(a => a.id)
+        .includes(UserService.Instance.user.id)
+    );
+  }
+
+  handleEditClick(i: Main) {
+    i.state.showEditSite = true;
+    i.setState(i.state);
+  }
+
+  handleEditCancel() {
+    this.state.showEditSite = false;
+    this.setState(this.state);
+  }
+
+  nextPage(i: Main) {
+    i.updateUrl({ page: i.state.page + 1 });
+    window.scrollTo(0, 0);
+  }
+
+  prevPage(i: Main) {
+    i.updateUrl({ page: i.state.page - 1 });
+    window.scrollTo(0, 0);
+  }
+
+  handleSortChange(val: SortType) {
+    this.updateUrl({ sort: val, page: 1 });
+    window.scrollTo(0, 0);
+  }
+
+  handleListingTypeChange(val: ListingType) {
+    this.updateUrl({ listingType: val, page: 1 });
+    window.scrollTo(0, 0);
+  }
+
+  handleDataTypeChange(val: DataType) {
+    this.updateUrl({ dataType: DataType[val], page: 1 });
+    window.scrollTo(0, 0);
+  }
+
+  fetchData() {
+    if (this.state.dataType == DataType.Post) {
+      let getPostsForm: GetPostsForm = {
+        page: this.state.page,
+        limit: fetchLimit,
+        sort: this.state.sort,
+        type_: this.state.listingType,
+      };
+      WebSocketService.Instance.getPosts(getPostsForm);
+    } else {
+      let getCommentsForm: GetCommentsForm = {
+        page: this.state.page,
+        limit: fetchLimit,
+        sort: this.state.sort,
+        type_: this.state.listingType,
+      };
+      WebSocketService.Instance.getComments(getCommentsForm);
+    }
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      return;
+    } else if (msg.reconnect) {
+      this.fetchData();
+    } else if (res.op == UserOperation.GetFollowedCommunities) {
+      let data = res.data as GetFollowedCommunitiesResponse;
+      this.state.subscribedCommunities = data.communities;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.ListCommunities) {
+      let data = res.data as ListCommunitiesResponse;
+      this.state.trendingCommunities = data.communities;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+
+      // This means it hasn't been set up yet
+      if (!data.site) {
+        this.context.router.history.push('/setup');
+      }
+      this.state.siteRes.admins = data.admins;
+      this.state.siteRes.site = data.site;
+      this.state.siteRes.banned = data.banned;
+      this.state.siteRes.online = data.online;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.EditSite) {
+      let data = res.data as SiteResponse;
+      this.state.siteRes.site = data.site;
+      this.state.showEditSite = false;
+      this.setState(this.state);
+      toast(i18n.t('site_saved'));
+    } else if (res.op == UserOperation.GetPosts) {
+      let data = res.data as GetPostsResponse;
+      this.state.posts = data.posts;
+      this.state.loading = false;
+      this.setState(this.state);
+      setupTippy();
+    } else if (res.op == UserOperation.CreatePost) {
+      let data = res.data as PostResponse;
+
+      // If you're on subscribed, only push it if you're subscribed.
+      if (this.state.listingType == ListingType.Subscribed) {
+        if (
+          this.state.subscribedCommunities
+            .map(c => c.community_id)
+            .includes(data.post.community_id)
+        ) {
+          this.state.posts.unshift(data.post);
+          notifyPost(data.post, this.context.router);
+        }
+      } else {
+        // NSFW posts
+        let nsfw = data.post.nsfw || data.post.community_nsfw;
+
+        // Don't push the post if its nsfw, and don't have that setting on
+        if (
+          !nsfw ||
+          (nsfw &&
+            UserService.Instance.user &&
+            UserService.Instance.user.show_nsfw)
+        ) {
+          this.state.posts.unshift(data.post);
+          notifyPost(data.post, this.context.router);
+        }
+      }
+      this.setState(this.state);
+    } else if (res.op == UserOperation.EditPost) {
+      let data = res.data as PostResponse;
+      editPostFindRes(data, this.state.posts);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreatePostLike) {
+      let data = res.data as PostResponse;
+      createPostLikeFindRes(data, this.state.posts);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.AddAdmin) {
+      let data = res.data as AddAdminResponse;
+      this.state.siteRes.admins = data.admins;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.BanUser) {
+      let data = res.data as BanUserResponse;
+      let found = this.state.siteRes.banned.find(u => (u.id = data.user.id));
+
+      // Remove the banned if its found in the list, and the action is an unban
+      if (found && !data.banned) {
+        this.state.siteRes.banned = this.state.siteRes.banned.filter(
+          i => i.id !== data.user.id
+        );
+      } else {
+        this.state.siteRes.banned.push(data.user);
+      }
+
+      this.state.posts
+        .filter(p => p.creator_id == data.user.id)
+        .forEach(p => (p.banned = data.banned));
+
+      this.setState(this.state);
+    } else if (res.op == UserOperation.GetComments) {
+      let data = res.data as GetCommentsResponse;
+      this.state.comments = data.comments;
+      this.state.loading = false;
+      this.setState(this.state);
+    } else if (
+      res.op == UserOperation.EditComment ||
+      res.op == UserOperation.DeleteComment ||
+      res.op == UserOperation.RemoveComment
+    ) {
+      let data = res.data as CommentResponse;
+      editCommentRes(data, this.state.comments);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreateComment) {
+      let data = res.data as CommentResponse;
+
+      // Necessary since it might be a user reply
+      if (data.recipient_ids.length == 0) {
+        // If you're on subscribed, only push it if you're subscribed.
+        if (this.state.listingType == ListingType.Subscribed) {
+          if (
+            this.state.subscribedCommunities
+              .map(c => c.community_id)
+              .includes(data.comment.community_id)
+          ) {
+            this.state.comments.unshift(data.comment);
+          }
+        } else {
+          this.state.comments.unshift(data.comment);
+        }
+        this.setState(this.state);
+      }
+    } else if (res.op == UserOperation.SaveComment) {
+      let data = res.data as CommentResponse;
+      saveCommentRes(data, this.state.comments);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreateCommentLike) {
+      let data = res.data as CommentResponse;
+      createCommentLikeRes(data, this.state.comments);
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/markdown-textarea.tsx b/src/shared/components/markdown-textarea.tsx
new file mode 100644 (file)
index 0000000..1faa8a9
--- /dev/null
@@ -0,0 +1,544 @@
+import { Component, linkEvent } from 'inferno';
+import { Prompt } from 'inferno-router';
+import {
+  mdToHtml,
+  randomStr,
+  markdownHelpUrl,
+  toast,
+  setupTribute,
+  pictrsDeleteToast,
+  setupTippy,
+} from '../utils';
+import { UserService } from '../services';
+import autosize from 'autosize';
+import Tribute from 'tributejs/src/Tribute.js';
+import { i18n } from '../i18next';
+
+interface MarkdownTextAreaProps {
+  initialContent: string;
+  finished?: boolean;
+  buttonTitle?: string;
+  replyType?: boolean;
+  focus?: boolean;
+  disabled?: boolean;
+  maxLength?: number;
+  onSubmit?(msg: { val: string; formId: string }): any;
+  onContentChange?(val: string): any;
+  onReplyCancel?(): any;
+  hideNavigationWarnings?: boolean;
+}
+
+interface MarkdownTextAreaState {
+  content: string;
+  previewMode: boolean;
+  loading: boolean;
+  imageLoading: boolean;
+}
+
+export class MarkdownTextArea extends Component<
+  MarkdownTextAreaProps,
+  MarkdownTextAreaState
+> {
+  private id = `comment-textarea-${randomStr()}`;
+  private formId = `comment-form-${randomStr()}`;
+  private tribute: Tribute;
+  private emptyState: MarkdownTextAreaState = {
+    content: this.props.initialContent,
+    previewMode: false,
+    loading: false,
+    imageLoading: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    // TODO
+    /* this.tribute = setupTribute(); */
+    this.state = this.emptyState;
+  }
+
+  componentDidMount() {
+    let textarea: any = document.getElementById(this.id);
+    if (textarea) {
+      autosize(textarea);
+      this.tribute.attach(textarea);
+      textarea.addEventListener('tribute-replaced', () => {
+        this.state.content = textarea.value;
+        this.setState(this.state);
+        autosize.update(textarea);
+      });
+
+      this.quoteInsert();
+
+      if (this.props.focus) {
+        textarea.focus();
+      }
+
+      // TODO this is slow for some reason
+      setupTippy();
+    }
+  }
+
+  componentDidUpdate() {
+    if (!this.props.hideNavigationWarnings && this.state.content) {
+      window.onbeforeunload = () => true;
+    } else {
+      window.onbeforeunload = undefined;
+    }
+  }
+
+  componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
+    if (nextProps.finished) {
+      this.state.previewMode = false;
+      this.state.loading = false;
+      this.state.content = '';
+      this.setState(this.state);
+      if (this.props.replyType) {
+        this.props.onReplyCancel();
+      }
+
+      let textarea: any = document.getElementById(this.id);
+      let form: any = document.getElementById(this.formId);
+      form.reset();
+      setTimeout(() => autosize.update(textarea), 10);
+      this.setState(this.state);
+    }
+  }
+
+  componentWillUnmount() {
+    window.onbeforeunload = null;
+  }
+
+  render() {
+    return (
+      <form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
+        <Prompt
+          when={!this.props.hideNavigationWarnings && this.state.content}
+          message={i18n.t('block_leaving')}
+        />
+        <div class="form-group row">
+          <div className={`col-sm-12`}>
+            <textarea
+              id={this.id}
+              className={`form-control ${this.state.previewMode && 'd-none'}`}
+              value={this.state.content}
+              onInput={linkEvent(this, this.handleContentChange)}
+              onPaste={linkEvent(this, this.handleImageUploadPaste)}
+              required
+              disabled={this.props.disabled}
+              rows={2}
+              maxLength={this.props.maxLength || 10000}
+            />
+            {this.state.previewMode && (
+              <div
+                className="card bg-transparent border-secondary card-body md-div"
+                dangerouslySetInnerHTML={mdToHtml(this.state.content)}
+              />
+            )}
+          </div>
+        </div>
+        <div class="row">
+          <div class="col-sm-12 d-flex flex-wrap">
+            {this.props.buttonTitle && (
+              <button
+                type="submit"
+                class="btn btn-sm btn-secondary mr-2"
+                disabled={this.props.disabled || this.state.loading}
+              >
+                {this.state.loading ? (
+                  <svg class="icon icon-spinner spin">
+                    <use xlinkHref="#icon-spinner"></use>
+                  </svg>
+                ) : (
+                  <span>{this.props.buttonTitle}</span>
+                )}
+              </button>
+            )}
+            {this.props.replyType && (
+              <button
+                type="button"
+                class="btn btn-sm btn-secondary mr-2"
+                onClick={linkEvent(this, this.handleReplyCancel)}
+              >
+                {i18n.t('cancel')}
+              </button>
+            )}
+            {this.state.content && (
+              <button
+                className={`btn btn-sm btn-secondary mr-2 ${
+                  this.state.previewMode && 'active'
+                }`}
+                onClick={linkEvent(this, this.handlePreviewToggle)}
+              >
+                {i18n.t('preview')}
+              </button>
+            )}
+            {/* A flex expander */}
+            <div class="flex-grow-1"></div>
+            <button
+              class="btn btn-sm text-muted"
+              data-tippy-content={i18n.t('bold')}
+              onClick={linkEvent(this, this.handleInsertBold)}
+            >
+              <svg class="icon icon-inline">
+                <use xlinkHref="#icon-bold"></use>
+              </svg>
+            </button>
+            <button
+              class="btn btn-sm text-muted"
+              data-tippy-content={i18n.t('italic')}
+              onClick={linkEvent(this, this.handleInsertItalic)}
+            >
+              <svg class="icon icon-inline">
+                <use xlinkHref="#icon-italic"></use>
+              </svg>
+            </button>
+            <button
+              class="btn btn-sm text-muted"
+              data-tippy-content={i18n.t('link')}
+              onClick={linkEvent(this, this.handleInsertLink)}
+            >
+              <svg class="icon icon-inline">
+                <use xlinkHref="#icon-link"></use>
+              </svg>
+            </button>
+            <form class="btn btn-sm text-muted font-weight-bold">
+              <label
+                htmlFor={`file-upload-${this.id}`}
+                className={`mb-0 ${UserService.Instance.user && 'pointer'}`}
+                data-tippy-content={i18n.t('upload_image')}
+              >
+                {this.state.imageLoading ? (
+                  <svg class="icon icon-spinner spin">
+                    <use xlinkHref="#icon-spinner"></use>
+                  </svg>
+                ) : (
+                  <svg class="icon icon-inline">
+                    <use xlinkHref="#icon-image"></use>
+                  </svg>
+                )}
+              </label>
+              <input
+                id={`file-upload-${this.id}`}
+                type="file"
+                accept="image/*,video/*"
+                name="file"
+                class="d-none"
+                disabled={!UserService.Instance.user}
+                onChange={linkEvent(this, this.handleImageUpload)}
+              />
+            </form>
+            <button
+              class="btn btn-sm text-muted"
+              data-tippy-content={i18n.t('header')}
+              onClick={linkEvent(this, this.handleInsertHeader)}
+            >
+              <svg class="icon icon-inline">
+                <use xlinkHref="#icon-header"></use>
+              </svg>
+            </button>
+            <button
+              class="btn btn-sm text-muted"
+              data-tippy-content={i18n.t('strikethrough')}
+              onClick={linkEvent(this, this.handleInsertStrikethrough)}
+            >
+              <svg class="icon icon-inline">
+                <use xlinkHref="#icon-strikethrough"></use>
+              </svg>
+            </button>
+            <button
+              class="btn btn-sm text-muted"
+              data-tippy-content={i18n.t('quote')}
+              onClick={linkEvent(this, this.handleInsertQuote)}
+            >
+              <svg class="icon icon-inline">
+                <use xlinkHref="#icon-format_quote"></use>
+              </svg>
+            </button>
+            <button
+              class="btn btn-sm text-muted"
+              data-tippy-content={i18n.t('list')}
+              onClick={linkEvent(this, this.handleInsertList)}
+            >
+              <svg class="icon icon-inline">
+                <use xlinkHref="#icon-list"></use>
+              </svg>
+            </button>
+            <button
+              class="btn btn-sm text-muted"
+              data-tippy-content={i18n.t('code')}
+              onClick={linkEvent(this, this.handleInsertCode)}
+            >
+              <svg class="icon icon-inline">
+                <use xlinkHref="#icon-code"></use>
+              </svg>
+            </button>
+            <button
+              class="btn btn-sm text-muted"
+              data-tippy-content={i18n.t('subscript')}
+              onClick={linkEvent(this, this.handleInsertSubscript)}
+            >
+              <svg class="icon icon-inline">
+                <use xlinkHref="#icon-subscript"></use>
+              </svg>
+            </button>
+            <button
+              class="btn btn-sm text-muted"
+              data-tippy-content={i18n.t('superscript')}
+              onClick={linkEvent(this, this.handleInsertSuperscript)}
+            >
+              <svg class="icon icon-inline">
+                <use xlinkHref="#icon-superscript"></use>
+              </svg>
+            </button>
+            <button
+              class="btn btn-sm text-muted"
+              data-tippy-content={i18n.t('spoiler')}
+              onClick={linkEvent(this, this.handleInsertSpoiler)}
+            >
+              <svg class="icon icon-inline">
+                <use xlinkHref="#icon-alert-triangle"></use>
+              </svg>
+            </button>
+            <a
+              href={markdownHelpUrl}
+              target="_blank"
+              class="btn btn-sm text-muted font-weight-bold"
+              title={i18n.t('formatting_help')}
+              rel="noopener"
+            >
+              <svg class="icon icon-inline">
+                <use xlinkHref="#icon-help-circle"></use>
+              </svg>
+            </a>
+          </div>
+        </div>
+      </form>
+    );
+  }
+
+  handleImageUploadPaste(i: MarkdownTextArea, event: any) {
+    let image = event.clipboardData.files[0];
+    if (image) {
+      i.handleImageUpload(i, image);
+    }
+  }
+
+  handleImageUpload(i: MarkdownTextArea, event: any) {
+    let file: any;
+    if (event.target) {
+      event.preventDefault();
+      file = event.target.files[0];
+    } else {
+      file = event;
+    }
+
+    const imageUploadUrl = `/pictrs/image`;
+    const formData = new FormData();
+    formData.append('images[]', file);
+
+    i.state.imageLoading = true;
+    i.setState(i.state);
+
+    fetch(imageUploadUrl, {
+      method: 'POST',
+      body: formData,
+    })
+      .then(res => res.json())
+      .then(res => {
+        console.log('pictrs upload:');
+        console.log(res);
+        if (res.msg == 'ok') {
+          let hash = res.files[0].file;
+          let url = `${window.location.origin}/pictrs/image/${hash}`;
+          let deleteToken = res.files[0].delete_token;
+          let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
+          let imageMarkdown = `![](${url})`;
+          let content = i.state.content;
+          content = content ? `${content}\n${imageMarkdown}` : imageMarkdown;
+          i.state.content = content;
+          i.state.imageLoading = false;
+          i.setState(i.state);
+          let textarea: any = document.getElementById(i.id);
+          autosize.update(textarea);
+          pictrsDeleteToast(
+            i18n.t('click_to_delete_picture'),
+            i18n.t('picture_deleted'),
+            deleteUrl
+          );
+        } else {
+          i.state.imageLoading = false;
+          i.setState(i.state);
+          toast(JSON.stringify(res), 'danger');
+        }
+      })
+      .catch(error => {
+        i.state.imageLoading = false;
+        i.setState(i.state);
+        toast(error, 'danger');
+      });
+  }
+
+  handleContentChange(i: MarkdownTextArea, event: any) {
+    i.state.content = event.target.value;
+    i.setState(i.state);
+    if (i.props.onContentChange) {
+      i.props.onContentChange(i.state.content);
+    }
+  }
+
+  handlePreviewToggle(i: MarkdownTextArea, event: any) {
+    event.preventDefault();
+    i.state.previewMode = !i.state.previewMode;
+    i.setState(i.state);
+  }
+
+  handleSubmit(i: MarkdownTextArea, event: any) {
+    event.preventDefault();
+    i.state.loading = true;
+    i.setState(i.state);
+    let msg = { val: i.state.content, formId: i.formId };
+    i.props.onSubmit(msg);
+  }
+
+  handleReplyCancel(i: MarkdownTextArea) {
+    i.props.onReplyCancel();
+  }
+
+  handleInsertLink(i: MarkdownTextArea, event: any) {
+    event.preventDefault();
+    if (!i.state.content) {
+      i.state.content = '';
+    }
+    let textarea: any = document.getElementById(i.id);
+    let start: number = textarea.selectionStart;
+    let end: number = textarea.selectionEnd;
+
+    if (start !== end) {
+      let selectedText = i.state.content.substring(start, end);
+      i.state.content = `${i.state.content.substring(
+        0,
+        start
+      )} [${selectedText}]() ${i.state.content.substring(end)}`;
+      textarea.focus();
+      setTimeout(() => (textarea.selectionEnd = end + 4), 10);
+    } else {
+      i.state.content += '[]()';
+      textarea.focus();
+      setTimeout(() => (textarea.selectionEnd -= 1), 10);
+    }
+    i.setState(i.state);
+  }
+
+  simpleSurround(chars: string) {
+    this.simpleSurroundBeforeAfter(chars, chars);
+  }
+
+  simpleSurroundBeforeAfter(beforeChars: string, afterChars: string) {
+    if (!this.state.content) {
+      this.state.content = '';
+    }
+    let textarea: any = document.getElementById(this.id);
+    let start: number = textarea.selectionStart;
+    let end: number = textarea.selectionEnd;
+
+    if (start !== end) {
+      let selectedText = this.state.content.substring(start, end);
+      this.state.content = `${this.state.content.substring(
+        0,
+        start - 1
+      )} ${beforeChars}${selectedText}${afterChars} ${this.state.content.substring(
+        end + 1
+      )}`;
+    } else {
+      this.state.content += `${beforeChars}___${afterChars}`;
+    }
+    this.setState(this.state);
+    setTimeout(() => {
+      autosize.update(textarea);
+    }, 10);
+  }
+
+  handleInsertBold(i: MarkdownTextArea, event: any) {
+    event.preventDefault();
+    i.simpleSurround('**');
+  }
+
+  handleInsertItalic(i: MarkdownTextArea, event: any) {
+    event.preventDefault();
+    i.simpleSurround('*');
+  }
+
+  handleInsertCode(i: MarkdownTextArea, event: any) {
+    event.preventDefault();
+    i.simpleSurround('`');
+  }
+
+  handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
+    event.preventDefault();
+    i.simpleSurround('~~');
+  }
+
+  handleInsertList(i: MarkdownTextArea, event: any) {
+    event.preventDefault();
+    i.simpleInsert('-');
+  }
+
+  handleInsertQuote(i: MarkdownTextArea, event: any) {
+    event.preventDefault();
+    i.simpleInsert('>');
+  }
+
+  handleInsertHeader(i: MarkdownTextArea, event: any) {
+    event.preventDefault();
+    i.simpleInsert('#');
+  }
+
+  handleInsertSubscript(i: MarkdownTextArea, event: any) {
+    event.preventDefault();
+    i.simpleSurround('~');
+  }
+
+  handleInsertSuperscript(i: MarkdownTextArea, event: any) {
+    event.preventDefault();
+    i.simpleSurround('^');
+  }
+
+  simpleInsert(chars: string) {
+    if (!this.state.content) {
+      this.state.content = `${chars} `;
+    } else {
+      this.state.content += `\n${chars} `;
+    }
+
+    let textarea: any = document.getElementById(this.id);
+    textarea.focus();
+    setTimeout(() => {
+      autosize.update(textarea);
+    }, 10);
+    this.setState(this.state);
+  }
+
+  handleInsertSpoiler(i: MarkdownTextArea, event: any) {
+    event.preventDefault();
+    let beforeChars = `\n::: spoiler ${i18n.t('spoiler')}\n`;
+    let afterChars = '\n:::\n';
+    i.simpleSurroundBeforeAfter(beforeChars, afterChars);
+  }
+
+  quoteInsert() {
+    let textarea: any = document.getElementById(this.id);
+    let selectedText = window.getSelection().toString();
+    if (selectedText) {
+      let quotedText =
+        selectedText
+          .split('\n')
+          .map(t => `> ${t}`)
+          .join('\n') + '\n\n';
+      this.state.content = quotedText;
+      this.setState(this.state);
+      // Not sure why this needs a delay
+      setTimeout(() => autosize.update(textarea), 10);
+    }
+  }
+}
diff --git a/src/shared/components/modlog.tsx b/src/shared/components/modlog.tsx
new file mode 100644 (file)
index 0000000..6bbe392
--- /dev/null
@@ -0,0 +1,454 @@
+import { Component, linkEvent } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Link } from 'inferno-router';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  UserOperation,
+  GetModlogForm,
+  GetModlogResponse,
+  ModRemovePost,
+  ModLockPost,
+  ModStickyPost,
+  ModRemoveComment,
+  ModRemoveCommunity,
+  ModBanFromCommunity,
+  ModBan,
+  ModAddCommunity,
+  ModAdd,
+  WebSocketJsonResponse,
+  GetSiteResponse,
+  Site,
+} from 'lemmy-js-client';
+import { WebSocketService } from '../services';
+import { wsJsonToRes, addTypeInfo, fetchLimit, toast } from '../utils';
+import { MomentTime } from './moment-time';
+import moment from 'moment';
+import { i18n } from '../i18next';
+
+interface ModlogState {
+  combined: {
+    type_: string;
+    data:
+      | ModRemovePost
+      | ModLockPost
+      | ModStickyPost
+      | ModRemoveCommunity
+      | ModAdd
+      | ModBan;
+  }[];
+  communityId?: number;
+  communityName?: string;
+  page: number;
+  site: Site;
+  loading: boolean;
+}
+
+export class Modlog extends Component<any, ModlogState> {
+  private subscription: Subscription;
+  private emptyState: ModlogState = {
+    combined: [],
+    page: 1,
+    loading: true,
+    site: undefined,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+    this.state.communityId = this.props.match.params.community_id
+      ? Number(this.props.match.params.community_id)
+      : undefined;
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    this.refetch();
+    WebSocketService.Instance.getSite();
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  setCombined(res: GetModlogResponse) {
+    let removed_posts = addTypeInfo(res.removed_posts, 'removed_posts');
+    let locked_posts = addTypeInfo(res.locked_posts, 'locked_posts');
+    let stickied_posts = addTypeInfo(res.stickied_posts, 'stickied_posts');
+    let removed_comments = addTypeInfo(
+      res.removed_comments,
+      'removed_comments'
+    );
+    let removed_communities = addTypeInfo(
+      res.removed_communities,
+      'removed_communities'
+    );
+    let banned_from_community = addTypeInfo(
+      res.banned_from_community,
+      'banned_from_community'
+    );
+    let added_to_community = addTypeInfo(
+      res.added_to_community,
+      'added_to_community'
+    );
+    let added = addTypeInfo(res.added, 'added');
+    let banned = addTypeInfo(res.banned, 'banned');
+    this.state.combined = [];
+
+    this.state.combined.push(...removed_posts);
+    this.state.combined.push(...locked_posts);
+    this.state.combined.push(...stickied_posts);
+    this.state.combined.push(...removed_comments);
+    this.state.combined.push(...removed_communities);
+    this.state.combined.push(...banned_from_community);
+    this.state.combined.push(...added_to_community);
+    this.state.combined.push(...added);
+    this.state.combined.push(...banned);
+
+    if (this.state.communityId && this.state.combined.length > 0) {
+      this.state.communityName = (this.state.combined[0]
+        .data as ModRemovePost).community_name;
+    }
+
+    // Sort them by time
+    this.state.combined.sort((a, b) =>
+      b.data.when_.localeCompare(a.data.when_)
+    );
+
+    this.setState(this.state);
+  }
+
+  combined() {
+    return (
+      <tbody>
+        {this.state.combined.map(i => (
+          <tr>
+            <td>
+              <MomentTime data={i.data} />
+            </td>
+            <td>
+              <Link to={`/u/${i.data.mod_user_name}`}>
+                {i.data.mod_user_name}
+              </Link>
+            </td>
+            <td>
+              {i.type_ == 'removed_posts' && (
+                <>
+                  {(i.data as ModRemovePost).removed ? 'Removed' : 'Restored'}
+                  <span>
+                    {' '}
+                    Post{' '}
+                    <Link to={`/post/${(i.data as ModRemovePost).post_id}`}>
+                      {(i.data as ModRemovePost).post_name}
+                    </Link>
+                  </span>
+                  <div>
+                    {(i.data as ModRemovePost).reason &&
+                      ` reason: ${(i.data as ModRemovePost).reason}`}
+                  </div>
+                </>
+              )}
+              {i.type_ == 'locked_posts' && (
+                <>
+                  {(i.data as ModLockPost).locked ? 'Locked' : 'Unlocked'}
+                  <span>
+                    {' '}
+                    Post{' '}
+                    <Link to={`/post/${(i.data as ModLockPost).post_id}`}>
+                      {(i.data as ModLockPost).post_name}
+                    </Link>
+                  </span>
+                </>
+              )}
+              {i.type_ == 'stickied_posts' && (
+                <>
+                  {(i.data as ModStickyPost).stickied
+                    ? 'Stickied'
+                    : 'Unstickied'}
+                  <span>
+                    {' '}
+                    Post{' '}
+                    <Link to={`/post/${(i.data as ModStickyPost).post_id}`}>
+                      {(i.data as ModStickyPost).post_name}
+                    </Link>
+                  </span>
+                </>
+              )}
+              {i.type_ == 'removed_comments' && (
+                <>
+                  {(i.data as ModRemoveComment).removed
+                    ? 'Removed'
+                    : 'Restored'}
+                  <span>
+                    {' '}
+                    Comment{' '}
+                    <Link
+                      to={`/post/${
+                        (i.data as ModRemoveComment).post_id
+                      }/comment/${(i.data as ModRemoveComment).comment_id}`}
+                    >
+                      {(i.data as ModRemoveComment).comment_content}
+                    </Link>
+                  </span>
+                  <span>
+                    {' '}
+                    by{' '}
+                    <Link
+                      to={`/u/${
+                        (i.data as ModRemoveComment).comment_user_name
+                      }`}
+                    >
+                      {(i.data as ModRemoveComment).comment_user_name}
+                    </Link>
+                  </span>
+                  <div>
+                    {(i.data as ModRemoveComment).reason &&
+                      ` reason: ${(i.data as ModRemoveComment).reason}`}
+                  </div>
+                </>
+              )}
+              {i.type_ == 'removed_communities' && (
+                <>
+                  {(i.data as ModRemoveCommunity).removed
+                    ? 'Removed'
+                    : 'Restored'}
+                  <span>
+                    {' '}
+                    Community{' '}
+                    <Link
+                      to={`/c/${(i.data as ModRemoveCommunity).community_name}`}
+                    >
+                      {(i.data as ModRemoveCommunity).community_name}
+                    </Link>
+                  </span>
+                  <div>
+                    {(i.data as ModRemoveCommunity).reason &&
+                      ` reason: ${(i.data as ModRemoveCommunity).reason}`}
+                  </div>
+                  <div>
+                    {(i.data as ModRemoveCommunity).expires &&
+                      ` expires: ${moment
+                        .utc((i.data as ModRemoveCommunity).expires)
+                        .fromNow()}`}
+                  </div>
+                </>
+              )}
+              {i.type_ == 'banned_from_community' && (
+                <>
+                  <span>
+                    {(i.data as ModBanFromCommunity).banned
+                      ? 'Banned '
+                      : 'Unbanned '}{' '}
+                  </span>
+                  <span>
+                    <Link
+                      to={`/u/${
+                        (i.data as ModBanFromCommunity).other_user_name
+                      }`}
+                    >
+                      {(i.data as ModBanFromCommunity).other_user_name}
+                    </Link>
+                  </span>
+                  <span> from the community </span>
+                  <span>
+                    <Link
+                      to={`/c/${
+                        (i.data as ModBanFromCommunity).community_name
+                      }`}
+                    >
+                      {(i.data as ModBanFromCommunity).community_name}
+                    </Link>
+                  </span>
+                  <div>
+                    {(i.data as ModBanFromCommunity).reason &&
+                      ` reason: ${(i.data as ModBanFromCommunity).reason}`}
+                  </div>
+                  <div>
+                    {(i.data as ModBanFromCommunity).expires &&
+                      ` expires: ${moment
+                        .utc((i.data as ModBanFromCommunity).expires)
+                        .fromNow()}`}
+                  </div>
+                </>
+              )}
+              {i.type_ == 'added_to_community' && (
+                <>
+                  <span>
+                    {(i.data as ModAddCommunity).removed
+                      ? 'Removed '
+                      : 'Appointed '}{' '}
+                  </span>
+                  <span>
+                    <Link
+                      to={`/u/${(i.data as ModAddCommunity).other_user_name}`}
+                    >
+                      {(i.data as ModAddCommunity).other_user_name}
+                    </Link>
+                  </span>
+                  <span> as a mod to the community </span>
+                  <span>
+                    <Link
+                      to={`/c/${(i.data as ModAddCommunity).community_name}`}
+                    >
+                      {(i.data as ModAddCommunity).community_name}
+                    </Link>
+                  </span>
+                </>
+              )}
+              {i.type_ == 'banned' && (
+                <>
+                  <span>
+                    {(i.data as ModBan).banned ? 'Banned ' : 'Unbanned '}{' '}
+                  </span>
+                  <span>
+                    <Link to={`/u/${(i.data as ModBan).other_user_name}`}>
+                      {(i.data as ModBan).other_user_name}
+                    </Link>
+                  </span>
+                  <div>
+                    {(i.data as ModBan).reason &&
+                      ` reason: ${(i.data as ModBan).reason}`}
+                  </div>
+                  <div>
+                    {(i.data as ModBan).expires &&
+                      ` expires: ${moment
+                        .utc((i.data as ModBan).expires)
+                        .fromNow()}`}
+                  </div>
+                </>
+              )}
+              {i.type_ == 'added' && (
+                <>
+                  <span>
+                    {(i.data as ModAdd).removed ? 'Removed ' : 'Appointed '}{' '}
+                  </span>
+                  <span>
+                    <Link to={`/u/${(i.data as ModAdd).other_user_name}`}>
+                      {(i.data as ModAdd).other_user_name}
+                    </Link>
+                  </span>
+                  <span> as an admin </span>
+                </>
+              )}
+            </td>
+          </tr>
+        ))}
+      </tbody>
+    );
+  }
+
+  get documentTitle(): string {
+    if (this.state.site) {
+      return `Modlog - ${this.state.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle} />
+        {this.state.loading ? (
+          <h5 class="">
+            <svg class="icon icon-spinner spin">
+              <use xlinkHref="#icon-spinner"></use>
+            </svg>
+          </h5>
+        ) : (
+          <div>
+            <h5>
+              {this.state.communityName && (
+                <Link
+                  className="text-body"
+                  to={`/c/${this.state.communityName}`}
+                >
+                  /c/{this.state.communityName}{' '}
+                </Link>
+              )}
+              <span>{i18n.t('modlog')}</span>
+            </h5>
+            <div class="table-responsive">
+              <table id="modlog_table" class="table table-sm table-hover">
+                <thead class="pointer">
+                  <tr>
+                    <th> {i18n.t('time')}</th>
+                    <th>{i18n.t('mod')}</th>
+                    <th>{i18n.t('action')}</th>
+                  </tr>
+                </thead>
+                {this.combined()}
+              </table>
+              {this.paginator()}
+            </div>
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  paginator() {
+    return (
+      <div class="mt-2">
+        {this.state.page > 1 && (
+          <button
+            class="btn btn-secondary mr-1"
+            onClick={linkEvent(this, this.prevPage)}
+          >
+            {i18n.t('prev')}
+          </button>
+        )}
+        <button
+          class="btn btn-secondary"
+          onClick={linkEvent(this, this.nextPage)}
+        >
+          {i18n.t('next')}
+        </button>
+      </div>
+    );
+  }
+
+  nextPage(i: Modlog) {
+    i.state.page++;
+    i.setState(i.state);
+    i.refetch();
+  }
+
+  prevPage(i: Modlog) {
+    i.state.page--;
+    i.setState(i.state);
+    i.refetch();
+  }
+
+  refetch() {
+    let modlogForm: GetModlogForm = {
+      community_id: this.state.communityId,
+      page: this.state.page,
+      limit: fetchLimit,
+    };
+    WebSocketService.Instance.getModlog(modlogForm);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      return;
+    } else if (res.op == UserOperation.GetModlog) {
+      let data = res.data as GetModlogResponse;
+      this.state.loading = false;
+      window.scrollTo(0, 0);
+      this.setCombined(data);
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+      this.state.site = data.site;
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/moment-time.tsx b/src/shared/components/moment-time.tsx
new file mode 100644 (file)
index 0000000..e833805
--- /dev/null
@@ -0,0 +1,55 @@
+import { Component } from 'inferno';
+import moment from 'moment';
+import { getMomentLanguage, capitalizeFirstLetter } from '../utils';
+import { i18n } from '../i18next';
+
+interface MomentTimeProps {
+  data: {
+    published?: string;
+    when_?: string;
+    updated?: string;
+  };
+  showAgo?: boolean;
+}
+
+export class MomentTime extends Component<MomentTimeProps, any> {
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    let lang = getMomentLanguage();
+
+    moment.locale(lang);
+  }
+
+  render() {
+    if (this.props.data.updated) {
+      return (
+        <span
+          data-tippy-content={`${capitalizeFirstLetter(
+            i18n.t('modified')
+          )} ${this.format(this.props.data.updated)}`}
+          className="font-italics pointer unselectable"
+        >
+          <svg class="icon icon-inline mr-1">
+            <use xlinkHref="#icon-edit-2"></use>
+          </svg>
+          {moment.utc(this.props.data.updated).fromNow(!this.props.showAgo)}
+        </span>
+      );
+    } else {
+      let str = this.props.data.published || this.props.data.when_;
+      return (
+        <span
+          className="pointer unselectable"
+          data-tippy-content={this.format(str)}
+        >
+          {moment.utc(str).fromNow(!this.props.showAgo)}
+        </span>
+      );
+    }
+  }
+
+  format(input: string): string {
+    return moment.utc(input).local().format('LLLL');
+  }
+}
diff --git a/src/shared/components/navbar.tsx b/src/shared/components/navbar.tsx
new file mode 100644 (file)
index 0000000..fd433c0
--- /dev/null
@@ -0,0 +1,556 @@
+import { Component, linkEvent, createRef, RefObject } from 'inferno';
+import { Link } from 'inferno-router';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import { WebSocketService, UserService } from '../services';
+import {
+  UserOperation,
+  GetRepliesForm,
+  GetRepliesResponse,
+  GetUserMentionsForm,
+  GetUserMentionsResponse,
+  GetPrivateMessagesForm,
+  PrivateMessagesResponse,
+  SortType,
+  GetSiteResponse,
+  Comment,
+  CommentResponse,
+  PrivateMessage,
+  PrivateMessageResponse,
+  WebSocketJsonResponse,
+} from 'lemmy-js-client';
+import {
+  wsJsonToRes,
+  pictrsAvatarThumbnail,
+  showAvatars,
+  fetchLimit,
+  toast,
+  setTheme,
+  getLanguage,
+  notifyComment,
+  notifyPrivateMessage,
+  isBrowser,
+} from '../utils';
+import { i18n } from '../i18next';
+
+interface NavbarState {
+  isLoggedIn: boolean;
+  expanded: boolean;
+  replies: Comment[];
+  mentions: Comment[];
+  messages: PrivateMessage[];
+  unreadCount: number;
+  searchParam: string;
+  toggleSearch: boolean;
+  siteLoading: boolean;
+  siteRes: GetSiteResponse;
+  onSiteBanner?(url: string): any;
+}
+
+export class Navbar extends Component<any, NavbarState> {
+  private wsSub: Subscription;
+  private userSub: Subscription;
+  private unreadCountSub: Subscription;
+  private searchTextField: RefObject<HTMLInputElement>;
+  emptyState: NavbarState = {
+    isLoggedIn: false,
+    unreadCount: 0,
+    replies: [],
+    mentions: [],
+    messages: [],
+    expanded: false,
+    siteRes: {
+      site: {
+        id: null,
+        name: null,
+        creator_id: null,
+        creator_name: null,
+        published: null,
+        number_of_users: null,
+        number_of_posts: null,
+        number_of_comments: null,
+        number_of_communities: null,
+        enable_downvotes: null,
+        open_registration: null,
+        enable_nsfw: null,
+        icon: null,
+        banner: null,
+        creator_preferred_username: null,
+      },
+      my_user: null,
+      admins: [],
+      banned: [],
+      online: null,
+      version: null,
+      federated_instances: null,
+    },
+    searchParam: '',
+    toggleSearch: false,
+    siteLoading: true,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.state = this.emptyState;
+
+    if (isBrowser()) {
+      this.wsSub = WebSocketService.Instance.subject
+        .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+        .subscribe(
+          msg => this.parseMessage(msg),
+          err => console.error(err),
+          () => console.log('complete')
+        );
+
+      WebSocketService.Instance.getSite();
+
+      this.searchTextField = createRef();
+    }
+  }
+
+  componentDidMount() {
+    if (isBrowser()) {
+      // Subscribe to jwt changes
+      this.userSub = UserService.Instance.jwtSub.subscribe(res => {
+        // A login
+        if (res !== undefined) {
+          this.requestNotificationPermission();
+        } else {
+          this.state.isLoggedIn = false;
+        }
+        WebSocketService.Instance.getSite();
+        this.setState(this.state);
+      });
+
+      // Subscribe to unread count changes
+      this.unreadCountSub = UserService.Instance.unreadCountSub.subscribe(
+        res => {
+          this.setState({ unreadCount: res });
+        }
+      );
+    }
+  }
+
+  handleSearchParam(i: Navbar, event: any) {
+    i.state.searchParam = event.target.value;
+    i.setState(i.state);
+  }
+
+  updateUrl() {
+    /* const searchParam = this.state.searchParam; */
+    /* this.setState({ searchParam: '' }); */
+    /* this.setState({ toggleSearch: false }); */
+    /* if (searchParam === '') { */
+    /*   this.context.router.history.push(`/search/`); */
+    /* } else { */
+    /*   this.context.router.history.push( */
+    /*     `/search/q/${searchParam}/type/All/sort/TopAll/page/1` */
+    /*   ); */
+    /* } */
+  }
+
+  handleSearchSubmit(i: Navbar, event: any) {
+    event.preventDefault();
+    i.updateUrl();
+  }
+
+  handleSearchBtn(i: Navbar, event: any) {
+    event.preventDefault();
+    i.setState({ toggleSearch: true });
+
+    i.searchTextField.current.focus();
+    const offsetWidth = i.searchTextField.current.offsetWidth;
+    if (i.state.searchParam && offsetWidth > 100) {
+      i.updateUrl();
+    }
+  }
+
+  handleSearchBlur(i: Navbar, event: any) {
+    if (!(event.relatedTarget && event.relatedTarget.name !== 'search-btn')) {
+      i.state.toggleSearch = false;
+      i.setState(i.state);
+    }
+  }
+
+  render() {
+    return this.navbar();
+  }
+
+  componentWillUnmount() {
+    this.wsSub.unsubscribe();
+    this.userSub.unsubscribe();
+    this.unreadCountSub.unsubscribe();
+  }
+
+  // TODO class active corresponding to current page
+  navbar() {
+    let user = UserService.Instance.user;
+    let expandedClass = `${!this.state.expanded && 'collapse'} navbar-collapse`;
+
+    return (
+      <nav class="navbar navbar-expand-lg navbar-light shadow-sm p-0 px-3">
+        <div class="container">
+          {!this.state.siteLoading ? (
+            <Link
+              title={this.state.siteRes.version}
+              class="d-flex align-items-center navbar-brand mr-md-3"
+              to="/"
+            >
+              {this.state.siteRes.site.icon && showAvatars() && (
+                <img
+                  src={pictrsAvatarThumbnail(this.state.siteRes.site.icon)}
+                  height="32"
+                  width="32"
+                  class="rounded-circle mr-2"
+                />
+              )}
+              {this.state.siteRes.site.name}
+            </Link>
+          ) : (
+            <div class="navbar-item">
+              <svg class="icon icon-spinner spin">
+                <use xlinkHref="#icon-spinner"></use>
+              </svg>
+            </div>
+          )}
+          {this.state.isLoggedIn && (
+            <Link
+              class="ml-auto p-0 navbar-toggler nav-link border-0"
+              to="/inbox"
+              title={i18n.t('inbox')}
+            >
+              <svg class="icon">
+                <use xlinkHref="#icon-bell"></use>
+              </svg>
+              {this.state.unreadCount > 0 && (
+                <span class="mx-1 badge badge-light">
+                  {this.state.unreadCount}
+                </span>
+              )}
+            </Link>
+          )}
+          <button
+            class="navbar-toggler border-0 p-1"
+            type="button"
+            aria-label="menu"
+            onClick={linkEvent(this, this.expandNavbar)}
+            data-tippy-content={i18n.t('expand_here')}
+          >
+            <span class="navbar-toggler-icon"></span>
+          </button>
+          {/* TODO this isn't working
+                className={`${!this.state.expanded && 'collapse'
+              } navbar-collapse`}
+          */}
+          {!this.state.siteLoading && (
+            <div class="navbar-collapse">
+              <ul class="navbar-nav my-2 mr-auto">
+                <li class="nav-item">
+                  <Link
+                    class="nav-link"
+                    to="/communities"
+                    title={i18n.t('communities')}
+                  >
+                    {i18n.t('communities')}
+                  </Link>
+                </li>
+                <li class="nav-item">
+                  <Link
+                    class="nav-link"
+                    to={{
+                      pathname: '/create_post',
+                      state: { prevPath: this.currentLocation },
+                    }}
+                    title={i18n.t('create_post')}
+                  >
+                    {i18n.t('create_post')}
+                  </Link>
+                </li>
+                <li class="nav-item">
+                  <Link
+                    class="nav-link"
+                    to="/create_community"
+                    title={i18n.t('create_community')}
+                  >
+                    {i18n.t('create_community')}
+                  </Link>
+                </li>
+                <li className="nav-item">
+                  <Link
+                    class="nav-link"
+                    to="/sponsors"
+                    title={i18n.t('donate_to_lemmy')}
+                  >
+                    <svg class="icon">
+                      <use xlinkHref="#icon-coffee"></use>
+                    </svg>
+                  </Link>
+                </li>
+              </ul>
+              <ul class="navbar-nav my-2">
+                {this.canAdmin && (
+                  <li className="nav-item">
+                    <Link
+                      class="nav-link"
+                      to={`/admin`}
+                      title={i18n.t('admin_settings')}
+                    >
+                      <svg class="icon">
+                        <use xlinkHref="#icon-settings"></use>
+                      </svg>
+                    </Link>
+                  </li>
+                )}
+              </ul>
+              {!this.context.router.history.location.pathname.match(
+                /^\/search/
+              ) && (
+                <form
+                  class="form-inline"
+                  onSubmit={linkEvent(this, this.handleSearchSubmit)}
+                >
+                  {/* TODO No idea why, but this class here fails
+                    class={`form-control mr-0 search-input ${
+                      this.state.toggleSearch ? 'show-input' : 'hide-input'
+                    }`}
+
+                */}
+                  <input
+                    onInput={linkEvent(this, this.handleSearchParam)}
+                    value={this.state.searchParam}
+                    type="text"
+                    placeholder={i18n.t('search')}
+                    onBlur={linkEvent(this, this.handleSearchBlur)}
+                  ></input>
+                  <button
+                    name="search-btn"
+                    onClick={linkEvent(this, this.handleSearchBtn)}
+                    class="px-1 btn btn-link"
+                    style="color: var(--gray)"
+                  >
+                    <svg class="icon">
+                      <use xlinkHref="#icon-search"></use>
+                    </svg>
+                  </button>
+                </form>
+              )}
+              {this.state.isLoggedIn ? (
+                <>
+                  <ul class="navbar-nav my-2">
+                    <li className="nav-item">
+                      <Link
+                        class="nav-link"
+                        to="/inbox"
+                        title={i18n.t('inbox')}
+                      >
+                        <svg class="icon">
+                          <use xlinkHref="#icon-bell"></use>
+                        </svg>
+                        {this.state.unreadCount > 0 && (
+                          <span class="ml-1 badge badge-light">
+                            {this.state.unreadCount}
+                          </span>
+                        )}
+                      </Link>
+                    </li>
+                  </ul>
+                  <ul class="navbar-nav">
+                    <li className="nav-item">
+                      <Link
+                        class="nav-link"
+                        to={`/u/${user.name}`}
+                        title={i18n.t('settings')}
+                      >
+                        <span>
+                          {user.avatar && showAvatars() && (
+                            <img
+                              src={pictrsAvatarThumbnail(user.avatar)}
+                              height="32"
+                              width="32"
+                              class="rounded-circle mr-2"
+                            />
+                          )}
+                          {user.preferred_username
+                            ? user.preferred_username
+                            : user.name}
+                        </span>
+                      </Link>
+                    </li>
+                  </ul>
+                </>
+              ) : (
+                <ul class="navbar-nav my-2">
+                  <li className="ml-2 nav-item">
+                    <Link
+                      class="btn btn-success"
+                      to="/login"
+                      title={i18n.t('login_sign_up')}
+                    >
+                      {i18n.t('login_sign_up')}
+                    </Link>
+                  </li>
+                </ul>
+              )}
+            </div>
+          )}
+        </div>
+      </nav>
+    );
+  }
+
+  expandNavbar(i: Navbar) {
+    i.state.expanded = !i.state.expanded;
+    i.setState(i.state);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    let res = wsJsonToRes(msg);
+    console.log(res);
+    if (msg.error) {
+      if (msg.error == 'not_logged_in') {
+        UserService.Instance.logout();
+        location.reload();
+      }
+      return;
+    } else if (msg.reconnect) {
+      this.fetchUnreads();
+    } else if (res.op == UserOperation.GetReplies) {
+      let data = res.data as GetRepliesResponse;
+      let unreadReplies = data.replies.filter(r => !r.read);
+
+      this.state.replies = unreadReplies;
+      this.state.unreadCount = this.calculateUnreadCount();
+      this.setState(this.state);
+      this.sendUnreadCount();
+    } else if (res.op == UserOperation.GetUserMentions) {
+      let data = res.data as GetUserMentionsResponse;
+      let unreadMentions = data.mentions.filter(r => !r.read);
+
+      this.state.mentions = unreadMentions;
+      this.state.unreadCount = this.calculateUnreadCount();
+      this.setState(this.state);
+      this.sendUnreadCount();
+    } else if (res.op == UserOperation.GetPrivateMessages) {
+      let data = res.data as PrivateMessagesResponse;
+      let unreadMessages = data.messages.filter(r => !r.read);
+
+      this.state.messages = unreadMessages;
+      this.state.unreadCount = this.calculateUnreadCount();
+      this.setState(this.state);
+      this.sendUnreadCount();
+    } else if (res.op == UserOperation.CreateComment) {
+      let data = res.data as CommentResponse;
+
+      if (this.state.isLoggedIn) {
+        if (data.recipient_ids.includes(UserService.Instance.user.id)) {
+          this.state.replies.push(data.comment);
+          this.state.unreadCount++;
+          this.setState(this.state);
+          this.sendUnreadCount();
+          notifyComment(data.comment, this.context.router);
+        }
+      }
+    } else if (res.op == UserOperation.CreatePrivateMessage) {
+      let data = res.data as PrivateMessageResponse;
+
+      if (this.state.isLoggedIn) {
+        if (data.message.recipient_id == UserService.Instance.user.id) {
+          this.state.messages.push(data.message);
+          this.state.unreadCount++;
+          this.setState(this.state);
+          this.sendUnreadCount();
+          notifyPrivateMessage(data.message, this.context.router);
+        }
+      }
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+
+      this.state.siteRes = data;
+
+      // The login
+      if (data.my_user) {
+        UserService.Instance.user = data.my_user;
+        WebSocketService.Instance.userJoin();
+        // On the first load, check the unreads
+        if (this.state.isLoggedIn == false) {
+          this.requestNotificationPermission();
+          this.fetchUnreads();
+          setTheme(data.my_user.theme, true);
+          i18n.changeLanguage(getLanguage());
+        }
+        this.state.isLoggedIn = true;
+      }
+    }
+
+    this.state.siteLoading = false;
+    this.setState(this.state);
+  }
+
+  fetchUnreads() {
+    console.log('Fetching unreads...');
+    let repliesForm: GetRepliesForm = {
+      sort: SortType.New,
+      unread_only: true,
+      page: 1,
+      limit: fetchLimit,
+    };
+
+    let userMentionsForm: GetUserMentionsForm = {
+      sort: SortType.New,
+      unread_only: true,
+      page: 1,
+      limit: fetchLimit,
+    };
+
+    let privateMessagesForm: GetPrivateMessagesForm = {
+      unread_only: true,
+      page: 1,
+      limit: fetchLimit,
+    };
+
+    if (this.currentLocation !== '/inbox') {
+      WebSocketService.Instance.getReplies(repliesForm);
+      WebSocketService.Instance.getUserMentions(userMentionsForm);
+      WebSocketService.Instance.getPrivateMessages(privateMessagesForm);
+    }
+  }
+
+  get currentLocation() {
+    return this.context.router.history.location.pathname;
+  }
+
+  sendUnreadCount() {
+    UserService.Instance.unreadCountSub.next(this.state.unreadCount);
+  }
+
+  calculateUnreadCount(): number {
+    return (
+      this.state.replies.filter(r => !r.read).length +
+      this.state.mentions.filter(r => !r.read).length +
+      this.state.messages.filter(r => !r.read).length
+    );
+  }
+
+  get canAdmin(): boolean {
+    return (
+      UserService.Instance.user &&
+      this.state.siteRes.admins
+        .map(a => a.id)
+        .includes(UserService.Instance.user.id)
+    );
+  }
+
+  requestNotificationPermission() {
+    if (UserService.Instance.user) {
+      document.addEventListener('DOMContentLoaded', function () {
+        if (!Notification) {
+          toast(i18n.t('notifications_error'), 'danger');
+          return;
+        }
+
+        if (Notification.permission !== 'granted')
+          Notification.requestPermission();
+      });
+    }
+  }
+}
diff --git a/src/shared/components/password_change.tsx b/src/shared/components/password_change.tsx
new file mode 100644 (file)
index 0000000..527f21e
--- /dev/null
@@ -0,0 +1,162 @@
+import { Component, linkEvent } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  UserOperation,
+  LoginResponse,
+  PasswordChangeForm,
+  WebSocketJsonResponse,
+  GetSiteResponse,
+  Site,
+} from 'lemmy-js-client';
+import { WebSocketService, UserService } from '../services';
+import { wsJsonToRes, capitalizeFirstLetter, toast } from '../utils';
+import { i18n } from '../i18next';
+
+interface State {
+  passwordChangeForm: PasswordChangeForm;
+  loading: boolean;
+  site: Site;
+}
+
+export class PasswordChange extends Component<any, State> {
+  private subscription: Subscription;
+
+  emptyState: State = {
+    passwordChangeForm: {
+      token: this.props.match.params.token,
+      password: undefined,
+      password_verify: undefined,
+    },
+    loading: false,
+    site: undefined,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+    WebSocketService.Instance.getSite();
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  get documentTitle(): string {
+    if (this.state.site) {
+      return `${i18n.t('password_change')} - ${this.state.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle} />
+        <div class="row">
+          <div class="col-12 col-lg-6 offset-lg-3 mb-4">
+            <h5>{i18n.t('password_change')}</h5>
+            {this.passwordChangeForm()}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  passwordChangeForm() {
+    return (
+      <form onSubmit={linkEvent(this, this.handlePasswordChangeSubmit)}>
+        <div class="form-group row">
+          <label class="col-sm-2 col-form-label">
+            {i18n.t('new_password')}
+          </label>
+          <div class="col-sm-10">
+            <input
+              type="password"
+              value={this.state.passwordChangeForm.password}
+              onInput={linkEvent(this, this.handlePasswordChange)}
+              class="form-control"
+              required
+            />
+          </div>
+        </div>
+        <div class="form-group row">
+          <label class="col-sm-2 col-form-label">
+            {i18n.t('verify_password')}
+          </label>
+          <div class="col-sm-10">
+            <input
+              type="password"
+              value={this.state.passwordChangeForm.password_verify}
+              onInput={linkEvent(this, this.handleVerifyPasswordChange)}
+              class="form-control"
+              required
+            />
+          </div>
+        </div>
+        <div class="form-group row">
+          <div class="col-sm-10">
+            <button type="submit" class="btn btn-secondary">
+              {this.state.loading ? (
+                <svg class="icon icon-spinner spin">
+                  <use xlinkHref="#icon-spinner"></use>
+                </svg>
+              ) : (
+                capitalizeFirstLetter(i18n.t('save'))
+              )}
+            </button>
+          </div>
+        </div>
+      </form>
+    );
+  }
+
+  handlePasswordChange(i: PasswordChange, event: any) {
+    i.state.passwordChangeForm.password = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleVerifyPasswordChange(i: PasswordChange, event: any) {
+    i.state.passwordChangeForm.password_verify = event.target.value;
+    i.setState(i.state);
+  }
+
+  handlePasswordChangeSubmit(i: PasswordChange, event: any) {
+    event.preventDefault();
+    i.state.loading = true;
+    i.setState(i.state);
+
+    WebSocketService.Instance.passwordChange(i.state.passwordChangeForm);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      this.state.loading = false;
+      this.setState(this.state);
+      return;
+    } else if (res.op == UserOperation.PasswordChange) {
+      let data = res.data as LoginResponse;
+      this.state = this.emptyState;
+      this.setState(this.state);
+      UserService.Instance.login(data);
+      this.props.history.push('/');
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+      this.state.site = data.site;
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/post-form.tsx b/src/shared/components/post-form.tsx
new file mode 100644 (file)
index 0000000..8e34c6d
--- /dev/null
@@ -0,0 +1,623 @@
+import { Component, linkEvent } from 'inferno';
+import { Prompt } from 'inferno-router';
+import { PostListings } from './post-listings';
+import { MarkdownTextArea } from './markdown-textarea';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  PostForm as PostFormI,
+  PostFormParams,
+  Post,
+  PostResponse,
+  UserOperation,
+  Community,
+  ListCommunitiesResponse,
+  ListCommunitiesForm,
+  SortType,
+  SearchForm,
+  SearchType,
+  SearchResponse,
+  WebSocketJsonResponse,
+} from 'lemmy-js-client';
+import { WebSocketService, UserService } from '../services';
+import {
+  wsJsonToRes,
+  getPageTitle,
+  validURL,
+  capitalizeFirstLetter,
+  archiveUrl,
+  debounce,
+  isImage,
+  toast,
+  randomStr,
+  setupTippy,
+  hostname,
+  pictrsDeleteToast,
+  validTitle,
+} from '../utils';
+import Choices from 'choices.js';
+import { i18n } from '../i18next';
+
+const MAX_POST_TITLE_LENGTH = 200;
+
+interface PostFormProps {
+  post?: Post; // If a post is given, that means this is an edit
+  params?: PostFormParams;
+  onCancel?(): any;
+  onCreate?(id: number): any;
+  onEdit?(post: Post): any;
+  enableNsfw: boolean;
+  enableDownvotes: boolean;
+}
+
+interface PostFormState {
+  postForm: PostFormI;
+  communities: Community[];
+  loading: boolean;
+  imageLoading: boolean;
+  previewMode: boolean;
+  suggestedTitle: string;
+  suggestedPosts: Post[];
+  crossPosts: Post[];
+}
+
+export class PostForm extends Component<PostFormProps, PostFormState> {
+  private id = `post-form-${randomStr()}`;
+  private subscription: Subscription;
+  private choices: Choices;
+  private emptyState: PostFormState = {
+    postForm: {
+      name: null,
+      nsfw: false,
+      auth: null,
+      community_id: null,
+    },
+    communities: [],
+    loading: false,
+    imageLoading: false,
+    previewMode: false,
+    suggestedTitle: undefined,
+    suggestedPosts: [],
+    crossPosts: [],
+  };
+
+  constructor(props: any, 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.state = this.emptyState;
+
+    if (this.props.post) {
+      this.state.postForm = {
+        body: this.props.post.body,
+        // NOTE: debouncing breaks both these for some reason, unless you use defaultValue
+        name: this.props.post.name,
+        community_id: this.props.post.community_id,
+        edit_id: this.props.post.id,
+        url: this.props.post.url,
+        nsfw: this.props.post.nsfw,
+        auth: null,
+      };
+    }
+
+    if (this.props.params) {
+      this.state.postForm.name = this.props.params.name;
+      if (this.props.params.url) {
+        this.state.postForm.url = this.props.params.url;
+      }
+      if (this.props.params.body) {
+        this.state.postForm.body = this.props.params.body;
+      }
+    }
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    let listCommunitiesForm: ListCommunitiesForm = {
+      sort: SortType.TopAll,
+      limit: 9999,
+    };
+
+    WebSocketService.Instance.listCommunities(listCommunitiesForm);
+  }
+
+  componentDidMount() {
+    setupTippy();
+  }
+
+  componentDidUpdate() {
+    if (
+      !this.state.loading &&
+      (this.state.postForm.name ||
+        this.state.postForm.url ||
+        this.state.postForm.body)
+    ) {
+      window.onbeforeunload = () => true;
+    } else {
+      window.onbeforeunload = undefined;
+    }
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+    /* this.choices && this.choices.destroy(); */
+    window.onbeforeunload = null;
+  }
+
+  render() {
+    return (
+      <div>
+        <Prompt
+          when={
+            !this.state.loading &&
+            (this.state.postForm.name ||
+              this.state.postForm.url ||
+              this.state.postForm.body)
+          }
+          message={i18n.t('block_leaving')}
+        />
+        <form onSubmit={linkEvent(this, this.handlePostSubmit)}>
+          <div class="form-group row">
+            <label class="col-sm-2 col-form-label" htmlFor="post-url">
+              {i18n.t('url')}
+            </label>
+            <div class="col-sm-10">
+              <input
+                type="url"
+                id="post-url"
+                class="form-control"
+                value={this.state.postForm.url}
+                onInput={linkEvent(this, this.handlePostUrlChange)}
+                onPaste={linkEvent(this, this.handleImageUploadPaste)}
+              />
+              {this.state.suggestedTitle && (
+                <div
+                  class="mt-1 text-muted small font-weight-bold pointer"
+                  onClick={linkEvent(this, this.copySuggestedTitle)}
+                >
+                  {i18n.t('copy_suggested_title', {
+                    title: this.state.suggestedTitle,
+                  })}
+                </div>
+              )}
+              <form>
+                <label
+                  htmlFor="file-upload"
+                  className={`${
+                    UserService.Instance.user && 'pointer'
+                  } d-inline-block float-right text-muted font-weight-bold`}
+                  data-tippy-content={i18n.t('upload_image')}
+                >
+                  <svg class="icon icon-inline">
+                    <use xlinkHref="#icon-image"></use>
+                  </svg>
+                </label>
+                <input
+                  id="file-upload"
+                  type="file"
+                  accept="image/*,video/*"
+                  name="file"
+                  class="d-none"
+                  disabled={!UserService.Instance.user}
+                  onChange={linkEvent(this, this.handleImageUpload)}
+                />
+              </form>
+              {validURL(this.state.postForm.url) && (
+                <a
+                  href={`${archiveUrl}/?run=1&url=${encodeURIComponent(
+                    this.state.postForm.url
+                  )}`}
+                  target="_blank"
+                  class="mr-2 d-inline-block float-right text-muted small font-weight-bold"
+                  rel="noopener"
+                >
+                  {i18n.t('archive_link')}
+                </a>
+              )}
+              {this.state.imageLoading && (
+                <svg class="icon icon-spinner spin">
+                  <use xlinkHref="#icon-spinner"></use>
+                </svg>
+              )}
+              {isImage(this.state.postForm.url) && (
+                <img src={this.state.postForm.url} class="img-fluid" />
+              )}
+              {this.state.crossPosts.length > 0 && (
+                <>
+                  <div class="my-1 text-muted small font-weight-bold">
+                    {i18n.t('cross_posts')}
+                  </div>
+                  <PostListings
+                    showCommunity
+                    posts={this.state.crossPosts}
+                    enableDownvotes={this.props.enableDownvotes}
+                    enableNsfw={this.props.enableNsfw}
+                  />
+                </>
+              )}
+            </div>
+          </div>
+          <div class="form-group row">
+            <label class="col-sm-2 col-form-label" htmlFor="post-title">
+              {i18n.t('title')}
+            </label>
+            <div class="col-sm-10">
+              <textarea
+                value={this.state.postForm.name}
+                id="post-title"
+                onInput={linkEvent(this, this.handlePostNameChange)}
+                class={`form-control ${
+                  !validTitle(this.state.postForm.name) && 'is-invalid'
+                }`}
+                required
+                rows={2}
+                minLength={3}
+                maxLength={MAX_POST_TITLE_LENGTH}
+              />
+              {!validTitle(this.state.postForm.name) && (
+                <div class="invalid-feedback">
+                  {i18n.t('invalid_post_title')}
+                </div>
+              )}
+              {this.state.suggestedPosts.length > 0 && (
+                <>
+                  <div class="my-1 text-muted small font-weight-bold">
+                    {i18n.t('related_posts')}
+                  </div>
+                  <PostListings
+                    posts={this.state.suggestedPosts}
+                    enableDownvotes={this.props.enableDownvotes}
+                    enableNsfw={this.props.enableNsfw}
+                  />
+                </>
+              )}
+            </div>
+          </div>
+
+          <div class="form-group row">
+            <label class="col-sm-2 col-form-label" htmlFor={this.id}>
+              {i18n.t('body')}
+            </label>
+            <div class="col-sm-10">
+              <MarkdownTextArea
+                initialContent={this.state.postForm.body}
+                onContentChange={this.handlePostBodyChange}
+              />
+            </div>
+          </div>
+          {!this.props.post && (
+            <div class="form-group row">
+              <label class="col-sm-2 col-form-label" htmlFor="post-community">
+                {i18n.t('community')}
+              </label>
+              <div class="col-sm-10">
+                <select
+                  class="form-control"
+                  id="post-community"
+                  value={this.state.postForm.community_id}
+                  onInput={linkEvent(this, this.handlePostCommunityChange)}
+                >
+                  <option>{i18n.t('select_a_community')}</option>
+                  {this.state.communities.map(community => (
+                    <option value={community.id}>
+                      {community.local
+                        ? community.name
+                        : `${hostname(community.actor_id)}/${community.name}`}
+                    </option>
+                  ))}
+                </select>
+              </div>
+            </div>
+          )}
+          {this.props.enableNsfw && (
+            <div class="form-group row">
+              <div class="col-sm-10">
+                <div class="form-check">
+                  <input
+                    class="form-check-input"
+                    id="post-nsfw"
+                    type="checkbox"
+                    checked={this.state.postForm.nsfw}
+                    onChange={linkEvent(this, this.handlePostNsfwChange)}
+                  />
+                  <label class="form-check-label" htmlFor="post-nsfw">
+                    {i18n.t('nsfw')}
+                  </label>
+                </div>
+              </div>
+            </div>
+          )}
+          <div class="form-group row">
+            <div class="col-sm-10">
+              <button
+                disabled={
+                  !this.state.postForm.community_id || this.state.loading
+                }
+                type="submit"
+                class="btn btn-secondary mr-2"
+              >
+                {this.state.loading ? (
+                  <svg class="icon icon-spinner spin">
+                    <use xlinkHref="#icon-spinner"></use>
+                  </svg>
+                ) : this.props.post ? (
+                  capitalizeFirstLetter(i18n.t('save'))
+                ) : (
+                  capitalizeFirstLetter(i18n.t('create'))
+                )}
+              </button>
+              {this.props.post && (
+                <button
+                  type="button"
+                  class="btn btn-secondary"
+                  onClick={linkEvent(this, this.handleCancel)}
+                >
+                  {i18n.t('cancel')}
+                </button>
+              )}
+            </div>
+          </div>
+        </form>
+      </div>
+    );
+  }
+
+  handlePostSubmit(i: PostForm, event: any) {
+    event.preventDefault();
+
+    // Coerce empty url string to undefined
+    if (i.state.postForm.url && i.state.postForm.url === '') {
+      i.state.postForm.url = undefined;
+    }
+
+    if (i.props.post) {
+      WebSocketService.Instance.editPost(i.state.postForm);
+    } else {
+      WebSocketService.Instance.createPost(i.state.postForm);
+    }
+    i.state.loading = true;
+    i.setState(i.state);
+  }
+
+  copySuggestedTitle(i: PostForm) {
+    i.state.postForm.name = i.state.suggestedTitle.substring(
+      0,
+      MAX_POST_TITLE_LENGTH
+    );
+    i.state.suggestedTitle = undefined;
+    i.setState(i.state);
+  }
+
+  handlePostUrlChange(i: PostForm, event: any) {
+    i.state.postForm.url = event.target.value;
+    i.setState(i.state);
+    i.fetchPageTitle();
+  }
+
+  fetchPageTitle() {
+    if (validURL(this.state.postForm.url)) {
+      let form: SearchForm = {
+        q: this.state.postForm.url,
+        type_: SearchType.Url,
+        sort: SortType.TopAll,
+        page: 1,
+        limit: 6,
+      };
+
+      WebSocketService.Instance.search(form);
+
+      // Fetch the page title
+      getPageTitle(this.state.postForm.url).then(d => {
+        this.state.suggestedTitle = d;
+        this.setState(this.state);
+      });
+    } else {
+      this.state.suggestedTitle = undefined;
+      this.state.crossPosts = [];
+    }
+  }
+
+  handlePostNameChange(i: PostForm, event: any) {
+    i.state.postForm.name = event.target.value;
+    i.setState(i.state);
+    i.fetchSimilarPosts();
+  }
+
+  fetchSimilarPosts() {
+    let form: SearchForm = {
+      q: this.state.postForm.name,
+      type_: SearchType.Posts,
+      sort: SortType.TopAll,
+      community_id: this.state.postForm.community_id,
+      page: 1,
+      limit: 6,
+    };
+
+    if (this.state.postForm.name !== '') {
+      WebSocketService.Instance.search(form);
+    } else {
+      this.state.suggestedPosts = [];
+    }
+
+    this.setState(this.state);
+  }
+
+  handlePostBodyChange(val: string) {
+    this.state.postForm.body = val;
+    this.setState(this.state);
+  }
+
+  handlePostCommunityChange(i: PostForm, event: any) {
+    i.state.postForm.community_id = Number(event.target.value);
+    i.setState(i.state);
+  }
+
+  handlePostNsfwChange(i: PostForm, event: any) {
+    i.state.postForm.nsfw = event.target.checked;
+    i.setState(i.state);
+  }
+
+  handleCancel(i: PostForm) {
+    i.props.onCancel();
+  }
+
+  handlePreviewToggle(i: PostForm, event: any) {
+    event.preventDefault();
+    i.state.previewMode = !i.state.previewMode;
+    i.setState(i.state);
+  }
+
+  handleImageUploadPaste(i: PostForm, event: any) {
+    let image = event.clipboardData.files[0];
+    if (image) {
+      i.handleImageUpload(i, image);
+    }
+  }
+
+  handleImageUpload(i: PostForm, event: any) {
+    let file: any;
+    if (event.target) {
+      event.preventDefault();
+      file = event.target.files[0];
+    } else {
+      file = event;
+    }
+
+    const imageUploadUrl = `/pictrs/image`;
+    const formData = new FormData();
+    formData.append('images[]', file);
+
+    i.state.imageLoading = true;
+    i.setState(i.state);
+
+    fetch(imageUploadUrl, {
+      method: 'POST',
+      body: formData,
+    })
+      .then(res => res.json())
+      .then(res => {
+        console.log('pictrs upload:');
+        console.log(res);
+        if (res.msg == 'ok') {
+          let hash = res.files[0].file;
+          let url = `${window.location.origin}/pictrs/image/${hash}`;
+          let deleteToken = res.files[0].delete_token;
+          let deleteUrl = `${window.location.origin}/pictrs/image/delete/${deleteToken}/${hash}`;
+          i.state.postForm.url = url;
+          i.state.imageLoading = false;
+          i.setState(i.state);
+          pictrsDeleteToast(
+            i18n.t('click_to_delete_picture'),
+            i18n.t('picture_deleted'),
+            deleteUrl
+          );
+        } else {
+          i.state.imageLoading = false;
+          i.setState(i.state);
+          toast(JSON.stringify(res), 'danger');
+        }
+      })
+      .catch(error => {
+        i.state.imageLoading = false;
+        i.setState(i.state);
+        toast(error, 'danger');
+      });
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      this.state.loading = false;
+      this.setState(this.state);
+      return;
+    } else if (res.op == UserOperation.ListCommunities) {
+      let data = res.data as ListCommunitiesResponse;
+      this.state.communities = data.communities;
+      if (this.props.post) {
+        this.state.postForm.community_id = this.props.post.community_id;
+      } else if (this.props.params && this.props.params.community) {
+        let foundCommunityId = data.communities.find(
+          r => r.name == this.props.params.community
+        ).id;
+        this.state.postForm.community_id = foundCommunityId;
+      } else {
+        // By default, the null valued 'Select a Community'
+      }
+      this.setState(this.state);
+
+      // Set up select searching
+      let selectId: any = document.getElementById('post-community');
+      if (selectId) {
+        // TODO
+        /* this.choices = new Choices(selectId, { */
+        /*   shouldSort: false, */
+        /*   classNames: { */
+        /*     containerOuter: 'choices', */
+        /*     containerInner: 'choices__inner bg-secondary border-0', */
+        /*     input: 'form-control', */
+        /*     inputCloned: 'choices__input--cloned', */
+        /*     list: 'choices__list', */
+        /*     listItems: 'choices__list--multiple', */
+        /*     listSingle: 'choices__list--single', */
+        /*     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', */
+        /*   }, */
+        /* }); */
+        this.choices.passedElement.element.addEventListener(
+          'choice',
+          (e: any) => {
+            this.state.postForm.community_id = Number(e.detail.choice.value);
+            this.setState(this.state);
+          },
+          false
+        );
+      }
+    } else if (res.op == UserOperation.CreatePost) {
+      let data = res.data as PostResponse;
+      if (data.post.creator_id == UserService.Instance.user.id) {
+        this.state.loading = false;
+        this.props.onCreate(data.post.id);
+      }
+    } else if (res.op == UserOperation.EditPost) {
+      let data = res.data as PostResponse;
+      if (data.post.creator_id == UserService.Instance.user.id) {
+        this.state.loading = false;
+        this.props.onEdit(data.post);
+      }
+    } else if (res.op == UserOperation.Search) {
+      let data = res.data as SearchResponse;
+
+      if (data.type_ == SearchType[SearchType.Posts]) {
+        this.state.suggestedPosts = data.posts;
+      } else if (data.type_ == SearchType[SearchType.Url]) {
+        this.state.crossPosts = data.posts;
+      }
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/post-listing.tsx b/src/shared/components/post-listing.tsx
new file mode 100644 (file)
index 0000000..d82ddc8
--- /dev/null
@@ -0,0 +1,1458 @@
+import { Component, linkEvent } from 'inferno';
+import { Link } from 'inferno-router';
+import { WebSocketService, UserService } from '../services';
+import {
+  Post,
+  CreatePostLikeForm,
+  DeletePostForm,
+  RemovePostForm,
+  LockPostForm,
+  StickyPostForm,
+  SavePostForm,
+  CommunityUser,
+  UserView,
+  BanFromCommunityForm,
+  BanUserForm,
+  AddModToCommunityForm,
+  AddAdminForm,
+  TransferSiteForm,
+  TransferCommunityForm,
+} from 'lemmy-js-client';
+import { BanType } from '../interfaces';
+import { MomentTime } from './moment-time';
+import { PostForm } from './post-form';
+import { IFramelyCard } from './iframely-card';
+import { UserListing } from './user-listing';
+import { CommunityLink } from './community-link';
+import {
+  md,
+  mdToHtml,
+  canMod,
+  isMod,
+  isImage,
+  isVideo,
+  getUnixTime,
+  pictrsImage,
+  setupTippy,
+  hostname,
+  previewLines,
+} from '../utils';
+import { i18n } from '../i18next';
+
+interface PostListingState {
+  showEdit: boolean;
+  showRemoveDialog: boolean;
+  removeReason: string;
+  showBanDialog: boolean;
+  removeData: boolean;
+  banReason: string;
+  banExpires: string;
+  banType: BanType;
+  showConfirmTransferSite: boolean;
+  showConfirmTransferCommunity: boolean;
+  imageExpanded: boolean;
+  viewSource: boolean;
+  showAdvanced: boolean;
+  my_vote: number;
+  score: number;
+  upvotes: number;
+  downvotes: number;
+}
+
+interface PostListingProps {
+  post: Post;
+  showCommunity?: boolean;
+  showBody?: boolean;
+  moderators?: CommunityUser[];
+  admins?: UserView[];
+  enableDownvotes: boolean;
+  enableNsfw: boolean;
+}
+
+export class PostListing extends Component<PostListingProps, PostListingState> {
+  private emptyState: PostListingState = {
+    showEdit: false,
+    showRemoveDialog: false,
+    removeReason: null,
+    showBanDialog: false,
+    removeData: null,
+    banReason: null,
+    banExpires: null,
+    banType: BanType.Community,
+    showConfirmTransferSite: false,
+    showConfirmTransferCommunity: false,
+    imageExpanded: false,
+    viewSource: false,
+    showAdvanced: false,
+    my_vote: this.props.post.my_vote,
+    score: this.props.post.score,
+    upvotes: this.props.post.upvotes,
+    downvotes: this.props.post.downvotes,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+    this.handlePostLike = this.handlePostLike.bind(this);
+    this.handlePostDisLike = this.handlePostDisLike.bind(this);
+    this.handleEditPost = this.handleEditPost.bind(this);
+    this.handleEditCancel = this.handleEditCancel.bind(this);
+  }
+
+  componentWillReceiveProps(nextProps: PostListingProps) {
+    this.state.my_vote = nextProps.post.my_vote;
+    this.state.upvotes = nextProps.post.upvotes;
+    this.state.downvotes = nextProps.post.downvotes;
+    this.state.score = nextProps.post.score;
+    if (this.props.post.id !== nextProps.post.id) {
+      this.state.imageExpanded = false;
+    }
+    this.setState(this.state);
+  }
+
+  render() {
+    return (
+      <div class="">
+        {!this.state.showEdit ? (
+          <>
+            {this.listing()}
+            {this.body()}
+          </>
+        ) : (
+          <div class="col-12">
+            <PostForm
+              post={this.props.post}
+              onEdit={this.handleEditPost}
+              onCancel={this.handleEditCancel}
+              enableNsfw={this.props.enableNsfw}
+              enableDownvotes={this.props.enableDownvotes}
+            />
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  body() {
+    return (
+      <div class="row">
+        <div class="col-12">
+          {this.props.post.url &&
+            this.props.showBody &&
+            this.props.post.embed_title && (
+              <IFramelyCard post={this.props.post} />
+            )}
+          {this.props.showBody && this.props.post.body && (
+            <>
+              {this.state.viewSource ? (
+                <pre>{this.props.post.body}</pre>
+              ) : (
+                <div
+                  className="md-div"
+                  dangerouslySetInnerHTML={mdToHtml(this.props.post.body)}
+                />
+              )}
+            </>
+          )}
+        </div>
+      </div>
+    );
+  }
+
+  imgThumb(src: string) {
+    let post = this.props.post;
+    return (
+      <img
+        className={`img-fluid thumbnail rounded ${
+          post.nsfw || post.community_nsfw ? 'img-blur' : ''
+        }`}
+        src={src}
+      />
+    );
+  }
+
+  getImage(thumbnail: boolean = false) {
+    let post = this.props.post;
+    if (isImage(post.url)) {
+      if (post.url.includes('pictrs')) {
+        return pictrsImage(post.url, thumbnail);
+      } else if (post.thumbnail_url) {
+        return pictrsImage(post.thumbnail_url, thumbnail);
+      } else {
+        return post.url;
+      }
+    } else if (post.thumbnail_url) {
+      return pictrsImage(post.thumbnail_url, thumbnail);
+    }
+  }
+
+  thumbnail() {
+    let post = this.props.post;
+
+    if (isImage(post.url)) {
+      return (
+        <div
+          class="float-right text-body pointer d-inline-block position-relative"
+          data-tippy-content={i18n.t('expand_here')}
+          onClick={linkEvent(this, this.handleImageExpandClick)}
+        >
+          {this.imgThumb(this.getImage(true))}
+          <svg class="icon mini-overlay">
+            <use xlinkHref="#icon-image"></use>
+          </svg>
+        </div>
+      );
+    } else if (post.thumbnail_url) {
+      return (
+        <a
+          class="float-right text-body d-inline-block position-relative"
+          href={post.url}
+          target="_blank"
+          rel="noopener"
+          title={post.url}
+        >
+          {this.imgThumb(this.getImage(true))}
+          <svg class="icon mini-overlay">
+            <use xlinkHref="#icon-external-link"></use>
+          </svg>
+        </a>
+      );
+    } else if (post.url) {
+      if (isVideo(post.url)) {
+        return (
+          <div class="embed-responsive embed-responsive-16by9">
+            <video
+              playsinline
+              muted
+              loop
+              controls
+              class="embed-responsive-item"
+            >
+              <source src={post.url} type="video/mp4" />
+            </video>
+          </div>
+        );
+      } else {
+        return (
+          <a
+            className="text-body"
+            href={post.url}
+            target="_blank"
+            title={post.url}
+            rel="noopener"
+          >
+            <div class="thumbnail rounded bg-light d-flex justify-content-center">
+              <svg class="icon d-flex align-items-center">
+                <use xlinkHref="#icon-external-link"></use>
+              </svg>
+            </div>
+          </a>
+        );
+      }
+    } else {
+      return (
+        <Link
+          className="text-body"
+          to={`/post/${post.id}`}
+          title={i18n.t('comments')}
+        >
+          <div class="thumbnail rounded bg-light d-flex justify-content-center">
+            <svg class="icon d-flex align-items-center">
+              <use xlinkHref="#icon-message-square"></use>
+            </svg>
+          </div>
+        </Link>
+      );
+    }
+  }
+
+  createdLine() {
+    let post = this.props.post;
+    return (
+      <ul class="list-inline mb-1 text-muted small">
+        <li className="list-inline-item">
+          <UserListing
+            user={{
+              name: post.creator_name,
+              preferred_username: post.creator_preferred_username,
+              avatar: post.creator_avatar,
+              id: post.creator_id,
+              local: post.creator_local,
+              actor_id: post.creator_actor_id,
+              published: post.creator_published,
+            }}
+          />
+
+          {this.isMod && (
+            <span className="mx-1 badge badge-light">{i18n.t('mod')}</span>
+          )}
+          {this.isAdmin && (
+            <span className="mx-1 badge badge-light">{i18n.t('admin')}</span>
+          )}
+          {(post.banned_from_community || post.banned) && (
+            <span className="mx-1 badge badge-danger">{i18n.t('banned')}</span>
+          )}
+          {this.props.showCommunity && (
+            <span>
+              <span class="mx-1"> {i18n.t('to')} </span>
+              <CommunityLink
+                community={{
+                  name: post.community_name,
+                  id: post.community_id,
+                  local: post.community_local,
+                  actor_id: post.community_actor_id,
+                  icon: post.community_icon,
+                }}
+              />
+            </span>
+          )}
+        </li>
+        <li className="list-inline-item">•</li>
+        {post.url && !(hostname(post.url) == window.location.hostname) && (
+          <>
+            <li className="list-inline-item">
+              <a
+                className="text-muted font-italic"
+                href={post.url}
+                target="_blank"
+                title={post.url}
+                rel="noopener"
+              >
+                {hostname(post.url)}
+              </a>
+            </li>
+            <li className="list-inline-item">•</li>
+          </>
+        )}
+        <li className="list-inline-item">
+          <span>
+            <MomentTime data={post} />
+          </span>
+        </li>
+        {post.body && (
+          <>
+            <li className="list-inline-item">•</li>
+            <li className="list-inline-item">
+              {/* Using a link with tippy doesn't work on touch devices unfortunately */}
+              <Link
+                className="text-muted"
+                data-tippy-content={md.render(previewLines(post.body))}
+                data-tippy-allowHtml={true}
+                to={`/post/${post.id}`}
+              >
+                <svg class="mr-1 icon icon-inline">
+                  <use xlinkHref="#icon-book-open"></use>
+                </svg>
+              </Link>
+            </li>
+          </>
+        )}
+      </ul>
+    );
+  }
+
+  voteBar() {
+    return (
+      <div className={`vote-bar col-1 pr-0 small text-center`}>
+        <button
+          className={`btn-animate btn btn-link p-0 ${
+            this.state.my_vote == 1 ? 'text-info' : 'text-muted'
+          }`}
+          onClick={linkEvent(this, this.handlePostLike)}
+          data-tippy-content={i18n.t('upvote')}
+        >
+          <svg class="icon upvote">
+            <use xlinkHref="#icon-arrow-up1"></use>
+          </svg>
+        </button>
+        <div
+          class={`unselectable pointer font-weight-bold text-muted px-1`}
+          data-tippy-content={this.pointsTippy}
+        >
+          {this.state.score}
+        </div>
+        {this.props.enableDownvotes && (
+          <button
+            className={`btn-animate btn btn-link p-0 ${
+              this.state.my_vote == -1 ? 'text-danger' : 'text-muted'
+            }`}
+            onClick={linkEvent(this, this.handlePostDisLike)}
+            data-tippy-content={i18n.t('downvote')}
+          >
+            <svg class="icon downvote">
+              <use xlinkHref="#icon-arrow-down1"></use>
+            </svg>
+          </button>
+        )}
+      </div>
+    );
+  }
+
+  postTitleLine() {
+    let post = this.props.post;
+    return (
+      <div className="post-title overflow-hidden">
+        <h5>
+          {this.props.showBody && post.url ? (
+            <a
+              className={!post.stickied ? 'text-body' : 'text-primary'}
+              href={post.url}
+              target="_blank"
+              title={post.url}
+              rel="noopener"
+            >
+              {post.name}
+            </a>
+          ) : (
+            <Link
+              className={!post.stickied ? 'text-body' : 'text-primary'}
+              to={`/post/${post.id}`}
+              title={i18n.t('comments')}
+            >
+              {post.name}
+            </Link>
+          )}
+          {(isImage(post.url) || this.props.post.thumbnail_url) && (
+            <>
+              {!this.state.imageExpanded ? (
+                <span
+                  class="text-monospace unselectable pointer ml-2 text-muted small"
+                  data-tippy-content={i18n.t('expand_here')}
+                  onClick={linkEvent(this, this.handleImageExpandClick)}
+                >
+                  <svg class="icon icon-inline">
+                    <use xlinkHref="#icon-plus-square"></use>
+                  </svg>
+                </span>
+              ) : (
+                <span>
+                  <span
+                    class="text-monospace unselectable pointer ml-2 text-muted small"
+                    onClick={linkEvent(this, this.handleImageExpandClick)}
+                  >
+                    <svg class="icon icon-inline">
+                      <use xlinkHref="#icon-minus-square"></use>
+                    </svg>
+                  </span>
+                  <div>
+                    <span
+                      class="pointer"
+                      onClick={linkEvent(this, this.handleImageExpandClick)}
+                    >
+                      <img
+                        class="img-fluid img-expanded"
+                        src={this.getImage()}
+                      />
+                    </span>
+                  </div>
+                </span>
+              )}
+            </>
+          )}
+          {post.removed && (
+            <small className="ml-2 text-muted font-italic">
+              {i18n.t('removed')}
+            </small>
+          )}
+          {post.deleted && (
+            <small
+              className="unselectable pointer ml-2 text-muted font-italic"
+              data-tippy-content={i18n.t('deleted')}
+            >
+              <svg class={`icon icon-inline text-danger`}>
+                <use xlinkHref="#icon-trash"></use>
+              </svg>
+            </small>
+          )}
+          {post.locked && (
+            <small
+              className="unselectable pointer ml-2 text-muted font-italic"
+              data-tippy-content={i18n.t('locked')}
+            >
+              <svg class={`icon icon-inline text-danger`}>
+                <use xlinkHref="#icon-lock"></use>
+              </svg>
+            </small>
+          )}
+          {post.stickied && (
+            <small
+              className="unselectable pointer ml-2 text-muted font-italic"
+              data-tippy-content={i18n.t('stickied')}
+            >
+              <svg class={`icon icon-inline text-primary`}>
+                <use xlinkHref="#icon-pin"></use>
+              </svg>
+            </small>
+          )}
+          {post.nsfw && (
+            <small className="ml-2 text-muted font-italic">
+              {i18n.t('nsfw')}
+            </small>
+          )}
+        </h5>
+      </div>
+    );
+  }
+
+  commentsLine(showVotes: boolean = false) {
+    let post = this.props.post;
+    return (
+      <ul class="d-flex align-items-center list-inline mb-1 text-muted small">
+        <li className="list-inline-item">
+          <Link
+            className="text-muted"
+            title={i18n.t('number_of_comments', {
+              count: post.number_of_comments,
+            })}
+            to={`/post/${post.id}`}
+          >
+            <svg class="mr-1 icon icon-inline">
+              <use xlinkHref="#icon-message-square"></use>
+            </svg>
+            {i18n.t('number_of_comments', {
+              count: post.number_of_comments,
+            })}
+          </Link>
+        </li>
+        {(showVotes || this.state.upvotes !== this.state.score) && (
+          <>
+            <span
+              class="unselectable pointer ml-3"
+              data-tippy-content={this.pointsTippy}
+            >
+              <li className="list-inline-item">
+                <a
+                  className={`btn-animate btn btn-link p-0 ${
+                    this.state.my_vote == 1 ? 'text-info' : 'text-muted'
+                  }`}
+                  onClick={linkEvent(this, this.handlePostLike)}
+                >
+                  <svg class="small icon icon-inline mx-1">
+                    <use xlinkHref="#icon-arrow-up1"></use>
+                  </svg>
+                  {this.state.upvotes}
+                </a>
+              </li>
+              <li className="list-inline-item">
+                <a
+                  className={`btn-animate btn btn-link p-0 ${
+                    this.state.my_vote == -1 ? 'text-danger' : 'text-muted'
+                  }`}
+                  onClick={linkEvent(this, this.handlePostDisLike)}
+                >
+                  <svg class="small icon icon-inline mx-1">
+                    <use xlinkHref="#icon-arrow-down1"></use>
+                  </svg>
+                  {this.state.downvotes}
+                </a>
+              </li>
+            </span>
+          </>
+        )}
+      </ul>
+    );
+  }
+
+  duplicatesLine() {
+    return (
+      this.props.post.duplicates && (
+        <ul class="list-inline mb-1 small text-muted">
+          <>
+            <li className="list-inline-item mr-2">
+              {i18n.t('cross_posted_to')}
+            </li>
+            {this.props.post.duplicates.map(post => (
+              <li className="list-inline-item mr-2">
+                <Link to={`/post/${post.id}`}>{post.community_name}</Link>
+              </li>
+            ))}
+          </>
+        </ul>
+      )
+    );
+  }
+
+  postActions() {
+    let post = this.props.post;
+    return (
+      <ul class="list-inline mb-1 text-muted font-weight-bold">
+        {UserService.Instance.user && (
+          <>
+            {this.props.showBody && (
+              <>
+                <li className="list-inline-item">
+                  <button
+                    class="btn btn-link btn-animate text-muted"
+                    onClick={linkEvent(this, this.handleSavePostClick)}
+                    data-tippy-content={
+                      post.saved ? i18n.t('unsave') : i18n.t('save')
+                    }
+                  >
+                    <svg
+                      class={`icon icon-inline ${post.saved && 'text-warning'}`}
+                    >
+                      <use xlinkHref="#icon-star"></use>
+                    </svg>
+                  </button>
+                </li>
+                <li className="list-inline-item">
+                  <Link
+                    class="btn btn-link btn-animate text-muted"
+                    to={`/create_post${this.crossPostParams}`}
+                    title={i18n.t('cross_post')}
+                  >
+                    <svg class="icon icon-inline">
+                      <use xlinkHref="#icon-copy"></use>
+                    </svg>
+                  </Link>
+                </li>
+              </>
+            )}
+            {this.myPost && this.props.showBody && (
+              <>
+                <li className="list-inline-item">
+                  <button
+                    class="btn btn-link btn-animate text-muted"
+                    onClick={linkEvent(this, this.handleEditClick)}
+                    data-tippy-content={i18n.t('edit')}
+                  >
+                    <svg class="icon icon-inline">
+                      <use xlinkHref="#icon-edit"></use>
+                    </svg>
+                  </button>
+                </li>
+                <li className="list-inline-item">
+                  <button
+                    class="btn btn-link btn-animate text-muted"
+                    onClick={linkEvent(this, this.handleDeleteClick)}
+                    data-tippy-content={
+                      !post.deleted ? i18n.t('delete') : i18n.t('restore')
+                    }
+                  >
+                    <svg
+                      class={`icon icon-inline ${
+                        post.deleted && 'text-danger'
+                      }`}
+                    >
+                      <use xlinkHref="#icon-trash"></use>
+                    </svg>
+                  </button>
+                </li>
+              </>
+            )}
+
+            {!this.state.showAdvanced && this.props.showBody ? (
+              <li className="list-inline-item">
+                <button
+                  class="btn btn-link btn-animate text-muted"
+                  onClick={linkEvent(this, this.handleShowAdvanced)}
+                  data-tippy-content={i18n.t('more')}
+                >
+                  <svg class="icon icon-inline">
+                    <use xlinkHref="#icon-more-vertical"></use>
+                  </svg>
+                </button>
+              </li>
+            ) : (
+              <>
+                {this.props.showBody && post.body && (
+                  <li className="list-inline-item">
+                    <button
+                      class="btn btn-link btn-animate text-muted"
+                      onClick={linkEvent(this, this.handleViewSource)}
+                      data-tippy-content={i18n.t('view_source')}
+                    >
+                      <svg
+                        class={`icon icon-inline ${
+                          this.state.viewSource && 'text-success'
+                        }`}
+                      >
+                        <use xlinkHref="#icon-file-text"></use>
+                      </svg>
+                    </button>
+                  </li>
+                )}
+                {this.canModOnSelf && (
+                  <>
+                    <li className="list-inline-item">
+                      <button
+                        class="btn btn-link btn-animate text-muted"
+                        onClick={linkEvent(this, this.handleModLock)}
+                        data-tippy-content={
+                          post.locked ? i18n.t('unlock') : i18n.t('lock')
+                        }
+                      >
+                        <svg
+                          class={`icon icon-inline ${
+                            post.locked && 'text-danger'
+                          }`}
+                        >
+                          <use xlinkHref="#icon-lock"></use>
+                        </svg>
+                      </button>
+                    </li>
+                    <li className="list-inline-item">
+                      <button
+                        class="btn btn-link btn-animate text-muted"
+                        onClick={linkEvent(this, this.handleModSticky)}
+                        data-tippy-content={
+                          post.stickied ? i18n.t('unsticky') : i18n.t('sticky')
+                        }
+                      >
+                        <svg
+                          class={`icon icon-inline ${
+                            post.stickied && 'text-success'
+                          }`}
+                        >
+                          <use xlinkHref="#icon-pin"></use>
+                        </svg>
+                      </button>
+                    </li>
+                  </>
+                )}
+                {/* Mods can ban from community, and appoint as mods to community */}
+                {(this.canMod || this.canAdmin) && (
+                  <li className="list-inline-item">
+                    {!post.removed ? (
+                      <span
+                        class="pointer"
+                        onClick={linkEvent(this, this.handleModRemoveShow)}
+                      >
+                        {i18n.t('remove')}
+                      </span>
+                    ) : (
+                      <span
+                        class="pointer"
+                        onClick={linkEvent(this, this.handleModRemoveSubmit)}
+                      >
+                        {i18n.t('restore')}
+                      </span>
+                    )}
+                  </li>
+                )}
+                {this.canMod && (
+                  <>
+                    {!this.isMod && (
+                      <li className="list-inline-item">
+                        {!post.banned_from_community ? (
+                          <span
+                            class="pointer"
+                            onClick={linkEvent(
+                              this,
+                              this.handleModBanFromCommunityShow
+                            )}
+                          >
+                            {i18n.t('ban')}
+                          </span>
+                        ) : (
+                          <span
+                            class="pointer"
+                            onClick={linkEvent(
+                              this,
+                              this.handleModBanFromCommunitySubmit
+                            )}
+                          >
+                            {i18n.t('unban')}
+                          </span>
+                        )}
+                      </li>
+                    )}
+                    {!post.banned_from_community && post.creator_local && (
+                      <li className="list-inline-item">
+                        <span
+                          class="pointer"
+                          onClick={linkEvent(
+                            this,
+                            this.handleAddModToCommunity
+                          )}
+                        >
+                          {this.isMod
+                            ? i18n.t('remove_as_mod')
+                            : i18n.t('appoint_as_mod')}
+                        </span>
+                      </li>
+                    )}
+                  </>
+                )}
+                {/* Community creators and admins can transfer community to another mod */}
+                {(this.amCommunityCreator || this.canAdmin) &&
+                  this.isMod &&
+                  post.creator_local && (
+                    <li className="list-inline-item">
+                      {!this.state.showConfirmTransferCommunity ? (
+                        <span
+                          class="pointer"
+                          onClick={linkEvent(
+                            this,
+                            this.handleShowConfirmTransferCommunity
+                          )}
+                        >
+                          {i18n.t('transfer_community')}
+                        </span>
+                      ) : (
+                        <>
+                          <span class="d-inline-block mr-1">
+                            {i18n.t('are_you_sure')}
+                          </span>
+                          <span
+                            class="pointer d-inline-block mr-1"
+                            onClick={linkEvent(
+                              this,
+                              this.handleTransferCommunity
+                            )}
+                          >
+                            {i18n.t('yes')}
+                          </span>
+                          <span
+                            class="pointer d-inline-block"
+                            onClick={linkEvent(
+                              this,
+                              this.handleCancelShowConfirmTransferCommunity
+                            )}
+                          >
+                            {i18n.t('no')}
+                          </span>
+                        </>
+                      )}
+                    </li>
+                  )}
+                {/* Admins can ban from all, and appoint other admins */}
+                {this.canAdmin && (
+                  <>
+                    {!this.isAdmin && (
+                      <li className="list-inline-item">
+                        {!post.banned ? (
+                          <span
+                            class="pointer"
+                            onClick={linkEvent(this, this.handleModBanShow)}
+                          >
+                            {i18n.t('ban_from_site')}
+                          </span>
+                        ) : (
+                          <span
+                            class="pointer"
+                            onClick={linkEvent(this, this.handleModBanSubmit)}
+                          >
+                            {i18n.t('unban_from_site')}
+                          </span>
+                        )}
+                      </li>
+                    )}
+                    {!post.banned && post.creator_local && (
+                      <li className="list-inline-item">
+                        <span
+                          class="pointer"
+                          onClick={linkEvent(this, this.handleAddAdmin)}
+                        >
+                          {this.isAdmin
+                            ? i18n.t('remove_as_admin')
+                            : i18n.t('appoint_as_admin')}
+                        </span>
+                      </li>
+                    )}
+                  </>
+                )}
+                {/* Site Creator can transfer to another admin */}
+                {this.amSiteCreator && this.isAdmin && (
+                  <li className="list-inline-item">
+                    {!this.state.showConfirmTransferSite ? (
+                      <span
+                        class="pointer"
+                        onClick={linkEvent(
+                          this,
+                          this.handleShowConfirmTransferSite
+                        )}
+                      >
+                        {i18n.t('transfer_site')}
+                      </span>
+                    ) : (
+                      <>
+                        <span class="d-inline-block mr-1">
+                          {i18n.t('are_you_sure')}
+                        </span>
+                        <span
+                          class="pointer d-inline-block mr-1"
+                          onClick={linkEvent(this, this.handleTransferSite)}
+                        >
+                          {i18n.t('yes')}
+                        </span>
+                        <span
+                          class="pointer d-inline-block"
+                          onClick={linkEvent(
+                            this,
+                            this.handleCancelShowConfirmTransferSite
+                          )}
+                        >
+                          {i18n.t('no')}
+                        </span>
+                      </>
+                    )}
+                  </li>
+                )}
+              </>
+            )}
+          </>
+        )}
+      </ul>
+    );
+  }
+
+  removeAndBanDialogs() {
+    let post = this.props.post;
+    return (
+      <>
+        {this.state.showRemoveDialog && (
+          <form
+            class="form-inline"
+            onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
+          >
+            <input
+              type="text"
+              class="form-control mr-2"
+              placeholder={i18n.t('reason')}
+              value={this.state.removeReason}
+              onInput={linkEvent(this, this.handleModRemoveReasonChange)}
+            />
+            <button type="submit" class="btn btn-secondary">
+              {i18n.t('remove_post')}
+            </button>
+          </form>
+        )}
+        {this.state.showBanDialog && (
+          <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
+            <div class="form-group row">
+              <label class="col-form-label" htmlFor="post-listing-reason">
+                {i18n.t('reason')}
+              </label>
+              <input
+                type="text"
+                id="post-listing-reason"
+                class="form-control mr-2"
+                placeholder={i18n.t('reason')}
+                value={this.state.banReason}
+                onInput={linkEvent(this, this.handleModBanReasonChange)}
+              />
+              <div class="form-group">
+                <div class="form-check">
+                  <input
+                    class="form-check-input"
+                    id="mod-ban-remove-data"
+                    type="checkbox"
+                    checked={this.state.removeData}
+                    onChange={linkEvent(this, this.handleModRemoveDataChange)}
+                  />
+                  <label class="form-check-label" htmlFor="mod-ban-remove-data">
+                    {i18n.t('remove_posts_comments')}
+                  </label>
+                </div>
+              </div>
+            </div>
+            {/* TODO hold off on expires until later */}
+            {/* <div class="form-group row"> */}
+            {/*   <label class="col-form-label">Expires</label> */}
+            {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
+            {/* </div> */}
+            <div class="form-group row">
+              <button type="submit" class="btn btn-secondary">
+                {i18n.t('ban')} {post.creator_name}
+              </button>
+            </div>
+          </form>
+        )}
+      </>
+    );
+  }
+
+  mobileThumbnail() {
+    return this.props.post.thumbnail_url || isImage(this.props.post.url) ? (
+      <div class="row">
+        <div className={`${this.state.imageExpanded ? 'col-12' : 'col-8'}`}>
+          {this.postTitleLine()}
+        </div>
+        <div class="col-4">
+          {/* Post body prev or thumbnail */}
+          {!this.state.imageExpanded && this.thumbnail()}
+        </div>
+      </div>
+    ) : (
+      this.postTitleLine()
+    );
+  }
+
+  showMobilePreview() {
+    return (
+      this.props.post.body &&
+      !this.props.showBody && (
+        <div
+          className="md-div mb-1"
+          dangerouslySetInnerHTML={{
+            __html: md.render(previewLines(this.props.post.body)),
+          }}
+        />
+      )
+    );
+  }
+
+  listing() {
+    return (
+      <>
+        {/* The mobile view*/}
+        <div class="d-block d-sm-none">
+          <div class="row">
+            <div class="col-12">
+              {this.createdLine()}
+
+              {/* If it has a thumbnail, do a right aligned thumbnail */}
+              {this.mobileThumbnail()}
+
+              {/* Show a preview of the post body */}
+              {this.showMobilePreview()}
+
+              {this.commentsLine(true)}
+              {this.duplicatesLine()}
+              {this.postActions()}
+              {this.removeAndBanDialogs()}
+            </div>
+          </div>
+        </div>
+
+        {/* The larger view*/}
+        <div class="d-none d-sm-block">
+          <div class="row">
+            {this.voteBar()}
+            {!this.state.imageExpanded && (
+              <div class="col-sm-2 pr-0">
+                <div class="">{this.thumbnail()}</div>
+              </div>
+            )}
+            <div
+              class={`${
+                this.state.imageExpanded ? 'col-12' : 'col-12 col-sm-9'
+              }`}
+            >
+              <div class="row">
+                <div className="col-12">
+                  {this.postTitleLine()}
+                  {this.createdLine()}
+                  {this.commentsLine()}
+                  {this.duplicatesLine()}
+                  {this.postActions()}
+                  {this.removeAndBanDialogs()}
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </>
+    );
+  }
+
+  private get myPost(): boolean {
+    return (
+      UserService.Instance.user &&
+      this.props.post.creator_id == UserService.Instance.user.id
+    );
+  }
+
+  get isMod(): boolean {
+    return (
+      this.props.moderators &&
+      isMod(
+        this.props.moderators.map(m => m.user_id),
+        this.props.post.creator_id
+      )
+    );
+  }
+
+  get isAdmin(): boolean {
+    return (
+      this.props.admins &&
+      isMod(
+        this.props.admins.map(a => a.id),
+        this.props.post.creator_id
+      )
+    );
+  }
+
+  get canMod(): boolean {
+    if (this.props.admins && this.props.moderators) {
+      let adminsThenMods = this.props.admins
+        .map(a => a.id)
+        .concat(this.props.moderators.map(m => m.user_id));
+
+      return canMod(
+        UserService.Instance.user,
+        adminsThenMods,
+        this.props.post.creator_id
+      );
+    } else {
+      return false;
+    }
+  }
+
+  get canModOnSelf(): boolean {
+    if (this.props.admins && this.props.moderators) {
+      let adminsThenMods = this.props.admins
+        .map(a => a.id)
+        .concat(this.props.moderators.map(m => m.user_id));
+
+      return canMod(
+        UserService.Instance.user,
+        adminsThenMods,
+        this.props.post.creator_id,
+        true
+      );
+    } else {
+      return false;
+    }
+  }
+
+  get canAdmin(): boolean {
+    return (
+      this.props.admins &&
+      canMod(
+        UserService.Instance.user,
+        this.props.admins.map(a => a.id),
+        this.props.post.creator_id
+      )
+    );
+  }
+
+  get amCommunityCreator(): boolean {
+    return (
+      this.props.moderators &&
+      UserService.Instance.user &&
+      this.props.post.creator_id != UserService.Instance.user.id &&
+      UserService.Instance.user.id == this.props.moderators[0].user_id
+    );
+  }
+
+  get amSiteCreator(): boolean {
+    return (
+      this.props.admins &&
+      UserService.Instance.user &&
+      this.props.post.creator_id != UserService.Instance.user.id &&
+      UserService.Instance.user.id == this.props.admins[0].id
+    );
+  }
+
+  handlePostLike(i: PostListing) {
+    if (!UserService.Instance.user) {
+      this.context.router.history.push(`/login`);
+    }
+
+    let new_vote = i.state.my_vote == 1 ? 0 : 1;
+
+    if (i.state.my_vote == 1) {
+      i.state.score--;
+      i.state.upvotes--;
+    } else if (i.state.my_vote == -1) {
+      i.state.downvotes--;
+      i.state.upvotes++;
+      i.state.score += 2;
+    } else {
+      i.state.upvotes++;
+      i.state.score++;
+    }
+
+    i.state.my_vote = new_vote;
+
+    let form: CreatePostLikeForm = {
+      post_id: i.props.post.id,
+      score: i.state.my_vote,
+    };
+
+    WebSocketService.Instance.likePost(form);
+    i.setState(i.state);
+    setupTippy();
+  }
+
+  handlePostDisLike(i: PostListing) {
+    if (!UserService.Instance.user) {
+      this.context.router.history.push(`/login`);
+    }
+
+    let new_vote = i.state.my_vote == -1 ? 0 : -1;
+
+    if (i.state.my_vote == 1) {
+      i.state.score -= 2;
+      i.state.upvotes--;
+      i.state.downvotes++;
+    } else if (i.state.my_vote == -1) {
+      i.state.downvotes--;
+      i.state.score++;
+    } else {
+      i.state.downvotes++;
+      i.state.score--;
+    }
+
+    i.state.my_vote = new_vote;
+
+    let form: CreatePostLikeForm = {
+      post_id: i.props.post.id,
+      score: i.state.my_vote,
+    };
+
+    WebSocketService.Instance.likePost(form);
+    i.setState(i.state);
+    setupTippy();
+  }
+
+  handleEditClick(i: PostListing) {
+    i.state.showEdit = true;
+    i.setState(i.state);
+  }
+
+  handleEditCancel() {
+    this.state.showEdit = false;
+    this.setState(this.state);
+  }
+
+  // The actual editing is done in the recieve for post
+  handleEditPost() {
+    this.state.showEdit = false;
+    this.setState(this.state);
+  }
+
+  handleDeleteClick(i: PostListing) {
+    let deleteForm: DeletePostForm = {
+      edit_id: i.props.post.id,
+      deleted: !i.props.post.deleted,
+      auth: null,
+    };
+    WebSocketService.Instance.deletePost(deleteForm);
+  }
+
+  handleSavePostClick(i: PostListing) {
+    let saved = i.props.post.saved == undefined ? true : !i.props.post.saved;
+    let form: SavePostForm = {
+      post_id: i.props.post.id,
+      save: saved,
+    };
+
+    WebSocketService.Instance.savePost(form);
+  }
+
+  get crossPostParams(): string {
+    let params = `?title=${this.props.post.name}`;
+    let post = this.props.post;
+
+    if (post.url) {
+      params += `&url=${encodeURIComponent(post.url)}`;
+    }
+    if (this.props.post.body) {
+      params += `&body=${this.props.post.body}`;
+    }
+    return params;
+  }
+
+  handleModRemoveShow(i: PostListing) {
+    i.state.showRemoveDialog = true;
+    i.setState(i.state);
+  }
+
+  handleModRemoveReasonChange(i: PostListing, event: any) {
+    i.state.removeReason = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleModRemoveDataChange(i: PostListing, event: any) {
+    i.state.removeData = event.target.checked;
+    i.setState(i.state);
+  }
+
+  handleModRemoveSubmit(i: PostListing) {
+    event.preventDefault();
+    let form: RemovePostForm = {
+      edit_id: i.props.post.id,
+      removed: !i.props.post.removed,
+      reason: i.state.removeReason,
+      auth: null,
+    };
+    WebSocketService.Instance.removePost(form);
+
+    i.state.showRemoveDialog = false;
+    i.setState(i.state);
+  }
+
+  handleModLock(i: PostListing) {
+    let form: LockPostForm = {
+      edit_id: i.props.post.id,
+      locked: !i.props.post.locked,
+      auth: null,
+    };
+    WebSocketService.Instance.lockPost(form);
+  }
+
+  handleModSticky(i: PostListing) {
+    let form: StickyPostForm = {
+      edit_id: i.props.post.id,
+      stickied: !i.props.post.stickied,
+      auth: null,
+    };
+    WebSocketService.Instance.stickyPost(form);
+  }
+
+  handleModBanFromCommunityShow(i: PostListing) {
+    i.state.showBanDialog = true;
+    i.state.banType = BanType.Community;
+    i.setState(i.state);
+  }
+
+  handleModBanShow(i: PostListing) {
+    i.state.showBanDialog = true;
+    i.state.banType = BanType.Site;
+    i.setState(i.state);
+  }
+
+  handleModBanReasonChange(i: PostListing, event: any) {
+    i.state.banReason = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleModBanExpiresChange(i: PostListing, event: any) {
+    i.state.banExpires = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleModBanFromCommunitySubmit(i: PostListing) {
+    i.state.banType = BanType.Community;
+    i.setState(i.state);
+    i.handleModBanBothSubmit(i);
+  }
+
+  handleModBanSubmit(i: PostListing) {
+    i.state.banType = BanType.Site;
+    i.setState(i.state);
+    i.handleModBanBothSubmit(i);
+  }
+
+  handleModBanBothSubmit(i: PostListing) {
+    event.preventDefault();
+
+    if (i.state.banType == BanType.Community) {
+      // If its an unban, restore all their data
+      let ban = !i.props.post.banned_from_community;
+      if (ban == false) {
+        i.state.removeData = false;
+      }
+      let form: BanFromCommunityForm = {
+        user_id: i.props.post.creator_id,
+        community_id: i.props.post.community_id,
+        ban,
+        remove_data: i.state.removeData,
+        reason: i.state.banReason,
+        expires: getUnixTime(i.state.banExpires),
+      };
+      WebSocketService.Instance.banFromCommunity(form);
+    } else {
+      // If its an unban, restore all their data
+      let ban = !i.props.post.banned;
+      if (ban == false) {
+        i.state.removeData = false;
+      }
+      let form: BanUserForm = {
+        user_id: i.props.post.creator_id,
+        ban,
+        remove_data: i.state.removeData,
+        reason: i.state.banReason,
+        expires: getUnixTime(i.state.banExpires),
+      };
+      WebSocketService.Instance.banUser(form);
+    }
+
+    i.state.showBanDialog = false;
+    i.setState(i.state);
+  }
+
+  handleAddModToCommunity(i: PostListing) {
+    let form: AddModToCommunityForm = {
+      user_id: i.props.post.creator_id,
+      community_id: i.props.post.community_id,
+      added: !i.isMod,
+    };
+    WebSocketService.Instance.addModToCommunity(form);
+    i.setState(i.state);
+  }
+
+  handleAddAdmin(i: PostListing) {
+    let form: AddAdminForm = {
+      user_id: i.props.post.creator_id,
+      added: !i.isAdmin,
+    };
+    WebSocketService.Instance.addAdmin(form);
+    i.setState(i.state);
+  }
+
+  handleShowConfirmTransferCommunity(i: PostListing) {
+    i.state.showConfirmTransferCommunity = true;
+    i.setState(i.state);
+  }
+
+  handleCancelShowConfirmTransferCommunity(i: PostListing) {
+    i.state.showConfirmTransferCommunity = false;
+    i.setState(i.state);
+  }
+
+  handleTransferCommunity(i: PostListing) {
+    let form: TransferCommunityForm = {
+      community_id: i.props.post.community_id,
+      user_id: i.props.post.creator_id,
+    };
+    WebSocketService.Instance.transferCommunity(form);
+    i.state.showConfirmTransferCommunity = false;
+    i.setState(i.state);
+  }
+
+  handleShowConfirmTransferSite(i: PostListing) {
+    i.state.showConfirmTransferSite = true;
+    i.setState(i.state);
+  }
+
+  handleCancelShowConfirmTransferSite(i: PostListing) {
+    i.state.showConfirmTransferSite = false;
+    i.setState(i.state);
+  }
+
+  handleTransferSite(i: PostListing) {
+    let form: TransferSiteForm = {
+      user_id: i.props.post.creator_id,
+    };
+    WebSocketService.Instance.transferSite(form);
+    i.state.showConfirmTransferSite = false;
+    i.setState(i.state);
+  }
+
+  handleImageExpandClick(i: PostListing) {
+    i.state.imageExpanded = !i.state.imageExpanded;
+    i.setState(i.state);
+  }
+
+  handleViewSource(i: PostListing) {
+    i.state.viewSource = !i.state.viewSource;
+    i.setState(i.state);
+  }
+
+  handleShowAdvanced(i: PostListing) {
+    i.state.showAdvanced = !i.state.showAdvanced;
+    i.setState(i.state);
+    setupTippy();
+  }
+
+  get pointsTippy(): string {
+    let points = i18n.t('number_of_points', {
+      count: this.state.score,
+    });
+
+    let upvotes = i18n.t('number_of_upvotes', {
+      count: this.state.upvotes,
+    });
+
+    let downvotes = i18n.t('number_of_downvotes', {
+      count: this.state.downvotes,
+    });
+
+    return `${points} • ${upvotes} • ${downvotes}`;
+  }
+}
diff --git a/src/shared/components/post-listings.tsx b/src/shared/components/post-listings.tsx
new file mode 100644 (file)
index 0000000..47aab75
--- /dev/null
@@ -0,0 +1,115 @@
+import { Component } from 'inferno';
+import { Link } from 'inferno-router';
+import { Post, SortType } from 'lemmy-js-client';
+import { postSort } from '../utils';
+import { PostListing } from './post-listing';
+import { i18n } from '../i18next';
+import { T } from 'inferno-i18next';
+
+interface PostListingsProps {
+  posts: Post[];
+  showCommunity?: boolean;
+  removeDuplicates?: boolean;
+  sort?: SortType;
+  enableDownvotes: boolean;
+  enableNsfw: boolean;
+}
+
+export class PostListings extends Component<PostListingsProps, any> {
+  constructor(props: any, context: any) {
+    super(props, context);
+  }
+
+  render() {
+    return (
+      <div>
+        {this.props.posts.length > 0 ? (
+          this.outer().map(post => (
+            <>
+              <PostListing
+                post={post}
+                showCommunity={this.props.showCommunity}
+                enableDownvotes={this.props.enableDownvotes}
+                enableNsfw={this.props.enableNsfw}
+              />
+              <hr class="my-3" />
+            </>
+          ))
+        ) : (
+          <>
+            <div>{i18n.t('no_posts')}</div>
+            {this.props.showCommunity !== undefined && (
+              <T i18nKey="subscribe_to_communities">
+                #<Link to="/communities">#</Link>
+              </T>
+            )}
+          </>
+        )}
+      </div>
+    );
+  }
+
+  outer(): Post[] {
+    let out = this.props.posts;
+    if (this.props.removeDuplicates) {
+      out = this.removeDuplicates(out);
+    }
+
+    if (this.props.sort !== undefined) {
+      postSort(out, this.props.sort, this.props.showCommunity == undefined);
+    }
+
+    return out;
+  }
+
+  removeDuplicates(posts: Post[]): Post[] {
+    // A map from post url to list of posts (dupes)
+    let urlMap = new Map<string, Post[]>();
+
+    // Loop over the posts, find ones with same urls
+    for (let post of posts) {
+      if (
+        post.url &&
+        !post.deleted &&
+        !post.removed &&
+        !post.community_deleted &&
+        !post.community_removed
+      ) {
+        if (!urlMap.get(post.url)) {
+          urlMap.set(post.url, [post]);
+        } else {
+          urlMap.get(post.url).push(post);
+        }
+      }
+    }
+
+    // Sort by oldest
+    // Remove the ones that have no length
+    for (let e of urlMap.entries()) {
+      if (e[1].length == 1) {
+        urlMap.delete(e[0]);
+      } else {
+        e[1].sort((a, b) => a.published.localeCompare(b.published));
+      }
+    }
+
+    for (let i = 0; i < posts.length; i++) {
+      let post = posts[i];
+      if (post.url) {
+        let found = urlMap.get(post.url);
+        if (found) {
+          // If its the oldest, add
+          if (post.id == found[0].id) {
+            post.duplicates = found.slice(1);
+          }
+          // Otherwise, delete it
+          else {
+            posts.splice(i--, 1);
+          }
+        }
+      }
+    }
+
+    return posts;
+  }
+}
diff --git a/src/shared/components/post.tsx b/src/shared/components/post.tsx
new file mode 100644 (file)
index 0000000..d35a77d
--- /dev/null
@@ -0,0 +1,561 @@
+import { Component, linkEvent } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  UserOperation,
+  Community,
+  Post as PostI,
+  GetPostResponse,
+  PostResponse,
+  Comment,
+  MarkCommentAsReadForm,
+  CommentResponse,
+  CommunityUser,
+  CommunityResponse,
+  CommentNode as CommentNodeI,
+  BanFromCommunityResponse,
+  BanUserResponse,
+  AddModToCommunityResponse,
+  AddAdminResponse,
+  SearchType,
+  SortType,
+  SearchForm,
+  GetPostForm,
+  SearchResponse,
+  GetSiteResponse,
+  GetCommunityResponse,
+  WebSocketJsonResponse,
+} from 'lemmy-js-client';
+import { CommentSortType, CommentViewType } from '../interfaces';
+import { WebSocketService, UserService } from '../services';
+import {
+  wsJsonToRes,
+  toast,
+  editCommentRes,
+  saveCommentRes,
+  createCommentLikeRes,
+  createPostLikeRes,
+  commentsToFlatNodes,
+  setupTippy,
+  favIconUrl,
+} from '../utils';
+import { PostListing } from './post-listing';
+import { Sidebar } from './sidebar';
+import { CommentForm } from './comment-form';
+import { CommentNodes } from './comment-nodes';
+import autosize from 'autosize';
+import { i18n } from '../i18next';
+
+interface PostState {
+  post: PostI;
+  comments: Comment[];
+  commentSort: CommentSortType;
+  commentViewType: CommentViewType;
+  community: Community;
+  moderators: CommunityUser[];
+  online: number;
+  scrolled?: boolean;
+  scrolled_comment_id?: number;
+  loading: boolean;
+  crossPosts: PostI[];
+  siteRes: GetSiteResponse;
+}
+
+export class Post extends Component<any, PostState> {
+  private subscription: Subscription;
+  private emptyState: PostState = {
+    post: null,
+    comments: [],
+    commentSort: CommentSortType.Hot,
+    commentViewType: CommentViewType.Tree,
+    community: null,
+    moderators: [],
+    online: null,
+    scrolled: false,
+    loading: true,
+    crossPosts: [],
+    siteRes: {
+      admins: [],
+      banned: [],
+      site: {
+        id: undefined,
+        name: undefined,
+        creator_id: undefined,
+        published: undefined,
+        creator_name: undefined,
+        number_of_users: undefined,
+        number_of_posts: undefined,
+        number_of_comments: undefined,
+        number_of_communities: undefined,
+        enable_downvotes: undefined,
+        open_registration: undefined,
+        enable_nsfw: undefined,
+        icon: undefined,
+        banner: undefined,
+      },
+      online: null,
+      version: null,
+      federated_instances: undefined,
+    },
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+
+    let postId = Number(this.props.match.params.id);
+    if (this.props.match.params.comment_id) {
+      this.state.scrolled_comment_id = this.props.match.params.comment_id;
+    }
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    let form: GetPostForm = {
+      id: postId,
+    };
+    WebSocketService.Instance.getPost(form);
+    WebSocketService.Instance.getSite();
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  componentDidMount() {
+    autosize(document.querySelectorAll('textarea'));
+  }
+
+  componentDidUpdate(_lastProps: any, lastState: PostState, _snapshot: any) {
+    if (
+      this.state.scrolled_comment_id &&
+      !this.state.scrolled &&
+      lastState.comments.length > 0
+    ) {
+      var elmnt = document.getElementById(
+        `comment-${this.state.scrolled_comment_id}`
+      );
+      elmnt.scrollIntoView();
+      elmnt.classList.add('mark');
+      this.state.scrolled = true;
+      this.markScrolledAsRead(this.state.scrolled_comment_id);
+    }
+
+    // Necessary if you are on a post and you click another post (same route)
+    if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
+      // Couldnt get a refresh working. This does for now.
+      location.reload();
+
+      // let currentId = this.props.match.params.id;
+      // WebSocketService.Instance.getPost(currentId);
+      // this.context.router.history.push('/sponsors');
+      // this.context.refresh();
+      // this.context.router.history.push(_lastProps.location.pathname);
+    }
+  }
+
+  markScrolledAsRead(commentId: number) {
+    let found = this.state.comments.find(c => c.id == commentId);
+    let parent = this.state.comments.find(c => found.parent_id == c.id);
+    let parent_user_id = parent
+      ? parent.creator_id
+      : this.state.post.creator_id;
+
+    if (
+      UserService.Instance.user &&
+      UserService.Instance.user.id == parent_user_id
+    ) {
+      let form: MarkCommentAsReadForm = {
+        edit_id: found.id,
+        read: true,
+        auth: null,
+      };
+      WebSocketService.Instance.markCommentAsRead(form);
+      UserService.Instance.unreadCountSub.next(
+        UserService.Instance.unreadCountSub.value - 1
+      );
+    }
+  }
+
+  get documentTitle(): string {
+    if (this.state.post) {
+      return `${this.state.post.name} - ${this.state.siteRes.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  get favIcon(): string {
+    return this.state.siteRes.site.icon
+      ? this.state.siteRes.site.icon
+      : favIconUrl;
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle}>
+          <link
+            id="favicon"
+            rel="icon"
+            type="image/x-icon"
+            href={this.favIcon}
+          />
+        </Helmet>
+        {this.state.loading ? (
+          <h5>
+            <svg class="icon icon-spinner spin">
+              <use xlinkHref="#icon-spinner"></use>
+            </svg>
+          </h5>
+        ) : (
+          <div class="row">
+            <div class="col-12 col-md-8 mb-3">
+              <PostListing
+                post={this.state.post}
+                showBody
+                showCommunity
+                moderators={this.state.moderators}
+                admins={this.state.siteRes.admins}
+                enableDownvotes={this.state.siteRes.site.enable_downvotes}
+                enableNsfw={this.state.siteRes.site.enable_nsfw}
+              />
+              <div className="mb-2" />
+              <CommentForm
+                postId={this.state.post.id}
+                disabled={this.state.post.locked}
+              />
+              {this.state.comments.length > 0 && this.sortRadios()}
+              {this.state.commentViewType == CommentViewType.Tree &&
+                this.commentsTree()}
+              {this.state.commentViewType == CommentViewType.Chat &&
+                this.commentsFlat()}
+            </div>
+            <div class="col-12 col-sm-12 col-md-4">{this.sidebar()}</div>
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  sortRadios() {
+    return (
+      <>
+        <div class="btn-group btn-group-toggle flex-wrap mr-3 mb-2">
+          <label
+            className={`btn btn-outline-secondary pointer ${
+              this.state.commentSort === CommentSortType.Hot && 'active'
+            }`}
+          >
+            {i18n.t('hot')}
+            <input
+              type="radio"
+              value={CommentSortType.Hot}
+              checked={this.state.commentSort === CommentSortType.Hot}
+              onChange={linkEvent(this, this.handleCommentSortChange)}
+            />
+          </label>
+          <label
+            className={`btn btn-outline-secondary pointer ${
+              this.state.commentSort === CommentSortType.Top && 'active'
+            }`}
+          >
+            {i18n.t('top')}
+            <input
+              type="radio"
+              value={CommentSortType.Top}
+              checked={this.state.commentSort === CommentSortType.Top}
+              onChange={linkEvent(this, this.handleCommentSortChange)}
+            />
+          </label>
+          <label
+            className={`btn btn-outline-secondary pointer ${
+              this.state.commentSort === CommentSortType.New && 'active'
+            }`}
+          >
+            {i18n.t('new')}
+            <input
+              type="radio"
+              value={CommentSortType.New}
+              checked={this.state.commentSort === CommentSortType.New}
+              onChange={linkEvent(this, this.handleCommentSortChange)}
+            />
+          </label>
+          <label
+            className={`btn btn-outline-secondary pointer ${
+              this.state.commentSort === CommentSortType.Old && 'active'
+            }`}
+          >
+            {i18n.t('old')}
+            <input
+              type="radio"
+              value={CommentSortType.Old}
+              checked={this.state.commentSort === CommentSortType.Old}
+              onChange={linkEvent(this, this.handleCommentSortChange)}
+            />
+          </label>
+        </div>
+        <div class="btn-group btn-group-toggle flex-wrap mb-2">
+          <label
+            className={`btn btn-outline-secondary pointer ${
+              this.state.commentViewType === CommentViewType.Chat && 'active'
+            }`}
+          >
+            {i18n.t('chat')}
+            <input
+              type="radio"
+              value={CommentViewType.Chat}
+              checked={this.state.commentViewType === CommentViewType.Chat}
+              onChange={linkEvent(this, this.handleCommentViewTypeChange)}
+            />
+          </label>
+        </div>
+      </>
+    );
+  }
+
+  commentsFlat() {
+    return (
+      <div>
+        <CommentNodes
+          nodes={commentsToFlatNodes(this.state.comments)}
+          noIndent
+          locked={this.state.post.locked}
+          moderators={this.state.moderators}
+          admins={this.state.siteRes.admins}
+          postCreatorId={this.state.post.creator_id}
+          showContext
+          enableDownvotes={this.state.siteRes.site.enable_downvotes}
+          sort={this.state.commentSort}
+        />
+      </div>
+    );
+  }
+
+  sidebar() {
+    return (
+      <div class="mb-3">
+        <Sidebar
+          community={this.state.community}
+          moderators={this.state.moderators}
+          admins={this.state.siteRes.admins}
+          online={this.state.online}
+          enableNsfw={this.state.siteRes.site.enable_nsfw}
+          showIcon
+        />
+      </div>
+    );
+  }
+
+  handleCommentSortChange(i: Post, event: any) {
+    i.state.commentSort = Number(event.target.value);
+    i.state.commentViewType = CommentViewType.Tree;
+    i.setState(i.state);
+  }
+
+  handleCommentViewTypeChange(i: Post, event: any) {
+    i.state.commentViewType = Number(event.target.value);
+    i.state.commentSort = CommentSortType.New;
+    i.setState(i.state);
+  }
+
+  buildCommentsTree(): CommentNodeI[] {
+    let map = new Map<number, CommentNodeI>();
+    for (let comment of this.state.comments) {
+      let node: CommentNodeI = {
+        comment: comment,
+        children: [],
+      };
+      map.set(comment.id, { ...node });
+    }
+    let tree: CommentNodeI[] = [];
+    for (let comment of this.state.comments) {
+      let child = map.get(comment.id);
+      if (comment.parent_id) {
+        let parent_ = map.get(comment.parent_id);
+        parent_.children.push(child);
+      } else {
+        tree.push(child);
+      }
+
+      this.setDepth(child);
+    }
+
+    return tree;
+  }
+
+  setDepth(node: CommentNodeI, i: number = 0): void {
+    for (let child of node.children) {
+      child.comment.depth = i;
+      this.setDepth(child, i + 1);
+    }
+  }
+
+  commentsTree() {
+    let nodes = this.buildCommentsTree();
+    return (
+      <div>
+        <CommentNodes
+          nodes={nodes}
+          locked={this.state.post.locked}
+          moderators={this.state.moderators}
+          admins={this.state.siteRes.admins}
+          postCreatorId={this.state.post.creator_id}
+          sort={this.state.commentSort}
+          enableDownvotes={this.state.siteRes.site.enable_downvotes}
+        />
+      </div>
+    );
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      return;
+    } else if (msg.reconnect) {
+      WebSocketService.Instance.getPost({
+        id: Number(this.props.match.params.id),
+      });
+    } else if (res.op == UserOperation.GetPost) {
+      let data = res.data as GetPostResponse;
+      this.state.post = data.post;
+      this.state.comments = data.comments;
+      this.state.community = data.community;
+      this.state.moderators = data.moderators;
+      this.state.online = data.online;
+      this.state.loading = false;
+
+      // Get cross-posts
+      if (this.state.post.url) {
+        let form: SearchForm = {
+          q: this.state.post.url,
+          type_: SearchType.Url,
+          sort: SortType.TopAll,
+          page: 1,
+          limit: 6,
+        };
+        WebSocketService.Instance.search(form);
+      }
+
+      this.setState(this.state);
+      setupTippy();
+    } else if (res.op == UserOperation.CreateComment) {
+      let data = res.data as CommentResponse;
+
+      // Necessary since it might be a user reply
+      if (data.recipient_ids.length == 0) {
+        this.state.comments.unshift(data.comment);
+        this.setState(this.state);
+      }
+    } else if (
+      res.op == UserOperation.EditComment ||
+      res.op == UserOperation.DeleteComment ||
+      res.op == UserOperation.RemoveComment
+    ) {
+      let data = res.data as CommentResponse;
+      editCommentRes(data, this.state.comments);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.SaveComment) {
+      let data = res.data as CommentResponse;
+      saveCommentRes(data, this.state.comments);
+      this.setState(this.state);
+      setupTippy();
+    } else if (res.op == UserOperation.CreateCommentLike) {
+      let data = res.data as CommentResponse;
+      createCommentLikeRes(data, this.state.comments);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreatePostLike) {
+      let data = res.data as PostResponse;
+      createPostLikeRes(data, this.state.post);
+      this.setState(this.state);
+    } else if (
+      res.op == UserOperation.EditPost ||
+      res.op == UserOperation.DeletePost ||
+      res.op == UserOperation.RemovePost ||
+      res.op == UserOperation.LockPost ||
+      res.op == UserOperation.StickyPost
+    ) {
+      let data = res.data as PostResponse;
+      this.state.post = data.post;
+      this.setState(this.state);
+      setupTippy();
+    } else if (res.op == UserOperation.SavePost) {
+      let data = res.data as PostResponse;
+      this.state.post = data.post;
+      this.setState(this.state);
+      setupTippy();
+    } else if (
+      res.op == UserOperation.EditCommunity ||
+      res.op == UserOperation.DeleteCommunity ||
+      res.op == UserOperation.RemoveCommunity
+    ) {
+      let data = res.data as CommunityResponse;
+      this.state.community = data.community;
+      this.state.post.community_id = data.community.id;
+      this.state.post.community_name = data.community.name;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.FollowCommunity) {
+      let data = res.data as CommunityResponse;
+      this.state.community.subscribed = data.community.subscribed;
+      this.state.community.number_of_subscribers =
+        data.community.number_of_subscribers;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.BanFromCommunity) {
+      let data = res.data as BanFromCommunityResponse;
+      this.state.comments
+        .filter(c => c.creator_id == data.user.id)
+        .forEach(c => (c.banned_from_community = data.banned));
+      if (this.state.post.creator_id == data.user.id) {
+        this.state.post.banned_from_community = data.banned;
+      }
+      this.setState(this.state);
+    } else if (res.op == UserOperation.AddModToCommunity) {
+      let data = res.data as AddModToCommunityResponse;
+      this.state.moderators = data.moderators;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.BanUser) {
+      let data = res.data as BanUserResponse;
+      this.state.comments
+        .filter(c => c.creator_id == data.user.id)
+        .forEach(c => (c.banned = data.banned));
+      if (this.state.post.creator_id == data.user.id) {
+        this.state.post.banned = data.banned;
+      }
+      this.setState(this.state);
+    } else if (res.op == UserOperation.AddAdmin) {
+      let data = res.data as AddAdminResponse;
+      this.state.siteRes.admins = data.admins;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.Search) {
+      let data = res.data as SearchResponse;
+      this.state.crossPosts = data.posts.filter(
+        p => p.id != Number(this.props.match.params.id)
+      );
+      if (this.state.crossPosts.length) {
+        this.state.post.duplicates = this.state.crossPosts;
+      }
+      this.setState(this.state);
+    } else if (
+      res.op == UserOperation.TransferSite ||
+      res.op == UserOperation.GetSite
+    ) {
+      let data = res.data as GetSiteResponse;
+      this.state.siteRes = data;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.TransferCommunity) {
+      let data = res.data as GetCommunityResponse;
+      this.state.community = data.community;
+      this.state.moderators = data.moderators;
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/private-message-form.tsx b/src/shared/components/private-message-form.tsx
new file mode 100644 (file)
index 0000000..6d7825c
--- /dev/null
@@ -0,0 +1,288 @@
+import { Component, linkEvent } from 'inferno';
+import { Prompt } from 'inferno-router';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  PrivateMessageForm as PrivateMessageFormI,
+  EditPrivateMessageForm,
+  PrivateMessageFormParams,
+  PrivateMessage,
+  PrivateMessageResponse,
+  UserView,
+  UserOperation,
+  UserDetailsResponse,
+  GetUserDetailsForm,
+  SortType,
+  WebSocketJsonResponse,
+} from 'lemmy-js-client';
+import { WebSocketService } from '../services';
+import {
+  capitalizeFirstLetter,
+  wsJsonToRes,
+  toast,
+  setupTippy,
+} from '../utils';
+import { UserListing } from './user-listing';
+import { MarkdownTextArea } from './markdown-textarea';
+import { i18n } from '../i18next';
+import { T } from 'inferno-i18next';
+
+interface PrivateMessageFormProps {
+  privateMessage?: PrivateMessage; // If a pm is given, that means this is an edit
+  params?: PrivateMessageFormParams;
+  onCancel?(): any;
+  onCreate?(message: PrivateMessage): any;
+  onEdit?(message: PrivateMessage): any;
+}
+
+interface PrivateMessageFormState {
+  privateMessageForm: PrivateMessageFormI;
+  recipient: UserView;
+  loading: boolean;
+  previewMode: boolean;
+  showDisclaimer: boolean;
+}
+
+export class PrivateMessageForm extends Component<
+  PrivateMessageFormProps,
+  PrivateMessageFormState
+> {
+  private subscription: Subscription;
+  private emptyState: PrivateMessageFormState = {
+    privateMessageForm: {
+      content: null,
+      recipient_id: null,
+    },
+    recipient: null,
+    loading: false,
+    previewMode: false,
+    showDisclaimer: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+
+    this.handleContentChange = this.handleContentChange.bind(this);
+
+    if (this.props.privateMessage) {
+      this.state.privateMessageForm = {
+        content: this.props.privateMessage.content,
+        recipient_id: this.props.privateMessage.recipient_id,
+      };
+    }
+
+    if (this.props.params) {
+      this.state.privateMessageForm.recipient_id = this.props.params.recipient_id;
+      let form: GetUserDetailsForm = {
+        user_id: this.state.privateMessageForm.recipient_id,
+        sort: SortType.New,
+        saved_only: false,
+      };
+      WebSocketService.Instance.getUserDetails(form);
+    }
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+  }
+
+  componentDidMount() {
+    setupTippy();
+  }
+
+  componentDidUpdate() {
+    if (!this.state.loading && this.state.privateMessageForm.content) {
+      window.onbeforeunload = () => true;
+    } else {
+      window.onbeforeunload = undefined;
+    }
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+    window.onbeforeunload = null;
+  }
+
+  render() {
+    return (
+      <div>
+        <Prompt
+          when={!this.state.loading && this.state.privateMessageForm.content}
+          message={i18n.t('block_leaving')}
+        />
+        <form onSubmit={linkEvent(this, this.handlePrivateMessageSubmit)}>
+          {!this.props.privateMessage && (
+            <div class="form-group row">
+              <label class="col-sm-2 col-form-label">
+                {capitalizeFirstLetter(i18n.t('to'))}
+              </label>
+
+              {this.state.recipient && (
+                <div class="col-sm-10 form-control-plaintext">
+                  <UserListing
+                    user={{
+                      name: this.state.recipient.name,
+                      preferred_username: this.state.recipient
+                        .preferred_username,
+                      avatar: this.state.recipient.avatar,
+                      id: this.state.recipient.id,
+                      local: this.state.recipient.local,
+                      actor_id: this.state.recipient.actor_id,
+                    }}
+                  />
+                </div>
+              )}
+            </div>
+          )}
+          <div class="form-group row">
+            <label class="col-sm-2 col-form-label">
+              {i18n.t('message')}
+              <span
+                onClick={linkEvent(this, this.handleShowDisclaimer)}
+                class="ml-2 pointer text-danger"
+                data-tippy-content={i18n.t('disclaimer')}
+              >
+                <svg class={`icon icon-inline`}>
+                  <use xlinkHref="#icon-alert-triangle"></use>
+                </svg>
+              </span>
+            </label>
+            <div class="col-sm-10">
+              <MarkdownTextArea
+                initialContent={this.state.privateMessageForm.content}
+                onContentChange={this.handleContentChange}
+              />
+            </div>
+          </div>
+
+          {this.state.showDisclaimer && (
+            <div class="form-group row">
+              <div class="offset-sm-2 col-sm-10">
+                <div class="alert alert-danger" role="alert">
+                  <T i18nKey="private_message_disclaimer">
+                    #
+                    <a
+                      class="alert-link"
+                      target="_blank"
+                      rel="noopener"
+                      href="https://element.io/get-started"
+                    >
+                      #
+                    </a>
+                  </T>
+                </div>
+              </div>
+            </div>
+          )}
+          <div class="form-group row">
+            <div class="offset-sm-2 col-sm-10">
+              <button
+                type="submit"
+                class="btn btn-secondary mr-2"
+                disabled={this.state.loading}
+              >
+                {this.state.loading ? (
+                  <svg class="icon icon-spinner spin">
+                    <use xlinkHref="#icon-spinner"></use>
+                  </svg>
+                ) : this.props.privateMessage ? (
+                  capitalizeFirstLetter(i18n.t('save'))
+                ) : (
+                  capitalizeFirstLetter(i18n.t('send_message'))
+                )}
+              </button>
+              {this.props.privateMessage && (
+                <button
+                  type="button"
+                  class="btn btn-secondary"
+                  onClick={linkEvent(this, this.handleCancel)}
+                >
+                  {i18n.t('cancel')}
+                </button>
+              )}
+              <ul class="d-inline-block float-right list-inline mb-1 text-muted font-weight-bold">
+                <li class="list-inline-item"></li>
+              </ul>
+            </div>
+          </div>
+        </form>
+      </div>
+    );
+  }
+
+  handlePrivateMessageSubmit(i: PrivateMessageForm, event: any) {
+    event.preventDefault();
+    if (i.props.privateMessage) {
+      let editForm: EditPrivateMessageForm = {
+        edit_id: i.props.privateMessage.id,
+        content: i.state.privateMessageForm.content,
+      };
+      WebSocketService.Instance.editPrivateMessage(editForm);
+    } else {
+      WebSocketService.Instance.createPrivateMessage(
+        i.state.privateMessageForm
+      );
+    }
+    i.state.loading = true;
+    i.setState(i.state);
+  }
+
+  handleRecipientChange(i: PrivateMessageForm, event: any) {
+    i.state.recipient = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleContentChange(val: string) {
+    this.state.privateMessageForm.content = val;
+    this.setState(this.state);
+  }
+
+  handleCancel(i: PrivateMessageForm) {
+    i.props.onCancel();
+  }
+
+  handlePreviewToggle(i: PrivateMessageForm, event: any) {
+    event.preventDefault();
+    i.state.previewMode = !i.state.previewMode;
+    i.setState(i.state);
+  }
+
+  handleShowDisclaimer(i: PrivateMessageForm) {
+    i.state.showDisclaimer = !i.state.showDisclaimer;
+    i.setState(i.state);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      this.state.loading = false;
+      this.setState(this.state);
+      return;
+    } else if (
+      res.op == UserOperation.EditPrivateMessage ||
+      res.op == UserOperation.DeletePrivateMessage ||
+      res.op == UserOperation.MarkPrivateMessageAsRead
+    ) {
+      let data = res.data as PrivateMessageResponse;
+      this.state.loading = false;
+      this.props.onEdit(data.message);
+    } else if (res.op == UserOperation.GetUserDetails) {
+      let data = res.data as UserDetailsResponse;
+      this.state.recipient = data.user;
+      this.state.privateMessageForm.recipient_id = data.user.id;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreatePrivateMessage) {
+      let data = res.data as PrivateMessageResponse;
+      this.state.loading = false;
+      this.props.onCreate(data.message);
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/private-message.tsx b/src/shared/components/private-message.tsx
new file mode 100644 (file)
index 0000000..243d12e
--- /dev/null
@@ -0,0 +1,292 @@
+import { Component, linkEvent } from 'inferno';
+import { Link } from 'inferno-router';
+import {
+  PrivateMessage as PrivateMessageI,
+  DeletePrivateMessageForm,
+  MarkPrivateMessageAsReadForm,
+} from 'lemmy-js-client';
+import { WebSocketService, UserService } from '../services';
+import { mdToHtml, pictrsAvatarThumbnail, showAvatars, toast } from '../utils';
+import { MomentTime } from './moment-time';
+import { PrivateMessageForm } from './private-message-form';
+import { UserListing, UserOther } from './user-listing';
+import { i18n } from '../i18next';
+
+interface PrivateMessageState {
+  showReply: boolean;
+  showEdit: boolean;
+  collapsed: boolean;
+  viewSource: boolean;
+}
+
+interface PrivateMessageProps {
+  privateMessage: PrivateMessageI;
+}
+
+export class PrivateMessage extends Component<
+  PrivateMessageProps,
+  PrivateMessageState
+> {
+  private emptyState: PrivateMessageState = {
+    showReply: false,
+    showEdit: false,
+    collapsed: false,
+    viewSource: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+    this.handleReplyCancel = this.handleReplyCancel.bind(this);
+    this.handlePrivateMessageCreate = this.handlePrivateMessageCreate.bind(
+      this
+    );
+    this.handlePrivateMessageEdit = this.handlePrivateMessageEdit.bind(this);
+  }
+
+  get mine(): boolean {
+    return (
+      UserService.Instance.user &&
+      UserService.Instance.user.id == this.props.privateMessage.creator_id
+    );
+  }
+
+  render() {
+    let message = this.props.privateMessage;
+    let userOther: UserOther = this.mine
+      ? {
+          name: message.recipient_name,
+          preferred_username: message.recipient_preferred_username,
+          id: message.id,
+          avatar: message.recipient_avatar,
+          local: message.recipient_local,
+          actor_id: message.recipient_actor_id,
+          published: message.published,
+        }
+      : {
+          name: message.creator_name,
+          preferred_username: message.creator_preferred_username,
+          id: message.id,
+          avatar: message.creator_avatar,
+          local: message.creator_local,
+          actor_id: message.creator_actor_id,
+          published: message.published,
+        };
+
+    return (
+      <div class="border-top border-light">
+        <div>
+          <ul class="list-inline mb-0 text-muted small">
+            {/* TODO refactor this */}
+            <li className="list-inline-item">
+              {this.mine ? i18n.t('to') : i18n.t('from')}
+            </li>
+            <li className="list-inline-item">
+              <UserListing user={userOther} />
+            </li>
+            <li className="list-inline-item">
+              <span>
+                <MomentTime data={message} />
+              </span>
+            </li>
+            <li className="list-inline-item">
+              <div
+                className="pointer text-monospace"
+                onClick={linkEvent(this, this.handleMessageCollapse)}
+              >
+                {this.state.collapsed ? (
+                  <svg class="icon icon-inline">
+                    <use xlinkHref="#icon-plus-square"></use>
+                  </svg>
+                ) : (
+                  <svg class="icon icon-inline">
+                    <use xlinkHref="#icon-minus-square"></use>
+                  </svg>
+                )}
+              </div>
+            </li>
+          </ul>
+          {this.state.showEdit && (
+            <PrivateMessageForm
+              privateMessage={message}
+              onEdit={this.handlePrivateMessageEdit}
+              onCreate={this.handlePrivateMessageCreate}
+              onCancel={this.handleReplyCancel}
+            />
+          )}
+          {!this.state.showEdit && !this.state.collapsed && (
+            <div>
+              {this.state.viewSource ? (
+                <pre>{this.messageUnlessRemoved}</pre>
+              ) : (
+                <div
+                  className="md-div"
+                  dangerouslySetInnerHTML={mdToHtml(this.messageUnlessRemoved)}
+                />
+              )}
+              <ul class="list-inline mb-0 text-muted font-weight-bold">
+                {!this.mine && (
+                  <>
+                    <li className="list-inline-item">
+                      <button
+                        class="btn btn-link btn-animate text-muted"
+                        onClick={linkEvent(this, this.handleMarkRead)}
+                        data-tippy-content={
+                          message.read
+                            ? i18n.t('mark_as_unread')
+                            : i18n.t('mark_as_read')
+                        }
+                      >
+                        <svg
+                          class={`icon icon-inline ${
+                            message.read && 'text-success'
+                          }`}
+                        >
+                          <use xlinkHref="#icon-check"></use>
+                        </svg>
+                      </button>
+                    </li>
+                    <li className="list-inline-item">
+                      <button
+                        class="btn btn-link btn-animate text-muted"
+                        onClick={linkEvent(this, this.handleReplyClick)}
+                        data-tippy-content={i18n.t('reply')}
+                      >
+                        <svg class="icon icon-inline">
+                          <use xlinkHref="#icon-reply1"></use>
+                        </svg>
+                      </button>
+                    </li>
+                  </>
+                )}
+                {this.mine && (
+                  <>
+                    <li className="list-inline-item">
+                      <button
+                        class="btn btn-link btn-animate text-muted"
+                        onClick={linkEvent(this, this.handleEditClick)}
+                        data-tippy-content={i18n.t('edit')}
+                      >
+                        <svg class="icon icon-inline">
+                          <use xlinkHref="#icon-edit"></use>
+                        </svg>
+                      </button>
+                    </li>
+                    <li className="list-inline-item">
+                      <button
+                        class="btn btn-link btn-animate text-muted"
+                        onClick={linkEvent(this, this.handleDeleteClick)}
+                        data-tippy-content={
+                          !message.deleted
+                            ? i18n.t('delete')
+                            : i18n.t('restore')
+                        }
+                      >
+                        <svg
+                          class={`icon icon-inline ${
+                            message.deleted && 'text-danger'
+                          }`}
+                        >
+                          <use xlinkHref="#icon-trash"></use>
+                        </svg>
+                      </button>
+                    </li>
+                  </>
+                )}
+                <li className="list-inline-item">
+                  <button
+                    class="btn btn-link btn-animate text-muted"
+                    onClick={linkEvent(this, this.handleViewSource)}
+                    data-tippy-content={i18n.t('view_source')}
+                  >
+                    <svg
+                      class={`icon icon-inline ${
+                        this.state.viewSource && 'text-success'
+                      }`}
+                    >
+                      <use xlinkHref="#icon-file-text"></use>
+                    </svg>
+                  </button>
+                </li>
+              </ul>
+            </div>
+          )}
+        </div>
+        {this.state.showReply && (
+          <PrivateMessageForm
+            params={{
+              recipient_id: this.props.privateMessage.creator_id,
+            }}
+            onCreate={this.handlePrivateMessageCreate}
+          />
+        )}
+        {/* A collapsed clearfix */}
+        {this.state.collapsed && <div class="row col-12"></div>}
+      </div>
+    );
+  }
+
+  get messageUnlessRemoved(): string {
+    let message = this.props.privateMessage;
+    return message.deleted ? `*${i18n.t('deleted')}*` : message.content;
+  }
+
+  handleReplyClick(i: PrivateMessage) {
+    i.state.showReply = true;
+    i.setState(i.state);
+  }
+
+  handleEditClick(i: PrivateMessage) {
+    i.state.showEdit = true;
+    i.setState(i.state);
+  }
+
+  handleDeleteClick(i: PrivateMessage) {
+    let form: DeletePrivateMessageForm = {
+      edit_id: i.props.privateMessage.id,
+      deleted: !i.props.privateMessage.deleted,
+    };
+    WebSocketService.Instance.deletePrivateMessage(form);
+  }
+
+  handleReplyCancel() {
+    this.state.showReply = false;
+    this.state.showEdit = false;
+    this.setState(this.state);
+  }
+
+  handleMarkRead(i: PrivateMessage) {
+    let form: MarkPrivateMessageAsReadForm = {
+      edit_id: i.props.privateMessage.id,
+      read: !i.props.privateMessage.read,
+    };
+    WebSocketService.Instance.markPrivateMessageAsRead(form);
+  }
+
+  handleMessageCollapse(i: PrivateMessage) {
+    i.state.collapsed = !i.state.collapsed;
+    i.setState(i.state);
+  }
+
+  handleViewSource(i: PrivateMessage) {
+    i.state.viewSource = !i.state.viewSource;
+    i.setState(i.state);
+  }
+
+  handlePrivateMessageEdit() {
+    this.state.showEdit = false;
+    this.setState(this.state);
+  }
+
+  handlePrivateMessageCreate(message: PrivateMessageI) {
+    if (
+      UserService.Instance.user &&
+      message.creator_id == UserService.Instance.user.id
+    ) {
+      this.state.showReply = false;
+      this.setState(this.state);
+      toast(i18n.t('message_sent'));
+    }
+  }
+}
diff --git a/src/shared/components/search.tsx b/src/shared/components/search.tsx
new file mode 100644 (file)
index 0000000..fa9f3f6
--- /dev/null
@@ -0,0 +1,536 @@
+import { Component, linkEvent } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  UserOperation,
+  Post,
+  Comment,
+  Community,
+  UserView,
+  SortType,
+  SearchForm,
+  SearchResponse,
+  SearchType,
+  PostResponse,
+  CommentResponse,
+  WebSocketJsonResponse,
+  GetSiteResponse,
+  Site,
+} from 'lemmy-js-client';
+import { WebSocketService } from '../services';
+import {
+  wsJsonToRes,
+  fetchLimit,
+  routeSearchTypeToEnum,
+  routeSortTypeToEnum,
+  toast,
+  createCommentLikeRes,
+  createPostLikeFindRes,
+  commentsToFlatNodes,
+  getPageFromProps,
+} from '../utils';
+import { PostListing } from './post-listing';
+import { UserListing } from './user-listing';
+import { CommunityLink } from './community-link';
+import { SortSelect } from './sort-select';
+import { CommentNodes } from './comment-nodes';
+import { i18n } from '../i18next';
+
+interface SearchState {
+  q: string;
+  type_: SearchType;
+  sort: SortType;
+  page: number;
+  searchResponse: SearchResponse;
+  loading: boolean;
+  site: Site;
+  searchText: string;
+}
+
+interface SearchProps {
+  q: string;
+  type_: SearchType;
+  sort: SortType;
+  page: number;
+}
+
+interface UrlParams {
+  q?: string;
+  type_?: SearchType;
+  sort?: SortType;
+  page?: number;
+}
+
+export class Search extends Component<any, SearchState> {
+  private subscription: Subscription;
+  private emptyState: SearchState = {
+    q: Search.getSearchQueryFromProps(this.props),
+    type_: Search.getSearchTypeFromProps(this.props),
+    sort: Search.getSortTypeFromProps(this.props),
+    page: getPageFromProps(this.props),
+    searchText: Search.getSearchQueryFromProps(this.props),
+    searchResponse: {
+      type_: null,
+      posts: [],
+      comments: [],
+      communities: [],
+      users: [],
+    },
+    loading: false,
+    site: {
+      id: undefined,
+      name: undefined,
+      creator_id: undefined,
+      published: undefined,
+      creator_name: undefined,
+      number_of_users: undefined,
+      number_of_posts: undefined,
+      number_of_comments: undefined,
+      number_of_communities: undefined,
+      enable_downvotes: undefined,
+      open_registration: undefined,
+      enable_nsfw: undefined,
+    },
+  };
+
+  static getSearchQueryFromProps(props: any): string {
+    return props.match.params.q ? props.match.params.q : '';
+  }
+
+  static getSearchTypeFromProps(props: any): SearchType {
+    return props.match.params.type
+      ? routeSearchTypeToEnum(props.match.params.type)
+      : SearchType.All;
+  }
+
+  static getSortTypeFromProps(props: any): SortType {
+    return props.match.params.sort
+      ? routeSortTypeToEnum(props.match.params.sort)
+      : SortType.TopAll;
+  }
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+    this.handleSortChange = this.handleSortChange.bind(this);
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    WebSocketService.Instance.getSite();
+
+    if (this.state.q) {
+      this.search();
+    }
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  static getDerivedStateFromProps(props: any): SearchProps {
+    return {
+      q: Search.getSearchQueryFromProps(props),
+      type_: Search.getSearchTypeFromProps(props),
+      sort: Search.getSortTypeFromProps(props),
+      page: getPageFromProps(props),
+    };
+  }
+
+  componentDidUpdate(_: any, lastState: SearchState) {
+    if (
+      lastState.q !== this.state.q ||
+      lastState.type_ !== this.state.type_ ||
+      lastState.sort !== this.state.sort ||
+      lastState.page !== this.state.page
+    ) {
+      this.setState({ loading: true, searchText: this.state.q });
+      this.search();
+    }
+  }
+
+  get documentTitle(): string {
+    if (this.state.site.name) {
+      if (this.state.q) {
+        return `${i18n.t('search')} - ${this.state.q} - ${
+          this.state.site.name
+        }`;
+      } else {
+        return `${i18n.t('search')} - ${this.state.site.name}`;
+      }
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle} />
+        <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.resultsCount() == 0 && <span>{i18n.t('no_results')}</span>}
+        {this.paginator()}
+      </div>
+    );
+  }
+
+  searchForm() {
+    return (
+      <form
+        class="form-inline"
+        onSubmit={linkEvent(this, this.handleSearchSubmit)}
+      >
+        <input
+          type="text"
+          class="form-control mr-2 mb-2"
+          value={this.state.searchText}
+          placeholder={`${i18n.t('search')}...`}
+          onInput={linkEvent(this, this.handleQChange)}
+          required
+          minLength={3}
+        />
+        <button type="submit" class="btn btn-secondary mr-2 mb-2">
+          {this.state.loading ? (
+            <svg class="icon icon-spinner spin">
+              <use xlinkHref="#icon-spinner"></use>
+            </svg>
+          ) : (
+            <span>{i18n.t('search')}</span>
+          )}
+        </button>
+      </form>
+    );
+  }
+
+  selects() {
+    return (
+      <div className="mb-2">
+        <select
+          value={this.state.type_}
+          onChange={linkEvent(this, this.handleTypeChange)}
+          class="custom-select w-auto mb-2"
+        >
+          <option disabled>{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>
+        </select>
+        <span class="ml-2">
+          <SortSelect
+            sort={this.state.sort}
+            onChange={this.handleSortChange}
+            hideHot
+          />
+        </span>
+      </div>
+    );
+  }
+
+  all() {
+    let combined: {
+      type_: string;
+      data: Comment | Post | Community | UserView;
+    }[] = [];
+    let comments = this.state.searchResponse.comments.map(e => {
+      return { type_: 'comments', data: e };
+    });
+    let posts = this.state.searchResponse.posts.map(e => {
+      return { type_: 'posts', data: e };
+    });
+    let communities = this.state.searchResponse.communities.map(e => {
+      return { type_: 'communities', data: e };
+    });
+    let users = this.state.searchResponse.users.map(e => {
+      return { type_: 'users', data: e };
+    });
+
+    combined.push(...comments);
+    combined.push(...posts);
+    combined.push(...communities);
+    combined.push(...users);
+
+    // Sort it
+    if (this.state.sort == SortType.New) {
+      combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
+    } else {
+      combined.sort(
+        (a, b) =>
+          ((b.data as Comment | Post).score |
+            (b.data as Community).number_of_subscribers |
+            (b.data as UserView).comment_score) -
+          ((a.data as Comment | Post).score |
+            (a.data as Community).number_of_subscribers |
+            (a.data as UserView).comment_score)
+      );
+    }
+
+    return (
+      <div>
+        {combined.map(i => (
+          <div class="row">
+            <div class="col-12">
+              {i.type_ == 'posts' && (
+                <PostListing
+                  key={(i.data as Post).id}
+                  post={i.data as Post}
+                  showCommunity
+                  enableDownvotes={this.state.site.enable_downvotes}
+                  enableNsfw={this.state.site.enable_nsfw}
+                />
+              )}
+              {i.type_ == 'comments' && (
+                <CommentNodes
+                  key={(i.data as Comment).id}
+                  nodes={[{ comment: i.data as Comment }]}
+                  locked
+                  noIndent
+                  enableDownvotes={this.state.site.enable_downvotes}
+                />
+              )}
+              {i.type_ == 'communities' && (
+                <div>{this.communityListing(i.data as Community)}</div>
+              )}
+              {i.type_ == 'users' && (
+                <div>
+                  <span>
+                    @
+                    <UserListing
+                      user={{
+                        name: (i.data as UserView).name,
+                        preferred_username: (i.data as UserView)
+                          .preferred_username,
+                        avatar: (i.data as UserView).avatar,
+                      }}
+                    />
+                  </span>
+                  <span>{` - ${i18n.t('number_of_comments', {
+                    count: (i.data as UserView).number_of_comments,
+                  })}`}</span>
+                </div>
+              )}
+            </div>
+          </div>
+        ))}
+      </div>
+    );
+  }
+
+  comments() {
+    return (
+      <CommentNodes
+        nodes={commentsToFlatNodes(this.state.searchResponse.comments)}
+        locked
+        noIndent
+        enableDownvotes={this.state.site.enable_downvotes}
+      />
+    );
+  }
+
+  posts() {
+    return (
+      <>
+        {this.state.searchResponse.posts.map(post => (
+          <div class="row">
+            <div class="col-12">
+              <PostListing
+                post={post}
+                showCommunity
+                enableDownvotes={this.state.site.enable_downvotes}
+                enableNsfw={this.state.site.enable_nsfw}
+              />
+            </div>
+          </div>
+        ))}
+      </>
+    );
+  }
+
+  // Todo possibly create UserListing and CommunityListing
+  communities() {
+    return (
+      <>
+        {this.state.searchResponse.communities.map(community => (
+          <div class="row">
+            <div class="col-12">{this.communityListing(community)}</div>
+          </div>
+        ))}
+      </>
+    );
+  }
+
+  communityListing(community: Community) {
+    return (
+      <>
+        <span>
+          <CommunityLink community={community} />
+        </span>
+        <span>{` - ${community.title} - 
+        ${i18n.t('number_of_subscribers', {
+          count: community.number_of_subscribers,
+        })}
+      `}</span>
+      </>
+    );
+  }
+
+  users() {
+    return (
+      <>
+        {this.state.searchResponse.users.map(user => (
+          <div class="row">
+            <div class="col-12">
+              <span>
+                @
+                <UserListing
+                  user={{
+                    name: user.name,
+                    avatar: user.avatar,
+                  }}
+                />
+              </span>
+              <span>{` - ${i18n.t('number_of_comments', {
+                count: user.number_of_comments,
+              })}`}</span>
+            </div>
+          </div>
+        ))}
+      </>
+    );
+  }
+
+  paginator() {
+    return (
+      <div class="mt-2">
+        {this.state.page > 1 && (
+          <button
+            class="btn btn-secondary mr-1"
+            onClick={linkEvent(this, this.prevPage)}
+          >
+            {i18n.t('prev')}
+          </button>
+        )}
+
+        {this.resultsCount() > 0 && (
+          <button
+            class="btn btn-secondary"
+            onClick={linkEvent(this, this.nextPage)}
+          >
+            {i18n.t('next')}
+          </button>
+        )}
+      </div>
+    );
+  }
+
+  resultsCount(): number {
+    let res = this.state.searchResponse;
+    return (
+      res.posts.length +
+      res.comments.length +
+      res.communities.length +
+      res.users.length
+    );
+  }
+
+  nextPage(i: Search) {
+    i.updateUrl({ page: i.state.page + 1 });
+  }
+
+  prevPage(i: Search) {
+    i.updateUrl({ page: i.state.page - 1 });
+  }
+
+  search() {
+    let form: SearchForm = {
+      q: this.state.q,
+      type_: this.state.type_,
+      sort: this.state.sort,
+      page: this.state.page,
+      limit: fetchLimit,
+    };
+
+    if (this.state.q != '') {
+      WebSocketService.Instance.search(form);
+    }
+  }
+
+  handleSortChange(val: SortType) {
+    this.updateUrl({ sort: val, page: 1 });
+  }
+
+  handleTypeChange(i: Search, event: any) {
+    i.updateUrl({
+      type_: SearchType[event.target.value],
+      page: 1,
+    });
+  }
+
+  handleSearchSubmit(i: Search, event: any) {
+    event.preventDefault();
+    i.updateUrl({
+      q: i.state.searchText,
+      type_: i.state.type_,
+      sort: i.state.sort,
+      page: i.state.page,
+    });
+  }
+
+  handleQChange(i: Search, event: any) {
+    i.setState({ searchText: event.target.value });
+  }
+
+  updateUrl(paramUpdates: UrlParams) {
+    const qStr = paramUpdates.q || this.state.q;
+    const typeStr = paramUpdates.type_ || this.state.type_;
+    const sortStr = paramUpdates.sort || this.state.sort;
+    const page = paramUpdates.page || this.state.page;
+    this.props.history.push(
+      `/search/q/${qStr}/type/${typeStr}/sort/${sortStr}/page/${page}`
+    );
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      return;
+    } else if (res.op == UserOperation.Search) {
+      let data = res.data as SearchResponse;
+      this.state.searchResponse = data;
+      this.state.loading = false;
+      window.scrollTo(0, 0);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreateCommentLike) {
+      let data = res.data as CommentResponse;
+      createCommentLikeRes(data, this.state.searchResponse.comments);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreatePostLike) {
+      let data = res.data as PostResponse;
+      createPostLikeFindRes(data, this.state.searchResponse.posts);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+      this.state.site = data.site;
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/setup.tsx b/src/shared/components/setup.tsx
new file mode 100644 (file)
index 0000000..6360ec5
--- /dev/null
@@ -0,0 +1,211 @@
+import { Component, linkEvent } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  RegisterForm,
+  LoginResponse,
+  UserOperation,
+  WebSocketJsonResponse,
+} from 'lemmy-js-client';
+import { WebSocketService, UserService } from '../services';
+import { wsJsonToRes, toast } from '../utils';
+import { SiteForm } from './site-form';
+import { i18n } from '../i18next';
+
+interface State {
+  userForm: RegisterForm;
+  doneRegisteringUser: boolean;
+  userLoading: boolean;
+}
+
+export class Setup extends Component<any, State> {
+  private subscription: Subscription;
+
+  private emptyState: State = {
+    userForm: {
+      username: undefined,
+      password: undefined,
+      password_verify: undefined,
+      admin: true,
+      show_nsfw: true,
+      // The first admin signup doesn't need a captcha
+      captcha_uuid: '',
+      captcha_answer: '',
+    },
+    doneRegisteringUser: false,
+    userLoading: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  get documentTitle(): string {
+    return `${i18n.t('setup')} - Lemmy`;
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle} />
+        <div class="row">
+          <div class="col-12 offset-lg-3 col-lg-6">
+            <h3>{i18n.t('lemmy_instance_setup')}</h3>
+            {!this.state.doneRegisteringUser ? (
+              this.registerUser()
+            ) : (
+              <SiteForm />
+            )}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  registerUser() {
+    return (
+      <form onSubmit={linkEvent(this, this.handleRegisterSubmit)}>
+        <h5>{i18n.t('setup_admin')}</h5>
+        <div class="form-group row">
+          <label class="col-sm-2 col-form-label" htmlFor="username">
+            {i18n.t('username')}
+          </label>
+          <div class="col-sm-10">
+            <input
+              type="text"
+              class="form-control"
+              id="username"
+              value={this.state.userForm.username}
+              onInput={linkEvent(this, this.handleRegisterUsernameChange)}
+              required
+              minLength={3}
+              maxLength={20}
+              pattern="[a-zA-Z0-9_]+"
+            />
+          </div>
+        </div>
+        <div class="form-group row">
+          <label class="col-sm-2 col-form-label" htmlFor="email">
+            {i18n.t('email')}
+          </label>
+
+          <div class="col-sm-10">
+            <input
+              type="email"
+              id="email"
+              class="form-control"
+              placeholder={i18n.t('optional')}
+              value={this.state.userForm.email}
+              onInput={linkEvent(this, this.handleRegisterEmailChange)}
+              minLength={3}
+            />
+          </div>
+        </div>
+        <div class="form-group row">
+          <label class="col-sm-2 col-form-label" htmlFor="password">
+            {i18n.t('password')}
+          </label>
+          <div class="col-sm-10">
+            <input
+              type="password"
+              id="password"
+              value={this.state.userForm.password}
+              onInput={linkEvent(this, this.handleRegisterPasswordChange)}
+              class="form-control"
+              required
+            />
+          </div>
+        </div>
+        <div class="form-group row">
+          <label class="col-sm-2 col-form-label" htmlFor="verify-password">
+            {i18n.t('verify_password')}
+          </label>
+          <div class="col-sm-10">
+            <input
+              type="password"
+              id="verify-password"
+              value={this.state.userForm.password_verify}
+              onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)}
+              class="form-control"
+              required
+            />
+          </div>
+        </div>
+        <div class="form-group row">
+          <div class="col-sm-10">
+            <button type="submit" class="btn btn-secondary">
+              {this.state.userLoading ? (
+                <svg class="icon icon-spinner spin">
+                  <use xlinkHref="#icon-spinner"></use>
+                </svg>
+              ) : (
+                i18n.t('sign_up')
+              )}
+            </button>
+          </div>
+        </div>
+      </form>
+    );
+  }
+
+  handleRegisterSubmit(i: Setup, event: any) {
+    event.preventDefault();
+    i.state.userLoading = true;
+    i.setState(i.state);
+    event.preventDefault();
+    WebSocketService.Instance.register(i.state.userForm);
+  }
+
+  handleRegisterUsernameChange(i: Setup, event: any) {
+    i.state.userForm.username = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleRegisterEmailChange(i: Setup, event: any) {
+    i.state.userForm.email = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleRegisterPasswordChange(i: Setup, event: any) {
+    i.state.userForm.password = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleRegisterPasswordVerifyChange(i: Setup, event: any) {
+    i.state.userForm.password_verify = event.target.value;
+    i.setState(i.state);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      this.state.userLoading = false;
+      this.setState(this.state);
+      return;
+    } else if (res.op == UserOperation.Register) {
+      let data = res.data as LoginResponse;
+      this.state.userLoading = false;
+      this.state.doneRegisteringUser = true;
+      UserService.Instance.login(data);
+      this.setState(this.state);
+    } else if (res.op == UserOperation.CreateSite) {
+      this.props.history.push('/');
+    }
+  }
+}
diff --git a/src/shared/components/sidebar.tsx b/src/shared/components/sidebar.tsx
new file mode 100644 (file)
index 0000000..34cc8b3
--- /dev/null
@@ -0,0 +1,477 @@
+import { Component, linkEvent } from 'inferno';
+import { Link } from 'inferno-router';
+import {
+  Community,
+  CommunityUser,
+  FollowCommunityForm,
+  DeleteCommunityForm,
+  RemoveCommunityForm,
+  UserView,
+  AddModToCommunityForm,
+} from 'lemmy-js-client';
+import { WebSocketService, UserService } from '../services';
+import { mdToHtml, getUnixTime } from '../utils';
+import { CommunityForm } from './community-form';
+import { UserListing } from './user-listing';
+import { CommunityLink } from './community-link';
+import { BannerIconHeader } from './banner-icon-header';
+import { i18n } from '../i18next';
+
+interface SidebarProps {
+  community: Community;
+  moderators: CommunityUser[];
+  admins: UserView[];
+  online: number;
+  enableNsfw: boolean;
+  showIcon?: boolean;
+}
+
+interface SidebarState {
+  showEdit: boolean;
+  showRemoveDialog: boolean;
+  removeReason: string;
+  removeExpires: string;
+  showConfirmLeaveModTeam: boolean;
+}
+
+export class Sidebar extends Component<SidebarProps, SidebarState> {
+  private emptyState: SidebarState = {
+    showEdit: false,
+    showRemoveDialog: false,
+    removeReason: null,
+    removeExpires: null,
+    showConfirmLeaveModTeam: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.state = this.emptyState;
+    this.handleEditCommunity = this.handleEditCommunity.bind(this);
+    this.handleEditCancel = this.handleEditCancel.bind(this);
+  }
+
+  render() {
+    return (
+      <div>
+        {!this.state.showEdit ? (
+          this.sidebar()
+        ) : (
+          <CommunityForm
+            community={this.props.community}
+            onEdit={this.handleEditCommunity}
+            onCancel={this.handleEditCancel}
+            enableNsfw={this.props.enableNsfw}
+          />
+        )}
+      </div>
+    );
+  }
+
+  sidebar() {
+    return (
+      <div>
+        <div class="card bg-transparent border-secondary mb-3">
+          <div class="card-header bg-transparent border-secondary">
+            {this.communityTitle()}
+            {this.adminButtons()}
+          </div>
+          <div class="card-body">{this.subscribes()}</div>
+        </div>
+        <div class="card bg-transparent border-secondary mb-3">
+          <div class="card-body">
+            {this.description()}
+            {this.badges()}
+            {this.mods()}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  communityTitle() {
+    let community = this.props.community;
+    return (
+      <div>
+        <h5 className="mb-0">
+          {this.props.showIcon && (
+            <BannerIconHeader icon={community.icon} banner={community.banner} />
+          )}
+          <span>{community.title}</span>
+          {community.removed && (
+            <small className="ml-2 text-muted font-italic">
+              {i18n.t('removed')}
+            </small>
+          )}
+          {community.deleted && (
+            <small className="ml-2 text-muted font-italic">
+              {i18n.t('deleted')}
+            </small>
+          )}
+          {community.nsfw && (
+            <small className="ml-2 text-muted font-italic">
+              {i18n.t('nsfw')}
+            </small>
+          )}
+        </h5>
+        <CommunityLink
+          community={community}
+          realLink
+          useApubName
+          muted
+          hideAvatar
+        />
+      </div>
+    );
+  }
+
+  badges() {
+    let community = this.props.community;
+    return (
+      <ul class="my-1 list-inline">
+        <li className="list-inline-item badge badge-light">
+          {i18n.t('number_online', { count: this.props.online })}
+        </li>
+        <li className="list-inline-item badge badge-light">
+          {i18n.t('number_of_subscribers', {
+            count: community.number_of_subscribers,
+          })}
+        </li>
+        <li className="list-inline-item badge badge-light">
+          {i18n.t('number_of_posts', {
+            count: community.number_of_posts,
+          })}
+        </li>
+        <li className="list-inline-item badge badge-light">
+          {i18n.t('number_of_comments', {
+            count: community.number_of_comments,
+          })}
+        </li>
+        <li className="list-inline-item">
+          <Link className="badge badge-light" to="/communities">
+            {community.category_name}
+          </Link>
+        </li>
+        <li className="list-inline-item">
+          <Link
+            className="badge badge-light"
+            to={`/modlog/community/${this.props.community.id}`}
+          >
+            {i18n.t('modlog')}
+          </Link>
+        </li>
+        <li className="list-inline-item badge badge-light">
+          <CommunityLink community={community} realLink />
+        </li>
+      </ul>
+    );
+  }
+
+  mods() {
+    return (
+      <ul class="list-inline small">
+        <li class="list-inline-item">{i18n.t('mods')}: </li>
+        {this.props.moderators.map(mod => (
+          <li class="list-inline-item">
+            <UserListing
+              user={{
+                name: mod.user_name,
+                preferred_username: mod.user_preferred_username,
+                avatar: mod.avatar,
+                id: mod.user_id,
+                local: mod.user_local,
+                actor_id: mod.user_actor_id,
+              }}
+            />
+          </li>
+        ))}
+      </ul>
+    );
+  }
+
+  subscribes() {
+    let community = this.props.community;
+    return (
+      <div class="d-flex flex-wrap">
+        <Link
+          class={`btn btn-secondary flex-fill mr-2 mb-2 ${
+            community.deleted || community.removed ? 'no-click' : ''
+          }`}
+          to={`/create_post?community=${community.name}`}
+        >
+          {i18n.t('create_a_post')}
+        </Link>
+        {community.subscribed ? (
+          <a
+            class="btn btn-secondary flex-fill mb-2"
+            href="#"
+            onClick={linkEvent(community.id, this.handleUnsubscribe)}
+          >
+            {i18n.t('unsubscribe')}
+          </a>
+        ) : (
+          <a
+            class="btn btn-secondary flex-fill mb-2"
+            href="#"
+            onClick={linkEvent(community.id, this.handleSubscribe)}
+          >
+            {i18n.t('subscribe')}
+          </a>
+        )}
+      </div>
+    );
+  }
+
+  description() {
+    let community = this.props.community;
+    return (
+      community.description && (
+        <div
+          className="md-div"
+          dangerouslySetInnerHTML={mdToHtml(community.description)}
+        />
+      )
+    );
+  }
+
+  adminButtons() {
+    let community = this.props.community;
+    return (
+      <>
+        <ul class="list-inline mb-1 text-muted font-weight-bold">
+          {this.canMod && (
+            <>
+              <li className="list-inline-item-action">
+                <span
+                  class="pointer"
+                  onClick={linkEvent(this, this.handleEditClick)}
+                  data-tippy-content={i18n.t('edit')}
+                >
+                  <svg class="icon icon-inline">
+                    <use xlinkHref="#icon-edit"></use>
+                  </svg>
+                </span>
+              </li>
+              {!this.amCreator &&
+                (!this.state.showConfirmLeaveModTeam ? (
+                  <li className="list-inline-item-action">
+                    <span
+                      class="pointer"
+                      onClick={linkEvent(
+                        this,
+                        this.handleShowConfirmLeaveModTeamClick
+                      )}
+                    >
+                      {i18n.t('leave_mod_team')}
+                    </span>
+                  </li>
+                ) : (
+                  <>
+                    <li className="list-inline-item-action">
+                      {i18n.t('are_you_sure')}
+                    </li>
+                    <li className="list-inline-item-action">
+                      <span
+                        class="pointer"
+                        onClick={linkEvent(this, this.handleLeaveModTeamClick)}
+                      >
+                        {i18n.t('yes')}
+                      </span>
+                    </li>
+                    <li className="list-inline-item-action">
+                      <span
+                        class="pointer"
+                        onClick={linkEvent(
+                          this,
+                          this.handleCancelLeaveModTeamClick
+                        )}
+                      >
+                        {i18n.t('no')}
+                      </span>
+                    </li>
+                  </>
+                ))}
+              {this.amCreator && (
+                <li className="list-inline-item-action">
+                  <span
+                    class="pointer"
+                    onClick={linkEvent(this, this.handleDeleteClick)}
+                    data-tippy-content={
+                      !community.deleted ? i18n.t('delete') : i18n.t('restore')
+                    }
+                  >
+                    <svg
+                      class={`icon icon-inline ${
+                        community.deleted && 'text-danger'
+                      }`}
+                    >
+                      <use xlinkHref="#icon-trash"></use>
+                    </svg>
+                  </span>
+                </li>
+              )}
+            </>
+          )}
+          {this.canAdmin && (
+            <li className="list-inline-item">
+              {!this.props.community.removed ? (
+                <span
+                  class="pointer"
+                  onClick={linkEvent(this, this.handleModRemoveShow)}
+                >
+                  {i18n.t('remove')}
+                </span>
+              ) : (
+                <span
+                  class="pointer"
+                  onClick={linkEvent(this, this.handleModRemoveSubmit)}
+                >
+                  {i18n.t('restore')}
+                </span>
+              )}
+            </li>
+          )}
+        </ul>
+        {this.state.showRemoveDialog && (
+          <form onSubmit={linkEvent(this, this.handleModRemoveSubmit)}>
+            <div class="form-group row">
+              <label class="col-form-label" htmlFor="remove-reason">
+                {i18n.t('reason')}
+              </label>
+              <input
+                type="text"
+                id="remove-reason"
+                class="form-control mr-2"
+                placeholder={i18n.t('optional')}
+                value={this.state.removeReason}
+                onInput={linkEvent(this, this.handleModRemoveReasonChange)}
+              />
+            </div>
+            {/* TODO hold off on expires for now */}
+            {/* <div class="form-group row"> */}
+            {/*   <label class="col-form-label">Expires</label> */}
+            {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.removeExpires} onInput={linkEvent(this, this.handleModRemoveExpiresChange)} /> */}
+            {/* </div> */}
+            <div class="form-group row">
+              <button type="submit" class="btn btn-secondary">
+                {i18n.t('remove_community')}
+              </button>
+            </div>
+          </form>
+        )}
+      </>
+    );
+  }
+
+  handleEditClick(i: Sidebar) {
+    i.state.showEdit = true;
+    i.setState(i.state);
+  }
+
+  handleEditCommunity() {
+    this.state.showEdit = false;
+    this.setState(this.state);
+  }
+
+  handleEditCancel() {
+    this.state.showEdit = false;
+    this.setState(this.state);
+  }
+
+  handleDeleteClick(i: Sidebar) {
+    event.preventDefault();
+    let deleteForm: DeleteCommunityForm = {
+      edit_id: i.props.community.id,
+      deleted: !i.props.community.deleted,
+    };
+    WebSocketService.Instance.deleteCommunity(deleteForm);
+  }
+
+  handleShowConfirmLeaveModTeamClick(i: Sidebar) {
+    i.state.showConfirmLeaveModTeam = true;
+    i.setState(i.state);
+  }
+
+  handleLeaveModTeamClick(i: Sidebar) {
+    let form: AddModToCommunityForm = {
+      user_id: UserService.Instance.user.id,
+      community_id: i.props.community.id,
+      added: false,
+    };
+    WebSocketService.Instance.addModToCommunity(form);
+    i.state.showConfirmLeaveModTeam = false;
+    i.setState(i.state);
+  }
+
+  handleCancelLeaveModTeamClick(i: Sidebar) {
+    i.state.showConfirmLeaveModTeam = false;
+    i.setState(i.state);
+  }
+
+  handleUnsubscribe(communityId: number) {
+    event.preventDefault();
+    let form: FollowCommunityForm = {
+      community_id: communityId,
+      follow: false,
+    };
+    WebSocketService.Instance.followCommunity(form);
+  }
+
+  handleSubscribe(communityId: number) {
+    event.preventDefault();
+    let form: FollowCommunityForm = {
+      community_id: communityId,
+      follow: true,
+    };
+    WebSocketService.Instance.followCommunity(form);
+  }
+
+  private get amCreator(): boolean {
+    return this.props.community.creator_id == UserService.Instance.user.id;
+  }
+
+  get canMod(): boolean {
+    return (
+      UserService.Instance.user &&
+      this.props.moderators
+        .map(m => m.user_id)
+        .includes(UserService.Instance.user.id)
+    );
+  }
+
+  get canAdmin(): boolean {
+    return (
+      UserService.Instance.user &&
+      this.props.admins.map(a => a.id).includes(UserService.Instance.user.id)
+    );
+  }
+
+  handleModRemoveShow(i: Sidebar) {
+    i.state.showRemoveDialog = true;
+    i.setState(i.state);
+  }
+
+  handleModRemoveReasonChange(i: Sidebar, event: any) {
+    i.state.removeReason = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleModRemoveExpiresChange(i: Sidebar, event: any) {
+    console.log(event.target.value);
+    i.state.removeExpires = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleModRemoveSubmit(i: Sidebar) {
+    event.preventDefault();
+    let removeForm: RemoveCommunityForm = {
+      edit_id: i.props.community.id,
+      removed: !i.props.community.removed,
+      reason: i.state.removeReason,
+      expires: getUnixTime(i.state.removeExpires),
+    };
+    WebSocketService.Instance.removeCommunity(removeForm);
+
+    i.state.showRemoveDialog = false;
+    i.setState(i.state);
+  }
+}
diff --git a/src/shared/components/site-form.tsx b/src/shared/components/site-form.tsx
new file mode 100644 (file)
index 0000000..9b572f5
--- /dev/null
@@ -0,0 +1,300 @@
+import { Component, linkEvent } from 'inferno';
+import { Prompt } from 'inferno-router';
+import { MarkdownTextArea } from './markdown-textarea';
+import { ImageUploadForm } from './image-upload-form';
+import { Site, SiteForm as SiteFormI } from 'lemmy-js-client';
+import { WebSocketService } from '../services';
+import { capitalizeFirstLetter, randomStr } from '../utils';
+import { i18n } from '../i18next';
+
+interface SiteFormProps {
+  site?: Site; // If a site is given, that means this is an edit
+  onCancel?(): any;
+}
+
+interface SiteFormState {
+  siteForm: SiteFormI;
+  loading: boolean;
+}
+
+export class SiteForm extends Component<SiteFormProps, SiteFormState> {
+  private id = `site-form-${randomStr()}`;
+  private emptyState: SiteFormState = {
+    siteForm: {
+      enable_downvotes: true,
+      open_registration: true,
+      enable_nsfw: true,
+      name: null,
+      icon: null,
+      banner: null,
+    },
+    loading: false,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+    this.handleSiteDescriptionChange = this.handleSiteDescriptionChange.bind(
+      this
+    );
+
+    this.handleIconUpload = this.handleIconUpload.bind(this);
+    this.handleIconRemove = this.handleIconRemove.bind(this);
+
+    this.handleBannerUpload = this.handleBannerUpload.bind(this);
+    this.handleBannerRemove = this.handleBannerRemove.bind(this);
+
+    if (this.props.site) {
+      this.state.siteForm = {
+        name: this.props.site.name,
+        description: this.props.site.description,
+        enable_downvotes: this.props.site.enable_downvotes,
+        open_registration: this.props.site.open_registration,
+        enable_nsfw: this.props.site.enable_nsfw,
+        icon: this.props.site.icon,
+        banner: this.props.site.banner,
+      };
+    }
+  }
+
+  // Necessary to stop the loading
+  componentWillReceiveProps() {
+    this.state.loading = false;
+    this.setState(this.state);
+  }
+
+  componentDidUpdate() {
+    if (
+      !this.state.loading &&
+      !this.props.site &&
+      (this.state.siteForm.name || this.state.siteForm.description)
+    ) {
+      window.onbeforeunload = () => true;
+    } else {
+      window.onbeforeunload = undefined;
+    }
+  }
+
+  componentWillUnmount() {
+    window.onbeforeunload = null;
+  }
+
+  render() {
+    return (
+      <>
+        <Prompt
+          when={
+            !this.state.loading &&
+            !this.props.site &&
+            (this.state.siteForm.name || this.state.siteForm.description)
+          }
+          message={i18n.t('block_leaving')}
+        />
+        <form onSubmit={linkEvent(this, this.handleCreateSiteSubmit)}>
+          <h5>{`${
+            this.props.site
+              ? capitalizeFirstLetter(i18n.t('save'))
+              : capitalizeFirstLetter(i18n.t('name'))
+          } ${i18n.t('your_site')}`}</h5>
+          <div class="form-group row">
+            <label class="col-12 col-form-label" htmlFor="create-site-name">
+              {i18n.t('name')}
+            </label>
+            <div class="col-12">
+              <input
+                type="text"
+                id="create-site-name"
+                class="form-control"
+                value={this.state.siteForm.name}
+                onInput={linkEvent(this, this.handleSiteNameChange)}
+                required
+                minLength={3}
+                maxLength={20}
+              />
+            </div>
+          </div>
+          <div class="form-group">
+            <label>{i18n.t('icon')}</label>
+            <ImageUploadForm
+              uploadTitle={i18n.t('upload_icon')}
+              imageSrc={this.state.siteForm.icon}
+              onUpload={this.handleIconUpload}
+              onRemove={this.handleIconRemove}
+              rounded
+            />
+          </div>
+          <div class="form-group">
+            <label>{i18n.t('banner')}</label>
+            <ImageUploadForm
+              uploadTitle={i18n.t('upload_banner')}
+              imageSrc={this.state.siteForm.banner}
+              onUpload={this.handleBannerUpload}
+              onRemove={this.handleBannerRemove}
+            />
+          </div>
+          <div class="form-group row">
+            <label class="col-12 col-form-label" htmlFor={this.id}>
+              {i18n.t('sidebar')}
+            </label>
+            <div class="col-12">
+              <MarkdownTextArea
+                initialContent={this.state.siteForm.description}
+                onContentChange={this.handleSiteDescriptionChange}
+                hideNavigationWarnings
+              />
+            </div>
+          </div>
+          <div class="form-group row">
+            <div class="col-12">
+              <div class="form-check">
+                <input
+                  class="form-check-input"
+                  id="create-site-downvotes"
+                  type="checkbox"
+                  checked={this.state.siteForm.enable_downvotes}
+                  onChange={linkEvent(
+                    this,
+                    this.handleSiteEnableDownvotesChange
+                  )}
+                />
+                <label class="form-check-label" htmlFor="create-site-downvotes">
+                  {i18n.t('enable_downvotes')}
+                </label>
+              </div>
+            </div>
+          </div>
+          <div class="form-group row">
+            <div class="col-12">
+              <div class="form-check">
+                <input
+                  class="form-check-input"
+                  id="create-site-enable-nsfw"
+                  type="checkbox"
+                  checked={this.state.siteForm.enable_nsfw}
+                  onChange={linkEvent(this, this.handleSiteEnableNsfwChange)}
+                />
+                <label
+                  class="form-check-label"
+                  htmlFor="create-site-enable-nsfw"
+                >
+                  {i18n.t('enable_nsfw')}
+                </label>
+              </div>
+            </div>
+          </div>
+          <div class="form-group row">
+            <div class="col-12">
+              <div class="form-check">
+                <input
+                  class="form-check-input"
+                  id="create-site-open-registration"
+                  type="checkbox"
+                  checked={this.state.siteForm.open_registration}
+                  onChange={linkEvent(
+                    this,
+                    this.handleSiteOpenRegistrationChange
+                  )}
+                />
+                <label
+                  class="form-check-label"
+                  htmlFor="create-site-open-registration"
+                >
+                  {i18n.t('open_registration')}
+                </label>
+              </div>
+            </div>
+          </div>
+          <div class="form-group row">
+            <div class="col-12">
+              <button
+                type="submit"
+                class="btn btn-secondary mr-2"
+                disabled={this.state.loading}
+              >
+                {this.state.loading ? (
+                  <svg class="icon icon-spinner spin">
+                    <use xlinkHref="#icon-spinner"></use>
+                  </svg>
+                ) : this.props.site ? (
+                  capitalizeFirstLetter(i18n.t('save'))
+                ) : (
+                  capitalizeFirstLetter(i18n.t('create'))
+                )}
+              </button>
+              {this.props.site && (
+                <button
+                  type="button"
+                  class="btn btn-secondary"
+                  onClick={linkEvent(this, this.handleCancel)}
+                >
+                  {i18n.t('cancel')}
+                </button>
+              )}
+            </div>
+          </div>
+        </form>
+      </>
+    );
+  }
+
+  handleCreateSiteSubmit(i: SiteForm, event: any) {
+    event.preventDefault();
+    i.state.loading = true;
+    if (i.props.site) {
+      WebSocketService.Instance.editSite(i.state.siteForm);
+    } else {
+      WebSocketService.Instance.createSite(i.state.siteForm);
+    }
+    i.setState(i.state);
+  }
+
+  handleSiteNameChange(i: SiteForm, event: any) {
+    i.state.siteForm.name = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleSiteDescriptionChange(val: string) {
+    this.state.siteForm.description = val;
+    this.setState(this.state);
+  }
+
+  handleSiteEnableNsfwChange(i: SiteForm, event: any) {
+    i.state.siteForm.enable_nsfw = event.target.checked;
+    i.setState(i.state);
+  }
+
+  handleSiteOpenRegistrationChange(i: SiteForm, event: any) {
+    i.state.siteForm.open_registration = event.target.checked;
+    i.setState(i.state);
+  }
+
+  handleSiteEnableDownvotesChange(i: SiteForm, event: any) {
+    i.state.siteForm.enable_downvotes = event.target.checked;
+    i.setState(i.state);
+  }
+
+  handleCancel(i: SiteForm) {
+    i.props.onCancel();
+  }
+
+  handleIconUpload(url: string) {
+    this.state.siteForm.icon = url;
+    this.setState(this.state);
+  }
+
+  handleIconRemove() {
+    this.state.siteForm.icon = '';
+    this.setState(this.state);
+  }
+
+  handleBannerUpload(url: string) {
+    this.state.siteForm.banner = url;
+    this.setState(this.state);
+  }
+
+  handleBannerRemove() {
+    this.state.siteForm.banner = '';
+    this.setState(this.state);
+  }
+}
diff --git a/src/shared/components/sort-select.tsx b/src/shared/components/sort-select.tsx
new file mode 100644 (file)
index 0000000..1f0fb05
--- /dev/null
@@ -0,0 +1,76 @@
+import { Component, linkEvent } from 'inferno';
+import { SortType } from 'lemmy-js-client';
+import { sortingHelpUrl, randomStr } from '../utils';
+import { i18n } from '../i18next';
+
+interface SortSelectProps {
+  sort: SortType;
+  onChange?(val: SortType): any;
+  hideHot?: boolean;
+}
+
+interface SortSelectState {
+  sort: SortType;
+}
+
+export class SortSelect extends Component<SortSelectProps, SortSelectState> {
+  private id = `sort-select-${randomStr()}`;
+  private emptyState: SortSelectState = {
+    sort: this.props.sort,
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.state = this.emptyState;
+  }
+
+  static getDerivedStateFromProps(props: any): SortSelectState {
+    return {
+      sort: props.sort,
+    };
+  }
+
+  render() {
+    return (
+      <>
+        <select
+          id={this.id}
+          name={this.id}
+          value={this.state.sort}
+          onChange={linkEvent(this, this.handleSortChange)}
+          class="custom-select w-auto mr-2 mb-2"
+        >
+          <option disabled>{i18n.t('sort_type')}</option>
+          {!this.props.hideHot && (
+            <>
+              <option value={SortType.Active}>{i18n.t('active')}</option>
+              <option value={SortType.Hot}>{i18n.t('hot')}</option>
+            </>
+          )}
+          <option value={SortType.New}>{i18n.t('new')}</option>
+          <option disabled>─────</option>
+          <option value={SortType.TopDay}>{i18n.t('top_day')}</option>
+          <option value={SortType.TopWeek}>{i18n.t('week')}</option>
+          <option value={SortType.TopMonth}>{i18n.t('month')}</option>
+          <option value={SortType.TopYear}>{i18n.t('year')}</option>
+          <option value={SortType.TopAll}>{i18n.t('all')}</option>
+        </select>
+        <a
+          className="text-muted"
+          href={sortingHelpUrl}
+          target="_blank"
+          rel="noopener"
+          title={i18n.t('sorting_help')}
+        >
+          <svg class={`icon icon-inline`}>
+            <use xlinkHref="#icon-help-circle"></use>
+          </svg>
+        </a>
+      </>
+    );
+  }
+
+  handleSortChange(i: SortSelect, event: any) {
+    i.props.onChange(event.target.value);
+  }
+}
diff --git a/src/shared/components/sponsors.tsx b/src/shared/components/sponsors.tsx
new file mode 100644 (file)
index 0000000..a3b05cc
--- /dev/null
@@ -0,0 +1,211 @@
+import { Component } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import { WebSocketService } from '../services';
+import {
+  GetSiteResponse,
+  Site,
+  WebSocketJsonResponse,
+  UserOperation,
+} from 'lemmy-js-client';
+import { i18n } from '../i18next';
+import { T } from 'inferno-i18next';
+import { repoUrl, wsJsonToRes, toast } from '../utils';
+
+interface SilverUser {
+  name: string;
+  link?: string;
+}
+
+let general = [
+  'Brendan',
+  'mexicanhalloween',
+  'William Moore',
+  'Rachel Schmitz',
+  'comradeda',
+  'ybaumy',
+  'dude in phx',
+  'twilight loki',
+  'Andrew Plaza',
+  'Jonathan Cremin',
+  'Arthur Nieuwland',
+  'Ernest Wiśniewski',
+  'HN',
+  'Forrest Weghorst',
+  'Andre Vallestero',
+  'NotTooHighToHack',
+];
+let highlighted = ['DQW', 'DiscountFuneral', 'Oskenso Kashi', 'Alex Benishek'];
+let silver: SilverUser[] = [
+  {
+    name: 'Redjoker',
+    link: 'https://iww.org',
+  },
+];
+// let gold = [];
+// let latinum = [];
+
+interface SponsorsState {
+  site: Site;
+}
+
+export class Sponsors extends Component<any, SponsorsState> {
+  private subscription: Subscription;
+  private emptyState: SponsorsState = {
+    site: undefined,
+  };
+  constructor(props: any, context: any) {
+    super(props, context);
+    this.state = this.emptyState;
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    WebSocketService.Instance.getSite();
+  }
+
+  componentDidMount() {
+    window.scrollTo(0, 0);
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  get documentTitle(): string {
+    if (this.state.site) {
+      return `${i18n.t('sponsors')} - ${this.state.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  render() {
+    return (
+      <div class="container text-center">
+        <Helmet title={this.documentTitle} />
+        {this.topMessage()}
+        <hr />
+        {this.sponsors()}
+        <hr />
+        {this.bitcoin()}
+      </div>
+    );
+  }
+
+  topMessage() {
+    return (
+      <div>
+        <h5>{i18n.t('donate_to_lemmy')}</h5>
+        <p>
+          <T i18nKey="sponsor_message">
+            #<a href={repoUrl}>#</a>
+          </T>
+        </p>
+        <a class="btn btn-secondary" href="https://liberapay.com/Lemmy/">
+          {i18n.t('support_on_liberapay')}
+        </a>
+        <a
+          class="btn btn-secondary ml-2"
+          href="https://www.patreon.com/dessalines"
+        >
+          {i18n.t('support_on_patreon')}
+        </a>
+        <a
+          class="btn btn-secondary ml-2"
+          href="https://opencollective.com/lemmy"
+        >
+          {i18n.t('support_on_open_collective')}
+        </a>
+      </div>
+    );
+  }
+  sponsors() {
+    return (
+      <div class="container">
+        <h5>{i18n.t('sponsors')}</h5>
+        <p>{i18n.t('silver_sponsors')}</p>
+        <div class="row justify-content-md-center card-columns">
+          {silver.map(s => (
+            <div class="card col-12 col-md-2">
+              <div>
+                {s.link ? (
+                  <a href={s.link} target="_blank" rel="noopener">
+                    💎 {s.name}
+                  </a>
+                ) : (
+                  <div>💎 {s.name}</div>
+                )}
+              </div>
+            </div>
+          ))}
+        </div>
+        <p>{i18n.t('general_sponsors')}</p>
+        <div class="row justify-content-md-center card-columns">
+          {highlighted.map(s => (
+            <div class="card bg-primary col-12 col-md-2 font-weight-bold">
+              <div>{s}</div>
+            </div>
+          ))}
+          {general.map(s => (
+            <div class="card col-12 col-md-2">
+              <div>{s}</div>
+            </div>
+          ))}
+        </div>
+      </div>
+    );
+  }
+
+  bitcoin() {
+    return (
+      <div>
+        <h5>{i18n.t('crypto')}</h5>
+        <div class="table-responsive">
+          <table class="table table-hover text-center">
+            <tbody>
+              <tr>
+                <td>{i18n.t('bitcoin')}</td>
+                <td>
+                  <code>1Hefs7miXS5ff5Ck5xvmjKjXf5242KzRtK</code>
+                </td>
+              </tr>
+              <tr>
+                <td>{i18n.t('ethereum')}</td>
+                <td>
+                  <code>0x400c96c96acbC6E7B3B43B1dc1BB446540a88A01</code>
+                </td>
+              </tr>
+              <tr>
+                <td>{i18n.t('monero')}</td>
+                <td>
+                  <code>
+                    41taVyY6e1xApqKyMVDRVxJ76sPkfZhALLTjRvVKpaAh2pBd4wv9RgYj1tSPrx8wc6iE1uWUfjtQdTmTy2FGMeChGVKPQuV
+                  </code>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </div>
+    );
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    let res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      return;
+    } else if (res.op == UserOperation.GetSite) {
+      let data = res.data as GetSiteResponse;
+      this.state.site = data.site;
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/components/symbols.tsx b/src/shared/components/symbols.tsx
new file mode 100644 (file)
index 0000000..327a40b
--- /dev/null
@@ -0,0 +1,214 @@
+import { Component } from 'inferno';
+
+export class Symbols extends Component<any, any> {
+  constructor(props: any, context: any) {
+    super(props, context);
+  }
+
+  render() {
+    return (
+      <svg
+        aria-hidden="true"
+        style="position: absolute; width: 0; height: 0; overflow: hidden;"
+        version="1.1"
+        xmlns="http://www.w3.org/2000/svg"
+        xmlnsXlink="http://www.w3.org/1999/xlink"
+      >
+        <defs>
+          <symbol id="icon-x" viewBox="0 0 24 24">
+            <path d="M5.293 6.707l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0l5.293-5.293 5.293 5.293c0.391 0.391 1.024 0.391 1.414 0s0.391-1.024 0-1.414l-5.293-5.293 5.293-5.293c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0l-5.293 5.293-5.293-5.293c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414z"></path>
+          </symbol>
+          <symbol id="icon-refresh-cw" viewBox="0 0 24 24">
+            <path d="M4.453 9.334c0.737-2.083 2.247-3.669 4.096-4.552s4.032-1.059 6.114-0.322c1.186 0.42 2.206 1.088 2.983 1.88l2.83 2.66h-3.476c-0.552 0-1 0.448-1 1s0.448 1 1 1h5.997c0.005 0 0.009 0 0.014 0 0.137-0.001 0.268-0.031 0.386-0.082 0.119-0.051 0.229-0.126 0.324-0.225 0.012-0.013 0.024-0.026 0.036-0.039 0.075-0.087 0.133-0.183 0.173-0.285s0.064-0.211 0.069-0.326c0.001-0.015 0.001-0.029 0.001-0.043v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1v3.689l-2.926-2.749c-0.992-1.010-2.271-1.843-3.743-2.364-2.603-0.921-5.335-0.699-7.643 0.402s-4.199 3.086-5.12 5.689c-0.185 0.52 0.088 1.091 0.608 1.276s1.092-0.088 1.276-0.609zM2 16.312l2.955 2.777c1.929 1.931 4.49 2.908 7.048 2.909s5.119-0.975 7.072-2.927c1.104-1.104 1.901-2.407 2.361-3.745 0.18-0.522-0.098-1.091-0.621-1.271s-1.091 0.098-1.271 0.621c-0.361 1.050-0.993 2.091-1.883 2.981-1.563 1.562-3.609 2.342-5.657 2.342s-4.094-0.782-5.679-2.366l-2.8-2.633h3.475c0.552 0 1-0.448 1-1s-0.448-1-1-1h-5.997c-0.005 0-0.009 0-0.014 0-0.137 0.001-0.268 0.031-0.386 0.082-0.119 0.051-0.229 0.126-0.324 0.225-0.012 0.013-0.024 0.026-0.036 0.039-0.075 0.087-0.133 0.183-0.173 0.285s-0.064 0.211-0.069 0.326c-0.001 0.015-0.001 0.029-0.001 0.043v6c0 0.552 0.448 1 1 1s1-0.448 1-1z"></path>
+          </symbol>
+          <symbol id="icon-play" viewBox="0 0 24 24">
+            <path d="M5.541 2.159c-0.153-0.1-0.34-0.159-0.541-0.159-0.552 0-1 0.448-1 1v18c-0.001 0.182 0.050 0.372 0.159 0.541 0.299 0.465 0.917 0.599 1.382 0.3l14-9c0.114-0.072 0.219-0.174 0.3-0.3 0.299-0.465 0.164-1.083-0.3-1.382zM6 4.832l11.151 7.168-11.151 7.168z"></path>
+          </symbol>
+          <symbol id="icon-strikethrough" viewBox="0 0 28 28">
+            <path d="M27.5 14c0.281 0 0.5 0.219 0.5 0.5v1c0 0.281-0.219 0.5-0.5 0.5h-27c-0.281 0-0.5-0.219-0.5-0.5v-1c0-0.281 0.219-0.5 0.5-0.5h27zM7.547 13c-0.297-0.375-0.562-0.797-0.797-1.25-0.5-1.016-0.75-2-0.75-2.938 0-1.906 0.703-3.5 2.094-4.828s3.437-1.984 6.141-1.984c0.594 0 1.453 0.109 2.609 0.297 0.688 0.125 1.609 0.375 2.766 0.75 0.109 0.406 0.219 1.031 0.328 1.844 0.141 1.234 0.219 2.187 0.219 2.859 0 0.219-0.031 0.453-0.078 0.703l-0.187 0.047-1.313-0.094-0.219-0.031c-0.531-1.578-1.078-2.641-1.609-3.203-0.922-0.953-2.031-1.422-3.281-1.422-1.188 0-2.141 0.313-2.844 0.922s-1.047 1.375-1.047 2.281c0 0.766 0.344 1.484 1.031 2.188s2.141 1.375 4.359 2.016c0.75 0.219 1.641 0.562 2.703 1.031 0.562 0.266 1.062 0.531 1.484 0.812h-11.609zM15.469 17h6.422c0.078 0.438 0.109 0.922 0.109 1.437 0 1.125-0.203 2.234-0.641 3.313-0.234 0.578-0.594 1.109-1.109 1.625-0.375 0.359-0.938 0.781-1.703 1.266-0.781 0.469-1.563 0.828-2.391 1.031-0.828 0.219-1.875 0.328-3.172 0.328-0.859 0-1.891-0.031-3.047-0.359l-2.188-0.625c-0.609-0.172-0.969-0.313-1.125-0.438-0.063-0.063-0.125-0.172-0.125-0.344v-0.203c0-0.125 0.031-0.938-0.031-2.438-0.031-0.781 0.031-1.328 0.031-1.641v-0.688l1.594-0.031c0.578 1.328 0.844 2.125 1.016 2.406 0.375 0.609 0.797 1.094 1.25 1.469s1 0.672 1.641 0.891c0.625 0.234 1.328 0.344 2.063 0.344 0.656 0 1.391-0.141 2.172-0.422 0.797-0.266 1.437-0.719 1.906-1.344 0.484-0.625 0.734-1.297 0.734-2.016 0-0.875-0.422-1.687-1.266-2.453-0.344-0.297-1.062-0.672-2.141-1.109z"></path>
+          </symbol>
+          <symbol id="icon-header" viewBox="0 0 28 28">
+            <path d="M26.281 26c-1.375 0-2.766-0.109-4.156-0.109-1.375 0-2.75 0.109-4.125 0.109-0.531 0-0.781-0.578-0.781-1.031 0-1.391 1.563-0.797 2.375-1.328 0.516-0.328 0.516-1.641 0.516-2.188l-0.016-6.109c0-0.172 0-0.328-0.016-0.484-0.25-0.078-0.531-0.063-0.781-0.063h-10.547c-0.266 0-0.547-0.016-0.797 0.063-0.016 0.156-0.016 0.313-0.016 0.484l-0.016 5.797c0 0.594 0 2.219 0.578 2.562 0.812 0.5 2.656-0.203 2.656 1.203 0 0.469-0.219 1.094-0.766 1.094-1.453 0-2.906-0.109-4.344-0.109-1.328 0-2.656 0.109-3.984 0.109-0.516 0-0.75-0.594-0.75-1.031 0-1.359 1.437-0.797 2.203-1.328 0.5-0.344 0.516-1.687 0.516-2.234l-0.016-0.891v-12.703c0-0.75 0.109-3.156-0.594-3.578-0.781-0.484-2.453 0.266-2.453-1.141 0-0.453 0.203-1.094 0.75-1.094 1.437 0 2.891 0.109 4.328 0.109 1.313 0 2.641-0.109 3.953-0.109 0.562 0 0.781 0.625 0.781 1.094 0 1.344-1.547 0.688-2.312 1.172-0.547 0.328-0.547 1.937-0.547 2.5l0.016 5c0 0.172 0 0.328 0.016 0.5 0.203 0.047 0.406 0.047 0.609 0.047h10.922c0.187 0 0.391 0 0.594-0.047 0.016-0.172 0.016-0.328 0.016-0.5l0.016-5c0-0.578 0-2.172-0.547-2.5-0.781-0.469-2.344 0.156-2.344-1.172 0-0.469 0.219-1.094 0.781-1.094 1.375 0 2.75 0.109 4.125 0.109 1.344 0 2.688-0.109 4.031-0.109 0.562 0 0.781 0.625 0.781 1.094 0 1.359-1.609 0.672-2.391 1.156-0.531 0.344-0.547 1.953-0.547 2.516l0.016 14.734c0 0.516 0.031 1.875 0.531 2.188 0.797 0.5 2.484-0.141 2.484 1.219 0 0.453-0.203 1.094-0.75 1.094z"></path>
+          </symbol>
+          <symbol id="icon-list" viewBox="0 0 24 24">
+            <path d="M8 7h13c0.552 0 1-0.448 1-1s-0.448-1-1-1h-13c-0.552 0-1 0.448-1 1s0.448 1 1 1zM8 13h13c0.552 0 1-0.448 1-1s-0.448-1-1-1h-13c-0.552 0-1 0.448-1 1s0.448 1 1 1zM8 19h13c0.552 0 1-0.448 1-1s-0.448-1-1-1h-13c-0.552 0-1 0.448-1 1s0.448 1 1 1zM3 7c0.552 0 1-0.448 1-1s-0.448-1-1-1-1 0.448-1 1 0.448 1 1 1zM3 13c0.552 0 1-0.448 1-1s-0.448-1-1-1-1 0.448-1 1 0.448 1 1 1zM3 19c0.552 0 1-0.448 1-1s-0.448-1-1-1-1 0.448-1 1 0.448 1 1 1z"></path>
+          </symbol>
+          <symbol id="icon-italic" viewBox="0 0 24 24">
+            <path d="M13.557 5l-5.25 14h-3.307c-0.552 0-1 0.448-1 1s0.448 1 1 1h9c0.552 0 1-0.448 1-1s-0.448-1-1-1h-3.557l5.25-14h3.307c0.552 0 1-0.448 1-1s-0.448-1-1-1h-9c-0.552 0-1 0.448-1 1s0.448 1 1 1z"></path>
+          </symbol>
+          <symbol id="icon-code" viewBox="0 0 24 24">
+            <path d="M16.707 18.707l6-6c0.391-0.391 0.391-1.024 0-1.414l-6-6c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414l5.293 5.293-5.293 5.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0zM7.293 5.293l-6 6c-0.391 0.391-0.391 1.024 0 1.414l6 6c0.391 0.391 1.024 0.391 1.414 0s0.391-1.024 0-1.414l-5.293-5.293 5.293-5.293c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0z"></path>
+          </symbol>
+          <symbol id="icon-bold" viewBox="0 0 24 24">
+            <path d="M7 11v-6h7c0.829 0 1.577 0.335 2.121 0.879s0.879 1.292 0.879 2.121-0.335 1.577-0.879 2.121-1.292 0.879-2.121 0.879zM5 12v8c0 0.552 0.448 1 1 1h9c1.38 0 2.632-0.561 3.536-1.464s1.464-2.156 1.464-3.536-0.561-2.632-1.464-3.536c-0.325-0.325-0.695-0.606-1.1-0.832 0.034-0.032 0.067-0.064 0.1-0.097 0.903-0.903 1.464-2.155 1.464-3.535s-0.561-2.632-1.464-3.536-2.156-1.464-3.536-1.464h-8c-0.552 0-1 0.448-1 1zM7 13h8c0.829 0 1.577 0.335 2.121 0.879s0.879 1.292 0.879 2.121-0.335 1.577-0.879 2.121-1.292 0.879-2.121 0.879h-8z"></path>
+          </symbol>
+          <symbol id="icon-format_quote" viewBox="0 0 24 24">
+            <path d="M14.016 17.016l1.969-4.031h-3v-6h6v6l-1.969 4.031h-3zM6 17.016l2.016-4.031h-3v-6h6v6l-2.016 4.031h-3z"></path>
+          </symbol>
+          <symbol id="icon-settings" viewBox="0 0 24 24">
+            <path d="M16 12c0-1.104-0.449-2.106-1.172-2.828s-1.724-1.172-2.828-1.172-2.106 0.449-2.828 1.172-1.172 1.724-1.172 2.828 0.449 2.106 1.172 2.828 1.724 1.172 2.828 1.172 2.106-0.449 2.828-1.172 1.172-1.724 1.172-2.828zM14 12c0 0.553-0.223 1.051-0.586 1.414s-0.861 0.586-1.414 0.586-1.051-0.223-1.414-0.586-0.586-0.861-0.586-1.414 0.223-1.051 0.586-1.414 0.861-0.586 1.414-0.586 1.051 0.223 1.414 0.586 0.586 0.861 0.586 1.414zM20.315 15.404c0.046-0.105 0.112-0.191 0.192-0.257 0.112-0.092 0.251-0.146 0.403-0.147h0.090c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121-0.337-1.58-0.879-2.121-1.293-0.879-2.121-0.879h-0.159c-0.11-0.001-0.215-0.028-0.308-0.076-0.127-0.066-0.23-0.172-0.292-0.312-0.003-0.029-0.004-0.059-0.004-0.089-0.024-0.055-0.040-0.111-0.049-0.168 0.020-0.334 0.077-0.454 0.168-0.547l0.062-0.062c0.585-0.586 0.878-1.356 0.877-2.122s-0.294-1.536-0.881-2.122c-0.586-0.585-1.356-0.878-2.122-0.877s-1.536 0.294-2.12 0.879l-0.046 0.046c-0.083 0.080-0.183 0.136-0.288 0.166-0.14 0.039-0.291 0.032-0.438-0.033-0.101-0.044-0.187-0.11-0.253-0.19-0.092-0.112-0.146-0.251-0.147-0.403v-0.090c0-0.828-0.337-1.58-0.879-2.121s-1.293-0.879-2.121-0.879-1.58 0.337-2.121 0.879-0.879 1.293-0.879 2.121v0.159c-0.001 0.11-0.028 0.215-0.076 0.308-0.066 0.127-0.172 0.23-0.312 0.292-0.029 0.003-0.059 0.004-0.089 0.004-0.055 0.024-0.111 0.040-0.168 0.049-0.335-0.021-0.455-0.078-0.548-0.169l-0.062-0.062c-0.586-0.585-1.355-0.878-2.122-0.878s-1.535 0.294-2.122 0.882c-0.585 0.586-0.878 1.355-0.878 2.122s0.294 1.536 0.879 2.12l0.048 0.047c0.080 0.083 0.136 0.183 0.166 0.288 0.039 0.14 0.032 0.291-0.031 0.434-0.006 0.016-0.013 0.034-0.021 0.052-0.041 0.109-0.108 0.203-0.191 0.275-0.11 0.095-0.25 0.153-0.383 0.156h-0.090c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.294-0.879 2.122 0.337 1.58 0.879 2.121 1.293 0.879 2.121 0.879h0.159c0.11 0.001 0.215 0.028 0.308 0.076 0.128 0.067 0.233 0.174 0.296 0.321 0.024 0.055 0.040 0.111 0.049 0.168-0.020 0.334-0.077 0.454-0.168 0.547l-0.062 0.062c-0.585 0.586-0.878 1.356-0.877 2.122s0.294 1.536 0.881 2.122c0.586 0.585 1.356 0.878 2.122 0.877s1.536-0.294 2.12-0.879l0.047-0.048c0.083-0.080 0.183-0.136 0.288-0.166 0.14-0.039 0.291-0.032 0.434 0.031 0.016 0.006 0.034 0.013 0.052 0.021 0.109 0.041 0.203 0.108 0.275 0.191 0.095 0.11 0.153 0.25 0.156 0.383v0.092c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879 1.58-0.337 2.121-0.879 0.879-1.293 0.879-2.121v-0.159c0.001-0.11 0.028-0.215 0.076-0.308 0.067-0.128 0.174-0.233 0.321-0.296 0.055-0.024 0.111-0.040 0.168-0.049 0.334 0.020 0.454 0.077 0.547 0.168l0.062 0.062c0.586 0.585 1.356 0.878 2.122 0.877s1.536-0.294 2.122-0.881c0.585-0.586 0.878-1.356 0.877-2.122s-0.294-1.536-0.879-2.12l-0.048-0.047c-0.080-0.083-0.136-0.183-0.166-0.288-0.039-0.14-0.032-0.291 0.031-0.434zM18.396 9.302c-0.012-0.201-0.038-0.297-0.076-0.382v0.080c0 0.043 0.003 0.084 0.008 0.125 0.021 0.060 0.043 0.119 0.068 0.177 0.004 0.090 0.005 0.091 0.005 0.092 0.249 0.581 0.684 1.030 1.208 1.303 0.371 0.193 0.785 0.298 1.211 0.303h0.18c0.276 0 0.525 0.111 0.707 0.293s0.293 0.431 0.293 0.707-0.111 0.525-0.293 0.707-0.431 0.293-0.707 0.293h-0.090c-0.637 0.003-1.22 0.228-1.675 0.603-0.323 0.266-0.581 0.607-0.75 0.993-0.257 0.582-0.288 1.21-0.127 1.782 0.119 0.423 0.341 0.814 0.652 1.136l0.072 0.073c0.196 0.196 0.294 0.45 0.294 0.707s-0.097 0.512-0.292 0.707c-0.197 0.197-0.451 0.295-0.709 0.295s-0.512-0.097-0.707-0.292l-0.061-0.061c-0.463-0.453-1.040-0.702-1.632-0.752-0.437-0.037-0.882 0.034-1.293 0.212-0.578 0.248-1.027 0.683-1.3 1.206-0.193 0.371-0.298 0.785-0.303 1.211v0.181c0 0.276-0.111 0.525-0.293 0.707s-0.43 0.292-0.706 0.292-0.525-0.111-0.707-0.293-0.293-0.431-0.293-0.707v-0.090c-0.015-0.66-0.255-1.242-0.644-1.692-0.284-0.328-0.646-0.585-1.058-0.744-0.575-0.247-1.193-0.274-1.756-0.116-0.423 0.119-0.814 0.341-1.136 0.652l-0.073 0.072c-0.196 0.196-0.45 0.294-0.707 0.294s-0.512-0.097-0.707-0.292c-0.197-0.197-0.295-0.451-0.295-0.709s0.097-0.512 0.292-0.707l0.061-0.061c0.453-0.463 0.702-1.040 0.752-1.632 0.037-0.437-0.034-0.882-0.212-1.293-0.248-0.578-0.683-1.027-1.206-1.3-0.371-0.193-0.785-0.298-1.211-0.303l-0.18 0.001c-0.276 0-0.525-0.111-0.707-0.293s-0.293-0.431-0.293-0.707 0.111-0.525 0.293-0.707 0.431-0.293 0.707-0.293h0.090c0.66-0.015 1.242-0.255 1.692-0.644 0.328-0.284 0.585-0.646 0.744-1.058 0.247-0.575 0.274-1.193 0.116-1.756-0.119-0.423-0.341-0.814-0.652-1.136l-0.073-0.073c-0.196-0.196-0.294-0.45-0.294-0.707s0.097-0.512 0.292-0.707c0.197-0.197 0.451-0.295 0.709-0.295s0.512 0.097 0.707 0.292l0.061 0.061c0.463 0.453 1.040 0.702 1.632 0.752 0.37 0.032 0.745-0.014 1.101-0.137 0.096-0.012 0.186-0.036 0.266-0.072-0.031 0.001-0.061 0.003-0.089 0.004-0.201 0.012-0.297 0.038-0.382 0.076h0.080c0.043 0 0.084-0.003 0.125-0.008 0.060-0.021 0.119-0.043 0.177-0.068 0.090-0.004 0.091-0.005 0.092-0.005 0.581-0.249 1.030-0.684 1.303-1.208 0.193-0.37 0.298-0.785 0.303-1.21v-0.181c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293 0.525 0.111 0.707 0.293 0.293 0.431 0.293 0.707v0.090c0.003 0.637 0.228 1.22 0.603 1.675 0.266 0.323 0.607 0.581 0.996 0.751 0.578 0.255 1.206 0.286 1.778 0.125 0.423-0.119 0.814-0.341 1.136-0.652l0.073-0.072c0.196-0.196 0.45-0.294 0.707-0.294s0.512 0.097 0.707 0.292c0.197 0.197 0.295 0.451 0.295 0.709s-0.097 0.512-0.292 0.707l-0.061 0.061c-0.453 0.463-0.702 1.040-0.752 1.632-0.032 0.37 0.014 0.745 0.137 1.101 0.012 0.095 0.037 0.185 0.072 0.266-0.001-0.032-0.002-0.062-0.004-0.089z"></path>
+          </symbol>
+          <symbol id="icon-smile" viewBox="0 0 24 24">
+            <path d="M23 12c0-3.037-1.232-5.789-3.222-7.778s-4.741-3.222-7.778-3.222-5.789 1.232-7.778 3.222-3.222 4.741-3.222 7.778 1.232 5.789 3.222 7.778 4.741 3.222 7.778 3.222 5.789-1.232 7.778-3.222 3.222-4.741 3.222-7.778zM21 12c0 2.486-1.006 4.734-2.636 6.364s-3.878 2.636-6.364 2.636-4.734-1.006-6.364-2.636-2.636-3.878-2.636-6.364 1.006-4.734 2.636-6.364 3.878-2.636 6.364-2.636 4.734 1.006 6.364 2.636 2.636 3.878 2.636 6.364zM7.2 14.6c0 0 0.131 0.173 0.331 0.383 0.145 0.153 0.338 0.341 0.577 0.54 0.337 0.281 0.772 0.59 1.297 0.853 0.705 0.352 1.579 0.624 2.595 0.624s1.89-0.272 2.595-0.624c0.525-0.263 0.96-0.572 1.297-0.853 0.239-0.199 0.432-0.387 0.577-0.54 0.2-0.21 0.331-0.383 0.331-0.383 0.331-0.442 0.242-1.069-0.2-1.4s-1.069-0.242-1.4 0.2c-0.041 0.050-0.181 0.206-0.181 0.206-0.1 0.105-0.237 0.239-0.408 0.382-0.243 0.203-0.549 0.419-0.91 0.6-0.48 0.239-1.050 0.412-1.701 0.412s-1.221-0.173-1.701-0.413c-0.36-0.18-0.667-0.397-0.91-0.6-0.171-0.143-0.308-0.277-0.408-0.382-0.14-0.155-0.181-0.205-0.181-0.205-0.331-0.442-0.958-0.531-1.4-0.2s-0.531 0.958-0.2 1.4zM9 10c0.552 0 1-0.448 1-1s-0.448-1-1-1-1 0.448-1 1 0.448 1 1 1zM15 10c0.552 0 1-0.448 1-1s-0.448-1-1-1-1 0.448-1 1 0.448 1 1 1z"></path>
+          </symbol>
+          <symbol id="icon-book-open" viewBox="0 0 24 24">
+            <path d="M21 4v13h-6c-0.728 0-1.412 0.195-2 0.535v-10.535c0-0.829 0.335-1.577 0.879-2.121s1.292-0.879 2.121-0.879zM11 17.535c-0.588-0.34-1.272-0.535-2-0.535h-6v-13h5c0.829 0 1.577 0.335 2.121 0.879s0.879 1.292 0.879 2.121zM22 2h-6c-1.38 0-2.632 0.561-3.536 1.464-0.167 0.167-0.322 0.346-0.464 0.536-0.142-0.19-0.297-0.369-0.464-0.536-0.904-0.903-2.156-1.464-3.536-1.464h-6c-0.552 0-1 0.448-1 1v15c0 0.552 0.448 1 1 1h7c0.553 0 1.051 0.223 1.414 0.586s0.586 0.861 0.586 1.414c0 0.552 0.448 1 1 1s1-0.448 1-1c0-0.553 0.223-1.051 0.586-1.414s0.861-0.586 1.414-0.586h7c0.552 0 1-0.448 1-1v-15c0-0.552-0.448-1-1-1z"></path>
+          </symbol>
+          <symbol id="icon-alert-triangle" viewBox="0 0 24 24">
+            <path d="M11.148 4.374c0.073-0.123 0.185-0.242 0.334-0.332 0.236-0.143 0.506-0.178 0.756-0.116s0.474 0.216 0.614 0.448l8.466 14.133c0.070 0.12 0.119 0.268 0.128 0.434-0.015 0.368-0.119 0.591-0.283 0.759-0.18 0.184-0.427 0.298-0.693 0.301l-16.937-0.001c-0.152-0.001-0.321-0.041-0.481-0.134-0.239-0.138-0.399-0.359-0.466-0.607s-0.038-0.519 0.092-0.745zM9.432 3.346l-8.47 14.14c-0.422 0.731-0.506 1.55-0.308 2.29s0.68 1.408 1.398 1.822c0.464 0.268 0.976 0.4 1.475 0.402h16.943c0.839-0.009 1.587-0.354 2.123-0.902s0.864-1.303 0.855-2.131c-0.006-0.536-0.153-1.044-0.406-1.474l-8.474-14.147c-0.432-0.713-1.11-1.181-1.854-1.363s-1.561-0.081-2.269 0.349c-0.429 0.26-0.775 0.615-1.012 1.014zM11 9v4c0 0.552 0.448 1 1 1s1-0.448 1-1v-4c0-0.552-0.448-1-1-1s-1 0.448-1 1zM12 18c0.552 0 1-0.448 1-1s-0.448-1-1-1-1 0.448-1 1 0.448 1 1 1z"></path>
+          </symbol>
+          <symbol id="icon-zap" viewBox="0 0 24 24">
+            <path d="M11.585 5.26l-0.577 4.616c0.033 0.716 0.465 1.124 0.992 1.124h6.865l-6.45 7.74 0.577-4.616c-0.033-0.716-0.465-1.124-0.992-1.124h-6.865zM12.232 1.36l-10 12c-0.354 0.424-0.296 1.055 0.128 1.408 0.187 0.157 0.415 0.233 0.64 0.232h7.867l-0.859 6.876c-0.069 0.548 0.32 1.048 0.868 1.116 0.349 0.044 0.678-0.098 0.892-0.352l10-12c0.354-0.424 0.296-1.055-0.128-1.408-0.187-0.157-0.415-0.233-0.64-0.232h-7.867l0.859-6.876c0.069-0.548-0.32-1.048-0.868-1.116-0.349-0.044-0.678 0.098-0.892 0.352z"></path>
+          </symbol>
+          <symbol id="icon-heart" viewBox="0 0 24 24">
+            <path d="M20.133 5.317c0.88 0.881 1.319 2.031 1.319 3.184s-0.44 2.303-1.319 3.182l-8.133 8.133-8.133-8.133c-0.879-0.879-1.318-2.029-1.318-3.183s0.439-2.304 1.318-3.183 2.029-1.318 3.183-1.318 2.304 0.439 3.183 1.318l1.060 1.060c0.391 0.391 1.024 0.391 1.414 0l1.062-1.062c0.879-0.879 2.029-1.318 3.182-1.317s2.303 0.44 3.182 1.319zM21.547 3.903c-1.269-1.269-2.934-1.904-4.596-1.905s-3.327 0.634-4.597 1.903l-0.354 0.355-0.353-0.353c-1.269-1.269-2.935-1.904-4.597-1.904s-3.328 0.635-4.597 1.904-1.904 2.935-1.904 4.597 0.635 3.328 1.904 4.597l8.84 8.84c0.391 0.391 1.024 0.391 1.414 0l8.84-8.84c1.269-1.269 1.904-2.934 1.905-4.596s-0.634-3.327-1.905-4.598z"></path>
+          </symbol>
+          <symbol id="icon-link" viewBox="0 0 24 24">
+            <path d="M9.199 13.599c0.992 1.327 2.43 2.126 3.948 2.345s3.123-0.142 4.45-1.134c0.239-0.179 0.465-0.375 0.655-0.568l2.995-2.995c1.163-1.204 1.722-2.751 1.696-4.285s-0.639-3.061-1.831-4.211c-1.172-1.132-2.688-1.692-4.199-1.683-1.492 0.008-2.984 0.571-4.137 1.683l-1.731 1.721c-0.392 0.389-0.394 1.023-0.004 1.414s1.023 0.394 1.414 0.004l1.709-1.699c0.77-0.742 1.763-1.117 2.76-1.123 1.009-0.006 2.016 0.367 2.798 1.122 0.795 0.768 1.203 1.783 1.221 2.808s-0.355 2.054-1.11 2.836l-3.005 3.005c-0.114 0.116-0.263 0.247-0.428 0.37-0.885 0.662-1.952 0.902-2.967 0.756s-1.971-0.678-2.632-1.563c-0.331-0.442-0.957-0.533-1.4-0.202s-0.533 0.957-0.202 1.4zM14.801 10.401c-0.992-1.327-2.43-2.126-3.948-2.345s-3.124 0.142-4.451 1.134c-0.239 0.179-0.464 0.375-0.655 0.568l-2.995 2.995c-1.163 1.204-1.722 2.751-1.696 4.285s0.639 3.061 1.831 4.211c1.172 1.132 2.688 1.692 4.199 1.683 1.492-0.008 2.984-0.571 4.137-1.683l1.723-1.723c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0l-1.696 1.698c-0.77 0.742-1.763 1.117-2.76 1.123-1.009 0.006-2.016-0.367-2.798-1.122-0.795-0.768-1.203-1.783-1.221-2.808s0.355-2.054 1.11-2.836l3.005-3.005c0.114-0.116 0.263-0.247 0.428-0.37 0.885-0.662 1.952-0.902 2.967-0.756s1.971 0.678 2.632 1.563c0.331 0.442 0.957 0.533 1.4 0.202s0.533-0.957 0.202-1.4z"></path>
+          </symbol>
+          <symbol id="icon-minus-square" viewBox="0 0 24 24">
+            <path d="M5 2c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v14c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879h14c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121v-14c0-0.828-0.337-1.58-0.879-2.121s-1.293-0.879-2.121-0.879zM5 4h14c0.276 0 0.525 0.111 0.707 0.293s0.293 0.431 0.293 0.707v14c0 0.276-0.111 0.525-0.293 0.707s-0.431 0.293-0.707 0.293h-14c-0.276 0-0.525-0.111-0.707-0.293s-0.293-0.431-0.293-0.707v-14c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293zM8 13h8c0.552 0 1-0.448 1-1s-0.448-1-1-1h-8c-0.552 0-1 0.448-1 1s0.448 1 1 1z"></path>
+          </symbol>
+          <symbol id="icon-plus-square" viewBox="0 0 24 24">
+            <path d="M5 2c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v14c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879h14c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121v-14c0-0.828-0.337-1.58-0.879-2.121s-1.293-0.879-2.121-0.879zM5 4h14c0.276 0 0.525 0.111 0.707 0.293s0.293 0.431 0.293 0.707v14c0 0.276-0.111 0.525-0.293 0.707s-0.431 0.293-0.707 0.293h-14c-0.276 0-0.525-0.111-0.707-0.293s-0.293-0.431-0.293-0.707v-14c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293zM8 13h3v3c0 0.552 0.448 1 1 1s1-0.448 1-1v-3h3c0.552 0 1-0.448 1-1s-0.448-1-1-1h-3v-3c0-0.552-0.448-1-1-1s-1 0.448-1 1v3h-3c-0.552 0-1 0.448-1 1s0.448 1 1 1z"></path>
+          </symbol>
+          <symbol id="icon-help-circle" viewBox="0 0 24 24">
+            <path d="M23 12c0-3.037-1.232-5.789-3.222-7.778s-4.741-3.222-7.778-3.222-5.789 1.232-7.778 3.222-3.222 4.741-3.222 7.778 1.232 5.789 3.222 7.778 4.741 3.222 7.778 3.222 5.789-1.232 7.778-3.222 3.222-4.741 3.222-7.778zM21 12c0 2.486-1.006 4.734-2.636 6.364s-3.878 2.636-6.364 2.636-4.734-1.006-6.364-2.636-2.636-3.878-2.636-6.364 1.006-4.734 2.636-6.364 3.878-2.636 6.364-2.636 4.734 1.006 6.364 2.636 2.636 3.878 2.636 6.364zM10.033 9.332c0.183-0.521 0.559-0.918 1.022-1.14s1.007-0.267 1.528-0.083c0.458 0.161 0.819 0.47 1.050 0.859 0.183 0.307 0.284 0.665 0.286 1.037 0 0.155-0.039 0.309-0.117 0.464-0.080 0.16-0.203 0.325-0.368 0.49-0.709 0.709-1.831 1.092-1.831 1.092-0.524 0.175-0.807 0.741-0.632 1.265s0.741 0.807 1.265 0.632c0 0 1.544-0.506 2.613-1.575 0.279-0.279 0.545-0.614 0.743-1.010 0.2-0.4 0.328-0.858 0.328-1.369-0.004-0.731-0.204-1.437-0.567-2.049-0.463-0.778-1.19-1.402-2.105-1.724-1.042-0.366-2.135-0.275-3.057 0.167s-1.678 1.238-2.044 2.28c-0.184 0.521 0.090 1.092 0.611 1.275s1.092-0.091 1.275-0.611zM12 18c0.552 0 1-0.448 1-1s-0.448-1-1-1-1 0.448-1 1 0.448 1 1 1z"></path>
+          </symbol>
+          <symbol id="icon-pin" viewBox="0 0 18 18">
+            <path d="M15 2v-1h-12v1c0 0.552 0.448 1 1 1v8c-0.552 0-1 0.448-1 1v1h5v3c0 0.552 0.448 1 1 1s1-0.448 1-1v-3h5v-1c0-0.552-0.448-1-1-1v-8c0.552 0 1-0.448 1-1zM12 11h-6v-8h6v8z"></path>
+          </symbol>
+          <symbol id="icon-lock" viewBox="0 0 24 24">
+            <path d="M5 12h14c0.276 0 0.525 0.111 0.707 0.293s0.293 0.431 0.293 0.707v7c0 0.276-0.111 0.525-0.293 0.707s-0.431 0.293-0.707 0.293h-14c-0.276 0-0.525-0.111-0.707-0.293s-0.293-0.431-0.293-0.707v-7c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293zM18 10v-3c0-1.657-0.673-3.158-1.757-4.243s-2.586-1.757-4.243-1.757-3.158 0.673-4.243 1.757-1.757 2.586-1.757 4.243v3h-1c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v7c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879h14c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121v-7c0-0.828-0.337-1.58-0.879-2.121s-1.293-0.879-2.121-0.879zM8 10v-3c0-1.105 0.447-2.103 1.172-2.828s1.723-1.172 2.828-1.172 2.103 0.447 2.828 1.172 1.172 1.723 1.172 2.828v3z"></path>
+          </symbol>
+          <symbol id="icon-check" viewBox="0 0 24 24">
+            <path d="M19.293 5.293l-10.293 10.293-4.293-4.293c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414l5 5c0.391 0.391 1.024 0.391 1.414 0l11-11c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0z"></path>
+          </symbol>
+          <symbol id="icon-copy" viewBox="0 0 24 24">
+            <path d="M11 8c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v9c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879h9c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121v-9c0-0.828-0.337-1.58-0.879-2.121s-1.293-0.879-2.121-0.879zM11 10h9c0.276 0 0.525 0.111 0.707 0.293s0.293 0.431 0.293 0.707v9c0 0.276-0.111 0.525-0.293 0.707s-0.431 0.293-0.707 0.293h-9c-0.276 0-0.525-0.111-0.707-0.293s-0.293-0.431-0.293-0.707v-9c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293zM5 14h-1c-0.276 0-0.525-0.111-0.707-0.293s-0.293-0.431-0.293-0.707v-9c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293h9c0.276 0 0.525 0.111 0.707 0.293s0.293 0.431 0.293 0.707v1c0 0.552 0.448 1 1 1s1-0.448 1-1v-1c0-0.828-0.337-1.58-0.879-2.121s-1.293-0.879-2.121-0.879h-9c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v9c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879h1c0.552 0 1-0.448 1-1s-0.448-1-1-1z"></path>
+          </symbol>
+          <symbol id="icon-more-vertical" viewBox="0 0 24 24">
+            <path d="M14 12c0-0.552-0.225-1.053-0.586-1.414s-0.862-0.586-1.414-0.586-1.053 0.225-1.414 0.586-0.586 0.862-0.586 1.414 0.225 1.053 0.586 1.414 0.862 0.586 1.414 0.586 1.053-0.225 1.414-0.586 0.586-0.862 0.586-1.414zM14 5c0-0.552-0.225-1.053-0.586-1.414s-0.862-0.586-1.414-0.586-1.053 0.225-1.414 0.586-0.586 0.862-0.586 1.414 0.225 1.053 0.586 1.414 0.862 0.586 1.414 0.586 1.053-0.225 1.414-0.586 0.586-0.862 0.586-1.414zM14 19c0-0.552-0.225-1.053-0.586-1.414s-0.862-0.586-1.414-0.586-1.053 0.225-1.414 0.586-0.586 0.862-0.586 1.414 0.225 1.053 0.586 1.414 0.862 0.586 1.414 0.586 1.053-0.225 1.414-0.586 0.586-0.862 0.586-1.414z"></path>
+          </symbol>
+          <symbol id="icon-bell" viewBox="0 0 24 24">
+            <path d="M17 8c0 4.011 0.947 6.52 1.851 8h-13.702c0.904-1.48 1.851-3.989 1.851-8 0-1.381 0.559-2.63 1.464-3.536s2.155-1.464 3.536-1.464 2.63 0.559 3.536 1.464 1.464 2.155 1.464 3.536zM19 8c0-1.933-0.785-3.684-2.050-4.95s-3.017-2.050-4.95-2.050-3.684 0.785-4.95 2.050-2.050 3.017-2.050 4.95c0 6.127-2.393 8.047-2.563 8.174-0.453 0.308-0.573 0.924-0.269 1.381 0.192 0.287 0.506 0.443 0.832 0.445h18c0.552 0 1-0.448 1-1 0-0.339-0.168-0.638-0.429-0.821-0.176-0.13-2.571-2.050-2.571-8.179zM12.865 20.498c-0.139 0.239-0.359 0.399-0.608 0.465s-0.52 0.037-0.759-0.101c-0.162-0.094-0.283-0.222-0.359-0.357-0.274-0.48-0.884-0.647-1.364-0.373s-0.647 0.884-0.373 1.364c0.25 0.439 0.623 0.823 1.093 1.096 0.716 0.416 1.535 0.501 2.276 0.304s1.409-0.678 1.824-1.394c0.277-0.478 0.114-1.090-0.363-1.367s-1.090-0.114-1.367 0.363z"></path>
+          </symbol>
+          <symbol id="icon-file-text" viewBox="0 0 24 24">
+            <path d="M17.586 7h-2.586v-2.586zM20.707 7.293l-6-6c-0.092-0.092-0.202-0.166-0.324-0.217s-0.253-0.076-0.383-0.076h-8c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v16c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879h12c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121v-12c0-0.276-0.112-0.526-0.293-0.707zM13 3v5c0 0.552 0.448 1 1 1h5v11c0 0.276-0.111 0.525-0.293 0.707s-0.431 0.293-0.707 0.293h-12c-0.276 0-0.525-0.111-0.707-0.293s-0.293-0.431-0.293-0.707v-16c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293zM16 12h-8c-0.552 0-1 0.448-1 1s0.448 1 1 1h8c0.552 0 1-0.448 1-1s-0.448-1-1-1zM16 16h-8c-0.552 0-1 0.448-1 1s0.448 1 1 1h8c0.552 0 1-0.448 1-1s-0.448-1-1-1zM10 8h-2c-0.552 0-1 0.448-1 1s0.448 1 1 1h2c0.552 0 1-0.448 1-1s-0.448-1-1-1z"></path>
+          </symbol>
+          <symbol id="icon-eye" viewBox="0 0 24 24">
+            <path d="M0.106 11.553c-0.136 0.274-0.146 0.603 0 0.894 0 0 0.396 0.789 1.12 1.843 0.451 0.656 1.038 1.432 1.757 2.218 0.894 0.979 2.004 1.987 3.319 2.8 1.595 0.986 3.506 1.692 5.698 1.692s4.103-0.706 5.698-1.692c1.315-0.813 2.425-1.821 3.319-2.8 0.718-0.786 1.306-1.562 1.757-2.218 0.724-1.054 1.12-1.843 1.12-1.843 0.136-0.274 0.146-0.603 0-0.894 0 0-0.396-0.789-1.12-1.843-0.451-0.656-1.038-1.432-1.757-2.218-0.894-0.979-2.004-1.987-3.319-2.8-1.595-0.986-3.506-1.692-5.698-1.692s-4.103 0.706-5.698 1.692c-1.315 0.813-2.425 1.821-3.319 2.8-0.719 0.786-1.306 1.561-1.757 2.218-0.724 1.054-1.12 1.843-1.12 1.843zM2.14 12c0.163-0.281 0.407-0.681 0.734-1.158 0.41-0.596 0.94-1.296 1.585-2.001 0.805-0.881 1.775-1.756 2.894-2.448 1.35-0.834 2.901-1.393 4.647-1.393s3.297 0.559 4.646 1.393c1.119 0.692 2.089 1.567 2.894 2.448 0.644 0.705 1.175 1.405 1.585 2.001 0.328 0.477 0.572 0.876 0.734 1.158-0.163 0.281-0.407 0.681-0.734 1.158-0.41 0.596-0.94 1.296-1.585 2.001-0.805 0.881-1.775 1.756-2.894 2.448-1.349 0.834-2.9 1.393-4.646 1.393s-3.297-0.559-4.646-1.393c-1.119-0.692-2.089-1.567-2.894-2.448-0.644-0.705-1.175-1.405-1.585-2.001-0.328-0.477-0.572-0.877-0.735-1.158zM16 12c0-1.104-0.449-2.106-1.172-2.828s-1.724-1.172-2.828-1.172-2.106 0.449-2.828 1.172-1.172 1.724-1.172 2.828 0.449 2.106 1.172 2.828 1.724 1.172 2.828 1.172 2.106-0.449 2.828-1.172 1.172-1.724 1.172-2.828zM14 12c0 0.553-0.223 1.051-0.586 1.414s-0.861 0.586-1.414 0.586-1.051-0.223-1.414-0.586-0.586-0.861-0.586-1.414 0.223-1.051 0.586-1.414 0.861-0.586 1.414-0.586 1.051 0.223 1.414 0.586 0.586 0.861 0.586 1.414z"></path>
+          </symbol>
+          <symbol id="icon-edit" viewBox="0 0 24 24">
+            <path d="M11 3h-7c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v14c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879h14c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121v-7c0-0.552-0.448-1-1-1s-1 0.448-1 1v7c0 0.276-0.111 0.525-0.293 0.707s-0.431 0.293-0.707 0.293h-14c-0.276 0-0.525-0.111-0.707-0.293s-0.293-0.431-0.293-0.707v-14c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293h7c0.552 0 1-0.448 1-1s-0.448-1-1-1zM17.793 1.793l-9.5 9.5c-0.122 0.121-0.217 0.28-0.263 0.465l-1 4c-0.039 0.15-0.042 0.318 0 0.485 0.134 0.536 0.677 0.862 1.213 0.728l4-1c0.167-0.041 0.33-0.129 0.465-0.263l9.5-9.5c0.609-0.609 0.914-1.41 0.914-2.207s-0.305-1.598-0.914-2.207-1.411-0.915-2.208-0.915-1.598 0.305-2.207 0.914zM19.207 3.207c0.219-0.219 0.504-0.328 0.793-0.328s0.574 0.109 0.793 0.328 0.328 0.504 0.328 0.793-0.109 0.574-0.328 0.793l-9.304 9.304-2.114 0.529 0.529-2.114z"></path>
+          </symbol>
+          <symbol id="icon-edit-2" viewBox="0 0 24 24">
+            <path d="M16.293 2.293l-13.5 13.5c-0.117 0.116-0.21 0.268-0.258 0.444l-1.5 5.5c-0.046 0.163-0.049 0.346 0 0.526 0.145 0.533 0.695 0.847 1.228 0.702l5.5-1.5c0.159-0.042 0.315-0.129 0.444-0.258l13.5-13.5c0.747-0.747 1.121-1.729 1.121-2.707s-0.374-1.96-1.121-2.707-1.729-1.121-2.707-1.121-1.96 0.374-2.707 1.121zM17.707 3.707c0.357-0.357 0.824-0.535 1.293-0.535s0.936 0.178 1.293 0.536 0.535 0.823 0.535 1.292-0.178 0.936-0.535 1.293l-13.312 13.312-3.556 0.97 0.97-3.555z"></path>
+          </symbol>
+          <symbol id="icon-trash" viewBox="0 0 24 24">
+            <path d="M18 7v13c0 0.276-0.111 0.525-0.293 0.707s-0.431 0.293-0.707 0.293h-10c-0.276 0-0.525-0.111-0.707-0.293s-0.293-0.431-0.293-0.707v-13zM17 5v-1c0-0.828-0.337-1.58-0.879-2.121s-1.293-0.879-2.121-0.879h-4c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v1h-4c-0.552 0-1 0.448-1 1s0.448 1 1 1h1v13c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879h10c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121v-13h1c0.552 0 1-0.448 1-1s-0.448-1-1-1zM9 5v-1c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293h4c0.276 0 0.525 0.111 0.707 0.293s0.293 0.431 0.293 0.707v1z"></path>
+          </symbol>
+          <symbol id="icon-reply1" viewBox="0 0 20 20">
+            <path d="M19 16.685c0 0-2.225-9.732-11-9.732v-3.984l-7 6.573 7 6.69v-4.357c4.763-0.001 8.516 0.421 11 4.81z"></path>
+          </symbol>
+          <symbol id="icon-star" viewBox="0 0 24 24">
+            <path d="M12.897 1.557c-0.092-0.189-0.248-0.352-0.454-0.454-0.495-0.244-1.095-0.041-1.339 0.454l-2.858 5.789-6.391 0.935c-0.208 0.029-0.411 0.127-0.571 0.291-0.386 0.396-0.377 1.029 0.018 1.414l4.623 4.503-1.091 6.362c-0.036 0.207-0.006 0.431 0.101 0.634 0.257 0.489 0.862 0.677 1.351 0.42l5.714-3.005 5.715 3.005c0.186 0.099 0.408 0.139 0.634 0.101 0.544-0.093 0.91-0.61 0.817-1.155l-1.091-6.362 4.623-4.503c0.151-0.146 0.259-0.344 0.292-0.572 0.080-0.546-0.298-1.054-0.845-1.134l-6.39-0.934zM12 4.259l2.193 4.444c0.151 0.305 0.436 0.499 0.752 0.547l4.906 0.717-3.549 3.457c-0.244 0.238-0.341 0.569-0.288 0.885l0.837 4.883-4.386-2.307c-0.301-0.158-0.647-0.148-0.931 0l-4.386 2.307 0.837-4.883c0.058-0.336-0.059-0.661-0.288-0.885l-3.549-3.457 4.907-0.718c0.336-0.049 0.609-0.26 0.752-0.546z"></path>
+          </symbol>
+          <symbol id="icon-message-square" viewBox="0 0 24 24">
+            <path d="M22 15v-10c0-0.828-0.337-1.58-0.879-2.121s-1.293-0.879-2.121-0.879h-14c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v16c0 0.256 0.098 0.512 0.293 0.707 0.391 0.391 1.024 0.391 1.414 0l3.707-3.707h11.586c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121zM20 15c0 0.276-0.111 0.525-0.293 0.707s-0.431 0.293-0.707 0.293h-12c-0.276 0-0.526 0.112-0.707 0.293l-2.293 2.293v-13.586c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293h14c0.276 0 0.525 0.111 0.707 0.293s0.293 0.431 0.293 0.707z"></path>
+          </symbol>
+          <symbol id="icon-image" viewBox="0 0 24 24">
+            <path d="M5 2c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v14c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879h14c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121v-14c0-0.828-0.337-1.58-0.879-2.121s-1.293-0.879-2.121-0.879zM11 8.5c0-0.69-0.281-1.316-0.732-1.768s-1.078-0.732-1.768-0.732-1.316 0.281-1.768 0.732-0.732 1.078-0.732 1.768 0.281 1.316 0.732 1.768 1.078 0.732 1.768 0.732 1.316-0.281 1.768-0.732 0.732-1.078 0.732-1.768zM9 8.5c0 0.138-0.055 0.262-0.146 0.354s-0.216 0.146-0.354 0.146-0.262-0.055-0.354-0.146-0.146-0.216-0.146-0.354 0.055-0.262 0.146-0.354 0.216-0.146 0.354-0.146 0.262 0.055 0.354 0.146 0.146 0.216 0.146 0.354zM7.414 20l8.586-8.586 4 4v3.586c0 0.276-0.111 0.525-0.293 0.707s-0.431 0.293-0.707 0.293zM20 12.586l-3.293-3.293c-0.391-0.391-1.024-0.391-1.414 0l-10.644 10.644c-0.135-0.050-0.255-0.129-0.356-0.23-0.182-0.182-0.293-0.431-0.293-0.707v-14c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293h14c0.276 0 0.525 0.111 0.707 0.293s0.293 0.431 0.293 0.707z"></path>
+          </symbol>
+          <symbol id="icon-external-link" viewBox="0 0 24 24">
+            <path d="M17 13v6c0 0.276-0.111 0.525-0.293 0.707s-0.431 0.293-0.707 0.293h-11c-0.276 0-0.525-0.111-0.707-0.293s-0.293-0.431-0.293-0.707v-11c0-0.276 0.111-0.525 0.293-0.707s0.431-0.293 0.707-0.293h6c0.552 0 1-0.448 1-1s-0.448-1-1-1h-6c-0.828 0-1.58 0.337-2.121 0.879s-0.879 1.293-0.879 2.121v11c0 0.828 0.337 1.58 0.879 2.121s1.293 0.879 2.121 0.879h11c0.828 0 1.58-0.337 2.121-0.879s0.879-1.293 0.879-2.121v-6c0-0.552-0.448-1-1-1s-1 0.448-1 1zM10.707 14.707l9.293-9.293v3.586c0 0.552 0.448 1 1 1s1-0.448 1-1v-6c0-0.136-0.027-0.265-0.076-0.383s-0.121-0.228-0.216-0.323c-0.001-0.001-0.001-0.001-0.002-0.002-0.092-0.092-0.202-0.166-0.323-0.216-0.118-0.049-0.247-0.076-0.383-0.076h-6c-0.552 0-1 0.448-1 1s0.448 1 1 1h3.586l-9.293 9.293c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0z"></path>
+          </symbol>
+          <symbol id="icon-coffee" viewBox="0 0 24 24">
+            <path d="M17 19h-12c-0.553 0-1-0.447-1-1s0.447-1 1-1h12c0.553 0 1 0.447 1 1s-0.447 1-1 1z"></path>
+            <path d="M17.5 5h-12.5v9c0 1.1 0.9 2 2 2h8c1.1 0 2-0.9 2-2v-2h0.5c1.93 0 3.5-1.57 3.5-3.5s-1.57-3.5-3.5-3.5zM15 14h-8v-7h8v7zM17.5 10h-1.5v-3h1.5c0.827 0 1.5 0.673 1.5 1.5s-0.673 1.5-1.5 1.5z"></path>
+          </symbol>
+          <symbol id="icon-rss" viewBox="0 0 24 24">
+            <path d="M4 12c2.209 0 4.208 0.894 5.657 2.343s2.343 3.448 2.343 5.657c0 0.552 0.448 1 1 1s1-0.448 1-1c0-2.761-1.12-5.263-2.929-7.071s-4.31-2.929-7.071-2.929c-0.552 0-1 0.448-1 1s0.448 1 1 1zM4 5c4.142 0 7.891 1.678 10.607 4.393s4.393 6.465 4.393 10.607c0 0.552 0.448 1 1 1s1-0.448 1-1c0-4.694-1.904-8.946-4.979-12.021s-7.327-4.979-12.021-4.979c-0.552 0-1 0.448-1 1s0.448 1 1 1zM7 19c0-0.552-0.225-1.053-0.586-1.414s-0.862-0.586-1.414-0.586-1.053 0.225-1.414 0.586-0.586 0.862-0.586 1.414 0.225 1.053 0.586 1.414 0.862 0.586 1.414 0.586 1.053-0.225 1.414-0.586 0.586-0.862 0.586-1.414z"></path>
+          </symbol>
+          <symbol id="icon-arrow-down" viewBox="0 0 24 24">
+            <path d="M18.293 11.293l-5.293 5.293v-11.586c0-0.552-0.448-1-1-1s-1 0.448-1 1v11.586l-5.293-5.293c-0.391-0.391-1.024-0.391-1.414 0s-0.391 1.024 0 1.414l7 7c0.092 0.092 0.202 0.166 0.324 0.217 0.245 0.101 0.521 0.101 0.766 0 0.118-0.049 0.228-0.121 0.324-0.217l7-7c0.391-0.391 0.391-1.024 0-1.414s-1.024-0.391-1.414 0z"></path>
+          </symbol>
+          <symbol id="icon-arrow-up" viewBox="0 0 24 24">
+            <path d="M5.707 12.707l5.293-5.293v11.586c0 0.552 0.448 1 1 1s1-0.448 1-1v-11.586l5.293 5.293c0.391 0.391 1.024 0.391 1.414 0s0.391-1.024 0-1.414l-7-7c-0.092-0.092-0.202-0.166-0.324-0.217s-0.253-0.076-0.383-0.076c-0.256 0-0.512 0.098-0.707 0.293l-7 7c-0.391 0.391-0.391 1.024 0 1.414s1.024 0.391 1.414 0z"></path>
+          </symbol>
+          <symbol id="icon-arrow-up1" viewBox="0 0 26 28">
+            <path d="M25.172 15.172c0 0.531-0.219 1.031-0.578 1.406l-1.172 1.172c-0.375 0.375-0.891 0.594-1.422 0.594s-1.047-0.219-1.406-0.594l-4.594-4.578v11c0 1.125-0.938 1.828-2 1.828h-2c-1.062 0-2-0.703-2-1.828v-11l-4.594 4.578c-0.359 0.375-0.875 0.594-1.406 0.594s-1.047-0.219-1.406-0.594l-1.172-1.172c-0.375-0.375-0.594-0.875-0.594-1.406s0.219-1.047 0.594-1.422l10.172-10.172c0.359-0.375 0.875-0.578 1.406-0.578s1.047 0.203 1.422 0.578l10.172 10.172c0.359 0.375 0.578 0.891 0.578 1.422z"></path>
+          </symbol>
+          <symbol id="icon-arrow-down1" viewBox="0 0 26 28">
+            <path d="M25.172 13c0 0.531-0.219 1.047-0.578 1.406l-10.172 10.187c-0.375 0.359-0.891 0.578-1.422 0.578s-1.047-0.219-1.406-0.578l-10.172-10.187c-0.375-0.359-0.594-0.875-0.594-1.406s0.219-1.047 0.594-1.422l1.156-1.172c0.375-0.359 0.891-0.578 1.422-0.578s1.047 0.219 1.406 0.578l4.594 4.594v-11c0-1.094 0.906-2 2-2h2c1.094 0 2 0.906 2 2v11l4.594-4.594c0.359-0.359 0.875-0.578 1.406-0.578s1.047 0.219 1.422 0.578l1.172 1.172c0.359 0.375 0.578 0.891 0.578 1.422z"></path>
+          </symbol>
+          <symbol id="icon-mail" viewBox="0 0 24 24">
+            <path d="M3 7.921l8.427 5.899c0.34 0.235 0.795 0.246 1.147 0l8.426-5.899v10.079c0 0.272-0.11 0.521-0.295 0.705s-0.433 0.295-0.705 0.295h-16c-0.272 0-0.521-0.11-0.705-0.295s-0.295-0.433-0.295-0.705zM1 5.983c0 0.010 0 0.020 0 0.030v11.987c0 0.828 0.34 1.579 0.88 2.12s1.292 0.88 2.12 0.88h16c0.828 0 1.579-0.34 2.12-0.88s0.88-1.292 0.88-2.12v-11.988c0-0.010 0-0.020 0-0.030-0.005-0.821-0.343-1.565-0.88-2.102-0.541-0.54-1.292-0.88-2.12-0.88h-16c-0.828 0-1.579 0.34-2.12 0.88-0.537 0.537-0.875 1.281-0.88 2.103zM20.894 5.554l-8.894 6.225-8.894-6.225c0.048-0.096 0.112-0.183 0.188-0.259 0.185-0.185 0.434-0.295 0.706-0.295h16c0.272 0 0.521 0.11 0.705 0.295 0.076 0.076 0.14 0.164 0.188 0.259z"></path>
+          </symbol>
+          <symbol
+            id="icon-mouse"
+            version="1.1"
+            x="0px"
+            y="0px"
+            viewBox="0 0 1024 1024"
+          >
+            <g
+              id="layer1"
+              transform="translate(0,-26.066658)"
+              style="display:inline"
+            >
+              <path
+                style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-feature-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#ffffff;stroke-width:28;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate"
+                d="m 167.03908,270.78735 c -0.94784,-0.002 -1.8939,0.004 -2.83789,0.0215 -4.31538,0.0778 -8.58934,0.3593 -12.8125,0.8457 -33.78522,3.89116 -64.215716,21.86394 -82.871086,53.27344 -18.27982,30.77718 -22.77749,64.66635 -13.46094,96.06837 9.31655,31.40203 31.88488,59.93174 65.296886,82.5332 0.20163,0.13618 0.40678,0.26709 0.61523,0.39258 28.65434,17.27768 57.18167,28.93179 87.74218,34.95508 -0.74566,12.61339 -0.72532,25.5717 0.082,38.84375 2.43989,40.10943 16.60718,77.03742 38.0957,109.67187 l -77.00781,31.4375 c -8.30605,3.25932 -12.34178,12.68234 -8.96967,20.94324 3.37211,8.2609 12.84919,12.16798 21.06342,8.68371 l 84.69727,-34.57617 c 15.70675,18.72702 33.75346,35.68305 53.12109,50.57032 0.74013,0.56891 1.4904,1.12236 2.23437,1.68554 l -49.61132,65.69141 c -5.45446,7.0474 -4.10058,17.19288 3.01098,22.5634 7.11156,5.37052 17.24028,3.89649 22.52612,-3.27824 l 50.38672,-66.71876 c 27.68572,17.53469 57.07524,31.20388 86.07227,40.25196 14.88153,27.28008 43.96965,44.64648 77.58789,44.64648 33.93762,0 63.04252,-18.68693 77.80082,-45.4375 28.7072,-9.21295 57.7527,-22.93196 85.1484,-40.40234 l 51.0977,67.66016 c 5.2858,7.17473 15.4145,8.64876 22.5261,3.27824 7.1115,-5.37052 8.4654,-15.516 3.011,-22.5634 l -50.3614,-66.68555 c 0.334,-0.25394 0.6727,-0.50077 1.0059,-0.75586 19.1376,-14.64919 37.0259,-31.28581 52.7031,-49.63476 l 82.5625,33.70507 c 8.2143,3.48427 17.6913,-0.42281 21.0634,-8.68371 3.3722,-8.2609 -0.6636,-17.68392 -8.9696,-20.94324 l -74.5391,-30.42773 c 22.1722,-32.82971 37.0383,-70.03397 40.1426,-110.46094 1.0253,-13.35251 1.2292,-26.42535 0.6387,-39.17578 30.3557,-6.05408 58.7164,-17.66833 87.2011,-34.84375 0.2085,-0.12549 0.4136,-0.2564 0.6153,-0.39258 33.412,-22.60147 55.9803,-51.13117 65.2968,-82.5332 9.3166,-31.40202 4.8189,-65.29118 -13.4609,-96.06837 -18.6553,-31.40951 -49.0859,-49.38228 -82.8711,-53.27344 -4.2231,-0.4864 -8.4971,-0.76791 -12.8125,-0.8457 -30.2077,-0.54448 -62.4407,8.82427 -93.4316,26.71484 -22.7976,13.16063 -43.3521,33.31423 -59.4375,55.30469 -44.9968,-25.75094 -103.5444,-40.25065 -175.4785,-41.43945 -6.4522,-0.10663 -13.0125,-0.10696 -19.67974,0.002 -80.18875,1.30929 -144.38284,16.5086 -192.87109,43.9922 -0.11914,-0.19111 -0.24287,-0.37932 -0.37109,-0.56446 -16.29,-22.764 -37.41085,-43.73706 -60.89649,-57.29493 -30.02247,-17.33149 -61.21051,-26.66489 -90.59375,-26.73633 z"
+                id="path817-3"
+              />
+              <path
+                id="path1087"
+                style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:28;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+                d="m 716.85595,362.96478 c 15.29075,-21.36763 35.36198,-41.10921 56.50979,-53.31749 66.66377,-38.48393 137.02617,-33.22172 170.08018,22.43043 33.09493,55.72093 14.98656,117.48866 -47.64399,159.85496 -31.95554,19.26819 -62.93318,30.92309 -97.22892,35.54473 M 307.14407,362.96478 C 291.85332,341.59715 271.78209,321.85557 250.63429,309.64729 183.97051,271.16336 113.60811,276.42557 80.554051,332.07772 47.459131,387.79865 65.56752,449.56638 128.19809,491.93268 c 31.95554,19.26819 62.93319,30.92309 97.22893,35.54473"
+              />
+              <path
+                style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:28;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+                d="M 801.23205,576.8699 C 812.73478,427.06971 720.58431,321.98291 511.99999,325.38859 303.41568,328.79426 213.71393,428.0311 222.76794,576.8699 c 8.64289,142.08048 176.80223,246.40388 288.12038,246.40388 111.31815,0 279.45076,-104.5447 290.34373,-246.40388 z"
+                id="path969"
+              />
+              <path
+                id="path1084"
+                style="display:inline;opacity:1;fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+                d="m 610.4991,644.28932 c 0,23.11198 18.70595,41.84795 41.78091,41.84795 23.07495,0 41.7809,-18.73597 41.7809,-41.84795 0,-23.112 -18.70594,-41.84796 -41.7809,-41.84796 -23.07496,0 -41.78091,18.73596 -41.78091,41.84796 z m -280.56002,0 c 0,23.32492 18.87829,42.23352 42.16586,42.23352 23.28755,0 42.16585,-18.9086 42.16585,-42.23352 0,-23.32494 -18.87829,-42.23353 -42.16585,-42.23353 -23.28757,0 -42.16586,18.90859 -42.16586,42.23353 z"
+              />
+              <path
+                id="path1008"
+                style="display:inline;opacity:1;fill:none;stroke:#000000;stroke-width:32;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+                d="m 339.72919,769.2467 -54.54422,72.22481 m 399.08582,-72.22481 54.54423,72.22481 M 263.68341,697.82002 175.92752,733.64353 m 579.85765,-35.82351 87.7559,35.82351"
+              />
+              <path
+                style="display:inline;opacity:1;fill:#ffffff;fill-opacity:1;stroke:#000000;stroke-width:28;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
+                d="m 512.00082,713.08977 c -45.86417,0 -75.13006,31.84485 -74.14159,71.10084 1.07048,42.51275 32.46865,71.10323 74.14159,71.10323 41.67296,0 74.05118,-32.99608 74.14161,-71.10323 0.0932,-39.26839 -28.27742,-71.10084 -74.14161,-71.10084 z"
+                id="path1115"
+              />
+            </g>
+          </symbol>
+          <symbol id="icon-search" viewBox="0 0 24 24">
+            <path d="M16.041 15.856c-0.034 0.026-0.067 0.055-0.099 0.087s-0.060 0.064-0.087 0.099c-1.258 1.213-2.969 1.958-4.855 1.958-1.933 0-3.682-0.782-4.95-2.050s-2.050-3.017-2.050-4.95 0.782-3.682 2.050-4.95 3.017-2.050 4.95-2.050 3.682 0.782 4.95 2.050 2.050 3.017 2.050 4.95c0 1.886-0.745 3.597-1.959 4.856zM21.707 20.293l-3.675-3.675c1.231-1.54 1.968-3.493 1.968-5.618 0-2.485-1.008-4.736-2.636-6.364s-3.879-2.636-6.364-2.636-4.736 1.008-6.364 2.636-2.636 3.879-2.636 6.364 1.008 4.736 2.636 6.364 3.879 2.636 6.364 2.636c2.125 0 4.078-0.737 5.618-1.968l3.675 3.675c0.391 0.391 1.024 0.391 1.414 0s0.391-1.024 0-1.414z"></path>
+          </symbol>
+          <symbol id="icon-github" viewBox="0 0 32 32">
+            <path d="M16 0.395c-8.836 0-16 7.163-16 16 0 7.069 4.585 13.067 10.942 15.182 0.8 0.148 1.094-0.347 1.094-0.77 0-0.381-0.015-1.642-0.022-2.979-4.452 0.968-5.391-1.888-5.391-1.888-0.728-1.849-1.776-2.341-1.776-2.341-1.452-0.993 0.11-0.973 0.11-0.973 1.606 0.113 2.452 1.649 2.452 1.649 1.427 2.446 3.743 1.739 4.656 1.33 0.143-1.034 0.558-1.74 1.016-2.14-3.554-0.404-7.29-1.777-7.29-7.907 0-1.747 0.625-3.174 1.649-4.295-0.166-0.403-0.714-2.030 0.155-4.234 0 0 1.344-0.43 4.401 1.64 1.276-0.355 2.645-0.532 4.005-0.539 1.359 0.006 2.729 0.184 4.008 0.539 3.054-2.070 4.395-1.64 4.395-1.64 0.871 2.204 0.323 3.831 0.157 4.234 1.026 1.12 1.647 2.548 1.647 4.295 0 6.145-3.743 7.498-7.306 7.895 0.574 0.497 1.085 1.47 1.085 2.963 0 2.141-0.019 3.864-0.019 4.391 0 0.426 0.288 0.925 1.099 0.768 6.354-2.118 10.933-8.113 10.933-15.18 0-8.837-7.164-16-16-16z"></path>
+          </symbol>
+          <symbol id="icon-spinner" viewBox="0 0 32 32">
+            <path d="M16 32c-4.274 0-8.292-1.664-11.314-4.686s-4.686-7.040-4.686-11.314c0-3.026 0.849-5.973 2.456-8.522 1.563-2.478 3.771-4.48 6.386-5.791l1.344 2.682c-2.126 1.065-3.922 2.693-5.192 4.708-1.305 2.069-1.994 4.462-1.994 6.922 0 7.168 5.832 13 13 13s13-5.832 13-13c0-2.459-0.69-4.853-1.994-6.922-1.271-2.015-3.066-3.643-5.192-4.708l1.344-2.682c2.615 1.31 4.824 3.313 6.386 5.791 1.607 2.549 2.456 5.495 2.456 8.522 0 4.274-1.664 8.292-4.686 11.314s-7.040 4.686-11.314 4.686z"></path>
+          </symbol>
+          <symbol id="icon-cake" viewBox="0 0 24 24">
+            <path d="M 23.296875 22.394531 L 22.082031 22.394531 L 22.082031 17.007812 C 22.453125 16.699219 22.664062 16.261719 22.664062 15.796875 L 22.664062 13.984375 C 22.664062 12.996094 21.785156 12.191406 20.703125 12.191406 L 19.785156 12.191406 L 19.785156 7.785156 C 19.785156 7.050781 19.1875 6.449219 18.449219 6.449219 L 18.367188 6.449219 L 18.367188 5.96875 C 19.199219 5.675781 19.796875 4.882812 19.796875 3.957031 C 19.796875 3.644531 19.703125 3.117188 18.996094 1.800781 C 18.632812 1.121094 18.273438 0.550781 18.257812 0.527344 C 18.128906 0.320312 17.90625 0.199219 17.664062 0.199219 C 17.421875 0.199219 17.199219 0.320312 17.070312 0.527344 C 17.054688 0.550781 16.695312 1.121094 16.332031 1.800781 C 15.621094 3.117188 15.53125 3.644531 15.53125 3.957031 C 15.53125 4.882812 16.128906 5.675781 16.960938 5.96875 L 16.960938 6.449219 L 16.878906 6.449219 C 16.140625 6.449219 15.542969 7.050781 15.542969 7.785156 L 15.542969 12.191406 L 14.121094 12.191406 L 14.121094 7.785156 C 14.121094 7.050781 13.523438 6.449219 12.785156 6.449219 L 12.703125 6.449219 L 12.703125 5.96875 C 13.535156 5.675781 14.132812 4.882812 14.132812 3.957031 C 14.132812 3.644531 14.039062 3.117188 13.332031 1.800781 C 12.96875 1.121094 12.609375 0.550781 12.59375 0.527344 C 12.464844 0.320312 12.242188 0.199219 12 0.199219 C 11.757812 0.199219 11.535156 0.320312 11.40625 0.527344 C 11.390625 0.550781 11.03125 1.121094 10.667969 1.800781 C 9.960938 3.117188 9.867188 3.644531 9.867188 3.957031 C 9.867188 4.882812 10.464844 5.675781 11.296875 5.96875 L 11.296875 6.449219 L 11.214844 6.449219 C 10.476562 6.449219 9.878906 7.050781 9.878906 7.785156 L 9.878906 12.191406 L 8.457031 12.191406 L 8.457031 7.785156 C 8.457031 7.050781 7.859375 6.449219 7.121094 6.449219 L 7.039062 6.449219 L 7.039062 5.96875 C 7.871094 5.675781 8.46875 4.882812 8.46875 3.957031 C 8.46875 3.644531 8.378906 3.117188 7.667969 1.800781 C 7.304688 1.121094 6.945312 0.550781 6.929688 0.527344 C 6.800781 0.320312 6.578125 0.199219 6.335938 0.199219 C 6.09375 0.199219 5.871094 0.320312 5.742188 0.527344 C 5.726562 0.550781 5.367188 1.121094 5.003906 1.800781 C 4.296875 3.117188 4.203125 3.644531 4.203125 3.957031 C 4.203125 4.882812 4.800781 5.675781 5.632812 5.96875 L 5.632812 6.449219 L 5.550781 6.449219 C 4.8125 6.449219 4.214844 7.050781 4.214844 7.785156 L 4.214844 12.191406 L 3.296875 12.191406 C 2.214844 12.191406 1.335938 12.996094 1.335938 13.984375 L 1.335938 15.796875 C 1.335938 16.261719 1.546875 16.699219 1.917969 17.007812 L 1.917969 22.394531 L 0.703125 22.394531 C 0.316406 22.394531 0 22.710938 0 23.097656 C 0 23.488281 0.316406 23.800781 0.703125 23.800781 L 23.296875 23.800781 C 23.683594 23.800781 24 23.488281 24 23.097656 C 24 22.710938 23.683594 22.394531 23.296875 22.394531 Z M 16.9375 3.957031 C 16.941406 3.730469 17.246094 3.054688 17.664062 2.289062 C 18.082031 3.054688 18.382812 3.730469 18.390625 3.957031 C 18.390625 4.355469 18.0625 4.679688 17.664062 4.679688 C 17.265625 4.679688 16.9375 4.355469 16.9375 3.957031 Z M 16.949219 7.855469 L 18.378906 7.855469 L 18.378906 12.1875 L 16.949219 12.1875 Z M 11.273438 3.957031 C 11.277344 3.730469 11.582031 3.054688 12 2.289062 C 12.417969 3.054688 12.722656 3.730469 12.726562 3.957031 C 12.726562 4.355469 12.398438 4.679688 12 4.679688 C 11.601562 4.679688 11.273438 4.355469 11.273438 3.957031 Z M 11.285156 7.855469 L 12.714844 7.855469 L 12.714844 12.1875 L 11.285156 12.1875 Z M 5.609375 3.957031 C 5.613281 3.730469 5.917969 3.054688 6.335938 2.289062 C 6.753906 3.054688 7.058594 3.730469 7.0625 3.957031 C 7.0625 4.355469 6.734375 4.679688 6.335938 4.679688 C 5.9375 4.679688 5.609375 4.355469 5.609375 3.957031 Z M 5.621094 7.855469 L 7.050781 7.855469 L 7.050781 12.1875 L 5.621094 12.1875 Z M 20.675781 22.394531 L 3.324219 22.394531 L 3.324219 17.414062 C 3.433594 17.398438 3.546875 17.378906 3.652344 17.347656 L 5.429688 16.820312 C 6.453125 16.515625 7.582031 16.515625 8.609375 16.820312 L 10.011719 17.234375 C 10.652344 17.425781 11.324219 17.519531 12 17.519531 C 12.675781 17.519531 13.347656 17.425781 13.988281 17.234375 L 15.390625 16.820312 C 16.417969 16.515625 17.546875 16.515625 18.570312 16.820312 L 20.347656 17.347656 C 20.453125 17.378906 20.5625 17.398438 20.675781 17.414062 Z M 21.257812 15.796875 C 21.257812 15.855469 21.210938 15.902344 21.171875 15.933594 C 21.082031 16 20.925781 16.050781 20.746094 15.996094 L 18.972656 15.472656 C 17.6875 15.09375 16.273438 15.09375 14.992188 15.472656 L 13.589844 15.886719 C 12.566406 16.191406 11.433594 16.191406 10.410156 15.886719 L 9.007812 15.472656 C 8.367188 15.28125 7.691406 15.1875 7.019531 15.1875 C 6.34375 15.1875 5.671875 15.28125 5.027344 15.472656 L 3.253906 15.996094 C 3.074219 16.050781 2.917969 16 2.828125 15.933594 C 2.789062 15.902344 2.742188 15.855469 2.742188 15.796875 L 2.742188 13.984375 C 2.742188 13.800781 2.96875 13.597656 3.296875 13.597656 L 20.703125 13.597656 C 21.03125 13.597656 21.257812 13.800781 21.257812 13.984375 Z M 21.257812 15.796875 " />
+          </symbol>
+          <symbol id="icon-subscript" viewBox="0 0 20 20">
+            <path d="M13.68 16h-2.42a.67.67 0 0 1-.46-.15 1.33 1.33 0 0 1-.28-.34l-2.77-4.44a2.65 2.65 0 0 1-.28.69L5 15.51a2.22 2.22 0 0 1-.29.34.58.58 0 0 1-.42.15H2l4.15-6.19L2.17 4h2.42a.81.81 0 0 1 .41.09.8.8 0 0 1 .24.26L8 8.59a2.71 2.71 0 0 1 .33-.74L10.6 4.4a.69.69 0 0 1 .6-.4h2.32l-4 5.71zm3.82-4h.5v-1h-.5a1.49 1.49 0 0 0-1 .39 1.49 1.49 0 0 0-1-.39H15v1h.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5H15v1h.5a1.49 1.49 0 0 0 1-.39 1.49 1.49 0 0 0 1 .39h.5v-1h-.5a.5.5 0 0 1-.5-.5v-6a.5.5 0 0 1 .5-.5z" />
+          </symbol>
+          <symbol id="icon-superscript" viewBox="0 0 20 20">
+            <path d="M17.5 1h.5V0h-.5a1.49 1.49 0 0 0-1 .39 1.49 1.49 0 0 0-1-.39H15v1h.5a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-.5.5H15v1h.5a1.49 1.49 0 0 0 1-.39 1.49 1.49 0 0 0 1 .39h.5V8h-.5a.5.5 0 0 1-.5-.5v-6a.5.5 0 0 1 .5-.5zm-3.82 15h-2.42a.67.67 0 0 1-.46-.15 1.33 1.33 0 0 1-.28-.34l-2.77-4.44a2.65 2.65 0 0 1-.28.69L5 15.51a2.22 2.22 0 0 1-.29.34.58.58 0 0 1-.42.15H2l4.15-6.19L2.17 4h2.42a.81.81 0 0 1 .41.09.8.8 0 0 1 .24.26L8 8.59a2.71 2.71 0 0 1 .33-.74L10.6 4.4a.69.69 0 0 1 .6-.4h2.32l-4 5.71z" />
+          </symbol>
+        </defs>
+      </svg>
+    );
+  }
+}
diff --git a/src/shared/components/user-details.tsx b/src/shared/components/user-details.tsx
new file mode 100644 (file)
index 0000000..c249689
--- /dev/null
@@ -0,0 +1,315 @@
+import { Component, linkEvent } from 'inferno';
+import { WebSocketService, UserService } from '../services';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import { i18n } from '../i18next';
+import {
+  UserOperation,
+  Post,
+  Comment,
+  CommunityUser,
+  SortType,
+  UserDetailsResponse,
+  UserView,
+  WebSocketJsonResponse,
+  CommentResponse,
+  BanUserResponse,
+  PostResponse,
+} from 'lemmy-js-client';
+import { UserDetailsView } from '../interfaces';
+import {
+  wsJsonToRes,
+  toast,
+  commentsToFlatNodes,
+  setupTippy,
+  editCommentRes,
+  saveCommentRes,
+  createCommentLikeRes,
+  createPostLikeFindRes,
+} from '../utils';
+import { PostListing } from './post-listing';
+import { CommentNodes } from './comment-nodes';
+
+interface UserDetailsProps {
+  username?: string;
+  user_id?: number;
+  page: number;
+  limit: number;
+  sort: SortType;
+  enableDownvotes: boolean;
+  enableNsfw: boolean;
+  view: UserDetailsView;
+  onPageChange(page: number): number | any;
+  admins: UserView[];
+}
+
+interface UserDetailsState {
+  follows: CommunityUser[];
+  moderates: CommunityUser[];
+  comments: Comment[];
+  posts: Post[];
+  saved?: Post[];
+}
+
+export class UserDetails extends Component<UserDetailsProps, UserDetailsState> {
+  private subscription: Subscription;
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = {
+      follows: [],
+      moderates: [],
+      comments: [],
+      posts: [],
+      saved: [],
+    };
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  componentDidMount() {
+    this.fetchUserData();
+    setupTippy();
+  }
+
+  componentDidUpdate(lastProps: UserDetailsProps) {
+    for (const key of Object.keys(lastProps)) {
+      if (lastProps[key] !== this.props[key]) {
+        this.fetchUserData();
+        break;
+      }
+    }
+  }
+
+  fetchUserData() {
+    WebSocketService.Instance.getUserDetails({
+      user_id: this.props.user_id,
+      username: this.props.username,
+      sort: this.props.sort,
+      saved_only: this.props.view === UserDetailsView.Saved,
+      page: this.props.page,
+      limit: this.props.limit,
+    });
+  }
+
+  render() {
+    return (
+      <div>
+        {this.viewSelector(this.props.view)}
+        {this.paginator()}
+      </div>
+    );
+  }
+
+  viewSelector(view: UserDetailsView) {
+    if (view === UserDetailsView.Overview || view === UserDetailsView.Saved) {
+      return this.overview();
+    }
+    if (view === UserDetailsView.Comments) {
+      return this.comments();
+    }
+    if (view === UserDetailsView.Posts) {
+      return this.posts();
+    }
+  }
+
+  overview() {
+    const comments = this.state.comments.map((c: Comment) => {
+      return { type: 'comments', data: c };
+    });
+    const posts = this.state.posts.map((p: Post) => {
+      return { type: 'posts', data: p };
+    });
+
+    const combined: { type: string; data: Comment | Post }[] = [
+      ...comments,
+      ...posts,
+    ];
+
+    // Sort it
+    if (this.props.sort === SortType.New) {
+      combined.sort((a, b) => b.data.published.localeCompare(a.data.published));
+    } else {
+      combined.sort((a, b) => b.data.score - a.data.score);
+    }
+
+    return (
+      <div>
+        {combined.map(i => (
+          <>
+            <div>
+              {i.type === 'posts' ? (
+                <PostListing
+                  key={(i.data as Post).id}
+                  post={i.data as Post}
+                  admins={this.props.admins}
+                  showCommunity
+                  enableDownvotes={this.props.enableDownvotes}
+                  enableNsfw={this.props.enableNsfw}
+                />
+              ) : (
+                <CommentNodes
+                  key={(i.data as Comment).id}
+                  nodes={[{ comment: i.data as Comment }]}
+                  admins={this.props.admins}
+                  noBorder
+                  noIndent
+                  showCommunity
+                  showContext
+                  enableDownvotes={this.props.enableDownvotes}
+                />
+              )}
+            </div>
+            <hr class="my-3" />
+          </>
+        ))}
+      </div>
+    );
+  }
+
+  comments() {
+    return (
+      <div>
+        <CommentNodes
+          nodes={commentsToFlatNodes(this.state.comments)}
+          admins={this.props.admins}
+          noIndent
+          showCommunity
+          showContext
+          enableDownvotes={this.props.enableDownvotes}
+        />
+      </div>
+    );
+  }
+
+  posts() {
+    return (
+      <div>
+        {this.state.posts.map(post => (
+          <>
+            <PostListing
+              post={post}
+              admins={this.props.admins}
+              showCommunity
+              enableDownvotes={this.props.enableDownvotes}
+              enableNsfw={this.props.enableNsfw}
+            />
+            <hr class="my-3" />
+          </>
+        ))}
+      </div>
+    );
+  }
+
+  paginator() {
+    return (
+      <div class="my-2">
+        {this.props.page > 1 && (
+          <button
+            class="btn btn-secondary mr-1"
+            onClick={linkEvent(this, this.prevPage)}
+          >
+            {i18n.t('prev')}
+          </button>
+        )}
+        {this.state.comments.length + this.state.posts.length > 0 && (
+          <button
+            class="btn btn-secondary"
+            onClick={linkEvent(this, this.nextPage)}
+          >
+            {i18n.t('next')}
+          </button>
+        )}
+      </div>
+    );
+  }
+
+  nextPage(i: UserDetails) {
+    i.props.onPageChange(i.props.page + 1);
+  }
+
+  prevPage(i: UserDetails) {
+    i.props.onPageChange(i.props.page - 1);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    const res = wsJsonToRes(msg);
+
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      if (msg.error == 'couldnt_find_that_username_or_email') {
+        this.context.router.history.push('/');
+      }
+      return;
+    } else if (msg.reconnect) {
+      this.fetchUserData();
+    } else if (res.op == UserOperation.GetUserDetails) {
+      const data = res.data as UserDetailsResponse;
+      this.setState({
+        comments: data.comments,
+        follows: data.follows,
+        moderates: data.moderates,
+        posts: data.posts,
+      });
+    } else if (res.op == UserOperation.CreateCommentLike) {
+      const data = res.data as CommentResponse;
+      createCommentLikeRes(data, this.state.comments);
+      this.setState({
+        comments: this.state.comments,
+      });
+    } else if (
+      res.op == UserOperation.EditComment ||
+      res.op == UserOperation.DeleteComment ||
+      res.op == UserOperation.RemoveComment
+    ) {
+      const data = res.data as CommentResponse;
+      editCommentRes(data, this.state.comments);
+      this.setState({
+        comments: this.state.comments,
+      });
+    } else if (res.op == UserOperation.CreateComment) {
+      const data = res.data as CommentResponse;
+      if (
+        UserService.Instance.user &&
+        data.comment.creator_id == UserService.Instance.user.id
+      ) {
+        toast(i18n.t('reply_sent'));
+      }
+    } else if (res.op == UserOperation.SaveComment) {
+      const data = res.data as CommentResponse;
+      saveCommentRes(data, this.state.comments);
+      this.setState({
+        comments: this.state.comments,
+      });
+    } else if (res.op == UserOperation.CreatePostLike) {
+      const data = res.data as PostResponse;
+      createPostLikeFindRes(data, this.state.posts);
+      this.setState({
+        posts: this.state.posts,
+      });
+    } else if (res.op == UserOperation.BanUser) {
+      const data = res.data as BanUserResponse;
+      this.state.comments
+        .filter(c => c.creator_id == data.user.id)
+        .forEach(c => (c.banned = data.banned));
+      this.state.posts
+        .filter(c => c.creator_id == data.user.id)
+        .forEach(c => (c.banned = data.banned));
+      this.setState({
+        posts: this.state.posts,
+        comments: this.state.comments,
+      });
+    }
+  }
+}
diff --git a/src/shared/components/user-listing.tsx b/src/shared/components/user-listing.tsx
new file mode 100644 (file)
index 0000000..fd296fa
--- /dev/null
@@ -0,0 +1,75 @@
+import { Component } from 'inferno';
+import { Link } from 'inferno-router';
+import { UserView } from 'lemmy-js-client';
+import {
+  pictrsAvatarThumbnail,
+  showAvatars,
+  hostname,
+  isCakeDay,
+} from '../utils';
+import { CakeDay } from './cake-day';
+
+export interface UserOther {
+  name: string;
+  preferred_username?: string;
+  id?: number; // Necessary if its federated
+  avatar?: string;
+  local?: boolean;
+  actor_id?: string;
+  published?: string;
+}
+
+interface UserListingProps {
+  user: UserView | UserOther;
+  realLink?: boolean;
+  useApubName?: boolean;
+  muted?: boolean;
+  hideAvatar?: boolean;
+}
+
+export class UserListing extends Component<UserListingProps, any> {
+  constructor(props: any, context: any) {
+    super(props, context);
+  }
+
+  render() {
+    let user = this.props.user;
+    let local = user.local == null ? true : user.local;
+    let apubName: string, link: string;
+
+    if (local) {
+      apubName = `@${user.name}`;
+      link = `/u/${user.name}`;
+    } else {
+      apubName = `@${user.name}@${hostname(user.actor_id)}`;
+      link = !this.props.realLink ? `/user/${user.id}` : user.actor_id;
+    }
+
+    let displayName = this.props.useApubName
+      ? apubName
+      : user.preferred_username
+      ? user.preferred_username
+      : apubName;
+
+    return (
+      <>
+        <Link
+          title={apubName}
+          className={this.props.muted ? 'text-muted' : 'text-info'}
+          to={link}
+        >
+          {!this.props.hideAvatar && user.avatar && showAvatars() && (
+            <img
+              style="width: 2rem; height: 2rem;"
+              src={pictrsAvatarThumbnail(user.avatar)}
+              class="rounded-circle mr-2"
+            />
+          )}
+          <span>{displayName}</span>
+        </Link>
+
+        {isCakeDay(user.published) && <CakeDay creatorName={apubName} />}
+      </>
+    );
+  }
+}
diff --git a/src/shared/components/user.tsx b/src/shared/components/user.tsx
new file mode 100644 (file)
index 0000000..2c1a054
--- /dev/null
@@ -0,0 +1,1109 @@
+import { Component, linkEvent } from 'inferno';
+import { Helmet } from 'inferno-helmet';
+import { Link } from 'inferno-router';
+import { Subscription } from 'rxjs';
+import { retryWhen, delay, take } from 'rxjs/operators';
+import {
+  UserOperation,
+  CommunityUser,
+  SortType,
+  ListingType,
+  UserView,
+  UserSettingsForm,
+  LoginResponse,
+  DeleteAccountForm,
+  WebSocketJsonResponse,
+  GetSiteResponse,
+  UserDetailsResponse,
+  AddAdminResponse,
+} from 'lemmy-js-client';
+import { UserDetailsView } from '../interfaces';
+import { WebSocketService, UserService } from '../services';
+import {
+  wsJsonToRes,
+  fetchLimit,
+  routeSortTypeToEnum,
+  capitalizeFirstLetter,
+  themes,
+  setTheme,
+  languages,
+  toast,
+  setupTippy,
+  getLanguage,
+  mdToHtml,
+  elementUrl,
+  favIconUrl,
+} from '../utils';
+import { UserListing } from './user-listing';
+import { SortSelect } from './sort-select';
+import { ListingTypeSelect } from './listing-type-select';
+import { MomentTime } from './moment-time';
+import { i18n } from '../i18next';
+import moment from 'moment';
+import { UserDetails } from './user-details';
+import { MarkdownTextArea } from './markdown-textarea';
+import { ImageUploadForm } from './image-upload-form';
+import { BannerIconHeader } from './banner-icon-header';
+
+interface UserState {
+  user: UserView;
+  user_id: number;
+  username: string;
+  follows: CommunityUser[];
+  moderates: CommunityUser[];
+  view: UserDetailsView;
+  sort: SortType;
+  page: number;
+  loading: boolean;
+  userSettingsForm: UserSettingsForm;
+  userSettingsLoading: boolean;
+  deleteAccountLoading: boolean;
+  deleteAccountShowConfirm: boolean;
+  deleteAccountForm: DeleteAccountForm;
+  siteRes: GetSiteResponse;
+}
+
+interface UserProps {
+  view: UserDetailsView;
+  sort: SortType;
+  page: number;
+  user_id: number | null;
+  username: string;
+}
+
+interface UrlParams {
+  view?: string;
+  sort?: SortType;
+  page?: number;
+}
+
+export class User extends Component<any, UserState> {
+  private subscription: Subscription;
+  private emptyState: UserState = {
+    user: {
+      id: null,
+      name: null,
+      published: null,
+      number_of_posts: null,
+      post_score: null,
+      number_of_comments: null,
+      comment_score: null,
+      banned: null,
+      avatar: null,
+      actor_id: null,
+      local: null,
+    },
+    user_id: null,
+    username: null,
+    follows: [],
+    moderates: [],
+    loading: true,
+    view: User.getViewFromProps(this.props.match.view),
+    sort: User.getSortTypeFromProps(this.props.match.sort),
+    page: User.getPageFromProps(this.props.match.page),
+    userSettingsForm: {
+      show_nsfw: null,
+      theme: null,
+      default_sort_type: null,
+      default_listing_type: null,
+      lang: null,
+      show_avatars: null,
+      send_notifications_to_email: null,
+      auth: null,
+      bio: null,
+      preferred_username: null,
+    },
+    userSettingsLoading: null,
+    deleteAccountLoading: null,
+    deleteAccountShowConfirm: false,
+    deleteAccountForm: {
+      password: null,
+    },
+    siteRes: {
+      admins: [],
+      banned: [],
+      online: undefined,
+      site: {
+        id: undefined,
+        name: undefined,
+        creator_id: undefined,
+        published: undefined,
+        creator_name: undefined,
+        number_of_users: undefined,
+        number_of_posts: undefined,
+        number_of_comments: undefined,
+        number_of_communities: undefined,
+        enable_downvotes: undefined,
+        open_registration: undefined,
+        enable_nsfw: undefined,
+        icon: undefined,
+        banner: undefined,
+        creator_preferred_username: undefined,
+      },
+      version: undefined,
+      my_user: undefined,
+      federated_instances: undefined,
+    },
+  };
+
+  constructor(props: any, context: any) {
+    super(props, context);
+
+    this.state = this.emptyState;
+    this.handleSortChange = this.handleSortChange.bind(this);
+    this.handleUserSettingsSortTypeChange = this.handleUserSettingsSortTypeChange.bind(
+      this
+    );
+    this.handleUserSettingsListingTypeChange = this.handleUserSettingsListingTypeChange.bind(
+      this
+    );
+    this.handlePageChange = this.handlePageChange.bind(this);
+    this.handleUserSettingsBioChange = this.handleUserSettingsBioChange.bind(
+      this
+    );
+
+    this.handleAvatarUpload = this.handleAvatarUpload.bind(this);
+    this.handleAvatarRemove = this.handleAvatarRemove.bind(this);
+
+    this.handleBannerUpload = this.handleBannerUpload.bind(this);
+    this.handleBannerRemove = this.handleBannerRemove.bind(this);
+
+    this.state.user_id = Number(this.props.match.params.id) || null;
+    this.state.username = this.props.match.params.username;
+
+    this.subscription = WebSocketService.Instance.subject
+      .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
+      .subscribe(
+        msg => this.parseMessage(msg),
+        err => console.error(err),
+        () => console.log('complete')
+      );
+
+    WebSocketService.Instance.getSite();
+    setupTippy();
+  }
+
+  get isCurrentUser() {
+    return (
+      UserService.Instance.user &&
+      UserService.Instance.user.id == this.state.user.id
+    );
+  }
+
+  static getViewFromProps(view: string): UserDetailsView {
+    return view ? UserDetailsView[view] : UserDetailsView.Overview;
+  }
+
+  static getSortTypeFromProps(sort: string): SortType {
+    return sort ? routeSortTypeToEnum(sort) : SortType.New;
+  }
+
+  static getPageFromProps(page: number): number {
+    return page ? Number(page) : 1;
+  }
+
+  componentWillUnmount() {
+    this.subscription.unsubscribe();
+  }
+
+  static getDerivedStateFromProps(props: any): UserProps {
+    return {
+      view: this.getViewFromProps(props.match.params.view),
+      sort: this.getSortTypeFromProps(props.match.params.sort),
+      page: this.getPageFromProps(props.match.params.page),
+      user_id: Number(props.match.params.id) || null,
+      username: props.match.params.username,
+    };
+  }
+
+  componentDidUpdate(lastProps: any, _lastState: UserState, _snapshot: 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 {
+    if (this.state.siteRes.site.name) {
+      return `@${this.state.username} - ${this.state.siteRes.site.name}`;
+    } else {
+      return 'Lemmy';
+    }
+  }
+
+  get favIcon(): string {
+    return this.state.siteRes.site.icon
+      ? this.state.siteRes.site.icon
+      : favIconUrl;
+  }
+
+  render() {
+    return (
+      <div class="container">
+        <Helmet title={this.documentTitle}>
+          <link
+            id="favicon"
+            rel="icon"
+            type="image/x-icon"
+            href={this.favIcon}
+          />
+        </Helmet>
+        <div class="row">
+          <div class="col-12 col-md-8">
+            {this.state.loading ? (
+              <h5>
+                <svg class="icon icon-spinner spin">
+                  <use xlinkHref="#icon-spinner"></use>
+                </svg>
+              </h5>
+            ) : (
+              <>
+                {this.userInfo()}
+                <hr />
+              </>
+            )}
+            {!this.state.loading && this.selects()}
+            <UserDetails
+              user_id={this.state.user_id}
+              username={this.state.username}
+              sort={this.state.sort}
+              page={this.state.page}
+              limit={fetchLimit}
+              enableDownvotes={this.state.siteRes.site.enable_downvotes}
+              enableNsfw={this.state.siteRes.site.enable_nsfw}
+              admins={this.state.siteRes.admins}
+              view={this.state.view}
+              onPageChange={this.handlePageChange}
+            />
+          </div>
+
+          {!this.state.loading && (
+            <div class="col-12 col-md-4">
+              {this.isCurrentUser && this.userSettings()}
+              {this.moderates()}
+              {this.follows()}
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  }
+
+  viewRadios() {
+    return (
+      <div class="btn-group btn-group-toggle flex-wrap mb-2">
+        <label
+          className={`btn btn-outline-secondary pointer
+            ${this.state.view == UserDetailsView.Overview && 'active'}
+          `}
+        >
+          <input
+            type="radio"
+            value={UserDetailsView.Overview}
+            checked={this.state.view === UserDetailsView.Overview}
+            onChange={linkEvent(this, this.handleViewChange)}
+          />
+          {i18n.t('overview')}
+        </label>
+        <label
+          className={`btn btn-outline-secondary pointer
+            ${this.state.view == UserDetailsView.Comments && 'active'}
+          `}
+        >
+          <input
+            type="radio"
+            value={UserDetailsView.Comments}
+            checked={this.state.view == UserDetailsView.Comments}
+            onChange={linkEvent(this, this.handleViewChange)}
+          />
+          {i18n.t('comments')}
+        </label>
+        <label
+          className={`btn btn-outline-secondary pointer
+            ${this.state.view == UserDetailsView.Posts && 'active'}
+          `}
+        >
+          <input
+            type="radio"
+            value={UserDetailsView.Posts}
+            checked={this.state.view == UserDetailsView.Posts}
+            onChange={linkEvent(this, this.handleViewChange)}
+          />
+          {i18n.t('posts')}
+        </label>
+        <label
+          className={`btn btn-outline-secondary pointer
+            ${this.state.view == UserDetailsView.Saved && 'active'}
+          `}
+        >
+          <input
+            type="radio"
+            value={UserDetailsView.Saved}
+            checked={this.state.view == UserDetailsView.Saved}
+            onChange={linkEvent(this, this.handleViewChange)}
+          />
+          {i18n.t('saved')}
+        </label>
+      </div>
+    );
+  }
+
+  selects() {
+    return (
+      <div className="mb-2">
+        <span class="mr-3">{this.viewRadios()}</span>
+        <SortSelect
+          sort={this.state.sort}
+          onChange={this.handleSortChange}
+          hideHot
+        />
+        <a
+          href={`/feeds/u/${this.state.username}.xml?sort=${this.state.sort}`}
+          target="_blank"
+          rel="noopener"
+          title="RSS"
+        >
+          <svg class="icon mx-2 text-muted small">
+            <use xlinkHref="#icon-rss">#</use>
+          </svg>
+        </a>
+      </div>
+    );
+  }
+
+  userInfo() {
+    let user = this.state.user;
+
+    return (
+      <div>
+        <BannerIconHeader
+          banner={this.state.user.banner}
+          icon={this.state.user.avatar}
+        />
+        <div class="mb-3">
+          <div class="">
+            <div class="mb-0 d-flex flex-wrap">
+              <div>
+                {user.preferred_username && (
+                  <h5 class="mb-0">{user.preferred_username}</h5>
+                )}
+                <ul class="list-inline mb-2">
+                  <li className="list-inline-item">
+                    <UserListing
+                      user={user}
+                      realLink
+                      useApubName
+                      muted
+                      hideAvatar
+                    />
+                  </li>
+                  {user.banned && (
+                    <li className="list-inline-item badge badge-danger">
+                      {i18n.t('banned')}
+                    </li>
+                  )}
+                </ul>
+              </div>
+              <div className="flex-grow-1 unselectable pointer mx-2"></div>
+              {this.isCurrentUser ? (
+                <button
+                  class="d-flex align-self-start btn btn-secondary ml-2"
+                  onClick={linkEvent(this, this.handleLogoutClick)}
+                >
+                  {i18n.t('logout')}
+                </button>
+              ) : (
+                <>
+                  <a
+                    className={`d-flex align-self-start btn btn-secondary ml-2 ${
+                      !this.state.user.matrix_user_id && 'invisible'
+                    }`}
+                    target="_blank"
+                    rel="noopener"
+                    href={`https://matrix.to/#/${this.state.user.matrix_user_id}`}
+                  >
+                    {i18n.t('send_secure_message')}
+                  </a>
+                  <Link
+                    class="d-flex align-self-start btn btn-secondary ml-2"
+                    to={`/create_private_message?recipient_id=${this.state.user.id}`}
+                  >
+                    {i18n.t('send_message')}
+                  </Link>
+                </>
+              )}
+            </div>
+            {user.bio && (
+              <div className="d-flex align-items-center mb-2">
+                <div
+                  className="md-div"
+                  dangerouslySetInnerHTML={mdToHtml(user.bio)}
+                />
+              </div>
+            )}
+            <div>
+              <ul class="list-inline mb-2">
+                <li className="list-inline-item badge badge-light">
+                  {i18n.t('number_of_posts', { count: user.number_of_posts })}
+                </li>
+                <li className="list-inline-item badge badge-light">
+                  {i18n.t('number_of_comments', {
+                    count: user.number_of_comments,
+                  })}
+                </li>
+              </ul>
+            </div>
+            <div class="text-muted">
+              {i18n.t('joined')} <MomentTime data={user} showAgo />
+            </div>
+            <div className="d-flex align-items-center text-muted mb-2">
+              <svg class="icon">
+                <use xlinkHref="#icon-cake"></use>
+              </svg>
+              <span className="ml-2">
+                {i18n.t('cake_day_title')}{' '}
+                {moment.utc(user.published).local().format('MMM DD, YYYY')}
+              </span>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  userSettings() {
+    return (
+      <div>
+        <div class="card bg-transparent border-secondary mb-3">
+          <div class="card-body">
+            <h5>{i18n.t('settings')}</h5>
+            <form onSubmit={linkEvent(this, this.handleUserSettingsSubmit)}>
+              <div class="form-group">
+                <label>{i18n.t('avatar')}</label>
+                <ImageUploadForm
+                  uploadTitle={i18n.t('upload_avatar')}
+                  imageSrc={this.state.userSettingsForm.avatar}
+                  onUpload={this.handleAvatarUpload}
+                  onRemove={this.handleAvatarRemove}
+                  rounded
+                />
+              </div>
+              <div class="form-group">
+                <label>{i18n.t('banner')}</label>
+                <ImageUploadForm
+                  uploadTitle={i18n.t('upload_banner')}
+                  imageSrc={this.state.userSettingsForm.banner}
+                  onUpload={this.handleBannerUpload}
+                  onRemove={this.handleBannerRemove}
+                />
+              </div>
+              <div class="form-group">
+                <label>{i18n.t('language')}</label>
+                <select
+                  value={this.state.userSettingsForm.lang}
+                  onChange={linkEvent(this, this.handleUserSettingsLangChange)}
+                  class="ml-2 custom-select w-auto"
+                >
+                  <option disabled>{i18n.t('language')}</option>
+                  <option value="browser">{i18n.t('browser_default')}</option>
+                  <option disabled>──</option>
+                  {languages.map(lang => (
+                    <option value={lang.code}>{lang.name}</option>
+                  ))}
+                </select>
+              </div>
+              <div class="form-group">
+                <label>{i18n.t('theme')}</label>
+                <select
+                  value={this.state.userSettingsForm.theme}
+                  onChange={linkEvent(this, this.handleUserSettingsThemeChange)}
+                  class="ml-2 custom-select w-auto"
+                >
+                  <option disabled>{i18n.t('theme')}</option>
+                  {themes.map(theme => (
+                    <option value={theme}>{theme}</option>
+                  ))}
+                </select>
+              </div>
+              <form className="form-group">
+                <label>
+                  <div class="mr-2">{i18n.t('sort_type')}</div>
+                </label>
+                <ListingTypeSelect
+                  type_={
+                    Object.values(ListingType)[
+                      this.state.userSettingsForm.default_listing_type
+                    ]
+                  }
+                  onChange={this.handleUserSettingsListingTypeChange}
+                />
+              </form>
+              <form className="form-group">
+                <label>
+                  <div class="mr-2">{i18n.t('type')}</div>
+                </label>
+                <SortSelect
+                  sort={
+                    Object.values(SortType)[
+                      this.state.userSettingsForm.default_sort_type
+                    ]
+                  }
+                  onChange={this.handleUserSettingsSortTypeChange}
+                />
+              </form>
+              <div class="form-group row">
+                <label class="col-lg-5 col-form-label">
+                  {i18n.t('display_name')}
+                </label>
+                <div class="col-lg-7">
+                  <input
+                    type="text"
+                    class="form-control"
+                    placeholder={i18n.t('optional')}
+                    value={this.state.userSettingsForm.preferred_username}
+                    onInput={linkEvent(
+                      this,
+                      this.handleUserSettingsPreferredUsernameChange
+                    )}
+                    pattern="^(?!@)(.+)$"
+                    minLength={3}
+                    maxLength={20}
+                  />
+                </div>
+              </div>
+              <div class="form-group row">
+                <label class="col-lg-3 col-form-label" htmlFor="user-bio">
+                  {i18n.t('bio')}
+                </label>
+                <div class="col-lg-9">
+                  <MarkdownTextArea
+                    initialContent={this.state.userSettingsForm.bio}
+                    onContentChange={this.handleUserSettingsBioChange}
+                    maxLength={300}
+                    hideNavigationWarnings
+                  />
+                </div>
+              </div>
+              <div class="form-group row">
+                <label class="col-lg-3 col-form-label" htmlFor="user-email">
+                  {i18n.t('email')}
+                </label>
+                <div class="col-lg-9">
+                  <input
+                    type="email"
+                    id="user-email"
+                    class="form-control"
+                    placeholder={i18n.t('optional')}
+                    value={this.state.userSettingsForm.email}
+                    onInput={linkEvent(
+                      this,
+                      this.handleUserSettingsEmailChange
+                    )}
+                    minLength={3}
+                  />
+                </div>
+              </div>
+              <div class="form-group row">
+                <label class="col-lg-5 col-form-label">
+                  <a href={elementUrl} target="_blank" rel="noopener">
+                    {i18n.t('matrix_user_id')}
+                  </a>
+                </label>
+                <div class="col-lg-7">
+                  <input
+                    type="text"
+                    class="form-control"
+                    placeholder="@user:example.com"
+                    value={this.state.userSettingsForm.matrix_user_id}
+                    onInput={linkEvent(
+                      this,
+                      this.handleUserSettingsMatrixUserIdChange
+                    )}
+                    minLength={3}
+                  />
+                </div>
+              </div>
+              <div class="form-group row">
+                <label class="col-lg-5 col-form-label" htmlFor="user-password">
+                  {i18n.t('new_password')}
+                </label>
+                <div class="col-lg-7">
+                  <input
+                    type="password"
+                    id="user-password"
+                    class="form-control"
+                    value={this.state.userSettingsForm.new_password}
+                    autoComplete="new-password"
+                    onInput={linkEvent(
+                      this,
+                      this.handleUserSettingsNewPasswordChange
+                    )}
+                  />
+                </div>
+              </div>
+              <div class="form-group row">
+                <label
+                  class="col-lg-5 col-form-label"
+                  htmlFor="user-verify-password"
+                >
+                  {i18n.t('verify_password')}
+                </label>
+                <div class="col-lg-7">
+                  <input
+                    type="password"
+                    id="user-verify-password"
+                    class="form-control"
+                    value={this.state.userSettingsForm.new_password_verify}
+                    autoComplete="new-password"
+                    onInput={linkEvent(
+                      this,
+                      this.handleUserSettingsNewPasswordVerifyChange
+                    )}
+                  />
+                </div>
+              </div>
+              <div class="form-group row">
+                <label
+                  class="col-lg-5 col-form-label"
+                  htmlFor="user-old-password"
+                >
+                  {i18n.t('old_password')}
+                </label>
+                <div class="col-lg-7">
+                  <input
+                    type="password"
+                    id="user-old-password"
+                    class="form-control"
+                    value={this.state.userSettingsForm.old_password}
+                    autoComplete="new-password"
+                    onInput={linkEvent(
+                      this,
+                      this.handleUserSettingsOldPasswordChange
+                    )}
+                  />
+                </div>
+              </div>
+              {this.state.siteRes.site.enable_nsfw && (
+                <div class="form-group">
+                  <div class="form-check">
+                    <input
+                      class="form-check-input"
+                      id="user-show-nsfw"
+                      type="checkbox"
+                      checked={this.state.userSettingsForm.show_nsfw}
+                      onChange={linkEvent(
+                        this,
+                        this.handleUserSettingsShowNsfwChange
+                      )}
+                    />
+                    <label class="form-check-label" htmlFor="user-show-nsfw">
+                      {i18n.t('show_nsfw')}
+                    </label>
+                  </div>
+                </div>
+              )}
+              <div class="form-group">
+                <div class="form-check">
+                  <input
+                    class="form-check-input"
+                    id="user-show-avatars"
+                    type="checkbox"
+                    checked={this.state.userSettingsForm.show_avatars}
+                    onChange={linkEvent(
+                      this,
+                      this.handleUserSettingsShowAvatarsChange
+                    )}
+                  />
+                  <label class="form-check-label" htmlFor="user-show-avatars">
+                    {i18n.t('show_avatars')}
+                  </label>
+                </div>
+              </div>
+              <div class="form-group">
+                <div class="form-check">
+                  <input
+                    class="form-check-input"
+                    id="user-send-notifications-to-email"
+                    type="checkbox"
+                    disabled={!this.state.userSettingsForm.email}
+                    checked={
+                      this.state.userSettingsForm.send_notifications_to_email
+                    }
+                    onChange={linkEvent(
+                      this,
+                      this.handleUserSettingsSendNotificationsToEmailChange
+                    )}
+                  />
+                  <label
+                    class="form-check-label"
+                    htmlFor="user-send-notifications-to-email"
+                  >
+                    {i18n.t('send_notifications_to_email')}
+                  </label>
+                </div>
+              </div>
+              <div class="form-group">
+                <button type="submit" class="btn btn-block btn-secondary mr-4">
+                  {this.state.userSettingsLoading ? (
+                    <svg class="icon icon-spinner spin">
+                      <use xlinkHref="#icon-spinner"></use>
+                    </svg>
+                  ) : (
+                    capitalizeFirstLetter(i18n.t('save'))
+                  )}
+                </button>
+              </div>
+              <hr />
+              <div class="form-group mb-0">
+                <button
+                  class="btn btn-block btn-danger"
+                  onClick={linkEvent(
+                    this,
+                    this.handleDeleteAccountShowConfirmToggle
+                  )}
+                >
+                  {i18n.t('delete_account')}
+                </button>
+                {this.state.deleteAccountShowConfirm && (
+                  <>
+                    <div class="my-2 alert alert-danger" role="alert">
+                      {i18n.t('delete_account_confirm')}
+                    </div>
+                    <input
+                      type="password"
+                      value={this.state.deleteAccountForm.password}
+                      autoComplete="new-password"
+                      onInput={linkEvent(
+                        this,
+                        this.handleDeleteAccountPasswordChange
+                      )}
+                      class="form-control my-2"
+                    />
+                    <button
+                      class="btn btn-danger mr-4"
+                      disabled={!this.state.deleteAccountForm.password}
+                      onClick={linkEvent(this, this.handleDeleteAccount)}
+                    >
+                      {this.state.deleteAccountLoading ? (
+                        <svg class="icon icon-spinner spin">
+                          <use xlinkHref="#icon-spinner"></use>
+                        </svg>
+                      ) : (
+                        capitalizeFirstLetter(i18n.t('delete'))
+                      )}
+                    </button>
+                    <button
+                      class="btn btn-secondary"
+                      onClick={linkEvent(
+                        this,
+                        this.handleDeleteAccountShowConfirmToggle
+                      )}
+                    >
+                      {i18n.t('cancel')}
+                    </button>
+                  </>
+                )}
+              </div>
+            </form>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  moderates() {
+    return (
+      <div>
+        {this.state.moderates.length > 0 && (
+          <div class="card bg-transparent border-secondary mb-3">
+            <div class="card-body">
+              <h5>{i18n.t('moderates')}</h5>
+              <ul class="list-unstyled mb-0">
+                {this.state.moderates.map(community => (
+                  <li>
+                    <Link to={`/c/${community.community_name}`}>
+                      {community.community_name}
+                    </Link>
+                  </li>
+                ))}
+              </ul>
+            </div>
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  follows() {
+    return (
+      <div>
+        {this.state.follows.length > 0 && (
+          <div class="card bg-transparent border-secondary mb-3">
+            <div class="card-body">
+              <h5>{i18n.t('subscribed')}</h5>
+              <ul class="list-unstyled mb-0">
+                {this.state.follows.map(community => (
+                  <li>
+                    <Link to={`/c/${community.community_name}`}>
+                      {community.community_name}
+                    </Link>
+                  </li>
+                ))}
+              </ul>
+            </div>
+          </div>
+        )}
+      </div>
+    );
+  }
+
+  updateUrl(paramUpdates: UrlParams) {
+    const page = paramUpdates.page || this.state.page;
+    const viewStr = paramUpdates.view || UserDetailsView[this.state.view];
+    const sortStr = paramUpdates.sort || this.state.sort;
+    this.props.history.push(
+      `/u/${this.state.username}/view/${viewStr}/sort/${sortStr}/page/${page}`
+    );
+  }
+
+  handlePageChange(page: number) {
+    this.updateUrl({ page });
+  }
+
+  handleSortChange(val: SortType) {
+    this.updateUrl({ sort: val, page: 1 });
+  }
+
+  handleViewChange(i: User, event: any) {
+    i.updateUrl({
+      view: UserDetailsView[Number(event.target.value)],
+      page: 1,
+    });
+  }
+
+  handleUserSettingsShowNsfwChange(i: User, event: any) {
+    i.state.userSettingsForm.show_nsfw = event.target.checked;
+    i.setState(i.state);
+  }
+
+  handleUserSettingsShowAvatarsChange(i: User, event: any) {
+    i.state.userSettingsForm.show_avatars = event.target.checked;
+    UserService.Instance.user.show_avatars = event.target.checked; // Just for instant updates
+    i.setState(i.state);
+  }
+
+  handleUserSettingsSendNotificationsToEmailChange(i: User, event: any) {
+    i.state.userSettingsForm.send_notifications_to_email = event.target.checked;
+    i.setState(i.state);
+  }
+
+  handleUserSettingsThemeChange(i: User, event: any) {
+    i.state.userSettingsForm.theme = event.target.value;
+    setTheme(event.target.value, true);
+    i.setState(i.state);
+  }
+
+  handleUserSettingsLangChange(i: User, event: any) {
+    i.state.userSettingsForm.lang = event.target.value;
+    i18n.changeLanguage(getLanguage(i.state.userSettingsForm.lang));
+    i.setState(i.state);
+  }
+
+  handleUserSettingsSortTypeChange(val: SortType) {
+    this.state.userSettingsForm.default_sort_type = Object.keys(
+      SortType
+    ).indexOf(val);
+    this.setState(this.state);
+  }
+
+  handleUserSettingsListingTypeChange(val: ListingType) {
+    this.state.userSettingsForm.default_listing_type = Object.keys(
+      ListingType
+    ).indexOf(val);
+    this.setState(this.state);
+  }
+
+  handleUserSettingsEmailChange(i: User, event: any) {
+    i.state.userSettingsForm.email = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleUserSettingsBioChange(val: string) {
+    this.state.userSettingsForm.bio = val;
+    this.setState(this.state);
+  }
+
+  handleAvatarUpload(url: string) {
+    this.state.userSettingsForm.avatar = url;
+    this.setState(this.state);
+  }
+
+  handleAvatarRemove() {
+    this.state.userSettingsForm.avatar = '';
+    this.setState(this.state);
+  }
+
+  handleBannerUpload(url: string) {
+    this.state.userSettingsForm.banner = url;
+    this.setState(this.state);
+  }
+
+  handleBannerRemove() {
+    this.state.userSettingsForm.banner = '';
+    this.setState(this.state);
+  }
+
+  handleUserSettingsPreferredUsernameChange(i: User, event: any) {
+    i.state.userSettingsForm.preferred_username = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleUserSettingsMatrixUserIdChange(i: User, event: any) {
+    i.state.userSettingsForm.matrix_user_id = event.target.value;
+    if (
+      i.state.userSettingsForm.matrix_user_id == '' &&
+      !i.state.user.matrix_user_id
+    ) {
+      i.state.userSettingsForm.matrix_user_id = undefined;
+    }
+    i.setState(i.state);
+  }
+
+  handleUserSettingsNewPasswordChange(i: User, event: any) {
+    i.state.userSettingsForm.new_password = event.target.value;
+    if (i.state.userSettingsForm.new_password == '') {
+      i.state.userSettingsForm.new_password = undefined;
+    }
+    i.setState(i.state);
+  }
+
+  handleUserSettingsNewPasswordVerifyChange(i: User, event: any) {
+    i.state.userSettingsForm.new_password_verify = event.target.value;
+    if (i.state.userSettingsForm.new_password_verify == '') {
+      i.state.userSettingsForm.new_password_verify = undefined;
+    }
+    i.setState(i.state);
+  }
+
+  handleUserSettingsOldPasswordChange(i: User, event: any) {
+    i.state.userSettingsForm.old_password = event.target.value;
+    if (i.state.userSettingsForm.old_password == '') {
+      i.state.userSettingsForm.old_password = undefined;
+    }
+    i.setState(i.state);
+  }
+
+  handleUserSettingsSubmit(i: User, event: any) {
+    event.preventDefault();
+    i.state.userSettingsLoading = true;
+    i.setState(i.state);
+
+    WebSocketService.Instance.saveUserSettings(i.state.userSettingsForm);
+  }
+
+  handleDeleteAccountShowConfirmToggle(i: User, event: any) {
+    event.preventDefault();
+    i.state.deleteAccountShowConfirm = !i.state.deleteAccountShowConfirm;
+    i.setState(i.state);
+  }
+
+  handleDeleteAccountPasswordChange(i: User, event: any) {
+    i.state.deleteAccountForm.password = event.target.value;
+    i.setState(i.state);
+  }
+
+  handleLogoutClick(i: User) {
+    UserService.Instance.logout();
+    i.context.router.history.push('/');
+  }
+
+  handleDeleteAccount(i: User, event: any) {
+    event.preventDefault();
+    i.state.deleteAccountLoading = true;
+    i.setState(i.state);
+
+    WebSocketService.Instance.deleteAccount(i.state.deleteAccountForm);
+  }
+
+  parseMessage(msg: WebSocketJsonResponse) {
+    console.log(msg);
+    const res = wsJsonToRes(msg);
+    if (msg.error) {
+      toast(i18n.t(msg.error), 'danger');
+      if (msg.error == 'couldnt_find_that_username_or_email') {
+        this.context.router.history.push('/');
+      }
+      this.setState({
+        deleteAccountLoading: false,
+        userSettingsLoading: false,
+      });
+      return;
+    } else if (res.op == UserOperation.GetUserDetails) {
+      // Since the UserDetails 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
+      const data = res.data as UserDetailsResponse;
+
+      if (this.state.user.id !== data.user.id) {
+        this.state.user = data.user;
+        this.state.follows = data.follows;
+        this.state.moderates = data.moderates;
+
+        if (this.isCurrentUser) {
+          this.state.userSettingsForm.show_nsfw =
+            UserService.Instance.user.show_nsfw;
+          this.state.userSettingsForm.theme = UserService.Instance.user.theme
+            ? UserService.Instance.user.theme
+            : 'darkly';
+          this.state.userSettingsForm.default_sort_type =
+            UserService.Instance.user.default_sort_type;
+          this.state.userSettingsForm.default_listing_type =
+            UserService.Instance.user.default_listing_type;
+          this.state.userSettingsForm.lang = UserService.Instance.user.lang;
+          this.state.userSettingsForm.avatar = UserService.Instance.user.avatar;
+          this.state.userSettingsForm.banner = UserService.Instance.user.banner;
+          this.state.userSettingsForm.preferred_username =
+            UserService.Instance.user.preferred_username;
+          this.state.userSettingsForm.show_avatars =
+            UserService.Instance.user.show_avatars;
+          this.state.userSettingsForm.email = UserService.Instance.user.email;
+          this.state.userSettingsForm.bio = UserService.Instance.user.bio;
+          this.state.userSettingsForm.send_notifications_to_email =
+            UserService.Instance.user.send_notifications_to_email;
+          this.state.userSettingsForm.matrix_user_id =
+            UserService.Instance.user.matrix_user_id;
+        }
+        this.state.loading = false;
+        this.setState(this.state);
+      }
+    } else if (res.op == UserOperation.SaveUserSettings) {
+      const data = res.data as LoginResponse;
+      UserService.Instance.login(data);
+      this.state.user.bio = this.state.userSettingsForm.bio;
+      this.state.user.preferred_username = this.state.userSettingsForm.preferred_username;
+      this.state.user.banner = this.state.userSettingsForm.banner;
+      this.state.user.avatar = this.state.userSettingsForm.avatar;
+      this.state.userSettingsLoading = false;
+      this.setState(this.state);
+
+      window.scrollTo(0, 0);
+    } else if (res.op == UserOperation.DeleteAccount) {
+      this.setState({
+        deleteAccountLoading: false,
+        deleteAccountShowConfirm: false,
+      });
+      this.context.router.history.push('/');
+    } else if (res.op == UserOperation.GetSite) {
+      const data = res.data as GetSiteResponse;
+      this.state.siteRes = data;
+      this.setState(this.state);
+    } else if (res.op == UserOperation.AddAdmin) {
+      const data = res.data as AddAdminResponse;
+      this.state.siteRes.admins = data.admins;
+      this.setState(this.state);
+    }
+  }
+}
diff --git a/src/shared/env.ts b/src/shared/env.ts
new file mode 100644 (file)
index 0000000..be78155
--- /dev/null
@@ -0,0 +1,15 @@
+// TODO
+// const host = `${window.location.hostname}`;
+// const port = `${
+//   window.location.port == '4444' ? '8536' : window.location.port
+// }`;
+// const endpoint = `${host}:${port}`;
+
+// export const wsUri = `${
+//   window.location.protocol == 'https:' ? 'wss://' : 'ws://'
+// }${endpoint}/api/v1/ws`;
+
+const host = '192.168.50.60';
+const port = 8536;
+const endpoint = `${host}:${port}`;
+export const wsUri = `ws://${endpoint}/api/v1/ws`;
diff --git a/src/shared/i18next.ts b/src/shared/i18next.ts
new file mode 100644 (file)
index 0000000..3657da3
--- /dev/null
@@ -0,0 +1,79 @@
+import i18next from 'i18next';
+import { getLanguage } from './utils';
+import { en } from './translations/en';
+import { el } from './translations/el';
+import { eu } from './translations/eu';
+import { eo } from './translations/eo';
+import { es } from './translations/es';
+import { de } from './translations/de';
+import { fr } from './translations/fr';
+import { sv } from './translations/sv';
+import { ru } from './translations/ru';
+import { zh } from './translations/zh';
+import { nl } from './translations/nl';
+import { it } from './translations/it';
+import { fi } from './translations/fi';
+import { ca } from './translations/ca';
+import { fa } from './translations/fa';
+import { hi } from './translations/hi';
+import { pl } from './translations/pl';
+import { pt_BR } from './translations/pt_BR';
+import { ja } from './translations/ja';
+import { ka } from './translations/ka';
+import { gl } from './translations/gl';
+import { tr } from './translations/tr';
+import { hu } from './translations/hu';
+import { uk } from './translations/uk';
+import { sq } from './translations/sq';
+import { km } from './translations/km';
+import { ga } from './translations/ga';
+import { sr_Latn } from './translations/sr_Latn';
+
+// https://github.com/nimbusec-oss/inferno-i18next/blob/master/tests/T.test.js#L66
+const resources = {
+  en,
+  el,
+  eu,
+  eo,
+  es,
+  ka,
+  hi,
+  de,
+  zh,
+  fr,
+  sv,
+  ru,
+  nl,
+  it,
+  fi,
+  ca,
+  fa,
+  pl,
+  pt_BR,
+  ja,
+  gl,
+  tr,
+  hu,
+  uk,
+  sq,
+  km,
+  ga,
+  sr_Latn,
+};
+
+function format(value: any, format: any, lng: any): any {
+  return format === 'uppercase' ? value.toUpperCase() : value;
+}
+
+i18next.init({
+  debug: false,
+  // load: 'languageOnly',
+
+  // initImmediate: false,
+  lng: getLanguage(),
+  fallbackLng: 'en',
+  resources,
+  interpolation: { format },
+});
+
+export { i18next as i18n, resources };
diff --git a/src/shared/interfaces.ts b/src/shared/interfaces.ts
new file mode 100644 (file)
index 0000000..3b08118
--- /dev/null
@@ -0,0 +1,28 @@
+export enum CommentSortType {
+  Hot,
+  Top,
+  New,
+  Old,
+}
+
+export enum CommentViewType {
+  Tree,
+  Chat,
+}
+
+export enum DataType {
+  Post,
+  Comment,
+}
+
+export enum BanType {
+  Community,
+  Site,
+}
+
+export enum UserDetailsView {
+  Overview,
+  Comments,
+  Posts,
+  Saved,
+}
diff --git a/src/shared/routes.ts b/src/shared/routes.ts
new file mode 100644 (file)
index 0000000..1b4cb45
--- /dev/null
@@ -0,0 +1,77 @@
+import { BrowserRouter, Route, Switch } from 'inferno-router';
+import { IRouteProps } from 'inferno-router/dist/Route';
+import { Main } from './components/main';
+import { Navbar } from './components/navbar';
+import { Footer } from './components/footer';
+import { Login } from './components/login';
+import { CreatePost } from './components/create-post';
+import { CreateCommunity } from './components/create-community';
+import { CreatePrivateMessage } from './components/create-private-message';
+import { PasswordChange } from './components/password_change';
+import { Post } from './components/post';
+import { Community } from './components/community';
+import { Communities } from './components/communities';
+import { User } from './components/user';
+import { Modlog } from './components/modlog';
+import { Setup } from './components/setup';
+import { AdminSettings } from './components/admin-settings';
+import { Inbox } from './components/inbox';
+import { Search } from './components/search';
+import { Sponsors } from './components/sponsors';
+import { Instances } from './components/instances';
+
+export const routes: IRouteProps[] = [
+  { exact: true, path: `/`, component: Main },
+  {
+    path: `/home/data_type/:data_type/listing_type/:listing_type/sort/:sort/page/:page`,
+    component: Main,
+  },
+  { path: `/login`, component: Login },
+  { path: `/create_post`, component: CreatePost },
+  { path: `/create_community`, component: CreateCommunity },
+  {
+    path: `/create_private_message`,
+    component: CreatePrivateMessage,
+  },
+  {
+    path: `/communities/page/:page`,
+    component: Communities,
+  },
+  { path: `/communities`, component: Communities },
+  {
+    path: `/post/:id/comment/:comment_id`,
+    component: Post,
+  },
+  { path: `/post/:id`, component: Post },
+  {
+    path: `/c/:name/data_type/:data_type/sort/:sort/page/:page`,
+    component: Community,
+  },
+  { path: `/community/:id`, component: Community },
+  { path: `/c/:name`, component: Community },
+  {
+    path: `/u/:username/view/:view/sort/:sort/page/:page`,
+    component: User,
+  },
+  { path: `/user/:id`, component: User },
+  { path: `/u/:username`, component: User },
+  { path: `/inbox`, component: Inbox },
+  {
+    path: `/modlog/community/:community_id`,
+    component: Modlog,
+  },
+  { path: `/modlog`, component: Modlog },
+  { path: `/setup`, component: Setup },
+  { path: `/admin`, component: AdminSettings },
+  {
+    path: `/search/q/:q/type/:type/sort/:sort/page/:page`,
+    component: Search,
+  },
+  { path: `/search`, component: Search },
+  { path: `/sponsors`, component: Sponsors },
+  {
+    path: `/password_change/:token`,
+    component: PasswordChange,
+  },
+  { path: `/instances`, component: Instances },
+];
diff --git a/src/shared/services/UserService.ts b/src/shared/services/UserService.ts
new file mode 100644 (file)
index 0000000..de85bb4
--- /dev/null
@@ -0,0 +1,59 @@
+// import Cookies from 'js-cookie';
+import IsomorphicCookie from 'isomorphic-cookie';
+import { User, LoginResponse } from 'lemmy-js-client';
+import { setTheme } from '../utils';
+import jwt_decode from 'jwt-decode';
+import { Subject, BehaviorSubject } from 'rxjs';
+
+interface Claims {
+  id: number;
+  iss: string;
+}
+
+export class UserService {
+  private static _instance: UserService;
+  public user: User;
+  public claims: Claims;
+  public jwtSub: Subject<string> = new Subject<string>();
+  public unreadCountSub: BehaviorSubject<number> = new BehaviorSubject<number>(
+    0
+  );
+
+  private constructor() {
+    let jwt = IsomorphicCookie.load('jwt');
+    if (jwt) {
+      this.setClaims(jwt);
+    } else {
+      setTheme();
+      console.log('No JWT cookie found.');
+    }
+  }
+
+  public login(res: LoginResponse) {
+    this.setClaims(res.jwt);
+    IsomorphicCookie.save('jwt', res.jwt, { expires: 365 });
+    console.log('jwt cookie set');
+  }
+
+  public logout() {
+    this.claims = undefined;
+    this.user = undefined;
+    IsomorphicCookie.remove('jwt');
+    setTheme();
+    this.jwtSub.next();
+    console.log('Logged out.');
+  }
+
+  public get auth(): string {
+    return IsomorphicCookie.load('jwt');
+  }
+
+  private setClaims(jwt: string) {
+    this.claims = jwt_decode(jwt);
+    this.jwtSub.next(jwt);
+  }
+
+  public static get Instance() {
+    return this._instance || (this._instance = new this());
+  }
+}
diff --git a/src/shared/services/WebSocketService.ts b/src/shared/services/WebSocketService.ts
new file mode 100644 (file)
index 0000000..3af3829
--- /dev/null
@@ -0,0 +1,409 @@
+import { wsUri } from '../env';
+import {
+  LemmyWebsocket,
+  LoginForm,
+  RegisterForm,
+  CommunityForm,
+  DeleteCommunityForm,
+  RemoveCommunityForm,
+  PostForm,
+  DeletePostForm,
+  RemovePostForm,
+  LockPostForm,
+  StickyPostForm,
+  SavePostForm,
+  CommentForm,
+  DeleteCommentForm,
+  RemoveCommentForm,
+  MarkCommentAsReadForm,
+  SaveCommentForm,
+  CommentLikeForm,
+  GetPostForm,
+  GetPostsForm,
+  CreatePostLikeForm,
+  GetCommunityForm,
+  FollowCommunityForm,
+  GetFollowedCommunitiesForm,
+  GetUserDetailsForm,
+  ListCommunitiesForm,
+  GetModlogForm,
+  BanFromCommunityForm,
+  AddModToCommunityForm,
+  TransferCommunityForm,
+  AddAdminForm,
+  TransferSiteForm,
+  BanUserForm,
+  SiteForm,
+  UserView,
+  GetRepliesForm,
+  GetUserMentionsForm,
+  MarkUserMentionAsReadForm,
+  SearchForm,
+  UserSettingsForm,
+  DeleteAccountForm,
+  PasswordResetForm,
+  PasswordChangeForm,
+  PrivateMessageForm,
+  EditPrivateMessageForm,
+  DeletePrivateMessageForm,
+  MarkPrivateMessageAsReadForm,
+  GetPrivateMessagesForm,
+  GetCommentsForm,
+  UserJoinForm,
+  GetSiteConfig,
+  GetSiteForm,
+  SiteConfigForm,
+  MarkAllAsReadForm,
+  WebSocketJsonResponse,
+} from 'lemmy-js-client';
+import { UserService } from './';
+import { i18n } from '../i18next';
+import { toast, isBrowser } from '../utils';
+import { Observable } from 'rxjs';
+import { share } from 'rxjs/operators';
+import WebSocket from 'isomorphic-ws';
+import {
+  Options as WSOptions,
+  default as ReconnectingWebSocket,
+} from 'reconnecting-websocket';
+
+export class WebSocketService {
+  private static _instance: WebSocketService;
+  public ws: ReconnectingWebSocket;
+  public wsOptions: WSOptions = {
+    WebSocket: WebSocket,
+    connectionTimeout: 1000,
+    maxRetries: 10,
+  };
+  public subject: Observable<any>;
+
+  public admins: UserView[];
+  public banned: UserView[];
+  private client = new LemmyWebsocket();
+
+  private constructor() {
+    this.ws = new ReconnectingWebSocket(wsUri, [], this.wsOptions);
+    let firstConnect = true;
+
+    this.subject = Observable.create((obs: any) => {
+      this.ws.onmessage = e => {
+        obs.next(JSON.parse(e.data));
+      };
+      this.ws.onopen = () => {
+        console.log(`Connected to ${wsUri}`);
+
+        if (!firstConnect) {
+          let res: WebSocketJsonResponse = {
+            reconnect: true,
+          };
+          obs.next(res);
+        }
+
+        firstConnect = false;
+      };
+    }).pipe(share());
+  }
+
+  public static get Instance() {
+    return this._instance || (this._instance = new this());
+  }
+
+  public userJoin() {
+    let form: UserJoinForm = { auth: UserService.Instance.auth };
+    this.ws.send(this.client.userJoin(form));
+  }
+
+  public login(form: LoginForm) {
+    this.ws.send(this.client.login(form));
+  }
+
+  public register(form: RegisterForm) {
+    this.ws.send(this.client.register(form));
+  }
+
+  public getCaptcha() {
+    this.ws.send(this.client.getCaptcha());
+  }
+
+  public createCommunity(form: CommunityForm) {
+    this.setAuth(form); // TODO all these setauths at some point would be good to make required
+    this.ws.send(this.client.createCommunity(form));
+  }
+
+  public editCommunity(form: CommunityForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.editCommunity(form));
+  }
+
+  public deleteCommunity(form: DeleteCommunityForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.deleteCommunity(form));
+  }
+
+  public removeCommunity(form: RemoveCommunityForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.removeCommunity(form));
+  }
+
+  public followCommunity(form: FollowCommunityForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.followCommunity(form));
+  }
+
+  public listCommunities(form: ListCommunitiesForm) {
+    this.setAuth(form, false);
+    this.ws.send(this.client.listCommunities(form));
+  }
+
+  public getFollowedCommunities() {
+    let form: GetFollowedCommunitiesForm = { auth: UserService.Instance.auth };
+    this.ws.send(this.client.getFollowedCommunities(form));
+  }
+
+  public listCategories() {
+    this.ws.send(this.client.listCategories());
+  }
+
+  public createPost(form: PostForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.createPost(form));
+  }
+
+  public getPost(form: GetPostForm) {
+    this.setAuth(form, false);
+    this.ws.send(this.client.getPost(form));
+  }
+
+  public getCommunity(form: GetCommunityForm) {
+    this.setAuth(form, false);
+    this.ws.send(this.client.getCommunity(form));
+  }
+
+  public createComment(form: CommentForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.createComment(form));
+  }
+
+  public editComment(form: CommentForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.editComment(form));
+  }
+
+  public deleteComment(form: DeleteCommentForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.deleteComment(form));
+  }
+
+  public removeComment(form: RemoveCommentForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.removeComment(form));
+  }
+
+  public markCommentAsRead(form: MarkCommentAsReadForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.markCommentAsRead(form));
+  }
+
+  public likeComment(form: CommentLikeForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.likeComment(form));
+  }
+
+  public saveComment(form: SaveCommentForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.saveComment(form));
+  }
+
+  public getPosts(form: GetPostsForm) {
+    this.setAuth(form, false);
+    this.ws.send(this.client.getPosts(form));
+  }
+
+  public getComments(form: GetCommentsForm) {
+    this.setAuth(form, false);
+    this.ws.send(this.client.getComments(form));
+  }
+
+  public likePost(form: CreatePostLikeForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.likePost(form));
+  }
+
+  public editPost(form: PostForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.editPost(form));
+  }
+
+  public deletePost(form: DeletePostForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.deletePost(form));
+  }
+
+  public removePost(form: RemovePostForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.removePost(form));
+  }
+
+  public lockPost(form: LockPostForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.lockPost(form));
+  }
+
+  public stickyPost(form: StickyPostForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.stickyPost(form));
+  }
+
+  public savePost(form: SavePostForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.savePost(form));
+  }
+
+  public banFromCommunity(form: BanFromCommunityForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.banFromCommunity(form));
+  }
+
+  public addModToCommunity(form: AddModToCommunityForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.addModToCommunity(form));
+  }
+
+  public transferCommunity(form: TransferCommunityForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.transferCommunity(form));
+  }
+
+  public transferSite(form: TransferSiteForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.transferSite(form));
+  }
+
+  public banUser(form: BanUserForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.banUser(form));
+  }
+
+  public addAdmin(form: AddAdminForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.addAdmin(form));
+  }
+
+  public getUserDetails(form: GetUserDetailsForm) {
+    this.setAuth(form, false);
+    this.ws.send(this.client.getUserDetails(form));
+  }
+
+  public getReplies(form: GetRepliesForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.getReplies(form));
+  }
+
+  public getUserMentions(form: GetUserMentionsForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.getUserMentions(form));
+  }
+
+  public markUserMentionAsRead(form: MarkUserMentionAsReadForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.markUserMentionAsRead(form));
+  }
+
+  public getModlog(form: GetModlogForm) {
+    this.ws.send(this.client.getModlog(form));
+  }
+
+  public createSite(form: SiteForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.createSite(form));
+  }
+
+  public editSite(form: SiteForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.editSite(form));
+  }
+
+  public getSite(form: GetSiteForm = {}) {
+    this.setAuth(form, false);
+    this.ws.send(this.client.getSite(form));
+  }
+
+  public getSiteConfig() {
+    let form: GetSiteConfig = {};
+    this.setAuth(form);
+    this.ws.send(this.client.getSiteConfig(form));
+  }
+
+  public search(form: SearchForm) {
+    this.setAuth(form, false);
+    this.ws.send(this.client.search(form));
+  }
+
+  public markAllAsRead() {
+    let form: MarkAllAsReadForm;
+    this.setAuth(form);
+    this.ws.send(this.client.markAllAsRead(form));
+  }
+
+  public saveUserSettings(form: UserSettingsForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.saveUserSettings(form));
+  }
+
+  public deleteAccount(form: DeleteAccountForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.deleteAccount(form));
+  }
+
+  public passwordReset(form: PasswordResetForm) {
+    this.ws.send(this.client.passwordReset(form));
+  }
+
+  public passwordChange(form: PasswordChangeForm) {
+    this.ws.send(this.client.passwordChange(form));
+  }
+
+  public createPrivateMessage(form: PrivateMessageForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.createPrivateMessage(form));
+  }
+
+  public editPrivateMessage(form: EditPrivateMessageForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.editPrivateMessage(form));
+  }
+
+  public deletePrivateMessage(form: DeletePrivateMessageForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.deletePrivateMessage(form));
+  }
+
+  public markPrivateMessageAsRead(form: MarkPrivateMessageAsReadForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.markPrivateMessageAsRead(form));
+  }
+
+  public getPrivateMessages(form: GetPrivateMessagesForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.getPrivateMessages(form));
+  }
+
+  public saveSiteConfig(form: SiteConfigForm) {
+    this.setAuth(form);
+    this.ws.send(this.client.saveSiteConfig(form));
+  }
+
+  private setAuth(obj: any, throwErr: boolean = true) {
+    obj.auth = UserService.Instance.auth;
+    if (obj.auth == null && throwErr) {
+      toast(i18n.t('not_logged_in'), 'danger');
+      throw 'Not logged in';
+    }
+  }
+}
+
+if (isBrowser()) {
+  window.onbeforeunload = () => {
+    WebSocketService.Instance.ws.close();
+  };
+}
diff --git a/src/shared/services/index.ts b/src/shared/services/index.ts
new file mode 100644 (file)
index 0000000..f0f4ccf
--- /dev/null
@@ -0,0 +1,2 @@
+export { UserService } from './UserService';
+export { WebSocketService } from './WebSocketService';
diff --git a/src/shared/utils.ts b/src/shared/utils.ts
new file mode 100644 (file)
index 0000000..01eee5e
--- /dev/null
@@ -0,0 +1,1111 @@
+import 'moment/locale/es';
+import 'moment/locale/el';
+import 'moment/locale/eu';
+import 'moment/locale/eo';
+import 'moment/locale/de';
+import 'moment/locale/zh-cn';
+import 'moment/locale/fr';
+import 'moment/locale/sv';
+import 'moment/locale/ru';
+import 'moment/locale/nl';
+import 'moment/locale/it';
+import 'moment/locale/fi';
+import 'moment/locale/ca';
+import 'moment/locale/fa';
+import 'moment/locale/pl';
+import 'moment/locale/pt-br';
+import 'moment/locale/ja';
+import 'moment/locale/ka';
+import 'moment/locale/hi';
+import 'moment/locale/gl';
+import 'moment/locale/tr';
+import 'moment/locale/hu';
+import 'moment/locale/uk';
+import 'moment/locale/sq';
+import 'moment/locale/km';
+import 'moment/locale/ga';
+import 'moment/locale/sr';
+
+import {
+  UserOperation,
+  Comment,
+  CommentNode as CommentNodeI,
+  Post,
+  PrivateMessage,
+  User,
+  SortType,
+  ListingType,
+  SearchType,
+  WebSocketResponse,
+  WebSocketJsonResponse,
+  SearchForm,
+  SearchResponse,
+  CommentResponse,
+  PostResponse,
+} from 'lemmy-js-client';
+
+import { CommentSortType, DataType } from './interfaces';
+import { UserService, WebSocketService } from './services';
+
+// import Tribute from 'tributejs';
+import markdown_it from 'markdown-it';
+import markdown_it_sub from 'markdown-it-sub';
+import markdown_it_sup from 'markdown-it-sup';
+import markdownitEmoji from 'markdown-it-emoji/light';
+import markdown_it_container from 'markdown-it-container';
+import emojiShortName from 'emoji-short-name';
+import Toastify from 'toastify-js';
+import tippy from 'tippy.js';
+import moment from 'moment';
+
+export const favIconUrl = '/static/assets/favicon.svg';
+export const favIconPngUrl = '/static/assets/apple-touch-icon.png';
+// TODO
+// export const defaultFavIcon = `${window.location.protocol}//${window.location.host}${favIconPngUrl}`;
+export const defaultFavIcon = 'test';
+export const repoUrl = 'https://github.com/LemmyNet/lemmy';
+export const helpGuideUrl = '/docs/about_guide.html';
+export const markdownHelpUrl = `${helpGuideUrl}#markdown-guide`;
+export const sortingHelpUrl = `${helpGuideUrl}#sorting`;
+export const archiveUrl = 'https://archive.is';
+export const elementUrl = 'https://element.io/';
+
+export const postRefetchSeconds: number = 60 * 1000;
+export const fetchLimit: number = 20;
+export const mentionDropdownFetchLimit = 10;
+
+export const languages = [
+  { code: 'ca', name: 'Català' },
+  { code: 'en', name: 'English' },
+  { code: 'el', name: 'Ελληνικά' },
+  { code: 'eu', name: 'Euskara' },
+  { code: 'eo', name: 'Esperanto' },
+  { code: 'es', name: 'Español' },
+  { code: 'de', name: 'Deutsch' },
+  { code: 'ga', name: 'Gaeilge' },
+  { code: 'gl', name: 'Galego' },
+  { code: 'hu', name: 'Magyar Nyelv' },
+  { code: 'ka', name: 'ქართული ენა' },
+  { code: 'km', name: 'ភាសាខ្មែរ' },
+  { code: 'hi', name: 'मानक हिन्दी' },
+  { code: 'fa', name: 'فارسی' },
+  { code: 'ja', name: '日本語' },
+  { code: 'pl', name: 'Polski' },
+  { code: 'pt_BR', name: 'Português Brasileiro' },
+  { code: 'zh', name: '中文' },
+  { code: 'fi', name: 'Suomi' },
+  { code: 'fr', name: 'Français' },
+  { code: 'sv', name: 'Svenska' },
+  { code: 'sq', name: 'Shqip' },
+  { code: 'sr_Latn', name: 'srpski' },
+  { code: 'tr', name: 'Türkçe' },
+  { code: 'uk', name: 'Українська Mова' },
+  { code: 'ru', name: 'Русский' },
+  { code: 'nl', name: 'Nederlands' },
+  { code: 'it', name: 'Italiano' },
+];
+
+export const themes = [
+  'litera',
+  'materia',
+  'minty',
+  'solar',
+  'united',
+  'cyborg',
+  'darkly',
+  'journal',
+  'sketchy',
+  'vaporwave',
+  'vaporwave-dark',
+  'i386',
+  'litely',
+];
+
+const DEFAULT_ALPHABET =
+  'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+
+function getRandomCharFromAlphabet(alphabet: string): string {
+  return alphabet.charAt(Math.floor(Math.random() * alphabet.length));
+}
+
+export function randomStr(
+  idDesiredLength: number = 20,
+  alphabet = DEFAULT_ALPHABET
+): string {
+  /**
+   * Create n-long array and map it to random chars from given alphabet.
+   * Then join individual chars as string
+   */
+  return Array.from({ length: idDesiredLength })
+    .map(() => {
+      return getRandomCharFromAlphabet(alphabet);
+    })
+    .join('');
+}
+
+export function wsJsonToRes(msg: WebSocketJsonResponse): WebSocketResponse {
+  let opStr: string = msg.op;
+  return {
+    op: UserOperation[opStr],
+    data: msg.data,
+  };
+}
+
+export const md = new markdown_it({
+  html: false,
+  linkify: true,
+  typographer: true,
+})
+  .use(markdown_it_sub)
+  .use(markdown_it_sup)
+  .use(markdown_it_container, 'spoiler', {
+    validate: function (params: any) {
+      return params.trim().match(/^spoiler\s+(.*)$/);
+    },
+
+    render: function (tokens: any, idx: any) {
+      var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
+
+      if (tokens[idx].nesting === 1) {
+        // opening tag
+        return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
+      } else {
+        // closing tag
+        return '</details>\n';
+      }
+    },
+  })
+  .use(markdownitEmoji, {
+    defs: objectFlip(emojiShortName),
+  });
+
+export function hotRankComment(comment: Comment): number {
+  return hotRank(comment.score, comment.published);
+}
+
+export function hotRankPost(post: Post): number {
+  return hotRank(post.score, post.newest_activity_time);
+}
+
+export function hotRank(score: number, timeStr: string): number {
+  // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
+  let date: Date = new Date(timeStr + 'Z'); // Add Z to convert from UTC date
+  let now: Date = new Date();
+  let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
+
+  let rank =
+    (10000 * Math.log10(Math.max(1, 3 + score))) /
+    Math.pow(hoursElapsed + 2, 1.8);
+
+  // console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
+
+  return rank;
+}
+
+export function mdToHtml(text: string) {
+  return { __html: md.render(text) };
+}
+
+export function getUnixTime(text: string): number {
+  return text ? new Date(text).getTime() / 1000 : undefined;
+}
+
+export function addTypeInfo<T>(
+  arr: T[],
+  name: string
+): { type_: string; data: T }[] {
+  return arr.map(e => {
+    return { type_: name, data: e };
+  });
+}
+
+export function canMod(
+  user: User,
+  modIds: number[],
+  creator_id: number,
+  onSelf: boolean = false
+): boolean {
+  // You can do moderator actions only on the mods added after you.
+  if (user) {
+    let yourIndex = modIds.findIndex(id => id == user.id);
+    if (yourIndex == -1) {
+      return false;
+    } else {
+      // onSelf +1 on mod actions not for yourself, IE ban, remove, etc
+      modIds = modIds.slice(0, yourIndex + (onSelf ? 0 : 1));
+      return !modIds.includes(creator_id);
+    }
+  } else {
+    return false;
+  }
+}
+
+export function isMod(modIds: number[], creator_id: number): boolean {
+  return modIds.includes(creator_id);
+}
+
+const imageRegex = new RegExp(
+  /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/
+);
+const videoRegex = new RegExp(`(http)?s?:?(\/\/[^"']*\.(?:mp4))`);
+
+export function isImage(url: string) {
+  return imageRegex.test(url);
+}
+
+export function isVideo(url: string) {
+  return videoRegex.test(url);
+}
+
+// TODO this broke
+export function validURL(str: string) {
+  // try {
+  return !!new URL(str);
+  // } catch {
+  // return false;
+  // }
+}
+
+export function validEmail(email: string) {
+  let re = /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/;
+  return re.test(String(email).toLowerCase());
+}
+
+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 routeListingTypeToEnum(type: string): ListingType {
+  return ListingType[type];
+}
+
+export function routeDataTypeToEnum(type: string): DataType {
+  return DataType[capitalizeFirstLetter(type)];
+}
+
+export function routeSearchTypeToEnum(type: string): SearchType {
+  return SearchType[capitalizeFirstLetter(type)];
+}
+
+export async function getPageTitle(url: string) {
+  let res = await fetch(`/iframely/oembed?url=${url}`).then(res => res.json());
+  let title = await res.title;
+  return title;
+}
+
+export function debounce(
+  func: any,
+  wait: number = 1000,
+  immediate: boolean = 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;
+
+  // Calling debounce returns a new anonymous function
+  return function () {
+    // reference the context and args for the setTimeout function
+    var context = this,
+      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;
+
+    // This is the basic debounce behaviour 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);
+
+    // Set the new timeout
+    timeout = setTimeout(function () {
+      // Inside the timeout function, clear the timeout variable
+      // which will let the next execution run when in 'immediate' mode
+      timeout = null;
+
+      // Check if the function already ran with the immediate flag
+      if (!immediate) {
+        // Call the original function with apply
+        // apply lets you define the 'this' object as well as the arguments
+        //    (both captured before setTimeout)
+        func.apply(context, args);
+      }
+    }, wait);
+
+    // Immediate mode and no wait timer? Execute the function..
+    if (callNow) func.apply(context, args);
+  };
+}
+
+// TODO
+export function getLanguage(override?: string): string {
+  return 'en';
+  // let user = UserService.Instance.user;
+  // let lang = override || (user && user.lang ? user.lang : 'browser');
+
+  // if (lang == 'browser') {
+  //   return getBrowserLanguage();
+  // } else {
+  //   return lang;
+  // }
+}
+
+// TODO
+export function getBrowserLanguage(): string {
+  return navigator.language;
+}
+
+export function getMomentLanguage(): string {
+  let lang = getLanguage();
+  if (lang.startsWith('zh')) {
+    lang = 'zh-cn';
+  } else if (lang.startsWith('sv')) {
+    lang = 'sv';
+  } else if (lang.startsWith('fr')) {
+    lang = 'fr';
+  } else if (lang.startsWith('de')) {
+    lang = 'de';
+  } else if (lang.startsWith('ru')) {
+    lang = 'ru';
+  } else if (lang.startsWith('es')) {
+    lang = 'es';
+  } else if (lang.startsWith('eo')) {
+    lang = 'eo';
+  } else if (lang.startsWith('nl')) {
+    lang = 'nl';
+  } else if (lang.startsWith('it')) {
+    lang = 'it';
+  } else if (lang.startsWith('fi')) {
+    lang = 'fi';
+  } else if (lang.startsWith('ca')) {
+    lang = 'ca';
+  } else if (lang.startsWith('fa')) {
+    lang = 'fa';
+  } else if (lang.startsWith('pl')) {
+    lang = 'pl';
+  } else if (lang.startsWith('pt')) {
+    lang = 'pt-br';
+  } else if (lang.startsWith('ja')) {
+    lang = 'ja';
+  } else if (lang.startsWith('ka')) {
+    lang = 'ka';
+  } else if (lang.startsWith('hi')) {
+    lang = 'hi';
+  } else if (lang.startsWith('el')) {
+    lang = 'el';
+  } else if (lang.startsWith('eu')) {
+    lang = 'eu';
+  } else if (lang.startsWith('gl')) {
+    lang = 'gl';
+  } else if (lang.startsWith('tr')) {
+    lang = 'tr';
+  } else if (lang.startsWith('hu')) {
+    lang = 'hu';
+  } else if (lang.startsWith('uk')) {
+    lang = 'uk';
+  } else if (lang.startsWith('sq')) {
+    lang = 'sq';
+  } else if (lang.startsWith('km')) {
+    lang = 'km';
+  } else if (lang.startsWith('ga')) {
+    lang = 'ga';
+  } else if (lang.startsWith('sr')) {
+    lang = 'sr';
+  } else {
+    lang = 'en';
+  }
+  return lang;
+}
+
+// TODO
+export function setTheme(theme: string = 'darkly', loggedIn: boolean = false) {
+  // unload all the other themes
+  // for (var i = 0; i < themes.length; i++) {
+  //   let styleSheet = document.getElementById(themes[i]);
+  //   if (styleSheet) {
+  //     styleSheet.setAttribute('disabled', 'disabled');
+  //   }
+  // }
+  // // if the user is not logged in, we load the default themes and let the browser decide
+  // if (!loggedIn) {
+  //   document.getElementById('default-light').removeAttribute('disabled');
+  //   document.getElementById('default-dark').removeAttribute('disabled');
+  // } else {
+  //   document
+  //     .getElementById('default-light')
+  //     .setAttribute('disabled', 'disabled');
+  //   document
+  //     .getElementById('default-dark')
+  //     .setAttribute('disabled', 'disabled');
+  //   // Load the theme dynamically
+  //   let cssLoc = `/static/assets/css/themes/${theme}.min.css`;
+  //   loadCss(theme, cssLoc);
+  //   document.getElementById(theme).removeAttribute('disabled');
+  // }
+}
+
+export function loadCss(id: string, loc: string) {
+  if (!document.getElementById(id)) {
+    var head = document.getElementsByTagName('head')[0];
+    var link = document.createElement('link');
+    link.id = id;
+    link.rel = 'stylesheet';
+    link.type = 'text/css';
+    link.href = loc;
+    link.media = 'all';
+    head.appendChild(link);
+  }
+}
+
+export function objectFlip(obj: any) {
+  const ret = {};
+  Object.keys(obj).forEach(key => {
+    ret[obj[key]] = key;
+  });
+  return ret;
+}
+
+export function pictrsAvatarThumbnail(src: string): string {
+  // sample url: http://localhost:8535/pictrs/image/thumbnail256/gs7xuu.jpg
+  let split = src.split('/pictrs/image');
+  let out = `${split[0]}/pictrs/image/${
+    canUseWebP() ? 'webp/' : ''
+  }thumbnail96${split[1]}`;
+  return out;
+}
+
+export function showAvatars(): boolean {
+  return (
+    (UserService.Instance.user && UserService.Instance.user.show_avatars) ||
+    !UserService.Instance.user
+  );
+}
+
+export function isCakeDay(published: string): boolean {
+  // moment(undefined) or moment.utc(undefined) returns the current date/time
+  // moment(null) or moment.utc(null) returns null
+  const userCreationDate = moment.utc(published || null).local();
+  const currentDate = moment(new Date());
+
+  return (
+    userCreationDate.date() === currentDate.date() &&
+    userCreationDate.month() === currentDate.month() &&
+    userCreationDate.year() !== currentDate.year()
+  );
+}
+
+// Converts to image thumbnail
+export function pictrsImage(hash: string, thumbnail: boolean = false): string {
+  let root = `/pictrs/image`;
+
+  // Necessary for other servers / domains
+  if (hash.includes('pictrs')) {
+    let split = hash.split('/pictrs/image/');
+    root = `${split[0]}/pictrs/image`;
+    hash = split[1];
+  }
+
+  let out = `${root}/${canUseWebP() ? 'webp/' : ''}${
+    thumbnail ? 'thumbnail256/' : ''
+  }${hash}`;
+  return out;
+}
+
+export function isCommentType(
+  item: Comment | PrivateMessage | Post
+): item is Comment {
+  return (
+    (item as Comment).community_id !== undefined &&
+    (item as Comment).content !== undefined
+  );
+}
+
+export function isPostType(
+  item: Comment | PrivateMessage | Post
+): item is Post {
+  return (item as Post).stickied !== undefined;
+}
+
+export function toast(text: string, background: string = 'success') {
+  let backgroundColor = `var(--${background})`;
+  Toastify({
+    text: text,
+    backgroundColor: backgroundColor,
+    gravity: 'bottom',
+    position: 'left',
+  }).showToast();
+}
+
+export function pictrsDeleteToast(
+  clickToDeleteText: string,
+  deletePictureText: string,
+  deleteUrl: string
+) {
+  let backgroundColor = `var(--light)`;
+  let toast = Toastify({
+    text: clickToDeleteText,
+    backgroundColor: backgroundColor,
+    gravity: 'top',
+    position: 'right',
+    duration: 10000,
+    onClick: () => {
+      if (toast) {
+        window.location.replace(deleteUrl);
+        alert(deletePictureText);
+        toast.hideToast();
+      }
+    },
+    close: true,
+  }).showToast();
+}
+
+interface NotifyInfo {
+  name: string;
+  icon: string;
+  link: string;
+  body: string;
+}
+
+export function messageToastify(info: NotifyInfo, router: any) {
+  let htmlBody = info.body ? md.render(info.body) : '';
+  let backgroundColor = `var(--light)`;
+
+  let toast = Toastify({
+    text: `${htmlBody}<br />${info.name}`,
+    avatar: info.icon,
+    backgroundColor: backgroundColor,
+    className: 'text-dark',
+    close: true,
+    gravity: 'top',
+    position: 'right',
+    duration: 5000,
+    onClick: () => {
+      if (toast) {
+        toast.hideToast();
+        router.history.push(info.link);
+      }
+    },
+  }).showToast();
+}
+
+export function notifyPost(post: Post, router: any) {
+  let info: NotifyInfo = {
+    name: post.community_name,
+    icon: post.community_icon ? post.community_icon : defaultFavIcon,
+    link: `/post/${post.id}`,
+    body: post.name,
+  };
+  notify(info, router);
+}
+
+export function notifyComment(comment: Comment, router: any) {
+  let info: NotifyInfo = {
+    name: comment.creator_name,
+    icon: comment.creator_avatar ? comment.creator_avatar : defaultFavIcon,
+    link: `/post/${comment.post_id}/comment/${comment.id}`,
+    body: comment.content,
+  };
+  notify(info, router);
+}
+
+export function notifyPrivateMessage(pm: PrivateMessage, router: any) {
+  let info: NotifyInfo = {
+    name: pm.creator_name,
+    icon: pm.creator_avatar ? pm.creator_avatar : defaultFavIcon,
+    link: `/inbox`,
+    body: pm.content,
+  };
+  notify(info, router);
+}
+
+function notify(info: NotifyInfo, router: any) {
+  messageToastify(info, router);
+
+  if (Notification.permission !== 'granted') Notification.requestPermission();
+  else {
+    var notification = new Notification(info.name, {
+      icon: info.icon,
+      body: info.body,
+    });
+
+    notification.onclick = () => {
+      event.preventDefault();
+      router.history.push(info.link);
+    };
+  }
+}
+
+// export function setupTribute(): Tribute<{}> {
+//   return new Tribute({
+//     noMatchTemplate: function () {
+//       return '';
+//     },
+//     collection: [
+//       // Emojis
+//       {
+//         trigger: ':',
+//         menuItemTemplate: (item: any) => {
+//           let shortName = `:${item.original.key}:`;
+//           return `${item.original.val} ${shortName}`;
+//         },
+//         selectTemplate: (item: any) => {
+//           return `:${item.original.key}:`;
+//         },
+//         values: Object.entries(emojiShortName).map(e => {
+//           return { key: e[1], val: e[0] };
+//         }),
+//         allowSpaces: false,
+//         autocompleteMode: true,
+//         // TODO
+//         // menuItemLimit: mentionDropdownFetchLimit,
+//         menuShowMinLength: 2,
+//       },
+//       // Users
+//       {
+//         trigger: '@',
+//         selectTemplate: (item: any) => {
+//           let link = item.original.local
+//             ? `[${item.original.key}](/u/${item.original.name})`
+//             : `[${item.original.key}](/user/${item.original.id})`;
+//           return link;
+//         },
+//         values: (text: string, cb: any) => {
+//           userSearch(text, (users: any) => cb(users));
+//         },
+//         allowSpaces: false,
+//         autocompleteMode: true,
+//         // TODO
+//         // menuItemLimit: mentionDropdownFetchLimit,
+//         menuShowMinLength: 2,
+//       },
+
+//       // Communities
+//       {
+//         trigger: '!',
+//         selectTemplate: (item: any) => {
+//           let link = item.original.local
+//             ? `[${item.original.key}](/c/${item.original.name})`
+//             : `[${item.original.key}](/community/${item.original.id})`;
+//           return link;
+//         },
+//         values: (text: string, cb: any) => {
+//           communitySearch(text, (communities: any) => cb(communities));
+//         },
+//         allowSpaces: false,
+//         autocompleteMode: true,
+//         // TODO
+//         // menuItemLimit: mentionDropdownFetchLimit,
+//         menuShowMinLength: 2,
+//       },
+//     ],
+//   });
+// }
+
+// TODO
+// let tippyInstance = tippy('[data-tippy-content]');
+
+export function setupTippy() {
+  // tippyInstance.forEach(e => e.destroy());
+  // tippyInstance = tippy('[data-tippy-content]', {
+  //   delay: [500, 0],
+  //   // Display on "long press"
+  //   touch: ['hold', 500],
+  // });
+}
+
+function userSearch(text: string, cb: any) {
+  if (text) {
+    let form: SearchForm = {
+      q: text,
+      type_: SearchType.Users,
+      sort: SortType.TopAll,
+      page: 1,
+      limit: mentionDropdownFetchLimit,
+    };
+
+    WebSocketService.Instance.search(form);
+
+    let userSub = WebSocketService.Instance.subject.subscribe(
+      msg => {
+        let res = wsJsonToRes(msg);
+        if (res.op == UserOperation.Search) {
+          let data = res.data as SearchResponse;
+          let users = data.users.map(u => {
+            return {
+              key: `@${u.name}@${hostname(u.actor_id)}`,
+              name: u.name,
+              local: u.local,
+              id: u.id,
+            };
+          });
+          cb(users);
+          userSub.unsubscribe();
+        }
+      },
+      err => console.error(err),
+      () => console.log('complete')
+    );
+  } else {
+    cb([]);
+  }
+}
+
+function communitySearch(text: string, cb: any) {
+  if (text) {
+    let form: SearchForm = {
+      q: text,
+      type_: SearchType.Communities,
+      sort: SortType.TopAll,
+      page: 1,
+      limit: mentionDropdownFetchLimit,
+    };
+
+    WebSocketService.Instance.search(form);
+
+    let communitySub = WebSocketService.Instance.subject.subscribe(
+      msg => {
+        let res = wsJsonToRes(msg);
+        if (res.op == UserOperation.Search) {
+          let data = res.data as SearchResponse;
+          let communities = data.communities.map(c => {
+            return {
+              key: `!${c.name}@${hostname(c.actor_id)}`,
+              name: c.name,
+              local: c.local,
+              id: c.id,
+            };
+          });
+          cb(communities);
+          communitySub.unsubscribe();
+        }
+      },
+      err => console.error(err),
+      () => console.log('complete')
+    );
+  } else {
+    cb([]);
+  }
+}
+
+export function getListingTypeFromProps(props: any): ListingType {
+  // TODO
+  return ListingType.All;
+  // return props.match.params.listing_type
+  //   ? routeListingTypeToEnum(props.match.params.listing_type)
+  //   : UserService.Instance.user
+  //   ? Object.values(ListingType)[UserService.Instance.user.default_listing_type]
+  //   : ListingType.All;
+}
+
+// TODO might need to add a user setting for this too
+export function getDataTypeFromProps(props: any): DataType {
+  // TODO
+  return DataType.Post;
+  // return props.match.params.data_type
+  //   ? routeDataTypeToEnum(props.match.params.data_type)
+  //   : DataType.Post;
+}
+
+export function getSortTypeFromProps(props: any): SortType {
+  // TODO
+  return SortType.Active;
+  // return props.match.params.sort
+  //   ? routeSortTypeToEnum(props.match.params.sort)
+  //   : UserService.Instance.user
+  //   ? Object.values(SortType)[UserService.Instance.user.default_sort_type]
+  //   : SortType.Active;
+}
+
+export function getPageFromProps(props: any): number {
+  // TODO
+  return 1;
+  // return props.match.params.page ? Number(props.match.params.page) : 1;
+}
+
+export function editCommentRes(
+  data: CommentResponse,
+  comments: Comment[]
+) {
+  let found = comments.find(c => c.id == data.comment.id);
+  if (found) {
+    found.content = data.comment.content;
+    found.updated = data.comment.updated;
+    found.removed = data.comment.removed;
+    found.deleted = data.comment.deleted;
+    found.upvotes = data.comment.upvotes;
+    found.downvotes = data.comment.downvotes;
+    found.score = data.comment.score;
+  }
+}
+
+export function saveCommentRes(
+  data: CommentResponse,
+  comments: Comment[]
+) {
+  let found = comments.find(c => c.id == data.comment.id);
+  if (found) {
+    found.saved = data.comment.saved;
+  }
+}
+
+export function createCommentLikeRes(
+  data: CommentResponse,
+  comments: Comment[]
+) {
+  let found: Comment = comments.find(c => c.id === data.comment.id);
+  if (found) {
+    found.score = data.comment.score;
+    found.upvotes = data.comment.upvotes;
+    found.downvotes = data.comment.downvotes;
+    if (data.comment.my_vote !== null) {
+      found.my_vote = data.comment.my_vote;
+    }
+  }
+}
+
+export function createPostLikeFindRes(data: PostResponse, posts: Post[]) {
+  let found = posts.find(c => c.id == data.post.id);
+  if (found) {
+    createPostLikeRes(data, found);
+  }
+}
+
+export function createPostLikeRes(data: PostResponse, post: Post) {
+  if (post) {
+    post.score = data.post.score;
+    post.upvotes = data.post.upvotes;
+    post.downvotes = data.post.downvotes;
+    if (data.post.my_vote !== null) {
+      post.my_vote = data.post.my_vote;
+    }
+  }
+}
+
+export function editPostFindRes(data: PostResponse, posts: Post[]) {
+  let found = posts.find(c => c.id == data.post.id);
+  if (found) {
+    editPostRes(data, found);
+  }
+}
+
+export function editPostRes(data: PostResponse, post: Post) {
+  if (post) {
+    post.url = data.post.url;
+    post.name = data.post.name;
+    post.nsfw = data.post.nsfw;
+    post.deleted = data.post.deleted;
+    post.removed = data.post.removed;
+    post.stickied = data.post.stickied;
+    post.body = data.post.body;
+    post.locked = data.post.locked;
+  }
+}
+
+export function commentsToFlatNodes(
+  comments: Comment[]
+): CommentNodeI[] {
+  let nodes: CommentNodeI[] = [];
+  for (let comment of comments) {
+    nodes.push({ comment: comment });
+  }
+  return nodes;
+}
+
+export function commentSort(tree: CommentNodeI[], sort: CommentSortType) {
+  // First, put removed and deleted comments at the bottom, then do your other sorts
+  if (sort == CommentSortType.Top) {
+    tree.sort(
+      (a, b) =>
+        +a.comment.removed - +b.comment.removed ||
+        +a.comment.deleted - +b.comment.deleted ||
+        b.comment.score - a.comment.score
+    );
+  } else if (sort == CommentSortType.New) {
+    tree.sort(
+      (a, b) =>
+        +a.comment.removed - +b.comment.removed ||
+        +a.comment.deleted - +b.comment.deleted ||
+        b.comment.published.localeCompare(a.comment.published)
+    );
+  } else if (sort == CommentSortType.Old) {
+    tree.sort(
+      (a, b) =>
+        +a.comment.removed - +b.comment.removed ||
+        +a.comment.deleted - +b.comment.deleted ||
+        a.comment.published.localeCompare(b.comment.published)
+    );
+  } else if (sort == CommentSortType.Hot) {
+    tree.sort(
+      (a, b) =>
+        +a.comment.removed - +b.comment.removed ||
+        +a.comment.deleted - +b.comment.deleted ||
+        hotRankComment(b.comment) - hotRankComment(a.comment)
+    );
+  }
+
+  // Go through the children recursively
+  for (let node of tree) {
+    if (node.children) {
+      commentSort(node.children, sort);
+    }
+  }
+}
+
+export function commentSortSortType(tree: CommentNodeI[], sort: SortType) {
+  commentSort(tree, convertCommentSortType(sort));
+}
+
+function convertCommentSortType(sort: SortType): CommentSortType {
+  if (
+    sort == SortType.TopAll ||
+    sort == SortType.TopDay ||
+    sort == SortType.TopWeek ||
+    sort == SortType.TopMonth ||
+    sort == SortType.TopYear
+  ) {
+    return CommentSortType.Top;
+  } else if (sort == SortType.New) {
+    return CommentSortType.New;
+  } else if (sort == SortType.Hot || sort == SortType.Active) {
+    return CommentSortType.Hot;
+  } else {
+    return CommentSortType.Hot;
+  }
+}
+
+export function postSort(
+  posts: Post[],
+  sort: SortType,
+  communityType: boolean
+) {
+  // First, put removed and deleted comments at the bottom, then do your other sorts
+  if (
+    sort == SortType.TopAll ||
+    sort == SortType.TopDay ||
+    sort == SortType.TopWeek ||
+    sort == SortType.TopMonth ||
+    sort == SortType.TopYear
+  ) {
+    posts.sort(
+      (a, b) =>
+        +a.removed - +b.removed ||
+        +a.deleted - +b.deleted ||
+        (communityType && +b.stickied - +a.stickied) ||
+        b.score - a.score
+    );
+  } else if (sort == SortType.New) {
+    posts.sort(
+      (a, b) =>
+        +a.removed - +b.removed ||
+        +a.deleted - +b.deleted ||
+        (communityType && +b.stickied - +a.stickied) ||
+        b.published.localeCompare(a.published)
+    );
+  } else if (sort == SortType.Hot) {
+    posts.sort(
+      (a, b) =>
+        +a.removed - +b.removed ||
+        +a.deleted - +b.deleted ||
+        (communityType && +b.stickied - +a.stickied) ||
+        b.hot_rank - a.hot_rank
+    );
+  } else if (sort == SortType.Active) {
+    posts.sort(
+      (a, b) =>
+        +a.removed - +b.removed ||
+        +a.deleted - +b.deleted ||
+        (communityType && +b.stickied - +a.stickied) ||
+        b.hot_rank_active - a.hot_rank_active
+    );
+  }
+}
+
+export const colorList: string[] = [
+  hsl(0),
+  hsl(100),
+  hsl(150),
+  hsl(200),
+  hsl(250),
+  hsl(300),
+];
+
+function hsl(num: number) {
+  return `hsla(${num}, 35%, 50%, 1)`;
+}
+
+function randomHsl() {
+  return `hsla(${Math.random() * 360}, 100%, 50%, 1)`;
+}
+
+export function previewLines(
+  text: string,
+  maxChars: number = 300,
+  maxLines: number = 1
+): string {
+  return (
+    text
+      .slice(0, maxChars)
+      .split('\n')
+      // Use lines * 2 because markdown requires 2 lines
+      .slice(0, maxLines * 2)
+      .join('\n') + '...'
+  );
+}
+
+export function hostname(url: string): string {
+  let cUrl = new URL(url);
+  // TODO
+  return `${cUrl.hostname}:${cUrl.port}`;
+  // return window.location.port
+  //   ? `${cUrl.hostname}:${cUrl.port}`
+  //   : `${cUrl.hostname}`;
+}
+
+function canUseWebP() {
+  // TODO pictshare might have a webp conversion bug, try disabling this
+  return false;
+
+  // var elem = document.createElement('canvas');
+  // if (!!(elem.getContext && elem.getContext('2d'))) {
+  //   var testString = !(window.mozInnerScreenX == null) ? 'png' : 'webp';
+  //   // was able or not to get WebP representation
+  //   return (
+  //     elem.toDataURL('image/webp').startsWith('data:image/' + testString)
+  //   );
+  // }
+
+  // // very old browser like IE 8, canvas not supported
+  // return false;
+}
+
+export function validTitle(title?: string): boolean {
+  // Initial title is null, minimum length is taken care of by textarea's minLength={3}
+  if (title === null || title.length < 3) return true;
+
+  const regex = new RegExp(/.*\S.*/, 'g');
+
+  return regex.test(title);
+}
+
+export function siteBannerCss(banner: string): string {
+  return ` \
+    background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \
+    background-attachment: fixed; \
+    background-position: top; \
+    background-repeat: no-repeat; \
+    background-size: 100% cover; \
+
+    width: 100%; \
+    max-height: 100vh; \
+    `;
+}
+
+export function isBrowser() {
+  return typeof window !== 'undefined';
+}
index 178af851becfb3af1548ba1294921fbde6e8e73d..04e9bb0a5d0c9e2203acc0c43d2b5a061fa9e77f 100644 (file)
@@ -1,12 +1,14 @@
 {\r
-   "compilerOptions": {\r
-      "module": "commonjs",\r
-      "target": "esnext",\r
-      "sourceMap": true,\r
-      "jsx": "preserve",\r
-      "importHelpers": true,\r
-      "emitDecoratorMetadata": true,\r
-      "experimentalDecorators": true\r
-   },\r
-   "exclude": ["node_modules", "fuse.ts"]\r
+  "compilerOptions": {\r
+    "module": "commonjs",\r
+    "target": "esnext",\r
+    "sourceMap": true,\r
+    "inlineSources": true,\r
+    "jsx": "preserve",\r
+    "importHelpers": true,\r
+    "emitDecoratorMetadata": true,\r
+    "experimentalDecorators": true,\r
+    "esModuleInterop": true\r
+  },\r
+  "exclude": ["node_modules", "fuse.ts"]\r
 }\r
index b46c611a670482f30f806021e6f47f6703cc0478..0eb5b5430038cdce5df58a9e3989789a957c34b6 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
     core-js-pure "^3.0.0"
     regenerator-runtime "^0.13.4"
 
-"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2":
+"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.2":
   version "7.11.2"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736"
   integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw==
     exec-sh "^0.3.2"
     minimist "^1.2.0"
 
+"@iarna/cli@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@iarna/cli/-/cli-1.2.0.tgz#0f7af5e851afe895104583c4ca07377a8094d641"
+  integrity sha512-ukITQAqVs2n9HGmn3car/Ir7d3ta650iXhrG7pjr3EWdFmJuuOVWgYsu7ftsSe5VifEFFhjxVuX9+8F7L8hwcA==
+  dependencies:
+    signal-exit "^3.0.2"
+    update-notifier "^2.2.0"
+    yargs "^8.0.2"
+
 "@istanbuljs/load-nyc-config@^1.0.0":
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced"
     "@types/yargs" "^15.0.0"
     chalk "^4.0.0"
 
+"@popperjs/core@^2.4.4":
+  version "2.4.4"
+  resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.4.4.tgz#11d5db19bd178936ec89cd84519c4de439574398"
+  integrity sha512-1oO6+dN5kdIA3sKPZhRGJTfGVP4SWV6KqlMOwry4J3HfyD68sl/3KmG7DeYUzvN+RbhXDnv/D8vNNB8168tAMg==
+
 "@sinonjs/commons@^1.7.0":
   version "1.8.1"
   resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.1.tgz#e7df00f98a203324f6dc7cc606cad9d4a8ab2217"
   dependencies:
     "@sinonjs/commons" "^1.7.0"
 
+"@types/autosize@^3.0.6":
+  version "3.0.7"
+  resolved "https://registry.yarnpkg.com/@types/autosize/-/autosize-3.0.7.tgz#f5da28d7ea4532c8b60573d67ec04fc866fa13db"
+  integrity sha512-D46m3aBNg81QKk9ZigmDFuhXUkD4IpBSrkGUKpYo2QBETbUjqEe8msXNCcECaXLXv1O4ppdMpizgFRzpfrgOxA==
+  dependencies:
+    "@types/jquery" "*"
+
 "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
   version "7.1.9"
   resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.9.tgz#77e59d438522a6fb898fa43dc3455c6e72f3963d"
     jest-diff "^25.2.1"
     pretty-format "^25.2.1"
 
+"@types/jquery@*":
+  version "3.5.1"
+  resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.5.1.tgz#cebb057acf5071c40e439f30e840c57a30d406c3"
+  integrity sha512-Tyctjh56U7eX2b9udu3wG853ASYP0uagChJcQJXLUXEU6C/JiW5qt5dl8ao01VRj1i5pgXPAf8f1mq4+FDLRQg==
+  dependencies:
+    "@types/sizzle" "*"
+
 "@types/json-schema@^7.0.3":
   version "7.0.5"
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.5.tgz#dcce4430e64b443ba8945f0290fb564ad5bac6dd"
   resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.3.tgz#c893b73721db73699943bfc3653b1deb7faa4a3a"
   integrity sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==
 
-"@types/node@*", "@types/node@^14.6.0":
+"@types/node-fetch@^2.5.7":
+  version "2.5.7"
+  resolved "https://registry.yarnpkg.com/@types/node-fetch/-/node-fetch-2.5.7.tgz#20a2afffa882ab04d44ca786449a276f9f6bbf3c"
+  integrity sha512-o2WVNf5UhWRkxlf6eq+jMZDu7kjgpgJfl4xVNlvryc95O/6F2ld8ztKX+qu+Rjyet93WAWm5LjeX9H5FGkODvw==
+  dependencies:
+    "@types/node" "*"
+    form-data "^3.0.0"
+
+"@types/node@*":
   version "14.6.0"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-14.6.0.tgz#7d4411bf5157339337d7cff864d9ff45f177b499"
   integrity sha512-mikldZQitV94akrc4sCcSjtJfsTKt4p+e/s0AGscVA6XArQ9kFclP+ZiYUMnq987rc6QlYxXv/EivqlfSLxpKA==
 
+"@types/node@^14.6.0":
+  version "14.6.2"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.6.2.tgz#264b44c5a28dfa80198fc2f7b6d3c8a054b9491f"
+  integrity sha512-onlIwbaeqvZyniGPfdw/TEhKIh79pz66L1q06WUQqJLnAb6wbjvOtepLYTGHTqzdXgBYIE3ZdmqHDGsRsbBz7A==
+
 "@types/normalize-package-data@^2.4.0":
   version "2.4.0"
   resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e"
     "@types/express-serve-static-core" "*"
     "@types/mime" "*"
 
+"@types/sizzle@*":
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.2.tgz#a811b8c18e2babab7d542b3365887ae2e4d9de47"
+  integrity sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==
+
 "@types/stack-utils@^1.0.1":
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
   dependencies:
     "@types/yargs-parser" "*"
 
-"@typescript-eslint/eslint-plugin@3.9.0":
-  version "3.9.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.9.0.tgz#0fe529b33d63c9a94f7503ca2bb12c84b9477ff3"
-  integrity sha512-UD6b4p0/hSe1xdTvRCENSx7iQ+KR6ourlZFfYuPC7FlXEzdHuLPrEmuxZ23b2zW96KJX9Z3w05GE/wNOiEzrVg==
+"@typescript-eslint/eslint-plugin@3.10.1":
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.10.1.tgz#7e061338a1383f59edc204c605899f93dc2e2c8f"
+  integrity sha512-PQg0emRtzZFWq6PxBcdxRH3QIQiyFO3WCVpRL3fgj5oQS3CDs3AeAKfv4DxNhzn8ITdNJGJ4D3Qw8eAJf3lXeQ==
   dependencies:
-    "@typescript-eslint/experimental-utils" "3.9.0"
+    "@typescript-eslint/experimental-utils" "3.10.1"
     debug "^4.1.1"
     functional-red-black-tree "^1.0.1"
     regexpp "^3.0.0"
     semver "^7.3.2"
     tsutils "^3.17.1"
 
-"@typescript-eslint/experimental-utils@3.9.0":
-  version "3.9.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-3.9.0.tgz#3171d8ddba0bf02a8c2034188593630914fcf5ee"
-  integrity sha512-/vSHUDYizSOhrOJdjYxPNGfb4a3ibO8zd4nUKo/QBFOmxosT3cVUV7KIg8Dwi6TXlr667G7YPqFK9+VSZOorNA==
+"@typescript-eslint/experimental-utils@3.10.1":
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-3.10.1.tgz#e179ffc81a80ebcae2ea04e0332f8b251345a686"
+  integrity sha512-DewqIgscDzmAfd5nOGe4zm6Bl7PKtMG2Ad0KG8CUZAHlXfAKTF9Ol5PXhiMh39yRL2ChRH1cuuUGOcVyyrhQIw==
   dependencies:
     "@types/json-schema" "^7.0.3"
-    "@typescript-eslint/types" "3.9.0"
-    "@typescript-eslint/typescript-estree" "3.9.0"
+    "@typescript-eslint/types" "3.10.1"
+    "@typescript-eslint/typescript-estree" "3.10.1"
     eslint-scope "^5.0.0"
     eslint-utils "^2.0.0"
 
     eslint-scope "^5.0.0"
     eslint-utils "^2.0.0"
 
-"@typescript-eslint/parser@3.9.0":
-  version "3.9.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-3.9.0.tgz#344978a265d9a5c7c8f13e62c78172a4374dabea"
-  integrity sha512-rDHOKb6uW2jZkHQniUQVZkixQrfsZGUCNWWbKWep4A5hGhN5dLHMUCNAWnC4tXRlHedXkTDptIpxs6e4Pz8UfA==
+"@typescript-eslint/parser@3.10.1":
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-3.10.1.tgz#1883858e83e8b442627e1ac6f408925211155467"
+  integrity sha512-Ug1RcWcrJP02hmtaXVS3axPPTTPnZjupqhgj+NnZ6BCkwSImWk/283347+x9wN+lqOdK9Eo3vsyiyDHgsmiEJw==
   dependencies:
     "@types/eslint-visitor-keys" "^1.0.0"
-    "@typescript-eslint/experimental-utils" "3.9.0"
-    "@typescript-eslint/types" "3.9.0"
-    "@typescript-eslint/typescript-estree" "3.9.0"
+    "@typescript-eslint/experimental-utils" "3.10.1"
+    "@typescript-eslint/types" "3.10.1"
+    "@typescript-eslint/typescript-estree" "3.10.1"
     eslint-visitor-keys "^1.1.0"
 
-"@typescript-eslint/types@3.9.0":
-  version "3.9.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-3.9.0.tgz#be9d0aa451e1bf3ce99f2e6920659e5b2e6bfe18"
-  integrity sha512-rb6LDr+dk9RVVXO/NJE8dT1pGlso3voNdEIN8ugm4CWM5w5GimbThCMiMl4da1t5u3YwPWEwOnKAULCZgBtBHg==
+"@typescript-eslint/types@3.10.1":
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-3.10.1.tgz#1d7463fa7c32d8a23ab508a803ca2fe26e758727"
+  integrity sha512-+3+FCUJIahE9q0lDi1WleYzjCwJs5hIsbugIgnbB+dSCYUxl8L6PwmsyOPFZde2hc1DlTo/xnkOgiTLSyAbHiQ==
 
 "@typescript-eslint/typescript-estree@2.34.0":
   version "2.34.0"
     semver "^7.3.2"
     tsutils "^3.17.1"
 
-"@typescript-eslint/typescript-estree@3.9.0":
-  version "3.9.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-3.9.0.tgz#c6abbb50fa0d715cab46fef67ca6378bf2eaca13"
-  integrity sha512-N+158NKgN4rOmWVfvKOMoMFV5n8XxAliaKkArm/sOypzQ0bUL8MSnOEBW3VFIeffb/K5ce/cAV0yYhR7U4ALAA==
+"@typescript-eslint/typescript-estree@3.10.1":
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-3.10.1.tgz#fd0061cc38add4fad45136d654408569f365b853"
+  integrity sha512-QbcXOuq6WYvnB3XPsZpIwztBoquEYLXh2MtwVU+kO8jgYCiv4G5xrSP/1wg4tkvrEE+esZVquIPX/dxPlePk1w==
   dependencies:
-    "@typescript-eslint/types" "3.9.0"
-    "@typescript-eslint/visitor-keys" "3.9.0"
+    "@typescript-eslint/types" "3.10.1"
+    "@typescript-eslint/visitor-keys" "3.10.1"
     debug "^4.1.1"
     glob "^7.1.6"
     is-glob "^4.0.1"
     semver "^7.3.2"
     tsutils "^3.17.1"
 
-"@typescript-eslint/visitor-keys@3.9.0":
-  version "3.9.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-3.9.0.tgz#44de8e1b1df67adaf3b94d6b60b80f8faebc8dd3"
-  integrity sha512-O1qeoGqDbu0EZUC/MZ6F1WHTIzcBVhGqDj3LhTnj65WUA548RXVxUHbYhAW9bZWfb2rnX9QsbbP5nmeJ5Z4+ng==
+"@typescript-eslint/visitor-keys@3.10.1":
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-3.10.1.tgz#cd4274773e3eb63b2e870ac602274487ecd1e931"
+  integrity sha512-9JgC82AaQeglebjZMgYR5wgmfUdUc+EitGUUMW8u2nDckaeimzW+VsoLV6FoimPv2id3VQzfjwBxEMVz08ameQ==
   dependencies:
     eslint-visitor-keys "^1.1.0"
 
+JSONStream@^1.3.2:
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0"
+  integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==
+  dependencies:
+    jsonparse "^1.2.0"
+    through ">=2.2.7 <3"
+
 abab@^2.0.3:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.4.tgz#6dfa57b417ca06d21b2478f0e638302f99c2405c"
   integrity sha512-Eu9ELJWCz/c1e9gTiCY+FceWxcqzjYEbqMgtndnuSqZSUCOL73TWNK2mHfIj4Cw2E/ongOp+JISVNCmovt2KYQ==
 
+abbrev@1, abbrev@~1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
+  integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
+
 accepts@~1.3.7:
   version "1.3.7"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
@@ -857,6 +916,27 @@ acorn@^7.1.1, acorn@^7.4.0:
   resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.0.tgz#e1ad486e6c54501634c6c397c5c121daa383607c"
   integrity sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w==
 
+agent-base@4, agent-base@^4.1.0, agent-base@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
+  integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
+  dependencies:
+    es6-promisify "^5.0.0"
+
+agent-base@~4.2.1:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9"
+  integrity sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg==
+  dependencies:
+    es6-promisify "^5.0.0"
+
+agentkeepalive@^3.3.0, agentkeepalive@^3.4.1:
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-3.5.2.tgz#a113924dd3fa24a0bc3b78108c450c2abee00f67"
+  integrity sha512-e0L/HNe6qkQ7H19kTlRRqUibEAwDK5AFk6y3PtMsuut2VAH6+Q4xZml1tNDJD7kSAyqmbG/K08K5WEJYtUrSlQ==
+  dependencies:
+    humanize-ms "^1.2.1"
+
 aggregate-error@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a"
@@ -883,6 +963,13 @@ ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.3:
     json-schema-traverse "^0.4.1"
     uri-js "^4.2.2"
 
+ansi-align@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f"
+  integrity sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=
+  dependencies:
+    string-width "^2.0.0"
+
 ansi-colors@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
@@ -900,12 +987,12 @@ ansi-escapes@^4.2.1, ansi-escapes@^4.3.0:
   dependencies:
     type-fest "^0.11.0"
 
-ansi-regex@^2.1.1:
+ansi-regex@^2.0.0, ansi-regex@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
   integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
 
-ansi-regex@^3.0.0:
+ansi-regex@^3.0.0, ansi-regex@~3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
   integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
@@ -940,6 +1027,16 @@ ansi@^0.3.1:
   resolved "https://registry.yarnpkg.com/ansi/-/ansi-0.3.1.tgz#0c42d4fb17160d5a9af1e484bace1c66922c1b21"
   integrity sha1-DELU+xcWDVqa8eSEus4cZpIsGyE=
 
+ansicolors@~0.3.2:
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/ansicolors/-/ansicolors-0.3.2.tgz#665597de86a9ffe3aa9bfbe6cae5c6ea426b4979"
+  integrity sha1-ZlWX3oap/+Oqm/vmyuXG6kJrSXk=
+
+ansistyles@~0.1.3:
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/ansistyles/-/ansistyles-0.1.3.tgz#5de60415bda071bb37127854c864f41b23254539"
+  integrity sha1-XeYEFb2gcbs3EnhUyGT0GyMlRTk=
+
 anymatch@^1.3.0:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a"
@@ -974,6 +1071,29 @@ app-root-path@^2.0.1:
   resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-2.2.1.tgz#d0df4a682ee408273583d43f6f79e9892624bc9a"
   integrity sha512-91IFKeKk7FjfmezPKkwtaRvSpnUc4gDwPAjA1YZ9Gn0q0PPeW+vbeUsZuyDwjI7+QTHhcLen2v25fi/AmhvbJA==
 
+aproba@^1.0.3, aproba@^1.1.1, aproba@^1.1.2, aproba@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
+  integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
+
+"aproba@^1.1.2 || 2":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc"
+  integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==
+
+archy@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40"
+  integrity sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=
+
+are-we-there-yet@~1.1.2:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
+  integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==
+  dependencies:
+    delegates "^1.0.0"
+    readable-stream "^2.0.6"
+
 arg@^4.1.0:
   version "4.1.3"
   resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
@@ -1062,6 +1182,11 @@ array.prototype.flatmap@^1.2.3:
     es-abstract "^1.17.0-next.1"
     function-bind "^1.1.1"
 
+asap@^2.0.0:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
+  integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=
+
 asn1@~0.2.3:
   version "0.2.4"
   resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136"
@@ -1109,6 +1234,11 @@ atob@^2.1.2:
   resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
   integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
 
+autosize@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/autosize/-/autosize-4.0.2.tgz#073cfd07c8bf45da4b9fd153437f5bafbba1e4c9"
+  integrity sha512-jnSyH2d+qdfPGpWlcuhGiHmqBJ6g3X+8T+iRwFrHPLVcdoGJE/x6Qicm6aDHfTsbgZKxyV8UU/YB2p4cjKDRRA==
+
 aws-sign2@~0.7.0:
   version "0.7.0"
   resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
@@ -1239,6 +1369,18 @@ bcrypt-pbkdf@^1.0.0:
   dependencies:
     tweetnacl "^0.14.3"
 
+bin-links@^1.1.0, bin-links@^1.1.2:
+  version "1.1.8"
+  resolved "https://registry.yarnpkg.com/bin-links/-/bin-links-1.1.8.tgz#bd39aadab5dc4bdac222a07df5baf1af745b2228"
+  integrity sha512-KgmVfx+QqggqP9dA3iIc5pA4T1qEEEL+hOhOhNPaUm77OTrJoOXE/C05SJLNJe6m/2wUK7F1tDSou7n5TfCDzQ==
+  dependencies:
+    bluebird "^3.5.3"
+    cmd-shim "^3.0.0"
+    gentle-fs "^2.3.0"
+    graceful-fs "^4.1.15"
+    npm-normalize-package-bin "^1.0.0"
+    write-file-atomic "^2.3.0"
+
 binary-extensions@^1.0.0:
   version "1.13.1"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65"
@@ -1251,6 +1393,23 @@ bindings@^1.5.0:
   dependencies:
     file-uri-to-path "1.0.0"
 
+block-stream@*:
+  version "0.0.9"
+  resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a"
+  integrity sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=
+  dependencies:
+    inherits "~2.0.0"
+
+bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3.5.5:
+  version "3.7.2"
+  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
+  integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
+
+bluebird@~3.5.1:
+  version "3.5.5"
+  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f"
+  integrity sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w==
+
 body-parser@1.19.0:
   version "1.19.0"
   resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
@@ -1277,6 +1436,19 @@ bowser@^2.0.0-beta.3:
   resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.10.0.tgz#be3736f161c4bb8b10958027ab99465d2a811198"
   integrity sha512-OCsqTQboTEWWsUjcp5jLSw2ZHsBiv2C105iFs61bOT0Hnwi9p7/uuXdd7mu8RYcarREfdjNN+8LitmEHATsLYg==
 
+boxen@^1.2.1:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
+  integrity sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==
+  dependencies:
+    ansi-align "^2.0.0"
+    camelcase "^4.0.0"
+    chalk "^2.0.1"
+    cli-boxes "^1.0.0"
+    string-width "^2.0.0"
+    term-size "^1.2.0"
+    widest-line "^2.0.0"
+
 brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
@@ -1334,11 +1506,70 @@ buffer-from@^1.0.0:
   resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
   integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
 
+builtin-modules@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f"
+  integrity sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=
+
+builtins@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/builtins/-/builtins-1.0.3.tgz#cb94faeb61c8696451db36534e1422f94f0aee88"
+  integrity sha1-y5T662HIaWRR2zZTThQi+U8K7og=
+
+byline@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/byline/-/byline-5.0.0.tgz#741c5216468eadc457b03410118ad77de8c1ddb1"
+  integrity sha1-dBxSFkaOrcRXsDQQEYrXfejB3bE=
+
+byte-size@^4.0.2:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/byte-size/-/byte-size-4.0.4.tgz#29d381709f41aae0d89c631f1c81aec88cd40b23"
+  integrity sha512-82RPeneC6nqCdSwCX2hZUz3JPOvN5at/nTEw/CMf05Smu3Hrpo9Psb7LjN+k+XndNArG1EY8L4+BM3aTM4BCvw==
+
 bytes@3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
   integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
 
+cacache@^10.0.0, cacache@^10.0.4:
+  version "10.0.4"
+  resolved "https://registry.yarnpkg.com/cacache/-/cacache-10.0.4.tgz#6452367999eff9d4188aefd9a14e9d7c6a263460"
+  integrity sha512-Dph0MzuH+rTQzGPNT9fAnrPmMmjKfST6trxJeK7NQuHRaVw24VzPRWTmg9MpcwOVQZO0E1FBICUlFeNaKPIfHA==
+  dependencies:
+    bluebird "^3.5.1"
+    chownr "^1.0.1"
+    glob "^7.1.2"
+    graceful-fs "^4.1.11"
+    lru-cache "^4.1.1"
+    mississippi "^2.0.0"
+    mkdirp "^0.5.1"
+    move-concurrently "^1.0.1"
+    promise-inflight "^1.0.1"
+    rimraf "^2.6.2"
+    ssri "^5.2.4"
+    unique-filename "^1.1.0"
+    y18n "^4.0.0"
+
+cacache@^11.0.2, cacache@^11.3.3:
+  version "11.3.3"
+  resolved "https://registry.yarnpkg.com/cacache/-/cacache-11.3.3.tgz#8bd29df8c6a718a6ebd2d010da4d7972ae3bbadc"
+  integrity sha512-p8WcneCytvzPxhDvYp31PD039vi77I12W+/KfR9S8AZbaiARFBCpsPJS+9uhWfeBfeAtW7o/4vt3MUqLkbY6nA==
+  dependencies:
+    bluebird "^3.5.5"
+    chownr "^1.1.1"
+    figgy-pudding "^3.5.1"
+    glob "^7.1.4"
+    graceful-fs "^4.1.15"
+    lru-cache "^5.1.1"
+    mississippi "^3.0.0"
+    mkdirp "^0.5.1"
+    move-concurrently "^1.0.1"
+    promise-inflight "^1.0.1"
+    rimraf "^2.6.3"
+    ssri "^6.0.1"
+    unique-filename "^1.1.1"
+    y18n "^4.0.0"
+
 cache-base@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2"
@@ -1354,11 +1585,21 @@ cache-base@^1.0.1:
     union-value "^1.0.0"
     unset-value "^1.0.0"
 
+call-limit@~1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/call-limit/-/call-limit-1.1.1.tgz#ef15f2670db3f1992557e2d965abc459e6e358d4"
+  integrity sha512-5twvci5b9eRBw2wCfPtN0GmlR2/gadZqyFpPhOK6CvMFoFgA+USnZ6Jpu1lhG9h85pQ3Ouil3PfXWRD4EUaRiQ==
+
 callsites@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
   integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
 
+camelcase@^4.0.0, camelcase@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
+  integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
+
 camelcase@^5.0.0, camelcase@^5.3.1:
   version "5.3.1"
   resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
@@ -1376,6 +1617,11 @@ capture-exit@^2.0.0:
   dependencies:
     rsvp "^4.8.4"
 
+capture-stack-trace@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d"
+  integrity sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw==
+
 caseless@~0.12.0:
   version "0.12.0"
   resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
@@ -1391,7 +1637,7 @@ chain-able@^3.0.0:
   resolved "https://registry.yarnpkg.com/chain-able/-/chain-able-3.0.0.tgz#dcffe8b04f3da210941a23843bc1332bb288ca9f"
   integrity sha512-26MoELhta86n7gCsE2T1hGRyncZvPjFXTkB/DEp4+i/EJVSxXQNwXMDZZb2+SWcbPuow18wQtztaW7GXOel9DA==
 
-chalk@^2.0.0, chalk@^2.4.1:
+chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.1:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
   integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
@@ -1438,6 +1684,15 @@ cheerio@^1.0.0-rc.3:
     lodash "^4.15.0"
     parse5 "^3.0.1"
 
+choices.js@^9.0.1:
+  version "9.0.1"
+  resolved "https://registry.yarnpkg.com/choices.js/-/choices.js-9.0.1.tgz#745fb29af8670428fdc0bf1cc9dfaa404e9d0510"
+  integrity sha512-JgpeDY0Tmg7tqY6jaW/druSklJSt7W68tXFJIw0GSGWmO37SDAL8o60eICNGbzIODjj02VNNtf5h6TgoHDtCsA==
+  dependencies:
+    deepmerge "^4.2.0"
+    fuse.js "^3.4.5"
+    redux "^4.0.4"
+
 chokidar@^1.6.1:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
@@ -1454,11 +1709,31 @@ chokidar@^1.6.1:
   optionalDependencies:
     fsevents "^1.0.0"
 
+chownr@^1.0.1, chownr@^1.1.1, chownr@^1.1.2:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
+  integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
+
+chownr@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.0.1.tgz#e2a75042a9551908bebd25b8523d5f9769d79181"
+  integrity sha1-4qdQQqlVGQi+vSW4Uj1fl2nXkYE=
+
+ci-info@^1.5.0:
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497"
+  integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==
+
 ci-info@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
   integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==
 
+cidr-regex@1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/cidr-regex/-/cidr-regex-1.0.6.tgz#74abfd619df370b9d54ab14475568e97dd64c0c1"
+  integrity sha1-dKv9YZ3zcLnVSrFEdVaOl91kwME=
+
 class-utils@^0.3.5:
   version "0.3.6"
   resolved "https://registry.yarnpkg.com/class-utils/-/class-utils-0.3.6.tgz#f93369ae8b9a7ce02fd41faad0ca83033190c463"
@@ -1469,6 +1744,11 @@ class-utils@^0.3.5:
     isobject "^3.0.0"
     static-extend "^0.1.1"
 
+classcat@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/classcat/-/classcat-4.1.0.tgz#e8fd8623e5625187b58adf49bb669a13b6c520f4"
+  integrity sha512-RA8O5oCi1I1CF6rR4cRBROh8MtZzM4w7xKLm0jd+S6UN2G4FIto+9DVOeFc46JEZFN5PVe/EZWLQO1VU/AUH4A==
+
 clean-css@^4.1.9:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"
@@ -1488,6 +1768,19 @@ clean-stack@^2.0.0:
   resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b"
   integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==
 
+cli-boxes@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
+  integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM=
+
+cli-columns@^3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/cli-columns/-/cli-columns-3.1.2.tgz#6732d972979efc2ae444a1f08e08fa139c96a18e"
+  integrity sha1-ZzLZcpee/CrkRKHwjgj6E5yWoY4=
+  dependencies:
+    string-width "^2.0.0"
+    strip-ansi "^3.0.1"
+
 cli-cursor@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
@@ -1502,7 +1795,27 @@ cli-cursor@^3.1.0:
   dependencies:
     restore-cursor "^3.1.0"
 
-cli-truncate@2.1.0, cli-truncate@^2.1.0:
+cli-table2@~0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/cli-table2/-/cli-table2-0.2.0.tgz#2d1ef7f218a0e786e214540562d4bd177fe32d97"
+  integrity sha1-LR738hig54biFFQFYtS9F3/jLZc=
+  dependencies:
+    lodash "^3.10.1"
+    string-width "^1.0.1"
+  optionalDependencies:
+    colors "^1.1.2"
+
+cli-table3@^0.5.0:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.5.1.tgz#0252372d94dfc40dbd8df06005f48f31f656f202"
+  integrity sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw==
+  dependencies:
+    object-assign "^4.1.0"
+    string-width "^2.1.1"
+  optionalDependencies:
+    colors "^1.1.2"
+
+cli-truncate@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7"
   integrity sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==
@@ -1515,6 +1828,24 @@ cli-width@^2.0.0:
   resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48"
   integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==
 
+cliui@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/cliui/-/cliui-3.2.0.tgz#120601537a916d29940f934da3b48d585a39213d"
+  integrity sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=
+  dependencies:
+    string-width "^1.0.1"
+    strip-ansi "^3.0.1"
+    wrap-ansi "^2.0.0"
+
+cliui@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/cliui/-/cliui-5.0.0.tgz#deefcfdb2e800784aa34f46fa08e06851c7bbbc5"
+  integrity sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==
+  dependencies:
+    string-width "^3.1.0"
+    strip-ansi "^5.2.0"
+    wrap-ansi "^5.1.0"
+
 cliui@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
@@ -1524,11 +1855,37 @@ cliui@^6.0.0:
     strip-ansi "^6.0.0"
     wrap-ansi "^6.2.0"
 
+clone@^1.0.2:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
+  integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
+
+cmd-shim@^3.0.0, cmd-shim@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-3.0.3.tgz#2c35238d3df37d98ecdd7d5f6b8dc6b21cadc7cb"
+  integrity sha512-DtGg+0xiFhQIntSBRzL2fRQBnmtAVwXIDo4Qq46HPpObYquxMaZS4sb82U9nH91qJrlosC1wa9gwr0QyL/HypA==
+  dependencies:
+    graceful-fs "^4.1.2"
+    mkdirp "~0.5.0"
+
+cmd-shim@~2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/cmd-shim/-/cmd-shim-2.0.2.tgz#6fcbda99483a8fd15d7d30a196ca69d688a2efdb"
+  integrity sha1-b8vamUg6j9FdfTChlspp1oii79s=
+  dependencies:
+    graceful-fs "^4.1.2"
+    mkdirp "~0.5.0"
+
 co@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
   integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=
 
+code-point-at@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
+  integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
+
 collect-v8-coverage@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59"
@@ -1571,22 +1928,30 @@ colors@^1.1.2:
   resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
   integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
 
-combined-stream@^1.0.6, combined-stream@~1.0.6:
+columnify@~1.5.4:
+  version "1.5.4"
+  resolved "https://registry.yarnpkg.com/columnify/-/columnify-1.5.4.tgz#4737ddf1c7b69a8a7c340570782e947eec8e78bb"
+  integrity sha1-Rzfd8ce2mop8NAVweC6UfuyOeLs=
+  dependencies:
+    strip-ansi "^3.0.0"
+    wcwidth "^1.0.0"
+
+combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
   integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
   dependencies:
     delayed-stream "~1.0.0"
 
-commander@^2.19.0:
+commander@^2.19.0, commander@^2.20.0:
   version "2.20.3"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
   integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
 
-commander@^5.1.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae"
-  integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==
+commander@^6.0.0:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-6.1.0.tgz#f8d722b78103141006b66f4c7ba1e97315ba75bc"
+  integrity sha512-wl7PNrYWd2y5mp1OK/LhTlv8Ff4kQJQRXXAvF+uU/TPNiVJUxZLRYGj/B0y/lPGAVcSbJqH2Za/cvHmrPMC8mA==
 
 compare-versions@^3.6.0:
   version "3.6.0"
@@ -1603,6 +1968,41 @@ concat-map@0.0.1:
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
 
+concat-stream@^1.5.0, concat-stream@^1.5.2:
+  version "1.6.2"
+  resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
+  integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
+  dependencies:
+    buffer-from "^1.0.0"
+    inherits "^2.0.3"
+    readable-stream "^2.2.2"
+    typedarray "^0.0.6"
+
+config-chain@~1.1.11:
+  version "1.1.12"
+  resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.12.tgz#0fde8d091200eb5e808caf25fe618c02f48e4efa"
+  integrity sha512-a1eOIcu8+7lUInge4Rpf/n4Krkf3Dd9lqhljRzII1/Zno/kRtUWnznPO3jOKBmTEktkt3fkxisUcivoj0ebzoA==
+  dependencies:
+    ini "^1.3.4"
+    proto-list "~1.2.1"
+
+configstore@^3.0.0:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.5.tgz#e9af331fadc14dabd544d3e7e76dc446a09a530f"
+  integrity sha512-nlOhI4+fdzoK5xmJ+NY+1gZK56bwEaWZr8fYuXohZ9Vkc1o3a4T/R3M+yE/w7x/ZVJ1zF8c+oaOvF0dztdUgmA==
+  dependencies:
+    dot-prop "^4.2.1"
+    graceful-fs "^4.1.2"
+    make-dir "^1.0.0"
+    unique-string "^1.0.0"
+    write-file-atomic "^2.0.0"
+    xdg-basedir "^3.0.0"
+
+console-control-strings@^1.0.0, console-control-strings@^1.1.0, console-control-strings@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
+  integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
+
 contains-path@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a"
@@ -1645,6 +2045,23 @@ cookie@0.4.0:
   resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
   integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
 
+cookie@^0.1.2:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.1.5.tgz#6ab9948a4b1ae21952cd2588530a4722d4044d7c"
+  integrity sha1-armUiksa4hlSzSWIUwpHItQETXw=
+
+copy-concurrently@^1.0.0:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/copy-concurrently/-/copy-concurrently-1.0.5.tgz#92297398cae34937fcafd6ec8139c18051f0b5e0"
+  integrity sha512-f2domd9fsVDFtaFcbaRZuYXwtdmnzqbADSwhSWYxYB/Q8zsdUUFMXVRwXGDMWmbEzAn1kdRrtI1T/KTFOL4X2A==
+  dependencies:
+    aproba "^1.1.1"
+    fs-write-stream-atomic "^1.0.8"
+    iferr "^0.1.5"
+    mkdirp "^0.5.1"
+    rimraf "^2.5.4"
+    run-queue "^1.0.0"
+
 copy-descriptor@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
@@ -1671,6 +2088,33 @@ cosmiconfig@^6.0.0:
     path-type "^4.0.0"
     yaml "^1.7.2"
 
+cosmiconfig@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.0.0.tgz#ef9b44d773959cae63ddecd122de23853b60f8d3"
+  integrity sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==
+  dependencies:
+    "@types/parse-json" "^4.0.0"
+    import-fresh "^3.2.1"
+    parse-json "^5.0.0"
+    path-type "^4.0.0"
+    yaml "^1.10.0"
+
+create-error-class@^3.0.0:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6"
+  integrity sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=
+  dependencies:
+    capture-stack-trace "^1.0.0"
+
+cross-spawn@^5.0.1:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
+  integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=
+  dependencies:
+    lru-cache "^4.0.1"
+    shebang-command "^1.2.0"
+    which "^1.2.9"
+
 cross-spawn@^6.0.0:
   version "6.0.5"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
@@ -1691,6 +2135,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2:
     shebang-command "^2.0.0"
     which "^2.0.1"
 
+crypto-random-string@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
+  integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=
+
 css-select@~1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858"
@@ -1728,6 +2177,11 @@ csstype@^3.0.2:
   resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.2.tgz#ee5ff8f208c8cd613b389f7b222c9801ca62b3f7"
   integrity sha512-ofovWglpqoqbfLNOTBNZLSbMuGrblAf1efvvArGKOZMBrIoJeu5UsAipQolkijtyQx5MtAzT/J9IHj/CEY1mJw==
 
+cyclist@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
+  integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=
+
 damerau-levenshtein@^1.0.6:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz#143c1641cb3d85c60c32329e26899adea8701791"
@@ -1756,6 +2210,20 @@ debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
   dependencies:
     ms "2.0.0"
 
+debug@3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
+  integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
+  dependencies:
+    ms "2.0.0"
+
+debug@^3.1.0:
+  version "3.2.6"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
+  integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
+  dependencies:
+    ms "^2.1.1"
+
 debug@^4.0.1, debug@^4.1.0, debug@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
@@ -1763,7 +2231,12 @@ debug@^4.0.1, debug@^4.1.0, debug@^4.1.1:
   dependencies:
     ms "^2.1.1"
 
-decamelize@^1.2.0:
+debuglog@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492"
+  integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI=
+
+decamelize@^1.1.1, decamelize@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
   integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
@@ -1783,16 +2256,40 @@ dedent@^0.7.0:
   resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c"
   integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=
 
+deep-equal@^1.0.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a"
+  integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==
+  dependencies:
+    is-arguments "^1.0.4"
+    is-date-object "^1.0.1"
+    is-regex "^1.0.4"
+    object-is "^1.0.1"
+    object-keys "^1.1.1"
+    regexp.prototype.flags "^1.2.0"
+
+deep-extend@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
+  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
+
 deep-is@^0.1.3, deep-is@~0.1.3:
   version "0.1.3"
   resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
   integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
 
-deepmerge@^4.2.2:
+deepmerge@^4.2.0, deepmerge@^4.2.2:
   version "4.2.2"
   resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
   integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
 
+defaults@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.3.tgz#c656051e9817d9ff08ed881477f3fe4019f3ef7d"
+  integrity sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=
+  dependencies:
+    clone "^1.0.2"
+
 define-properties@^1.1.2, define-properties@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
@@ -1827,6 +2324,11 @@ delayed-stream@~1.0.0:
   resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
   integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
 
+delegates@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
+
 depd@~1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
@@ -1837,11 +2339,29 @@ destroy@~1.0.4:
   resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
   integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
 
+detect-indent@~5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d"
+  integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50=
+
+detect-newline@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2"
+  integrity sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I=
+
 detect-newline@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
   integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==
 
+dezalgo@^1.0.0, dezalgo@~1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.3.tgz#7f742de066fc748bc8db820569dddce49bf0d456"
+  integrity sha1-f3Qt4Gb8dIvI24IFad3c5Jvw1FY=
+  dependencies:
+    asap "^2.0.0"
+    wrappy "1"
+
 diff-sequences@^25.2.6:
   version "25.2.6"
   resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd"
@@ -1945,6 +2465,33 @@ domutils@^1.5.1:
     dom-serializer "0"
     domelementtype "1"
 
+dot-prop@^4.2.1:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.1.tgz#45884194a71fc2cda71cbb4bceb3a4dd2f433ba4"
+  integrity sha512-l0p4+mIuJIua0mhxGoh4a+iNL9bmeK5DvnSVQa6T0OhrVmaEa1XScX5Etc673FePCJOArq/4Pa2cLGODUWTPOQ==
+  dependencies:
+    is-obj "^1.0.0"
+
+dotenv@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-5.0.1.tgz#a5317459bd3d79ab88cff6e44057a6a3fbb1fcef"
+  integrity sha512-4As8uPrjfwb7VXC+WnLCbXK7y+Ueb2B3zgNCePYfhxS1PYeaO1YTeplffTEcbfLhvFNGLAz90VvJs9yomG7bow==
+
+duplexer3@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
+  integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
+
+duplexify@^3.4.2, duplexify@^3.6.0:
+  version "3.7.1"
+  resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309"
+  integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==
+  dependencies:
+    end-of-stream "^1.0.0"
+    inherits "^2.0.1"
+    readable-stream "^2.0.0"
+    stream-shift "^1.0.0"
+
 ecc-jsbn@~0.1.1:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
@@ -1953,6 +2500,11 @@ ecc-jsbn@~0.1.1:
     jsbn "~0.1.0"
     safer-buffer "^2.1.0"
 
+editor@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/editor/-/editor-1.0.0.tgz#60c7f87bd62bcc6a894fa8ccd6afb7823a24f742"
+  integrity sha1-YMf4e9YrzGqJT6jM1q+3gjok90I=
+
 ee-first@1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@@ -1978,19 +2530,31 @@ emoji-regex@^9.0.0:
   resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.0.0.tgz#48a2309cc8a1d2e9d23bc6a67c39b63032e76ea4"
   integrity sha512-6p1NII1Vm62wni/VR/cUMauVQoxmLVb9csqQlvLz+hO2gk8U2UYDfXHQSUYIBKmZwAKz867IDqG7B+u0mj+M6w==
 
+emoji-short-name@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/emoji-short-name/-/emoji-short-name-1.0.0.tgz#82e6f543b6c68984d69bdc80eac735104fdd4af8"
+  integrity sha512-+tiniHvgRR7XMI1jAaGveumWg5LALE/nWkFD6CcOn6M5IDM9w4PkMs8UwzLTMoZtDLdTdQmzxGvLOxHVIjPzjg==
+
 encodeurl@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
   integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
 
-end-of-stream@^1.1.0:
+encoding@^0.1.11:
+  version "0.1.13"
+  resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9"
+  integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==
+  dependencies:
+    iconv-lite "^0.6.2"
+
+end-of-stream@^1.0.0, end-of-stream@^1.1.0:
   version "1.4.4"
   resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
   integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
   dependencies:
     once "^1.4.0"
 
-enquirer@^2.3.5:
+enquirer@^2.3.5, enquirer@^2.3.6:
   version "2.3.6"
   resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d"
   integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==
@@ -2002,7 +2566,7 @@ entities@^1.1.1, entities@~1.1.1:
   resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
   integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==
 
-entities@^2.0.0:
+entities@^2.0.0, entities@~2.0.0:
   version "2.0.3"
   resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f"
   integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==
@@ -2050,6 +2614,18 @@ enzyme@^3.3.0:
     rst-selector-parser "^2.2.3"
     string.prototype.trim "^1.2.1"
 
+err-code@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/err-code/-/err-code-1.1.2.tgz#06e0116d3028f6aef4806849eb0ea6a748ae6960"
+  integrity sha1-BuARbTAo9q70gGhJ6w6mp0iuaWA=
+
+errno@~0.1.7:
+  version "0.1.7"
+  resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618"
+  integrity sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==
+  dependencies:
+    prr "~1.0.1"
+
 error-ex@^1.2.0, error-ex@^1.3.1:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
@@ -2074,6 +2650,24 @@ es-abstract@^1.17.0, es-abstract@^1.17.0-next.1, es-abstract@^1.17.5:
     string.prototype.trimend "^1.0.1"
     string.prototype.trimstart "^1.0.1"
 
+es-abstract@^1.18.0-next.0:
+  version "1.18.0-next.0"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.0-next.0.tgz#b302834927e624d8e5837ed48224291f2c66e6fc"
+  integrity sha512-elZXTZXKn51hUBdJjSZGYRujuzilgXo8vSPQzjGYXLvSlGiCo8VO8ZGV3kjo9a0WNJJ57hENagwbtlRuHuzkcQ==
+  dependencies:
+    es-to-primitive "^1.2.1"
+    function-bind "^1.1.1"
+    has "^1.0.3"
+    has-symbols "^1.0.1"
+    is-callable "^1.2.0"
+    is-negative-zero "^2.0.0"
+    is-regex "^1.1.1"
+    object-inspect "^1.8.0"
+    object-keys "^1.1.1"
+    object.assign "^4.1.0"
+    string.prototype.trimend "^1.0.1"
+    string.prototype.trimstart "^1.0.1"
+
 es-to-primitive@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
@@ -2083,6 +2677,18 @@ es-to-primitive@^1.2.1:
     is-date-object "^1.0.1"
     is-symbol "^1.0.2"
 
+es6-promise@^4.0.3:
+  version "4.2.8"
+  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
+  integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
+
+es6-promisify@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
+  integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
+  dependencies:
+    es6-promise "^4.0.3"
+
 escape-html@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
@@ -2176,12 +2782,12 @@ eslint-plugin-import@2.22.0:
     tsconfig-paths "^3.9.0"
 
 eslint-plugin-jane@^8.0.4:
-  version "8.1.1"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-jane/-/eslint-plugin-jane-8.1.1.tgz#0e673a4219690f01787553af2365c1fcd5e5363a"
-  integrity sha512-5nrNbAgqVNq2P+tkxE8bd6Lr7YrU4hRqH+h6kmrEfnH4SRptVwswY103ppe5ThSUntZSygzn5NDpji5FLJenDg==
+  version "8.1.2"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-jane/-/eslint-plugin-jane-8.1.2.tgz#7281da8377922ea0972766f5ec9a5a0add1d0429"
+  integrity sha512-NUZKavJB7jg+J/i4+/zyT10VY4htC6iLbt2Yyig8JevsgchhW/8RIidnHfyp0OipRABTI0mZqW1p7Fu827f48g==
   dependencies:
-    "@typescript-eslint/eslint-plugin" "3.9.0"
-    "@typescript-eslint/parser" "3.9.0"
+    "@typescript-eslint/eslint-plugin" "3.10.1"
+    "@typescript-eslint/parser" "3.10.1"
     babel-eslint "10.1.0"
     eslint-config-prettier "6.11.0"
     eslint-plugin-babel "5.3.1"
@@ -2191,8 +2797,8 @@ eslint-plugin-jane@^8.0.4:
     eslint-plugin-node "11.1.0"
     eslint-plugin-prettier "3.1.4"
     eslint-plugin-promise "4.2.1"
-    eslint-plugin-react "7.20.5"
-    eslint-plugin-react-hooks "4.0.8"
+    eslint-plugin-react "7.20.6"
+    eslint-plugin-react-hooks "4.1.0"
     eslint-plugin-unicorn "21.0.0"
 
 eslint-plugin-jest@23.20.0:
@@ -2243,15 +2849,15 @@ eslint-plugin-promise@4.2.1:
   resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a"
   integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw==
 
-eslint-plugin-react-hooks@4.0.8:
-  version "4.0.8"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.0.8.tgz#a9b1e3d57475ccd18276882eff3d6cba00da7a56"
-  integrity sha512-6SSb5AiMCPd8FDJrzah+Z4F44P2CdOaK026cXFV+o/xSRzfOiV1FNFeLl2z6xm3yqWOQEZ5OfVgiec90qV2xrQ==
+eslint-plugin-react-hooks@4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.1.0.tgz#6323fbd5e650e84b2987ba76370523a60f4e7925"
+  integrity sha512-36zilUcDwDReiORXmcmTc6rRumu9JIM3WjSvV0nclHoUQ0CNrX866EwONvLR/UqaeqFutbAnVu8PEmctdo2SRQ==
 
-eslint-plugin-react@7.20.5:
-  version "7.20.5"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.20.5.tgz#29480f3071f64a04b2c3d99d9b460ce0f76fb857"
-  integrity sha512-ajbJfHuFnpVNJjhyrfq+pH1C0gLc2y94OiCbAXT5O0J0YCKaFEHDV8+3+mDOr+w8WguRX+vSs1bM2BDG0VLvCw==
+eslint-plugin-react@7.20.6:
+  version "7.20.6"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.20.6.tgz#4d7845311a93c463493ccfa0a19c9c5d0fd69f60"
+  integrity sha512-kidMTE5HAEBSLu23CUDvj8dc3LdBU0ri1scwHBZjI41oDv4tjsWZKU7MQccFzH1QYPYhsnTF2ovh7JlcIcmxgg==
   dependencies:
     array-includes "^3.1.1"
     array.prototype.flatmap "^1.2.3"
@@ -2421,6 +3027,19 @@ exec-sh@^0.3.2:
   resolved "https://registry.yarnpkg.com/exec-sh/-/exec-sh-0.3.4.tgz#3a018ceb526cc6f6df2bb504b2bfe8e3a4934ec5"
   integrity sha512-sEFIkc61v75sWeOe72qyrqg2Qg0OuLESziUDk/O/z2qgS15y2gWVFrI6f2Qn/qw/0/NCfCEsmNA4zOjkwEZT1A==
 
+execa@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
+  integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=
+  dependencies:
+    cross-spawn "^5.0.1"
+    get-stream "^3.0.0"
+    is-stream "^1.1.0"
+    npm-run-path "^2.0.0"
+    p-finally "^1.0.0"
+    signal-exit "^3.0.0"
+    strip-eof "^1.0.0"
+
 execa@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8"
@@ -2434,7 +3053,7 @@ execa@^1.0.0:
     signal-exit "^3.0.0"
     strip-eof "^1.0.0"
 
-execa@^4.0.0, execa@^4.0.1:
+execa@^4.0.0, execa@^4.0.3:
   version "4.0.3"
   resolved "https://registry.yarnpkg.com/execa/-/execa-4.0.3.tgz#0a34dabbad6d66100bd6f2c576c8669403f317f2"
   integrity sha512-WFDXGHckXPWZX19t1kCsXzOpqX9LWYNqn4C+HqZlk/V0imTkzJZqf87ZBhvpHaftERYknpk0fjSylnXVlVgI0A==
@@ -2449,6 +3068,11 @@ execa@^4.0.0, execa@^4.0.1:
     signal-exit "^3.0.2"
     strip-final-newline "^2.0.0"
 
+exenv@^1.2.1:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/exenv/-/exenv-1.2.2.tgz#2ae78e85d9894158670b03d47bec1f03bd91bb9d"
+  integrity sha1-KueOhdmJQVhnCwPUe+wfA72Ru50=
+
 exit@^0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
@@ -2616,6 +3240,11 @@ fb-watchman@^2.0.0:
   dependencies:
     bser "2.1.1"
 
+figgy-pudding@^3.0.0, figgy-pudding@^3.5.1:
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e"
+  integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==
+
 figures@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962"
@@ -2703,6 +3332,11 @@ finalhandler@~1.1.2:
     statuses "~1.5.0"
     unpipe "~1.0.0"
 
+find-npm-prefix@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/find-npm-prefix/-/find-npm-prefix-1.0.2.tgz#8d8ce2c78b3b4b9e66c8acc6a37c231eb841cfdf"
+  integrity sha512-KEftzJ+H90x6pcKtdXZEPsQse8/y/UnvzRKrOSQFprnrGaFuJ62fVkP34Iu2IYuMvyauCyoLTNkJZgrrGA2wkA==
+
 find-up@^2.0.0, find-up@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7"
@@ -2710,6 +3344,13 @@ find-up@^2.0.0, find-up@^2.1.0:
   dependencies:
     locate-path "^2.0.0"
 
+find-up@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73"
+  integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==
+  dependencies:
+    locate-path "^3.0.0"
+
 find-up@^4.0.0, find-up@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
@@ -2746,6 +3387,14 @@ fliplog@^0.3.13:
   dependencies:
     chain-able "^1.0.1"
 
+flush-write-stream@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8"
+  integrity sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==
+  dependencies:
+    inherits "^2.0.3"
+    readable-stream "^2.3.6"
+
 for-in@^1.0.1, for-in@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
@@ -2763,6 +3412,15 @@ forever-agent@~0.6.1:
   resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
   integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
 
+form-data@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682"
+  integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==
+  dependencies:
+    asynckit "^0.4.0"
+    combined-stream "^1.0.8"
+    mime-types "^2.1.12"
+
 form-data@~2.3.2:
   version "2.3.3"
   resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
@@ -2789,6 +3447,22 @@ fresh@0.5.2:
   resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
   integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
 
+from2@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/from2/-/from2-1.3.0.tgz#88413baaa5f9a597cfde9221d86986cd3c061dfd"
+  integrity sha1-iEE7qqX5pZfP3pIh2GmGzTwGHf0=
+  dependencies:
+    inherits "~2.0.1"
+    readable-stream "~1.1.10"
+
+from2@^2.1.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af"
+  integrity sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8=
+  dependencies:
+    inherits "^2.0.1"
+    readable-stream "^2.0.0"
+
 fs-extra@^7.0.0:
   version "7.0.1"
   resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9"
@@ -2798,6 +3472,32 @@ fs-extra@^7.0.0:
     jsonfile "^4.0.0"
     universalify "^0.1.0"
 
+fs-minipass@^1.2.5:
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7"
+  integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==
+  dependencies:
+    minipass "^2.6.0"
+
+fs-vacuum@^1.2.10, fs-vacuum@~1.2.10:
+  version "1.2.10"
+  resolved "https://registry.yarnpkg.com/fs-vacuum/-/fs-vacuum-1.2.10.tgz#b7629bec07a4031a2548fdf99f5ecf1cc8b31e36"
+  integrity sha1-t2Kb7AekAxolSP35n17PHMizHjY=
+  dependencies:
+    graceful-fs "^4.1.2"
+    path-is-inside "^1.0.1"
+    rimraf "^2.5.2"
+
+fs-write-stream-atomic@^1.0.8, fs-write-stream-atomic@~1.0.10:
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/fs-write-stream-atomic/-/fs-write-stream-atomic-1.0.10.tgz#b47df53493ef911df75731e70a9ded0189db40c9"
+  integrity sha1-tH31NJPvkR33VzHnCp3tAYnbQMk=
+  dependencies:
+    graceful-fs "^4.1.2"
+    iferr "^0.1.5"
+    imurmurhash "^0.1.4"
+    readable-stream "1 || 2"
+
 fs.realpath@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
@@ -2816,6 +3516,16 @@ fsevents@^2.1.2:
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
   integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
 
+fstream@^1.0.0, fstream@^1.0.12:
+  version "1.0.12"
+  resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045"
+  integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==
+  dependencies:
+    graceful-fs "^4.1.2"
+    inherits "~2.0.0"
+    mkdirp ">=0.5 0"
+    rimraf "2"
+
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
@@ -2896,11 +3606,57 @@ fuse-test-runner@^1.0.16:
     pretty-format "^20.0.3"
     realm-utils "^1.0.7"
 
+fuse.js@^3.4.5:
+  version "3.6.1"
+  resolved "https://registry.yarnpkg.com/fuse.js/-/fuse.js-3.6.1.tgz#7de85fdd6e1b3377c23ce010892656385fd9b10c"
+  integrity sha512-hT9yh/tiinkmirKrlv4KWOjztdoZo1mx9Qh4KvWqC7isoXwdUY3PNWUxceF4/qO9R6riA2C29jdTOeQOIROjgw==
+
+gauge@~2.7.3:
+  version "2.7.4"
+  resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
+  integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=
+  dependencies:
+    aproba "^1.0.3"
+    console-control-strings "^1.0.0"
+    has-unicode "^2.0.0"
+    object-assign "^4.1.0"
+    signal-exit "^3.0.0"
+    string-width "^1.0.1"
+    strip-ansi "^3.0.1"
+    wide-align "^1.1.0"
+
+genfun@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/genfun/-/genfun-5.0.0.tgz#9dd9710a06900a5c4a5bf57aca5da4e52fe76537"
+  integrity sha512-KGDOARWVga7+rnB3z9Sd2Letx515owfk0hSxHGuqjANb1M+x2bGZGqHLiozPsYMdM2OubeMni/Hpwmjq6qIUhA==
+
 gensync@^1.0.0-beta.1:
   version "1.0.0-beta.1"
   resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269"
   integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==
 
+gentle-fs@^2.0.1, gentle-fs@^2.3.0:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/gentle-fs/-/gentle-fs-2.3.1.tgz#11201bf66c18f930ddca72cf69460bdfa05727b1"
+  integrity sha512-OlwBBwqCFPcjm33rF2BjW+Pr6/ll2741l+xooiwTCeaX2CA1ZuclavyMBe0/KlR21/XGsgY6hzEQZ15BdNa13Q==
+  dependencies:
+    aproba "^1.1.2"
+    chownr "^1.1.2"
+    cmd-shim "^3.0.3"
+    fs-vacuum "^1.2.10"
+    graceful-fs "^4.1.11"
+    iferr "^0.1.5"
+    infer-owner "^1.0.4"
+    mkdirp "^0.5.1"
+    path-is-inside "^1.0.2"
+    read-cmd-shim "^1.0.1"
+    slide "^1.1.6"
+
+get-caller-file@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-1.0.3.tgz#f978fa4c90d1dfe7ff2d6beda2a515e713bdcf4a"
+  integrity sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==
+
 get-caller-file@^2.0.1:
   version "2.0.5"
   resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
@@ -2921,6 +3677,11 @@ get-stdin@^6.0.0:
   resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b"
   integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g==
 
+get-stream@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
+  integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=
+
 get-stream@^4.0.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
@@ -2974,7 +3735,7 @@ glob-parent@^5.0.0:
   dependencies:
     is-glob "^4.0.1"
 
-glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
+glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@~7.1.2:
   version "7.1.6"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
   integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
@@ -2986,6 +3747,13 @@ glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6:
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+global-dirs@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"
+  integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=
+  dependencies:
+    ini "^1.3.4"
+
 globals@^11.1.0:
   version "11.12.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
@@ -2998,11 +3766,33 @@ globals@^12.1.0:
   dependencies:
     type-fest "^0.8.1"
 
-graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.4:
+got@^6.7.1:
+  version "6.7.1"
+  resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0"
+  integrity sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=
+  dependencies:
+    create-error-class "^3.0.0"
+    duplexer3 "^0.1.4"
+    get-stream "^3.0.0"
+    is-redirect "^1.0.0"
+    is-retry-allowed "^1.0.0"
+    is-stream "^1.0.0"
+    lowercase-keys "^1.0.0"
+    safe-buffer "^5.0.1"
+    timed-out "^4.0.0"
+    unzip-response "^2.0.1"
+    url-parse-lax "^1.0.0"
+
+graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.4:
   version "4.2.4"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb"
   integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==
 
+graceful-fs@~4.1.11:
+  version "4.1.15"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00"
+  integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA==
+
 growly@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081"
@@ -3036,6 +3826,11 @@ has-symbols@^1.0.0, has-symbols@^1.0.1:
   resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
   integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
 
+has-unicode@^2.0.0, has-unicode@~2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
+  integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
+
 has-value@^0.3.1:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
@@ -3091,7 +3886,7 @@ hoist-non-inferno-statics@^1.1.3:
   resolved "https://registry.yarnpkg.com/hoist-non-inferno-statics/-/hoist-non-inferno-statics-1.1.3.tgz#7d870f4160bfb6a59269b45c343c027f0e30ab35"
   integrity sha1-fYcPQWC/tqWSabRcNDwCfw4wqzU=
 
-hosted-git-info@^2.1.4:
+hosted-git-info@^2.1.4, hosted-git-info@^2.6.0, hosted-git-info@^2.7.1:
   version "2.8.8"
   resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
   integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==
@@ -3115,6 +3910,13 @@ html-escaper@^2.0.0:
   resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453"
   integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==
 
+html-parse-stringify2@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/html-parse-stringify2/-/html-parse-stringify2-2.0.1.tgz#dc5670b7292ca158b7bc916c9a6735ac8872834a"
+  integrity sha1-3FZwtyksoVi3vJFsmmc1rIhyg0o=
+  dependencies:
+    void-elements "^2.0.1"
+
 htmlparser2@^3.9.1:
   version "3.10.1"
   resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
@@ -3127,6 +3929,11 @@ htmlparser2@^3.9.1:
     inherits "^2.0.1"
     readable-stream "^3.1.1"
 
+http-cache-semantics@^3.8.0, http-cache-semantics@^3.8.1:
+  version "3.8.1"
+  resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-3.8.1.tgz#39b0e16add9b605bf0a9ef3d9daaf4843b4cacd2"
+  integrity sha512-5ai2iksyV8ZXmnZhHH4rWPoxxistEexSi5936zIQ1bnNTW5VnA85B6P/VpXiRM017IgRvb2kKo1a//y+0wSp3w==
+
 http-errors@1.7.2:
   version "1.7.2"
   resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
@@ -3149,6 +3956,14 @@ http-errors@~1.7.2:
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
+http-proxy-agent@^2.0.0, http-proxy-agent@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405"
+  integrity sha512-qwHbBLV7WviBl0rQsOzH6o5lwyOIvwp/BdFnvVxXORldu5TmjFfjzBcWUWS5kWAZhmv+JtiDhSuQCp4sBfbIgg==
+  dependencies:
+    agent-base "4"
+    debug "3.1.0"
+
 http-signature@~1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
@@ -3158,11 +3973,26 @@ http-signature@~1.2.0:
     jsprim "^1.2.2"
     sshpk "^1.7.0"
 
+https-proxy-agent@^2.1.0, https-proxy-agent@^2.2.0, https-proxy-agent@^2.2.1:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b"
+  integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==
+  dependencies:
+    agent-base "^4.3.0"
+    debug "^3.1.0"
+
 human-signals@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3"
   integrity sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==
 
+humanize-ms@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/humanize-ms/-/humanize-ms-1.2.1.tgz#c46e3159a293f6b896da29316d8b6fe8bb79bbed"
+  integrity sha1-xG4xWaKT9riW2ikxbYtv6Lt5u+0=
+  dependencies:
+    ms "^2.0.0"
+
 husky@^4.2.5:
   version "4.2.5"
   resolved "https://registry.yarnpkg.com/husky/-/husky-4.2.5.tgz#2b4f7622673a71579f901d9885ed448394b5fa36"
@@ -3179,6 +4009,13 @@ husky@^4.2.5:
     slash "^3.0.0"
     which-pm-runs "^1.0.0"
 
+i18next@^19.4.1:
+  version "19.7.0"
+  resolved "https://registry.yarnpkg.com/i18next/-/i18next-19.7.0.tgz#e637bbbf36481d34b7d5e6d3b04e1bb654bf2a26"
+  integrity sha512-sxZhj6u7HbEYOMx81oGwq5MiXISRBVg2wRY3n6YIbe+HtU8ydzlGzv6ErHdrRKYxATBFssVXYbc3lNZoyB4vfA==
+  dependencies:
+    "@babel/runtime" "^7.10.1"
+
 iconv-lite@0.4.24, iconv-lite@^0.4.17:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@@ -3186,11 +4023,30 @@ iconv-lite@0.4.24, iconv-lite@^0.4.17:
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
+iconv-lite@^0.6.2:
+  version "0.6.2"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.2.tgz#ce13d1875b0c3a674bd6a04b7f76b01b1b6ded01"
+  integrity sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==
+  dependencies:
+    safer-buffer ">= 2.1.2 < 3.0.0"
+
 ieee754@^1.1.8:
   version "1.1.13"
   resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
   integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
 
+iferr@^0.1.5, iferr@~0.1.5:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
+  integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE=
+
+ignore-walk@^3.0.1:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37"
+  integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==
+  dependencies:
+    minimatch "^3.0.4"
+
 ignore@^4.0.6:
   version "4.0.6"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
@@ -3201,7 +4057,7 @@ ignore@^5.1.1:
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
   integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
 
-import-fresh@^3.0.0, import-fresh@^3.1.0:
+import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1:
   version "3.2.1"
   resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66"
   integrity sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==
@@ -3209,6 +4065,11 @@ import-fresh@^3.0.0, import-fresh@^3.1.0:
     parent-module "^1.0.0"
     resolve-from "^4.0.0"
 
+import-lazy@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
+  integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=
+
 import-local@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.0.2.tgz#a8cfd0431d1de4a2199703d003e3e62364fa6db6"
@@ -3232,7 +4093,19 @@ indent-string@^4.0.0:
   resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
   integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
 
-inferno-create-element@^7.4.3:
+infer-owner@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/infer-owner/-/infer-owner-1.0.4.tgz#c4cefcaa8e51051c2a40ba2ce8a3d27295af9467"
+  integrity sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==
+
+inferno-clone-vnode@^7.4.2:
+  version "7.4.3"
+  resolved "https://registry.yarnpkg.com/inferno-clone-vnode/-/inferno-clone-vnode-7.4.3.tgz#4372ddfb2fea6797945fd27b8590cc59c3b85e53"
+  integrity sha512-vNAw3kDREcEEL0GzTvZAHSwqPxB7o+H9J42czfYTEI6eJmXe78gbR75Q77sSG24K2U0CpYUQmtv3KtFHvIG/Ow==
+  dependencies:
+    inferno "7.4.3"
+
+inferno-create-element@^7.4.2, inferno-create-element@^7.4.3:
   version "7.4.3"
   resolved "https://registry.yarnpkg.com/inferno-create-element/-/inferno-create-element-7.4.3.tgz#48eb82f500d270bff17f0ba406c06813af8da763"
   integrity sha512-eO21kZ/lCHJMNr6+2/RlAJbXxTqGuc+XhQJNFmVAj0SuvK1peXYrzxjgrb2sX7JnscAfaMxRN68QP2U+d8WcYQ==
@@ -3254,6 +4127,15 @@ inferno-extras@7.4.3:
   dependencies:
     inferno "7.4.3"
 
+inferno-helmet@^5.2.1:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/inferno-helmet/-/inferno-helmet-5.2.1.tgz#3717f325760aa14abeae82a78af7213f9055a3dc"
+  integrity sha512-9xzUGENVoz8qk67s0UhHlGNGZKG9Ia0mk5KoCNgkkIcGNhk7mNIINm7jJ5OOigVetz2DwI94jHzouTggb49AJg==
+  dependencies:
+    deep-equal "^1.0.1"
+    inferno-side-effect "^1.1.5"
+    object-assign "^4.1.1"
+
 inferno-hydrate@^7.4.3:
   version "7.4.3"
   resolved "https://registry.yarnpkg.com/inferno-hydrate/-/inferno-hydrate-7.4.3.tgz#17ecf12309fe2f91cc05308ffb7c91594c963540"
@@ -3261,6 +4143,17 @@ inferno-hydrate@^7.4.3:
   dependencies:
     inferno "7.4.3"
 
+"inferno-i18next@github:nimbusec-oss/inferno-i18next#semver:^7.4.2":
+  version "7.4.2"
+  resolved "https://codeload.github.com/nimbusec-oss/inferno-i18next/tar.gz/54b9be591ccd62c53799ad23e35f17144a62f909"
+  dependencies:
+    html-parse-stringify2 "^2.0.1"
+    inferno "^7.4.2"
+    inferno-clone-vnode "^7.4.2"
+    inferno-create-element "^7.4.2"
+    inferno-shared "^7.4.2"
+    inferno-vnode-flags "^7.4.2"
+
 inferno-router@^7.4.3:
   version "7.4.3"
   resolved "https://registry.yarnpkg.com/inferno-router/-/inferno-router-7.4.3.tgz#a69d1b328247c68e6b774e49d6deda0ab4799e89"
@@ -3278,11 +4171,20 @@ inferno-server@^7.4.3:
   dependencies:
     inferno "7.4.3"
 
-inferno-shared@7.4.3:
+inferno-shared@7.4.3, inferno-shared@^7.4.2:
   version "7.4.3"
   resolved "https://registry.yarnpkg.com/inferno-shared/-/inferno-shared-7.4.3.tgz#9fbc8cf66f1d6e3d5ad0d5db9a1d90f0bbe38e9f"
   integrity sha512-g2e50wtzreOZflFAoMLkOF9uCjqHx7582vEA6SV+fxS8Hp8BCXcbD85ovbrRaUNtmlzRVFfvbFN1G/yb+ZINbQ==
 
+inferno-side-effect@^1.1.5:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/inferno-side-effect/-/inferno-side-effect-1.1.5.tgz#a874c80dbc73602aafc1e0f3f3f1ec216a916271"
+  integrity sha512-Q2O2qExGjBTPRfwM1suQPBN5FBHhANccCcEvz/3Dr7VcMFelqxnE0qjnlXVQ248S409nA6VtpiBwT7xBz4WyqA==
+  dependencies:
+    exenv "^1.2.1"
+    npm "^5.8.0"
+    shallowequal "^1.0.1"
+
 inferno-test-utils@^7.4.3:
   version "7.4.3"
   resolved "https://registry.yarnpkg.com/inferno-test-utils/-/inferno-test-utils-7.4.3.tgz#57d92f7a9cc39fa4d47fe5fa598297bd4232695f"
@@ -3290,7 +4192,7 @@ inferno-test-utils@^7.4.3:
   dependencies:
     inferno "7.4.3"
 
-inferno-vnode-flags@7.4.3:
+inferno-vnode-flags@7.4.3, inferno-vnode-flags@^7.4.2:
   version "7.4.3"
   resolved "https://registry.yarnpkg.com/inferno-vnode-flags/-/inferno-vnode-flags-7.4.3.tgz#267879b18ea89579be40a2b8d9a2221c19126ab0"
   integrity sha512-NRaE5O64w2GZltBc2Eh0sof2BKOE19BxCj2xRdE6q9lHlQoirtPhMttYgWVBHDbXEy1BWesLla/IMj3MEca48g==
@@ -3300,7 +4202,7 @@ inferno-vnode-flags@^5.4.2:
   resolved "https://registry.yarnpkg.com/inferno-vnode-flags/-/inferno-vnode-flags-5.6.1.tgz#43974687cc54f22c24ecee116376f6c1bd697499"
   integrity sha512-DDb6R17FX0Fs1SH+7SSVfHEFiYcN2U/DlPHB6qk2HOP149qtYgeRi5h0AAJswJCzBkV/QcBSyRirn0SAzl1NAQ==
 
-inferno@7.4.3, inferno@^7.4.3:
+inferno@7.4.3, inferno@^7.4.2, inferno@^7.4.3:
   version "7.4.3"
   resolved "https://registry.yarnpkg.com/inferno/-/inferno-7.4.3.tgz#91f9f22227963f010ef2b6fa37e17f0c9897e2a6"
   integrity sha512-ZUk7dUKGQRlkU8ssGEHTuRmzJP0X0BYjn+xQg26uTUeFZZarjmQGXLtzJZvQY7r7uvzZjSDMvz0xwg/Ai+T8Rg==
@@ -3309,7 +4211,7 @@ inferno@7.4.3, inferno@^7.4.3:
     inferno-vnode-flags "7.4.3"
     opencollective-postinstall "^2.0.2"
 
-inflight@^1.0.4:
+inflight@^1.0.4, inflight@~1.0.6:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
   integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
@@ -3317,7 +4219,7 @@ inflight@^1.0.4:
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3:
+inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.0, inherits@~2.0.1, inherits@~2.0.3:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -3327,6 +4229,25 @@ inherits@2.0.3:
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
   integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
 
+ini@^1.3.4, ini@^1.3.5, ini@~1.3.0:
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
+  integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
+
+init-package-json@^1.10.3:
+  version "1.10.3"
+  resolved "https://registry.yarnpkg.com/init-package-json/-/init-package-json-1.10.3.tgz#45ffe2f610a8ca134f2bd1db5637b235070f6cbe"
+  integrity sha512-zKSiXKhQveNteyhcj1CoOP8tqp1QuxPIPBl8Bid99DGLFqA1p87M6lNgfjJHSBoWJJlidGOv5rWjyYKEB3g2Jw==
+  dependencies:
+    glob "^7.1.1"
+    npm-package-arg "^4.0.0 || ^5.0.0 || ^6.0.0"
+    promzard "^0.3.0"
+    read "~1.0.1"
+    read-package-json "1 || 2"
+    semver "2.x || 3.x || 4 || 5"
+    validate-npm-package-license "^3.0.1"
+    validate-npm-package-name "^3.0.0"
+
 inquirer@^3.0.6:
   version "3.3.0"
   resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-3.3.0.tgz#9dd2f2ad765dcab1ff0443b491442a20ba227dc9"
@@ -3356,11 +4277,21 @@ internal-slot@^1.0.2:
     has "^1.0.3"
     side-channel "^1.0.2"
 
+invert-kv@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/invert-kv/-/invert-kv-1.0.0.tgz#104a8e4aaca6d3d8cd157a8ef8bfab2d7a3ffdb6"
+  integrity sha1-EEqOSqym09jNFXqO+L+rLXo//bY=
+
 ip-regex@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9"
   integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk=
 
+ip@1.1.5, ip@^1.1.4:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
+  integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=
+
 ipaddr.js@1.9.1:
   version "1.9.1"
   resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@@ -3380,6 +4311,11 @@ is-accessor-descriptor@^1.0.0:
   dependencies:
     kind-of "^6.0.0"
 
+is-arguments@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.0.4.tgz#3faf966c7cba0ff437fb31f6250082fcf0448cf3"
+  integrity sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==
+
 is-arrayish@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
@@ -3402,11 +4338,25 @@ is-buffer@^1.1.5:
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
   integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
 
+is-builtin-module@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-builtin-module/-/is-builtin-module-1.0.0.tgz#540572d34f7ac3119f8f76c30cbc1b1e037affbe"
+  integrity sha1-VAVy0096wxGfj3bDDLwbHgN6/74=
+  dependencies:
+    builtin-modules "^1.0.0"
+
 is-callable@^1.1.4, is-callable@^1.1.5, is-callable@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.0.tgz#83336560b54a38e35e3a2df7afd0454d691468bb"
   integrity sha512-pyVD9AaGLxtg6srb2Ng6ynWJqkHU9bEM087AKck0w8QwDarTfNcpIYoU8x8Hv2Icm8u6kFJM18Dag8lyqGkviw==
 
+is-ci@^1.0.10:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c"
+  integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==
+  dependencies:
+    ci-info "^1.5.0"
+
 is-ci@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c"
@@ -3414,6 +4364,13 @@ is-ci@^2.0.0:
   dependencies:
     ci-info "^2.0.0"
 
+is-cidr@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-cidr/-/is-cidr-1.0.0.tgz#fb5aacf659255310359da32cae03e40c6a1c2afc"
+  integrity sha1-+1qs9lklUxA1naMsrgPkDGocKvw=
+  dependencies:
+    cidr-regex "1.0.6"
+
 is-data-descriptor@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
@@ -3490,6 +4447,13 @@ is-extglob@^2.1.1:
   resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
   integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
 
+is-fullwidth-code-point@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
+  integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
+  dependencies:
+    number-is-nan "^1.0.0"
+
 is-fullwidth-code-point@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
@@ -3519,6 +4483,24 @@ is-glob@^4.0.0, is-glob@^4.0.1:
   dependencies:
     is-extglob "^2.1.1"
 
+is-installed-globally@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80"
+  integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=
+  dependencies:
+    global-dirs "^0.1.0"
+    is-path-inside "^1.0.0"
+
+is-negative-zero@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.0.tgz#9553b121b0fac28869da9ed459e20c7543788461"
+  integrity sha1-lVOxIbD6wohp2p7UWeIMdUN4hGE=
+
+is-npm@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-1.0.0.tgz#f2fb63a65e4905b406c86072765a1a4dc793b9f4"
+  integrity sha1-8vtjpl5JBbQGyGBydloaTceTufQ=
+
 is-number-object@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.4.tgz#36ac95e741cf18b283fc1ddf5e83da798e3ec197"
@@ -3548,11 +4530,18 @@ is-number@^7.0.0:
   resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
   integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
 
-is-obj@^1.0.1:
+is-obj@^1.0.0, is-obj@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f"
   integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=
 
+is-path-inside@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036"
+  integrity sha1-jvW33lBDej/cprToZe96pVy0gDY=
+  dependencies:
+    path-is-inside "^1.0.1"
+
 is-plain-object@^2.0.3, is-plain-object@^2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
@@ -3575,7 +4564,12 @@ is-primitive@^2.0.0:
   resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
   integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU=
 
-is-regex@^1.0.5, is-regex@^1.1.0:
+is-redirect@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
+  integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=
+
+is-regex@^1.0.4, is-regex@^1.0.5, is-regex@^1.1.0, is-regex@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.1.tgz#c6f98aacc546f6cec5468a07b7b153ab564a57b9"
   integrity sha512-1+QkEcxiLlB7VEyFtyBg94e08OAsvq7FUBgApTq/w2ymCLyKJgDPsybBENVtA7XCQEgEXxKPonG+mvYRxh/LIg==
@@ -3587,7 +4581,12 @@ is-regexp@^1.0.0:
   resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069"
   integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk=
 
-is-stream@^1.1.0:
+is-retry-allowed@^1.0.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4"
+  integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==
+
+is-stream@^1.0.0, is-stream@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
   integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
@@ -3658,6 +4657,19 @@ isobject@^3.0.0, isobject@^3.0.1:
   resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
   integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
 
+isomorphic-cookie@^1.2.4:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/isomorphic-cookie/-/isomorphic-cookie-1.2.4.tgz#b23cc170b4430be1af7cef659bcb77776c8cc351"
+  integrity sha512-Ua/H7NL/NqJyhM14gOisDwPQt5HCNohl23/i8g68EoItOoPQEydG+ZJ0A0i815FSiUQ+0ImtSLdo6d1psus/IQ==
+  dependencies:
+    cookie "^0.1.2"
+    lodash.pick "^4.4.0"
+
+isomorphic-ws@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc"
+  integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==
+
 isstream@~0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
@@ -4089,6 +5101,11 @@ jest@^26.4.2:
     import-local "^3.0.2"
     jest-cli "^26.4.2"
 
+js-cookie@^2.2.0:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8"
+  integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==
+
 "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
@@ -4154,6 +5171,11 @@ jsesc@~0.5.0:
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
   integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
 
+json-parse-better-errors@^1.0.0, json-parse-better-errors@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
+  integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==
+
 json-parse-even-better-errors@^2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.0.tgz#371873c5ffa44304a6ba12419bcfa95f404ae081"
@@ -4200,6 +5222,11 @@ jsonfile@^4.0.0:
   optionalDependencies:
     graceful-fs "^4.1.6"
 
+jsonparse@^1.2.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
+  integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
+
 jsprim@^1.2.2:
   version "1.4.1"
   resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
@@ -4218,6 +5245,11 @@ jsx-ast-utils@^2.4.1:
     array-includes "^3.1.1"
     object.assign "^4.1.0"
 
+jwt-decode@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79"
+  integrity sha1-fYa9VmefWM5qhHBKZX3TkruoGnk=
+
 kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
   version "3.2.2"
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
@@ -4259,6 +5291,25 @@ language-tags@^1.0.5:
   dependencies:
     language-subtag-registry "~0.3.2"
 
+latest-version@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"
+  integrity sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=
+  dependencies:
+    package-json "^4.0.0"
+
+lazy-property@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/lazy-property/-/lazy-property-1.0.0.tgz#84ddc4b370679ba8bd4cdcfa4c06b43d57111147"
+  integrity sha1-hN3Es3Bnm6i9TNz6TAa0PVcREUc=
+
+lcid@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/lcid/-/lcid-1.0.0.tgz#308accafa0bc483a3867b4b6f2b9506251d1b835"
+  integrity sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=
+  dependencies:
+    invert-kv "^1.0.0"
+
 lego-api@^1.0.7:
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/lego-api/-/lego-api-1.0.8.tgz#5e26be726c5e11d540f89e7c6b1abf8c5834bd01"
@@ -4266,6 +5317,11 @@ lego-api@^1.0.7:
   dependencies:
     chain-able "^3.0.0"
 
+lemmy-js-client@^1.0.8:
+  version "1.0.8"
+  resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-1.0.8.tgz#98e34c8e3cd07427f883f60fad376dc4d6f46e7f"
+  integrity sha512-YZxD3+8RGz7cRKdI8EIe5iQqQIMm5WzdNz6zZ7/CdkMtXUv6YuMOEv8HLTvBoGuaWIJwlMJ+23NIarxlT26IEw==
+
 leven@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2"
@@ -4287,25 +5343,65 @@ levn@~0.3.0:
     prelude-ls "~1.1.2"
     type-check "~0.3.2"
 
+libcipm@^1.6.2:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/libcipm/-/libcipm-1.6.3.tgz#dc4052d710941547782d85bbdb3c77eedec733ff"
+  integrity sha512-WUEjQk1aZDECb2MFnAbx6o7sJbBJWrWwt9rbinOmpc0cLKWgYJOvKNqCUN3sl2P9LFqPsnVT4Aj5SPw4/xKI5A==
+  dependencies:
+    bin-links "^1.1.2"
+    bluebird "^3.5.1"
+    find-npm-prefix "^1.0.2"
+    graceful-fs "^4.1.11"
+    lock-verify "^2.0.2"
+    npm-lifecycle "^2.0.3"
+    npm-logical-tree "^1.2.1"
+    npm-package-arg "^6.1.0"
+    pacote "^8.1.6"
+    protoduck "^5.0.0"
+    read-package-json "^2.0.13"
+    rimraf "^2.6.2"
+    worker-farm "^1.6.0"
+
+libnpx@^10.2.0:
+  version "10.2.4"
+  resolved "https://registry.yarnpkg.com/libnpx/-/libnpx-10.2.4.tgz#ef0e3258e29aef2ec7ee3276115e20e67f67d4ee"
+  integrity sha512-BPc0D1cOjBeS8VIBKUu5F80s6njm0wbVt7CsGMrIcJ+SI7pi7V0uVPGpEMH9H5L8csOcclTxAXFE2VAsJXUhfA==
+  dependencies:
+    dotenv "^5.0.1"
+    npm-package-arg "^6.0.0"
+    rimraf "^2.6.2"
+    safe-buffer "^5.1.0"
+    update-notifier "^2.3.0"
+    which "^1.3.0"
+    y18n "^4.0.0"
+    yargs "^14.2.3"
+
 lines-and-columns@^1.1.6:
   version "1.1.6"
   resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
   integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
 
+linkify-it@^3.0.1:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-3.0.2.tgz#f55eeb8bc1d3ae754049e124ab3bb56d97797fb8"
+  integrity sha512-gDBO4aHNZS6coiZCKVhSNh43F9ioIL4JwRjLZPkoLIY4yZFwg264Y5lu2x6rb1Js42Gh6Yqm2f6L2AJcnkzinQ==
+  dependencies:
+    uc.micro "^1.0.1"
+
 lint-staged@^10.1.3:
-  version "10.2.11"
-  resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-10.2.11.tgz#713c80877f2dc8b609b05bc59020234e766c9720"
-  integrity sha512-LRRrSogzbixYaZItE2APaS4l2eJMjjf5MbclRZpLJtcQJShcvUzKXsNeZgsLIZ0H0+fg2tL4B59fU9wHIHtFIA==
+  version "10.2.13"
+  resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-10.2.13.tgz#b9c504683470edfc464b7d3fe3845a5a1efcd814"
+  integrity sha512-conwlukNV6aL9SiMWjFtDp5exeDnTMekdNPDZsKGnpfQuHcO0E3L3Bbf58lcR+M7vk6LpCilxDAVks/DDVBYlA==
   dependencies:
-    chalk "^4.0.0"
-    cli-truncate "2.1.0"
-    commander "^5.1.0"
-    cosmiconfig "^6.0.0"
+    chalk "^4.1.0"
+    cli-truncate "^2.1.0"
+    commander "^6.0.0"
+    cosmiconfig "^7.0.0"
     debug "^4.1.1"
     dedent "^0.7.0"
-    enquirer "^2.3.5"
-    execa "^4.0.1"
-    listr2 "^2.1.0"
+    enquirer "^2.3.6"
+    execa "^4.0.3"
+    listr2 "^2.6.0"
     log-symbols "^4.0.0"
     micromatch "^4.0.2"
     normalize-path "^3.0.0"
@@ -4313,10 +5409,10 @@ lint-staged@^10.1.3:
     string-argv "0.3.1"
     stringify-object "^3.3.0"
 
-listr2@^2.1.0:
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/listr2/-/listr2-2.6.0.tgz#788a3d202978a1b8582062952cbc49272c8e206a"
-  integrity sha512-nwmqTJYQQ+AsKb4fCXH/6/UmLCEDL1jkRAdSn9M6cEUzoRGrs33YD/3N86gAZQnGZ6hxV18XSdlBcJ1GTmetJA==
+listr2@^2.6.0:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/listr2/-/listr2-2.6.2.tgz#4912eb01e1e2dd72ec37f3895a56bf2622d6f36a"
+  integrity sha512-6x6pKEMs8DSIpA/tixiYY2m/GcbgMplMVmhQAaLFxEtNSKLeWTGjtmU57xvv6QCm2XcqzyNXL/cTSVf4IChCRA==
   dependencies:
     chalk "^4.1.0"
     cli-truncate "^2.1.0"
@@ -4345,6 +5441,14 @@ locate-path@^2.0.0:
     p-locate "^2.0.0"
     path-exists "^3.0.0"
 
+locate-path@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
+  integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==
+  dependencies:
+    p-locate "^3.0.0"
+    path-exists "^3.0.0"
+
 locate-path@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
@@ -4352,6 +5456,45 @@ locate-path@^5.0.0:
   dependencies:
     p-locate "^4.1.0"
 
+lock-verify@^2.0.2:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/lock-verify/-/lock-verify-2.2.1.tgz#81107948c51ed16f97b96ff8b60675affb243fc1"
+  integrity sha512-n0Zw2DVupKfZMazy/HIFVNohJ1z8fIoZ77WBnyyBGG6ixw83uJNyrbiJvvHWe1QKkGiBCjj8RCPlymltliqEww==
+  dependencies:
+    "@iarna/cli" "^1.2.0"
+    npm-package-arg "^6.1.0"
+    semver "^5.4.1"
+
+lockfile@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/lockfile/-/lockfile-1.0.4.tgz#07f819d25ae48f87e538e6578b6964a4981a5609"
+  integrity sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==
+  dependencies:
+    signal-exit "^3.0.2"
+
+lodash._baseuniq@~4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8"
+  integrity sha1-DrtE5FaBSveQXGIS+iybLVG4Qeg=
+  dependencies:
+    lodash._createset "~4.0.0"
+    lodash._root "~3.0.0"
+
+lodash._createset@~4.0.0:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/lodash._createset/-/lodash._createset-4.0.3.tgz#0f4659fbb09d75194fa9e2b88a6644d363c9fe26"
+  integrity sha1-D0ZZ+7CddRlPqeK4imZE02PJ/iY=
+
+lodash._root@~3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/lodash._root/-/lodash._root-3.0.1.tgz#fba1c4524c19ee9a5f8136b4609f017cf4ded692"
+  integrity sha1-+6HEUkwZ7ppfgTa0YJ8BfPTe1pI=
+
+lodash.clonedeep@~4.5.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
+  integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
+
 lodash.escape@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/lodash.escape/-/lodash.escape-4.0.1.tgz#c9044690c21e04294beaa517712fded1fa88de98"
@@ -4372,16 +5515,41 @@ lodash.isequal@^4.5.0:
   resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
   integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
 
+lodash.pick@^4.4.0:
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3"
+  integrity sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM=
+
 lodash.sortby@^4.7.0:
   version "4.7.0"
   resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
   integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
 
+lodash.union@~4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
+  integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=
+
+lodash.uniq@~4.5.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
+  integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
+
+lodash.without@~4.4.0:
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/lodash.without/-/lodash.without-4.4.0.tgz#3cd4574a00b67bae373a94b748772640507b7aac"
+  integrity sha1-PNRXSgC2e643OpS3SHcmQFB7eqw=
+
 lodash.zip@^4.2.0:
   version "4.2.0"
   resolved "https://registry.yarnpkg.com/lodash.zip/-/lodash.zip-4.2.0.tgz#ec6662e4896408ed4ab6c542a3990b72cc080020"
   integrity sha1-7GZi5IlkCO1KtsVCo5kLcswIACA=
 
+lodash@^3.10.1:
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
+  integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=
+
 lodash@^4.15.0, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.3.0:
   version "4.17.20"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
@@ -4411,6 +5579,33 @@ loose-envify@^1.2.0, loose-envify@^1.4.0:
   dependencies:
     js-tokens "^3.0.0 || ^4.0.0"
 
+lowercase-keys@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
+  integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==
+
+lru-cache@^4.0.1, lru-cache@^4.1.1, lru-cache@^4.1.2, lru-cache@^4.1.3:
+  version "4.1.5"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
+  integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
+  dependencies:
+    pseudomap "^1.0.2"
+    yallist "^2.1.2"
+
+lru-cache@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
+  integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==
+  dependencies:
+    yallist "^3.0.2"
+
+make-dir@^1.0.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c"
+  integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==
+  dependencies:
+    pify "^3.0.0"
+
 make-dir@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
@@ -4423,6 +5618,57 @@ make-error@^1.1.1:
   resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
   integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
 
+"make-fetch-happen@^2.5.0 || 3 || 4", make-fetch-happen@^4.0.1:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-4.0.2.tgz#2d156b11696fb32bffbafe1ac1bc085dd6c78a79"
+  integrity sha512-YMJrAjHSb/BordlsDEcVcPyTbiJKkzqMf48N8dAJZT9Zjctrkb6Yg4TY9Sq2AwSIQJFn5qBBKVTYt3vP5FMIHA==
+  dependencies:
+    agentkeepalive "^3.4.1"
+    cacache "^11.3.3"
+    http-cache-semantics "^3.8.1"
+    http-proxy-agent "^2.1.0"
+    https-proxy-agent "^2.2.1"
+    lru-cache "^5.1.1"
+    mississippi "^3.0.0"
+    node-fetch-npm "^2.0.2"
+    promise-retry "^1.1.1"
+    socks-proxy-agent "^4.0.0"
+    ssri "^6.0.0"
+
+make-fetch-happen@^2.6.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-2.6.0.tgz#8474aa52198f6b1ae4f3094c04e8370d35ea8a38"
+  integrity sha512-FFq0lNI0ax+n9IWzWpH8A4JdgYiAp2DDYIZ3rsaav8JDe8I+72CzK6PQW/oom15YDZpV5bYW/9INd6nIJ2ZfZw==
+  dependencies:
+    agentkeepalive "^3.3.0"
+    cacache "^10.0.0"
+    http-cache-semantics "^3.8.0"
+    http-proxy-agent "^2.0.0"
+    https-proxy-agent "^2.1.0"
+    lru-cache "^4.1.1"
+    mississippi "^1.2.0"
+    node-fetch-npm "^2.0.2"
+    promise-retry "^1.1.1"
+    socks-proxy-agent "^3.0.1"
+    ssri "^5.0.0"
+
+make-fetch-happen@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-3.0.0.tgz#7b661d2372fc4710ab5cc8e1fa3c290eea69a961"
+  integrity sha512-FmWY7gC0mL6Z4N86vE14+m719JKE4H0A+pyiOH18B025gF/C113pyfb4gHDDYP5cqnRMHOz06JGdmffC/SES+w==
+  dependencies:
+    agentkeepalive "^3.4.1"
+    cacache "^10.0.4"
+    http-cache-semantics "^3.8.1"
+    http-proxy-agent "^2.1.0"
+    https-proxy-agent "^2.2.0"
+    lru-cache "^4.1.2"
+    mississippi "^3.0.0"
+    node-fetch-npm "^2.0.2"
+    promise-retry "^1.1.1"
+    socks-proxy-agent "^3.0.1"
+    ssri "^5.2.4"
+
 makeerror@1.0.x:
   version "1.0.11"
   resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.11.tgz#e01a5c9109f2af79660e4e8b9587790184f5a96c"
@@ -4442,16 +5688,64 @@ map-visit@^1.0.0:
   dependencies:
     object-visit "^1.0.0"
 
+markdown-it-container@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-container/-/markdown-it-container-3.0.0.tgz#1d19b06040a020f9a827577bb7dbf67aa5de9a5b"
+  integrity sha512-y6oKTq4BB9OQuY/KLfk/O3ysFhB3IMYoIWhGJEidXt1NQFocFK2sA2t0NYZAMyMShAGL6x5OPIbrmXPIqaN9rw==
+
+markdown-it-emoji@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-emoji/-/markdown-it-emoji-1.4.0.tgz#9bee0e9a990a963ba96df6980c4fddb05dfb4dcc"
+  integrity sha1-m+4OmpkKljupbfaYDE/dsF37Tcw=
+
+markdown-it-sub@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-sub/-/markdown-it-sub-1.0.0.tgz#375fd6026eae7ddcb012497f6411195ea1e3afe8"
+  integrity sha1-N1/WAm6ufdywEkl/ZBEZXqHjr+g=
+
+markdown-it-sup@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-sup/-/markdown-it-sup-1.0.0.tgz#cb9c9ff91a5255ac08f3fd3d63286e15df0a1fc3"
+  integrity sha1-y5yf+RpSVawI8/09YyhuFd8KH8M=
+
+markdown-it@^11.0.0:
+  version "11.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-11.0.0.tgz#dbfc30363e43d756ebc52c38586b91b90046b876"
+  integrity sha512-+CvOnmbSubmQFSA9dKz1BRiaSMV7rhexl3sngKqFyXSagoA3fBdJQ8oZWtRy2knXdpDXaBw44euz37DeJQ9asg==
+  dependencies:
+    argparse "^1.0.7"
+    entities "~2.0.0"
+    linkify-it "^3.0.1"
+    mdurl "^1.0.1"
+    uc.micro "^1.0.5"
+
 math-random@^1.0.1:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c"
   integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==
 
+mdurl@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
+  integrity sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=
+
+meant@~1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/meant/-/meant-1.0.2.tgz#5d0c78310a3d8ae1408a16be0fe0bd42a969f560"
+  integrity sha512-KN+1uowN/NK+sT/Lzx7WSGIj2u+3xe5n2LbwObfjOhPZiA+cCfCm6idVl0RkEfjThkw5XJ96CyRcanq6GmKtUg==
+
 media-typer@0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
   integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
 
+mem@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/mem/-/mem-1.1.0.tgz#5edd52b485ca1d900fe64895505399a0dfa45f76"
+  integrity sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y=
+  dependencies:
+    mimic-fn "^1.0.0"
+
 merge-descriptors@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
@@ -4557,6 +5851,69 @@ minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5:
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
   integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
+minipass@^2.3.3, minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0:
+  version "2.9.0"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6"
+  integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==
+  dependencies:
+    safe-buffer "^5.1.2"
+    yallist "^3.0.0"
+
+minizlib@^1.2.1:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d"
+  integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==
+  dependencies:
+    minipass "^2.9.0"
+
+mississippi@^1.2.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-1.3.1.tgz#2a8bb465e86550ac8b36a7b6f45599171d78671e"
+  integrity sha512-/6rB8YXFbAtsUVRphIRQqB0+9c7VaPHCjVtvto+JqwVxgz8Zz+I+f68/JgQ+Pb4VlZb2svA9OtdXnHHsZz7ltg==
+  dependencies:
+    concat-stream "^1.5.0"
+    duplexify "^3.4.2"
+    end-of-stream "^1.1.0"
+    flush-write-stream "^1.0.0"
+    from2 "^2.1.0"
+    parallel-transform "^1.1.0"
+    pump "^1.0.0"
+    pumpify "^1.3.3"
+    stream-each "^1.1.0"
+    through2 "^2.0.0"
+
+mississippi@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-2.0.0.tgz#3442a508fafc28500486feea99409676e4ee5a6f"
+  integrity sha512-zHo8v+otD1J10j/tC+VNoGK9keCuByhKovAvdn74dmxJl9+mWHnx6EMsDN4lgRoMI/eYo2nchAxniIbUPb5onw==
+  dependencies:
+    concat-stream "^1.5.0"
+    duplexify "^3.4.2"
+    end-of-stream "^1.1.0"
+    flush-write-stream "^1.0.0"
+    from2 "^2.1.0"
+    parallel-transform "^1.1.0"
+    pump "^2.0.1"
+    pumpify "^1.3.3"
+    stream-each "^1.1.0"
+    through2 "^2.0.0"
+
+mississippi@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022"
+  integrity sha512-x471SsVjUtBRtcvd4BzKE9kFC+/2TeWgKCgw0bZcw1b9l2X3QX5vCWgF+KaZaYm87Ss//rHnWryupDrgLvmSkA==
+  dependencies:
+    concat-stream "^1.5.0"
+    duplexify "^3.4.2"
+    end-of-stream "^1.1.0"
+    flush-write-stream "^1.0.0"
+    from2 "^2.1.0"
+    parallel-transform "^1.1.0"
+    pump "^3.0.0"
+    pumpify "^1.3.3"
+    stream-each "^1.1.0"
+    through2 "^2.0.0"
+
 mixin-deep@^1.2.0:
   version "1.3.2"
   resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
@@ -4565,21 +5922,38 @@ mixin-deep@^1.2.0:
     for-in "^1.0.2"
     is-extendable "^1.0.1"
 
-mkdirp@^0.5.1:
+"mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.0, mkdirp@~0.5.1:
   version "0.5.5"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
   integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
   dependencies:
     minimist "^1.2.5"
 
+moment@^2.24.0:
+  version "2.27.0"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d"
+  integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ==
+
 moo@^0.5.0:
   version "0.5.1"
   resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4"
   integrity sha512-I1mnb5xn4fO80BH9BLcF0yLypy2UKl+Cb01Fu0hJRkJjlCRtxZMWkTdAtDd5ZqCOxtCkhmRwyI57vWT+1iZ67w==
 
-ms@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+move-concurrently@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
+  integrity sha1-viwAX9oy4LKa8fBdfEszIUxwH5I=
+  dependencies:
+    aproba "^1.1.1"
+    copy-concurrently "^1.0.0"
+    fs-write-stream-atomic "^1.0.8"
+    mkdirp "^0.5.1"
+    rimraf "^2.5.4"
+    run-queue "^1.0.3"
+
+ms@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
   integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
 
 ms@2.1.1:
@@ -4587,7 +5961,7 @@ ms@2.1.1:
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
   integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
 
-ms@^2.1.1:
+ms@^2.0.0, ms@^2.1.1:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
   integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
@@ -4607,6 +5981,11 @@ mute-stream@0.0.7:
   resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
   integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=
 
+mute-stream@~0.0.4:
+  version "0.0.8"
+  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
+  integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
+
 nan@^2.12.1:
   version "2.14.1"
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
@@ -4660,6 +6039,55 @@ nice-try@^1.0.4:
   resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
   integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
 
+node-fetch-npm@^2.0.2:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/node-fetch-npm/-/node-fetch-npm-2.0.4.tgz#6507d0e17a9ec0be3bec516958a497cec54bf5a4"
+  integrity sha512-iOuIQDWDyjhv9qSDrj9aq/klt6F9z1p2otB3AV7v3zBDcL/x+OfGsvGQZZCcMZbUf4Ujw1xGNQkjvGnVT22cKg==
+  dependencies:
+    encoding "^0.1.11"
+    json-parse-better-errors "^1.0.0"
+    safe-buffer "^5.1.1"
+
+node-fetch@^2.6.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
+  integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
+
+node-gyp@^3.6.2:
+  version "3.8.0"
+  resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c"
+  integrity sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA==
+  dependencies:
+    fstream "^1.0.0"
+    glob "^7.0.3"
+    graceful-fs "^4.1.2"
+    mkdirp "^0.5.0"
+    nopt "2 || 3"
+    npmlog "0 || 1 || 2 || 3 || 4"
+    osenv "0"
+    request "^2.87.0"
+    rimraf "2"
+    semver "~5.3.0"
+    tar "^2.0.0"
+    which "1"
+
+node-gyp@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-4.0.0.tgz#972654af4e5dd0cd2a19081b4b46fe0442ba6f45"
+  integrity sha512-2XiryJ8sICNo6ej8d0idXDEMKfVfFK7kekGCtJAuelGsYHQxhj13KTf95swTCN2dZ/4lTfZ84Fu31jqJEEgjWA==
+  dependencies:
+    glob "^7.0.3"
+    graceful-fs "^4.1.2"
+    mkdirp "^0.5.0"
+    nopt "2 || 3"
+    npmlog "0 || 1 || 2 || 3 || 4"
+    osenv "0"
+    request "^2.87.0"
+    rimraf "2"
+    semver "~5.3.0"
+    tar "^4.4.8"
+    which "1"
+
 node-int64@^0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b"
@@ -4682,7 +6110,22 @@ node-notifier@^8.0.0:
     uuid "^8.3.0"
     which "^2.0.2"
 
-normalize-package-data@^2.3.2, normalize-package-data@^2.5.0:
+"nopt@2 || 3":
+  version "3.0.6"
+  resolved "https://registry.yarnpkg.com/nopt/-/nopt-3.0.6.tgz#c6465dbf08abcd4db359317f79ac68a646b28ff9"
+  integrity sha1-xkZdvwirzU2zWTF/eaxopkayj/k=
+  dependencies:
+    abbrev "1"
+
+nopt@~4.0.1:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.3.tgz#a375cad9d02fd921278d954c2254d5aa57e15e48"
+  integrity sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==
+  dependencies:
+    abbrev "1"
+    osenv "^0.1.4"
+
+normalize-package-data@^2.0.0, normalize-package-data@^2.3.2, normalize-package-data@^2.4.0, normalize-package-data@^2.5.0, "normalize-package-data@~1.0.1 || ^2.0.0":
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
   integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
@@ -4692,6 +6135,16 @@ normalize-package-data@^2.3.2, normalize-package-data@^2.5.0:
     semver "2 || 3 || 4 || 5"
     validate-npm-package-license "^3.0.1"
 
+normalize-package-data@~2.4.0:
+  version "2.4.2"
+  resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.2.tgz#6b2abd85774e51f7936f1395e45acb905dc849b2"
+  integrity sha512-YcMnjqeoUckXTPKZSAsPjUPLxH85XotbpqK3w4RyCwdFQSU5FxxBys8buehkSfg0j9fKvV1hn7O0+8reEgkAiw==
+  dependencies:
+    hosted-git-info "^2.1.4"
+    is-builtin-module "^1.0.0"
+    semver "2 || 3 || 4 || 5"
+    validate-npm-package-license "^3.0.1"
+
 normalize-path@^2.0.0, normalize-path@^2.0.1, normalize-path@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-2.1.1.tgz#1ab28b556e198363a8c1a6f7e6fa20137fe6aed9"
@@ -4704,6 +6157,132 @@ normalize-path@^3.0.0:
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
   integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
 
+npm-audit-report@^1.0.9:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/npm-audit-report/-/npm-audit-report-1.3.3.tgz#8226deeb253b55176ed147592a3995442f2179ed"
+  integrity sha512-8nH/JjsFfAWMvn474HB9mpmMjrnKb1Hx/oTAdjv4PT9iZBvBxiZ+wtDUapHCJwLqYGQVPaAfs+vL5+5k9QndXw==
+  dependencies:
+    cli-table3 "^0.5.0"
+    console-control-strings "^1.1.0"
+
+npm-bundled@^1.0.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b"
+  integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA==
+  dependencies:
+    npm-normalize-package-bin "^1.0.1"
+
+npm-cache-filename@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/npm-cache-filename/-/npm-cache-filename-1.0.2.tgz#ded306c5b0bfc870a9e9faf823bc5f283e05ae11"
+  integrity sha1-3tMGxbC/yHCp6fr4I7xfKD4FrhE=
+
+npm-install-checks@~3.0.0:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/npm-install-checks/-/npm-install-checks-3.0.2.tgz#ab2e32ad27baa46720706908e5b14c1852de44d9"
+  integrity sha512-E4kzkyZDIWoin6uT5howP8VDvkM+E8IQDcHAycaAxMbwkqhIg5eEYALnXOl3Hq9MrkdQB/2/g1xwBINXdKSRkg==
+  dependencies:
+    semver "^2.3.0 || 3.x || 4 || 5"
+
+npm-lifecycle@^2.0.1, npm-lifecycle@^2.0.3:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/npm-lifecycle/-/npm-lifecycle-2.1.1.tgz#0027c09646f0fd346c5c93377bdaba59c6748fdf"
+  integrity sha512-+Vg6I60Z75V/09pdcH5iUo/99Q/vop35PaI99elvxk56azSVVsdsSsS/sXqKDNwbRRNN1qSxkcO45ZOu0yOWew==
+  dependencies:
+    byline "^5.0.0"
+    graceful-fs "^4.1.15"
+    node-gyp "^4.0.0"
+    resolve-from "^4.0.0"
+    slide "^1.1.6"
+    uid-number "0.0.6"
+    umask "^1.1.0"
+    which "^1.3.1"
+
+npm-logical-tree@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/npm-logical-tree/-/npm-logical-tree-1.2.1.tgz#44610141ca24664cad35d1e607176193fd8f5b88"
+  integrity sha512-AJI/qxDB2PWI4LG1CYN579AY1vCiNyWfkiquCsJWqntRu/WwimVrC8yXeILBFHDwxfOejxewlmnvW9XXjMlYIg==
+
+npm-normalize-package-bin@^1.0.0, npm-normalize-package-bin@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2"
+  integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==
+
+"npm-package-arg@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", "npm-package-arg@^4.0.0 || ^5.0.0 || ^6.0.0", npm-package-arg@^6.0.0, npm-package-arg@^6.1.0:
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-6.1.1.tgz#02168cb0a49a2b75bf988a28698de7b529df5cb7"
+  integrity sha512-qBpssaL3IOZWi5vEKUKW0cO7kzLeT+EQO9W8RsLOZf76KF9E/K9+wH0C7t06HXPpaH8WH5xF1MExLuCwbTqRUg==
+  dependencies:
+    hosted-git-info "^2.7.1"
+    osenv "^0.1.5"
+    semver "^5.6.0"
+    validate-npm-package-name "^3.0.0"
+
+npm-packlist@^1.1.10:
+  version "1.4.8"
+  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e"
+  integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==
+  dependencies:
+    ignore-walk "^3.0.1"
+    npm-bundled "^1.0.1"
+    npm-normalize-package-bin "^1.0.1"
+
+npm-packlist@~1.1.10:
+  version "1.1.12"
+  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.12.tgz#22bde2ebc12e72ca482abd67afc51eb49377243a"
+  integrity sha512-WJKFOVMeAlsU/pjXuqVdzU0WfgtIBCupkEVwn+1Y0ERAbUfWw8R4GjgVbaKnUjRoD2FoQbHOCbOyT5Mbs9Lw4g==
+  dependencies:
+    ignore-walk "^3.0.1"
+    npm-bundled "^1.0.1"
+
+npm-pick-manifest@^2.1.0:
+  version "2.2.3"
+  resolved "https://registry.yarnpkg.com/npm-pick-manifest/-/npm-pick-manifest-2.2.3.tgz#32111d2a9562638bb2c8f2bf27f7f3092c8fae40"
+  integrity sha512-+IluBC5K201+gRU85vFlUwX3PFShZAbAgDNp2ewJdWMVSppdo/Zih0ul2Ecky/X7b51J7LrrUAP+XOmOCvYZqA==
+  dependencies:
+    figgy-pudding "^3.5.1"
+    npm-package-arg "^6.0.0"
+    semver "^5.4.1"
+
+npm-profile@^3.0.1:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/npm-profile/-/npm-profile-3.0.2.tgz#58d568f1b56ef769602fd0aed8c43fa0e0de0f57"
+  integrity sha512-rEJOFR6PbwOvvhGa2YTNOJQKNuc6RovJ6T50xPU7pS9h/zKPNCJ+VHZY2OFXyZvEi+UQYtHRTp8O/YM3tUD20A==
+  dependencies:
+    aproba "^1.1.2 || 2"
+    make-fetch-happen "^2.5.0 || 3 || 4"
+
+npm-registry-client@^8.5.1:
+  version "8.6.0"
+  resolved "https://registry.yarnpkg.com/npm-registry-client/-/npm-registry-client-8.6.0.tgz#7f1529f91450732e89f8518e0f21459deea3e4c4"
+  integrity sha512-Qs6P6nnopig+Y8gbzpeN/dkt+n7IyVd8f45NTMotGk6Qo7GfBmzwYx6jRLoOOgKiMnaQfYxsuyQlD8Mc3guBhg==
+  dependencies:
+    concat-stream "^1.5.2"
+    graceful-fs "^4.1.6"
+    normalize-package-data "~1.0.1 || ^2.0.0"
+    npm-package-arg "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0"
+    once "^1.3.3"
+    request "^2.74.0"
+    retry "^0.10.0"
+    safe-buffer "^5.1.1"
+    semver "2 >=2.2.1 || 3.x || 4 || 5"
+    slide "^1.1.3"
+    ssri "^5.2.4"
+  optionalDependencies:
+    npmlog "2 || ^3.1.0 || ^4.0.0"
+
+npm-registry-fetch@^1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-1.1.1.tgz#710bc5947d9ee2c549375072dab6d5d17baf2eb2"
+  integrity sha512-ev+zxOXsgAqRsR8Rk+ErjgWOlbrXcqGdme94/VNdjDo1q8TSy10Pp8xgDv/ZmMk2jG/KvGtXUNG4GS3+l6xbDw==
+  dependencies:
+    bluebird "^3.5.1"
+    figgy-pudding "^3.0.0"
+    lru-cache "^4.1.2"
+    make-fetch-happen "^3.0.0"
+    npm-package-arg "^6.0.0"
+    safe-buffer "^5.1.1"
+
 npm-run-path@^2.0.0:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
@@ -4718,6 +6297,134 @@ npm-run-path@^4.0.0:
   dependencies:
     path-key "^3.0.0"
 
+npm-user-validate@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/npm-user-validate/-/npm-user-validate-1.0.0.tgz#8ceca0f5cea04d4e93519ef72d0557a75122e951"
+  integrity sha1-jOyg9c6gTU6TUZ73LQVXp1Ei6VE=
+
+npm@^5.8.0:
+  version "5.10.0"
+  resolved "https://registry.yarnpkg.com/npm/-/npm-5.10.0.tgz#3bec62312c94a9b0f48f208e00b98bf0304b40db"
+  integrity sha512-lvjvjgR5wG2RJ2uqak1xtZcVAWMwVOzN5HkUlUj/n8rU1f3A0fNn+7HwOzH9Lyf0Ppyu9ApgsEpHczOSnx1cwA==
+  dependencies:
+    JSONStream "^1.3.2"
+    abbrev "~1.1.1"
+    ansi-regex "~3.0.0"
+    ansicolors "~0.3.2"
+    ansistyles "~0.1.3"
+    aproba "~1.2.0"
+    archy "~1.0.0"
+    bin-links "^1.1.0"
+    bluebird "~3.5.1"
+    byte-size "^4.0.2"
+    cacache "^10.0.4"
+    call-limit "~1.1.0"
+    chownr "~1.0.1"
+    cli-columns "^3.1.2"
+    cli-table2 "~0.2.0"
+    cmd-shim "~2.0.2"
+    columnify "~1.5.4"
+    config-chain "~1.1.11"
+    detect-indent "~5.0.0"
+    detect-newline "^2.1.0"
+    dezalgo "~1.0.3"
+    editor "~1.0.0"
+    find-npm-prefix "^1.0.2"
+    fs-vacuum "~1.2.10"
+    fs-write-stream-atomic "~1.0.10"
+    gentle-fs "^2.0.1"
+    glob "~7.1.2"
+    graceful-fs "~4.1.11"
+    has-unicode "~2.0.1"
+    hosted-git-info "^2.6.0"
+    iferr "~0.1.5"
+    inflight "~1.0.6"
+    inherits "~2.0.3"
+    ini "^1.3.5"
+    init-package-json "^1.10.3"
+    is-cidr "~1.0.0"
+    json-parse-better-errors "^1.0.2"
+    lazy-property "~1.0.0"
+    libcipm "^1.6.2"
+    libnpx "^10.2.0"
+    lock-verify "^2.0.2"
+    lockfile "^1.0.4"
+    lodash._baseuniq "~4.6.0"
+    lodash.clonedeep "~4.5.0"
+    lodash.union "~4.6.0"
+    lodash.uniq "~4.5.0"
+    lodash.without "~4.4.0"
+    lru-cache "^4.1.2"
+    meant "~1.0.1"
+    mississippi "^3.0.0"
+    mkdirp "~0.5.1"
+    move-concurrently "^1.0.1"
+    node-gyp "^3.6.2"
+    nopt "~4.0.1"
+    normalize-package-data "~2.4.0"
+    npm-audit-report "^1.0.9"
+    npm-cache-filename "~1.0.2"
+    npm-install-checks "~3.0.0"
+    npm-lifecycle "^2.0.1"
+    npm-package-arg "^6.1.0"
+    npm-packlist "~1.1.10"
+    npm-profile "^3.0.1"
+    npm-registry-client "^8.5.1"
+    npm-registry-fetch "^1.1.0"
+    npm-user-validate "~1.0.0"
+    npmlog "~4.1.2"
+    once "~1.4.0"
+    opener "~1.4.3"
+    osenv "^0.1.5"
+    pacote "^7.6.1"
+    path-is-inside "~1.0.2"
+    promise-inflight "~1.0.1"
+    qrcode-terminal "^0.12.0"
+    query-string "^6.1.0"
+    qw "~1.0.1"
+    read "~1.0.7"
+    read-cmd-shim "~1.0.1"
+    read-installed "~4.0.3"
+    read-package-json "^2.0.13"
+    read-package-tree "^5.2.1"
+    readable-stream "^2.3.6"
+    request "^2.85.0"
+    retry "^0.12.0"
+    rimraf "~2.6.2"
+    safe-buffer "^5.1.2"
+    semver "^5.5.0"
+    sha "~2.0.1"
+    slide "~1.1.6"
+    sorted-object "~2.0.1"
+    sorted-union-stream "~2.1.3"
+    ssri "^5.3.0"
+    strip-ansi "~4.0.0"
+    tar "^4.4.2"
+    text-table "~0.2.0"
+    tiny-relative-date "^1.3.0"
+    uid-number "0.0.6"
+    umask "~1.1.0"
+    unique-filename "~1.1.0"
+    unpipe "~1.0.0"
+    update-notifier "^2.5.0"
+    uuid "^3.2.1"
+    validate-npm-package-license "^3.0.3"
+    validate-npm-package-name "~3.0.0"
+    which "~1.3.0"
+    worker-farm "^1.6.0"
+    wrappy "~1.0.2"
+    write-file-atomic "^2.3.0"
+
+"npmlog@0 || 1 || 2 || 3 || 4", "npmlog@2 || ^3.1.0 || ^4.0.0", npmlog@~4.1.2:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
+  integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
+  dependencies:
+    are-we-there-yet "~1.1.2"
+    console-control-strings "~1.1.0"
+    gauge "~2.7.3"
+    set-blocking "~2.0.0"
+
 nth-check@~1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c"
@@ -4725,6 +6432,11 @@ nth-check@~1.0.1:
   dependencies:
     boolbase "~1.0.0"
 
+number-is-nan@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
+  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
+
 nwsapi@^2.2.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7"
@@ -4735,7 +6447,7 @@ oauth-sign@~0.9.0:
   resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
   integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
 
-object-assign@^4.1.1:
+object-assign@^4.1.0, object-assign@^4.1.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
   integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
@@ -4749,12 +6461,12 @@ object-copy@^0.1.0:
     define-property "^0.2.5"
     kind-of "^3.0.3"
 
-object-inspect@^1.7.0:
+object-inspect@^1.7.0, object-inspect@^1.8.0:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.8.0.tgz#df807e5ecf53a609cc6bfe93eac3cc7be5b3a9d0"
   integrity sha512-jLdtEOB112fORuypAyl/50VRVIBIdVQOSUUGQHzJ4xBSbit81zRarz7GThkEFZy1RceYrWYcPcBFPQwHyAc1gA==
 
-object-is@^1.0.2, object-is@^1.1.2:
+object-is@^1.0.1, object-is@^1.0.2, object-is@^1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.2.tgz#c5d2e87ff9e119f78b7a088441519e2eec1573b6"
   integrity sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ==
@@ -4803,6 +6515,14 @@ object.fromentries@^2.0.2:
     function-bind "^1.1.1"
     has "^1.0.3"
 
+object.getownpropertydescriptors@^2.0.3:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.0.tgz#369bf1f9592d8ab89d712dced5cb81c7c5352649"
+  integrity sha512-Z53Oah9A3TdLoblT7VKJaTDdXdT+lQO+cNpKVnya5JDe9uLvzu1YyY1yFDFrcxrlRgWrEFH0jJtD/IbuwjcEVg==
+  dependencies:
+    define-properties "^1.1.3"
+    es-abstract "^1.17.0-next.1"
+
 object.omit@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
@@ -4835,7 +6555,7 @@ on-finished@~2.3.0:
   dependencies:
     ee-first "1.1.1"
 
-once@^1.3.0, once@^1.3.1, once@^1.4.0:
+once@^1.3.0, once@^1.3.1, once@^1.3.3, once@^1.4.0, once@~1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
   integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
@@ -4861,6 +6581,11 @@ opencollective-postinstall@^2.0.2:
   resolved "https://registry.yarnpkg.com/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz#7a0fff978f6dbfa4d006238fbac98ed4198c3259"
   integrity sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==
 
+opener@~1.4.3:
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/opener/-/opener-1.4.3.tgz#5c6da2c5d7e5831e8ffa3964950f8d6674ac90b8"
+  integrity sha1-XG2ixdflgx6P+jlklQ+NZnSskLg=
+
 optionator@^0.8.1:
   version "0.8.3"
   resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495"
@@ -4890,11 +6615,33 @@ options@>=0.0.5:
   resolved "https://registry.yarnpkg.com/options/-/options-0.0.6.tgz#ec22d312806bb53e731773e7cdaefcf1c643128f"
   integrity sha1-7CLTEoBrtT5zF3Pnza788cZDEo8=
 
-os-tmpdir@~1.0.2:
+os-homedir@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
+  integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
+
+os-locale@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/os-locale/-/os-locale-2.1.0.tgz#42bc2900a6b5b8bd17376c8e882b65afccf24bf2"
+  integrity sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA==
+  dependencies:
+    execa "^0.7.0"
+    lcid "^1.0.0"
+    mem "^1.1.0"
+
+os-tmpdir@^1.0.0, os-tmpdir@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
   integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
 
+osenv@0, osenv@^0.1.4, osenv@^0.1.5:
+  version "0.1.5"
+  resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
+  integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
+  dependencies:
+    os-homedir "^1.0.0"
+    os-tmpdir "^1.0.0"
+
 p-each-series@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/p-each-series/-/p-each-series-2.1.0.tgz#961c8dd3f195ea96c747e636b262b800a6b1af48"
@@ -4912,7 +6659,7 @@ p-limit@^1.1.0:
   dependencies:
     p-try "^1.0.0"
 
-p-limit@^2.2.0:
+p-limit@^2.0.0, p-limit@^2.2.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
   integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
@@ -4926,6 +6673,13 @@ p-locate@^2.0.0:
   dependencies:
     p-limit "^1.1.0"
 
+p-locate@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4"
+  integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==
+  dependencies:
+    p-limit "^2.0.0"
+
 p-locate@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
@@ -4950,6 +6704,86 @@ p-try@^2.0.0:
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
   integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
 
+package-json@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed"
+  integrity sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=
+  dependencies:
+    got "^6.7.1"
+    registry-auth-token "^3.0.1"
+    registry-url "^3.0.3"
+    semver "^5.1.0"
+
+pacote@^7.6.1:
+  version "7.6.1"
+  resolved "https://registry.yarnpkg.com/pacote/-/pacote-7.6.1.tgz#d44621c89a5a61f173989b60236757728387c094"
+  integrity sha512-2kRIsHxjuYC1KRUIK80AFIXKWy0IgtFj76nKcaunozKAOSlfT+DFh3EfeaaKvNHCWixgi0G0rLg11lJeyEnp/Q==
+  dependencies:
+    bluebird "^3.5.1"
+    cacache "^10.0.4"
+    get-stream "^3.0.0"
+    glob "^7.1.2"
+    lru-cache "^4.1.1"
+    make-fetch-happen "^2.6.0"
+    minimatch "^3.0.4"
+    mississippi "^3.0.0"
+    mkdirp "^0.5.1"
+    normalize-package-data "^2.4.0"
+    npm-package-arg "^6.0.0"
+    npm-packlist "^1.1.10"
+    npm-pick-manifest "^2.1.0"
+    osenv "^0.1.5"
+    promise-inflight "^1.0.1"
+    promise-retry "^1.1.1"
+    protoduck "^5.0.0"
+    rimraf "^2.6.2"
+    safe-buffer "^5.1.1"
+    semver "^5.5.0"
+    ssri "^5.2.4"
+    tar "^4.4.0"
+    unique-filename "^1.1.0"
+    which "^1.3.0"
+
+pacote@^8.1.6:
+  version "8.1.6"
+  resolved "https://registry.yarnpkg.com/pacote/-/pacote-8.1.6.tgz#8e647564d38156367e7a9dc47a79ca1ab278d46e"
+  integrity sha512-wTOOfpaAQNEQNtPEx92x9Y9kRWVu45v583XT8x2oEV2xRB74+xdqMZIeGW4uFvAyZdmSBtye+wKdyyLaT8pcmw==
+  dependencies:
+    bluebird "^3.5.1"
+    cacache "^11.0.2"
+    get-stream "^3.0.0"
+    glob "^7.1.2"
+    lru-cache "^4.1.3"
+    make-fetch-happen "^4.0.1"
+    minimatch "^3.0.4"
+    minipass "^2.3.3"
+    mississippi "^3.0.0"
+    mkdirp "^0.5.1"
+    normalize-package-data "^2.4.0"
+    npm-package-arg "^6.1.0"
+    npm-packlist "^1.1.10"
+    npm-pick-manifest "^2.1.0"
+    osenv "^0.1.5"
+    promise-inflight "^1.0.1"
+    promise-retry "^1.1.1"
+    protoduck "^5.0.0"
+    rimraf "^2.6.2"
+    safe-buffer "^5.1.2"
+    semver "^5.5.0"
+    ssri "^6.0.0"
+    tar "^4.4.3"
+    unique-filename "^1.1.0"
+    which "^1.3.0"
+
+parallel-transform@^1.1.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/parallel-transform/-/parallel-transform-1.2.0.tgz#9049ca37d6cb2182c3b1d2c720be94d14a5814fc"
+  integrity sha512-P2vSmIu38uIlvdcU7fDkyrxj33gTUy/ABO5ZUbGowxNCopBq/OoD42bP4UmMrJoPyk4Uqf0mu3mtWBhHCZD8yg==
+  dependencies:
+    cyclist "^1.0.1"
+    inherits "^2.0.3"
+    readable-stream "^2.1.5"
+
 parent-module@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
@@ -5021,6 +6855,11 @@ path-is-absolute@^1.0.0:
   resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
   integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
 
+path-is-inside@^1.0.1, path-is-inside@^1.0.2, path-is-inside@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
+  integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=
+
 path-key@^2.0.0, path-key@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
@@ -5082,6 +6921,11 @@ pify@^2.0.0:
   resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
   integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
 
+pify@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
+  integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
+
 pirates@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.1.tgz#643a92caf894566f91b2b986d2c66950a8e2fb87"
@@ -5139,6 +6983,11 @@ prelude-ls@~1.1.2:
   resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
   integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=
 
+prepend-http@^1.0.1:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
+  integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
+
 preserve@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
@@ -5152,9 +7001,9 @@ prettier-linter-helpers@^1.0.0:
     fast-diff "^1.1.2"
 
 prettier@^2.0.4:
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.0.5.tgz#d6d56282455243f2f92cc1716692c08aa31522d4"
-  integrity sha512-7PtVymN48hGcO4fGjybyBSIWDsLU4H4XlvOHfq91pz9kkGlonzwTfYkaIEwiRg/dAJF9YlbsduBAgtYLi+8cFg==
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.1.1.tgz#d9485dd5e499daa6cb547023b87a6cf51bee37d6"
+  integrity sha512-9bY+5ZWCfqj3ghYBLxApy2zf6m+NJo5GzmLTpr9FsApsfjriNnS2dahWReHMi7qNPhhHl9SYHJs2cHZLgexNIw==
 
 pretty-format@^20.0.3:
   version "20.0.3"
@@ -5207,6 +7056,19 @@ progress@^2.0.0:
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
   integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
 
+promise-inflight@^1.0.1, promise-inflight@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
+  integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM=
+
+promise-retry@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-1.1.1.tgz#6739e968e3051da20ce6497fb2b50f6911df3d6d"
+  integrity sha1-ZznpaOMFHaIM5kl/srUPaRHfPW0=
+  dependencies:
+    err-code "^1.0.0"
+    retry "^0.10.0"
+
 prompts@^2.0.1:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.3.2.tgz#480572d89ecf39566d2bd3fe2c9fccb7c4c0b068"
@@ -5215,6 +7077,13 @@ prompts@^2.0.1:
     kleur "^3.0.3"
     sisteransi "^1.0.4"
 
+promzard@^0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/promzard/-/promzard-0.3.0.tgz#26a5d6ee8c7dee4cb12208305acfb93ba382a9ee"
+  integrity sha1-JqXW7ox97kyxIggwWs+5O6OCqe4=
+  dependencies:
+    read "1"
+
 prop-types@^15.7.2:
   version "15.7.2"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
@@ -5224,6 +7093,18 @@ prop-types@^15.7.2:
     object-assign "^4.1.1"
     react-is "^16.8.1"
 
+proto-list@~1.2.1:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
+  integrity sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=
+
+protoduck@^5.0.0:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/protoduck/-/protoduck-5.0.1.tgz#03c3659ca18007b69a50fd82a7ebcc516261151f"
+  integrity sha512-WxoCeDCoCBY55BMvj4cAEjdVUFGRWed9ZxPlqTKYyw1nDDTQ4pqmnIMAGfJlg7Dx35uB/M+PHJPTmGOvaCaPTg==
+  dependencies:
+    genfun "^5.0.0"
+
 proxy-addr@~2.0.5:
   version "2.0.6"
   resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf"
@@ -5232,11 +7113,37 @@ proxy-addr@~2.0.5:
     forwarded "~0.1.2"
     ipaddr.js "1.9.1"
 
+prr@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
+  integrity sha1-0/wRS6BplaRexok/SEzrHXj19HY=
+
+pseudomap@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
+  integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
+
 psl@^1.1.28:
   version "1.8.0"
   resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
   integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==
 
+pump@^1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954"
+  integrity sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
+pump@^2.0.0, pump@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909"
+  integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==
+  dependencies:
+    end-of-stream "^1.1.0"
+    once "^1.3.1"
+
 pump@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
@@ -5245,11 +7152,25 @@ pump@^3.0.0:
     end-of-stream "^1.1.0"
     once "^1.3.1"
 
+pumpify@^1.3.3:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce"
+  integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==
+  dependencies:
+    duplexify "^3.6.0"
+    inherits "^2.0.3"
+    pump "^2.0.0"
+
 punycode@^2.1.0, punycode@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
+qrcode-terminal@^0.12.0:
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz#bb5b699ef7f9f0505092a3748be4464fe71b5819"
+  integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==
+
 qs@6.7.0:
   version "6.7.0"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
@@ -5260,6 +7181,20 @@ qs@~6.5.2:
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
   integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
 
+query-string@^6.1.0:
+  version "6.13.1"
+  resolved "https://registry.yarnpkg.com/query-string/-/query-string-6.13.1.tgz#d913ccfce3b4b3a713989fe6d39466d92e71ccad"
+  integrity sha512-RfoButmcK+yCta1+FuU8REvisx1oEzhMKwhLUNcepQTPGcNMp1sIqjnfCtfnvGSQZQEhaBHvccujtWoUV3TTbA==
+  dependencies:
+    decode-uri-component "^0.2.0"
+    split-on-first "^1.0.0"
+    strict-uri-encode "^2.0.0"
+
+qw@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/qw/-/qw-1.0.1.tgz#efbfdc740f9ad054304426acb183412cc8b996d4"
+  integrity sha1-77/cdA+a0FQwRCassYNBLMi5ltQ=
+
 raf@^3.4.1:
   version "3.4.1"
   resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
@@ -5311,11 +7246,61 @@ raw-body@2.4.0:
     iconv-lite "0.4.24"
     unpipe "1.0.0"
 
+rc@^1.0.1, rc@^1.1.6:
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
+  integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
+  dependencies:
+    deep-extend "^0.6.0"
+    ini "~1.3.0"
+    minimist "^1.2.0"
+    strip-json-comments "~2.0.1"
+
 react-is@^16.12.0, react-is@^16.8.1:
   version "16.13.1"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
   integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
 
+read-cmd-shim@^1.0.1, read-cmd-shim@~1.0.1:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/read-cmd-shim/-/read-cmd-shim-1.0.5.tgz#87e43eba50098ba5a32d0ceb583ab8e43b961c16"
+  integrity sha512-v5yCqQ/7okKoZZkBQUAfTsQ3sVJtXdNfbPnI5cceppoxEVLYA3k+VtV2omkeo8MS94JCy4fSiUwlRBAwCVRPUA==
+  dependencies:
+    graceful-fs "^4.1.2"
+
+read-installed@~4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/read-installed/-/read-installed-4.0.3.tgz#ff9b8b67f187d1e4c29b9feb31f6b223acd19067"
+  integrity sha1-/5uLZ/GH0eTCm5/rMfayI6zRkGc=
+  dependencies:
+    debuglog "^1.0.1"
+    read-package-json "^2.0.0"
+    readdir-scoped-modules "^1.0.0"
+    semver "2 || 3 || 4 || 5"
+    slide "~1.1.3"
+    util-extend "^1.0.1"
+  optionalDependencies:
+    graceful-fs "^4.1.2"
+
+"read-package-json@1 || 2", read-package-json@^2.0.0, read-package-json@^2.0.13:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/read-package-json/-/read-package-json-2.1.2.tgz#6992b2b66c7177259feb8eaac73c3acd28b9222a"
+  integrity sha512-D1KmuLQr6ZSJS0tW8hf3WGpRlwszJOXZ3E8Yd/DNRaM5d+1wVRZdHlpGBLAuovjr28LbWvjpWkBHMxpRGGjzNA==
+  dependencies:
+    glob "^7.1.1"
+    json-parse-even-better-errors "^2.3.0"
+    normalize-package-data "^2.0.0"
+    npm-normalize-package-bin "^1.0.0"
+
+read-package-tree@^5.2.1:
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/read-package-tree/-/read-package-tree-5.3.1.tgz#a32cb64c7f31eb8a6f31ef06f9cedf74068fe636"
+  integrity sha512-mLUDsD5JVtlZxjSlPPx1RETkNjjvQYuweKwNVt1Sn8kP5Jh44pvYuUHCp6xSVDZWbNxVxG5lyZJ921aJH61sTw==
+  dependencies:
+    read-package-json "^2.0.0"
+    readdir-scoped-modules "^1.0.0"
+    util-promisify "^2.1.0"
+
 read-pkg-up@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be"
@@ -5352,7 +7337,14 @@ read-pkg@^5.2.0:
     parse-json "^5.0.0"
     type-fest "^0.6.0"
 
-readable-stream@^2.0.2:
+read@1, read@~1.0.1, read@~1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/read/-/read-1.0.7.tgz#b3da19bd052431a97671d44a42634adf710b40c4"
+  integrity sha1-s9oZvQUkMal2cdRKQmNK33ELQMQ=
+  dependencies:
+    mute-stream "~0.0.4"
+
+"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.2, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.6, readable-stream@~2.3.6:
   version "2.3.7"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
   integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
@@ -5374,6 +7366,26 @@ readable-stream@^3.1.1:
     string_decoder "^1.1.1"
     util-deprecate "^1.0.1"
 
+readable-stream@~1.1.10:
+  version "1.1.14"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
+  integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.1"
+    isarray "0.0.1"
+    string_decoder "~0.10.x"
+
+readdir-scoped-modules@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309"
+  integrity sha512-asaikDeqAQg7JifRsZn1NJZXo9E+VwlyCfbkZhwyISinqk5zNS6266HS5kah6P0SaQKGF6SkNnZVHUzHFYxYDw==
+  dependencies:
+    debuglog "^1.0.1"
+    dezalgo "^1.0.0"
+    graceful-fs "^4.1.2"
+    once "^1.3.0"
+
 readdirp@^2.0.0:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525"
@@ -5391,6 +7403,19 @@ realm-utils@^1.0.7, realm-utils@^1.0.9:
     app-root-path "^1.3.0"
     mkdirp "^0.5.1"
 
+reconnecting-websocket@^4.4.0:
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783"
+  integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==
+
+redux@^4.0.4:
+  version "4.0.5"
+  resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f"
+  integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w==
+  dependencies:
+    loose-envify "^1.4.0"
+    symbol-observable "^1.2.0"
+
 regenerate-unicode-properties@^8.2.0:
   version "8.2.0"
   resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec"
@@ -5428,7 +7453,7 @@ regexp-tree@^0.1.21, regexp-tree@~0.1.1:
   resolved "https://registry.yarnpkg.com/regexp-tree/-/regexp-tree-0.1.21.tgz#55e2246b7f7d36f1b461490942fa780299c400d7"
   integrity sha512-kUUXjX4AnqnR8KRTCrayAo9PzYMRKmVoGgaz2tBuz0MF3g1ZbGebmtW0yFHfFK9CmBjQKeYIgoL22pFLBJY7sw==
 
-regexp.prototype.flags@^1.3.0:
+regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz#7aba89b3c13a64509dabcf3ca8d9fbb9bdf5cb75"
   integrity sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==
@@ -5453,6 +7478,21 @@ regexpu-core@^4.1.3:
     unicode-match-property-ecmascript "^1.0.4"
     unicode-match-property-value-ecmascript "^1.2.0"
 
+registry-auth-token@^3.0.1:
+  version "3.4.0"
+  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.4.0.tgz#d7446815433f5d5ed6431cd5dca21048f66b397e"
+  integrity sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A==
+  dependencies:
+    rc "^1.1.6"
+    safe-buffer "^5.0.1"
+
+registry-url@^3.0.3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
+  integrity sha1-PU74cPc93h138M+aOBQyRE4XSUI=
+  dependencies:
+    rc "^1.0.1"
+
 regjsgen@^0.5.1:
   version "0.5.2"
   resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733"
@@ -5496,7 +7536,7 @@ request-promise-native@^1.0.8:
     stealthy-require "^1.1.1"
     tough-cookie "^2.3.3"
 
-request@^2.79.0, request@^2.88.2:
+request@^2.74.0, request@^2.79.0, request@^2.85.0, request@^2.87.0, request@^2.88.2:
   version "2.88.2"
   resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
   integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
@@ -5527,6 +7567,11 @@ require-directory@^2.1.1:
   resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
   integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I=
 
+require-main-filename@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-1.0.1.tgz#97f717b69d48784f5f526a6c5aa8ffdda055a4d1"
+  integrity sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=
+
 require-main-filename@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
@@ -5592,7 +7637,24 @@ ret@~0.1.10:
   resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
   integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
 
-rimraf@2.6.3:
+retry@^0.10.0:
+  version "0.10.1"
+  resolved "https://registry.yarnpkg.com/retry/-/retry-0.10.1.tgz#e76388d217992c252750241d3d3956fed98d8ff4"
+  integrity sha1-52OI0heZLCUnUCQdPTlW/tmNj/Q=
+
+retry@^0.12.0:
+  version "0.12.0"
+  resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
+  integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs=
+
+rimraf@2, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.2, rimraf@^2.6.3:
+  version "2.7.1"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
+  integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
+  dependencies:
+    glob "^7.1.3"
+
+rimraf@2.6.3, rimraf@~2.6.2:
   version "2.6.3"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab"
   integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==
@@ -5624,6 +7686,13 @@ run-async@^2.2.0:
   resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
   integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==
 
+run-queue@^1.0.0, run-queue@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47"
+  integrity sha1-6Eg5bwV9Ij8kOGkkYY4laUFh7Ec=
+  dependencies:
+    aproba "^1.1.1"
+
 rx-lite-aggregates@^4.0.8:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz#753b87a89a11c95467c4ac1626c4efc4e05c67be"
@@ -5636,7 +7705,7 @@ rx-lite@*, rx-lite@^4.0.8:
   resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-4.0.8.tgz#0b1e11af8bc44836f04a6407e92da42467b79444"
   integrity sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ=
 
-rxjs@^6.6.2:
+rxjs@^6.5.5, rxjs@^6.6.2:
   version "6.6.2"
   resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.2.tgz#8096a7ac03f2cc4fe5860ef6e572810d9e01c0d2"
   integrity sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg==
@@ -5648,7 +7717,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
 
-safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
+safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -5667,7 +7736,7 @@ safe-regex@^2.1.1:
   dependencies:
     regexp-tree "~0.1.1"
 
-"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
+"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
@@ -5699,12 +7768,19 @@ semver-compare@^1.0.0:
   resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc"
   integrity sha1-De4hahyUGrN+nvsXiPavxf9VN/w=
 
+semver-diff@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36"
+  integrity sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY=
+  dependencies:
+    semver "^5.0.3"
+
 semver-regex@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/semver-regex/-/semver-regex-2.0.0.tgz#a93c2c5844539a770233379107b38c7b4ac9d338"
   integrity sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==
 
-"semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.0:
+"semver@2 >=2.2.1 || 3.x || 4 || 5", "semver@2 || 3 || 4 || 5", "semver@2.x || 3.x || 4 || 5", "semver@^2.3.0 || 3.x || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.4.1, semver@^5.5.0, semver@^5.6.0:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
   integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
@@ -5719,6 +7795,11 @@ semver@^7.2.1, semver@^7.3.2:
   resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938"
   integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==
 
+semver@~5.3.0:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f"
+  integrity sha1-myzl094C0XxgEq0yaqa00M9U+U8=
+
 send@0.17.1:
   version "0.17.1"
   resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8"
@@ -5755,7 +7836,7 @@ serve-static@1.14.1:
     parseurl "~1.3.3"
     send "0.17.1"
 
-set-blocking@^2.0.0:
+set-blocking@^2.0.0, set-blocking@~2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
   integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
@@ -5775,6 +7856,19 @@ setprototypeof@1.1.1:
   resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
   integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
 
+sha@~2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/sha/-/sha-2.0.1.tgz#6030822fbd2c9823949f8f72ed6411ee5cf25aae"
+  integrity sha1-YDCCL70smCOUn49y7WQR7lzyWq4=
+  dependencies:
+    graceful-fs "^4.1.2"
+    readable-stream "^2.0.2"
+
+shallowequal@^1.0.1:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
+  integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==
+
 shebang-command@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
@@ -5810,12 +7904,12 @@ shorthash@0.0.2:
   integrity sha1-WbJo7sveWQOLMNogK8+93rLEpOs=
 
 side-channel@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.2.tgz#df5d1abadb4e4bf4af1cd8852bf132d2f7876947"
-  integrity sha512-7rL9YlPHg7Ancea1S96Pa8/QWb4BtXL/TZvS6B8XFetGBeuhAsfmUspK6DokBeZ64+Kj9TCNRD/30pVz1BvQNA==
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.3.tgz#cdc46b057550bbab63706210838df5d4c19519c3"
+  integrity sha512-A6+ByhlLkksFoUepsGxfj5x1gTSrs+OydsRptUxeNCabQpCFUvcwIczgOigI8vhY/OJCnPnyE9rGiwgvr9cS1g==
   dependencies:
-    es-abstract "^1.17.0-next.1"
-    object-inspect "^1.7.0"
+    es-abstract "^1.18.0-next.0"
+    object-inspect "^1.8.0"
 
 signal-exit@^3.0.0, signal-exit@^3.0.2:
   version "3.0.3"
@@ -5859,6 +7953,21 @@ slice-ansi@^4.0.0:
     astral-regex "^2.0.0"
     is-fullwidth-code-point "^3.0.0"
 
+slide@^1.1.3, slide@^1.1.6, slide@~1.1.3, slide@~1.1.6:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
+  integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=
+
+smart-buffer@^1.0.13:
+  version "1.1.15"
+  resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-1.1.15.tgz#7f114b5b65fab3e2a35aa775bb12f0d1c649bf16"
+  integrity sha1-fxFLW2X6s+KjWqd1uxLw0cZJvxY=
+
+smart-buffer@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.1.0.tgz#91605c25d91652f4661ea69ccf45f1b331ca21ba"
+  integrity sha512-iVICrxOzCynf/SNaBQCw34eM9jROU/s5rzIhpOvzhzuYHfJR/DhZfDkXiZSgKXfgv26HT3Yni3AV/DGw0cGnnw==
+
 snapdragon-node@^2.0.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
@@ -5889,6 +7998,51 @@ snapdragon@^0.8.1:
     source-map-resolve "^0.5.0"
     use "^3.1.0"
 
+socks-proxy-agent@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-3.0.1.tgz#2eae7cf8e2a82d34565761539a7f9718c5617659"
+  integrity sha512-ZwEDymm204mTzvdqyUqOdovVr2YRd2NYskrYrF2LXyZ9qDiMAoFESGK8CRphiO7rtbo2Y757k2Nia3x2hGtalA==
+  dependencies:
+    agent-base "^4.1.0"
+    socks "^1.1.10"
+
+socks-proxy-agent@^4.0.0:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-4.0.2.tgz#3c8991f3145b2799e70e11bd5fbc8b1963116386"
+  integrity sha512-NT6syHhI9LmuEMSK6Kd2V7gNv5KFZoLE7V5udWmn0de+3Mkj3UMA/AJPLyeNUVmElCurSHtUdM3ETpR3z770Wg==
+  dependencies:
+    agent-base "~4.2.1"
+    socks "~2.3.2"
+
+socks@^1.1.10:
+  version "1.1.10"
+  resolved "https://registry.yarnpkg.com/socks/-/socks-1.1.10.tgz#5b8b7fc7c8f341c53ed056e929b7bf4de8ba7b5a"
+  integrity sha1-W4t/x8jzQcU+0FbpKbe/Tei6e1o=
+  dependencies:
+    ip "^1.1.4"
+    smart-buffer "^1.0.13"
+
+socks@~2.3.2:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/socks/-/socks-2.3.3.tgz#01129f0a5d534d2b897712ed8aceab7ee65d78e3"
+  integrity sha512-o5t52PCNtVdiOvzMry7wU4aOqYWL0PeCXRWBEiJow4/i/wr+wpsJQ9awEu1EonLIqsfGd5qSgDdxEOvCdmBEpA==
+  dependencies:
+    ip "1.1.5"
+    smart-buffer "^4.1.0"
+
+sorted-object@~2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/sorted-object/-/sorted-object-2.0.1.tgz#7d631f4bd3a798a24af1dffcfbfe83337a5df5fc"
+  integrity sha1-fWMfS9OnmKJK8d/8+/6DM3pd9fw=
+
+sorted-union-stream@~2.1.3:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/sorted-union-stream/-/sorted-union-stream-2.1.3.tgz#c7794c7e077880052ff71a8d4a2dbb4a9a638ac7"
+  integrity sha1-x3lMfgd4gAUv9xqNSi27Sppjisc=
+  dependencies:
+    from2 "^1.3.0"
+    stream-iterate "^1.1.0"
+
 sortpack@^2.1.4:
   version "2.1.7"
   resolved "https://registry.yarnpkg.com/sortpack/-/sortpack-2.1.7.tgz#240837033b00e0c671048d98bbd0d710d979bb2e"
@@ -5905,7 +8059,7 @@ source-map-resolve@^0.5.0:
     source-map-url "^0.4.0"
     urix "^0.1.0"
 
-source-map-support@^0.5.17, source-map-support@^0.5.6:
+source-map-support@^0.5.17, source-map-support@^0.5.6, source-map-support@~0.5.12:
   version "0.5.19"
   resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
   integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
@@ -5966,6 +8120,11 @@ spdx-license-ids@^3.0.0:
   resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654"
   integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==
 
+split-on-first@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-1.1.0.tgz#f610afeee3b12bce1d0c30425e76398b78249a5f"
+  integrity sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==
+
 split-string@^3.0.1, split-string@^3.0.2:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"
@@ -5993,6 +8152,20 @@ sshpk@^1.7.0:
     safer-buffer "^2.0.2"
     tweetnacl "~0.14.0"
 
+ssri@^5.0.0, ssri@^5.2.4, ssri@^5.3.0:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/ssri/-/ssri-5.3.0.tgz#ba3872c9c6d33a0704a7d71ff045e5ec48999d06"
+  integrity sha512-XRSIPqLij52MtgoQavH/x/dU1qVKtWUAAZeOHsR9c2Ddi4XerFy3mc1alf+dLJKl9EUIm/Ht+EowFkTUOA6GAQ==
+  dependencies:
+    safe-buffer "^5.1.1"
+
+ssri@^6.0.0, ssri@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8"
+  integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==
+  dependencies:
+    figgy-pudding "^3.5.1"
+
 stack-utils@^2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.2.tgz#5cf48b4557becb4638d0bc4f21d23f5d19586593"
@@ -6026,6 +8199,32 @@ stream-browserify@^2.0.1:
     inherits "~2.0.1"
     readable-stream "^2.0.2"
 
+stream-each@^1.1.0:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/stream-each/-/stream-each-1.2.3.tgz#ebe27a0c389b04fbcc233642952e10731afa9bae"
+  integrity sha512-vlMC2f8I2u/bZGqkdfLQW/13Zihpej/7PmSiMQsbYddxuTsJp8vRe2x2FvVExZg7FaOds43ROAuFJwPR4MTZLw==
+  dependencies:
+    end-of-stream "^1.1.0"
+    stream-shift "^1.0.0"
+
+stream-iterate@^1.1.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/stream-iterate/-/stream-iterate-1.2.0.tgz#2bd7c77296c1702a46488b8ad41f79865eecd4e1"
+  integrity sha1-K9fHcpbBcCpGSIuK1B95hl7s1OE=
+  dependencies:
+    readable-stream "^2.1.5"
+    stream-shift "^1.0.0"
+
+stream-shift@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d"
+  integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==
+
+strict-uri-encode@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546"
+  integrity sha1-ucczDHBChi9rFC3CdLvMWGbONUY=
+
 string-argv@0.3.1:
   version "0.3.1"
   resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da"
@@ -6039,7 +8238,16 @@ string-length@^4.0.1:
     char-regex "^1.0.2"
     strip-ansi "^6.0.0"
 
-string-width@^2.1.0:
+string-width@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
+  integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
+  dependencies:
+    code-point-at "^1.0.0"
+    is-fullwidth-code-point "^1.0.0"
+    strip-ansi "^3.0.0"
+
+"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
   integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
@@ -6047,7 +8255,7 @@ string-width@^2.1.0:
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^4.0.0"
 
-string-width@^3.0.0:
+string-width@^3.0.0, string-width@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
   integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==
@@ -6109,6 +8317,11 @@ string_decoder@^1.1.1:
   dependencies:
     safe-buffer "~5.2.0"
 
+string_decoder@~0.10.x:
+  version "0.10.31"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
+  integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
+
 string_decoder@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
@@ -6125,14 +8338,21 @@ stringify-object@^3.3.0:
     is-obj "^1.0.1"
     is-regexp "^1.0.0"
 
-strip-ansi@^4.0.0:
+strip-ansi@^3.0.0, strip-ansi@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
+  integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
+  dependencies:
+    ansi-regex "^2.0.0"
+
+strip-ansi@^4.0.0, strip-ansi@~4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
   integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
   dependencies:
     ansi-regex "^3.0.0"
 
-strip-ansi@^5.1.0:
+strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0:
   version "5.2.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
   integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
@@ -6171,6 +8391,11 @@ strip-json-comments@^3.1.0:
   resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
   integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
 
+strip-json-comments@~2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
+  integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
+
 supports-color@^5.3.0, supports-color@^5.4.0:
   version "5.5.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
@@ -6178,13 +8403,20 @@ supports-color@^5.3.0, supports-color@^5.4.0:
   dependencies:
     has-flag "^3.0.0"
 
-supports-color@^7.0.0, supports-color@^7.1.0:
+supports-color@^7.0.0:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1"
   integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==
   dependencies:
     has-flag "^4.0.0"
 
+supports-color@^7.1.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
+  integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
+  dependencies:
+    has-flag "^4.0.0"
+
 supports-hyperlinks@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-2.1.0.tgz#f663df252af5f37c5d49bbd7eeefa9e0b9e59e47"
@@ -6193,6 +8425,11 @@ supports-hyperlinks@^2.0.0:
     has-flag "^4.0.0"
     supports-color "^7.0.0"
 
+symbol-observable@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
+  integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
+
 symbol-tree@^3.2.4:
   version "3.2.4"
   resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
@@ -6208,6 +8445,35 @@ table@^5.2.3:
     slice-ansi "^2.1.0"
     string-width "^3.0.0"
 
+tar@^2.0.0:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-2.2.2.tgz#0ca8848562c7299b8b446ff6a4d60cdbb23edc40"
+  integrity sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA==
+  dependencies:
+    block-stream "*"
+    fstream "^1.0.12"
+    inherits "2"
+
+tar@^4.4.0, tar@^4.4.2, tar@^4.4.3, tar@^4.4.8:
+  version "4.4.13"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525"
+  integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==
+  dependencies:
+    chownr "^1.1.1"
+    fs-minipass "^1.2.5"
+    minipass "^2.8.6"
+    minizlib "^1.2.1"
+    mkdirp "^0.5.0"
+    safe-buffer "^5.1.2"
+    yallist "^3.0.3"
+
+term-size@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69"
+  integrity sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk=
+  dependencies:
+    execa "^0.7.0"
+
 terminal-link@^2.0.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/terminal-link/-/terminal-link-2.1.1.tgz#14a64a27ab3c0df933ea546fba55f2d078edc994"
@@ -6216,6 +8482,15 @@ terminal-link@^2.0.0:
     ansi-escapes "^4.2.1"
     supports-hyperlinks "^2.0.0"
 
+terser@^4.6.11:
+  version "4.8.0"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
+  integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==
+  dependencies:
+    commander "^2.20.0"
+    source-map "~0.6.1"
+    source-map-support "~0.5.12"
+
 test-exclude@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e"
@@ -6225,7 +8500,7 @@ test-exclude@^6.0.0:
     glob "^7.1.4"
     minimatch "^3.0.4"
 
-text-table@^0.2.0:
+text-table@^0.2.0, text-table@~0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
   integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
@@ -6235,21 +8510,46 @@ throat@^5.0.0:
   resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b"
   integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==
 
-through@^2.3.6, through@^2.3.8:
+through2@^2.0.0:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
+  integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==
+  dependencies:
+    readable-stream "~2.3.6"
+    xtend "~4.0.1"
+
+"through@>=2.2.7 <3", through@^2.3.6, through@^2.3.8:
   version "2.3.8"
   resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
   integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
 
+timed-out@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
+  integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=
+
 tiny-invariant@^1.0.2:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875"
   integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw==
 
+tiny-relative-date@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/tiny-relative-date/-/tiny-relative-date-1.3.0.tgz#fa08aad501ed730f31cc043181d995c39a935e07"
+  integrity sha512-MOQHpzllWxDCHHaDno30hhLfbouoYlOI8YlMNtvKe1zXbjEVhbcEovQxvZrPvtiYW630GQDoMMarCnjfyfHA+A==
+
 tiny-warning@^1.0.0:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754"
   integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
 
+tippy.js@^6.1.1:
+  version "6.2.6"
+  resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-6.2.6.tgz#4991bbe8f75e741fb92b5ccfeebcd072d71f8345"
+  integrity sha512-0tTL3WQNT0nWmpslhDryRahoBm6PT9fh1xXyDfOsvZpDzq52by2rF2nvsW0WX2j9nUZP/jSGDqfKJGjCtoGFKg==
+  dependencies:
+    "@popperjs/core" "^2.4.4"
+
 tmp@^0.0.33:
   version "0.0.33"
   resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
@@ -6299,6 +8599,11 @@ to-regex@^3.0.1, to-regex@^3.0.2:
     regex-not "^1.0.2"
     safe-regex "^1.1.0"
 
+toastify-js@^1.7.0:
+  version "1.9.1"
+  resolved "https://registry.yarnpkg.com/toastify-js/-/toastify-js-1.9.1.tgz#95bd5bc03a6144ea5e736a2e39fb0730195818b3"
+  integrity sha512-B3LTJURySMix/xWqVHyj2XGVsIHesb4euGVuIaFfKxfmjM4F6HMgbW9V66DHUEt98jGlGpeTWSiSJ78UfrJVbA==
+
 toidentifier@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
@@ -6328,6 +8633,11 @@ tr46@^2.0.2:
   dependencies:
     punycode "^2.1.1"
 
+tributejs@^5.1.3:
+  version "5.1.3"
+  resolved "https://registry.yarnpkg.com/tributejs/-/tributejs-5.1.3.tgz#980600fc72865be5868893078b4bfde721129eae"
+  integrity sha512-B5CXihaVzXw+1UHhNFyAwUTMDk1EfoLP5Tj1VhD9yybZ1I8DZJEv8tZ1l0RJo0t0tk9ZhR8eG5tEsaCvRigmdQ==
+
 ts-node@^9.0.0:
   version "9.0.0"
   resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-9.0.0.tgz#e7699d2a110cc8c0d3b831715e417688683460b3"
@@ -6364,11 +8674,6 @@ tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0:
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
   integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
 
-tslint-react-recommended@^1.0.15:
-  version "1.0.15"
-  resolved "https://registry.yarnpkg.com/tslint-react-recommended/-/tslint-react-recommended-1.0.15.tgz#4166dc7d87b57280110673c99315a35ac5a76a7e"
-  integrity sha1-QWbcfYe1coARBnPJkxWjWsWnan4=
-
 tsutils@^3.17.1:
   version "3.17.1"
   resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759"
@@ -6437,16 +8742,36 @@ typedarray-to-buffer@^3.1.5:
   dependencies:
     is-typedarray "^1.0.0"
 
+typedarray@^0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
+  integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
+
 typescript@^4.0.2:
   version "4.0.2"
   resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2"
   integrity sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ==
 
+uc.micro@^1.0.1, uc.micro@^1.0.5:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.6.tgz#9c411a802a409a91fc6cf74081baba34b24499ac"
+  integrity sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==
+
+uid-number@0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81"
+  integrity sha1-DqEOgDXo61uOREnwbaHHMGY7qoE=
+
 ultron@1.0.x:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.0.2.tgz#ace116ab557cd197386a4e88f4685378c8b2e4fa"
   integrity sha1-rOEWq1V80Zc4ak6I9GhTeMiy5Po=
 
+umask@^1.1.0, umask@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/umask/-/umask-1.1.0.tgz#f29cebf01df517912bb58ff9c4e50fde8e33320d"
+  integrity sha1-8pzr8B31F5ErtY/5xOUP3o4zMg0=
+
 unicode-canonical-property-names-ecmascript@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
@@ -6480,6 +8805,27 @@ union-value@^1.0.0:
     is-extendable "^0.1.1"
     set-value "^2.0.1"
 
+unique-filename@^1.1.0, unique-filename@^1.1.1, unique-filename@~1.1.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230"
+  integrity sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ==
+  dependencies:
+    unique-slug "^2.0.0"
+
+unique-slug@^2.0.0:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-2.0.2.tgz#baabce91083fc64e945b0f3ad613e264f7cd4e6c"
+  integrity sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w==
+  dependencies:
+    imurmurhash "^0.1.4"
+
+unique-string@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a"
+  integrity sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=
+  dependencies:
+    crypto-random-string "^1.0.0"
+
 universalify@^0.1.0:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
@@ -6498,6 +8844,27 @@ unset-value@^1.0.0:
     has-value "^0.3.1"
     isobject "^3.0.0"
 
+unzip-response@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
+  integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=
+
+update-notifier@^2.2.0, update-notifier@^2.3.0, update-notifier@^2.5.0:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6"
+  integrity sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw==
+  dependencies:
+    boxen "^1.2.1"
+    chalk "^2.0.1"
+    configstore "^3.0.0"
+    import-lazy "^2.1.0"
+    is-ci "^1.0.10"
+    is-installed-globally "^0.1.0"
+    is-npm "^1.0.0"
+    latest-version "^3.0.0"
+    semver-diff "^2.0.0"
+    xdg-basedir "^3.0.0"
+
 uri-js@^4.2.2:
   version "4.2.2"
   resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
@@ -6510,6 +8877,13 @@ urix@^0.1.0:
   resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
   integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
 
+url-parse-lax@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73"
+  integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=
+  dependencies:
+    prepend-http "^1.0.1"
+
 use@^3.1.0:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
@@ -6520,6 +8894,18 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1:
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
 
+util-extend@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/util-extend/-/util-extend-1.0.3.tgz#a7c216d267545169637b3b6edc6ca9119e2ff93f"
+  integrity sha1-p8IW0mdUUWljeztu3GypEZ4v+T8=
+
+util-promisify@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/util-promisify/-/util-promisify-2.1.0.tgz#3c2236476c4d32c5ff3c47002add7c13b9a82a53"
+  integrity sha1-PCI2R2xNMsX/PEcAKt18E7moKlM=
+  dependencies:
+    object.getownpropertydescriptors "^2.0.3"
+
 utils-extend@^1.0.4, utils-extend@^1.0.6, utils-extend@^1.0.7:
   version "1.0.8"
   resolved "https://registry.yarnpkg.com/utils-extend/-/utils-extend-1.0.8.tgz#ccfd7b64540f8e90ee21eec57769d0651cab8a5f"
@@ -6530,7 +8916,7 @@ utils-merge@1.0.1:
   resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
   integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
 
-uuid@^3.3.2:
+uuid@^3.2.1, uuid@^3.3.2:
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
   integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
@@ -6554,7 +8940,7 @@ v8-to-istanbul@^5.0.1:
     convert-source-map "^1.6.0"
     source-map "^0.7.3"
 
-validate-npm-package-license@^3.0.1:
+validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.3:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
   integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==
@@ -6562,6 +8948,13 @@ validate-npm-package-license@^3.0.1:
     spdx-correct "^3.0.0"
     spdx-expression-parse "^3.0.0"
 
+validate-npm-package-name@^3.0.0, validate-npm-package-name@~3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-3.0.0.tgz#5fa912d81eb7d0c74afc140de7317f0ca7df437e"
+  integrity sha1-X6kS2B630MdK/BQN5zF/DKffQ34=
+  dependencies:
+    builtins "^1.0.3"
+
 value-equal@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c"
@@ -6581,6 +8974,11 @@ verror@1.10.0:
     core-util-is "1.0.2"
     extsprintf "^1.2.0"
 
+void-elements@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
+  integrity sha1-wGavtYK7HLQSjWDqkjkulNXp2+w=
+
 w3c-hr-time@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd"
@@ -6610,6 +9008,13 @@ watch@^1.0.1:
     exec-sh "^0.2.0"
     minimist "^1.2.0"
 
+wcwidth@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8"
+  integrity sha1-8LDc+RW8X/FSivrbLA4XtTLaL+g=
+  dependencies:
+    defaults "^1.0.3"
+
 webidl-conversions@^5.0.0:
   version "5.0.0"
   resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"
@@ -6651,7 +9056,7 @@ which-pm-runs@^1.0.0:
   resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb"
   integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=
 
-which@^1.2.9:
+which@1, which@^1.2.9, which@^1.3.0, which@^1.3.1, which@~1.3.0:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
   integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
@@ -6665,11 +9070,49 @@ which@^2.0.1, which@^2.0.2:
   dependencies:
     isexe "^2.0.0"
 
+wide-align@^1.1.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
+  integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==
+  dependencies:
+    string-width "^1.0.2 || 2"
+
+widest-line@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc"
+  integrity sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA==
+  dependencies:
+    string-width "^2.1.1"
+
 word-wrap@^1.2.3, word-wrap@~1.2.3:
   version "1.2.3"
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
+worker-farm@^1.6.0:
+  version "1.7.0"
+  resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8"
+  integrity sha512-rvw3QTZc8lAxyVrqcSGVm5yP/IJ2UcB3U0graE3LCFoZ0Yn2x4EoVSqJKdB/T5M+FLcRPjz4TDacRf3OCfNUzw==
+  dependencies:
+    errno "~0.1.7"
+
+wrap-ansi@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-2.1.0.tgz#d8fc3d284dd05794fe84973caecdd1cf824fdd85"
+  integrity sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=
+  dependencies:
+    string-width "^1.0.1"
+    strip-ansi "^3.0.1"
+
+wrap-ansi@^5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09"
+  integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==
+  dependencies:
+    ansi-styles "^3.2.0"
+    string-width "^3.0.0"
+    strip-ansi "^5.0.0"
+
 wrap-ansi@^6.2.0:
   version "6.2.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
@@ -6679,11 +9122,20 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
 
-wrappy@1:
+wrappy@1, wrappy@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
+write-file-atomic@^2.0.0, write-file-atomic@^2.3.0:
+  version "2.4.3"
+  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.3.tgz#1fd2e9ae1df3e75b8d8c367443c692d4ca81f481"
+  integrity sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==
+  dependencies:
+    graceful-fs "^4.1.11"
+    imurmurhash "^0.1.4"
+    signal-exit "^3.0.2"
+
 write-file-atomic@^3.0.0:
   version "3.0.3"
   resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8"
@@ -6709,11 +9161,16 @@ ws@^1.1.1:
     options ">=0.0.5"
     ultron "1.0.x"
 
-ws@^7.2.3:
+ws@^7.2.3, ws@^7.3.1:
   version "7.3.1"
   resolved "https://registry.yarnpkg.com/ws/-/ws-7.3.1.tgz#d0547bf67f7ce4f12a72dfe31262c68d7dc551c8"
   integrity sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==
 
+xdg-basedir@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
+  integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=
+
 xml-name-validator@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
@@ -6724,16 +9181,44 @@ xmlchars@^2.2.0:
   resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
   integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
 
+xtend@~4.0.1:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
+  integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
+
+y18n@^3.2.1:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"
+  integrity sha1-bRX7qITAhnnA136I53WegR4H+kE=
+
 y18n@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
   integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==
 
-yaml@^1.7.2:
+yallist@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
+  integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
+
+yallist@^3.0.0, yallist@^3.0.2, yallist@^3.0.3:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
+  integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
+
+yaml@^1.10.0, yaml@^1.7.2:
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e"
   integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg==
 
+yargs-parser@^15.0.1:
+  version "15.0.1"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.1.tgz#54786af40b820dcb2fb8025b11b4d659d76323b3"
+  integrity sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw==
+  dependencies:
+    camelcase "^5.0.0"
+    decamelize "^1.2.0"
+
 yargs-parser@^18.1.2:
   version "18.1.3"
   resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0"
@@ -6742,6 +9227,30 @@ yargs-parser@^18.1.2:
     camelcase "^5.0.0"
     decamelize "^1.2.0"
 
+yargs-parser@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-7.0.0.tgz#8d0ac42f16ea55debd332caf4c4038b3e3f5dfd9"
+  integrity sha1-jQrELxbqVd69MyyvTEA4s+P139k=
+  dependencies:
+    camelcase "^4.1.0"
+
+yargs@^14.2.3:
+  version "14.2.3"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-14.2.3.tgz#1a1c3edced1afb2a2fea33604bc6d1d8d688a414"
+  integrity sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==
+  dependencies:
+    cliui "^5.0.0"
+    decamelize "^1.2.0"
+    find-up "^3.0.0"
+    get-caller-file "^2.0.1"
+    require-directory "^2.1.1"
+    require-main-filename "^2.0.0"
+    set-blocking "^2.0.0"
+    string-width "^3.0.0"
+    which-module "^2.0.0"
+    y18n "^4.0.0"
+    yargs-parser "^15.0.1"
+
 yargs@^15.3.1:
   version "15.4.1"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
@@ -6759,6 +9268,25 @@ yargs@^15.3.1:
     y18n "^4.0.0"
     yargs-parser "^18.1.2"
 
+yargs@^8.0.2:
+  version "8.0.2"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-8.0.2.tgz#6299a9055b1cefc969ff7e79c1d918dceb22c360"
+  integrity sha1-YpmpBVsc78lp/355wdkY3Osiw2A=
+  dependencies:
+    camelcase "^4.1.0"
+    cliui "^3.2.0"
+    decamelize "^1.1.1"
+    get-caller-file "^1.0.1"
+    os-locale "^2.0.0"
+    read-pkg-up "^2.0.0"
+    require-directory "^2.1.1"
+    require-main-filename "^1.0.1"
+    set-blocking "^2.0.0"
+    string-width "^2.0.0"
+    which-module "^2.0.0"
+    y18n "^3.2.1"
+    yargs-parser "^7.0.0"
+
 yn@3.1.1:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"