diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 3087d8f29..755378649 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -175,14 +175,15 @@ the pill to have the new files/hash. For most things, it is sufficient to run However, if you've made a change to Landscape's JS, then you will need to build a "glob" and upload it to bootstrap.urbit.org. To do this, run `npm install; npm run build:prod` in `pkg/interface`, and add the resulting -`pkg/arvo/app/landscape/index.js` to a fakezod at that path (or just create a +`pkg/arvo/app/landscape/index.[hash].js` to a fakezod at that path (or just create a new fakezod with `urbit -F zod -B bin/solid.pill -A pkg/arvo`). Run `:glob|make`, and this will output a file in `fakezod/.urb/put/glob-0vXXX.glob`. Upload this file to bootstrap.urbit.org, and modify `+hash` at the top of -`pkg/arvo/app/glob.hoon` to match the hash in the filename. Do not commit the -produced `index.js` and make sure it doesn't end up in your pills (they should -be less than 10MB each). +`pkg/arvo/app/glob.hoon` to match the hash in the filename of the `.glob` file. +Amend `pkg/arvo/app/landscape/index.html` to import the hashed JS bundle, instead +of the unversioned index.js. Do not commit the produced `index.js` and +make sure it doesn't end up in your pills (they should be less than 10MB each). ### Tag the resulting commit diff --git a/bin/solid.pill b/bin/solid.pill index b4a336f3e..ad04bed3c 100644 --- a/bin/solid.pill +++ b/bin/solid.pill @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d7088528dbfd54a913921ade093251d678c4ccebfd0ad85ef2022520266b3954 -size 16451173 +oid sha256:ef417c3092dc32d6d5897a7ba63f3f8910f928f0aa23adf3a356b88ce027a415 +size 6260173 diff --git a/pkg/arvo/app/file-server.hoon b/pkg/arvo/app/file-server.hoon index fdcf7bfd4..89f4da8ff 100644 --- a/pkg/arvo/app/file-server.hoon +++ b/pkg/arvo/app/file-server.hoon @@ -7,17 +7,20 @@ $% [%clay =path] [%glob =glob:glob] == -+$ state-1 - $: %1 - =configuration:srv ++$ state-base + $: =configuration:srv =serving == ++$ state-2 + $: %2 + state-base + == -- :: %+ verb | %- agent:dbug :: -=| state-1 +=| state-2 =* state - ^- agent:gall |_ =bowl:gall @@ -60,12 +63,18 @@ ^- [content ?] [[%clay clay-path] public] == - ?> ?=(%1 -.old-state) + =? old-state ?=(%1 -.old-state) + %= old-state + - %2 + serving (~(del by serving.old-state) /'~landscape'/js/index) + == + ?> ?=(%2 -.old-state) [~ this(state old-state)] :: +$ versioned-state - $% state-1 - state-0 + $% state-0 + state-1 + state-2 == :: +$ serving-0 (map url-base=path [=clay=path public=?]) @@ -74,6 +83,10 @@ =configuration:srv =serving-0 == + +$ state-1 + $: %1 + state-base + == -- :: ++ on-poke @@ -169,7 +182,7 @@ ?~ content [not-found:gen %.n] ?- -.content.u.content %clay - =/ scry-path + =/ scry-path=path :* (scot %p our.bowl) q.byk.bowl (scot %da now.bowl) @@ -179,10 +192,16 @@ =/ file (as-octs:mimes:html .^(@ %cx scry-path)) :_ public.u.content ?+ ext.req-line not-found:gen - [~ %html] (html-response:gen file) [~ %js] (js-response:gen file) [~ %css] (css-response:gen file) [~ %png] (png-response:gen file) + :: + [~ %html] + %. file + %* . html-response:gen + cache + !=(/app/landscape/index/html (slag 3 scry-path)) + == == :: %glob diff --git a/pkg/arvo/app/glob.hoon b/pkg/arvo/app/glob.hoon index 328264bb7..846fe5b6e 100644 --- a/pkg/arvo/app/glob.hoon +++ b/pkg/arvo/app/glob.hoon @@ -1,7 +1,7 @@ /- glob /+ default-agent, verb, dbug |% -++ hash 0v3.cus8h.vc64c.rfb3t.22oji.b529a +++ hash 0v7.foe2o.ang8k.28dnr.fudi0.74c8d +$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))] +$ all-states $% state-0 @@ -41,7 +41,7 @@ -- =| state=state-0 =. hash.state hash -=/ serve-path=path /'~landscape'/js/index +=/ serve-path=path /'~landscape'/js/bundle ^- agent:gall %+ verb | %- agent:dbug @@ -82,9 +82,19 @@ :_ this =/ home=path /(scot %p our.bowl)/home/(scot %da now.bowl) =+ .^(=tube:clay %cc (weld home /js/mime)) - =+ .^(js=@t %cx (weld home /app/landscape/js/index/js)) + =+ .^(arch %cy (weld home /app/landscape/js/bundle)) + =/ bundle=path + %- need + ^- (unit path) + %- ~(rep by dir) + |= [[file=@t ~] out=(unit path)] + ?^ out out + ?. =((end 3 5 file) 'index') + ~ + `/[file]/js + =+ .^(js=@t %cx :(weld home /app/landscape/js/bundle bundle)) =+ !<(=mime (tube !>(js))) - =/ =glob:glob (~(put by *glob:glob) /js mime) + =/ =glob:glob (~(put by *glob:glob) bundle mime) =/ =path /(cat 3 'glob-' (scot %uv (sham glob)))/glob [%pass /make %agent [our.bowl %hood] %poke %drum-put !>([path (jam glob)])]~ :: diff --git a/pkg/arvo/app/landscape/img/groups.png b/pkg/arvo/app/landscape/img/groups.png new file mode 100644 index 000000000..fa14f36da Binary files /dev/null and b/pkg/arvo/app/landscape/img/groups.png differ diff --git a/pkg/arvo/app/landscape/img/icon-home.png b/pkg/arvo/app/landscape/img/icon-home.png index 04b1e7b87..9eb1d0289 100644 Binary files a/pkg/arvo/app/landscape/img/icon-home.png and b/pkg/arvo/app/landscape/img/icon-home.png differ diff --git a/pkg/arvo/app/landscape/index.html b/pkg/arvo/app/landscape/index.html index 844b9df50..d728d39f3 100644 --- a/pkg/arvo/app/landscape/index.html +++ b/pkg/arvo/app/landscape/index.html @@ -4,11 +4,11 @@ OS1 - - - - + content="width=device-width, initial-scale=1, shrink-to-fit=no,maximum-scale=1"/> + + + + -
+
- + diff --git a/pkg/arvo/app/publish.hoon b/pkg/arvo/app/publish.hoon index 17470e7e5..2ed386584 100644 --- a/pkg/arvo/app/publish.hoon +++ b/pkg/arvo/app/publish.hoon @@ -1831,6 +1831,8 @@ :: %subscribe ?> (team:title our.bol src.bol) + ?: =(our.bol who.act) + [~ state] =/ join-wire=wire /join-group/[(scot %p who.act)]/[book.act] =/ meta=(unit (set path)) diff --git a/pkg/arvo/lib/server.hoon b/pkg/arvo/lib/server.hoon index 4a0b7ded0..d51fa12c8 100644 --- a/pkg/arvo/lib/server.hoon +++ b/pkg/arvo/lib/server.hoon @@ -80,9 +80,11 @@ ++ max-1-wk ['cache-control' 'max-age=604800'] :: ++ html-response + =| cache=? |= =octs ^- simple-payload:http - [[200 [['content-type' 'text/html'] max-1-wk ~]] `octs] + :_ `octs + [200 [['content-type' 'text/html'] ?:(cache [max-1-wk ~] ~)]] :: ++ js-response |= =octs diff --git a/pkg/interface/.eslintrc.js b/pkg/interface/.eslintrc.js new file mode 100644 index 000000000..0f63724ca --- /dev/null +++ b/pkg/interface/.eslintrc.js @@ -0,0 +1,186 @@ +const env = { + "browser": true, + "es6": true, + "node": true +}; + +const rules = { + "array-bracket-spacing": ["error", "never"], + "arrow-parens": [ + "error", + "as-needed", + { + "requireForBlockBody": true + } + ], + "arrow-spacing": "error", + "block-spacing": ["error", "always"], + "brace-style": ["error", "1tbs"], + "camelcase": [ + "error", + { + "properties": "never" + } + ], + "comma-dangle": ["error", "never"], + "eol-last": ["error", "always"], + "func-name-matching": "error", + "indent": [ + "off", + 2, + { + "ArrayExpression": "off", + "SwitchCase": 1, + "CallExpression": { + "arguments": "off" + }, + "FunctionDeclaration": { + "parameters": "off" + }, + "FunctionExpression": { + "parameters": "off" + }, + "MemberExpression": "off", + "ObjectExpression": "off", + "ImportDeclaration": "off" + } + ], + "handle-callback-err": "off", + "linebreak-style": ["error", "unix"], + "max-lines": [ + "error", + { + "max": 300, + "skipBlankLines": true, + "skipComments": true + } + ], + "max-lines-per-function": [ + "warn", + { + "skipBlankLines": true, + "skipComments": true + } + ], + "max-statements-per-line": [ + "error", + { + "max": 1 + } + ], + "new-cap": [ + "error", + { + "newIsCap": true, + "capIsNew": false + } + ], + "new-parens": "error", + "no-buffer-constructor": "error", + "no-console": "off", + "no-extra-semi": "off", + "no-fallthrough": "off", + "no-func-assign": "off", + "no-implicit-coercion": "error", + "no-multi-assign": "error", + "no-multiple-empty-lines": [ + "error", + { + "max": 1 + } + ], + "no-nested-ternary": "error", + "no-param-reassign": "off", + "no-return-assign": "error", + "no-return-await": "off", + "no-shadow-restricted-names": "error", + "no-tabs": "error", + "no-trailing-spaces": "error", + "no-unused-vars": [ + "error", + { + "vars": "all", + "args": "none", + "ignoreRestSiblings": false + } + ], + "no-use-before-define": [ + "error", + { + "functions": false, + "classes": false + } + ], + "no-useless-escape": "off", + "no-var": "error", + "nonblock-statement-body-position": ["error", "below"], + "object-curly-spacing": ["error", "always"], + "padded-blocks": ["error", "never"], + "prefer-arrow-callback": "error", + "prefer-const": [ + "error", + { + "destructuring": "all", + "ignoreReadBeforeAssign": true + } + ], + "prefer-template": "off", + "quotes": ["error", "single"], + "semi": ["error", "always"], + "spaced-comment": [ + "error", + "always", + { + "exceptions": ["!"] + } + ], + "space-before-blocks": "error", + "unicode-bom": ["error", "never"], + "valid-jsdoc": "error", + "wrap-iife": ["error", "inside"], + "react/jsx-closing-bracket-location": 1, + "react/jsx-tag-spacing": 1, + "react/jsx-max-props-per-line": ["error", { "maximum": 2, "when": "multiline" }], + "react/prop-types": 0 +}; + +module.exports = { + "env": env, + "extends": [ + "plugin:react/recommended", + "eslint:recommended", + ], + "settings": { + "react": { + "version": "^16.5.2" + } + }, + "parser": "babel-eslint", + "parserOptions": { + "ecmaVersion": 10, + "requireConfigFile": false, + "sourceType": "module" + }, + "root": true, + "rules": rules, + "overrides": [ + { + "files": ["**/*.ts", "**/*.tsx"], + "env": env, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/eslint-recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { "jsx": true }, + "ecmaVersion": 10, + "requireConfigFile": false, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint"], + "rules": rules + } + ] +}; diff --git a/pkg/interface/.eslintrc.json b/pkg/interface/.eslintrc.json deleted file mode 100644 index 87ab40377..000000000 --- a/pkg/interface/.eslintrc.json +++ /dev/null @@ -1,147 +0,0 @@ -{ - "env": { - "browser": true, - "es6": true, - "node": true - }, - "extends": [ - "eslint:recommended", - "plugin:react/recommended" - ], - "settings": { - "react": { - "version": "^16.5.2" - } - }, - "parser": "babel-eslint", - "parserOptions": { - "ecmaVersion": 10, - "requireConfigFile": false, - "sourceType": "module" - }, - "root": true, - "rules": { - "array-bracket-spacing": ["error", "never"], - "arrow-parens": [ - "error", - "as-needed", - { - "requireForBlockBody": true - } - ], - "arrow-spacing": "error", - "block-spacing": ["error", "always"], - "brace-style": ["error", "1tbs"], - "camelcase": [ - "error", - { - "properties": "never" - } - ], - "comma-dangle": ["error", "never"], - "eol-last": ["error", "always"], - "func-name-matching": "error", - "indent": [ - "off", - 2, - { - "ArrayExpression": "off", - "SwitchCase": 1, - "CallExpression": { - "arguments": "off" - }, - "FunctionDeclaration": { - "parameters": "off" - }, - "FunctionExpression": { - "parameters": "off" - }, - "MemberExpression": "off", - "ObjectExpression": "off", - "ImportDeclaration": "off" - } - ], - "handle-callback-err": "off", - "linebreak-style": ["error", "unix"], - "max-statements-per-line": [ - "error", - { - "max": 1 - } - ], - "new-cap": [ - "error", - { - "newIsCap": true, - "capIsNew": false - } - ], - "new-parens": "error", - "no-buffer-constructor": "error", - "no-console": "off", - "no-extra-semi": "off", - "no-fallthrough": "off", - "no-func-assign": "off", - "no-implicit-coercion": "error", - "no-multi-assign": "error", - "no-multiple-empty-lines": [ - "error", - { - "max": 1 - } - ], - "no-nested-ternary": "error", - "no-param-reassign": "off", - "no-return-assign": "error", - "no-return-await": "off", - "no-shadow-restricted-names": "error", - "no-tabs": "error", - "no-trailing-spaces": "error", - "no-unused-vars": [ - "error", - { - "vars": "all", - "args": "none", - "ignoreRestSiblings": false - } - ], - "no-use-before-define": [ - "error", - { - "functions": false, - "classes": false - } - ], - "no-useless-escape": "off", - "no-var": "error", - "nonblock-statement-body-position": ["error", "below"], - "object-curly-spacing": ["error", "always"], - "padded-blocks": ["error", "never"], - "prefer-arrow-callback": "error", - "prefer-const": [ - "error", - { - "destructuring": "all", - "ignoreReadBeforeAssign": true - } - ], - "prefer-template": "off", - "quotes": ["error", "single"], - "semi": ["error", "always"], - "spaced-comment": [ - "error", - "always", - { - "exceptions": ["!"] - } - ], - "space-before-blocks": "error", - "unicode-bom": ["error", "never"], - "valid-jsdoc": "error", - "wrap-iife": ["error", "inside"], - "react/jsx-closing-bracket-location": 1, - "react/jsx-tag-spacing": 1, - "react/jsx-max-props-per-line": ["error", { "maximum": 2, "when": "multiline" }], - "react/prop-types": 0 - } -} diff --git a/pkg/interface/config/webpack.dev.js b/pkg/interface/config/webpack.dev.js index 3d5c08d31..e2cb44ea4 100644 --- a/pkg/interface/config/webpack.dev.js +++ b/pkg/interface/config/webpack.dev.js @@ -52,7 +52,7 @@ if(urbitrc.URL) { ...devServer, index: '', proxy: { - '/~landscape/js/index.js': { + '/~landscape/js/bundle/index.*.js': { target: 'http://localhost:9000', pathRewrite: (req, path) => '/index.js' }, diff --git a/pkg/interface/config/webpack.prod.js b/pkg/interface/config/webpack.prod.js index 10525a526..7a0e5a2e7 100644 --- a/pkg/interface/config/webpack.prod.js +++ b/pkg/interface/config/webpack.prod.js @@ -1,6 +1,6 @@ const path = require('path'); // const HtmlWebpackPlugin = require('html-webpack-plugin'); -// const { CleanWebpackPlugin } = require('clean-webpack-plugin'); +const { CleanWebpackPlugin } = require('clean-webpack-plugin'); module.exports = { mode: 'production', @@ -49,17 +49,16 @@ module.exports = { // historyApiFallback: true // }, plugins: [ - // new CleanWebpackPlugin(), + new CleanWebpackPlugin(), // new HtmlWebpackPlugin({ // title: 'Hot Module Replacement', // template: './public/index.html', // }), ], output: { - filename: 'index.js', - chunkFilename: 'index.js', - path: path.resolve(__dirname, '../../arvo/app/landscape/js'), - publicPath: '/' + filename: 'index.[contenthash].js', + path: path.resolve(__dirname, '../../arvo/app/landscape/js/bundle'), + publicPath: '/', }, optimization: { minimize: true, diff --git a/pkg/interface/package-lock.json b/pkg/interface/package-lock.json index 27a526c50..c3f717ee9 100644 --- a/pkg/interface/package-lock.json +++ b/pkg/interface/package-lock.json @@ -1660,6 +1660,12 @@ "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "dev": true }, + "@types/eslint-visitor-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", + "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", + "dev": true + }, "@types/events": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", @@ -1689,6 +1695,12 @@ "integrity": "sha512-iYCgjm1dGPRuo12+BStjd1HiVQqhlRhWDOQigNxn023HcjnhsiFz9pc6CzJj4HwDCSQca9bxTL4PxJDbkdm3PA==", "dev": true }, + "@types/json-schema": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.5.tgz", + "integrity": "sha512-7+2BITlgjgDhH0vvwZU/HZJVyk+2XUlvxXe8dFMedNX/aMkaOq++rMAFXc0tM7ij15QaWlbdQASBR9dihi+bDQ==", + "dev": true + }, "@types/lodash": { "version": "4.14.155", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.155.tgz", @@ -1795,6 +1807,93 @@ "source-map": "^0.6.1" } }, + "@typescript-eslint/eslint-plugin": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-3.8.0.tgz", + "integrity": "sha512-lFb4VCDleFSR+eo4Ew+HvrJ37ZH1Y9ZyE+qyP7EiwBpcCVxwmUc5PAqhShCQ8N8U5vqYydm74nss+a0wrrCErw==", + "dev": true, + "requires": { + "@typescript-eslint/experimental-utils": "3.8.0", + "debug": "^4.1.1", + "functional-red-black-tree": "^1.0.1", + "regexpp": "^3.0.0", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + }, + "dependencies": { + "semver": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", + "dev": true + } + } + }, + "@typescript-eslint/experimental-utils": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-3.8.0.tgz", + "integrity": "sha512-o8T1blo1lAJE0QDsW7nSyvZHbiDzQDjINJKyB44Z3sSL39qBy5L10ScI/XwDtaiunoyKGLiY9bzRk4YjsUZl8w==", + "dev": true, + "requires": { + "@types/json-schema": "^7.0.3", + "@typescript-eslint/types": "3.8.0", + "@typescript-eslint/typescript-estree": "3.8.0", + "eslint-scope": "^5.0.0", + "eslint-utils": "^2.0.0" + } + }, + "@typescript-eslint/parser": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-3.8.0.tgz", + "integrity": "sha512-u5vjOBaCsnMVQOvkKCXAmmOhyyMmFFf5dbkM3TIbg3MZ2pyv5peE4gj81UAbTHwTOXEwf7eCQTUMKrDl/+qGnA==", + "dev": true, + "requires": { + "@types/eslint-visitor-keys": "^1.0.0", + "@typescript-eslint/experimental-utils": "3.8.0", + "@typescript-eslint/types": "3.8.0", + "@typescript-eslint/typescript-estree": "3.8.0", + "eslint-visitor-keys": "^1.1.0" + } + }, + "@typescript-eslint/types": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-3.8.0.tgz", + "integrity": "sha512-8kROmEQkv6ss9kdQ44vCN1dTrgu4Qxrd2kXr10kz2NP5T8/7JnEfYNxCpPkArbLIhhkGLZV3aVMplH1RXQRF7Q==", + "dev": true + }, + "@typescript-eslint/typescript-estree": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-3.8.0.tgz", + "integrity": "sha512-MTv9nPDhlKfclwnplRNDL44mP2SY96YmPGxmMbMy6x12I+pERcxpIUht7DXZaj4mOKKtet53wYYXU0ABaiXrLw==", + "dev": true, + "requires": { + "@typescript-eslint/types": "3.8.0", + "@typescript-eslint/visitor-keys": "3.8.0", + "debug": "^4.1.1", + "glob": "^7.1.6", + "is-glob": "^4.0.1", + "lodash": "^4.17.15", + "semver": "^7.3.2", + "tsutils": "^3.17.1" + }, + "dependencies": { + "semver": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==", + "dev": true + } + } + }, + "@typescript-eslint/visitor-keys": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-3.8.0.tgz", + "integrity": "sha512-gfqQWyVPpT9NpLREXNR820AYwgz+Kr1GuF3nf1wxpHD6hdxI62tq03ToomFnDxY0m3pUB39IF7sil7D5TQexLA==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + } + }, "@webassemblyjs/ast": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.9.0.tgz", @@ -1993,9 +2092,9 @@ } }, "acorn": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.1.1.tgz", - "integrity": "sha512-add7dgA5ppRPxCFJoAGfMDi7PIBXq1RtGo7BhbLaxwrXPOmw8gq48Y9ozT01hUKy9byMjlR20EJhu5zlkErEkg==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.0.tgz", + "integrity": "sha512-+G7P8jJmCHr+S+cLfQxygbWhXy+8YTVGzAkpEbcLo2mLoL7tij/VG41QSHACSf5QgYRhMZYHuNc6drJaO0Da+w==", "dev": true }, "acorn-jsx": { @@ -2931,9 +3030,9 @@ } }, "cli-width": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.1.tgz", - "integrity": "sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", "dev": true }, "cliui": { @@ -3960,6 +4059,15 @@ } } }, + "eslint-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz", + "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^1.1.0" + } + }, "globals": { "version": "12.4.0", "resolved": "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz", @@ -3975,6 +4083,12 @@ "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", "dev": true }, + "regexpp": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", + "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", + "dev": true + }, "shebang-command": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", @@ -4033,9 +4147,9 @@ } }, "eslint-scope": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.0.0.tgz", - "integrity": "sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.0.tgz", + "integrity": "sha512-iiGRvtxWqgtx5m8EyQUJihBloE4EnYeGE/bz1wSPwJE6tZuJUtHlhqDM4Xj2ukE8Dyy1+HCZ4hE0fzIVMzb58w==", "dev": true, "requires": { "esrecurse": "^4.1.0", @@ -4043,9 +4157,9 @@ } }, "eslint-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.4.3.tgz", - "integrity": "sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", "dev": true, "requires": { "eslint-visitor-keys": "^1.1.0" @@ -4084,9 +4198,9 @@ }, "dependencies": { "estraverse": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.1.0.tgz", - "integrity": "sha512-FyohXK+R0vE+y1nHLoBM7ZTyqRpqAlhdZHCWIWEviFLiGB8b04H6bQs8G+XTthacvT8VuwvteiP7RJSxMs8UEw==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz", + "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==", "dev": true } } @@ -5409,21 +5523,21 @@ "dev": true }, "inquirer": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.1.0.tgz", - "integrity": "sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz", + "integrity": "sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==", "dev": true, "requires": { "ansi-escapes": "^4.2.1", - "chalk": "^3.0.0", + "chalk": "^4.1.0", "cli-cursor": "^3.1.0", - "cli-width": "^2.0.0", + "cli-width": "^3.0.0", "external-editor": "^3.0.3", "figures": "^3.0.0", - "lodash": "^4.17.15", + "lodash": "^4.17.19", "mute-stream": "0.0.8", "run-async": "^2.4.0", - "rxjs": "^6.5.3", + "rxjs": "^6.6.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0", "through": "^2.3.6" @@ -5440,9 +5554,9 @@ } }, "chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", + "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", "dev": true, "requires": { "ansi-styles": "^4.1.0", @@ -5470,6 +5584,12 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "lodash": { + "version": "4.17.19", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", + "dev": true + }, "strip-ansi": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz", @@ -5792,9 +5912,9 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "js-yaml": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", - "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz", + "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==", "dev": true, "requires": { "argparse": "^1.0.7", @@ -6380,6 +6500,11 @@ "resolved": "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz", "integrity": "sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==" }, + "mousetrap-global-bind": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mousetrap-global-bind/-/mousetrap-global-bind-1.1.0.tgz", + "integrity": "sha1-zX3pIivQZG+i4BDVTISnTCaojt0=" + }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -6774,9 +6899,9 @@ } }, "onetime": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.0.tgz", - "integrity": "sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.1.tgz", + "integrity": "sha512-ZpZpjcJeugQfWsfyQlshVoowIIQ1qBGSVll4rfDq6JJVO//fesjoX808hXWfBjY+ROZgpKDI5TRSRBSoJiZ8eg==", "dev": true, "requires": { "mimic-fn": "^2.1.0" @@ -7661,9 +7786,9 @@ } }, "regexpp": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-2.0.1.tgz", - "integrity": "sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz", + "integrity": "sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==", "dev": true }, "regexpu-core": { @@ -7982,9 +8107,9 @@ } }, "rxjs": { - "version": "6.5.5", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz", - "integrity": "sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ==", + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.2.tgz", + "integrity": "sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg==", "dev": true, "requires": { "tslib": "^1.9.0" @@ -8826,9 +8951,9 @@ "dev": true }, "strip-json-comments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.0.tgz", - "integrity": "sha512-e6/d0eBu7gHtdCqFt0xJr642LdToM5/cN4Qb9DbHjVx1CP5RyeM+zH7pbecEmDv/lBqb0QH+6Uqq75rxFPkM0w==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true }, "style-loader": { @@ -9187,6 +9312,15 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz", "integrity": "sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==" }, + "tsutils": { + "version": "3.17.1", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz", + "integrity": "sha512-kzeQ5B8H3w60nFY2g8cJIuH7JDpsALXySGtwGJ0p2LSjLgay3NdIpqq5SoOBe46bKDW2iq25irHCr8wjomUS2g==", + "dev": true, + "requires": { + "tslib": "^1.8.1" + } + }, "tty-browserify": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", @@ -9224,6 +9358,12 @@ "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", "dev": true }, + "typescript": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.7.tgz", + "integrity": "sha512-BLbiRkiBzAwsjut4x/dsibSTB6yWpwT5qWmC2OfuCg3GgVQCSgMs4vEctYPhsaGtd0AeuuHMkjZ2h2WG8MSzRw==", + "dev": true + }, "unherit": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/unherit/-/unherit-1.1.3.tgz", @@ -9497,9 +9637,9 @@ "dev": true }, "v8-compile-cache": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz", - "integrity": "sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz", + "integrity": "sha512-8OQ9CL+VWyt3JStj7HX7/ciTL2V3Rl1Wf5OL+SNTm0yK1KvtReVulksyeRnCANHHuUxHlQig+JJDlUhBt1NQDQ==", "dev": true }, "value-equal": { diff --git a/pkg/interface/package.json b/pkg/interface/package.json index 1e21e1322..338828766 100644 --- a/pkg/interface/package.json +++ b/pkg/interface/package.json @@ -18,6 +18,7 @@ "markdown-to-jsx": "^6.11.4", "moment": "^2.20.1", "mousetrap": "^1.6.5", + "mousetrap-global-bind": "^1.1.0", "prop-types": "^15.7.2", "react": "^16.5.2", "react-codemirror2": "^6.0.1", @@ -45,6 +46,8 @@ "@types/lodash": "^4.14.155", "@types/react": "^16.9.38", "@types/react-router-dom": "^5.1.5", + "@typescript-eslint/eslint-plugin": "^3.8.0", + "@typescript-eslint/parser": "^3.8.0", "babel-eslint": "^10.1.0", "babel-loader": "^8.1.0", "clean-webpack-plugin": "^3.0.0", @@ -56,12 +59,13 @@ "react-hot-loader": "^4.12.21", "sass": "^1.26.5", "sass-loader": "^8.0.2", + "typescript": "^3.9.7", "webpack": "^4.43.0", "webpack-cli": "^3.3.11", "webpack-dev-server": "^3.10.3" }, "scripts": { - "lint": "eslint ./**/*.js", + "lint": "eslint ./src/**/*.{js,ts,tsx}", "lint-file": "eslint", "tsc": "tsc", "tsc:watch": "tsc --watch", diff --git a/pkg/interface/src/App.js b/pkg/interface/src/App.js deleted file mode 100644 index 94dc9d076..000000000 --- a/pkg/interface/src/App.js +++ /dev/null @@ -1,180 +0,0 @@ -import { hot } from 'react-hot-loader/root'; -import 'react-hot-loader'; -import * as React from 'react'; -import { BrowserRouter as Router, Route, withRouter, Switch } from 'react-router-dom'; -import styled, { ThemeProvider, createGlobalStyle } from 'styled-components'; - -import './css/indigo-static.css'; -import './css/fonts.css'; -import light from './themes/light'; -import dark from './themes/old-dark'; - -import LaunchApp from './apps/launch/app'; -import ChatApp from './apps/chat/app'; -import DojoApp from './apps/dojo/app'; -import GroupsApp from './apps/groups/app'; -import LinksApp from './apps/links/app'; -import PublishApp from './apps/publish/app'; - -import StatusBar from './components/StatusBar'; -import ErrorComponent from './components/Error'; - -import GlobalStore from './store/store'; -import GlobalSubscription from './subscription/global'; -import GlobalApi from './api/global'; - -// const Style = createGlobalStyle` -// ${cssReset} -// html { -// background-color: ${p => p.theme.colors.white}; -// } -// -// strong { -// font-weight: 600; -// } -// `; - -const Root = styled.div` - font-family: ${p => p.theme.fonts.sans}; - height: 100%; - width: 100%; - padding: 0; - margin: 0; -`; - -const Content = styled.div` - height: calc(100% - 45px); -`; - -const StatusBarWithRouter = withRouter(StatusBar); - -class App extends React.Component { - constructor(props) { - super(props); - this.ship = window.ship; - this.store = new GlobalStore(); - this.store.setStateHandler(this.setState.bind(this)); - this.state = this.store.state; - - this.appChannel = new window.channel(); - this.api = new GlobalApi(this.ship, this.appChannel, this.store); - this.subscription = - new GlobalSubscription(this.store, this.api, this.appChannel); - - this.updateTheme = this.updateTheme.bind(this); - } - - componentDidMount() { - this.subscription.start(); - this.themeWatcher = window.matchMedia('(prefers-color-scheme: dark)'); - this.api.local.setDark(this.themeWatcher.matches); - this.themeWatcher.addListener(this.updateTheme); - this.api.local.getBaseHash(); - } - - componentWillUnmount() { - this.themeWatcher.removeListener(this.updateTheme); - } - - updateTheme(e) { - this.api.local.setDark(e.matches); - } - - render() { - const channel = window.channel; - - const associations = this.state.associations ? this.state.associations : { contacts: {} }; - const selectedGroups = this.state.selectedGroups ? this.state.selectedGroups : []; - const { state } = this; - const theme = state.dark ? dark : light; - - return ( - - - - - - - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - - - - - ); - } -} - -export default process.env.NODE_ENV === 'production' ? App : hot(App); - diff --git a/pkg/interface/src/apps/chat/components/chat.tsx b/pkg/interface/src/apps/chat/components/chat.tsx deleted file mode 100644 index a8a94e62b..000000000 --- a/pkg/interface/src/apps/chat/components/chat.tsx +++ /dev/null @@ -1,609 +0,0 @@ -import React, { Component, Fragment } from "react"; -import _ from "lodash"; -import moment from "moment"; - -import { Link, RouteComponentProps } from "react-router-dom"; - -import { ResubscribeElement } from "./lib/resubscribe-element"; -import { BacklogElement } from "./lib/backlog-element"; -import { Message } from "./lib/message"; -import { SidebarSwitcher } from "../../../components/SidebarSwitch"; -import { ChatTabBar } from "./lib/chat-tabbar"; -import { ChatInput } from "./lib/chat-input"; -import { UnreadNotice } from "./lib/unread-notice"; -import { deSig } from "../../../lib/util"; -import { ChatHookUpdate } from "../../../types/chat-hook-update"; -import ChatApi from "../../../api/chat"; -import { Inbox, Envelope } from "../../../types/chat-update"; -import { Contacts } from "../../../types/contact-update"; -import { Path, Patp } from "../../../types/noun"; -import GlobalApi from "../../../api/global"; -import { Association } from "../../../types/metadata-update"; -import {Group} from "../../../types/group-update"; - -function getNumPending(props: any) { - const result = props.pendingMessages.has(props.station) - ? props.pendingMessages.get(props.station).length - : 0; - return result; -} - -const ACTIVITY_TIMEOUT = 60000; // a minute -const DEFAULT_BACKLOG_SIZE = 300; -const MAX_BACKLOG_SIZE = 1000; - -function scrollIsAtTop(container) { - if ( - (navigator.userAgent.includes("Safari") && - navigator.userAgent.includes("Chrome")) || - navigator.userAgent.includes("Firefox") - ) { - return container.scrollTop === 0; - } else if (navigator.userAgent.includes("Safari")) { - return ( - container.scrollHeight + Math.round(container.scrollTop) <= - container.clientHeight + 10 - ); - } else { - return false; - } -} - -function scrollIsAtBottom(container) { - if ( - (navigator.userAgent.includes("Safari") && - navigator.userAgent.includes("Chrome")) || - navigator.userAgent.includes("Firefox") - ) { - return ( - container.scrollHeight - Math.round(container.scrollTop) <= - container.clientHeight + 10 - ); - } else if (navigator.userAgent.includes("Safari")) { - return container.scrollTop === 0; - } else { - return false; - } -} - -type IMessage = Envelope & { pending?: boolean }; - -type ChatScreenProps = RouteComponentProps<{ - ship: Patp; - station: string; -}> & { - chatSynced: ChatHookUpdate; - station: any; - association: Association; - api: GlobalApi; - read: number; - length: number; - inbox: Inbox; - contacts: Contacts; - group: Group; - pendingMessages: Map; - s3: any; - popout: boolean; - sidebarShown: boolean; - chatInitialized: boolean; - envelopes: Envelope[]; -}; - -interface ChatScreenState { - numPages: number; - scrollLocked: boolean; - read: number; - active: boolean; - messages: Map; - lastScrollHeight: number | null; -} - -export class ChatScreen extends Component { - hasAskedForMessages = false; - lastNumPending = 0; - - scrollContainer: HTMLElement | null = null; - - unreadMarker = null; - scrolledToMarker = false; - - activityTimeout: NodeJS.Timeout | null = null; - - scrollElement: HTMLElement | null = null; - - constructor(props) { - super(props); - - this.state = { - numPages: 1, - scrollLocked: false, - read: props.read, - active: true, - messages: new Map(), - // only for FF - lastScrollHeight: null, - }; - - this.onScroll = this.onScroll.bind(this); - - this.setUnreadMarker = this.setUnreadMarker.bind(this); - - this.handleActivity = this.handleActivity.bind(this); - this.setInactive = this.setInactive.bind(this); - - moment.updateLocale("en", { - calendar: { - sameDay: "[Today]", - nextDay: "[Tomorrow]", - nextWeek: "dddd", - lastDay: "[Yesterday]", - lastWeek: "[Last] dddd", - sameElse: "DD/MM/YYYY", - }, - }); - } - - componentDidMount() { - document.addEventListener("mousemove", this.handleActivity, false); - document.addEventListener("mousedown", this.handleActivity, false); - document.addEventListener("keypress", this.handleActivity, false); - document.addEventListener("touchmove", this.handleActivity, false); - this.activityTimeout = setTimeout(this.setInactive, ACTIVITY_TIMEOUT); - } - - componentWillUnmount() { - document.removeEventListener("mousemove", this.handleActivity, false); - document.removeEventListener("mousedown", this.handleActivity, false); - document.removeEventListener("keypress", this.handleActivity, false); - document.removeEventListener("touchmove", this.handleActivity, false); - if (this.activityTimeout) { - clearTimeout(this.activityTimeout); - } - } - - handleActivity() { - if (!this.state.active) { - this.setState({ active: true }); - } - - if (this.activityTimeout) { - clearTimeout(this.activityTimeout); - } - - this.activityTimeout = setTimeout(this.setInactive, ACTIVITY_TIMEOUT); - } - - setInactive() { - this.activityTimeout = null; - this.setState({ active: false, scrollLocked: true }); - } - - receivedNewChat() { - const { props } = this; - this.hasAskedForMessages = false; - - this.unreadMarker = null; - this.scrolledToMarker = false; - - this.setState({ read: props.read }); - - const unread = props.length - props.read; - const unreadUnloaded = unread - props.envelopes.length; - const excessUnread = unreadUnloaded > MAX_BACKLOG_SIZE; - - if (!excessUnread && unreadUnloaded + 20 > DEFAULT_BACKLOG_SIZE) { - this.askForMessages(unreadUnloaded + 20); - } else { - this.askForMessages(DEFAULT_BACKLOG_SIZE); - } - - if (excessUnread || props.read === props.length) { - this.scrolledToMarker = true; - this.setState( - { - scrollLocked: false, - }, - () => { - this.scrollToBottom(); - } - ); - } else { - this.setState({ scrollLocked: true, numPages: Math.ceil(unread / 100) }); - } - } - - componentDidUpdate(prevProps, prevState) { - const { props, state } = this; - - if ( - prevProps.match.params.station !== props.match.params.station || - prevProps.match.params.ship !== props.match.params.ship - ) { - this.receivedNewChat(); - } else if ( - props.chatInitialized && - !(props.station in props.inbox) && - Boolean(props.chatSynced) && - !(props.station in props.chatSynced) - ) { - props.history.push("/~chat"); - } else if (props.envelopes.length >= prevProps.envelopes.length + 10) { - this.hasAskedForMessages = false; - } else if ( - props.length !== prevProps.length && - prevProps.length === prevState.read && - state.active - ) { - this.setState({ read: props.length }); - this.props.api.chat.read(this.props.station); - } - - if (!prevProps.chatInitialized && props.chatInitialized) { - this.receivedNewChat(); - } - - if ( - props.length !== prevProps.length || - props.envelopes.length !== prevProps.envelopes.length || - getNumPending(props) !== this.lastNumPending || - state.numPages !== prevState.numPages - ) { - this.scrollToBottom(); - if (navigator.userAgent.includes("Firefox")) { - this.recalculateScrollTop(); - } - - this.lastNumPending = getNumPending(props); - } - } - - askForMessages(size) { - const { props, state } = this; - - if ( - props.envelopes.length >= props.length || - this.hasAskedForMessages || - props.length <= 0 - ) { - return; - } - - const start = - props.length - props.envelopes[props.envelopes.length - 1].number; - if (start > 0) { - const end = start + size < props.length ? start + size : props.length; - this.hasAskedForMessages = true; - props.api.chat.fetchMessages(start + 1, end, props.station); - } - } - - scrollToBottom() { - if (!this.state.scrollLocked && this.scrollElement) { - this.scrollElement.scrollIntoView(); - } - } - - // Restore chat position on FF when new messages come in - recalculateScrollTop() { - const { lastScrollHeight } = this.state; - if (!this.scrollContainer || !lastScrollHeight) { - return; - } - - const target = this.scrollContainer; - const newScrollTop = this.scrollContainer.scrollHeight - lastScrollHeight; - if (target.scrollTop !== 0 || newScrollTop === target.scrollTop) { - return; - } - target.scrollTop = target.scrollHeight - lastScrollHeight; - } - - onScroll(e) { - if (scrollIsAtTop(e.target)) { - // Save scroll position for FF - if (navigator.userAgent.includes("Firefox")) { - this.setState({ - lastScrollHeight: e.target.scrollHeight, - }); - } - this.setState( - { - numPages: this.state.numPages + 1, - scrollLocked: true, - }, - () => { - this.askForMessages(DEFAULT_BACKLOG_SIZE); - } - ); - } else if (scrollIsAtBottom(e.target)) { - this.dismissUnread(); - this.setState({ - numPages: 1, - scrollLocked: false, - }); - } - } - - setUnreadMarker(ref) { - if (ref && !this.scrolledToMarker) { - this.setState({ scrollLocked: true }, () => { - ref.scrollIntoView({ block: "center" }); - if (ref.offsetParent && scrollIsAtBottom(ref.offsetParent)) { - this.dismissUnread(); - this.setState({ - numPages: 1, - scrollLocked: false, - }); - } - }); - this.scrolledToMarker = true; - } - this.unreadMarker = ref; - } - - dismissUnread() { - this.props.api.chat.read(this.props.station); - } - - chatWindow(unread) { - // Replace with just the "not Firefox" implementation - // when Firefox #1042151 is patched. - - const { props, state } = this; - - let messages: IMessage[] = props.envelopes.slice(0); - const lastMsgNum = messages.length > 0 ? messages.length : 0; - - if (messages.length > 100 * state.numPages) { - messages = messages.slice(0, 100 * state.numPages); - } - - const pendingMessages: IMessage[] = ( - props.pendingMessages.get(props.station) || [] - ).map((value) => ({ ...value, pending: true })); - - if(unread !== 0) { - unread += pendingMessages.length; - } - - messages = pendingMessages.concat(messages); - - const messageElements = messages.map((msg, i) => { - // Render sigil if previous message is not by the same sender - const aut = ["author"]; - const renderSigil = - _.get(messages[i + 1], aut) !== _.get(msg, aut, msg.author); - const paddingTop = renderSigil; - const paddingBot = - _.get(messages[i - 1], aut) !== _.get(msg, aut, msg.author); - - const when = ["when"]; - const dayBreak = - moment(_.get(messages[i + 1], when)).format("YYYY.MM.DD") !== - moment(_.get(messages[i], when)).format("YYYY.MM.DD"); - - const messageElem = ( - - ); - if (unread > 0 && i === unread - 1) { - return ( - - {messageElem} -
-
-

New messages below

-
- {dayBreak && ( -

- {moment(_.get(messages[i], when)).calendar()} -

- )} -
-
-
- ); - } else if (dayBreak) { - return ( - - {messageElem} -
-

{moment(_.get(messages[i], when)).calendar()}

-
-
- ); - } else { - return messageElem; - } - }); - - if (navigator.userAgent.includes("Firefox")) { - return ( -
{ - this.scrollContainer = e; - }} - > -
-
{ - this.scrollElement = el; - }} - >
- {props.chatInitialized && !(props.station in props.inbox) && ( - - )} - {props.chatSynced && - !(props.station in props.chatSynced) && - messages.length > 0 ? ( - - ) : ( -
- )} - {messageElements} -
-
- ); - } else { - return ( -
-
{ - this.scrollElement = el; - }} - >
- {props.chatInitialized && !(props.station in props.inbox) && ( - - )} - {props.chatSynced && - !(props.station in props.chatSynced) && - messages.length > 0 ? ( - - ) : ( -
- )} - {messageElements} -
- ); - } - } - - render() { - const { props, state } = this; - - const messages = props.envelopes.slice(0); - - const lastMsgNum = messages.length > 0 ? messages.length : 0; - - const group = Array.from(props.group.members); - - const isinPopout = props.popout ? "popout/" : ""; - - const ownerContact = - window.ship in props.contacts ? props.contacts[window.ship] : false; - - let title = props.station.substr(1); - - if (props.association && "metadata" in props.association) { - title = - props.association.metadata.title !== "" - ? props.association.metadata.title - : props.station.substr(1); - } - - const unread = props.length - state.read; - - const unreadMsg = unread > 0 && messages[unread - 1]; - - const showUnreadNotice = - props.length !== props.read && props.read === state.read; - - return ( -
-
- {"⟵ All Chats"} -
- -
- - -

- {title} -

- - -
- {!!unreadMsg && showUnreadNotice && ( - this.dismissUnread()} - /> - )} - {this.chatWindow(unread)} - this.setState({ scrollLocked: false })} - onUnmount={(msg: string) => this.setState({ - messages: this.state.messages.set(props.station, msg) - })} - s3={props.s3} - placeholder="Message..." - message={this.state.messages.get(props.station) || ""} - /> -
- ); - } -} diff --git a/pkg/interface/src/apps/chat/components/lib/backlog-element.js b/pkg/interface/src/apps/chat/components/lib/backlog-element.js deleted file mode 100644 index 6e16882ff..000000000 --- a/pkg/interface/src/apps/chat/components/lib/backlog-element.js +++ /dev/null @@ -1,21 +0,0 @@ -import React, { Component } from 'react'; - -export class BacklogElement extends Component { - render() { - return ( -
-
- -

- Past messages are being restored -

-
-
- - ); - } -} diff --git a/pkg/interface/src/apps/chat/components/lib/chat-input.js b/pkg/interface/src/apps/chat/components/lib/chat-input.js deleted file mode 100644 index 9e844b94d..000000000 --- a/pkg/interface/src/apps/chat/components/lib/chat-input.js +++ /dev/null @@ -1,431 +0,0 @@ -import React, { Component } from 'react'; -import _ from 'lodash'; -import moment from 'moment'; -import { UnControlled as CodeEditor } from 'react-codemirror2'; -import CodeMirror from 'codemirror'; - -import 'codemirror/mode/markdown/markdown'; -import 'codemirror/addon/display/placeholder'; - -import 'codemirror/lib/codemirror.css'; - -import { Sigil } from '../../../../lib/sigil'; -import { ShipSearch } from './ship-search'; -import { S3Upload } from './s3-upload'; - -import { uxToHex } from '../../../../lib/util'; - -const MARKDOWN_CONFIG = { - name: 'markdown', - tokenTypeOverrides: { - header: 'presentation', - quote: 'presentation', - list1: 'presentation', - list2: 'presentation', - list3: 'presentation', - hr: 'presentation', - image: 'presentation', - imageAltText: 'presentation', - imageMarker: 'presentation', - formatting: 'presentation', - linkInline: 'presentation', - linkEmail: 'presentation', - linkText: 'presentation', - linkHref: 'presentation' - } -}; - -export class ChatInput extends Component { - constructor(props) { - super(props); - - this.state = { - message: props.message, - patpSearch: null - }; - - this.textareaRef = React.createRef(); - - this.messageSubmit = this.messageSubmit.bind(this); - this.messageChange = this.messageChange.bind(this); - - this.patpAutocomplete = this.patpAutocomplete.bind(this); - this.completePatp = this.completePatp.bind(this); - this.clearSearch = this.clearSearch.bind(this); - - this.toggleCode = this.toggleCode.bind(this); - - this.editor = null; - - moment.updateLocale('en', { - relativeTime : { - past: function(input) { - return input === 'just now' - ? input - : input + ' ago'; - }, - s : 'just now', - future: 'in %s', - ss : '%d sec', - m: 'a minute', - mm: '%d min', - h: 'an hr', - hh: '%d hrs', - d: 'a day', - dd: '%d days', - M: 'a month', - MM: '%d months', - y: 'a year', - yy: '%d years' - } - }); - } - - componentWillUnmount() { - this.props.onUnmount(this.state.message); - } - - nextAutocompleteSuggestion(backward = false) { - const { patpSuggestions } = this.state; - let idx = patpSuggestions.findIndex(s => s === this.state.selectedSuggestion); - - idx = backward ? idx - 1 : idx + 1; - idx = idx % patpSuggestions.length; - if(idx < 0) { - idx = patpSuggestions.length - 1; - } - - this.setState({ selectedSuggestion: patpSuggestions[idx] }); - } - - patpAutocomplete(message) { - const match = /~([a-zA-Z\-]*)$/.exec(message); - - if (!match ) { - this.setState({ patpSearch: null }); - return; - } - this.setState({ patpSearch: match[1].toLowerCase() }); - } - - clearSearch() { - this.setState({ - patpSearch: null - }); - } - - completePatp(suggestion) { - if(!this.editor) { - return; - } - const newMessage = this.editor.getValue().replace( - /[a-zA-Z\-]*$/, - suggestion - ); - this.editor.setValue(newMessage); - const lastRow = this.editor.lastLine(); - const lastCol = this.editor.getLineHandle(lastRow).text.length; - this.editor.setCursor(lastRow, lastCol); - this.setState({ - patpSearch: null - }); - } - - messageChange(editor, data, value) { - const { patpSearch } = this.state; - if(patpSearch !== null) { - this.patpAutocomplete(value, false); - } - this.setState({ - message: value - }); - } - - getLetterType(letter) { - if (letter.startsWith('/me ')) { - letter = letter.slice(4); - // remove insignificant leading whitespace. - // aces might be relevant to style. - while (letter[0] === '\n') { - letter = letter.slice(1); - } - - return { - me: letter - }; - } else if (this.isUrl(letter)) { - return { - url: letter - }; - } else { - return { - text: letter - }; - } - } - - isUrl(string) { - try { - const websiteTest = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)/.source) - ); - return websiteTest.test(string); - } catch (e) { - return false; - } - } - - messageSubmit() { - if(!this.editor) { - return; - } - const { props, state } = this; - const editorMessage = this.editor.getValue(); - - if (editorMessage === '') { - return; - } - - props.onEnter(); - - if(state.code) { - props.api.chat.message(props.station, `~${window.ship}`, Date.now(), { - code: { - expression: editorMessage, - output: undefined - } - }); - this.editor.setValue(''); - return; - } - - let messages = []; // Users can send one or more messages on submit, depending on message content - let message = []; - let isInCodeBlock = false; - let endOfCodeBlock = false; - editorMessage.split(/\r?\n/).forEach((line) => { - message.push('\n'); - // A line of backticks enters and exits a codeblock - if (line.startsWith('```')) { - // But we need to check if we've ended a codeblock - endOfCodeBlock = isInCodeBlock; - isInCodeBlock = (!isInCodeBlock); - } else { - endOfCodeBlock = false; - } - - if (isInCodeBlock || endOfCodeBlock) { - message.push(line); - } else { - line.split(/\s/).forEach((str) => { - if ( - (str.startsWith('`') && str !== '`') - || (str === '`' && !isInCodeBlock) - ) { - isInCodeBlock = true; - } else if ( - (str.endsWith('`') && str !== '`') - || (str === '`' && isInCodeBlock) - ) { - isInCodeBlock = false; - } - - if (this.isUrl(str) && !isInCodeBlock) { - if (message.length > 0) { - // If we're in the middle of a message, add it to the stack and reset - messages.push(message); - message = []; - } - messages.push([str]); - message = []; - } else { - message.push(str); - } - }); - } - }); - - if (message.length) { - // Add any remaining message - messages.push(message); - } - - messages.forEach((message) => { - if (message.length > 0) { - message = this.getLetterType(message.join(' ')); - props.api.chat.message( - props.station, - `~${window.ship}`, - Date.now(), - message - ); - } - }); - - // perf testing: - /*let closure = () => { - let x = 0; - for (var i = 0; i < 30; i++) { - x++; - props.api.chat.message( - props.station, - `~${window.ship}`, - Date.now(), - { - text: `${x}` - } - ); - } - setTimeout(closure, 1000); - }; - this.closure = closure.bind(this); - setTimeout(this.closure, 2000);*/ - - this.editor.setValue(''); - } - - toggleCode() { - if(this.state.code) { - this.setState({ code: false }); - this.editor.setOption('mode', MARKDOWN_CONFIG); - this.editor.setOption('placeholder', this.props.placeholder); - } else { - this.setState({ code: true }); - this.editor.setOption('mode', null); - this.editor.setOption('placeholder', 'Code...'); - } - const value = this.editor.getValue(); - - // Force redraw of placeholder - if(value.length === 0) { - this.editor.setValue(' '); - this.editor.setValue(''); - } - } - - uploadSuccess(url) { - const { props } = this; - props.api.chat.message( - props.station, - `~${window.ship}`, - Date.now(), - { url } - ); - } - - uploadError(error) { - // no-op for now - } - - render() { - const { props, state } = this; - - const color = props.ownerContact - ? uxToHex(props.ownerContact.color) : '000000'; - - const sigilClass = props.ownerContact - ? '' : 'mix-blend-diff'; - - const img = (props.ownerContact && (props.ownerContact.avatar !== null)) - ? - : ; - - const candidates = _.chain(this.props.envelopes) - .defaultTo([]) - .map('author') - .uniq() - .reverse() - .value(); - - const codeTheme = state.code ? ' code' : ''; - - const options = { - mode: MARKDOWN_CONFIG, - theme: 'tlon' + codeTheme, - lineNumbers: false, - lineWrapping: true, - scrollbarStyle: 'native', - cursorHeight: 0.85, - placeholder: state.code ? 'Code...' : props.placeholder, - extraKeys: { - Tab: cm => - this.patpAutocomplete(cm.getValue(), true), - 'Enter': () => { - this.messageSubmit(); - if (this.state.code) { - this.toggleCode(); - } - }, - 'Shift-3': cm => - cm.getValue().length === 0 - ? this.toggleCode() - : CodeMirror.Pass - } - }; - - return ( -
- -
- {img} -
-
- { - this.editor = editor; - if (!/Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test( - navigator.userAgent - )) { - editor.focus(); - } - }} - onChange={(e, d, v) => this.messageChange(e, d, v)} - /> -
-
- -
-
- -
-
- ); - } -} diff --git a/pkg/interface/src/apps/chat/components/lib/chat-tabbar.js b/pkg/interface/src/apps/chat/components/lib/chat-tabbar.js deleted file mode 100644 index 97670f23e..000000000 --- a/pkg/interface/src/apps/chat/components/lib/chat-tabbar.js +++ /dev/null @@ -1,66 +0,0 @@ -import React, { Component } from 'react'; -import { Link } from 'react-router-dom'; - -export class ChatTabBar extends Component { - render() { - const props = this.props; - - let memColor = '', - setColor = '', - popout = ''; - - if (props.location.pathname.includes('/settings')) { - memColor = 'gray3'; - setColor = 'black white-d'; - } else if (props.location.pathname.includes('/members')) { - memColor = 'black white-d'; - setColor = 'gray3'; - } else { - memColor = 'gray3'; - setColor = 'gray3'; - } - - popout = props.location.pathname.includes('/popout') - ? 'popout/' : ''; - - const hidePopoutIcon = (this.props.popout) - ? 'dn-m dn-l dn-xl' : 'dib-m dib-l dib-xl'; - - return ( -
- {props.isOwner ? ( -
- - Members - -
- ) : ( -
- )} -
- - Settings - -
- - - -
- ); - } -} diff --git a/pkg/interface/src/apps/chat/components/lib/resubscribe-element.js b/pkg/interface/src/apps/chat/components/lib/resubscribe-element.js deleted file mode 100644 index 61186a375..000000000 --- a/pkg/interface/src/apps/chat/components/lib/resubscribe-element.js +++ /dev/null @@ -1,27 +0,0 @@ -import React, { Component } from 'react'; - -export class ResubscribeElement extends Component { - onClickResubscribe() { - this.props.api.chat.addSynced( - this.props.host, - this.props.station, - true); - } - - render() { - return ( -
-

- Your ship has been disconnected from the chat's host. - This may be due to a bad connection, going offline, lack of permission, - or an over-the-air update. -

- - Reconnect to this chat - -
- ); - } -} diff --git a/pkg/interface/src/apps/chat/components/lib/unread-notice.js b/pkg/interface/src/apps/chat/components/lib/unread-notice.js deleted file mode 100644 index e18f612ef..000000000 --- a/pkg/interface/src/apps/chat/components/lib/unread-notice.js +++ /dev/null @@ -1,37 +0,0 @@ -import React, { Component } from 'react'; -import moment from 'moment'; - -export class UnreadNotice extends Component { - render() { - const { unread, unreadMsg, onRead } = this.props; - - let datestamp = moment.unix(unreadMsg.when / 1000).format('YYYY.M.D'); - const timestamp = moment.unix(unreadMsg.when / 1000).format('HH:mm'); - - if (datestamp === moment().format('YYYY.M.D')) { - datestamp = null; - } - - return ( -
-
-

- {unread} new messages since{' '} - {datestamp && ( - <> - ~{datestamp} at{' '} - - )} - {timestamp} -

-
- Mark as Read -
-
-
- ); - } -} diff --git a/pkg/interface/src/apps/chat/components/member.js b/pkg/interface/src/apps/chat/components/member.js deleted file mode 100644 index 2ffb04461..000000000 --- a/pkg/interface/src/apps/chat/components/member.js +++ /dev/null @@ -1,94 +0,0 @@ -import React, { Component } from 'react'; - -import { Link } from 'react-router-dom'; - -import { deSig } from '../../../lib/util'; -import { ChatTabBar } from './lib/chat-tabbar'; -import { MemberElement } from './lib/member-element'; -import { InviteElement } from './lib/invite-element'; -import { SidebarSwitcher } from '../../../components/SidebarSwitch'; -import { GroupView } from '../../../components/Group'; -import { PatpNoSig } from '../../../types/noun'; - -export class MemberScreen extends Component { - constructor(props) { - super(props); - this.inviteShips = this.inviteShips.bind(this); - } - - inviteShips(ships) { - const { props } = this; - return props.api.chat.invite(props.station, ships.map(s => `~${s}`)); - } - - render() { - const { props } = this; - - const isinPopout = this.props.popout ? 'popout/' : ''; - - let title = props.station.substr(1); - - if (props.association && 'metadata' in props.association) { - title = - props.association.metadata.title !== '' - ? props.association.metadata.title - : props.station.substr(1); - } - - return ( -
-
- {'⟵ All Chats'} -
-
- - -

- {title} -

- - -
-
- { props.association['group-path'] && ( - )} -
-
- ); - } -} diff --git a/pkg/interface/src/apps/chat/components/settings.js b/pkg/interface/src/apps/chat/components/settings.js deleted file mode 100644 index 5471d42e8..000000000 --- a/pkg/interface/src/apps/chat/components/settings.js +++ /dev/null @@ -1,459 +0,0 @@ -import React, { Component } from 'react'; -import { deSig, uxToHex, writeText } from '../../../lib/util'; -import { Link } from 'react-router-dom'; - -import { Spinner } from '../../../components/Spinner'; -import { ChatTabBar } from './lib/chat-tabbar'; -import { InviteSearch } from '../../../components/InviteSearch'; -import SidebarSwitcher from '../../../components/SidebarSwitch'; -import Toggle from '../../../components/toggle'; - -export class SettingsScreen extends Component { - constructor(props) { - super(props); - - this.state = { - isLoading: false, - title: '', - description: '', - color: '', - // groupify settings - targetGroup: null, - inclusive: false, - awaiting: false, - type: 'Editing chat...' - }; - - this.renderDelete = this.renderDelete.bind(this); - this.changeTargetGroup = this.changeTargetGroup.bind(this); - this.changeInclusive = this.changeInclusive.bind(this); - this.changeTitle = this.changeTitle.bind(this); - this.changeDescription = this.changeDescription.bind(this); - this.changeColor = this.changeColor.bind(this); - this.submitColor = this.submitColor.bind(this); - } - - componentDidMount() { - const { props } = this; - if (props.association && 'metadata' in props.association) { - this.setState({ - title: props.association.metadata.title, - description: props.association.metadata.description, - color: `#${uxToHex(props.association.metadata.color)}` - }); - } - } - - componentDidUpdate(prevProps) { - const { props, state } = this; - if (Boolean(state.isLoading) && !(props.station in props.inbox)) { - this.setState({ - isLoading: false - }, () => { - props.history.push('/~chat'); - }); - } - - if ((state.title === '') && (prevProps !== props)) { - if (props.association && 'metadata' in props.association) - this.setState({ - title: props.association.metadata.title, - description: props.association.metadata.description, - color: `#${uxToHex(props.association.metadata.color)}` - }); - } - } - - changeTargetGroup(target) { - if (target.groups.length === 1) { - this.setState({ targetGroup: target.groups[0] }); - } else { - this.setState({ targetGroup: null }); - } - } - - changeInclusive(event) { - this.setState({ inclusive: Boolean(event.target.checked) }); - } - - changeTitle() { - this.setState({ title: event.target.value }); - } - - changeDescription() { - this.setState({ description: event.target.value }); - } - - changeColor() { - this.setState({ color: event.target.value }); - } - - submitColor() { - const { props, state } = this; - - let color = state.color; - if (color.startsWith('#')) { - color = state.color.substr(1); - } - const hexExp = /([0-9A-Fa-f]{6})/; - const hexTest = hexExp.exec(color); - let currentColor = '000000'; - if (props.association && 'metadata' in props.association) { - currentColor = uxToHex(props.association.metadata.color); - } - if (hexTest && (hexTest[1] !== currentColor)) { - const chatOwner = (deSig(props.match.params.ship) === window.ship); - const association = - (props.association) && ('metadata' in props.association) - ? props.association : {}; - - if (chatOwner) { - this.setState({ awaiting: true, type: 'Editing chat...' }, (() => { - props.api.metadata.metadataAdd( - 'chat', - association['app-path'], - association['group-path'], - association.metadata.title, - association.metadata.description, - association.metadata['date-created'], - color - ).then(() => { - this.setState({ awaiting: false }); - }); - })); - } - } - } - - deleteChat() { - const { props } = this; - - this.setState({ - isLoading: true, - awaiting: true, - type: (deSig(props.match.params.ship) === window.ship) - ? 'Deleting chat...' - : 'Leaving chat...' - }, (() => { - props.api.chat.delete(props.station); - })); - } - - groupifyChat() { - const { props, state } = this; - - this.setState({ - isLoading: true, - awaiting: true, - type: 'Converting chat...' - }, (() => { - props.api.chat.groupify( - props.station, state.targetGroup, state.inclusive - ).then(() => this.setState({ awaiting: false })); - })); - } - - renderDelete() { - const { props } = this; - - const chatOwner = (deSig(props.match.params.ship) === window.ship); - - const deleteButtonClasses = (chatOwner) ? 'b--red2 red2 pointer bg-gray0-d' : 'b--gray3 gray3 bg-gray0-d c-default'; - const leaveButtonClasses = (!chatOwner) ? 'pointer' : 'c-default'; - - return ( -
-
-

Leave Chat

-

Remove this chat from your chat list. You will need to request for access again.

- Leave this chat -
-
-

Delete Chat

-

Permanently delete this chat. All current members will no longer see this chat.

- Delete this chat -
-
- ); - } - - renderGroupify() { - const { props, state } = this; - - const chatOwner = (deSig(props.match.params.ship) === window.ship); - - const groupPath = props.association['group-path']; - const ownedUnmanagedVillage = - chatOwner && - !props.contacts[groupPath]; - - if (!ownedUnmanagedVillage) { - return null; - } else { - let inclusiveToggle =
; - if (state.targetGroup) { - inclusiveToggle = ( -
- - - Add all members to group - -

- Add chat members to the group if they aren't in it yet -

-
- ); - } - - return ( -
-
-

Convert Chat

-

- Convert this chat into a group with associated chat, or select a - group to add this chat to. -

- - {inclusiveToggle} - - Convert to group - -
-
- ); - } - } - - renderMetadataSettings() { - const { props, state } = this; - - const chatOwner = (deSig(props.match.params.ship) === window.ship); - - const association = (props.association) && ('metadata' in props.association) - ? props.association : {}; - - return( -
-
-

Rename

-

Change the name of this chat

-
- { - if (chatOwner) { - this.setState({ awaiting: true, type: 'Editing chat...' }, (() => { - props.api.metadata.metadataAdd( - 'chat', - association['app-path'], - association['group-path'], - state.title, - association.metadata.description, - association.metadata['date-created'], - uxToHex(association.metadata.color) - ).then(() => { - this.setState({ awaiting: false }); - }); - })); - } - }} - /> -
-

Change description

-

Change the description of this chat

-
- { - if (chatOwner) { - this.setState({ awaiting: true, type: 'Editing chat...' }, (() => { - props.api.metadata.metadataAdd( - 'chat', - association['app-path'], - association['group-path'], - association.metadata.title, - state.description, - association.metadata['date-created'], - uxToHex(association.metadata.color) - ).then(() => { - this.setState({ awaiting: false }); - }); - })); - } - }} - /> -
-

Change color

-

Give this chat a color when viewing group channels

-
-
- -
-
-
- ); - } - - render() { - const { props, state } = this; - const isinPopout = this.props.popout ? 'popout/' : ''; - - const permission = Array.from(props.group.members.values()); - - if (state.isLoading) { - let title = props.station.substr(1); - - if ((props.association) && ('metadata' in props.association)) { - title = (props.association.metadata.title !== '') - ? props.association.metadata.title : props.station.substr(1); - } - - return ( -
-
- {'⟵ All Chats'} -
-
- - -

- {title} -

- - -
-
- -
-
- ); - } - - let title = props.station.substr(1); - - if ((props.association) && ('metadata' in props.association)) { - title = (props.association.metadata.title !== '') - ? props.association.metadata.title : props.station.substr(1); - } - - return ( -
-
- {'⟵ All Chats'} -
-
- - -

- {title} -

- - -
-
-

Chat Settings

- {this.renderGroupify()} - {this.renderDelete()} - {this.renderMetadataSettings()} - -
-
- ); - } -} diff --git a/pkg/interface/src/components/GroupFilter.js b/pkg/interface/src/components/GroupFilter.js deleted file mode 100644 index 119de5cd4..000000000 --- a/pkg/interface/src/components/GroupFilter.js +++ /dev/null @@ -1,249 +0,0 @@ -import React, { Component } from 'react'; -import { Link } from 'react-router-dom'; - - -export default class GroupFilter extends Component { - constructor(props) { - super(props); - this.state = { - open: false, - selected: [], - groups: [], - searchTerm: '', - results: [] - }; - this.toggleOpen = this.toggleOpen.bind(this); - this.handleClickOutside = this.handleClickOutside.bind(this); - this.groupIndex = this.groupIndex.bind(this); - this.search = this.search.bind(this); - this.addGroup = this.addGroup.bind(this); - this.deleteGroup = this.deleteGroup.bind(this); - } - - componentDidMount() { - document.addEventListener('mousedown', this.handleClickOutside); - this.groupIndex(); - const selected = localStorage.getItem('urbit-selectedGroups'); - if (selected) { - this.setState({ selected: JSON.parse(selected) }, (() => { - this.props.api.local.setSelected(this.state.selected); - })); - } - } - - componentWillUnmount() { - document.removeEventListener('mousedown', this.handleClickOutside); - } - - componentDidUpdate(prevProps) { - if (prevProps !== this.props) { - this.groupIndex(); - } - } - - handleClickOutside(evt) { - if ((this.dropdown && !this.dropdown.contains(evt.target)) - && (this.toggleButton && !this.toggleButton.contains(evt.target))) { - this.setState({ open: false }); - } - } - - toggleOpen() { - this.setState({ open: !this.state.open }); - } - - groupIndex() { - const { props } = this; - let index = []; - const associations = - (props.associations && 'contacts' in props.associations) ? - props.associations.contacts : {}; - index = Object.keys(associations).map((each) => { - const eachGroup = []; - eachGroup.push(each); - let name = each; - if (associations[each].metadata) { - name = (associations[each].metadata.title !== '') - ? associations[each].metadata.title : name; - } - eachGroup.push(name); - return eachGroup; - }); - this.setState({ groups: index }); - } - - search(evt) { - this.setState({ searchTerm: evt.target.value }); - const term = evt.target.value.toLowerCase(); - - if (term.length < 3) { - return this.setState({ results: [] }); - } - - let groupMatches = []; - groupMatches = this.state.groups.filter((e) => { - return (e[0].includes(term) || e[1].includes(term)); - }); - this.setState({ results: groupMatches }); - } - - addGroup(group) { - const selected = this.state.selected; - if (!(group in selected)) { - selected.push(group); - } - this.setState({ - searchTerm: '', - selected: selected, - results: [] - }, (() => { - this.props.api.local.setSelected(this.state.selected); - localStorage.setItem('urbit-selectedGroups', JSON.stringify(this.state.selected)); - })); - } - - deleteGroup(group) { - let selected = this.state.selected; - selected = selected.filter((e) => { - return e !== group; - }); - this.setState({ selected: selected }, (() => { - this.props.api.local.setSelected(this.state.selected); - localStorage.setItem('urbit-selectedGroups', JSON.stringify(this.state.selected)); - })); - } - - render() { - const { props, state } = this; - - let currentGroup = 'All Groups'; - - if (state.selected.length > 0) { - const titles = state.selected.map((each) => { - return each[1]; - }); - currentGroup = titles.join(' + '); - } - - const buttonOpened = (state.open) - ? 'bg-gray5 bg-gray1-d white-d' : 'hover-bg-gray5 hover-bg-gray1-d white-d'; - - const dropdownClass = (state.open) - ? 'absolute db z-2 bg-white bg-gray0-d white-d ba b--gray3 b--gray1-d' - : 'dn'; - - const inviteCount = (props.invites && Object.keys(props.invites).length > 0) - ? - : ; - - let selectedGroups =
; - let searchResults =
; - - if (state.results.length > 0) { - const groupResults = state.results.map(((group) => { - return( -
  • this.addGroup(group)} - > - {(group[1]) ? group[1] : group[0]} -
  • - ); - })); - searchResults = ( -
    -

    Groups

    - {groupResults} -
    - ); - } - - if (state.selected.length > 0) { - const allSelected = this.state.selected.map((each) => { - const name = each[1]; - return( - - {name} - this.deleteGroup(each)} - > - x - - - ); - }); - selectedGroups = ( -
    - {allSelected} -
    - ); - } - - return ( -
    -
    this.toggleOpen()} - ref={el => this.toggleButton = el} - > -

    {currentGroup}

    -
    -
    { - this.dropdown = el; -}} - > -

    Group Select and Filter

    - this.setState({ open: false })} - > - Manage all Groups - {inviteCount} - -

    Filter Groups

    -
    - - {searchResults} - {selectedGroups} -
    -
    -
    - ); - } -} diff --git a/pkg/interface/src/components/StatusBar.js b/pkg/interface/src/components/StatusBar.js deleted file mode 100644 index 42793f5c3..000000000 --- a/pkg/interface/src/components/StatusBar.js +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import { useLocation, Link } from 'react-router-dom'; - -import GroupFilter from './GroupFilter'; -import { Sigil } from '../lib/sigil'; - -const getLocationName = (basePath) => { - if (basePath === '~chat') - return 'Chat'; - else if (basePath === '~dojo') - return 'Dojo'; - else if (basePath === '~groups') - return 'Groups'; - else if (basePath === '~link') - return 'Links'; - else if (basePath === '~publish') - return 'Publish'; - else - return 'Unknown'; -}; - -const StatusBar = (props) => { - const location = useLocation(); - const basePath = location.pathname.split('/')[1]; - const locationName = location.pathname === '/' - ? 'Home' - : getLocationName(basePath); - - const display = (!window.location.href.includes('popout/') && - (locationName !== 'Unknown')) - ? 'db' : 'dn'; - - const invites = (props.invites && props.invites['/contacts']) - ? props.invites['/contacts'] - : {}; - const connection = props.connection || 'connected'; - - const reconnect = props.subscription.restart.bind(props.subscription); - - return ( -
    -
    - - - - - / - { - location.pathname === '/' - ? null - : - ⟵ - - } -

    {locationName}

    - { connection === 'disconnected' && - (Reconnect ↻ ) - } - { connection === 'reconnecting' && - (Reconnecting ) - } -
    -
    - ); -}; - -export default StatusBar; diff --git a/pkg/interface/src/index.js b/pkg/interface/src/index.js index 083a99d78..a056a0cb2 100644 --- a/pkg/interface/src/index.js +++ b/pkg/interface/src/index.js @@ -1,7 +1,6 @@ import * as React from 'react'; import * as ReactDOM from 'react-dom'; -// import "./fonts/font.css"; -import App from './App'; +import App from './views/App'; ReactDOM.render(, document.getElementById('root')); diff --git a/pkg/interface/src/api/base.ts b/pkg/interface/src/logic/api/base.ts similarity index 100% rename from pkg/interface/src/api/base.ts rename to pkg/interface/src/logic/api/base.ts diff --git a/pkg/interface/src/api/chat.ts b/pkg/interface/src/logic/api/chat.ts similarity index 100% rename from pkg/interface/src/api/chat.ts rename to pkg/interface/src/logic/api/chat.ts diff --git a/pkg/interface/src/api/contacts.ts b/pkg/interface/src/logic/api/contacts.ts similarity index 100% rename from pkg/interface/src/api/contacts.ts rename to pkg/interface/src/logic/api/contacts.ts diff --git a/pkg/interface/src/api/global.ts b/pkg/interface/src/logic/api/global.ts similarity index 100% rename from pkg/interface/src/api/global.ts rename to pkg/interface/src/logic/api/global.ts diff --git a/pkg/interface/src/api/groups.ts b/pkg/interface/src/logic/api/groups.ts similarity index 100% rename from pkg/interface/src/api/groups.ts rename to pkg/interface/src/logic/api/groups.ts diff --git a/pkg/interface/src/api/invite.ts b/pkg/interface/src/logic/api/invite.ts similarity index 100% rename from pkg/interface/src/api/invite.ts rename to pkg/interface/src/logic/api/invite.ts diff --git a/pkg/interface/src/api/launch.ts b/pkg/interface/src/logic/api/launch.ts similarity index 100% rename from pkg/interface/src/api/launch.ts rename to pkg/interface/src/logic/api/launch.ts diff --git a/pkg/interface/src/api/links.ts b/pkg/interface/src/logic/api/links.ts similarity index 100% rename from pkg/interface/src/api/links.ts rename to pkg/interface/src/logic/api/links.ts diff --git a/pkg/interface/src/api/local.ts b/pkg/interface/src/logic/api/local.ts similarity index 82% rename from pkg/interface/src/api/local.ts rename to pkg/interface/src/logic/api/local.ts index 8610284c2..77ff9b2b3 100644 --- a/pkg/interface/src/api/local.ts +++ b/pkg/interface/src/logic/api/local.ts @@ -1,6 +1,5 @@ import BaseApi from "./base"; import { StoreState } from "../store/type"; -import { SelectedGroup } from "../types/local-update"; export default class LocalApi extends BaseApi { getBaseHash() { @@ -9,16 +8,6 @@ export default class LocalApi extends BaseApi { }); } - setSelected(selected: SelectedGroup[]) { - this.store.handleEvent({ - data: { - local: { - selected - } - } - }) - } - sidebarToggle() { this.store.handleEvent({ data: { @@ -39,4 +28,14 @@ export default class LocalApi extends BaseApi { }); } + setOmnibox() { + this.store.handleEvent({ + data: { + local: { + omniboxShown: true + }, + }, + }); + } + } diff --git a/pkg/interface/src/api/metadata.ts b/pkg/interface/src/logic/api/metadata.ts similarity index 100% rename from pkg/interface/src/api/metadata.ts rename to pkg/interface/src/logic/api/metadata.ts diff --git a/pkg/interface/src/api/publish.ts b/pkg/interface/src/logic/api/publish.ts similarity index 100% rename from pkg/interface/src/api/publish.ts rename to pkg/interface/src/logic/api/publish.ts diff --git a/pkg/interface/src/logic/lib/default-apps.js b/pkg/interface/src/logic/lib/default-apps.js new file mode 100644 index 000000000..4fcbe3733 --- /dev/null +++ b/pkg/interface/src/logic/lib/default-apps.js @@ -0,0 +1,3 @@ +const defaultApps = ['chat', 'dojo', 'groups', 'link', 'publish']; + +export default defaultApps; diff --git a/pkg/interface/src/lib/group.ts b/pkg/interface/src/logic/lib/group.ts similarity index 75% rename from pkg/interface/src/lib/group.ts rename to pkg/interface/src/logic/lib/group.ts index 7541e835b..2078ed368 100644 --- a/pkg/interface/src/lib/group.ts +++ b/pkg/interface/src/logic/lib/group.ts @@ -1,5 +1,5 @@ -import { roleTags, RoleTags, Group, Resource } from '../types/group-update'; -import { PatpNoSig, Path } from '../types/noun'; +import { roleTags, RoleTags, Group, Resource } from '../../types/group-update'; +import { PatpNoSig, Path } from '../../types/noun'; export function roleForShip(group: Group, ship: PatpNoSig): RoleTags | undefined { diff --git a/pkg/interface/src/logic/lib/omnibox.js b/pkg/interface/src/logic/lib/omnibox.js new file mode 100644 index 000000000..8485c6128 --- /dev/null +++ b/pkg/interface/src/logic/lib/omnibox.js @@ -0,0 +1,120 @@ +import defaultApps from './default-apps'; + + const indexes = new Map([ + ['commands', []], + ['subscriptions', []], + ['groups', []], + ['apps', []] + ]); + +// result schematic +const result = function(title, link, app, host) { + return { + 'title': title, + 'link': link, + 'app': app, + 'host': host + }; +}; + +const commandIndex = function () { + // commands are special cased for default suite + const commands = []; + defaultApps + .filter((e) => { + return e !== 'dojo'; + }) + .map((e) => { + let title = e; + if (e === 'link') { + title = 'Links'; + } + + title = title.charAt(0).toUpperCase() + title.slice(1); + + let obj = result(`${title}: Create`, `/~${e}/new`, e, null); + commands.push(obj); + + if (title === 'Groups') { + obj = result(`${title}: Join Group`, `/~${e}/join`, title, null); + commands.push(obj); + } + }); + return commands; +}; + +const appIndex = function (apps) { + // all apps are indexed from launch data + // indexed into 'apps' + const applications = []; + Object.keys(apps) + .filter((e) => { + return apps[e]?.type?.basic; + }) + .map((e) => { + const obj = result( + apps[e].type.basic.title, + apps[e].type.basic.linkedUrl, + apps[e].type.basic.title, + null + ); + applications.push(obj); + }); + // add groups separately + applications.push( + result('Groups', '/~groups', 'groups', null) + ); + return applications; +}; + +export default function index(associations, apps) { + // all metadata from all apps is indexed + // into subscriptions and groups + const subscriptions = []; + const groups = []; + Object.keys(associations).filter((e) => { + // skip apps with no metadata + return Object.keys(associations[e]).length > 0; + }).map((e) => { + // iterate through each app's metadata object + Object.keys(associations[e]).map((association) => { + const each = associations[e][association]; + let title = each['app-path']; + if (each.metadata.title !== '') { + title = each.metadata.title; + } + + let app = each['app-name']; + if (each['app-name'] === 'contacts') { + app = 'groups'; + }; + + const shipStart = each['app-path'].substr(each['app-path'].indexOf('~')); + + if (app === 'groups') { + const obj = result( + title, + `/~${app}${each['app-path']}`, + app.charAt(0).toUpperCase() + app.slice(1), + shipStart.slice(0, shipStart.indexOf('/')) + ); + groups.push(obj); + } else { + const obj = result( + title, + `/~${each['app-name']}/join${each['app-path']}`, + app.charAt(0).toUpperCase() + app.slice(1), + shipStart.slice(0, shipStart.indexOf('/')) + ); + subscriptions.push(obj); + } + }); + }); + + indexes.set('commands', commandIndex()); + indexes.set('subscriptions', subscriptions); + indexes.set('groups', groups); + indexes.set('apps', appIndex(apps)); + + return indexes; +}; diff --git a/pkg/interface/src/lib/s3.js b/pkg/interface/src/logic/lib/s3.js similarity index 100% rename from pkg/interface/src/lib/s3.js rename to pkg/interface/src/logic/lib/s3.js diff --git a/pkg/interface/src/lib/sigil.js b/pkg/interface/src/logic/lib/sigil.js similarity index 74% rename from pkg/interface/src/lib/sigil.js rename to pkg/interface/src/logic/lib/sigil.js index c9efb729d..bcdee52bb 100644 --- a/pkg/interface/src/lib/sigil.js +++ b/pkg/interface/src/logic/lib/sigil.js @@ -2,24 +2,24 @@ import React, { Component } from 'react'; import { sigil, reactRenderer } from 'urbit-sigil-js'; export class Sigil extends Component { + static foregroundFromBackground(background) { + const rgb = { + r: parseInt(background.slice(1, 3), 16), + g: parseInt(background.slice(3, 5), 16), + b: parseInt(background.slice(5, 7), 16) + }; + const brightness = ((299 * rgb.r) + (587 * rgb.g) + (114 * rgb.b)) / 1000; + const whiteBrightness = 255; + + return ((whiteBrightness - brightness) < 50) ? 'black' : 'white'; + } + render() { const { props } = this; const classes = props.classes || ''; - const rgb = { - r: parseInt(props.color.slice(1, 3), 16), - g: parseInt(props.color.slice(3, 5), 16), - b: parseInt(props.color.slice(5, 7), 16) - }; - const brightness = ((299 * rgb.r) + (587 * rgb.g) + (114 * rgb.b)) / 1000; - const whiteBrightness = 255; - - let foreground = 'white'; - - if ((whiteBrightness - brightness) < 50) { - foreground = 'black'; - } + const foreground = Sigil.foregroundFromBackground(props.color); if (props.ship.length > 14) { return ( diff --git a/pkg/interface/src/lib/util.js b/pkg/interface/src/logic/lib/util.js similarity index 87% rename from pkg/interface/src/lib/util.js rename to pkg/interface/src/logic/lib/util.js index 0b20a8f43..4f6162f42 100644 --- a/pkg/interface/src/lib/util.js +++ b/pkg/interface/src/logic/lib/util.js @@ -119,6 +119,9 @@ export function writeText(str) { // trim patps to match dojo, chat-cli export function cite(ship) { let patp = ship, shortened = ''; + if (patp === null || patp === '') { + return null; + } if (patp.startsWith('~')) { patp = patp.substr(1); } @@ -275,3 +278,38 @@ export function stringToSymbol(str) { } return result; } + +export function scrollIsAtTop(container) { + if ( + (navigator.userAgent.includes("Safari") && + navigator.userAgent.includes("Chrome")) || + navigator.userAgent.includes("Firefox") + ) { + return container.scrollTop === 0; + } else if (navigator.userAgent.includes("Safari")) { + return ( + container.scrollHeight + Math.round(container.scrollTop) <= + container.clientHeight + 10 + ); + } else { + return false; + } +} + +export function scrollIsAtBottom(container) { + if ( + (navigator.userAgent.includes("Safari") && + navigator.userAgent.includes("Chrome")) || + navigator.userAgent.includes("Firefox") + ) { + return ( + container.scrollHeight - Math.round(container.scrollTop) <= + container.clientHeight + 10 + ); + } else if (navigator.userAgent.includes("Safari")) { + return container.scrollTop === 0; + } else { + return false; + } +} + diff --git a/pkg/interface/src/reducers/chat-update.ts b/pkg/interface/src/logic/reducers/chat-update.ts similarity index 91% rename from pkg/interface/src/reducers/chat-update.ts rename to pkg/interface/src/logic/reducers/chat-update.ts index a66a287e1..5efe9cc23 100644 --- a/pkg/interface/src/reducers/chat-update.ts +++ b/pkg/interface/src/logic/reducers/chat-update.ts @@ -1,8 +1,8 @@ import _ from 'lodash'; -import { StoreState } from '../store/type'; -import { Cage } from '../types/cage'; -import { ChatUpdate } from '../types/chat-update'; -import { ChatHookUpdate } from '../types/chat-hook-update'; +import { StoreState } from '../../../store/type'; +import { Cage } from '../../types/cage'; +import { ChatUpdate } from '../../types/chat-update'; +import { ChatHookUpdate } from '../../types/chat-hook-update'; type ChatState = Pick; diff --git a/pkg/interface/src/reducers/connection.ts b/pkg/interface/src/logic/reducers/connection.ts similarity index 79% rename from pkg/interface/src/reducers/connection.ts rename to pkg/interface/src/logic/reducers/connection.ts index f0f811e46..cc0aba6f0 100644 --- a/pkg/interface/src/reducers/connection.ts +++ b/pkg/interface/src/logic/reducers/connection.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; -import { StoreState } from '../store/type'; -import { Cage } from '../types/cage'; +import { StoreState } from '../../store/type'; +import { Cage } from '../../types/cage'; type LocalState = Pick; diff --git a/pkg/interface/src/reducers/contact-update.ts b/pkg/interface/src/logic/reducers/contact-update.ts similarity index 92% rename from pkg/interface/src/reducers/contact-update.ts rename to pkg/interface/src/logic/reducers/contact-update.ts index 2cc7d04dc..c559d7b7e 100644 --- a/pkg/interface/src/reducers/contact-update.ts +++ b/pkg/interface/src/logic/reducers/contact-update.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; -import { StoreState } from '../store/type'; -import { Cage } from '../types/cage'; -import { ContactUpdate } from '../types/contact-update'; +import { StoreState } from '../../store/type'; +import { Cage } from '../../types/cage'; +import { ContactUpdate } from '../../types/contact-update'; type ContactState = Pick; diff --git a/pkg/interface/src/reducers/group-update.ts b/pkg/interface/src/logic/reducers/group-update.ts similarity index 97% rename from pkg/interface/src/reducers/group-update.ts rename to pkg/interface/src/logic/reducers/group-update.ts index 53cb0b6ba..1f584cbf1 100644 --- a/pkg/interface/src/reducers/group-update.ts +++ b/pkg/interface/src/logic/reducers/group-update.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; -import { StoreState } from '../store/type'; -import { Cage } from '../types/cage'; +import { StoreState } from '../../store/type'; +import { Cage } from '../../types/cage'; import { GroupUpdate, Group, @@ -11,8 +11,8 @@ import { OpenPolicy, InvitePolicyDiff, InvitePolicy, -} from '../types/group-update'; -import { Enc, PatpNoSig } from '../types/noun'; +} from '../../types/group-update'; +import { Enc, PatpNoSig } from '../../types/noun'; import { resourceAsPath } from '../lib/util'; type GroupState = Pick; diff --git a/pkg/interface/src/reducers/invite-update.ts b/pkg/interface/src/logic/reducers/invite-update.ts similarity index 90% rename from pkg/interface/src/reducers/invite-update.ts rename to pkg/interface/src/logic/reducers/invite-update.ts index cea77f163..512e21b7f 100644 --- a/pkg/interface/src/reducers/invite-update.ts +++ b/pkg/interface/src/logic/reducers/invite-update.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; -import { StoreState } from '../store/type'; -import { Cage } from '../types/cage'; -import { InviteUpdate } from '../types/invite-update'; +import { StoreState } from '../../store/type'; +import { Cage } from '../../types/cage'; +import { InviteUpdate } from '../../types/invite-update'; type InviteState = Pick; diff --git a/pkg/interface/src/reducers/launch-update.ts b/pkg/interface/src/logic/reducers/launch-update.ts similarity index 89% rename from pkg/interface/src/reducers/launch-update.ts rename to pkg/interface/src/logic/reducers/launch-update.ts index 5e74af793..96e968020 100644 --- a/pkg/interface/src/reducers/launch-update.ts +++ b/pkg/interface/src/logic/reducers/launch-update.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; -import { LaunchUpdate } from '../types/launch-update'; -import { Cage } from '../types/cage'; -import { StoreState } from '../store/type'; +import { LaunchUpdate } from '../../types/launch-update'; +import { Cage } from '../../types/cage'; +import { StoreState } from '../../store/type'; type LaunchState = Pick; @@ -50,7 +50,6 @@ export default class LaunchReducer { changeIsShown(json: LaunchUpdate, state: S) { const data = _.get(json, 'changeIsShown', false); - console.log(json, data); if (data) { let tile = state.launch.tiles[data.name]; console.log(tile); diff --git a/pkg/interface/src/reducers/link-update.ts b/pkg/interface/src/logic/reducers/link-update.ts similarity index 98% rename from pkg/interface/src/reducers/link-update.ts rename to pkg/interface/src/logic/reducers/link-update.ts index d960ec0bb..394c471a0 100644 --- a/pkg/interface/src/reducers/link-update.ts +++ b/pkg/interface/src/logic/reducers/link-update.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; -import { StoreState } from '../store/type'; -import { LinkUpdate, Pagination } from '../types/link-update'; +import { StoreState } from '../../store/type'; +import { LinkUpdate, Pagination } from '../../types/link-update'; // page size as expected from link-view. // must change in parallel with the +page-size in /app/link-view to diff --git a/pkg/interface/src/reducers/listen-update.ts b/pkg/interface/src/logic/reducers/listen-update.ts similarity index 84% rename from pkg/interface/src/reducers/listen-update.ts rename to pkg/interface/src/logic/reducers/listen-update.ts index 018204870..f823543cc 100644 --- a/pkg/interface/src/reducers/listen-update.ts +++ b/pkg/interface/src/logic/reducers/listen-update.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; -import { StoreState } from '../store/type'; -import { Cage } from '../types/cage'; -import { LinkListenUpdate } from '../types/link-listen-update'; +import { StoreState } from '../../store/type'; +import { Cage } from '../../types/cage'; +import { LinkListenUpdate } from '../../types/link-listen-update'; type LinkListenState = Pick; diff --git a/pkg/interface/src/reducers/local.ts b/pkg/interface/src/logic/reducers/local.ts similarity index 63% rename from pkg/interface/src/reducers/local.ts rename to pkg/interface/src/logic/reducers/local.ts index ec11a2366..6d1dcf793 100644 --- a/pkg/interface/src/reducers/local.ts +++ b/pkg/interface/src/logic/reducers/local.ts @@ -1,18 +1,18 @@ import _ from 'lodash'; -import { StoreState } from '../store/type'; -import { Cage } from '../types/cage'; -import { LocalUpdate } from '../types/local-update'; +import { StoreState } from '../../store/type'; +import { Cage } from '../../types/cage'; +import { LocalUpdate } from '../../types/local-update'; -type LocalState = Pick; +type LocalState = Pick; export default class LocalReducer { reduce(json: Cage, state: S) { const data = json['local']; if (data) { this.sidebarToggle(data, state); - this.setSelected(data, state); this.setDark(data, state); this.baseHash(data, state); + this.omniboxShown(data, state); } } baseHash(obj: LocalUpdate, state: S) { @@ -21,18 +21,18 @@ export default class LocalReducer { } } + omniboxShown(obj: LocalUpdate, state: S) { + if ('omniboxShown' in obj) { + state.omniboxShown = !state.omniboxShown; + } + } + sidebarToggle(obj: LocalUpdate, state: S) { if ('sidebarToggle' in obj) { state.sidebarShown = !state.sidebarShown; } } - setSelected(obj: LocalUpdate, state: S) { - if ('selected' in obj) { - state.selectedGroups = obj.selected; - } - } - setDark(obj: LocalUpdate, state: S) { if('setDark' in obj) { state.dark = obj.setDark; diff --git a/pkg/interface/src/reducers/metadata-update.ts b/pkg/interface/src/logic/reducers/metadata-update.ts similarity index 93% rename from pkg/interface/src/reducers/metadata-update.ts rename to pkg/interface/src/logic/reducers/metadata-update.ts index 24d26b7bd..94b7e7953 100644 --- a/pkg/interface/src/reducers/metadata-update.ts +++ b/pkg/interface/src/logic/reducers/metadata-update.ts @@ -1,9 +1,9 @@ import _ from 'lodash'; -import { StoreState } from '../store/type'; +import { StoreState } from '../../store/type'; -import { MetadataUpdate } from '../types/metadata-update'; -import { Cage } from '../types/cage'; +import { MetadataUpdate } from '../../types/metadata-update'; +import { Cage } from '../../types/cage'; type MetadataState = Pick; diff --git a/pkg/interface/src/reducers/permission-update.ts b/pkg/interface/src/logic/reducers/permission-update.ts similarity index 90% rename from pkg/interface/src/reducers/permission-update.ts rename to pkg/interface/src/logic/reducers/permission-update.ts index 6fc70188c..7d3ed4865 100644 --- a/pkg/interface/src/reducers/permission-update.ts +++ b/pkg/interface/src/logic/reducers/permission-update.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; -import { StoreState } from '../store/type'; -import { Cage } from '../types/cage'; -import { PermissionUpdate } from '../types/permission-update'; +import { StoreState } from '../../store/type'; +import { Cage } from '../../types/cage'; +import { PermissionUpdate } from '../../types/permission-update'; type PermissionState = Pick; diff --git a/pkg/interface/src/reducers/publish-response.ts b/pkg/interface/src/logic/reducers/publish-response.ts similarity index 98% rename from pkg/interface/src/reducers/publish-response.ts rename to pkg/interface/src/logic/reducers/publish-response.ts index f79e218b2..57ed8446c 100644 --- a/pkg/interface/src/reducers/publish-response.ts +++ b/pkg/interface/src/logic/reducers/publish-response.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; -import { StoreState } from '../store/type'; -import { Cage } from '../types/cage'; +import { StoreState } from '../../store/type'; +import { Cage } from '../../types/cage'; type PublishState = Pick; diff --git a/pkg/interface/src/reducers/publish-update.ts b/pkg/interface/src/logic/reducers/publish-update.ts similarity index 97% rename from pkg/interface/src/reducers/publish-update.ts rename to pkg/interface/src/logic/reducers/publish-update.ts index 91a2c0d0b..25ea071b9 100644 --- a/pkg/interface/src/reducers/publish-update.ts +++ b/pkg/interface/src/logic/reducers/publish-update.ts @@ -1,9 +1,9 @@ import _ from 'lodash'; -import { PublishUpdate } from '../types/publish-update'; -import { Cage } from '../types/cage'; -import { StoreState } from '../store/type'; -import { getTagFromFrond } from '../types/noun'; +import { PublishUpdate } from '../../types/publish-update'; +import { Cage } from '../../types/cage'; +import { StoreState } from '../../store/type'; +import { getTagFromFrond } from '../../types/noun'; type PublishState = Pick; diff --git a/pkg/interface/src/reducers/s3-update.ts b/pkg/interface/src/logic/reducers/s3-update.ts similarity index 93% rename from pkg/interface/src/reducers/s3-update.ts rename to pkg/interface/src/logic/reducers/s3-update.ts index 1915feec1..4d454ba40 100644 --- a/pkg/interface/src/reducers/s3-update.ts +++ b/pkg/interface/src/logic/reducers/s3-update.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; -import { StoreState } from '../store/type'; -import { Cage } from '../types/cage'; -import { S3Update } from '../types/s3-update'; +import { StoreState } from '../../store/type'; +import { Cage } from '../../types/cage'; +import { S3Update } from '../../types/s3-update'; type S3State = Pick; diff --git a/pkg/interface/src/store/base.ts b/pkg/interface/src/logic/store/base.ts similarity index 100% rename from pkg/interface/src/store/base.ts rename to pkg/interface/src/logic/store/base.ts diff --git a/pkg/interface/src/store/global.js b/pkg/interface/src/logic/store/global.js similarity index 100% rename from pkg/interface/src/store/global.js rename to pkg/interface/src/logic/store/global.js diff --git a/pkg/interface/src/store/groups.js b/pkg/interface/src/logic/store/groups.js similarity index 100% rename from pkg/interface/src/store/groups.js rename to pkg/interface/src/logic/store/groups.js diff --git a/pkg/interface/src/store/launch.js b/pkg/interface/src/logic/store/launch.js similarity index 100% rename from pkg/interface/src/store/launch.js rename to pkg/interface/src/logic/store/launch.js diff --git a/pkg/interface/src/store/links.js b/pkg/interface/src/logic/store/links.js similarity index 100% rename from pkg/interface/src/store/links.js rename to pkg/interface/src/logic/store/links.js diff --git a/pkg/interface/src/store/publish.js b/pkg/interface/src/logic/store/publish.js similarity index 100% rename from pkg/interface/src/store/publish.js rename to pkg/interface/src/logic/store/publish.js diff --git a/pkg/interface/src/store/store.ts b/pkg/interface/src/logic/store/store.ts similarity index 99% rename from pkg/interface/src/store/store.ts rename to pkg/interface/src/logic/store/store.ts index 76cdad3c2..bc840f085 100644 --- a/pkg/interface/src/store/store.ts +++ b/pkg/interface/src/logic/store/store.ts @@ -41,6 +41,7 @@ export default class GlobalStore extends BaseStore { chatInitialized: false, connection: 'connected', sidebarShown: true, + omniboxShown: false, baseHash: null, invites: {}, associations: { @@ -72,7 +73,6 @@ export default class GlobalStore extends BaseStore { linkComments: {}, notebooks: {}, contacts: {}, - selectedGroups: [], dark: false, inbox: {}, chatSynced: null, diff --git a/pkg/interface/src/store/type.ts b/pkg/interface/src/logic/store/type.ts similarity index 94% rename from pkg/interface/src/store/type.ts rename to pkg/interface/src/logic/store/type.ts index 875f80318..6ff247105 100644 --- a/pkg/interface/src/store/type.ts +++ b/pkg/interface/src/logic/store/type.ts @@ -2,7 +2,6 @@ import { Inbox, Envelope } from '../types/chat-update'; import { ChatHookUpdate } from '../types/chat-hook-update'; import { Path } from '../types/noun'; import { Invites } from '../types/invite-update'; -import { SelectedGroup } from '../types/local-update'; import { Associations } from '../types/metadata-update'; import { Rolodex } from '../types/contact-update'; import { Notebooks } from '../types/publish-update'; @@ -16,7 +15,7 @@ import { ConnectionStatus } from '../types/connection'; export interface StoreState { // local state sidebarShown: boolean; - selectedGroups: SelectedGroup[]; + omniboxShown: boolean; dark: boolean; connection: ConnectionStatus; baseHash: string | null; diff --git a/pkg/interface/src/subscription/base.ts b/pkg/interface/src/logic/subscription/base.ts similarity index 100% rename from pkg/interface/src/subscription/base.ts rename to pkg/interface/src/logic/subscription/base.ts diff --git a/pkg/interface/src/subscription/global.ts b/pkg/interface/src/logic/subscription/global.ts similarity index 100% rename from pkg/interface/src/subscription/global.ts rename to pkg/interface/src/logic/subscription/global.ts diff --git a/pkg/interface/src/types/local-update.ts b/pkg/interface/src/types/local-update.ts index 326d84540..0ea170da0 100644 --- a/pkg/interface/src/types/local-update.ts +++ b/pkg/interface/src/types/local-update.ts @@ -1,19 +1,13 @@ -import { Path } from './noun'; - export type LocalUpdate = LocalUpdateSidebarToggle -| LocalUpdateSelectedGroups | LocalUpdateSetDark +| LocalUpdateSetOmniboxShown | LocalUpdateBaseHash; interface LocalUpdateSidebarToggle { sidebarToggle: boolean; } -interface LocalUpdateSelectedGroups { - selected: SelectedGroup[]; -} - interface LocalUpdateSetDark { setDark: boolean; } @@ -22,4 +16,6 @@ interface LocalUpdateBaseHash { baseHash: string; } -export type SelectedGroup = [Path, string]; +interface LocalUpdateSetOmniboxShown { + omniboxShown: boolean; +} diff --git a/pkg/interface/src/views/App.js b/pkg/interface/src/views/App.js new file mode 100644 index 000000000..ba9e816d2 --- /dev/null +++ b/pkg/interface/src/views/App.js @@ -0,0 +1,138 @@ +import { hot } from 'react-hot-loader/root'; +import 'react-hot-loader'; +import * as React from 'react'; +import { BrowserRouter as Router, Route, withRouter, Switch } from 'react-router-dom'; +import styled, { ThemeProvider, createGlobalStyle } from 'styled-components'; +import { sigil as sigiljs, stringRenderer } from 'urbit-sigil-js'; + +import Mousetrap from 'mousetrap'; +import 'mousetrap-global-bind'; + +import './css/indigo-static.css'; +import './css/fonts.css'; +import light from './themes/light'; +import dark from './themes/old-dark'; + +import { Content } from './components/Content'; +import StatusBar from './components/StatusBar'; +import Omnibox from './components/Omnibox'; +import ErrorComponent from './components/Error'; + +import GlobalStore from '../logic/store/store'; +import GlobalSubscription from '../logic/subscription/global'; +import GlobalApi from '../logic/api/global'; +import { uxToHex } from '../logic/lib/util'; +import { Sigil } from '../logic/lib/sigil'; + +const Root = styled.div` + font-family: ${p => p.theme.fonts.sans}; + height: 100%; + width: 100%; + padding: 0; + margin: 0; +`; + +const StatusBarWithRouter = withRouter(StatusBar); + +class App extends React.Component { + constructor(props) { + super(props); + this.ship = window.ship; + this.store = new GlobalStore(); + this.store.setStateHandler(this.setState.bind(this)); + this.state = this.store.state; + + this.appChannel = new window.channel(); + this.api = new GlobalApi(this.ship, this.appChannel, this.store); + this.subscription = + new GlobalSubscription(this.store, this.api, this.appChannel); + + this.updateTheme = this.updateTheme.bind(this); + this.setFavicon = this.setFavicon.bind(this); + } + + componentDidMount() { + this.subscription.start(); + this.themeWatcher = window.matchMedia('(prefers-color-scheme: dark)'); + this.api.local.setDark(this.themeWatcher.matches); + this.themeWatcher.addListener(this.updateTheme); + this.api.local.getBaseHash(); + Mousetrap.bindGlobal(['command+/', 'ctrl+/'], (e) => { + e.preventDefault(); + this.api.local.setOmnibox(); + }); + this.setFavicon(); + } + + componentWillUnmount() { + this.themeWatcher.removeListener(this.updateTheme); + } + + componentDidUpdate(prevProps, prevState, snapshot) { + this.setFavicon(); + } + + updateTheme(e) { + this.api.local.setDark(e.matches); + } + + setFavicon() { + if (window.ship.length < 14) { + let background = '#ffffff'; + if (this.state.contacts.hasOwnProperty('/~/default')) { + background = `#${uxToHex(this.state.contacts['/~/default'][window.ship].color)}`; + } + const foreground = Sigil.foregroundFromBackground(background); + const svg = sigiljs({ + patp: window.ship, + renderer: stringRenderer, + size: 16, + colors: [background, foreground] + }); + const dataurl = 'data:image/svg+xml;base64,' + btoa(svg); + const favicon = document.querySelector('[rel=icon]'); + favicon.href = dataurl; + favicon.type = 'image/svg+xml'; + } + } + + render() { + const { state } = this; + const associations = state.associations ? + state.associations : { contacts: {} }; + const theme = state.dark ? dark : light; + + return ( + + + + + + + + + + ); + } +} + + +export default process.env.NODE_ENV === 'production' ? App : hot(App); + diff --git a/pkg/interface/src/apps/chat/app.tsx b/pkg/interface/src/views/apps/chat/app.tsx similarity index 82% rename from pkg/interface/src/apps/chat/app.tsx rename to pkg/interface/src/views/apps/chat/app.tsx index 8f9adc843..ea8e97ad0 100644 --- a/pkg/interface/src/apps/chat/app.tsx +++ b/pkg/interface/src/views/apps/chat/app.tsx @@ -11,11 +11,11 @@ import { SettingsScreen } from './components/settings'; import { NewScreen } from './components/new'; import { JoinScreen } from './components/join'; import { NewDmScreen } from './components/new-dm'; -import { PatpNoSig } from '../../types/noun'; -import GlobalApi from '../../api/global'; -import { StoreState } from '../../store/type'; -import GlobalSubscription from '../../subscription/global'; -import {groupBunts} from '../../types/group-update'; +import { PatpNoSig } from '../../../types/noun'; +import GlobalApi from '../../logic/api/global'; +import { StoreState } from '../../logic/store/type'; +import GlobalSubscription from '../../logic/subscription/global'; +import {groupBunts} from '../../../types/group-update'; type ChatAppProps = StoreState & { ship: PatpNoSig; @@ -53,7 +53,6 @@ export default class ChatApp extends React.Component { const unreads = {}; let totalUnreads = 0; - const selectedGroups = props.selectedGroups ? props.selectedGroups : []; const associations = props.associations ? props.associations : { chat: {}, contacts: {} }; @@ -74,14 +73,7 @@ export default class ChatApp extends React.Component { unreads[stat] = Boolean(unread); if ( unread && - stat in associations.chat && - (selectedGroups.length === 0 || - selectedGroups - .map((e) => { - return e[0]; - }) - .includes(associations.chat?.[stat]?.['group-path']) || - props.groups[associations.chat?.[stat]?.['group-path']]?.hidden) + stat in associations.chat ) { totalUnreads += unread; } @@ -111,7 +103,6 @@ export default class ChatApp extends React.Component { inbox={inbox} messagePreviews={messagePreviews} associations={associations} - selectedGroups={selectedGroups} contacts={contacts} invites={invites['/chat'] || {}} unreads={unreads} @@ -286,44 +277,6 @@ export default class ChatApp extends React.Component { ); }} /> - { - let station = `/${props.match.params.ship}/${props.match.params.station}`; - - const popout = props.match.url.includes('/popout/'); - - const association = - station in associations['chat'] ? associations.chat[station] : {}; - const groupPath = association['group-path']; - - const group = groups[groupPath] || {}; - return ( - - - - ); - }} - /> & { + chatSynced: ChatHookUpdate; + station: any; + association: Association; + api: GlobalApi; + read: number; + length: number; + inbox: Inbox; + contacts: Contacts; + group: Group; + pendingMessages: Map; + s3: any; + popout: boolean; + sidebarShown: boolean; + chatInitialized: boolean; + envelopes: Envelope[]; +}; + +interface ChatScreenState { + messages: Map; +} + +export class ChatScreen extends Component { + lastNumPending = 0; + activityTimeout: NodeJS.Timeout | null = null; + + constructor(props) { + super(props); + + this.state = { + messages: new Map(), + }; + + moment.updateLocale("en", { + calendar: { + sameDay: "[Today]", + nextDay: "[Tomorrow]", + nextWeek: "dddd", + lastDay: "[Yesterday]", + lastWeek: "[Last] dddd", + sameElse: "DD/MM/YYYY", + }, + }); + } + + render() { + const { props, state } = this; + + const lastMsgNum = props.envelopes.length > 0 ? props.envelopes.length : 0; + const ownerContact = + window.ship in props.contacts ? props.contacts[window.ship] : false; + + const pendingMessages = (props.pendingMessages.get(props.station) || []) + .map((value) => ({ + ...value, + pending: true + })); + + const isChatMissing = + props.chatInitialized && + !(props.station in props.inbox) && + props.chatSynced && + !(props.station in props.chatSynced); + + const isChatLoading = + props.chatInitialized && + !(props.station in props.inbox) && + props.chatSynced && + (props.station in props.chatSynced); + + const isChatUnsynced = + props.chatSynced && + !(props.station in props.chatSynced) && + props.envelopes.length > 0; + + const unreadCount = props.length - props.read; + const unreadMsg = unreadCount > 0 && props.envelopes[unreadCount - 1]; + + return ( +
    + + + this.setState({ + messages: this.state.messages.set(props.station, msg) + })} + s3={props.s3} + placeholder="Message..." + message={this.state.messages.get(props.station) || ""} + deleteMessage={() => this.setState({ + messages: this.state.messages.set(props.station, "") + })} + /> +
    + ); + } +} diff --git a/pkg/interface/src/apps/chat/components/join.js b/pkg/interface/src/views/apps/chat/components/join.js similarity index 100% rename from pkg/interface/src/apps/chat/components/join.js rename to pkg/interface/src/views/apps/chat/components/join.js diff --git a/pkg/interface/src/views/apps/chat/components/lib/backlog-element.js b/pkg/interface/src/views/apps/chat/components/lib/backlog-element.js new file mode 100644 index 000000000..64f8b26ca --- /dev/null +++ b/pkg/interface/src/views/apps/chat/components/lib/backlog-element.js @@ -0,0 +1,22 @@ +import React, { Component } from 'react'; + +export const BacklogElement = (props) => { + if (!props.isChatLoading) { + return null; + } + return ( +
    +
    + +

    Past messages are being restored

    +
    +
    + ); +} diff --git a/pkg/interface/src/apps/chat/components/lib/channel-item.js b/pkg/interface/src/views/apps/chat/components/lib/channel-item.js similarity index 100% rename from pkg/interface/src/apps/chat/components/lib/channel-item.js rename to pkg/interface/src/views/apps/chat/components/lib/channel-item.js diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-editor.js b/pkg/interface/src/views/apps/chat/components/lib/chat-editor.js new file mode 100644 index 000000000..ee6dc4b6c --- /dev/null +++ b/pkg/interface/src/views/apps/chat/components/lib/chat-editor.js @@ -0,0 +1,141 @@ +import React, { Component } from 'react'; +import { UnControlled as CodeEditor } from 'react-codemirror2'; +import CodeMirror from 'codemirror'; + +import 'codemirror/mode/markdown/markdown'; +import 'codemirror/addon/display/placeholder'; + +import 'codemirror/lib/codemirror.css'; + +const BROWSER_REGEX = + new RegExp(String(/Android|webOS|iPhone|iPad|iPod|BlackBerry/i)); + + +const MARKDOWN_CONFIG = { + name: 'markdown', + tokenTypeOverrides: { + header: 'presentation', + quote: 'presentation', + list1: 'presentation', + list2: 'presentation', + list3: 'presentation', + hr: 'presentation', + image: 'presentation', + imageAltText: 'presentation', + imageMarker: 'presentation', + formatting: 'presentation', + linkInline: 'presentation', + linkEmail: 'presentation', + linkText: 'presentation', + linkHref: 'presentation' + } +}; + +export default class ChatEditor extends Component { + constructor(props) { + super(props); + + this.state = { + message: props.message + }; + this.editor = null; + } + + componentWillUnmount() { + this.props.onUnmount(this.state.message); + } + + componentDidUpdate(prevProps) { + const { props } = this; + + if (prevProps.message !== props.message) { + this.editor.setValue(props.message); + this.editor.setOption('mode', MARKDOWN_CONFIG); + return; + } + + if (!props.inCodeMode) { + this.editor.setOption('mode', MARKDOWN_CONFIG); + this.editor.setOption('placeholder', this.props.placeholder); + } else { + this.editor.setOption('mode', null); + this.editor.setOption('placeholder', 'Code...'); + } + const value = this.editor.getValue(); + + // Force redraw of placeholder + if(value.length === 0) { + this.editor.setValue(' '); + this.editor.setValue(''); + } + } + + submit() { + if(!this.editor) { + return; + } + + let editorMessage = this.editor.getValue(); + if (editorMessage === '') { + return; + } + + this.setState({ message: '' }); + this.props.submit(editorMessage); + this.editor.setValue(''); + } + + messageChange(editor, data, value) { + if (this.state.message !== '' && value == '') { + this.setState({ + message: value + }); + } + if (value == this.props.message || value == '' || value == ' ') { + return; + } + this.setState({ + message: value + }); + } + + render() { + const { props } = this; + + const codeTheme = props.inCodeMode ? ' code' : ''; + + const options = { + mode: MARKDOWN_CONFIG, + theme: 'tlon' + codeTheme, + lineNumbers: false, + lineWrapping: true, + scrollbarStyle: 'native', + cursorHeight: 0.85, + placeholder: props.inCodeMode ? 'Code...' : props.placeholder, + extraKeys: { + 'Enter': () => { + this.submit(); + } + } + }; + + return ( +
    + this.messageChange(e, d, v)} + editorDidMount={(editor) => { + this.editor = editor; + if (!(BROWSER_REGEX.test(navigator.userAgent))) { + editor.focus(); + } + }} + /> +
    + ); + } +} diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-header.js b/pkg/interface/src/views/apps/chat/components/lib/chat-header.js new file mode 100644 index 000000000..ae503ee09 --- /dev/null +++ b/pkg/interface/src/views/apps/chat/components/lib/chat-header.js @@ -0,0 +1,58 @@ +import React, { Component, Fragment } from "react"; +import { Link } from "react-router-dom"; + +import { ChatTabBar } from "./chat-tabbar"; +import { SidebarSwitcher } from "../../../../components/SidebarSwitch"; +import { deSig } from "../../../../../logic/lib/util"; + + +export const ChatHeader = (props) => { + const isInPopout = props.popout ? "popout/" : ""; + const group = Array.from(props.group.members); + let title = props.station.substr(1); + if (props.association && + "metadata" in props.association && + props.association.metadata.tile !== "") { + title = props.association.metadata.title + } + + return ( + +
    + {"⟵ All Chats"} +
    +
    + + +

    + {title} +

    + + +
    +
    + ); +} diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-input.js b/pkg/interface/src/views/apps/chat/components/lib/chat-input.js new file mode 100644 index 000000000..c18beb8d9 --- /dev/null +++ b/pkg/interface/src/views/apps/chat/components/lib/chat-input.js @@ -0,0 +1,265 @@ +import React, { Component } from 'react'; +import ChatEditor from './chat-editor'; +import { S3Upload } from './s3-upload' +; +import { uxToHex } from '../../../../../logic/lib/util'; +import { Sigil } from '../../../../../logic/lib/sigil'; + + +const URL_REGEX = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)/.source)); + +export class ChatInput extends Component { + constructor(props) { + super(props); + + this.state = { + inCodeMode: false, + }; + + this.submit = this.submit.bind(this); + this.toggleCode = this.toggleCode.bind(this); + } + + uploadSuccess(url) { + const { props } = this; + props.api.chat.message( + props.station, + `~${window.ship}`, + Date.now(), + { url } + ); + } + + uploadError(error) { + // no-op for now + } + + toggleCode() { + this.setState({ + inCodeMode: !this.state.inCodeMode + }); + } + + getLetterType(letter) { + if (letter.startsWith('/me ')) { + letter = letter.slice(4); + // remove insignificant leading whitespace. + // aces might be relevant to style. + while (letter[0] === '\n') { + letter = letter.slice(1); + } + + return { + me: letter + }; + } else if (this.isUrl(letter)) { + return { + url: letter + }; + } else { + return { + text: letter + }; + } + } + + isUrl(string) { + try { + return URL_REGEX.test(string); + } catch (e) { + return false; + } + } + + submit(text) { + const { props, state } = this; + if (state.inCodeMode) { + this.setState({ + inCodeMode: false + }, () => { + props.api.chat.message( + props.station, + `~${window.ship}`, + Date.now(), { + code: { + expression: text, + output: undefined + } + } + ); + }); + return; + } + + let messages = []; + let message = []; + let isInCodeBlock = false; + let endOfCodeBlock = false; + text.split(/\r?\n/).forEach((line, index) => { + if (index !== 0) { + message.push('\n'); + } + // A line of backticks enters and exits a codeblock + if (line.startsWith('```')) { + // But we need to check if we've ended a codeblock + endOfCodeBlock = isInCodeBlock; + isInCodeBlock = (!isInCodeBlock); + } else { + endOfCodeBlock = false; + } + + if (isInCodeBlock || endOfCodeBlock) { + message.push(line); + } else { + line.split(/\s/).forEach((str) => { + if ( + (str.startsWith('`') && str !== '`') + || (str === '`' && !isInCodeBlock) + ) { + isInCodeBlock = true; + } else if ( + (str.endsWith('`') && str !== '`') + || (str === '`' && isInCodeBlock) + ) { + isInCodeBlock = false; + } + + if (this.isUrl(str) && !isInCodeBlock) { + if (message.length > 0) { + // If we're in the middle of a message, add it to the stack and reset + messages.push(message); + message = []; + } + messages.push([str]); + message = []; + } else { + message.push(str); + } + }); + } + }); + + if (message.length) { + // Add any remaining message + messages.push(message); + } + + props.deleteMessage(); + + messages.forEach((message) => { + if (message.length > 0) { + message = this.getLetterType(message.join(' ')); + props.api.chat.message( + props.station, + `~${window.ship}`, + Date.now(), + message + ); + } + }); + + // perf testing: + /*let closure = () => { + let x = 0; + for (var i = 0; i < 30; i++) { + x++; + props.api.chat.message( + props.station, + `~${window.ship}`, + Date.now(), + { + text: `${x}` + } + ); + } + setTimeout(closure, 1000); + }; + this.closure = closure.bind(this); + setTimeout(this.closure, 2000);*/ + } + + uploadSuccess(url) { + const { props } = this; + props.api.chat.message( + props.station, + `~${window.ship}`, + Date.now(), + { url } + ); + } + + uploadError(error) { + // no-op for now + } + + render() { + const { props, state } = this; + + const color = props.ownerContact + ? uxToHex(props.ownerContact.color) : '000000'; + + const sigilClass = props.ownerContact + ? '' : 'mix-blend-diff'; + + const avatar = (props.ownerContact && (props.ownerContact.avatar !== null)) + ? + : ; + + return ( +
    +
    + {avatar} +
    + +
    + +
    +
    + +
    +
    + ); + } +} diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-message.tsx b/pkg/interface/src/views/apps/chat/components/lib/chat-message.tsx new file mode 100644 index 000000000..84b985417 --- /dev/null +++ b/pkg/interface/src/views/apps/chat/components/lib/chat-message.tsx @@ -0,0 +1,84 @@ +import React, { PureComponent, Fragment } from "react"; +import moment from "moment"; + +import { Message } from "./message"; + +type IMessage = Envelope & { pending?: boolean }; + + +export const ChatMessage = (props) => { + const { + msg, + previousMsg, + nextMsg, + isLastUnread, + group, + association, + contacts, + unreadRef + } = props; + + // Render sigil if previous message is not by the same sender + const aut = ["author"]; + const renderSigil = + _.get(nextMsg, aut) !== _.get(msg, aut, msg.author); + const paddingTop = renderSigil; + const paddingBot = + _.get(previousMsg, aut) !== _.get(msg, aut, msg.author); + + const when = ["when"]; + const dayBreak = + moment(_.get(nextMsg, when)).format("YYYY.MM.DD") !== + moment(_.get(msg, when)).format("YYYY.MM.DD"); + + const messageElem = ( + + ); + + if (props.isLastUnread) { + return ( + + {messageElem} +
    +
    +

    New messages below

    +
    + {dayBreak && ( +

    + {moment(_.get(msg, when)).calendar()} +

    + )} +
    +
    +
    + ); + } else if (dayBreak) { + return ( + + {messageElem} +
    +

    {moment(_.get(msg, when)).calendar()}

    +
    +
    + ); + } else { + return messageElem; + } +}; + diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-scroll-container.js b/pkg/interface/src/views/apps/chat/components/lib/chat-scroll-container.js new file mode 100644 index 000000000..a0a531feb --- /dev/null +++ b/pkg/interface/src/views/apps/chat/components/lib/chat-scroll-container.js @@ -0,0 +1,143 @@ +import React, { Component, Fragment } from "react"; + +import { scrollIsAtTop, scrollIsAtBottom } from "../../../../../logic/lib/util"; + +// Restore chat position on FF when new messages come in +const recalculateScrollTop = (lastScrollHeight, scrollContainer) => { + if (!scrollContainer || !lastScrollHeight) { + return; + } + + const newScrollTop = scrollContainer.scrollHeight - lastScrollHeight; + if (scrollContainer.scrollTop !== 0 || + scrollContainer.scrollTop === newScrollTop) { + return; + } + + scrollContainer.scrollTop = scrollContainer.scrollHeight - lastScrollHeight; +}; + + +export class ChatScrollContainer extends Component { + constructor(props) { + super(props); + + // only for FF + this.state = { + lastScrollHeight: null + }; + + this.isTriggeredScroll = false; + + this.isAtBottom = true; + this.isAtTop = false; + + this.containerDidScroll = this.containerDidScroll.bind(this); + + this.containerRef = React.createRef(); + this.scrollRef = React.createRef(); + } + + containerDidScroll(e) { + const { props } = this; + if (scrollIsAtTop(e.target)) { + // Save scroll position for FF + if (navigator.userAgent.includes("Firefox")) { + this.setState({ + lastScrollHeight: e.target.scrollHeight, + }); + } + + if (!this.isAtTop) { + props.scrollIsAtTop(); + } + + this.isTriggeredScroll = false; + this.isAtBottom = false; + this.isAtTop = true; + } else if (scrollIsAtBottom(e.target) && !this.isTriggeredScroll) { + if (!this.isAtBottom) { + props.scrollIsAtBottom(); + } + + this.isTriggeredScroll = false; + this.isAtBottom = true; + this.isAtTop = false; + } else { + this.isAtBottom = false; + this.isAtTop = false; + this.isTriggeredScroll = false; + } + } + + render() { + // Replace with just the "not Firefox" implementation + // when Firefox #1042151 is patched. + + if (navigator.userAgent.includes("Firefox")) { + return this.firefoxScrollContainer(); + } else { + return this.normalScrollContainer(); + } + } + + firefoxScrollContainer() { + return ( +
    +
    +
    + {this.props.children} +
    +
    + ); + } + + normalScrollContainer() { + return ( +
    +
    + {this.props.children} +
    + ); + } + + scrollToBottom() { + this.isTriggeredScroll = true; + if (this.scrollRef.current) { + this.scrollRef.current.scrollIntoView(false); + } + + if (navigator.userAgent.includes("Firefox")) { + recalculateScrollTop( + this.state.lastScrollHeight, + this.scrollContainer + ); + } + } + + scrollToReference(ref) { + this.isTriggeredScroll = true; + if (this.scrollRef.current && ref.current) { + ref.current.scrollIntoView({ block: 'center' }); + } + + if (navigator.userAgent.includes("Firefox")) { + recalculateScrollTop( + this.state.lastScrollHeight, + this.scrollContainer + ); + } + } + +} diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-tabbar.js b/pkg/interface/src/views/apps/chat/components/lib/chat-tabbar.js new file mode 100644 index 000000000..69df556d3 --- /dev/null +++ b/pkg/interface/src/views/apps/chat/components/lib/chat-tabbar.js @@ -0,0 +1,41 @@ +import React, { Component } from 'react'; +import { Link } from 'react-router-dom'; + +export const ChatTabBar = (props) => { + const { + location, + station + } = props; + let setColor = '', popout = ''; + + if (location.pathname.includes('/settings')) { + setColor = 'black white-d'; + } else { + setColor = 'gray3'; + } + + const hidePopoutIcon = (popout) + ? 'dn-m dn-l dn-xl' : 'dib-m dib-l dib-xl'; + + return ( +
    +
    + + Settings + +
    + + + +
    + ); +} diff --git a/pkg/interface/src/views/apps/chat/components/lib/chat-window.tsx b/pkg/interface/src/views/apps/chat/components/lib/chat-window.tsx new file mode 100644 index 000000000..36a3a2c45 --- /dev/null +++ b/pkg/interface/src/views/apps/chat/components/lib/chat-window.tsx @@ -0,0 +1,195 @@ +import React, { Component, Fragment } from "react"; + +import { ChatMessage } from './chat-message'; +import { ChatScrollContainer } from "./chat-scroll-container"; +import { UnreadNotice } from "./unread-notice"; +import { ResubscribeElement } from "./resubscribe-element"; +import { BacklogElement } from "./backlog-element"; + +const MAX_BACKLOG_SIZE = 1000; +const DEFAULT_BACKLOG_SIZE = 200; +const PAGE_SIZE = 50; +const INITIAL_LOAD = 20; + + +export class ChatWindow extends Component { + constructor(props) { + super(props); + this.state = { + numPages: 1, + }; + + this.hasAskedForMessages = false; + + this.dismissUnread = this.dismissUnread.bind(this); + this.scrollIsAtBottom = this.scrollIsAtBottom.bind(this); + this.scrollIsAtTop = this.scrollIsAtTop.bind(this); + + this.scrollReference = React.createRef(); + this.unreadReference = React.createRef(); + } + + componentDidMount() { + this.initialFetch(); + + if (this.state.numPages === 1 && this.props.unreadCount < INITIAL_LOAD) { + this.dismissUnread(); + this.scrollToBottom(); + } + } + + initialFetch() { + const { props } = this; + if (props.messages.length > 0) { + const unreadUnloaded = props.unreadCount - props.messages.length; + + if (unreadUnloaded <= MAX_BACKLOG_SIZE && + unreadUnloaded + INITIAL_LOAD > DEFAULT_BACKLOG_SIZE) { + this.fetchBacklog(unreadUnloaded + INITIAL_LOAD); + } else { + this.fetchBacklog(DEFAULT_BACKLOG_SIZE); + } + } else { + setTimeout(() => { + this.initialFetch(); + }, 2000); + } + } + + componentDidUpdate(prevProps, prevState) { + const { props, state } = this; + + if (props.isChatMissing) { + props.history.push("/~chat"); + } else if (props.messages.length >= prevProps.messages.length + 10) { + this.hasAskedForMessages = false; + let numPages = props.unreadCount > 0 ? + Math.ceil(props.unreadCount / PAGE_SIZE) : this.state.numPages; + + if (this.state.numPages === numPages) { + if (props.unreadCount > 20) { + this.scrollToUnread(); + } + } else { + this.setState({ numPages }, () => { + if (props.unreadCount > 20) { + this.scrollToUnread(); + } + }); + } + } else if ( + state.numPages === 1 && + this.props.unreadCount < INITIAL_LOAD && + this.props.unreadCount > 0 + ) { + this.dismissUnread(); + this.scrollToBottom(); + } + } + + scrollIsAtTop() { + const { props, state } = this; + this.setState({ numPages: state.numPages + 1 }, () => { + if (state.numPages * PAGE_SIZE < props.length) { + this.fetchBacklog(DEFAULT_BACKLOG_SIZE); + } + }); + } + + scrollIsAtBottom() { + if (this.state.numPages !== 1) { + this.setState({ numPages: 1 }); + this.dismissUnread(); + } + } + + scrollToBottom() { + if (this.scrollReference.current) { + this.scrollReference.current.scrollToBottom(); + } + if (this.state.numPages !== 1) { + this.setState({ numPages: 1 }); + } + } + + scrollToUnread() { + if (this.scrollReference.current && this.unreadReference.current) { + this.scrollReference.current.scrollToReference(this.unreadReference); + } + } + + dismissUnread() { + this.props.api.chat.read(this.props.station); + } + + fetchBacklog(size) { + const { props } = this; + + if ( + props.messages.length >= props.length || + this.hasAskedForMessages || + props.length <= 0 + ) { + return; + } + + const start = + props.length - props.messages[props.messages.length - 1].number; + if (start > 0) { + const end = start + size < props.length ? start + size : props.length; + props.api.chat.fetchMessages(start + 1, end, props.station); + this.hasAskedForMessages = true; + } + } + + render() { + const { props, state } = this; + const sliceLength = Math.min( + state.numPages * PAGE_SIZE, + props.messages.length + props.pendingMessages.length + ); + const messages = + props.pendingMessages + .concat(props.messages) + .slice(0, sliceLength); + + return ( + + + + + + { messages.map((msg, i) => ( + 0 && + i === props.unreadCount - 1 && + state.numPages !== 1 + } + msg={msg} + previousMsg={messages[i - 1]} + nextMsg={messages[i + 1]} + association={props.association} + group={props.group} + contacts={props.contacts} /> + )) + } + + + ); + } +} + diff --git a/pkg/interface/src/apps/chat/components/lib/content/code.js b/pkg/interface/src/views/apps/chat/components/lib/content/code.js similarity index 100% rename from pkg/interface/src/apps/chat/components/lib/content/code.js rename to pkg/interface/src/views/apps/chat/components/lib/content/code.js diff --git a/pkg/interface/src/apps/chat/components/lib/content/text.js b/pkg/interface/src/views/apps/chat/components/lib/content/text.js similarity index 97% rename from pkg/interface/src/apps/chat/components/lib/content/text.js rename to pkg/interface/src/views/apps/chat/components/lib/content/text.js index ec1153b8d..f44d9bd6a 100644 --- a/pkg/interface/src/apps/chat/components/lib/content/text.js +++ b/pkg/interface/src/views/apps/chat/components/lib/content/text.js @@ -1,8 +1,8 @@ import React, { Component } from 'react'; +import { Link } from 'react-router-dom'; import ReactMarkdown from 'react-markdown'; import RemarkDisableTokenizers from 'remark-disable-tokenizers'; import urbitOb from 'urbit-ob'; -import { Link } from 'react-router-dom'; const DISABLED_BLOCK_TOKENS = [ 'indentedCode', diff --git a/pkg/interface/src/apps/chat/components/lib/content/url.js b/pkg/interface/src/views/apps/chat/components/lib/content/url.js similarity index 74% rename from pkg/interface/src/apps/chat/components/lib/content/url.js rename to pkg/interface/src/views/apps/chat/components/lib/content/url.js index e1f59dd5f..21fb6dff1 100644 --- a/pkg/interface/src/apps/chat/components/lib/content/url.js +++ b/pkg/interface/src/views/apps/chat/components/lib/content/url.js @@ -1,8 +1,6 @@ import React, { Component } from 'react'; - -const IMAGE_REGEX = - /(jpg|img|png|gif|tiff|jpeg|JPG|IMG|PNG|TIFF|GIF|webp|WEBP|webm|WEBM|svg|SVG)$/; +const IMAGE_REGEX = new RegExp(/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i); const YOUTUBE_REGEX = new RegExp( @@ -25,8 +23,7 @@ export default class UrlContent extends Component { let unfoldState = this.state.unfold; unfoldState = !unfoldState; this.setState({ unfold: unfoldState }); - const iframe = this.refs.iframe; - iframe.setAttribute('src', iframe.getAttribute('data-src')); + this.iframe.setAttribute('src', this.iframe.dataset.src); } render() { @@ -42,13 +39,12 @@ export default class UrlContent extends Component { className="o-80-d" src={content.url} style={{ - width: '50%', - maxWidth: '250px' + maxWidth: '18rem' }} > ); return ( -