Merge branch 'release/next-userspace' into mp/links-indigo

This commit is contained in:
Matilde Park 2020-08-14 16:04:36 -04:00
commit 9a819746b9
206 changed files with 3330 additions and 2882 deletions

View File

@ -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

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d7088528dbfd54a913921ade093251d678c4ccebfd0ad85ef2022520266b3954
size 16451173
oid sha256:ef417c3092dc32d6d5897a7ba63f3f8910f928f0aa23adf3a356b88ce027a415
size 6260173

View File

@ -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

View File

@ -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)])]~
::

Binary file not shown.

After

Width:  |  Height:  |  Size: 693 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 255 B

After

Width:  |  Height:  |  Size: 582 B

View File

@ -4,11 +4,11 @@
<title>OS1</title>
<meta charset="utf-8" />
<meta name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no"/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-touch-fullscreen" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" href="/~landscape/img/touch_icon.png">
content="width=device-width, initial-scale=1, shrink-to-fit=no,maximum-scale=1"/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-touch-fullscreen" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<link rel="apple-touch-icon" href="/~landscape/img/touch_icon.png">
<link rel="icon" type="image/png" href="/~landscape/img/Favicon.png">
<link rel="manifest"
href='data:application/manifest+json,{
@ -20,10 +20,10 @@
"theme_color": "%23000000"}' />
</head>
<body>
<div id="root"/>
<div id="root"></div>
<script src="/~landscape/js/channel.js"></script>
<script src="/~landscape/js/session.js"></script>
<script src="/~landscape/js/index.js"></script>
<script src="/~landscape/js/bundle/index.8e25dc41456a44c967da.js"></script>
<script src="https://sdk.amazonaws.com/js/aws-sdk-2.1.12.min.js"></script>
</body>
</html>

View File

@ -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))

View File

@ -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

186
pkg/interface/.eslintrc.js Normal file
View File

@ -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
}
]
};

View File

@ -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
}
}

View File

@ -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'
},

View File

@ -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,

View File

@ -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": {

View File

@ -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",

View File

@ -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 (
<ThemeProvider theme={theme}>
<Root>
<Router>
<StatusBarWithRouter props={this.props}
associations={associations}
invites={this.state.invites}
api={this.api}
connection={this.state.connection}
subscription={this.subscription}
/>
<Content>
<Switch>
<Route exact path="/"
render={ p => (
<LaunchApp
ship={this.ship}
api={this.api}
{...state}
{...p}
/>
)}
/>
<Route path="/~chat" render={ p => (
<ChatApp
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state}
{...p}
/>
)}
/>
<Route path="/~dojo" render={ p => (
<DojoApp
ship={this.ship}
channel={channel}
selectedGroups={selectedGroups}
subscription={this.subscription}
{...p}
/>
)}
/>
<Route path="/~groups" render={ p => (
<GroupsApp
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state}
{...p}
/>
)}
/>
<Route path="/~link" render={ p => (
<LinksApp
ship={this.ship}
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state}
{...p}
/>
)}
/>
<Route path="/~publish" render={ p => (
<PublishApp
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state}
{...p}
/>
)}
/>
<Route
render={(props) => (
<ErrorComponent {...props} code={404} description="Not Found" />
)}
/>
</Switch>
</Content>
</Router>
</Root>
</ThemeProvider>
);
}
}
export default process.env.NODE_ENV === 'production' ? App : hot(App);

View File

@ -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<Path, Envelope[]>;
s3: any;
popout: boolean;
sidebarShown: boolean;
chatInitialized: boolean;
envelopes: Envelope[];
};
interface ChatScreenState {
numPages: number;
scrollLocked: boolean;
read: number;
active: boolean;
messages: Map<string, string>;
lastScrollHeight: number | null;
}
export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
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 = (
<Message
key={msg.uid}
msg={msg}
contacts={props.contacts}
renderSigil={renderSigil}
paddingTop={paddingTop}
paddingBot={paddingBot}
pending={Boolean(msg.pending)}
group={props.group}
association={props.association}
/>
);
if (unread > 0 && i === unread - 1) {
return (
<Fragment key={msg.uid}>
{messageElem}
<div
ref={this.setUnreadMarker}
className="mv2 green2 flex items-center f9"
>
<hr className="dn-s ma0 w2 b--green2 bt-0" />
<p className="mh4">New messages below</p>
<hr className="ma0 flex-grow-1 b--green2 bt-0" />
{dayBreak && (
<p className="gray2 mh4">
{moment(_.get(messages[i], when)).calendar()}
</p>
)}
<hr
style={{ width: "calc(50% - 48px)" }}
className="b--green2 ma0 bt-0"
/>
</div>
</Fragment>
);
} else if (dayBreak) {
return (
<Fragment key={msg.uid}>
{messageElem}
<div
className="pv3 gray2 b--gray2 flex items-center justify-center f9 "
>
<p>{moment(_.get(messages[i], when)).calendar()}</p>
</div>
</Fragment>
);
} else {
return messageElem;
}
});
if (navigator.userAgent.includes("Firefox")) {
return (
<div
className="relative overflow-y-scroll h-100"
onScroll={this.onScroll}
ref={(e) => {
this.scrollContainer = e;
}}
>
<div
className="bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse"
style={{ resize: "vertical" }}
>
<div
ref={(el) => {
this.scrollElement = el;
}}
></div>
{props.chatInitialized && !(props.station in props.inbox) && (
<BacklogElement />
)}
{props.chatSynced &&
!(props.station in props.chatSynced) &&
messages.length > 0 ? (
<ResubscribeElement
api={props.api}
host={props.match.params.ship}
station={props.station}
/>
) : (
<div />
)}
{messageElements}
</div>
</div>
);
} else {
return (
<div
className="overflow-y-scroll bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse relative"
style={{ height: "100%", resize: "vertical" }}
onScroll={this.onScroll}
>
<div
ref={(el) => {
this.scrollElement = el;
}}
></div>
{props.chatInitialized && !(props.station in props.inbox) && (
<BacklogElement />
)}
{props.chatSynced &&
!(props.station in props.chatSynced) &&
messages.length > 0 ? (
<ResubscribeElement
api={props.api}
host={props.match.params.ship}
station={props.station}
/>
) : (
<div />
)}
{messageElements}
</div>
);
}
}
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 (
<div
key={props.station}
className="h-100 w-100 overflow-hidden flex flex-column relative"
>
<div
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: "1rem" }}
>
<Link to="/~chat/">{"⟵ All Chats"}</Link>
</div>
<div
className={
"pl4 pt2 bb b--gray4 b--gray1-d bg-gray0-d flex relative " +
"overflow-x-auto overflow-y-hidden flex-shrink-0 "
}
style={{ height: 48 }}
>
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}
api={props.api}
/>
<Link
to={"/~chat/" + isinPopout + "room" + props.station}
className="pt2 white-d"
>
<h2
className={
"dib f9 fw4 lh-solid v-top " +
(title === props.station.substr(1) ? "mono" : "")
}
style={{ width: "max-content" }}
>
{title}
</h2>
</Link>
<ChatTabBar
{...props}
station={props.station}
numPeers={group.length}
isOwner={deSig(props.match.params.ship) === window.ship}
popout={props.popout}
api={props.api}
/>
</div>
{!!unreadMsg && showUnreadNotice && (
<UnreadNotice
unread={unread}
unreadMsg={unreadMsg}
onRead={() => this.dismissUnread()}
/>
)}
{this.chatWindow(unread)}
<ChatInput
api={props.api}
numMsgs={lastMsgNum}
station={props.station}
owner={deSig(props.match.params.ship)}
ownerContact={ownerContact}
envelopes={props.envelopes}
contacts={props.contacts}
onEnter={() => 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) || ""}
/>
</div>
);
}
}

View File

@ -1,21 +0,0 @@
import React, { Component } from 'react';
export class BacklogElement extends Component {
render() {
return (
<div className="center mw6">
<div className="db pa3 ma3 ba b--gray4 bg-gray5 b--gray2-d bg-gray1-d white-d flex items-center">
<img className="invert-d spin-active v-mid"
src="/~chat/img/Spinner.png"
width={16}
height={16}
/>
<p className="lh-copy db ml3">
Past messages are being restored
</p>
</div>
</div>
);
}
}

View File

@ -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))
? <img src={props.ownerContact.avatar} height={24} width={24} className="dib" />
: <Sigil
ship={window.ship}
size={24}
color={`#${color}`}
classes={sigilClass}
/>;
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 (
<div className="chat pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white bg-gray0-d relative"
style={{ flexGrow: 1 }}
>
<ShipSearch
popover
onSelect={this.completePatp}
onClear={this.clearSearch}
contacts={props.contacts}
candidates={candidates}
searchTerm={this.state.patpSearch}
cm={this.editor}
/>
<div
className="fl"
style={{
marginTop: 6,
flexBasis: 24,
height: 24
}}
>
{img}
</div>
<div
className="fr h-100 flex bg-gray0-d lh-copy pl2 w-100 items-center"
style={{ flexGrow: 1, maxHeight: '224px', width: 'calc(100% - 72px)' }}
>
<CodeEditor
value={this.props.message}
options={options}
editorDidMount={(editor) => {
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)}
/>
</div>
<div className="ml2 mr2"
style={{ height: '16px', width: '16px', flexBasis: 16, marginTop: 10 }}>
<S3Upload
configuration={props.s3.configuration}
credentials={props.s3.credentials}
uploadSuccess={this.uploadSuccess.bind(this)}
uploadError={this.uploadError.bind(this)}
/>
</div>
<div style={{ height: '16px', width: '16px', flexBasis: 16, marginTop: 10 }}>
<img
style={{ filter: state.code && 'invert(100%)', height: '14px', width: '14px' }}
onClick={this.toggleCode}
src="/~chat/img/CodeEval.png"
className="contrast-10-d bg-white bg-none-d ba b--gray1-d br1"
/>
</div>
</div>
);
}
}

View File

@ -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 (
<div className="dib flex-shrink-0 flex-grow-1">
{props.isOwner ? (
<div className={'dib pt2 f9 pl6 lh-solid'}>
<Link
className={'no-underline ' + memColor}
to={'/~chat/' + popout + 'members' + props.station}
>
Members
</Link>
</div>
) : (
<div className="dib" style={{ width: 0 }}></div>
)}
<div className={'dib pt2 f9 pl6 pr6 lh-solid'}>
<Link
className={'no-underline ' + setColor}
to={'/~chat/' + popout + 'settings' + props.station}
>
Settings
</Link>
</div>
<a href={'/~chat/popout/room' + props.station} rel="noopener noreferrer"
target="_blank"
className="dib fr pr1"
style={{ paddingTop: '8px' }}
>
<img
className={'flex-shrink-0 pr3 dn ' + hidePopoutIcon}
src="/~chat/img/popout.png"
height="16"
width="16"
/>
</a>
</div>
);
}
}

View File

@ -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 (
<div className="db pa3 ma3 ba b--yellow2 bg-yellow0">
<p className="lh-copy db">
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.
</p>
<a onClick={this.onClickResubscribe.bind(this)}
className="db underline black pointer mt3"
>
Reconnect to this chat
</a>
</div>
);
}
}

View File

@ -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 (
<div
style={{ left: '0px' }}
className="pa4 w-100 absolute z-1 unread-notice"
>
<div className="ba b--green2 green2 bg-white bg-gray0-d flex items-center pa2 f9 justify-between br1">
<p className="lh-copy db">
{unread} new messages since{' '}
{datestamp && (
<>
<span className="green3">~{datestamp}</span> at{' '}
</>
)}
<span className="green3">{timestamp}</span>
</p>
<div onClick={onRead} className="ml4 inter b--green2 pointer tr lh-copy">
Mark as Read
</div>
</div>
</div>
);
}
}

View File

@ -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 (
<div className='h-100 w-100 overflow-x-hidden flex flex-column white-d'>
<div
className='w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8'
style={{ height: '1rem' }}
>
<Link to='/~chat/'>{'⟵ All Chats'}</Link>
</div>
<div
className={`pl4 pt2 bb b--gray4 b--gray1-d bg-gray0-d flex relative
overflow-x-scroll overflow-x-auto-l overflow-x-auto-xl flex-shrink-0`}
style={{ height: 48 }}
>
<SidebarSwitcher
sidebarShown={this.props.sidebarShown}
popout={this.props.popout}
api={this.props.api}
/>
<Link
to={'/~chat/' + isinPopout + 'room' + props.station}
className='pt2 white-d'
>
<h2
className={
'dib f9 fw4 lh-solid v-top ' +
(title === props.station.substr(1) ? 'mono' : '')
}
style={{ width: 'max-content' }}
>
{title}
</h2>
</Link>
<ChatTabBar
{...props}
station={props.station}
numPeers={5}
isOwner={deSig(props.match.params.ship) === window.ship}
popout={this.props.popout}
api={props.api}
/>
</div>
<div className='w-100 pl3 mt0 mt4-m mt4-l mt4-xl cf pr6'>
{ props.association['group-path'] && (
<GroupView
permissions
group={props.group}
resourcePath={props.association['group-path'] || ''}
associations={props.associations}
groups={props.groups}
inviteShips={this.inviteShips}
contacts={props.contacts}
/> )}
</div>
</div>
);
}
}

View File

@ -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 (
<div>
<div className={'w-100 fl mt3 ' + ((chatOwner) ? 'o-30' : '')}>
<p className="f8 mt3 lh-copy db">Leave Chat</p>
<p className="f9 gray2 db mb4">Remove this chat from your chat list. You will need to request for access again.</p>
<a onClick={(!chatOwner) ? this.deleteChat.bind(this) : null}
className={'dib f9 black gray4-d bg-gray0-d ba pa2 b--black b--gray1-d ' + leaveButtonClasses}
>Leave this chat</a>
</div>
<div className={'w-100 fl mt3 ' + ((!chatOwner) ? 'o-30' : '')}>
<p className="f8 mt3 lh-copy db">Delete Chat</p>
<p className="f9 gray2 db mb4">Permanently delete this chat. All current members will no longer see this chat.</p>
<a onClick={(chatOwner) ? this.deleteChat.bind(this) : null}
className={'dib f9 ba pa2 ' + deleteButtonClasses}
>Delete this chat</a>
</div>
</div>
);
}
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 = <div />;
if (state.targetGroup) {
inclusiveToggle = (
<div className="mt4">
<Toggle
boolean={state.inclusive}
change={this.changeInclusive}
/>
<span className="dib f9 white-d inter ml3">
Add all members to group
</span>
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
Add chat members to the group if they aren't in it yet
</p>
</div>
);
}
return (
<div>
<div className={'w-100 fl mt3'} style={{ maxWidth: '29rem' }}>
<p className="f8 mt3 lh-copy db">Convert Chat</p>
<p className="f9 gray2 db mb4">
Convert this chat into a group with associated chat, or select a
group to add this chat to.
</p>
<InviteSearch
groups={props.groups}
contacts={props.contacts}
associations={props.associations}
groupResults={true}
shipResults={false}
invites={{
groups: state.targetGroup ? [state.targetGroup] : [],
ships: []
}}
setInvite={this.changeTargetGroup}
/>
{inclusiveToggle}
<a onClick={this.groupifyChat.bind(this)}
className={'dib f9 black gray4-d bg-gray0-d ba pa2 mt4 b--black b--gray1-d pointer'}
>
Convert to group
</a>
</div>
</div>
);
}
}
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(
<div>
<div className={'w-100 pb6 fl mt3 ' + ((chatOwner) ? '' : 'o-30')}>
<p className="f8 mt3 lh-copy">Rename</p>
<p className="f9 gray2 db mb4">Change the name of this chat</p>
<div className="relative w-100 flex"
style={{ maxWidth: '29rem' }}
>
<input
className={'f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'}
value={state.title}
disabled={!chatOwner}
onChange={this.changeTitle}
onBlur={() => {
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 });
});
}));
}
}}
/>
</div>
<p className="f8 mt3 lh-copy">Change description</p>
<p className="f9 gray2 db mb4">Change the description of this chat</p>
<div className="relative w-100 flex"
style={{ maxWidth: '29rem' }}
>
<input
className={'f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'}
value={state.description}
disabled={!chatOwner}
onChange={this.changeDescription}
onBlur={() => {
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 });
});
}));
}
}}
/>
</div>
<p className="f8 mt3 lh-copy">Change color</p>
<p className="f9 gray2 db mb4">Give this chat a color when viewing group channels</p>
<div className="relative w-100 flex"
style={{ maxWidth: '10rem' }}
>
<div className="absolute"
style={{
height: 16,
width: 16,
backgroundColor: state.color,
top: 13,
left: 11
}}
/>
<input
className={'pl7 f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'}
value={state.color}
disabled={!chatOwner}
onChange={this.changeColor}
onBlur={this.submitColor}
/>
</div>
</div>
</div>
);
}
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 (
<div className="h-100 w-100 overflow-x-hidden flex flex-column white-d">
<div
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: '1rem' }}
>
<Link to="/~chat/">{'⟵ All Chats'}</Link>
</div>
<div
className="pl4 pt2 bb b--gray4 b--gray2-d bg-gray0-d flex relative overflow-x-scroll overflow-x-auto-l overflow-x-auto-xl flex-shrink-0"
style={{ height: 48 }}
>
<SidebarSwitcher
sidebarShown={this.props.sidebarShown}
popout={this.props.popout}
/>
<Link to={'/~chat/' + isinPopout + 'room' + props.station}
className="pt2 white-d"
>
<h2
className={'dib f9 fw4 lh-solid v-top ' +
((title === props.station.substr(1)) ? 'mono' : '')}
style={{ width: 'max-content' }}
>
{title}
</h2>
</Link>
<ChatTabBar
{...props}
station={props.station}
numPeers={permission.length}
host={props.match.params.ship}
api={props.api}
/>
</div>
<div className="w-100 pl3 mt4 cf">
<Spinner awaiting={state.awaiting}
classes="absolute right-2 bottom-2 ba pa2 b--gray1-d"
text={state.type} />
</div>
</div>
);
}
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 (
<div className="h-100 w-100 overflow-x-hidden flex flex-column white-d">
<div
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: '1rem' }}
>
<Link to="/~chat/">{'⟵ All Chats'}</Link>
</div>
<div
className="pl4 pt2 bb b--gray4 b--gray1-d flex relative overflow-x-scroll overflow-x-auto-l overflow-x-auto-xl flex-shrink-0"
style={{ height: 48 }}
>
<SidebarSwitcher
sidebarShown={this.props.sidebarShown}
popout={this.props.popout}
api={this.props.api}
/>
<Link to={'/~chat/' + isinPopout + 'room' + props.station}
className="pt2"
>
<h2
className={'dib f9 fw4 lh-solid v-top ' +
((title === props.station.substr(1)) ? 'mono' : '')}
style={{ width: 'max-content' }}
>
{title}
</h2>
</Link>
<ChatTabBar
{...props}
station={props.station}
numPeers={permission.length}
isOwner={deSig(props.match.params.ship) === window.ship}
popout={this.props.popout}
/>
</div>
<div className="w-100 pl3 mt4 cf">
<h2 className="f8 pb2">Chat Settings</h2>
{this.renderGroupify()}
{this.renderDelete()}
{this.renderMetadataSettings()}
<Spinner awaiting={state.awaiting}
classes="absolute right-2 bottom-2 ba pa2 b--gray1-d"
text={state.type} />
</div>
</div>
);
}
}

View File

@ -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)
? <template className="dib fr">
<p className="dib bg-green2 bg-gray2-d white fw6 ph1 br1 v-mid" style={{ marginBottom: 2 }}>
{Object.keys(props.invites).length}
</p>
<span className="dib v-mid ml1">
<img
className="v-mid"
src="/~landscape/img/Chevron.png"
style={{ height: 16, width: 16, paddingBottom: 1 }}
/>
</span>
</template>
: <template className="dib fr">
<span className="dib v-top ml1">
<img className="v-mid"
src="/~landscape/img/Chevron.png"
style={{ height: 16, width: 16, paddingBottom: 1 }}
/>
</span>
</template>;
let selectedGroups = <div />;
let searchResults = <div />;
if (state.results.length > 0) {
const groupResults = state.results.map(((group) => {
return(
<li
key={group[0]}
className="tl list white-d f9 pv2 ph3 pointer hover-bg-gray4 hover-bg-gray1-d inter"
onClick={() => this.addGroup(group)}
>
<span className="mix-blend-diff white">{(group[1]) ? group[1] : group[0]}</span>
</li>
);
}));
searchResults = (
<div className={'tl absolute bg-white bg-gray0-d white-d pv3 z-1 w-100 ba b--gray4 b--white-d overflow-y-scroll'} style={{ maxWidth: '15.67rem', maxHeight: '8rem' }}>
<p className="f9 tl gray2 ph3 pb2">Groups</p>
{groupResults}
</div>
);
}
if (state.selected.length > 0) {
const allSelected = this.state.selected.map((each) => {
const name = each[1];
return(
<span
key={each[0]}
className={'f9 inter black pa2 bg-gray5 bg-gray1-d ' +
'ba b--gray4 b--gray2-d white-d dib mr2 mt2 c-default'}
>
{name}
<span
className="white-d ml3 mono pointer"
onClick={e => this.deleteGroup(each)}
>
x
</span>
</span>
);
});
selectedGroups = (
<div className={
'f9 gray2 bb bl br b--gray3 b--gray2-d bg-gray0-d ' +
'white-d pa3 db w-100 inter bg-gray5 lh-solid tl'
}
style={{ width: 251 }}
>
{allSelected}
</div>
);
}
return (
<div className="ml1 dib">
<div className={buttonOpened}
onClick={() => this.toggleOpen()}
ref={el => this.toggleButton = el}
>
<p className="inter dib f9 pointer pv1 ph2 mw5 truncate v-mid">{currentGroup}</p>
</div>
<div className={dropdownClass}
style={{ maxHeight: '24rem', width: 285 }}
ref={(el) => {
this.dropdown = el;
}}
>
<p className="tc bb b--gray3 b--gray1-d gray3 pv4 f9">Group Select and Filter</p>
<Link to="/~groups"
className="ma4 bg-gray5 bg-gray1-d f9 tl pa1 br1 db no-underline"
style={{ paddingLeft: '6.5px', paddingRight: '6.5px' }}
onClick={() => this.setState({ open: false })}
>
Manage all Groups
{inviteCount}
</Link>
<p className="pt4 gray3 f9 tl mh4">Filter Groups</p>
<div className="relative w-100 ph4 pt2 pb4">
<input className="ba b--gray3 white-d bg-gray0-d inter w-100 f9 pa2"
style={{ boxSizing: 'border-box' }}
placeholder="Group name..."
onChange={this.search}
value={state.searchTerm}
/>
{searchResults}
{selectedGroups}
</div>
</div>
</div>
);
}
}

View File

@ -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 (
<div
className={
'bg-white bg-gray0-d w-100 justify-between relative tc pt3 ' + display
}
style={{ height: 45 }}
>
<div className="fl lh-copy absolute left-0 pl4" style={{ top: 8 }}>
<Link to="/~groups/me"
className="dib v-top" style={{ lineHeight: 0, paddingTop: 6 }}>
<Sigil
ship={'~' + window.ship}
classes="v-mid mix-blend-diff"
size={16}
color={'#000000'}
/>
</Link>
<GroupFilter invites={invites} associations={props.associations} api={props.api} />
<span className="dib f9 v-mid gray2 ml1 mr1 c-default inter">/</span>
{
location.pathname === '/'
? null
: <Link
className="dib f9 v-mid inter ml2 no-underline white-d"
to="/"
style={{ top: 14 }}
>
</Link>
}
<p className="dib f9 v-mid inter ml2 white-d">{locationName}</p>
{ connection === 'disconnected' &&
(<span
onClick={reconnect}
className="ml4 ph2 dib f9 v-mid red2 inter ba b-red2 br1 pointer"
>Reconnect </span> )
}
{ connection === 'reconnecting' &&
(<span className="ml4 ph2 dib f9 v-mid yellow2 inter ba b-yellow2 br1">Reconnecting</span> )
}
</div>
</div>
);
};
export default StatusBar;

View File

@ -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(<App />, document.getElementById('root'));

View File

@ -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<StoreState> {
getBaseHash() {
@ -9,16 +8,6 @@ export default class LocalApi extends BaseApi<StoreState> {
});
}
setSelected(selected: SelectedGroup[]) {
this.store.handleEvent({
data: {
local: {
selected
}
}
})
}
sidebarToggle() {
this.store.handleEvent({
data: {
@ -39,4 +28,14 @@ export default class LocalApi extends BaseApi<StoreState> {
});
}
setOmnibox() {
this.store.handleEvent({
data: {
local: {
omniboxShown: true
},
},
});
}
}

View File

@ -0,0 +1,3 @@
const defaultApps = ['chat', 'dojo', 'groups', 'link', 'publish'];
export default defaultApps;

View File

@ -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 {

View File

@ -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;
};

View File

@ -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 (

View File

@ -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;
}
}

View File

@ -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<StoreState, 'chatInitialized' | 'chatSynced' | 'inbox' | 'pendingMessages'>;

View File

@ -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<StoreState, 'connection'>;

View File

@ -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<StoreState, 'contacts'>;

View File

@ -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<StoreState, 'groups' | 'groupKeys'>;

View File

@ -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<StoreState, "invites">;

View File

@ -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<StoreState, 'launch' | 'weather' | 'userLocation'>;
@ -50,7 +50,6 @@ export default class LaunchReducer<S extends LaunchState> {
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);

View File

@ -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

View File

@ -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<StoreState, 'linkListening'>;

View File

@ -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<StoreState, 'sidebarShown' | 'selectedGroups' | 'dark' | 'baseHash'>;
type LocalState = Pick<StoreState, 'sidebarShown' | 'omniboxShown' | 'dark' | 'baseHash'>;
export default class LocalReducer<S extends LocalState> {
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<S extends LocalState> {
}
}
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;

View File

@ -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<StoreState, 'associations'>;

View File

@ -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<StoreState, "permissions">;

View File

@ -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<StoreState, 'notebooks'>;

View File

@ -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<StoreState, 'notebooks'>;

View File

@ -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<StoreState, 's3'>;

View File

@ -41,6 +41,7 @@ export default class GlobalStore extends BaseStore<StoreState> {
chatInitialized: false,
connection: 'connected',
sidebarShown: true,
omniboxShown: false,
baseHash: null,
invites: {},
associations: {
@ -72,7 +73,6 @@ export default class GlobalStore extends BaseStore<StoreState> {
linkComments: {},
notebooks: {},
contacts: {},
selectedGroups: [],
dark: false,
inbox: {},
chatSynced: null,

View File

@ -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;

View File

@ -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;
}

View File

@ -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 (
<ThemeProvider theme={theme}>
<Root>
<Router>
<StatusBarWithRouter
props={this.props}
associations={associations}
invites={this.state.invites}
api={this.api}
connection={this.state.connection}
subscription={this.subscription}
/>
<Omnibox
associations={state.associations}
apps={state.launch}
api={this.api}
dark={state.dark}
show={state.omniboxShown}
/>
<Content
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state} />
</Router>
</Root>
</ThemeProvider>
);
}
}
export default process.env.NODE_ENV === 'production' ? App : hot(App);

View File

@ -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<ChatAppProps, {}> {
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<ChatAppProps, {}> {
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<ChatAppProps, {}> {
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<ChatAppProps, {}> {
);
}}
/>
<Route
exact
path="/~chat/(popout)?/members/(~)?/:ship/:station+"
render={(props) => {
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 (
<Skeleton
associations={associations}
invites={invites}
sidebarHideOnMobile={true}
sidebarShown={sidebarShown}
popout={popout}
sidebar={renderChannelSidebar(props, station)}
>
<MemberScreen
{...props}
api={api}
group={group}
groups={groups}
associations={associations}
station={station}
association={association}
contacts={contacts}
popout={popout}
sidebarShown={sidebarShown}
/>
</Skeleton>
);
}}
/>
<Route
exact
path="/~chat/(popout)?/settings/(~)?/:ship/:station+"

View File

@ -0,0 +1,151 @@
import React, { Component, Fragment } from "react";
import moment from "moment";
import { Link, RouteComponentProps } from "react-router-dom";
import { ChatWindow } from './lib/chat-window';
import { ChatHeader } from './lib/chat-header';
import { ChatInput } from "./lib/chat-input";
import { deSig } from "../../../../logic/lib/util";
import { ChatHookUpdate } from "../../../../types/chat-hook-update";
import ChatApi from "../../../../logic/api/chat";
import { Inbox, Envelope } from "../../../../types/chat-update";
import { Contacts } from "../../../../types/contact-update";
import { Path, Patp } from "../../../../types/noun";
import GlobalApi from "../../../../logic/api/global";
import { Association } from "../../../../types/metadata-update";
import {Group} from "../../../../types/group-update";
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<Path, Envelope[]>;
s3: any;
popout: boolean;
sidebarShown: boolean;
chatInitialized: boolean;
envelopes: Envelope[];
};
interface ChatScreenState {
messages: Map<string, string>;
}
export class ChatScreen extends Component<ChatScreenProps, ChatScreenState> {
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 (
<div
key={props.station}
className="h-100 w-100 overflow-hidden flex flex-column relative">
<ChatHeader
match={props.match}
location={props.location}
api={props.api}
group={props.group}
association={props.association}
station={props.station}
sidebarShown={props.sidebarShown}
popout={props.popout} />
<ChatWindow
history={props.history}
isChatMissing={isChatMissing}
isChatLoading={isChatLoading}
isChatUnsynced={isChatUnsynced}
unreadCount={unreadCount}
unreadMsg={unreadMsg}
pendingMessages={pendingMessages}
messages={props.envelopes}
length={props.length}
contacts={props.contacts}
association={props.association}
group={props.group}
ship={props.match.params.ship}
station={props.station}
api={props.api} />
<ChatInput
api={props.api}
numMsgs={lastMsgNum}
station={props.station}
owner={deSig(props.match.params.ship)}
ownerContact={ownerContact}
envelopes={props.envelopes}
contacts={props.contacts}
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) || ""}
deleteMessage={() => this.setState({
messages: this.state.messages.set(props.station, "")
})}
/>
</div>
);
}
}

View File

@ -0,0 +1,22 @@
import React, { Component } from 'react';
export const BacklogElement = (props) => {
if (!props.isChatLoading) {
return null;
}
return (
<div className="center mw6">
<div className={
"db pa3 ma3 ba b--gray4 bg-gray5 b--gray2-d bg-gray1-d " +
"white-d flex items-center"
}>
<img className="invert-d spin-active v-mid"
src="/~chat/img/Spinner.png"
width={16}
height={16}
/>
<p className="lh-copy db ml3">Past messages are being restored</p>
</div>
</div>
);
}

View File

@ -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 (
<div
className="chat fr h-100 flex bg-gray0-d lh-copy pl2 w-100 items-center"
style={{ flexGrow: 1, maxHeight: '224px', width: 'calc(100% - 72px)' }}
>
<CodeEditor
value={props.message}
options={options}
onChange={(e, d, v) => this.messageChange(e, d, v)}
editorDidMount={(editor) => {
this.editor = editor;
if (!(BROWSER_REGEX.test(navigator.userAgent))) {
editor.focus();
}
}}
/>
</div>
);
}
}

View File

@ -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 (
<Fragment>
<div
className="w-100 dn-m dn-l dn-xl inter pt4 pb6 pl3 f8"
style={{ height: "1rem" }}>
<Link to="/~chat/">{"⟵ All Chats"}</Link>
</div>
<div
className={
"pl4 pt2 bb b--gray4 b--gray1-d bg-gray0-d flex relative " +
"overflow-x-auto overflow-y-hidden flex-shrink-0 "
}
style={{ height: 48 }}>
<SidebarSwitcher
sidebarShown={props.sidebarShown}
popout={props.popout}
api={props.api}
/>
<Link
to={"/~chat/" + isInPopout + "room" + props.station}
className="pt2 white-d">
<h2
className={
"dib f9 fw4 lh-solid v-top " +
(title === props.station.substr(1) ? "mono" : "")
}
style={{ width: "max-content" }}>
{title}
</h2>
</Link>
<ChatTabBar
location={props.location}
station={props.station}
isOwner={deSig(props.match.params.ship) === window.ship}
popout={props.popout}
/>
</div>
</Fragment>
);
}

View File

@ -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))
? <img src={props.ownerContact.avatar} height={24} width={24} className="dib" />
: <Sigil
ship={window.ship}
size={24}
color={`#${color}`}
classes={sigilClass}
/>;
return (
<div className={
"pa3 cf flex black white-d bt b--gray4 b--gray1-d bg-white " +
"bg-gray0-d relative"
}
style={{ flexGrow: 1 }}>
<div className="fl"
style={{
marginTop: 6,
flexBasis: 24,
height: 24
}}>
{avatar}
</div>
<ChatEditor
inCodeMode={state.inCodeMode}
submit={this.submit}
onUnmount={props.onUnmount}
message={props.message}
placeholder='Message...' />
<div className="ml2 mr2"
style={{
height: '16px',
width: '16px',
flexBasis: 16,
marginTop: 10
}}>
<S3Upload
configuration={props.s3.configuration}
credentials={props.s3.credentials}
uploadSuccess={this.uploadSuccess.bind(this)}
uploadError={this.uploadError.bind(this)}
/>
</div>
<div style={{
height: '16px',
width: '16px',
flexBasis: 16,
marginTop: 10
}}>
<img style={{
filter: state.inCodeMode && 'invert(100%)',
height: '14px',
width: '14px',
}}
onClick={this.toggleCode}
src="/~chat/img/CodeEval.png"
className="contrast-10-d bg-white bg-none-d ba b--gray1-d br1" />
</div>
</div>
);
}
}

View File

@ -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 = (
<Message
key={msg.uid}
msg={msg}
renderSigil={renderSigil}
paddingTop={paddingTop}
paddingBot={paddingBot}
pending={Boolean(msg.pending)}
group={group}
contacts={contacts}
association={association}
/>
);
if (props.isLastUnread) {
return (
<Fragment key={msg.uid}>
{messageElem}
<div ref={unreadRef}
className="mv2 green2 flex items-center f9">
<hr className="dn-s ma0 w2 b--green2 bt-0" />
<p className="mh4">New messages below</p>
<hr className="ma0 flex-grow-1 b--green2 bt-0" />
{dayBreak && (
<p className="gray2 mh4">
{moment(_.get(msg, when)).calendar()}
</p>
)}
<hr
style={{ width: "calc(50% - 48px)" }}
className="b--green2 ma0 bt-0"
/>
</div>
</Fragment>
);
} else if (dayBreak) {
return (
<Fragment key={msg.uid}>
{messageElem}
<div
className="pv3 gray2 b--gray2 flex items-center justify-center f9 "
>
<p>{moment(_.get(msg, when)).calendar()}</p>
</div>
</Fragment>
);
} else {
return messageElem;
}
};

View File

@ -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 (
<div
className="relative overflow-y-scroll h-100"
onScroll={this.containerDidScroll}
ref={this.containerRef}>
<div
className="bg-white bg-gray0-d pt3 pb2 flex flex-column-reverse"
style={{ resize: "vertical" }}>
<div ref={this.scrollRef}></div>
{this.props.children}
</div>
</div>
);
}
normalScrollContainer() {
return (
<div
className={
"overflow-y-scroll bg-white bg-gray0-d pt3 pb2 flex " +
"flex-column-reverse relative"
}
style={{ height: "100%", resize: "vertical" }}
onScroll={this.containerDidScroll}>
<div ref={this.scrollRef}></div>
{this.props.children}
</div>
);
}
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
);
}
}
}

View File

@ -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 (
<div className="dib flex-shrink-0 flex-grow-1">
<div className={'dib pt2 f9 pl6 pr6 lh-solid'}>
<Link
className={'no-underline ' + setColor}
to={'/~chat/' + popout + 'settings' + station}>
Settings
</Link>
</div>
<a href={'/~chat/popout/room' + station} rel="noopener noreferrer"
target="_blank"
className="dib fr pr1"
style={{ paddingTop: '8px' }}>
<img
className={'flex-shrink-0 pr3 dn ' + hidePopoutIcon}
src="/~chat/img/popout.png"
height="16"
width="16" />
</a>
</div>
);
}

View File

@ -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 (
<Fragment>
<UnreadNotice
unreadCount={props.unreadCount}
unreadMsg={props.unreadMsg}
dismissUnread={this.dismissUnread} />
<ChatScrollContainer
ref={this.scrollReference}
scrollIsAtBottom={this.scrollIsAtBottom}
scrollIsAtTop={this.scrollIsAtTop}>
<BacklogElement isChatLoading={props.isChatLoading} />
<ResubscribeElement
api={props.api}
host={props.ship}
station={props.station}
isChatUnsynced={props.isChatUnsynced}
/>
{ messages.map((msg, i) => (
<ChatMessage
key={msg.uid}
unreadRef={this.unreadReference}
isLastUnread={
props.unreadCount > 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} />
))
}
</ChatScrollContainer>
</Fragment>
);
}
}

View File

@ -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',

View File

@ -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'
}}
></img>
);
return (
<a className={`f7 lh-copy v-top word-break-all`}
<a className='f7 lh-copy v-top word-break-all'
href={content.url}
target="_blank"
rel="noopener noreferrer"
@ -63,7 +59,9 @@ export default class UrlContent extends Component {
? 'db' : 'dn')}
>
<iframe
ref="iframe"
ref={(el) => {
this.iframe = el;
}}
width="560"
height="315"
data-src={`https://www.youtube.com/embed/${ytMatch[1]}`}
@ -75,12 +73,15 @@ export default class UrlContent extends Component {
return (
<div>
<a href={content.url}
className={`f7 lh-copy v-top bb b--white-d word-break-all`}
href={content.url}
className='f7 lh-copy v-top bb b--white-d word-break-all'
target="_blank"
rel="noopener noreferrer">{content.url}</a>
<a className="ml2 f7 pointer lh-copy v-top"
onClick={e => this.unfoldEmbed()}>
rel="noopener noreferrer"
>
{content.url}
</a>
<a className="bs ml2 f7 pointer lh-copy v-top"
onClick={e => this.unfoldEmbed()}
>
[embed]
</a>
{contents}
@ -88,7 +89,7 @@ export default class UrlContent extends Component {
);
} else {
return (
<a className={`f7 lh-copy v-top bb b--white-d b--black word-break-all`}
<a className='f7 lh-copy v-top bb b--white-d b--black word-break-all'
href={content.url}
target="_blank"
rel="noopener noreferrer"

View File

@ -0,0 +1,51 @@
import React, { Component } from 'react';
export const DeleteButton = (props) => {
const { isOwner, station, changeLoading, api } = props;
const leaveButtonClasses = (!isOwner) ? 'pointer' : 'c-default';
const deleteButtonClasses = (isOwner) ?
'b--red2 red2 pointer bg-gray0-d' :
'b--gray3 gray3 bg-gray0-d c-default';
const deleteChat = () => {
changeLoading(
true,
true,
isOwner ? 'Deleting chat...' : 'Leaving chat...',
() => {
api.chat.delete(station);
}
);
};
return (
<div className="w-100 cf">
<div className={'w-100 fl mt3 ' + ((isOwner) ? 'o-30' : '')}>
<p className="f8 mt3 lh-copy db">Leave Chat</p>
<p className="f9 gray2 db mb4">
Remove this chat from your chat list.{' '}
You will need to request for access again.
</p>
<a onClick={(!isOwner) ? deleteChat : null}
className={
'dib f9 black gray4-d bg-gray0-d ba pa2 b--black b--gray1-d ' +
leaveButtonClasses
}>
Leave this chat
</a>
</div>
<div className={'w-100 fl mt3 ' + ((!isOwner) ? 'o-30' : '')}>
<p className="f8 mt3 lh-copy db">Delete Chat</p>
<p className="f9 gray2 db mb4">
Permanently delete this chat.{' '}
All current members will no longer see this chat.
</p>
<a onClick={(isOwner) ? deleteChat : null}
className={'dib f9 ba pa2 ' + deleteButtonClasses}
>Delete this chat</a>
</div>
</div>
);
};

View File

@ -0,0 +1,104 @@
import React, { Component } from 'react';
import Toggle from '../../../../components/toggle';
import { InviteSearch } from '../../../../components/InviteSearch';
export class GroupifyButton extends Component {
constructor(props) {
super(props);
this.state = {
inclusive: false,
targetGroup: null
};
}
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) });
}
renderInclusiveToggle() {
return this.state.targetGroup ? (
<div className="mt4">
<Toggle
boolean={inclusive}
change={this.changeInclusive.bind(this)}
/>
<span className="dib f9 white-d inter ml3">
Add all members to group
</span>
<p className="f9 gray2 pt1" style={{ paddingLeft: 40 }}>
Add chat members to the group if they aren't in it yet
</p>
</div>
) : <div />;
}
render() {
const { inclusive, targetGroup } = this.state;
const {
api,
isOwner,
association,
associations,
contacts,
groups,
station
} = this.props;
const groupPath = association['group-path'];
const ownedUnmanagedVillage =
isOwner &&
!contacts[groupPath];
if (!ownedUnmanagedVillage) {
return null;
}
return (
<div className={'w-100 fl mt3'} style={{ maxWidth: '29rem' }}>
<p className="f8 mt3 lh-copy db">Convert Chat</p>
<p className="f9 gray2 db mb4">
Convert this chat into a group with associated chat, or select a
group to add this chat to.
</p>
<InviteSearch
groups={groups}
contacts={contacts}
associations={associations}
groupResults={true}
shipResults={false}
invites={{
groups: targetGroup ? [targetGroup] : [],
ships: []
}}
setInvite={this.changeTargetGroup.bind(this)}
/>
{this.renderInclusiveToggle()}
<a onClick={() => {
changeLoading(true, true, 'Converting to group...', () => {
api.chat.groupify(
station, targetGroup, inclusive
).then(() => {
changeLoading(false, false, '', () => {});
});
});
}}
className={
'dib f9 black gray4-d bg-gray0-d ba pa2 mt4 b--black ' +
'b--gray1-d pointer'
}>Convert to group</a>
</div>
);
}
}

View File

@ -1,53 +1,45 @@
import React, { Component } from 'react';
import { OverlaySigil } from './overlay-sigil';
import MessageContent from './message-content';
import { uxToHex, cite, writeText } from '../../../../lib/util';
import { uxToHex, cite, writeText } from '../../../../../logic/lib/util';
import moment from 'moment';
export class Message extends Component {
constructor() {
super();
this.state = {
copied: false
};
}
export const Message = (props) => {
const pending = props.msg.pending ? ' o-40' : '';
const containerClass =
props.renderSigil ?
`w-100 f7 pl3 pt4 pr3 cf flex lh-copy ` + pending :
'w-100 pr3 cf hide-child flex' + pending;
render() {
const { props, state } = this;
const pending = props.msg.pending ? ' o-40' : '';
const containerClass =
props.renderSigil ?
`w-100 f7 pl3 pt4 pr3 cf flex lh-copy ` + pending :
'w-100 pr3 cf hide-child flex' + pending;
const timestamp =
moment.unix(props.msg.when / 1000).format(
props.renderSigil ? 'hh:mm a' : 'hh:mm'
);
return (
<div
ref={this.containerRef}
className={containerClass}
style={{
minHeight: 'min-content'
}}
>
{
props.renderSigil ? (
this.renderWithSigil(timestamp)
) : (
this.renderWithoutSigil(timestamp)
)
}
</div>
const timestamp =
moment.unix(props.msg.when / 1000).format(
props.renderSigil ? 'hh:mm a' : 'hh:mm'
);
}
renderWithSigil(timestamp) {
const { props, state } = this;
return (
<div className={containerClass}
style={{
minHeight: 'min-content'
}}>
{
props.renderSigil ? (
renderWithSigil(props, timestamp)
) : (
<div className="flex w-100">
<p className="child pt2 pl2 pr1 mono f9 gray2 dib">{timestamp}</p>
<div className="fr f7 clamp-message white-d pr3 lh-copy"
style={{ flexGrow: 1 }}>
<MessageContent letter={props.msg.letter} />
</div>
</div>
)
}
</div>
);
};
const renderWithSigil = (props, timestamp) => {
const paddingTop = props.paddingTop ? { 'paddingTop': '6px' } : '';
const datestamp =
'~' + moment.unix(props.msg.when / 1000).format('YYYY.M.D');
@ -85,19 +77,15 @@ export class Message extends Component {
<p className={`v-mid f9 gray2 dib mr3 c-default`}>
<span
className={
'mw5 dib truncate pointer ' +
(contact.nickname || state.copied ? '' : 'mono')
'mw5 db truncate pointer ' +
(contact.nickname ? '' : 'mono')
}
onClick={() => {
writeText(props.msg.author);
this.setState({ copied: true });
setTimeout(() => {
this.setState({ copied: false });
}, 800);
}}
title={`~${props.msg.author}`}
>
{state.copied && 'Copied' || name}
{name}
</span>
</p>
<p className={`v-mid mono f9 gray2 dib`}>{timestamp}</p>
@ -111,17 +99,3 @@ export class Message extends Component {
);
}
renderWithoutSigil(timestamp) {
const { props } = this;
return (
<div className="flex w-100">
<p className="child pt2 pl2 pr1 mono f9 gray2 dib">{timestamp}</p>
<div className="fr f7 clamp-message white-d pr3 lh-copy"
style={{ flexGrow: 1 }}>
<MessageContent letter={props.msg.letter} />
</div>
</div>
);
}
}

View File

@ -0,0 +1,71 @@
import React, { Component } from 'react';
import { uxToHex } from '../../../../../logic/lib/util';
export class MetadataColor extends Component {
constructor(props) {
super(props);
this.state = {
color: props.initialValue
};
this.changeColor = this.changeColor.bind(this);
this.submitColor = this.submitColor.bind(this);
}
componentDidUpdate(prevProps) {
const { props } = this;
if (prevProps.initialValue !== props.initialValue) {
this.setState({ color: props.initialValue });
}
}
changeColor(event) {
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);
if (!props.isDisabled && hexTest && (state.color !== props.initialValue)) {
props.setValue(color);
}
}
render() {
const { props, state } = this;
return (
<div className={'cf w-100 mb3 ' + ((props.isDisabled) ? 'o-30' : '')}>
<p className="f8 lh-copy">Change color</p>
<p className="f9 gray2 db mb4">Give this chat a color when viewing group channels</p>
<div className="relative w-100 flex"
style={{ maxWidth: '10rem' }}
>
<div className="absolute"
style={{
height: 16,
width: 16,
backgroundColor: state.color,
top: 13,
left: 11
}} />
<input
className={'pl7 f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'}
value={state.color}
disabled={props.isDisabled}
onChange={this.changeColor}
onBlur={this.submitColor} />
</div>
</div>
);
}
}

View File

@ -0,0 +1,52 @@
import React, { Component } from 'react';
export class MetadataInput extends Component {
constructor(props) {
super(props);
this.state = {
value: props.initialValue
};
}
componentDidUpdate(prevProps) {
const { props } = this;
if (prevProps.initialValue !== props.initialValue) {
this.setState({ value: props.initialValue });
}
}
render() {
const {
title,
description,
isDisabled,
setValue
} = this.props;
return (
<div className={'w-100 mb3 fl ' + ((isDisabled) ? 'o-30' : '')}>
<p className="f8 lh-copy">{title}</p>
<p className="f9 gray2 db mb4">{description}</p>
<div className="relative w-100 flex" style={{ maxWidth: '29rem' }}>
<input
className={'f8 ba b--gray3 b--gray2-d bg-gray0-d white-d ' +
'focus-b--black focus-b--white-d pa3 db w-100 flex-auto mr3'}
type="text"
value={this.state.value}
disabled={isDisabled}
onChange={(e) => {
this.setState({ value: e.target.value });
}}
onBlur={() => {
if (!isDisabled) {
setValue(this.state.value || '');
}
}}
/>
</div>
</div>
);
}
}

View File

@ -0,0 +1,90 @@
import React, { Component } from 'react';
import { MetadataColor } from './metadata-color';
import { MetadataInput } from './metadata-input';
import { uxToHex } from '../../../../../logic/lib/util';
export const MetadataSettings = (props) => {
const {
isOwner,
association,
changeLoading,
api,
station
} = props;
const title =
(props.association && 'metadata' in props.association) ?
association.metadata.title : '';
const description =
(props.association && 'metadata' in props.association) ?
association.metadata.description : '';
const color =
(props.association && 'metadata' in props.association) ?
`#${uxToHex(props.association.metadata.color)}` : '';
return (
<div className="cf mt6">
<MetadataInput
title='Rename'
description='Change the name of this chat'
isDisabled={!isOwner}
initialValue={title}
setValue={(val) => {
changeLoading(false, true, 'Editing chat...', () => {
api.metadata.metadataAdd(
'chat',
association['app-path'],
association['group-path'],
val,
association.metadata.description,
association.metadata['date-created'],
uxToHex(association.metadata.color)
).then(() => {
changeLoading(false, false, '', () => {});
});
});
}} />
<MetadataInput
title='Change description'
description='Change the description of this chat'
isDisabled={!isOwner}
initialValue={description}
setValue={(val) => {
changeLoading(false, true, 'Editing chat...', () => {
api.metadata.metadataAdd(
'chat',
association['app-path'],
association['group-path'],
association.metadata.title,
val,
association.metadata['date-created'],
uxToHex(association.metadata.color)
).then(() => {
changeLoading(false, false, '', () => {});
});
});
}} />
<MetadataColor
initialValue={color}
isDisabled={!isOwner}
setValue={(val) => {
changeLoading(false, true, 'Editing chat...', () => {
props.api.metadata.metadataAdd(
'chat',
association['app-path'],
association['group-path'],
association.metadata.title,
association.metadata.description,
association.metadata['date-created'],
val
).then(() => {
changeLoading(false, false, '', () => {});
});
});
}} />
</div>
);
};

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { Sigil } from '../../../../lib/sigil';
import { Sigil } from '../../../../../logic/lib/sigil';
import {
ProfileOverlay,
OVERLAY_HEIGHT

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react';
import { Link } from 'react-router-dom';
import { cite } from '../../../../lib/util';
import { Sigil } from '../../../../lib/sigil';
import { cite } from '../../../../../logic/lib/util';
import { Sigil } from '../../../../../logic/lib/sigil';
export const OVERLAY_HEIGHT = 250;

View File

@ -0,0 +1,32 @@
import React, { Component } from 'react';
export class ResubscribeElement extends Component {
onClickResubscribe() {
this.props.api.chat.addSynced(
this.props.host,
this.props.station,
true);
}
render() {
const { props } = this;
if (props.isChatUnsynced) {
return (
<div className="db pa3 ma3 ba b--yellow2 bg-yellow0">
<p className="lh-copy db">
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.
</p>
<a onClick={this.onClickResubscribe.bind(this)}
className="db underline black pointer mt3"
>
Reconnect to this chat
</a>
</div>
);
} else {
return null;
}
}
}

View File

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import S3Client from '../../../../lib/s3';
import S3Client from '../../../../../logic/lib/s3';
export class S3Upload extends Component {
constructor(props) {

Some files were not shown because too many files have changed in this diff Show More