Merge branch 'master' into pre-release/next-userspace

This commit is contained in:
Matilde Park 2021-03-30 19:49:14 -04:00
commit 15c1c2146a
156 changed files with 3289 additions and 2377 deletions

View File

@ -32,7 +32,29 @@
name: build name: build
on: [push, pull_request] on:
push:
paths:
- 'pkg/arvo/**'
- 'pkg/docker-image/**'
- 'pkg/ent/**'
- 'pkg/ge-additions/**'
- 'pkg/hs/**'
- 'pkg/libaes_siv/**'
- 'pkg/urbit/**'
- 'bin/**'
- 'nix/**'
pull_request:
paths:
- 'pkg/arvo/**'
- 'pkg/docker-image/**'
- 'pkg/ent/**'
- 'pkg/ge-additions/**'
- 'pkg/hs/**'
- 'pkg/libaes_siv/**'
- 'pkg/urbit/**'
- 'bin/**'
- 'nix/**'
jobs: jobs:
urbit: urbit:

View File

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a8b19cbe89f770f8d6c1e05972be7a3a01545b93b0f2d4523809e7df18635f3c oid sha256:59285407abdc63642ff71384d922f63f4b2c82b3a0daa3673a861c97c59e292f
size 9462938 size 9729397

View File

@ -45,17 +45,16 @@ Most parts of Arvo have dedicated maintainers.
* `/sys/vane/ames`: @belisarius222 (~rovnys-ricfer) & @philipcmonk (~wicdev-wisryt) * `/sys/vane/ames`: @belisarius222 (~rovnys-ricfer) & @philipcmonk (~wicdev-wisryt)
* `/sys/vane/behn`: @belisarius222 (~rovnys-ricfer) * `/sys/vane/behn`: @belisarius222 (~rovnys-ricfer)
* `/sys/vane/clay`: @philipcmonk (~wicdev-wisryt) & @belisarius222 (~rovnys-ricfer) * `/sys/vane/clay`: @philipcmonk (~wicdev-wisryt) & @belisarius222 (~rovnys-ricfer)
* `/sys/vane/dill`: @joemfb (~master-morzod) * `/sys/vane/dill`: @fang- (~palfun-foslup)
* `/sys/vane/eyre`: @eglaysher (~littel-ponnys) * `/sys/vane/eyre`: @fang- (~palfun-foslup)
* `/sys/vane/gall`: @philipcmonk (~wicdev-wisryt) * `/sys/vane/gall`: @philipcmonk (~wicdev-wisryt)
* `/sys/vane/jael`: @fang- (~palfun-foslup) & @philipcmonk (~wicdev-wisryt) * `/sys/vane/jael`: @fang- (~palfun-foslup) & @philipcmonk (~wicdev-wisryt)
* `/app/acme`: @joemfb (~master-morzod) * `/app/acme`: @joemfb (~master-morzod)
* `/app/dns`: @joemfb (~master-morzod) * `/app/dns`: @joemfb (~master-morzod)
* `/app/aqua`: @philipcmonk (~wicdev-wisryt) * `/app/aqua`: @philipcmonk (~wicdev-wisryt)
* `/app/hood`: @belisarius222 (~rovnys-ricfer) * `/app/hood`: @belisarius222 (~rovnys-ricfer)
* `/lib/hood/drum`: @philipcmonk (~wicdev-wisryt) * `/lib/hood/drum`: @fang- (~palfun-foslup)
* `/lib/hood/kiln`: @philipcmonk (~wicdev-wisryt) * `/lib/hood/kiln`: @philipcmonk (~wicdev-wisryt)
* `/lib/test`: @eglaysher (~littel-ponnys)
## Contributing ## Contributing

View File

@ -5,7 +5,7 @@
/- glob /- glob
/+ default-agent, verb, dbug /+ default-agent, verb, dbug
|% |%
++ hash 0v6.3olcs.d6chc.eidm2.1pft8.6k264 ++ hash 0v1.4ujsp.698kt.ojftv.7jual.4hhu5
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))] +$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
+$ all-states +$ all-states
$% state-0 $% state-0

View File

@ -293,7 +293,7 @@
~(tap by unreads-count) ~(tap by unreads-count)
|= [=stats-index:store count=@ud] |= [=stats-index:store count=@ud]
:* stats-index :* stats-index
~(wyt in (~(gut by by-index) stats-index ~)) (~(gut by by-index) stats-index ~)
[%count count] [%count count]
(~(gut by last-seen) stats-index *time) (~(gut by last-seen) stats-index *time)
== ==
@ -304,7 +304,7 @@
~(tap by unreads-each) ~(tap by unreads-each)
|= [=stats-index:store indices=(set index:graph-store)] |= [=stats-index:store indices=(set index:graph-store)]
:* stats-index :* stats-index
~(wyt in (~(gut by by-index) stats-index ~)) (~(gut by by-index) stats-index ~)
[%each indices] [%each indices]
(~(gut by last-seen) stats-index *time) (~(gut by last-seen) stats-index *time)
== ==
@ -317,7 +317,7 @@
~ ~
:- ~ :- ~
:* stats-index :* stats-index
~(wyt in nots) nots
[%count 0] [%count 0]
*time *time
== ==

View File

@ -24,6 +24,6 @@
<div id="portal-root"></div> <div id="portal-root"></div>
<script src="/~landscape/js/channel.js"></script> <script src="/~landscape/js/channel.js"></script>
<script src="/~landscape/js/session.js"></script> <script src="/~landscape/js/session.js"></script>
<script src="/~landscape/js/bundle/index.2825fbc0a1f2fb69e6cf.js"></script> <script src="/~landscape/js/bundle/index.e821c1b85987caabfb1f.js"></script>
</body> </body>
</html> </html>

View File

@ -151,7 +151,7 @@
^- json ^- json
%- pairs %- pairs
:~ unreads+(unread unreads.s) :~ unreads+(unread unreads.s)
notifications+(numb notifications.s) notifications+a+(turn ~(tap in notifications.s) notif-ref)
last+(time last-seen.s) last+(time last-seen.s)
== ==
++ added ++ added

View File

@ -150,7 +150,7 @@
[index notification] [index notification]
:: ::
+$ stats +$ stats
[notifications=@ud =unreads last-seen=@da] [notifications=(set [time index]) =unreads last-seen=@da]
:: ::
+$ unreads +$ unreads
$% [%count num=@ud] $% [%count num=@ud]

View File

@ -94,7 +94,11 @@ module.exports = {
use: { use: {
loader: 'babel-loader', loader: 'babel-loader',
options: { options: {
presets: ['@babel/preset-env', '@babel/typescript', '@babel/preset-react'], presets: ['@babel/preset-env', '@babel/typescript', ['@babel/preset-react', {
runtime: 'automatic',
development: true,
importSource: '@welldone-software/why-did-you-render',
}]],
plugins: [ plugins: [
'@babel/transform-runtime', '@babel/transform-runtime',
'@babel/plugin-proposal-object-rest-spread', '@babel/plugin-proposal-object-rest-spread',

View File

@ -1783,30 +1783,36 @@
"dependencies": { "dependencies": {
"@babel/runtime": { "@babel/runtime": {
"version": "7.12.5", "version": "7.12.5",
"bundled": true, "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz",
"integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==",
"requires": { "requires": {
"regenerator-runtime": "^0.13.4" "regenerator-runtime": "^0.13.4"
} }
}, },
"@types/lodash": { "@types/lodash": {
"version": "4.14.168", "version": "4.14.168",
"bundled": true "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.168.tgz",
"integrity": "sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q=="
}, },
"@urbit/eslint-config": { "@urbit/eslint-config": {
"version": "1.0.0", "version": "1.0.0",
"bundled": true "resolved": "https://registry.npmjs.org/@urbit/eslint-config/-/eslint-config-1.0.0.tgz",
"integrity": "sha512-Xmzb6MvM7KorlPJEq/hURZZ4BHSVy/7CoQXWogsBSTv5MOZnMqwNKw6yt24k2AO/2UpHwjGptimaNLqFfesJbw=="
}, },
"big-integer": { "big-integer": {
"version": "1.6.48", "version": "1.6.48",
"bundled": true "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.48.tgz",
"integrity": "sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w=="
}, },
"lodash": { "lodash": {
"version": "4.17.20", "version": "4.17.20",
"bundled": true "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
}, },
"regenerator-runtime": { "regenerator-runtime": {
"version": "0.13.7", "version": "0.13.7",
"bundled": true "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
} }
} }
}, },
@ -1989,6 +1995,15 @@
"@xtuc/long": "4.2.2" "@xtuc/long": "4.2.2"
} }
}, },
"@welldone-software/why-did-you-render": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@welldone-software/why-did-you-render/-/why-did-you-render-6.1.0.tgz",
"integrity": "sha512-0s+PuKQ4v9VV1SZSM6iS7d2T7X288T3DF+K8yfkFAhI31HhJGGH1SY1ssVm+LqjSMyrVWT60ZF5r0qUsO0Z9Lw==",
"dev": true,
"requires": {
"lodash": "^4"
}
},
"@xtuc/ieee754": { "@xtuc/ieee754": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
@ -2071,6 +2086,11 @@
"color-convert": "^1.9.0" "color-convert": "^1.9.0"
} }
}, },
"any-ascii": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/any-ascii/-/any-ascii-0.1.7.tgz",
"integrity": "sha512-9zc8XIPeG9lDGtjiQGQtRF2+ow97/eTtZJR7K4UvciSC5GSOySYoytXeA2fSaY8pLhpRMcAsiZDEEkuU20HD8g=="
},
"anymatch": { "anymatch": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz",
@ -3974,6 +3994,14 @@
"prr": "~1.0.1" "prr": "~1.0.1"
} }
}, },
"error-stack-parser": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.0.6.tgz",
"integrity": "sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ==",
"requires": {
"stackframe": "^1.1.1"
}
},
"es-abstract": { "es-abstract": {
"version": "1.18.0-next.2", "version": "1.18.0-next.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.2.tgz", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.2.tgz",
@ -8743,6 +8771,45 @@
"figgy-pudding": "^3.5.1" "figgy-pudding": "^3.5.1"
} }
}, },
"stack-generator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.5.tgz",
"integrity": "sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q==",
"requires": {
"stackframe": "^1.1.1"
}
},
"stackframe": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.2.0.tgz",
"integrity": "sha512-GrdeshiRmS1YLMYgzF16olf2jJ/IzxXY9lhKOskuVziubpTYcYqyOwYeJKzQkwy7uN0fYSsbsC4RQaXf9LCrYA=="
},
"stacktrace-gps": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.0.4.tgz",
"integrity": "sha512-qIr8x41yZVSldqdqe6jciXEaSCKw1U8XTXpjDuy0ki/apyTn/r3w9hDAAQOhZdxvsC93H+WwwEu5cq5VemzYeg==",
"requires": {
"source-map": "0.5.6",
"stackframe": "^1.1.1"
},
"dependencies": {
"source-map": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
"integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI="
}
}
},
"stacktrace-js": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz",
"integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==",
"requires": {
"error-stack-parser": "^2.0.6",
"stack-generator": "^2.0.5",
"stacktrace-gps": "^3.0.4"
}
},
"state-toggle": { "state-toggle": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz", "resolved": "https://registry.npmjs.org/state-toggle/-/state-toggle-1.0.3.tgz",
@ -9855,6 +9922,7 @@
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"is-extendable": "^0.1.0" "is-extendable": "^0.1.0"
} }
@ -9921,6 +9989,7 @@
"resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz",
"integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=",
"dev": true, "dev": true,
"optional": true,
"requires": { "requires": {
"kind-of": "^3.0.2" "kind-of": "^3.0.2"
} }

View File

@ -13,6 +13,7 @@
"@tlon/indigo-react": "^1.2.19", "@tlon/indigo-react": "^1.2.19",
"@tlon/sigil-js": "^1.4.3", "@tlon/sigil-js": "^1.4.3",
"@urbit/api": "file:../npm/api", "@urbit/api": "file:../npm/api",
"any-ascii": "^0.1.7",
"aws-sdk": "^2.830.0", "aws-sdk": "^2.830.0",
"big-integer": "^1.6.48", "big-integer": "^1.6.48",
"classnames": "^2.2.6", "classnames": "^2.2.6",
@ -41,6 +42,7 @@
"react-visibility-sensor": "^5.1.1", "react-visibility-sensor": "^5.1.1",
"remark-breaks": "^2.0.1", "remark-breaks": "^2.0.1",
"remark-disable-tokenizers": "^1.0.24", "remark-disable-tokenizers": "^1.0.24",
"stacktrace-js": "^2.0.2",
"style-loader": "^1.3.0", "style-loader": "^1.3.0",
"styled-components": "^5.1.1", "styled-components": "^5.1.1",
"styled-system": "^5.1.5", "styled-system": "^5.1.5",
@ -71,6 +73,7 @@
"@types/yup": "^0.29.11", "@types/yup": "^0.29.11",
"@typescript-eslint/eslint-plugin": "^4.15.0", "@typescript-eslint/eslint-plugin": "^4.15.0",
"@urbit/eslint-config": "file:../npm/eslint-config", "@urbit/eslint-config": "file:../npm/eslint-config",
"@welldone-software/why-did-you-render": "^6.1.0",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-loader": "^8.2.2", "babel-loader": "^8.2.2",
"babel-plugin-lodash": "^3.3.4", "babel-plugin-lodash": "^3.3.4",

View File

@ -1,3 +1,4 @@
import './wdyr';
import * as React from 'react'; import * as React from 'react';
import * as ReactDOM from 'react-dom'; import * as ReactDOM from 'react-dom';

View File

@ -4,7 +4,7 @@ import { StoreState } from '../store/type';
export default class LocalApi extends BaseApi<StoreState> { export default class LocalApi extends BaseApi<StoreState> {
getBaseHash() { getBaseHash() {
this.scry<string>('file-server', '/clay/base/hash').then((baseHash) => { this.scry<string>('file-server', '/clay/base/hash').then((baseHash) => {
this.store.handleEvent({ data: { local: { baseHash } } }); this.store.handleEvent({ data: { baseHash } });
}); });
} }

View File

@ -77,7 +77,6 @@ export default class MetadataApi extends BaseApi<StoreState> {
tempChannel.delete(); tempChannel.delete();
}, },
(ev: any) => { (ev: any) => {
console.log(ev);
if ('metadata-hook-update' in ev) { if ('metadata-hook-update' in ev) {
done = true; done = true;
tempChannel.delete(); tempChannel.delete();

View File

@ -14,16 +14,14 @@
// //
// //
import GlobalApi from '../api/global'; import GlobalApi from '../api/global';
import GlobalStore from '../store/store'; import useStorageState from '../state/storage';
class GcpManager { class GcpManager {
#api: GlobalApi | null = null; #api: GlobalApi | null = null;
#store: GlobalStore | null = null;
configure(api: GlobalApi, store: GlobalStore) { configure(api: GlobalApi) {
this.#api = api; this.#api = api;
this.#store = store;
} }
#running = false; #running = false;
@ -34,8 +32,8 @@ class GcpManager {
console.warn('GcpManager already running'); console.warn('GcpManager already running');
return; return;
} }
if (!this.#api || !this.#store) { if (!this.#api) {
console.error('GcpManager must have api and store set'); console.error('GcpManager must have api set');
return; return;
} }
this.#running = true; this.#running = true;
@ -65,7 +63,7 @@ class GcpManager {
#consecutiveFailures: number = 0; #consecutiveFailures: number = 0;
private isConfigured() { private isConfigured() {
return this.#store.state.storage.gcp.configured; return useStorageState.getState().gcp.configured;
} }
private refreshLoop() { private refreshLoop() {
@ -78,7 +76,8 @@ class GcpManager {
if (this.isConfigured()) { if (this.isConfigured()) {
this.refreshLoop(); this.refreshLoop();
} else { } else {
this.refreshAfter(10_000); console.log('GcpManager: GCP storage not configured; stopping.');
this.stop();
} }
}) })
.catch((reason) => { .catch((reason) => {
@ -89,7 +88,7 @@ class GcpManager {
} }
this.#api.gcp.getToken() this.#api.gcp.getToken()
.then(() => { .then(() => {
const token = this.#store.state.storage.gcp?.token; const token = useStorageState.getState().gcp.token;
if (token) { if (token) {
this.#consecutiveFailures = 0; this.#consecutiveFailures = 0;
const interval = this.refreshInterval(token.expiresIn); const interval = this.refreshInterval(token.expiresIn);

View File

@ -18,10 +18,6 @@ export function useMigrateSettings(api: GlobalApi) {
const { display, remoteContentPolicy, calm } = useSettingsState(); const { display, remoteContentPolicy, calm } = useSettingsState();
return async () => { return async () => {
if (!localStorage?.has("localReducer")) {
return;
}
let promises: Promise<any>[] = []; let promises: Promise<any>[] = [];
if (local.hideAvatars !== calm.hideAvatars) { if (local.hideAvatars !== calm.hideAvatars) {

View File

@ -107,7 +107,9 @@ export function getComments(node: GraphNode): GraphNode {
} }
export function getSnippet(body: string) { export function getSnippet(body: string) {
const start = body.slice(0, body.indexOf('\n', 2)); const newlineIdx = body.indexOf('\n', 2);
const end = newlineIdx > -1 ? newlineIdx : body.length;
const start = body.substr(0, end);
return (start === body || start.startsWith('![')) ? start : `${start}...`; return (start === body || start.startsWith('![')) ? start : `${start}...`;
} }

View File

@ -23,7 +23,8 @@ export const Sigil = memo(
size, size,
svgClass = '', svgClass = '',
icon = false, icon = false,
padding = 0 padding = 0,
display = 'inline-block'
}) => { }) => {
const innerSize = Number(size) - 2 * padding; const innerSize = Number(size) - 2 * padding;
const paddingPx = `${padding}px`; const paddingPx = `${padding}px`;
@ -34,14 +35,14 @@ export const Sigil = memo(
<Box <Box
backgroundColor={color} backgroundColor={color}
borderRadius={icon ? '1' : '0'} borderRadius={icon ? '1' : '0'}
display='inline-block' display={display}
height={size} height={size}
width={size} width={size}
className={classes} className={classes}
/> />
) : ( ) : (
<Box <Box
display='inline-block' display={display}
borderRadius={icon ? '1' : '0'} borderRadius={icon ? '1' : '0'}
flexBasis={size} flexBasis={size}
backgroundColor={color} backgroundColor={color}

View File

@ -8,6 +8,7 @@ import S3 from 'aws-sdk/clients/s3';
import GcpClient from './GcpClient'; import GcpClient from './GcpClient';
import { StorageClient, StorageAcl } from './StorageClient'; import { StorageClient, StorageAcl } from './StorageClient';
import { dateToDa, deSig } from './util'; import { dateToDa, deSig } from './util';
import useStorageState from '../state/storage';
export interface IuseStorage { export interface IuseStorage {
@ -18,9 +19,10 @@ export interface IuseStorage {
promptUpload: () => Promise<string | undefined>; promptUpload: () => Promise<string | undefined>;
} }
const useStorage = ({gcp, s3}: StorageState, const useStorage = ({ accept = '*' } = { accept: '*' }): IuseStorage => {
{ accept = '*' } = { accept: '*' }): IuseStorage => {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const gcp = useStorageState(state => state.gcp);
const s3 = useStorageState(state => state.s3);
const client = useRef<StorageClient | null>(null); const client = useRef<StorageClient | null>(null);

View File

@ -1,9 +1,17 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import _ from "lodash"; import _ from 'lodash';
import f, { memoize } from "lodash/fp"; import f, { compose, memoize } from 'lodash/fp';
import bigInt, { BigInteger } from "big-integer"; import bigInt, { BigInteger } from 'big-integer';
import { Contact } from '~/types'; import { Association, Contact } from '@urbit/api';
import useLocalState from '../state/local';
import produce, { enableMapSet } from 'immer';
import useSettingsState from '../state/settings'; import useSettingsState from '../state/settings';
import { State, UseStore } from 'zustand';
import { Cage } from '~/types/cage';
import { BaseState } from '../state/base';
import anyAscii from 'any-ascii';
enableMapSet();
export const MOBILE_BROWSER_REGEX = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i; export const MOBILE_BROWSER_REGEX = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i;
@ -319,11 +327,12 @@ export function getContactDetails(contact: any) {
} }
export function stringToSymbol(str: string) { export function stringToSymbol(str: string) {
const ascii = anyAscii(str);
let result = ''; let result = '';
for (let i = 0; i < str.length; i++) { for (let i = 0; i < ascii.length; i++) {
const n = str.charCodeAt(i); const n = ascii.charCodeAt(i);
if ((n >= 97 && n <= 122) || (n >= 48 && n <= 57)) { if ((n >= 97 && n <= 122) || (n >= 48 && n <= 57)) {
result += str[i]; result += ascii[i];
} else if (n >= 65 && n <= 90) { } else if (n >= 65 && n <= 90) {
result += String.fromCharCode(n + 32); result += String.fromCharCode(n + 32);
} else { } else {
@ -337,7 +346,6 @@ export function stringToSymbol(str: string) {
} }
return result; return result;
} }
/** /**
* Formats a numbers as a `@ud` inserting dot where needed * Formats a numbers as a `@ud` inserting dot where needed
*/ */
@ -376,7 +384,8 @@ export function pluralize(text: string, isPlural = false, vowel = false) {
// Hide is an optional second parameter for when this function is used in class components // Hide is an optional second parameter for when this function is used in class components
export function useShowNickname(contact: Contact | null, hide?: boolean): boolean { export function useShowNickname(contact: Contact | null, hide?: boolean): boolean {
const hideNicknames = typeof hide !== 'undefined' ? hide : useSettingsState(state => state.calm.hideNicknames); const hideState = useSettingsState(state => state.calm.hideNicknames);
const hideNicknames = typeof hide !== 'undefined' ? hide : hideState;
return !!(contact && contact.nickname && !hideNicknames); return !!(contact && contact.nickname && !hideNicknames);
} }
@ -408,3 +417,4 @@ export function getItemTitle(association: Association) {
} }
return association.metadata.title || association.resource; return association.metadata.title || association.resource;
} }

View File

@ -0,0 +1,44 @@
import React from "react";
import { ReactElement } from "react";
import { UseStore } from "zustand";
import { BaseState } from "../state/base";
const withStateo = <
StateType extends BaseState<any>
>(
useState: UseStore<StateType>,
Component: any,
stateMemberKeys?: (keyof StateType)[]
) => {
return React.forwardRef((props, ref) => {
const state = stateMemberKeys ? useState(
state => stateMemberKeys.reduce(
(object, key) => ({ ...object, [key]: state[key] }), {}
)
) : useState();
return <Component ref={ref} {...state} {...props} />
})
};
const withState = <
StateType extends BaseState<StateType>,
stateKey extends keyof StateType
>(
Component: any,
stores: ([UseStore<StateType>, stateKey[]])[],
) => {
return React.forwardRef((props, ref) => {
let stateProps: unknown = {};
stores.forEach(([store, keys]) => {
const storeProps = Array.isArray(keys)
? store(state => keys.reduce(
(object, key) => ({ ...object, [key]: state[key] }), {}
))
: store();
Object.assign(stateProps, storeProps);
});
return <Component ref={ref} {...stateProps} {...props} />
});
}
export default withState;

View File

@ -1,44 +1,51 @@
import _ from 'lodash'; import _ from 'lodash';
import { StoreState } from '../../store/type'; import { compose } from 'lodash/fp';
import { Cage } from '~/types/cage';
import { ContactUpdate } from '@urbit/api/contacts';
import { resourceAsPath } from '../lib/util';
type ContactState = Pick<StoreState, 'contacts'>; import { ContactUpdate } from '@urbit/api';
export const ContactReducer = (json, state) => { import useContactState, { ContactState } from '../state/contact';
const data = _.get(json, 'contact-update', false); import { reduceState } from '../state/base';
export const ContactReducer = (json) => {
const data: ContactUpdate = _.get(json, 'contact-update', false);
if (data) { if (data) {
initial(data, state); reduceState<ContactState, ContactUpdate>(useContactState, data, [
add(data, state); initial,
remove(data, state); add,
edit(data, state); remove,
setPublic(data, state); edit,
setPublic
]);
} }
// TODO: better isolation // TODO: better isolation
const res = _.get(json, 'resource', false); const res = _.get(json, 'resource', false);
if (res) { if (res) {
state.nackedContacts = state.nackedContacts.add(`~${res.ship}`); useContactState.setState({
nackedContacts: useContactState.getState().nackedContacts.add(`~${res.ship}`)
});
} }
}; };
const initial = (json: ContactUpdate, state: S) => { const initial = (json: ContactUpdate, state: ContactState): ContactState => {
const data = _.get(json, 'initial', false); const data = _.get(json, 'initial', false);
if (data) { if (data) {
state.contacts = data.rolodex; state.contacts = data.rolodex;
state.isContactPublic = data['is-public']; state.isContactPublic = data['is-public'];
} }
return state;
}; };
const add = (json: ContactUpdate, state: S) => { const add = (json: ContactUpdate, state: ContactState): ContactState => {
const data = _.get(json, 'add', false); const data = _.get(json, 'add', false);
if (data) { if (data) {
state.contacts[data.ship] = data.contact; state.contacts[data.ship] = data.contact;
} }
return state;
}; };
const remove = (json: ContactUpdate, state: S) => { const remove = (json: ContactUpdate, state: ContactState): ContactState => {
const data = _.get(json, 'remove', false); const data = _.get(json, 'remove', false);
if ( if (
data && data &&
@ -46,9 +53,10 @@ const remove = (json: ContactUpdate, state: S) => {
) { ) {
delete state.contacts[data.ship]; delete state.contacts[data.ship];
} }
return state;
}; };
const edit = (json: ContactUpdate, state: S) => { const edit = (json: ContactUpdate, state: ContactState): ContactState => {
const data = _.get(json, 'edit', false); const data = _.get(json, 'edit', false);
const ship = `~${data.ship}`; const ship = `~${data.ship}`;
if ( if (
@ -57,7 +65,7 @@ const edit = (json: ContactUpdate, state: S) => {
) { ) {
const [field] = Object.keys(data['edit-field']); const [field] = Object.keys(data['edit-field']);
if (!field) { if (!field) {
return; return state;
} }
const value = data['edit-field'][field]; const value = data['edit-field'][field];
@ -71,10 +79,12 @@ const edit = (json: ContactUpdate, state: S) => {
state.contacts[ship][field] = value; state.contacts[ship][field] = value;
} }
} }
return state;
}; };
const setPublic = (json: ContactUpdate, state: S) => { const setPublic = (json: ContactUpdate, state: ContactState): ContactState => {
const data = _.get(json, 'set-public', state.isContactPublic); const data = _.get(json, 'set-public', state.isContactPublic);
state.isContactPublic = data; state.isContactPublic = data;
return state;
}; };

View File

@ -1,37 +1,43 @@
import _ from 'lodash'; import _ from 'lodash';
import {StoreState} from '../store/type'; import {StoreState} from '../store/type';
import {GcpToken} from '../../types/gcp-state'; import {GcpToken} from '../../types/gcp-state';
import { Cage } from '~/types/cage';
import useStorageState, { StorageState } from '../state/storage';
import { reduceState } from '../state/base';
type GcpState = Pick<StoreState, 'gcp'>; export default class GcpReducer {
reduce(json: Cage) {
export default class GcpReducer<S extends GcpState>{ reduceState<StorageState, any>(useStorageState, json, [
reduce(json: Cage, state: S) { reduceConfigured,
this.reduceConfigured(json, state); reduceToken
this.reduceToken(json, state); ]);
}
} }
reduceConfigured(json, state) { const reduceConfigured = (json, state: StorageState): StorageState => {
let data = json['gcp-configured']; let data = json['gcp-configured'];
if (data !== undefined) { if (data !== undefined) {
state.storage.gcp.configured = data; state.gcp.configured = data;
} }
return state;
} }
reduceToken(json: Cage, state: S) { const reduceToken = (json: Cage, state: StorageState): StorageState => {
let data = json['gcp-token']; let data = json['gcp-token'];
if (data) { if (data) {
this.setToken(data, state); state = setToken(data, state);
} }
return state;
} }
setToken(data: any, state: S) { const setToken = (data: any, state: StorageState): StorageState => {
if (this.isToken(data)) { if (isToken(data)) {
state.storage.gcp.token = data; state.gcp.token = data;
} }
return state;
} }
isToken(token: any): token is GcpToken { const isToken = (token: any): boolean => {
return (typeof(token.accessKey) === 'string' && return (typeof(token.accessKey) === 'string' &&
typeof(token.expiresIn) === 'number'); typeof(token.expiresIn) === 'number');
} }
}

View File

@ -1,20 +1,24 @@
import _ from 'lodash'; import _ from 'lodash';
import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap"; import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap";
import bigInt, { BigInteger } from "big-integer"; import bigInt, { BigInteger } from "big-integer";
import useGraphState, { GraphState } from '../state/graph';
import { reduceState } from '../state/base';
export const GraphReducer = (json, state) => { export const GraphReducer = (json) => {
const data = _.get(json, 'graph-update', false); const data = _.get(json, 'graph-update', false);
if (data) { if (data) {
keys(data, state); reduceState<GraphState, any>(useGraphState, data, [
addGraph(data, state); keys,
removeGraph(data, state); addGraph,
addNodes(data, state); removeGraph,
removeNodes(data, state); addNodes,
removeNodes
]);
} }
}; };
const keys = (json, state) => { const keys = (json, state: GraphState): GraphState => {
const data = _.get(json, 'keys', false); const data = _.get(json, 'keys', false);
if (data) { if (data) {
state.graphKeys = new Set(data.map((res) => { state.graphKeys = new Set(data.map((res) => {
@ -22,9 +26,10 @@ const keys = (json, state) => {
return resource; return resource;
})); }));
} }
return state;
}; };
const addGraph = (json, state) => { const addGraph = (json, state: GraphState): GraphState => {
const _processNode = (node) => { const _processNode = (node) => {
// is empty // is empty
@ -72,10 +77,10 @@ const addGraph = (json, state) => {
} }
state.graphKeys.add(resource); state.graphKeys.add(resource);
} }
return state;
}; };
const removeGraph = (json, state) => { const removeGraph = (json, state: GraphState): GraphState => {
const data = _.get(json, 'remove-graph', false); const data = _.get(json, 'remove-graph', false);
if (data) { if (data) {
@ -86,6 +91,7 @@ const removeGraph = (json, state) => {
state.graphKeys.delete(resource); state.graphKeys.delete(resource);
delete state.graphs[resource]; delete state.graphs[resource];
} }
return state;
}; };
const mapifyChildren = (children) => { const mapifyChildren = (children) => {
@ -98,7 +104,7 @@ const mapifyChildren = (children) => {
}; };
const addNodes = (json, state) => { const addNodes = (json, state) => {
const _addNode = (graph, index, node, resource) => { const _addNode = (graph, index, node) => {
// set child of graph // set child of graph
if (index.length === 1) { if (index.length === 1) {
graph.set(index[0], node); graph.set(index[0], node);
@ -160,7 +166,7 @@ const addNodes = (json, state) => {
const data = _.get(json, 'add-nodes', false); const data = _.get(json, 'add-nodes', false);
if (data) { if (data) {
if (!('graphs' in state)) { return; } if (!('graphs' in state)) { return state; }
let resource = data.resource.ship + '/' + data.resource.name; let resource = data.resource.ship + '/' + data.resource.name;
if (!(resource in state.graphs)) { if (!(resource in state.graphs)) {
@ -192,7 +198,7 @@ const addNodes = (json, state) => {
return bigInt(ind); return bigInt(ind);
}); });
if (indexArr.length === 0) { return; } if (indexArr.length === 0) { return state; }
if (node.post.pending) { if (node.post.pending) {
state.graphTimesentMap[resource][node.post['time-sent']] = index; state.graphTimesentMap[resource][node.post['time-sent']] = index;
@ -210,10 +216,10 @@ const addNodes = (json, state) => {
state.graphs[resource] = graph; state.graphs[resource] = graph;
} }
return state;
}; };
const removeNodes = (json, state: GraphState): GraphState => {
const removeNodes = (json, state) => {
const _remove = (graph, index) => { const _remove = (graph, index) => {
if (index.length === 1) { if (index.length === 1) {
graph.delete(index[0]); graph.delete(index[0]);
@ -230,7 +236,7 @@ const removeNodes = (json, state) => {
if (data) { if (data) {
const { ship, name } = data.resource; const { ship, name } = data.resource;
const res = `${ship}/${name}`; const res = `${ship}/${name}`;
if (!(res in state.graphs)) { return; } if (!(res in state.graphs)) { return state; }
data.indices.forEach((index) => { data.indices.forEach((index) => {
if (index.split('/').length === 0) { return; } if (index.split('/').length === 0) { return; }
@ -240,4 +246,5 @@ const removeNodes = (json, state) => {
_remove(state.graphs[res], indexArr); _remove(state.graphs[res], indexArr);
}); });
} }
return state;
}; };

View File

@ -1,5 +1,4 @@
import _ from 'lodash'; import _ from 'lodash';
import { StoreState } from '../../store/type';
import { Cage } from '~/types/cage'; import { Cage } from '~/types/cage';
import { import {
GroupUpdate, GroupUpdate,
@ -14,8 +13,9 @@ import {
} from '@urbit/api/groups'; } from '@urbit/api/groups';
import { Enc, PatpNoSig } from '@urbit/api'; import { Enc, PatpNoSig } from '@urbit/api';
import { resourceAsPath } from '../lib/util'; import { resourceAsPath } from '../lib/util';
import useGroupState, { GroupState } from '../state/group';
type GroupState = Pick<StoreState, 'groups' | 'groupKeys'>; import { compose } from 'lodash/fp';
import { reduceState } from '../state/base';
function decodeGroup(group: Enc<Group>): Group { function decodeGroup(group: Enc<Group>): Group {
const members = new Set(group.members); const members = new Set(group.members);
@ -57,41 +57,44 @@ function decodeTags(tags: Enc<Tags>): Tags {
); );
} }
export default class GroupReducer<S extends GroupState> { export default class GroupReducer {
reduce(json: Cage, state: S) { reduce(json: Cage) {
const data = json.groupUpdate; const data = json.groupUpdate;
if (data) { if (data) {
console.log(data); reduceState<GroupState, GroupUpdate>(useGroupState, data, [
this.initial(data, state); initial,
this.addMembers(data, state); addMembers,
this.addTag(data, state); addTag,
this.removeMembers(data, state); removeMembers,
this.initialGroup(data, state); initialGroup,
this.removeTag(data, state); removeTag,
this.initial(data, state); addGroup,
this.addGroup(data, state); removeGroup,
this.removeGroup(data, state); changePolicy,
this.changePolicy(data, state); expose,
this.expose(data, state); ]);
} }
} }
initial(json: GroupUpdate, state: S) { }
const initial = (json: GroupUpdate, state: GroupState): GroupState => {
const data = json['initial']; const data = json['initial'];
if (data) { if (data) {
state.groups = _.mapValues(data, decodeGroup); state.groups = _.mapValues(data, decodeGroup);
} }
return state;
} }
initialGroup(json: GroupUpdate, state: S) { const initialGroup = (json: GroupUpdate, state: GroupState): GroupState => {
if ('initialGroup' in json) { if ('initialGroup' in json) {
const { resource, group } = json.initialGroup; const { resource, group } = json.initialGroup;
const path = resourceAsPath(resource); const path = resourceAsPath(resource);
state.groups[path] = decodeGroup(group); state.groups[path] = decodeGroup(group);
} }
return state;
} }
addGroup(json: GroupUpdate, state: S) { const addGroup = (json: GroupUpdate, state: GroupState): GroupState => {
if ('addGroup' in json) { if ('addGroup' in json) {
const { resource, policy, hidden } = json.addGroup; const { resource, policy, hidden } = json.addGroup;
const resourcePath = resourceAsPath(resource); const resourcePath = resourceAsPath(resource);
@ -102,16 +105,19 @@ export default class GroupReducer<S extends GroupState> {
hidden hidden
}; };
} }
return state;
} }
removeGroup(json: GroupUpdate, state: S) {
const removeGroup = (json: GroupUpdate, state: GroupState): GroupState => {
if('removeGroup' in json) { if('removeGroup' in json) {
const { resource } = json.removeGroup; const { resource } = json.removeGroup;
const resourcePath = resourceAsPath(resource); const resourcePath = resourceAsPath(resource);
delete state.groups[resourcePath]; delete state.groups[resourcePath];
} }
return state;
} }
addMembers(json: GroupUpdate, state: S) { const addMembers = (json: GroupUpdate, state: GroupState): GroupState => {
if ('addMembers' in json) { if ('addMembers' in json) {
const { resource, ships } = json.addMembers; const { resource, ships } = json.addMembers;
const resourcePath = resourceAsPath(resource); const resourcePath = resourceAsPath(resource);
@ -125,9 +131,10 @@ export default class GroupReducer<S extends GroupState> {
} }
} }
} }
return state;
} }
removeMembers(json: GroupUpdate, state: S) { const removeMembers = (json: GroupUpdate, state: GroupState): GroupState => {
if ('removeMembers' in json) { if ('removeMembers' in json) {
const { resource, ships } = json.removeMembers; const { resource, ships } = json.removeMembers;
const resourcePath = resourceAsPath(resource); const resourcePath = resourceAsPath(resource);
@ -135,9 +142,10 @@ export default class GroupReducer<S extends GroupState> {
state.groups[resourcePath].members.delete(member); state.groups[resourcePath].members.delete(member);
} }
} }
return state;
} }
addTag(json: GroupUpdate, state: S) { const addTag = (json: GroupUpdate, state: GroupState): GroupState => {
if ('addTag' in json) { if ('addTag' in json) {
const { resource, tag, ships } = json.addTag; const { resource, tag, ships } = json.addTag;
const resourcePath = resourceAsPath(resource); const resourcePath = resourceAsPath(resource);
@ -150,9 +158,10 @@ export default class GroupReducer<S extends GroupState> {
} }
_.set(tags, tagAccessors, tagged); _.set(tags, tagAccessors, tagged);
} }
return state;
} }
removeTag(json: GroupUpdate, state: S) { const removeTag = (json: GroupUpdate, state: GroupState): GroupState => {
if ('removeTag' in json) { if ('removeTag' in json) {
const { resource, tag, ships } = json.removeTag; const { resource, tag, ships } = json.removeTag;
const resourcePath = resourceAsPath(resource); const resourcePath = resourceAsPath(resource);
@ -162,41 +171,44 @@ export default class GroupReducer<S extends GroupState> {
const tagged = _.get(tags, tagAccessors, new Set()); const tagged = _.get(tags, tagAccessors, new Set());
if (!tagged) { if (!tagged) {
return; return state;
} }
for (const ship of ships) { for (const ship of ships) {
tagged.delete(ship); tagged.delete(ship);
} }
_.set(tags, tagAccessors, tagged); _.set(tags, tagAccessors, tagged);
} }
return state;
} }
changePolicy(json: GroupUpdate, state: S) { const changePolicy = (json: GroupUpdate, state: GroupState): GroupState => {
if ('changePolicy' in json && state) { if ('changePolicy' in json && state) {
const { resource, diff } = json.changePolicy; const { resource, diff } = json.changePolicy;
const resourcePath = resourceAsPath(resource); const resourcePath = resourceAsPath(resource);
const policy = state.groups[resourcePath].policy; const policy = state.groups[resourcePath].policy;
if ('open' in policy && 'open' in diff) { if ('open' in policy && 'open' in diff) {
this.openChangePolicy(diff.open, policy); openChangePolicy(diff.open, policy);
} else if ('invite' in policy && 'invite' in diff) { } else if ('invite' in policy && 'invite' in diff) {
this.inviteChangePolicy(diff.invite, policy); inviteChangePolicy(diff.invite, policy);
} else if ('replace' in diff) { } else if ('replace' in diff) {
state.groups[resourcePath].policy = diff.replace; state.groups[resourcePath].policy = diff.replace;
} else { } else {
console.log('bad policy diff'); console.log('bad policy diff');
} }
} }
return state;
} }
expose(json: GroupUpdate, state: S) { const expose = (json: GroupUpdate, state: GroupState): GroupState => {
if( 'expose' in json && state) { if( 'expose' in json && state) {
const { resource } = json.expose; const { resource } = json.expose;
const resourcePath = resourceAsPath(resource); const resourcePath = resourceAsPath(resource);
state.groups[resourcePath].hidden = false; state.groups[resourcePath].hidden = false;
} }
return state;
} }
private inviteChangePolicy(diff: InvitePolicyDiff, policy: InvitePolicy) { const inviteChangePolicy = (diff: InvitePolicyDiff, policy: InvitePolicy) => {
if ('addInvites' in diff) { if ('addInvites' in diff) {
const { addInvites } = diff; const { addInvites } = diff;
for (const ship of addInvites) { for (const ship of addInvites) {
@ -212,7 +224,7 @@ export default class GroupReducer<S extends GroupState> {
} }
} }
private openChangePolicy(diff: OpenPolicyDiff, policy: OpenPolicy) { const openChangePolicy = (diff: OpenPolicyDiff, policy: OpenPolicy) => {
if ('allowRanks' in diff) { if ('allowRanks' in diff) {
const { allowRanks } = diff; const { allowRanks } = diff;
for (const rank of allowRanks) { for (const rank of allowRanks) {
@ -224,13 +236,11 @@ export default class GroupReducer<S extends GroupState> {
policy.open.banRanks.delete(rank); policy.open.banRanks.delete(rank);
} }
} else if ('allowShips' in diff) { } else if ('allowShips' in diff) {
console.log('allowing ships');
const { allowShips } = diff; const { allowShips } = diff;
for (const ship of allowShips) { for (const ship of allowShips) {
policy.open.banned.delete(ship); policy.open.banned.delete(ship);
} }
} else if ('banShips' in diff) { } else if ('banShips' in diff) {
console.log('banning ships');
const { banShips } = diff; const { banShips } = diff;
for (const ship of banShips) { for (const ship of banShips) {
policy.open.banned.add(ship); policy.open.banned.add(ship);
@ -239,4 +249,3 @@ export default class GroupReducer<S extends GroupState> {
console.log('bad policy change'); console.log('bad policy change');
} }
} }
}

View File

@ -1,13 +1,17 @@
import { GroupUpdate } from '@urbit/api/groups';
import { resourceAsPath } from '~/logic/lib/util'; import { resourceAsPath } from '~/logic/lib/util';
import { reduceState } from '../state/base';
import useGroupState, { GroupState } from '../state/group';
const initial = (json: any, state: any) => { const initial = (json: any, state: GroupState): GroupState => {
const data = json.initial; const data = json.initial;
if(data) { if(data) {
state.pendingJoin = data; state.pendingJoin = data;
} }
return state;
}; };
const progress = (json: any, state: any) => { const progress = (json: any, state: GroupState): GroupState => {
const data = json.progress; const data = json.progress;
if(data) { if(data) {
const { progress, resource } = data; const { progress, resource } = data;
@ -18,12 +22,15 @@ const progress = (json: any, state: any) => {
}, 10000); }, 10000);
} }
} }
return state;
}; };
export const GroupViewReducer = (json: any, state: any) => { export const GroupViewReducer = (json: any) => {
const data = json['group-view-update']; const data = json['group-view-update'];
if (data) { if (data) {
progress(data, state); reduceState<GroupState, GroupUpdate>(useGroupState, data, [
initial(data, state); progress,
initial
]);
} }
}; };

View File

@ -3,51 +3,94 @@ import {
NotifIndex, NotifIndex,
NotificationGraphConfig, NotificationGraphConfig,
GroupNotificationsConfig, GroupNotificationsConfig,
UnreadStats UnreadStats,
Timebox
} from '@urbit/api'; } from '@urbit/api';
import { makePatDa } from '~/logic/lib/util'; import { makePatDa } from '~/logic/lib/util';
import _ from 'lodash'; import _ from 'lodash';
import { StoreState } from '../store/type';
import { BigIntOrderedMap } from '../lib/BigIntOrderedMap'; import { BigIntOrderedMap } from '../lib/BigIntOrderedMap';
import useHarkState, { HarkState } from '../state/hark';
import { compose } from 'lodash/fp';
import { reduceState } from '../state/base';
import bigInt, {BigInteger} from 'big-integer';
type HarkState = Pick<StoreState, 'notifications' | 'notificationsGraphConfig' | 'notificationsGroupConfig' | 'unreads' >; export const HarkReducer = (json: any) => {
export const HarkReducer = (json: any, state: HarkState) => {
const data = _.get(json, 'harkUpdate', false); const data = _.get(json, 'harkUpdate', false);
if (data) { if (data) {
reduce(data, state); reduce(data);
} }
const graphHookData = _.get(json, 'hark-graph-hook-update', false); const graphHookData = _.get(json, 'hark-graph-hook-update', false);
if (graphHookData) { if (graphHookData) {
graphInitial(graphHookData, state); reduceState<HarkState, any>(useHarkState, graphHookData, [
graphIgnore(graphHookData, state); graphInitial,
graphListen(graphHookData, state); graphIgnore,
graphWatchSelf(graphHookData, state); graphListen,
graphMentions(graphHookData, state); graphWatchSelf,
graphMentions,
]);
} }
const groupHookData = _.get(json, 'hark-group-hook-update', false); const groupHookData = _.get(json, 'hark-group-hook-update', false);
if (groupHookData) { if (groupHookData) {
groupInitial(groupHookData, state); reduceState<HarkState, any>(useHarkState, groupHookData, [
groupListen(groupHookData, state); groupInitial,
groupIgnore(groupHookData, state); groupListen,
groupIgnore,
]);
} }
}; };
function groupInitial(json: any, state: HarkState) { function reduce(data) {
reduceState<HarkState, any>(useHarkState, data, [
unread,
read,
archive,
timebox,
more,
dnd,
added,
unreads,
readEach,
readSince,
unreadSince,
unreadEach,
seenIndex,
removeGraph,
readAll,
calculateCount
]);
}
function calculateCount(json: any, state: HarkState) {
let count = 0;
_.forEach(state.unreads.graph, (graphs) => {
_.forEach(graphs, graph => {
count += (graph?.notifications || []).length;
});
});
_.forEach(state.unreads.group, group => {
count += (group?.notifications || []).length;
})
state.notificationsCount = count;
return state;
}
function groupInitial(json: any, state: HarkState): HarkState {
const data = _.get(json, 'initial', false); const data = _.get(json, 'initial', false);
if (data) { if (data) {
state.notificationsGroupConfig = data; state.notificationsGroupConfig = data;
} }
return state;
} }
function graphInitial(json: any, state: HarkState) { function graphInitial(json: any, state: HarkState): HarkState {
const data = _.get(json, 'initial', false); const data = _.get(json, 'initial', false);
if (data) { if (data) {
state.notificationsGraphConfig = data; state.notificationsGraphConfig = data;
} }
return state;
} }
function graphListen(json: any, state: HarkState) { function graphListen(json: any, state: HarkState): HarkState {
const data = _.get(json, 'listen', false); const data = _.get(json, 'listen', false);
if (data) { if (data) {
state.notificationsGraphConfig.watching = [ state.notificationsGraphConfig.watching = [
@ -55,134 +98,133 @@ function graphListen(json: any, state: HarkState) {
data data
]; ];
} }
return state;
} }
function graphIgnore(json: any, state: HarkState) { function graphIgnore(json: any, state: HarkState): HarkState {
const data = _.get(json, 'ignore', false); const data = _.get(json, 'ignore', false);
if (data) { if (data) {
state.notificationsGraphConfig.watching = state.notificationsGraphConfig.watching.filter( state.notificationsGraphConfig.watching = state.notificationsGraphConfig.watching.filter(
({ graph, index }) => !(graph === data.graph && index === data.index) ({ graph, index }) => !(graph === data.graph && index === data.index)
); );
} }
return state;
} }
function groupListen(json: any, state: HarkState) { function groupListen(json: any, state: HarkState): HarkState {
const data = _.get(json, 'listen', false); const data = _.get(json, 'listen', false);
if (data) { if (data) {
state.notificationsGroupConfig = [...state.notificationsGroupConfig, data]; state.notificationsGroupConfig = [...state.notificationsGroupConfig, data];
} }
return state;
} }
function groupIgnore(json: any, state: HarkState) { function groupIgnore(json: any, state: HarkState): HarkState {
const data = _.get(json, 'ignore', false); const data = _.get(json, 'ignore', false);
if (data) { if (data) {
state.notificationsGroupConfig = state.notificationsGroupConfig.filter( state.notificationsGroupConfig = state.notificationsGroupConfig.filter(
n => n !== data n => n !== data
); );
} }
return state;
} }
function graphMentions(json: any, state: HarkState) { function graphMentions(json: any, state: HarkState): HarkState {
const data = _.get(json, 'set-mentions', undefined); const data = _.get(json, 'set-mentions', undefined);
if (!_.isUndefined(data)) { if (!_.isUndefined(data)) {
state.notificationsGraphConfig.mentions = data; state.notificationsGraphConfig.mentions = data;
} }
return state;
} }
function graphWatchSelf(json: any, state: HarkState) { function graphWatchSelf(json: any, state: HarkState): HarkState {
const data = _.get(json, 'set-watch-on-self', undefined); const data = _.get(json, 'set-watch-on-self', undefined);
if (!_.isUndefined(data)) { if (!_.isUndefined(data)) {
state.notificationsGraphConfig.watchOnSelf = data; state.notificationsGraphConfig.watchOnSelf = data;
} }
return state;
} }
function reduce(data: any, state: HarkState) { function readAll(json: any, state: HarkState): HarkState {
unread(data, state);
read(data, state);
archive(data, state);
timebox(data, state);
more(data, state);
dnd(data, state);
added(data, state);
unreads(data, state);
readEach(data, state);
readSince(data, state);
unreadSince(data, state);
unreadEach(data, state);
seenIndex(data, state);
removeGraph(data, state);
readAll(data, state);
}
function readAll(json: any, state: HarkState) {
const data = _.get(json, 'read-all'); const data = _.get(json, 'read-all');
if(data) { if(data) {
clearState(state); state = clearState(state);
} }
return state;
} }
function removeGraph(json: any, state: HarkState) { function removeGraph(json: any, state: HarkState): HarkState {
const data = _.get(json, 'remove-graph'); const data = _.get(json, 'remove-graph');
if(data) { if(data) {
delete state.unreads.graph[data]; delete state.unreads.graph[data];
} }
return state;
} }
function seenIndex(json: any, state: HarkState) { function seenIndex(json: any, state: HarkState): HarkState {
const data = _.get(json, 'seen-index'); const data = _.get(json, 'seen-index');
if(data) { if(data) {
updateNotificationStats(state, data.index, 'last', () => data.time); state = updateNotificationStats(state, data.index, 'last', () => data.time);
} }
return state;
} }
function readEach(json: any, state: HarkState) { function readEach(json: any, state: HarkState): HarkState {
const data = _.get(json, 'read-each'); const data = _.get(json, 'read-each');
if (data) { if (data) {
updateUnreads(state, data.index, u => u.delete(data.target)); state = updateUnreads(state, data.index, u => u.delete(data.target));
} }
return state;
} }
function readSince(json: any, state: HarkState) { function readSince(json: any, state: HarkState): HarkState {
const data = _.get(json, 'read-count'); const data = _.get(json, 'read-count');
if(data) { if(data) {
updateUnreadCount(state, data, () => 0); state = updateUnreadCount(state, data, () => 0);
} }
return state;
} }
function unreadSince(json: any, state: HarkState) { function unreadSince(json: any, state: HarkState): HarkState {
const data = _.get(json, 'unread-count'); const data = _.get(json, 'unread-count');
if(data) { if(data) {
updateUnreadCount(state, data.index, u => u + 1); state = updateUnreadCount(state, data.index, u => u + 1);
} }
return state;
} }
function unreadEach(json: any, state: HarkState) { function unreadEach(json: any, state: HarkState): HarkState {
const data = _.get(json, 'unread-each'); const data = _.get(json, 'unread-each');
if(data) { if(data) {
updateUnreads(state, data.index, us => us.add(data.target)); state = updateUnreads(state, data.index, us => us.add(data.target));
} }
return state;
} }
function unreads(json: any, state: HarkState) { function unreads(json: any, state: HarkState): HarkState {
const data = _.get(json, 'unreads'); const data = _.get(json, 'unreads');
if(data) { if(data) {
clearState(state); state = clearState(state);
data.forEach(({ index, stats }) => { data.forEach(({ index, stats }) => {
const { unreads, notifications, last } = stats; const { unreads, notifications, last } = stats;
updateNotificationStats(state, index, 'notifications', x => x + notifications);
updateNotificationStats(state, index, 'last', () => last); updateNotificationStats(state, index, 'last', () => last);
_.each(notifications, ({ time, index }) => {
addNotificationToUnread(state, index, makePatDa(time));
});
if('count' in unreads) { if('count' in unreads) {
updateUnreadCount(state, index, (u = 0) => u + unreads.count); state = updateUnreadCount(state, index, (u = 0) => u + unreads.count);
} else { } else {
state = updateUnreads(state, index, s => new Set());
unreads.each.forEach((u: string) => { unreads.each.forEach((u: string) => {
updateUnreads(state, index, s => s.add(u)); state = updateUnreads(state, index, s => s.add(u));
}); });
} }
}); });
} }
return state;
} }
function clearState(state) { function clearState(state: HarkState): HarkState {
const initialState = { const initialState = {
notifications: new BigIntOrderedMap<Timebox>(), notifications: new BigIntOrderedMap<Timebox>(),
archivedNotifications: new BigIntOrderedMap<Timebox>(), archivedNotifications: new BigIntOrderedMap<Timebox>(),
@ -202,73 +244,110 @@ function clearState(state) {
Object.keys(initialState).forEach((key) => { Object.keys(initialState).forEach((key) => {
state[key] = initialState[key]; state[key] = initialState[key];
}); });
return state;
} }
function updateUnreadCount(state: HarkState, index: NotifIndex, count: (c: number) => number) { function updateUnreadCount(state: HarkState, index: NotifIndex, count: (c: number) => number): HarkState {
if(!('graph' in index)) { if(!('graph' in index)) {
return; return state;
} }
const property = [index.graph.graph, index.graph.index, 'unreads']; const property = [index.graph.graph, index.graph.index, 'unreads'];
const curr = _.get(state.unreads.graph, property, 0); const curr = _.get(state.unreads.graph, property, 0);
const newCount = count(curr); const newCount = count(curr);
_.set(state.unreads.graph, property, newCount); _.set(state.unreads.graph, property, newCount);
return state;
} }
function updateUnreads(state: HarkState, index: NotifIndex, f: (us: Set<string>) => void) { function updateUnreads(state: HarkState, index: NotifIndex, f: (us: Set<string>) => void): HarkState {
if(!('graph' in index)) { if(!('graph' in index)) {
return; return state;
} }
const unreads = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], new Set<string>()); let unreads = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], new Set<string>());
const oldSize = unreads.size;
f(unreads); f(unreads);
const newSize = unreads.size;
_.set(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], unreads); _.set(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], unreads);
return state;
} }
function updateNotificationStats(state: HarkState, index: NotifIndex, statField: 'notifications' | 'unreads' | 'last', f: (x: number) => number) { function addNotificationToUnread(state: HarkState, index: NotifIndex, time: BigInteger) {
if(statField === 'notifications') { if('graph' in index) {
state.notificationsCount = f(state.notificationsCount); const path = [index.graph.graph, index.graph.index, 'notifications'];
const curr = _.get(state.unreads.graph, path, []);
_.set(state.unreads.graph, path,
[
...curr.filter(c => !(c.time.eq(time) && notifIdxEqual(c.index, index))),
{ time, index}
]
);
} else if ('group' in index) {
const path = [index.group.group, 'notifications'];
const curr = _.get(state.unreads.group, path, []);
_.set(state.unreads.group, path,
[
...curr.filter(c => !(c.time.eq(time) && notifIdxEqual(c.index, index))),
{ time, index}
]
);
} }
}
function removeNotificationFromUnread(state: HarkState, index: NotifIndex, time: BigInteger) {
if('graph' in index) {
const path = [index.graph.graph, index.graph.index, 'notifications'];
const curr = _.get(state.unreads.graph, path, []);
_.set(state.unreads.graph, path,
curr.filter(c => !(c.time.eq(time) && notifIdxEqual(c.index, index))),
);
} else if ('group' in index) {
const path = [index.group.group, 'notifications'];
const curr = _.get(state.unreads.group, path, []);
_.set(state.unreads.group, path,
curr.filter(c => !(c.time.eq(time) && notifIdxEqual(c.index, index))),
);
}
}
function updateNotificationStats(state: HarkState, index: NotifIndex, statField: 'unreads' | 'last', f: (x: number) => number) {
if('graph' in index) { if('graph' in index) {
const curr = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, statField], 0); const curr = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, statField], 0);
_.set(state.unreads.graph, [index.graph.graph, index.graph.index, statField], f(curr)); _.set(state.unreads.graph, [index.graph.graph, index.graph.index, statField], f(curr));
} else if('group' in index) { } else if('group' in index) {
const curr = _.get(state.unreads.group, [index.group, statField], 0); const curr = _.get(state.unreads.group, [index.group.group, statField], 0);
_.set(state.unreads.group, [index.group, statField], f(curr)); _.set(state.unreads.group, [index.group.group, statField], f(curr));
} }
} }
function added(json: any, state: HarkState) { function added(json: any, state: HarkState): HarkState {
const data = _.get(json, 'added', false); const data = _.get(json, 'added', false);
if (data) { if (data) {
const { index, notification } = data; const { index, notification } = data;
const time = makePatDa(data.time); const time = makePatDa(data.time);
const timebox = state.notifications.get(time) || []; const timebox = state.notifications.get(time) || [];
addNotificationToUnread(state, index, time);
const arrIdx = timebox.findIndex(idxNotif => const arrIdx = timebox.findIndex(idxNotif =>
notifIdxEqual(index, idxNotif.index) notifIdxEqual(index, idxNotif.index)
); );
if (arrIdx !== -1) { if (arrIdx !== -1) {
if(timebox[arrIdx]?.notification?.read) {
updateNotificationStats(state, index, 'notifications', x => x+1);
}
timebox[arrIdx] = { index, notification }; timebox[arrIdx] = { index, notification };
state.notifications.set(time, timebox); state.notifications.set(time, timebox);
} else { } else {
updateNotificationStats(state, index, 'notifications', x => x+1);
state.notifications.set(time, [...timebox, { index, notification }]); state.notifications.set(time, [...timebox, { index, notification }]);
} }
} }
return state;
} }
const dnd = (json: any, state: HarkState) => { const dnd = (json: any, state: HarkState): HarkState => {
const data = _.get(json, 'set-dnd', undefined); const data = _.get(json, 'set-dnd', undefined);
if (!_.isUndefined(data)) { if (!_.isUndefined(data)) {
state.doNotDisturb = data; state.doNotDisturb = data;
} }
return state;
}; };
const timebox = (json: any, state: HarkState) => { const timebox = (json: any, state: HarkState): HarkState => {
const data = _.get(json, 'timebox', false); const data = _.get(json, 'timebox', false);
if (data) { if (data) {
const time = makePatDa(data.time); const time = makePatDa(data.time);
@ -276,13 +355,15 @@ const timebox = (json: any, state: HarkState) => {
state.notifications.set(time, data.notifications); state.notifications.set(time, data.notifications);
} }
} }
return state;
}; };
function more(json: any, state: HarkState) { function more(json: any, state: HarkState): HarkState {
const data = _.get(json, 'more', false); const data = _.get(json, 'more', false);
if (data) { if (data) {
_.forEach(data, d => reduce(d, state)); _.forEach(data, d => reduce(d));
} }
return state;
} }
function notifIdxEqual(a: NotifIndex, b: NotifIndex) { function notifIdxEqual(a: NotifIndex, b: NotifIndex) {
@ -307,51 +388,55 @@ function setRead(
index: NotifIndex, index: NotifIndex,
read: boolean, read: boolean,
state: HarkState state: HarkState
) { ): HarkState {
const patDa = makePatDa(time); const patDa = makePatDa(time);
const timebox = state.notifications.get(patDa); const timebox = state.notifications.get(patDa);
if (_.isNull(timebox)) { if (_.isNull(timebox)) {
console.warn('Modifying nonexistent timebox'); console.warn('Modifying nonexistent timebox');
return; return state;
} }
const arrIdx = timebox.findIndex(idxNotif => const arrIdx = timebox.findIndex(idxNotif =>
notifIdxEqual(index, idxNotif.index) notifIdxEqual(index, idxNotif.index)
); );
if (arrIdx === -1) { if (arrIdx === -1) {
console.warn('Modifying nonexistent index'); console.warn('Modifying nonexistent index');
return; return state;
} }
timebox[arrIdx].notification.read = read; timebox[arrIdx].notification.read = read;
state.notifications.set(patDa, timebox); state.notifications.set(patDa, timebox);
return state;
} }
function read(json: any, state: HarkState) { function read(json: any, state: HarkState): HarkState {
const data = _.get(json, 'read-note', false); const data = _.get(json, 'read-note', false);
if (data) { if (data) {
const { time, index } = data; const { time, index } = data;
updateNotificationStats(state, index, 'notifications', x => x-1); removeNotificationFromUnread(state, index, makePatDa(time));
setRead(time, index, true, state); setRead(time, index, true, state);
} }
return state;
} }
function unread(json: any, state: HarkState) { function unread(json: any, state: HarkState): HarkState {
const data = _.get(json, 'unread-note', false); const data = _.get(json, 'unread-note', false);
if (data) { if (data) {
const { time, index } = data; const { time, index } = data;
updateNotificationStats(state, index, 'notifications', x => x+1); addNotificationToUnread(state, index, makePatDa(time));
setRead(time, index, false, state); setRead(time, index, false, state);
} }
return state;
} }
function archive(json: any, state: HarkState) { function archive(json: any, state: HarkState): HarkState {
const data = _.get(json, 'archive', false); const data = _.get(json, 'archive', false);
if (data) { if (data) {
const { index } = data; const { index } = data;
removeNotificationFromUnread(state, index, makePatDa(data.time))
const time = makePatDa(data.time); const time = makePatDa(data.time);
const timebox = state.notifications.get(time); const timebox = state.notifications.get(time);
if (!timebox) { if (!timebox) {
console.warn('Modifying nonexistent timebox'); console.warn('Modifying nonexistent timebox');
return; return state;
} }
const [archived, unarchived] = _.partition(timebox, idxNotif => const [archived, unarchived] = _.partition(timebox, idxNotif =>
notifIdxEqual(index, idxNotif.index) notifIdxEqual(index, idxNotif.index)
@ -362,7 +447,6 @@ function archive(json: any, state: HarkState) {
} else { } else {
state.notifications.set(time, unarchived); state.notifications.set(time, unarchived);
} }
const newlyRead = archived.filter(x => !x.notification.read).length;
updateNotificationStats(state, index, 'notifications', x => x - newlyRead);
} }
return state;
} }

View File

@ -1,62 +1,72 @@
import _ from 'lodash'; import _ from 'lodash';
import { StoreState } from '../../store/type'; import { compose } from 'lodash/fp';
import { Cage } from '~/types/cage';
import { InviteUpdate } from '@urbit/api/invite'; import { InviteUpdate } from '@urbit/api/invite';
type InviteState = Pick<StoreState, 'invites'>; import { Cage } from '~/types/cage';
import useInviteState, { InviteState } from '../state/invite';
import { reduceState } from '../state/base';
export default class InviteReducer<S extends InviteState> { export default class InviteReducer {
reduce(json: Cage, state: S) { reduce(json: Cage) {
const data = json['invite-update']; const data = json['invite-update'];
if (data) { if (data) {
this.initial(data, state); reduceState<InviteState, InviteUpdate>(useInviteState, data, [
this.create(data, state); initial,
this.delete(data, state); create,
this.invite(data, state); deleteInvite,
this.accepted(data, state); invite,
this.decline(data, state); accepted,
decline,
]);
}
} }
} }
initial(json: InviteUpdate, state: S) { const initial = (json: InviteUpdate, state: InviteState): InviteState => {
const data = _.get(json, 'initial', false); const data = _.get(json, 'initial', false);
if (data) { if (data) {
state.invites = data; state.invites = data;
} }
return state;
} }
create(json: InviteUpdate, state: S) { const create = (json: InviteUpdate, state: InviteState): InviteState => {
const data = _.get(json, 'create', false); const data = _.get(json, 'create', false);
if (data) { if (data) {
state.invites[data] = {}; state.invites[data] = {};
} }
return state;
} }
delete(json: InviteUpdate, state: S) { const deleteInvite = (json: InviteUpdate, state: InviteState): InviteState => {
const data = _.get(json, 'delete', false); const data = _.get(json, 'delete', false);
if (data) { if (data) {
delete state.invites[data]; delete state.invites[data];
} }
return state;
} }
invite(json: InviteUpdate, state: S) { const invite = (json: InviteUpdate, state: InviteState): InviteState => {
const data = _.get(json, 'invite', false); const data = _.get(json, 'invite', false);
if (data) { if (data) {
state.invites[data.term][data.uid] = data.invite; state.invites[data.term][data.uid] = data.invite;
} }
return state;
} }
accepted(json: InviteUpdate, state: S) { const accepted = (json: InviteUpdate, state: InviteState): InviteState => {
const data = _.get(json, 'accepted', false); const data = _.get(json, 'accepted', false);
if (data) { if (data) {
delete state.invites[data.term][data.uid]; delete state.invites[data.term][data.uid];
} }
return state;
} }
decline(json: InviteUpdate, state: S) { const decline = (json: InviteUpdate, state: InviteState): InviteState => {
const data = _.get(json, 'decline', false); const data = _.get(json, 'decline', false);
if (data) { if (data) {
delete state.invites[data.term][data.uid]; delete state.invites[data.term][data.uid];
} }
} return state;
} }

View File

@ -1,61 +1,79 @@
import _ from 'lodash'; import _ from 'lodash';
import { LaunchUpdate } from '~/types/launch-update'; import { LaunchUpdate, WeatherState } from '~/types/launch-update';
import { Cage } from '~/types/cage'; import { Cage } from '~/types/cage';
import { StoreState } from '../../store/type'; import useLaunchState, { LaunchState } from '../state/launch';
import { compose } from 'lodash/fp';
import { reduceState } from '../state/base';
type LaunchState = Pick<StoreState, 'launch' | 'weather' | 'userLocation'>; export default class LaunchReducer {
reduce(json: Cage) {
export default class LaunchReducer<S extends LaunchState> {
reduce(json: Cage, state: S) {
const data = _.get(json, 'launch-update', false); const data = _.get(json, 'launch-update', false);
if (data) { if (data) {
this.initial(data, state); reduceState<LaunchState, LaunchUpdate>(useLaunchState, data, [
this.changeFirstTime(data, state); initial,
this.changeOrder(data, state); changeFirstTime,
this.changeFirstTime(data, state); changeOrder,
this.changeIsShown(data, state); changeFirstTime,
changeIsShown,
]);
} }
const weatherData = _.get(json, 'weather', false); const weatherData: WeatherState = _.get(json, 'weather', false);
if (weatherData) { if (weatherData) {
useLaunchState.getState().set(state => {
state.weather = weatherData; state.weather = weatherData;
});
} }
const locationData = _.get(json, 'location', false); const locationData = _.get(json, 'location', false);
if (locationData) { if (locationData) {
useLaunchState.getState().set(state => {
state.userLocation = locationData; state.userLocation = locationData;
});
}
const baseHash = _.get(json, 'baseHash', false);
if (baseHash) {
useLaunchState.getState().set(state => {
state.baseHash = baseHash;
})
}
} }
} }
initial(json: LaunchUpdate, state: S) { export const initial = (json: LaunchUpdate, state: LaunchState): LaunchState => {
const data = _.get(json, 'initial', false); const data = _.get(json, 'initial', false);
if (data) { if (data) {
state.launch = data; Object.keys(data).forEach(key => {
state[key] = data[key];
});
} }
return state;
} }
changeFirstTime(json: LaunchUpdate, state: S) { export const changeFirstTime = (json: LaunchUpdate, state: LaunchState): LaunchState => {
const data = _.get(json, 'changeFirstTime', false); const data = _.get(json, 'changeFirstTime', false);
if (data) { if (data) {
state.launch.firstTime = data; state.firstTime = data;
} }
return state;
} }
changeOrder(json: LaunchUpdate, state: S) { export const changeOrder = (json: LaunchUpdate, state: LaunchState): LaunchState => {
const data = _.get(json, 'changeOrder', false); const data = _.get(json, 'changeOrder', false);
if (data) { if (data) {
state.launch.tileOrdering = data; state.tileOrdering = data;
} }
return state;
} }
changeIsShown(json: LaunchUpdate, state: S) { export const changeIsShown = (json: LaunchUpdate, state: LaunchState): LaunchState => {
const data = _.get(json, 'changeIsShown', false); const data = _.get(json, 'changeIsShown', false);
if (data) { if (data) {
const tile = state.launch.tiles[data.name]; const tile = state.tiles[data.name];
console.log(tile);
if (tile) { if (tile) {
tile.isShown = data.isShown; tile.isShown = data.isShown;
} }
} }
} return state;
} }

View File

@ -1,33 +0,0 @@
import _ from 'lodash';
import { StoreState } from '~/store/type';
import { Cage } from '~/types/cage';
import { LocalUpdate } from '~/types/local-update';
type LocalState = Pick<StoreState, 'baseHash'>;
export default class LocalReducer<S extends LocalState> {
rehydrate(state: S) {
try {
const json = JSON.parse(localStorage.getItem('localReducer') || '{}');
_.forIn(json, (value, key) => {
state[key] = value;
});
} catch (e) {
console.warn('Failed to rehydrate localStorage state', e);
}
}
dehydrate(state: S) {
}
reduce(json: Cage, state: S) {
const data = json['local'];
if (data) {
this.baseHash(data, state);
}
}
baseHash(obj: LocalUpdate, state: S) {
if ('baseHash' in obj) {
state.baseHash = obj.baseHash;
}
}
}

View File

@ -1,34 +1,36 @@
import _ from 'lodash'; import _ from 'lodash';
import { compose } from 'lodash/fp';
import { StoreState } from '../../store/type';
import { MetadataUpdate } from '@urbit/api/metadata'; import { MetadataUpdate } from '@urbit/api/metadata';
import { Cage } from '~/types/cage'; import { Cage } from '~/types/cage';
import useMetadataState, { MetadataState } from '../state/metadata';
import { reduceState } from '../state/base';
type MetadataState = Pick<StoreState, 'associations'>; export default class MetadataReducer {
reduce(json: Cage) {
export default class MetadataReducer<S extends MetadataState> {
reduce(json: Cage, state: S) {
const data = json['metadata-update']; const data = json['metadata-update'];
if (data) { if (data) {
console.log(data); reduceState<MetadataState, MetadataUpdate>(useMetadataState, data, [
this.associations(data, state); associations,
this.add(data, state); add,
this.update(data, state); update,
this.remove(data, state); remove,
this.groupInitial(data, state); groupInitial,
]);
}
} }
} }
groupInitial(json: MetadataUpdate, state: S) { const groupInitial = (json: MetadataUpdate, state: MetadataState): MetadataState => {
const data = _.get(json, 'initial-group', false); const data = _.get(json, 'initial-group', false);
console.log(data);
if(data) { if(data) {
this.associations(data, state); state = associations(data, state);
} }
return state;
} }
associations(json: MetadataUpdate, state: S) { const associations = (json: MetadataUpdate, state: MetadataState): MetadataState => {
const data = _.get(json, 'associations', false); const data = _.get(json, 'associations', false);
if (data) { if (data) {
const metadata = state.associations; const metadata = state.associations;
@ -47,9 +49,10 @@ export default class MetadataReducer<S extends MetadataState> {
state.associations = metadata; state.associations = metadata;
} }
return state;
} }
add(json: MetadataUpdate, state: S) { const add = (json: MetadataUpdate, state: MetadataState): MetadataState => {
const data = _.get(json, 'add', false); const data = _.get(json, 'add', false);
if (data) { if (data) {
const metadata = state.associations; const metadata = state.associations;
@ -66,9 +69,10 @@ export default class MetadataReducer<S extends MetadataState> {
state.associations = metadata; state.associations = metadata;
} }
return state;
} }
update(json: MetadataUpdate, state: S) { const update = (json: MetadataUpdate, state: MetadataState): MetadataState => {
const data = _.get(json, 'update-metadata', false); const data = _.get(json, 'update-metadata', false);
if (data) { if (data) {
const metadata = state.associations; const metadata = state.associations;
@ -85,9 +89,10 @@ export default class MetadataReducer<S extends MetadataState> {
state.associations = metadata; state.associations = metadata;
} }
return state;
} }
remove(json: MetadataUpdate, state: S) { const remove = (json: MetadataUpdate, state: MetadataState): MetadataState => {
const data = _.get(json, 'remove', false); const data = _.get(json, 'remove', false);
if (data) { if (data) {
const metadata = state.associations; const metadata = state.associations;
@ -99,5 +104,5 @@ export default class MetadataReducer<S extends MetadataState> {
} }
state.associations = metadata; state.associations = metadata;
} }
} return state;
} }

View File

@ -1,83 +1,93 @@
import _ from 'lodash'; import _ from 'lodash';
import { StoreState } from '../../store/type'; import { compose } from 'lodash/fp';
import { Cage } from '~/types/cage'; import { Cage } from '~/types/cage';
import { S3Update } from '~/types/s3-update'; import { S3Update } from '~/types/s3-update';
import { reduceState } from '../state/base';
import useStorageState, { StorageState } from '../state/storage';
type S3State = Pick<StoreState, 's3'>;
export default class S3Reducer<S extends S3State> { export default class S3Reducer {
reduce(json: Cage, state: S) { reduce(json: Cage) {
const data = _.get(json, 's3-update', false); const data = _.get(json, 's3-update', false);
if (data) { if (data) {
this.credentials(data, state); reduceState<StorageState, S3Update>(useStorageState, data, [
this.configuration(data, state); credentials,
this.currentBucket(data, state); configuration,
this.addBucket(data, state); currentBucket,
this.removeBucket(data, state); addBucket,
this.endpoint(data, state); removeBucket,
this.accessKeyId(data, state); endpoint,
this.secretAccessKey(data, state); accessKeyId,
secretAccessKey,
]);
}
} }
} }
credentials(json: S3Update, state: S) { const credentials = (json: S3Update, state: StorageState): StorageState => {
const data = _.get(json, 'credentials', false); const data = _.get(json, 'credentials', false);
if (data) { if (data) {
state.storage.s3.credentials = data; state.s3.credentials = data;
} }
return state;
} }
configuration(json: S3Update, state: S) { const configuration = (json: S3Update, state: StorageState): StorageState => {
const data = _.get(json, 'configuration', false); const data = _.get(json, 'configuration', false);
if (data) { if (data) {
state.storage.s3.configuration = { state.s3.configuration = {
buckets: new Set(data.buckets), buckets: new Set(data.buckets),
currentBucket: data.currentBucket currentBucket: data.currentBucket
}; };
} }
return state;
} }
currentBucket(json: S3Update, state: S) { const currentBucket = (json: S3Update, state: StorageState): StorageState => {
const data = _.get(json, 'setCurrentBucket', false); const data = _.get(json, 'setCurrentBucket', false);
if (data && state.storage.s3) { if (data && state.s3) {
state.storage.s3.configuration.currentBucket = data; state.s3.configuration.currentBucket = data;
} }
return state;
} }
addBucket(json: S3Update, state: S) { const addBucket = (json: S3Update, state: StorageState): StorageState => {
const data = _.get(json, 'addBucket', false); const data = _.get(json, 'addBucket', false);
if (data) { if (data) {
state.storage.s3.configuration.buckets = state.s3.configuration.buckets =
state.storage.s3.configuration.buckets.add(data); state.s3.configuration.buckets.add(data);
} }
return state;
} }
removeBucket(json: S3Update, state: S) { const removeBucket = (json: S3Update, state: StorageState): StorageState => {
const data = _.get(json, 'removeBucket', false); const data = _.get(json, 'removeBucket', false);
if (data) { if (data) {
state.storage.s3.configuration.buckets.delete(data); state.s3.configuration.buckets.delete(data);
} }
return state;
} }
endpoint(json: S3Update, state: S) { const endpoint = (json: S3Update, state: StorageState): StorageState => {
const data = _.get(json, 'setEndpoint', false); const data = _.get(json, 'setEndpoint', false);
if (data && state.storage.s3.credentials) { if (data && state.s3.credentials) {
state.storage.s3.credentials.endpoint = data; state.s3.credentials.endpoint = data;
} }
return state;
} }
accessKeyId(json: S3Update , state: S) { const accessKeyId = (json: S3Update , state: StorageState): StorageState => {
const data = _.get(json, 'setAccessKeyId', false); const data = _.get(json, 'setAccessKeyId', false);
if (data && state.storage.s3.credentials) { if (data && state.s3.credentials) {
state.storage.s3.credentials.accessKeyId = data; state.s3.credentials.accessKeyId = data;
} }
return state;
} }
secretAccessKey(json: S3Update, state: S) { const secretAccessKey = (json: S3Update, state: StorageState): StorageState => {
const data = _.get(json, 'setSecretAccessKey', false); const data = _.get(json, 'setSecretAccessKey', false);
if (data && state.storage.s3.credentials) { if (data && state.s3.credentials) {
state.storage.s3.credentials.secretAccessKey = data; state.s3.credentials.secretAccessKey = data;
} }
return state;
} }
}

View File

@ -1,46 +1,46 @@
import _ from 'lodash'; import _ from 'lodash';
import { SettingsUpdate } from '~/types/settings'; import useSettingsState, { SettingsState } from "~/logic/state/settings";
import useSettingsState, { SettingsStateZus } from "~/logic/state/settings"; import { SettingsUpdate } from '@urbit/api/dist/settings';
import produce from 'immer'; import { reduceState } from '../state/base';
export default class SettingsStateZusettingsReducer{ export default class SettingsReducer {
reduce(json: any) { reduce(json: any) {
const old = useSettingsState.getState();
const newState = produce(old, state => {
let data = json["settings-event"]; let data = json["settings-event"];
if (data) { if (data) {
console.log(data); reduceState<SettingsState, SettingsUpdate>(useSettingsState, data, [
this.putBucket(data, state); this.putBucket,
this.delBucket(data, state); this.delBucket,
this.putEntry(data, state); this.putEntry,
this.delEntry(data, state); this.delEntry,
]);
} }
data = json["settings-data"]; data = json["settings-data"];
if (data) { if (data) {
console.log(data); reduceState<SettingsState, SettingsUpdate>(useSettingsState, data, [
this.getAll(data, state); this.getAll,
this.getBucket(data, state); this.getBucket,
this.getEntry(data, state); this.getEntry,
]);
} }
});
useSettingsState.setState(newState);
} }
putBucket(json: SettingsUpdate, state: SettingsStateZus) { putBucket(json: SettingsUpdate, state: SettingsState): SettingsState {
const data = _.get(json, 'put-bucket', false); const data = _.get(json, 'put-bucket', false);
if (data) { if (data) {
state[data["bucket-key"]] = data.bucket; state[data["bucket-key"]] = data.bucket;
} }
return state;
} }
delBucket(json: SettingsUpdate, state: SettingsStateZus) { delBucket(json: SettingsUpdate, state: SettingsState): SettingsState {
const data = _.get(json, 'del-bucket', false); const data = _.get(json, 'del-bucket', false);
if (data) { if (data) {
delete settings[data['bucket-key']]; delete state[data['bucket-key']];
} }
return state;
} }
putEntry(json: SettingsUpdate, state: SettingsStateZus) { putEntry(json: SettingsUpdate, state: SettingsState): SettingsState {
const data = _.get(json, 'put-entry', false); const data = _.get(json, 'put-entry', false);
if (data) { if (data) {
if (!state[data["bucket-key"]]) { if (!state[data["bucket-key"]]) {
@ -48,36 +48,41 @@ export default class SettingsStateZusettingsReducer{
} }
state[data["bucket-key"]][data["entry-key"]] = data.value; state[data["bucket-key"]][data["entry-key"]] = data.value;
} }
return state;
} }
delEntry(json: SettingsUpdate, state: SettingsStateZus) { delEntry(json: SettingsUpdate, state: SettingsState): SettingsState {
const data = _.get(json, 'del-entry', false); const data = _.get(json, 'del-entry', false);
if (data) { if (data) {
delete state[data["bucket-key"]][data["entry-key"]]; delete state[data["bucket-key"]][data["entry-key"]];
} }
return state;
} }
getAll(json: any, state: SettingsStateZus) { getAll(json: any, state: SettingsState): SettingsState {
const data = _.get(json, 'all'); const data = _.get(json, 'all');
if(data) { if(data) {
_.mergeWith(state, data, (obj, src) => _.isArray(src) ? src : undefined) _.mergeWith(state, data, (obj, src) => _.isArray(src) ? src : undefined)
} }
return state;
} }
getBucket(json: any, state: SettingsStateZus) { getBucket(json: any, state: SettingsState): SettingsState {
const key = _.get(json, 'bucket-key', false); const key = _.get(json, 'bucket-key', false);
const bucket = _.get(json, 'bucket', false); const bucket = _.get(json, 'bucket', false);
if (key && bucket) { if (key && bucket) {
state[key] = bucket; state[key] = bucket;
} }
return state;
} }
getEntry(json: any, state: SettingsStateZus) { getEntry(json: any, state: SettingsState) {
const bucketKey = _.get(json, 'bucket-key', false); const bucketKey = _.get(json, 'bucket-key', false);
const entryKey = _.get(json, 'entry-key', false); const entryKey = _.get(json, 'entry-key', false);
const entry = _.get(json, 'entry', false); const entry = _.get(json, 'entry', false);
if (bucketKey && entryKey && entry) { if (bucketKey && entryKey && entry) {
state[bucketKey][entryKey] = entry; state[bucketKey][entryKey] = entry;
} }
return state;
} }
} }

View File

@ -0,0 +1,64 @@
import produce from "immer";
import { compose } from "lodash/fp";
import create, { State, UseStore } from "zustand";
import { persist } from "zustand/middleware";
export const stateSetter = <StateType>(
fn: (state: StateType) => void,
set
): void => {
// fn = (state: StateType) => {
// // TODO this is a stub for the store debugging
// fn(state);
// }
return set(fn);
// TODO we want to use the below, but it makes everything read-only
return set(produce(fn));
};
export const reduceState = <
StateType extends BaseState<StateType>,
UpdateType
>(
state: UseStore<StateType>,
data: UpdateType,
reducers: ((data: UpdateType, state: StateType) => StateType)[]
): void => {
const oldState = state.getState();
const reducer = compose(reducers.map(reducer => reducer.bind(reducer, data)));
const newState = reducer(oldState);
state.getState().set(state => state = newState);
};
export let stateStorageKeys: string[] = [];
export const stateStorageKey = (stateName: string) => {
stateName = `Landscape${stateName}State`;
stateStorageKeys = [...new Set([...stateStorageKeys, stateName])];
return stateName;
};
(window as any).clearStates = () => {
stateStorageKeys.forEach(key => {
localStorage.removeItem(key);
});
}
export interface BaseState<StateType> extends State {
set: (fn: (state: StateType) => void) => void;
}
export const createState = <StateType extends BaseState<any>>(
name: string,
properties: Omit<StateType, 'set'>,
blacklist: string[] = []
): UseStore<StateType> => create(persist((set, get) => ({
// TODO why does this typing break?
set: fn => stateSetter(fn, set),
...properties
}), {
blacklist,
name: stateStorageKey(name),
version: 1, // TODO version these according to base hash
}));

View File

@ -0,0 +1,31 @@
import { Patp, Rolodex, Scry } from "@urbit/api";
import { BaseState, createState } from "./base";
export interface ContactState extends BaseState<ContactState> {
contacts: Rolodex;
isContactPublic: boolean;
nackedContacts: Set<Patp>;
// fetchIsAllowed: (entity, name, ship, personal) => Promise<boolean>;
};
const useContactState = createState<ContactState>('Contact', {
contacts: {},
nackedContacts: new Set(),
isContactPublic: false,
// fetchIsAllowed: async (
// entity,
// name,
// ship,
// personal
// ): Promise<boolean> => {
// const isPersonal = personal ? 'true' : 'false';
// const api = useApi();
// return api.scry({
// app: 'contact-store',
// path: `/is-allowed/${entity}/${name}/${ship}/${isPersonal}`
// });
// },
}, ['nackedContacts']);
export default useContactState;

View File

@ -0,0 +1,127 @@
import { Graphs, decToUd, numToUd } from "@urbit/api";
import { BaseState, createState } from "./base";
export interface GraphState extends BaseState<GraphState> {
graphs: Graphs;
graphKeys: Set<string>;
pendingIndices: Record<string, any>;
graphTimesentMap: Record<string, any>;
// getKeys: () => Promise<void>;
// getTags: () => Promise<void>;
// getTagQueries: () => Promise<void>;
// getGraph: (ship: string, resource: string) => Promise<void>;
// getNewest: (ship: string, resource: string, count: number, index?: string) => Promise<void>;
// getOlderSiblings: (ship: string, resource: string, count: number, index?: string) => Promise<void>;
// getYoungerSiblings: (ship: string, resource: string, count: number, index?: string) => Promise<void>;
// getGraphSubset: (ship: string, resource: string, start: string, end: string) => Promise<void>;
// getNode: (ship: string, resource: string, index: string) => Promise<void>;
};
const useGraphState = createState<GraphState>('Graph', {
graphs: {},
graphKeys: new Set(),
pendingIndices: {},
graphTimesentMap: {},
// getKeys: async () => {
// const api = useApi();
// const keys = await api.scry({
// app: 'graph-store',
// path: '/keys'
// });
// graphReducer(keys);
// },
// getTags: async () => {
// const api = useApi();
// const tags = await api.scry({
// app: 'graph-store',
// path: '/tags'
// });
// graphReducer(tags);
// },
// getTagQueries: async () => {
// const api = useApi();
// const tagQueries = await api.scry({
// app: 'graph-store',
// path: '/tag-queries'
// });
// graphReducer(tagQueries);
// },
// getGraph: async (ship: string, resource: string) => {
// const api = useApi();
// const graph = await api.scry({
// app: 'graph-store',
// path: `/graph/${ship}/${resource}`
// });
// graphReducer(graph);
// },
// getNewest: async (
// ship: string,
// resource: string,
// count: number,
// index: string = ''
// ) => {
// const api = useApi();
// const data = await api.scry({
// app: 'graph-store',
// path: `/newest/${ship}/${resource}/${count}${index}`
// });
// graphReducer(data);
// },
// getOlderSiblings: async (
// ship: string,
// resource: string,
// count: number,
// index: string = ''
// ) => {
// const api = useApi();
// index = index.split('/').map(decToUd).join('/');
// const data = await api.scry({
// app: 'graph-store',
// path: `/node-siblings/older/${ship}/${resource}/${count}${index}`
// });
// graphReducer(data);
// },
// getYoungerSiblings: async (
// ship: string,
// resource: string,
// count: number,
// index: string = ''
// ) => {
// const api = useApi();
// index = index.split('/').map(decToUd).join('/');
// const data = await api.scry({
// app: 'graph-store',
// path: `/node-siblings/younger/${ship}/${resource}/${count}${index}`
// });
// graphReducer(data);
// },
// getGraphSubset: async (
// ship: string,
// resource: string,
// start: string,
// end: string
// ) => {
// const api = useApi();
// const subset = await api.scry({
// app: 'graph-store',
// path: `/graph-subset/${ship}/${resource}/${end}/${start}`
// });
// graphReducer(subset);
// },
// getNode: async (
// ship: string,
// resource: string,
// index: string
// ) => {
// const api = useApi();
// index = index.split('/').map(numToUd).join('/');
// const node = api.scry({
// app: 'graph-store',
// path: `/node/${ship}/${resource}${index}`
// });
// graphReducer(node);
// },
}, ['graphs', 'graphKeys']);
export default useGraphState;

View File

@ -0,0 +1,15 @@
import { Path, JoinRequests } from "@urbit/api";
import { BaseState, createState } from "./base";
export interface GroupState extends BaseState<GroupState> {
groups: Set<Path>;
pendingJoin: JoinRequests;
};
const useGroupState = createState<GroupState>('Group', {
groups: new Set(),
pendingJoin: {},
}, ['groups']);
export default useGroupState;

View File

@ -0,0 +1,69 @@
import { NotificationGraphConfig, Timebox, Unreads, dateToDa } from "@urbit/api";
import BigIntOrderedMap from "@urbit/api/lib/BigIntOrderedMap";
// import { harkGraphHookReducer, harkGroupHookReducer, harkReducer } from "~/logic/subscription/hark";
import { BaseState, createState } from "./base";
export const HARK_FETCH_MORE_COUNT = 3;
export interface HarkState extends BaseState<HarkState> {
archivedNotifications: BigIntOrderedMap<Timebox>;
doNotDisturb: boolean;
// getMore: () => Promise<boolean>;
// getSubset: (offset: number, count: number, isArchive: boolean) => Promise<void>;
// getTimeSubset: (start?: Date, end?: Date) => Promise<void>;
notifications: BigIntOrderedMap<Timebox>;
notificationsCount: number;
notificationsGraphConfig: NotificationGraphConfig; // TODO unthread this everywhere
notificationsGroupConfig: []; // TODO type this
unreads: Unreads;
};
const useHarkState = createState<HarkState>('Hark', {
archivedNotifications: new BigIntOrderedMap<Timebox>(),
doNotDisturb: false,
// getMore: async (): Promise<boolean> => {
// const state = get();
// const offset = state.notifications.size || 0;
// await state.getSubset(offset, HARK_FETCH_MORE_COUNT, false);
// // TODO make sure that state has mutated at this point.
// return offset === (state.notifications.size || 0);
// },
// getSubset: async (offset, count, isArchive): Promise<void> => {
// const api = useApi();
// const where = isArchive ? 'archive' : 'inbox';
// const result = await api.scry({
// app: 'hark-store',
// path: `/recent/${where}/${offset}/${count}`
// });
// harkReducer(result);
// return;
// },
// getTimeSubset: async (start, end): Promise<void> => {
// const api = useApi();
// const s = start ? dateToDa(start) : '-';
// const e = end ? dateToDa(end) : '-';
// const result = await api.scry({
// app: 'hark-hook',
// path: `/recent/${s}/${e}`
// });
// harkGroupHookReducer(result);
// harkGraphHookReducer(result);
// return;
// },
notifications: new BigIntOrderedMap<Timebox>(),
notificationsCount: 0,
notificationsGraphConfig: {
watchOnSelf: false,
mentions: false,
watching: []
},
notificationsGroupConfig: [],
unreads: {
graph: {},
group: {}
},
}, ['notifications', 'archivedNotifications', 'unreads', 'notificationsCount']);
export default useHarkState;

View File

@ -0,0 +1,12 @@
import { Invites } from '@urbit/api';
import { BaseState, createState } from './base';
export interface InviteState extends BaseState<InviteState> {
invites: Invites;
};
const useInviteState = createState<InviteState>('Invite', {
invites: {},
});
export default useInviteState;

View File

@ -0,0 +1,27 @@
import { Tile, WeatherState } from "~/types/launch-update";
import { BaseState, createState } from "./base";
export interface LaunchState extends BaseState<LaunchState> {
firstTime: boolean;
tileOrdering: string[];
tiles: {
[app: string]: Tile;
},
weather: WeatherState | null,
userLocation: string | null;
baseHash: string | null;
};
const useLaunchState = createState<LaunchState>('Launch', {
firstTime: true,
tileOrdering: [],
tiles: {},
weather: null,
userLocation: null,
baseHash: null
});
export default useLaunchState;

View File

@ -0,0 +1,57 @@
import { MetadataUpdatePreview, Associations } from "@urbit/api";
import { BaseState, createState } from "./base";
export const METADATA_MAX_PREVIEW_WAIT = 150000;
export interface MetadataState extends BaseState<MetadataState> {
associations: Associations;
// preview: (group: string) => Promise<MetadataUpdatePreview>;
};
const useMetadataState = createState<MetadataState>('Metadata', {
associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} },
// preview: async (group): Promise<MetadataUpdatePreview> => {
// return new Promise<MetadataUpdatePreview>((resolve, reject) => {
// const api = useApi();
// let done = false;
// setTimeout(() => {
// if (done) {
// return;
// }
// done = true;
// reject(new Error('offline'));
// }, METADATA_MAX_PREVIEW_WAIT);
// api.subscribe({
// app: 'metadata-pull-hook',
// path: `/preview${group}`,
// // TODO type this message?
// event: (message) => {
// if ('metadata-hook-update' in message) {
// done = true;
// const update = message['metadata-hook-update'].preview as MetadataUpdatePreview;
// resolve(update);
// } else {
// done = true;
// reject(new Error('no-permissions'));
// }
// // TODO how to delete this subscription? Perhaps return the susbcription ID as the second parameter of all the handlers
// },
// err: (error) => {
// console.error(error);
// reject(error);
// },
// quit: () => {
// if (!done) {
// reject(new Error('offline'));
// }
// }
// });
// });
// },
});
export default useMetadataState;

View File

@ -1,12 +1,9 @@
import React, { ReactNode } from "react";
import f from 'lodash/fp'; import f from 'lodash/fp';
import create, { State } from 'zustand'; import { RemoteContentPolicy, LeapCategories, leapCategories } from "~/types/local-update";
import { persist } from 'zustand/middleware'; import { BaseState, createState } from '~/logic/state/base';
import produce from 'immer';
import { BackgroundConfig, RemoteContentPolicy, TutorialProgress, tutorialProgress, LeapCategories, leapCategories } from "~/types/local-update";
export interface SettingsState { export interface SettingsState extends BaseState<SettingsState> {
display: { display: {
backgroundType: 'none' | 'url' | 'color'; backgroundType: 'none' | 'url' | 'color';
background?: string; background?: string;
@ -28,11 +25,8 @@ export interface SettingsState {
seen: boolean; seen: boolean;
joined?: number; joined?: number;
}; };
set: (fn: (state: SettingsState) => void) => void
}; };
export type SettingsStateZus = SettingsState & State;
export const selectSettingsState = export const selectSettingsState =
<K extends keyof SettingsState>(keys: K[]) => f.pick<SettingsState, K>(keys); <K extends keyof SettingsState>(keys: K[]) => f.pick<SettingsState, K>(keys);
@ -40,7 +34,7 @@ export const selectCalmState = (s: SettingsState) => s.calm;
export const selectDisplayState = (s: SettingsState) => s.display; export const selectDisplayState = (s: SettingsState) => s.display;
const useSettingsState = create<SettingsStateZus>((set) => ({ const useSettingsState = createState<SettingsState>('Settings', {
display: { display: {
backgroundType: 'none', backgroundType: 'none',
background: undefined, background: undefined,
@ -66,17 +60,7 @@ const useSettingsState = create<SettingsStateZus>((set) => ({
tutorial: { tutorial: {
seen: false, seen: false,
joined: undefined joined: undefined
},
set: (fn: (state: SettingsState) => void) => set(produce(fn))
}));
function withSettingsState<P, S extends keyof SettingsState>(Component: any, stateMemberKeys?: S[]) {
return React.forwardRef((props: Omit<P, S>, ref) => {
const localState = stateMemberKeys
? useSettingsState(selectSettingsState(stateMemberKeys))
: useSettingsState();
return <Component ref={ref} {...localState} {...props} />
});
} }
});
export { useSettingsState as default, withSettingsState }; export default useSettingsState;

View File

@ -0,0 +1,33 @@
import { BaseState, createState } from "./base";
export interface GcpToken {
accessKey: string;
expiresIn: number;
}
export interface StorageState extends BaseState<StorageState> {
gcp: {
configured?: boolean;
token?: GcpToken;
},
s3: {
configuration: {
buckets: Set<string>;
currentBucket: string;
};
credentials: any | null; // TODO better type
}
};
const useStorageState = createState<StorageState>('Storage', {
gcp: {},
s3: {
configuration: {
buckets: new Set(),
currentBucket: ''
},
credentials: null,
}
}, ['s3']);
export default useStorageState;

View File

@ -5,10 +5,6 @@ export default class BaseStore<S extends object> {
this.state = this.initialState(); this.state = this.initialState();
} }
dehydrate() {}
rehydrate() {}
initialState() { initialState() {
return {} as S; return {} as S;
} }

View File

@ -20,11 +20,11 @@ import GcpReducer from '../reducers/gcp-reducer';
import { OrderedMap } from '../lib/OrderedMap'; import { OrderedMap } from '../lib/OrderedMap';
import { BigIntOrderedMap } from '../lib/BigIntOrderedMap'; import { BigIntOrderedMap } from '../lib/BigIntOrderedMap';
import { GroupViewReducer } from '../reducers/group-view'; import { GroupViewReducer } from '../reducers/group-view';
import { unstable_batchedUpdates } from 'react-dom';
export default class GlobalStore extends BaseStore<StoreState> { export default class GlobalStore extends BaseStore<StoreState> {
inviteReducer = new InviteReducer(); inviteReducer = new InviteReducer();
metadataReducer = new MetadataReducer(); metadataReducer = new MetadataReducer();
localReducer = new LocalReducer();
s3Reducer = new S3Reducer(); s3Reducer = new S3Reducer();
groupReducer = new GroupReducer(); groupReducer = new GroupReducer();
launchReducer = new LaunchReducer(); launchReducer = new LaunchReducer();
@ -44,84 +44,30 @@ export default class GlobalStore extends BaseStore<StoreState> {
console.log(_.pick(this.state, stateKeys)); console.log(_.pick(this.state, stateKeys));
} }
rehydrate() {
this.localReducer.rehydrate(this.state);
}
dehydrate() {
this.localReducer.dehydrate(this.state);
}
initialState(): StoreState { initialState(): StoreState {
return { return {
connection: 'connected', connection: 'connected',
baseHash: null,
invites: {},
associations: {
groups: {},
graph: {}
},
groups: {},
groupKeys: new Set(),
graphs: {},
graphKeys: new Set(),
launch: {
firstTime: false,
tileOrdering: [],
tiles: {}
},
weather: {},
userLocation: null,
storage: {
gcp: {},
s3: {
configuration: {
buckets: new Set(),
currentBucket: ''
},
credentials: null
},
},
isContactPublic: false,
contacts: {},
nackedContacts: new Set(),
notifications: new BigIntOrderedMap<Timebox>(),
archivedNotifications: new BigIntOrderedMap<Timebox>(),
notificationsGroupConfig: [],
notificationsGraphConfig: {
watchOnSelf: false,
mentions: false,
watching: []
},
unreads: {
graph: {},
group: {}
},
notificationsCount: 0,
settings: {},
pendingJoin: {},
graphTimesentMap: {}
}; };
} }
reduce(data: Cage, state: StoreState) { reduce(data: Cage, state: StoreState) {
unstable_batchedUpdates(() => {
// debug shim // debug shim
const tag = Object.keys(data)[0]; const tag = Object.keys(data)[0];
const oldActions = this.pastActions[tag] || []; const oldActions = this.pastActions[tag] || [];
this.pastActions[tag] = [data[tag], ...oldActions.slice(0, 14)]; this.pastActions[tag] = [data[tag], ...oldActions.slice(0, 14)];
this.inviteReducer.reduce(data);
this.inviteReducer.reduce(data, this.state); this.metadataReducer.reduce(data);
this.metadataReducer.reduce(data, this.state); this.s3Reducer.reduce(data);
this.localReducer.reduce(data, this.state); this.groupReducer.reduce(data);
this.s3Reducer.reduce(data, this.state); GroupViewReducer(data);
this.groupReducer.reduce(data, this.state); this.launchReducer.reduce(data);
this.launchReducer.reduce(data, this.state);
this.connReducer.reduce(data, this.state); this.connReducer.reduce(data, this.state);
GraphReducer(data, this.state); GraphReducer(data);
HarkReducer(data, this.state); HarkReducer(data);
ContactReducer(data, this.state); ContactReducer(data);
this.settingsReducer.reduce(data); this.settingsReducer.reduce(data);
this.gcpReducer.reduce(data, this.state); this.gcpReducer.reduce(data);
GroupViewReducer(data, this.state); });
} }
} }

View File

@ -1,52 +1,6 @@
import { Path } from '@urbit/api';
import { Invites } from '@urbit/api/invite';
import { Associations } from '@urbit/api/metadata';
import { Rolodex } from '@urbit/api/contacts';
import { Groups } from '@urbit/api/groups';
import { StorageState } from '~/types/storage-state';
import { LaunchState, WeatherState } from '~/types/launch-update';
import { ConnectionStatus } from '~/types/connection'; import { ConnectionStatus } from '~/types/connection';
import { Graphs } from '@urbit/api/graph';
import {
Notifications,
NotificationGraphConfig,
GroupNotificationsConfig,
Unreads,
JoinRequests,
Patp
} from '@urbit/api';
export interface StoreState { export interface StoreState {
// local state // local state
connection: ConnectionStatus; connection: ConnectionStatus;
baseHash: string | null;
// invite state
invites: Invites;
// metadata state
associations: Associations;
// contact state
contacts: Rolodex;
// groups state
groups: Groups;
groupKeys: Set<Path>;
nackedContacts: Set<Patp>
storage: StorageState;
graphs: Graphs;
graphKeys: Set<string>;
// App specific states
// launch state
launch: LaunchState;
weather: WeatherState | {} | null;
userLocation: string | null;
archivedNotifications: Notifications;
notifications: Notifications;
notificationsGraphConfig: NotificationGraphConfig;
notificationsGroupConfig: GroupNotificationsConfig;
notificationsCount: number,
unreads: Unreads;
doNotDisturb: boolean;
pendingJoin: JoinRequests;
} }

View File

@ -27,12 +27,15 @@ import GlobalSubscription from '~/logic/subscription/global';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { uxToHex } from '~/logic/lib/util'; import { uxToHex } from '~/logic/lib/util';
import { foregroundFromBackground } from '~/logic/lib/sigil'; import { foregroundFromBackground } from '~/logic/lib/sigil';
import withState from '~/logic/lib/withState';
import useLocalState from '~/logic/state/local';
import useContactState from '~/logic/state/contact';
import useGroupState from '~/logic/state/group';
import useSettingsState from '~/logic/state/settings';
import gcpManager from '~/logic/lib/gcpManager'; import gcpManager from '~/logic/lib/gcpManager';
import { withLocalState } from '~/logic/state/local';
import { withSettingsState } from '~/logic/state/settings';
const Root = withSettingsState(styled.div` const Root = withState(styled.div`
font-family: ${p => p.theme.fonts.sans}; font-family: ${p => p.theme.fonts.sans};
height: 100%; height: 100%;
width: 100%; width: 100%;
@ -66,7 +69,9 @@ const Root = withSettingsState(styled.div`
border-radius: 1rem; border-radius: 1rem;
border: 0px solid transparent; border: 0px solid transparent;
} }
`, ['display']); `, [
[useSettingsState, ['display']]
]);
const StatusBarWithRouter = withRouter(StatusBar); const StatusBarWithRouter = withRouter(StatusBar);
class App extends React.Component { class App extends React.Component {
@ -79,7 +84,7 @@ class App extends React.Component {
this.appChannel = new window.channel(); this.appChannel = new window.channel();
this.api = new GlobalApi(this.ship, this.appChannel, this.store); this.api = new GlobalApi(this.ship, this.appChannel, this.store);
gcpManager.configure(this.api, this.store); gcpManager.configure(this.api);
this.subscription = this.subscription =
new GlobalSubscription(this.store, this.api, this.appChannel); new GlobalSubscription(this.store, this.api, this.appChannel);
@ -98,7 +103,6 @@ class App extends React.Component {
}, 500); }, 500);
this.api.local.getBaseHash(); this.api.local.getBaseHash();
this.api.settings.getAll(); this.api.settings.getAll();
this.store.rehydrate();
gcpManager.start(); gcpManager.start();
Mousetrap.bindGlobal(['command+/', 'ctrl+/'], (e) => { Mousetrap.bindGlobal(['command+/', 'ctrl+/'], (e) => {
e.preventDefault(); e.preventDefault();
@ -119,8 +123,8 @@ class App extends React.Component {
faviconString() { faviconString() {
let background = '#ffffff'; let background = '#ffffff';
if (this.state.contacts.hasOwnProperty(`~${window.ship}`)) { if (this.props.contacts.hasOwnProperty(`~${window.ship}`)) {
background = `#${uxToHex(this.state.contacts[`~${window.ship}`].color)}`; background = `#${uxToHex(this.props.contacts[`~${window.ship}`].color)}`;
} }
const foreground = foregroundFromBackground(background); const foreground = foregroundFromBackground(background);
const svg = sigiljs({ const svg = sigiljs({
@ -135,16 +139,12 @@ class App extends React.Component {
render() { render() {
const { state, props } = this; const { state, props } = this;
const associations = state.associations ?
state.associations : { contacts: {} };
const theme = const theme =
((props.dark && props?.display?.theme == "auto") || ((props.dark && props?.display?.theme == "auto") ||
props?.display?.theme == "dark" props?.display?.theme == "dark"
) ? dark : light; ) ? dark : light;
const notificationsCount = state.notificationsCount || 0; const ourContact = this.props.contacts[`~${this.ship}`] || null;
const doNotDisturb = state.doNotDisturb || false;
const ourContact = this.state.contacts[`~${this.ship}`] || null;
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<Helmet> <Helmet>
@ -158,27 +158,17 @@ class App extends React.Component {
<ErrorBoundary> <ErrorBoundary>
<StatusBarWithRouter <StatusBarWithRouter
props={this.props} props={this.props}
associations={associations}
invites={this.state.invites}
ourContact={ourContact} ourContact={ourContact}
api={this.api} api={this.api}
connection={this.state.connection} connection={this.state.connection}
subscription={this.subscription} subscription={this.subscription}
ship={this.ship} ship={this.ship}
doNotDisturb={doNotDisturb}
notificationsCount={notificationsCount}
/> />
</ErrorBoundary> </ErrorBoundary>
<ErrorBoundary> <ErrorBoundary>
<Omnibox <Omnibox
associations={state.associations} associations={state.associations}
apps={state.launch}
tiles={state.launch.tiles}
api={this.api} api={this.api}
contacts={state.contacts}
notifications={state.notificationsCount}
invites={state.invites}
groups={state.groups}
show={this.props.omniboxShown} show={this.props.omniboxShown}
toggle={this.props.toggleOmnibox} toggle={this.props.toggleOmnibox}
/> />
@ -188,7 +178,7 @@ class App extends React.Component {
ship={this.ship} ship={this.ship}
api={this.api} api={this.api}
subscription={this.subscription} subscription={this.subscription}
{...state} connection={this.state.connection}
/> />
</ErrorBoundary> </ErrorBoundary>
</Router> </Router>
@ -199,4 +189,9 @@ class App extends React.Component {
} }
} }
export default withSettingsState(withLocalState(process.env.NODE_ENV === 'production' ? App : hot(App)), ['display']); export default withState(process.env.NODE_ENV === 'production' ? App : hot(App), [
[useGroupState],
[useContactState],
[useSettingsState, ['display']],
[useLocalState]
]);

View File

@ -16,6 +16,10 @@ import { Loading } from '~/views/components/Loading';
import { isWriter, resourceFromPath } from '~/logic/lib/group'; import { isWriter, resourceFromPath } from '~/logic/lib/group';
import './css/custom.css'; import './css/custom.css';
import useContactState from '~/logic/state/contact';
import useGraphState from '~/logic/state/graph';
import useGroupState from '~/logic/state/group';
import useHarkState from '~/logic/state/hark';
type ChatResourceProps = StoreState & { type ChatResourceProps = StoreState & {
association: Association; association: Association;
@ -26,12 +30,15 @@ type ChatResourceProps = StoreState & {
export function ChatResource(props: ChatResourceProps) { export function ChatResource(props: ChatResourceProps) {
const station = props.association.resource; const station = props.association.resource;
const groupPath = props.association.group; const groupPath = props.association.group;
const group = props.groups[groupPath]; const groups = useGroupState(state => state.groups);
const contacts = props.contacts; const group = groups[groupPath];
const contacts = useContactState(state => state.contacts);
const graphs = useGraphState(state => state.graphs);
const graphPath = station.slice(7); const graphPath = station.slice(7);
const graph = props.graphs[station.slice(7)]; const graph = graphs[graphPath];
const isChatMissing = !props.graphKeys.has(station.slice(7)); const unreads = useHarkState(state => state.unreads);
const unreadCount = props.unreads.graph?.[station]?.['/']?.unreads || 0; const unreadCount = unreads.graph?.[station]?.['/']?.unreads || 0;
const graphTimesentMap = useGraphState(state => state.graphTimesentMap);
const [,, owner, name] = station.split('/'); const [,, owner, name] = station.split('/');
const ourContact = contacts?.[`~${window.ship}`]; const ourContact = contacts?.[`~${window.ship}`];
const chatInput = useRef<ChatInput>(); const chatInput = useRef<ChatInput>();
@ -132,9 +139,6 @@ export function ChatResource(props: ChatResourceProps) {
return <Loading />; return <Loading />;
} }
const modifiedContacts = { ...contacts };
delete modifiedContacts[`~${window.ship}`];
return ( return (
<Col {...bind} height="100%" overflow="hidden" position="relative"> <Col {...bind} height="100%" overflow="hidden" position="relative">
<ShareProfile <ShareProfile
@ -152,15 +156,11 @@ export function ChatResource(props: ChatResourceProps) {
key={station} key={station}
history={props.history} history={props.history}
graph={graph} graph={graph}
graphSize={graph.size}
unreadCount={unreadCount} unreadCount={unreadCount}
contacts={ showOurContact={ !showBanner && hasLoadedAllowed }
(!showBanner && hasLoadedAllowed) ?
contacts : modifiedContacts
}
association={props.association} association={props.association}
associations={props.associations} pendingSize={Object.keys(graphTimesentMap[graphPath] || {}).length}
groups={props.groups}
pendingSize={Object.keys(props.graphTimesentMap[graphPath] || {}).length}
group={group} group={group}
ship={owner} ship={owner}
station={station} station={station}
@ -176,11 +176,7 @@ export function ChatResource(props: ChatResourceProps) {
(!showBanner && hasLoadedAllowed) ? ourContact : null (!showBanner && hasLoadedAllowed) ? ourContact : null
} }
envelopes={[]} envelopes={[]}
contacts={
(!showBanner && hasLoadedAllowed) ? contacts : modifiedContacts
}
onUnmount={appendUnsent} onUnmount={appendUnsent}
storage={props.storage}
placeholder="Message..." placeholder="Message..."
message={unsent[station] || ''} message={unsent[station] || ''}
deleteMessage={clearUnsent} deleteMessage={clearUnsent}

View File

@ -19,14 +19,12 @@ type ChatInputProps = IuseStorage & {
station: unknown; station: unknown;
ourContact: unknown; ourContact: unknown;
envelopes: Envelope[]; envelopes: Envelope[];
contacts: Contacts;
onUnmount(msg: string): void; onUnmount(msg: string): void;
storage: StorageState;
placeholder: string; placeholder: string;
message: string; message: string;
deleteMessage(): void; deleteMessage(): void;
hideAvatars: boolean; hideAvatars: boolean;
} };
interface ChatInputState { interface ChatInputState {
inCodeMode: boolean; inCodeMode: boolean;
@ -64,18 +62,21 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
const { props, state } = this; const { props, state } = this;
const [, , ship, name] = props.station.split('/'); const [, , ship, name] = props.station.split('/');
if (state.inCodeMode) { if (state.inCodeMode) {
this.setState({ this.setState(
{
inCodeMode: false inCodeMode: false
}, async () => { },
async () => {
const output = await props.api.graph.eval(text); const output = await props.api.graph.eval(text);
const contents: Content[] = [{ code: { output, expression: text } }]; const contents: Content[] = [{ code: { output, expression: text } }];
const post = createPost(contents); const post = createPost(contents);
props.api.graph.addPost(ship, name, post); props.api.graph.addPost(ship, name, post);
}); }
);
return; return;
} }
const post = createPost(tokenizeMessage((text))); const post = createPost(tokenizeMessage(text));
props.deleteMessage(); props.deleteMessage();
@ -112,7 +113,8 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
return; return;
} }
Array.from(files).forEach((file) => { Array.from(files).forEach((file) => {
this.props.uploadDefault(file) this.props
.uploadDefault(file)
.then(this.uploadSuccess) .then(this.uploadSuccess)
.catch(this.uploadError); .catch(this.uploadError);
}); });
@ -121,32 +123,40 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
render() { render() {
const { props, state } = this; const { props, state } = this;
const color = props.ourContact const color = props.ourContact ? uxToHex(props.ourContact.color) : '000000';
? uxToHex(props.ourContact.color) : '000000';
const sigilClass = props.ourContact const sigilClass = props.ourContact ? '' : 'mix-blend-diff';
? '' : 'mix-blend-diff';
const avatar = ( const avatar =
props.ourContact && props.ourContact && props.ourContact?.avatar && !props.hideAvatars ? (
((props.ourContact?.avatar) && !props.hideAvatars) <BaseImage
)
? <BaseImage
src={props.ourContact.avatar} src={props.ourContact.avatar}
height={16} height={24}
width={16} width={24}
style={{ objectFit: 'cover' }} style={{ objectFit: 'cover' }}
borderRadius={1} borderRadius={1}
display='inline-block' display='inline-block'
/> />
: <Sigil ) : (
<Box
width={24}
height={24}
display='flex'
justifyContent='center'
alignItems='center'
backgroundColor={`#${color}`}
borderRadius={1}
>
<Sigil
ship={window.ship} ship={window.ship}
size={16} size={16}
color={`#${color}`} color={`#${color}`}
classes={sigilClass} classes={sigilClass}
icon icon
padding={2} padding={2}
/>; />
</Box>
);
return ( return (
<Row <Row
@ -160,7 +170,7 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
className='cf' className='cf'
zIndex={0} zIndex={0}
> >
<Row p='2' alignItems='center'> <Row p='12px 4px 12px 12px' alignItems='center'>
{avatar} {avatar}
</Row> </Row>
<ChatEditor <ChatEditor
@ -172,31 +182,23 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
onPaste={this.onPaste.bind(this)} onPaste={this.onPaste.bind(this)}
placeholder='Message...' placeholder='Message...'
/> />
<Box <Box mx={2} flexShrink={0} height='16px' width='16px' flexBasis='16px'>
mx={2} {this.props.canUpload ? (
flexShrink={0} this.props.uploading ? (
height='16px' <LoadingSpinner />
width='16px' ) : (
flexBasis='16px' <Icon
> icon='Links'
{this.props.canUpload width='16'
? this.props.uploading height='16'
? <LoadingSpinner /> onClick={() =>
: <Icon icon='Links' this.props.promptUpload().then(this.uploadSuccess)
width="16"
height="16"
onClick={() => this.props.promptUpload().then(this.uploadSuccess)}
/>
: null
} }
/>
)
) : null}
</Box> </Box>
<Box <Box mr={2} flexShrink={0} height='16px' width='16px' flexBasis='16px'>
mr={2}
flexShrink={0}
height='16px'
width='16px'
flexBasis='16px'
>
<Icon <Icon
icon='Dojo' icon='Dojo'
onClick={this.toggleCode} onClick={this.toggleCode}
@ -208,4 +210,6 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
} }
} }
export default withLocalState(withStorage(ChatInput, { accept: 'image/*' }), ['hideAvatars']); export default withLocalState(withStorage(ChatInput, { accept: 'image/*' }), [
'hideAvatars'
]);

View File

@ -10,7 +10,7 @@ import React, {
import moment from 'moment'; import moment from 'moment';
import _ from 'lodash'; import _ from 'lodash';
import VisibilitySensor from 'react-visibility-sensor'; import VisibilitySensor from 'react-visibility-sensor';
import { Box, Row, Text, Rule, BaseImage } from '@tlon/indigo-react'; import { Box, Row, Text, Rule, BaseImage, Icon, Col } from '@tlon/indigo-react';
import { Sigil } from '~/logic/lib/sigil'; import { Sigil } from '~/logic/lib/sigil';
import OverlaySigil from '~/views/components/OverlaySigil'; import OverlaySigil from '~/views/components/OverlaySigil';
import { import {
@ -33,10 +33,12 @@ import TextContent from './content/text';
import CodeContent from './content/code'; import CodeContent from './content/code';
import RemoteContent from '~/views/components/RemoteContent'; import RemoteContent from '~/views/components/RemoteContent';
import { Mention } from '~/views/components/MentionText'; import { Mention } from '~/views/components/MentionText';
import { Dropdown } from '~/views/components/Dropdown';
import styled from 'styled-components'; import styled from 'styled-components';
import useLocalState from '~/logic/state/local'; import useLocalState from '~/logic/state/local';
import useSettingsState, {selectCalmState} from "~/logic/state/settings"; import useSettingsState, { selectCalmState } from '~/logic/state/settings';
import Timestamp from '~/views/components/Timestamp'; import Timestamp from '~/views/components/Timestamp';
import useContactState from '~/logic/state/contact';
import { useIdlingState } from '~/logic/lib/idling'; import { useIdlingState } from '~/logic/lib/idling';
export const DATESTAMP_FORMAT = '[~]YYYY.M.D'; export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
@ -56,14 +58,22 @@ export const DayBreak = ({ when, shimTop = false }: DayBreakProps) => (
mt={shimTop ? '-8px' : '0'} mt={shimTop ? '-8px' : '0'}
> >
<Rule borderColor='lightGray' /> <Rule borderColor='lightGray' />
<Text gray flexShrink='0' fontSize={0} px={2}> <Text
gray
flexShrink='0'
whiteSpace='nowrap'
textAlign='center'
fontSize={0}
px={2}
>
{moment(when).calendar(null, { sameElse: DATESTAMP_FORMAT })} {moment(when).calendar(null, { sameElse: DATESTAMP_FORMAT })}
</Text> </Text>
<Rule borderColor='lightGray' /> <Rule borderColor='lightGray' />
</Row> </Row>
); );
export const UnreadMarker = React.forwardRef(({ dayBreak, when, api, association }, ref) => { export const UnreadMarker = React.forwardRef(
({ dayBreak, when, api, association }, ref) => {
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const idling = useIdlingState(); const idling = useIdlingState();
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
@ -81,7 +91,7 @@ export const UnreadMarker = React.forwardRef(({ dayBreak, when, api, association
position='absolute' position='absolute'
ref={ref} ref={ref}
px={2} px={2}
mt={2} mt={0}
height={5} height={5}
justifyContent='center' justifyContent='center'
alignItems='center' alignItems='center'
@ -89,13 +99,138 @@ export const UnreadMarker = React.forwardRef(({ dayBreak, when, api, association
> >
<Rule borderColor='lightBlue' /> <Rule borderColor='lightBlue' />
<VisibilitySensor onChange={setVisible}> <VisibilitySensor onChange={setVisible}>
<Text color='blue' fontSize={0} flexShrink='0' px={2}> <Text
color='blue'
fontSize={0}
flexShrink='0'
whiteSpace='nowrap'
textAlign='center'
px={2}
>
New messages below New messages below
</Text> </Text>
</VisibilitySensor> </VisibilitySensor>
<Rule borderColor='lightBlue' /> <Rule borderColor='lightBlue' />
</Row> </Row>
)}); );
}
);
const MessageActionItem = (props) => {
return (
<Row
color='black'
cursor='pointer'
fontSize={1}
fontWeight='500'
px={3}
py={2}
onClick={props.onClick}
>
<Text fontWeight='500' color={props.color}>
{props.children}
</Text>
</Row>
);
};
const MessageActions = ({ api, history, msg, group }) => {
const isAdmin = () => group.tags.role.admin.has(window.ship);
const isOwn = () => msg.author === window.ship;
return (
<Box
borderRadius={1}
background='white'
border='1px solid'
borderColor='lightGray'
position='absolute'
top='-12px'
right={2}
>
<Row>
{isOwn() ? (
<Box
padding={1}
size={'24px'}
cursor='pointer'
onClick={(e) => console.log(e)}
>
<Icon icon='NullIcon' size={3} />
</Box>
) : null}
<Box
padding={1}
size={'24px'}
cursor='pointer'
onClick={(e) => console.log(e)}
>
<Icon icon='Chat' size={3} />
</Box>
<Dropdown
dropWidth='250px'
width='auto'
alignY='top'
alignX='right'
flexShrink={'0'}
offsetY={8}
offsetX={-24}
options={
<Col
py={2}
backgroundColor='white'
color='washedGray'
border={1}
borderRadius={2}
borderColor='lightGray'
boxShadow='0px 0px 0px 3px'
>
{isOwn() ? (
<MessageActionItem onClick={(e) => console.log(e)}>
Edit Message
</MessageActionItem>
) : null}
<MessageActionItem onClick={(e) => console.log(e)}>
Reply
</MessageActionItem>
<MessageActionItem onClick={(e) => console.log(e)}>
Copy Message Link
</MessageActionItem>
{isAdmin() || isOwn() ? (
<MessageActionItem onClick={(e) => console.log(e)} color='red'>
Delete Message
</MessageActionItem>
) : null}
<MessageActionItem onClick={(e) => console.log(e)}>
View Signature
</MessageActionItem>
</Col>
}
>
<Box padding={1} size={'24px'} cursor='pointer'>
<Icon icon='Menu' size={3} />
</Box>
</Dropdown>
</Row>
</Box>
);
};
const MessageWrapper = (props) => {
const { hovering, bind } = useHovering();
return (
<Box
py='1'
backgroundColor={
hovering && !props.hideHover ? 'washedGray' : 'transparent'
}
position='relative'
{...bind}
>
{props.children}
{/* {hovering ? <MessageActions {...props} /> : null} */}
</Box>
);
};
interface ChatMessageProps { interface ChatMessageProps {
msg: Post; msg: Post;
@ -104,7 +239,6 @@ interface ChatMessageProps {
isLastRead: boolean; isLastRead: boolean;
group: Group; group: Group;
association: Association; association: Association;
contacts: Contacts;
className?: string; className?: string;
isPending: boolean; isPending: boolean;
style?: unknown; style?: unknown;
@ -115,6 +249,7 @@ interface ChatMessageProps {
api: GlobalApi; api: GlobalApi;
highlighted?: boolean; highlighted?: boolean;
renderSigil?: boolean; renderSigil?: boolean;
hideHover?: boolean;
innerRef: (el: HTMLDivElement | null) => void; innerRef: (el: HTMLDivElement | null) => void;
} }
@ -126,8 +261,7 @@ class ChatMessage extends Component<ChatMessageProps> {
this.divRef = React.createRef(); this.divRef = React.createRef();
} }
componentDidMount() { componentDidMount() {}
}
render() { render() {
const { const {
@ -137,7 +271,6 @@ class ChatMessage extends Component<ChatMessageProps> {
isLastRead, isLastRead,
group, group,
association, association,
contacts,
className = '', className = '',
isPending, isPending,
style, style,
@ -147,9 +280,9 @@ class ChatMessage extends Component<ChatMessageProps> {
history, history,
api, api,
highlighted, highlighted,
showOurContact,
fontSize, fontSize,
groups, hideHover
associations
} = this.props; } = this.props;
let { renderSigil } = this.props; let { renderSigil } = this.props;
@ -173,23 +306,21 @@ class ChatMessage extends Component<ChatMessageProps> {
.unix(msg['time-sent'] / 1000) .unix(msg['time-sent'] / 1000)
.format(renderSigil ? 'h:mm A' : 'h:mm'); .format(renderSigil ? 'h:mm A' : 'h:mm');
const messageProps = { const messageProps = {
msg, msg,
timestamp, timestamp,
contacts,
association, association,
group, group,
style, style,
containerClass, containerClass,
isPending, isPending,
showOurContact,
history, history,
api, api,
scrollWindow, scrollWindow,
highlighted, highlighted,
fontSize, fontSize,
associations, hideHover
groups,
}; };
const unreadContainerStyle = { const unreadContainerStyle = {
@ -200,7 +331,7 @@ class ChatMessage extends Component<ChatMessageProps> {
<Box <Box
ref={this.props.innerRef} ref={this.props.innerRef}
pt={renderSigil ? 2 : 0} pt={renderSigil ? 2 : 0}
pb={isLastMessage ? 4 : 2} pb={isLastMessage ? '20px' : 0}
className={containerClass} className={containerClass}
backgroundColor={highlighted ? 'blue' : 'white'} backgroundColor={highlighted ? 'blue' : 'white'}
style={style} style={style}
@ -209,12 +340,14 @@ class ChatMessage extends Component<ChatMessageProps> {
<DayBreak when={msg['time-sent']} shimTop={renderSigil} /> <DayBreak when={msg['time-sent']} shimTop={renderSigil} />
) : null} ) : null}
{renderSigil ? ( {renderSigil ? (
<> <MessageWrapper {...messageProps}>
<MessageAuthor pb={'2px'} {...messageProps} /> <MessageAuthor pb={1} {...messageProps} />
<Message pl={5} pr={4} {...messageProps} /> <Message pl={'44px'} pr={4} {...messageProps} />
</> </MessageWrapper>
) : ( ) : (
<Message pl={5} pr={4} timestampHover {...messageProps} /> <MessageWrapper {...messageProps}>
<Message pl={'44px'} pr={4} timestampHover {...messageProps} />
</MessageWrapper>
)} )}
<Box style={unreadContainerStyle}> <Box style={unreadContainerStyle}>
{isLastRead ? ( {isLastRead ? (
@ -232,30 +365,36 @@ class ChatMessage extends Component<ChatMessageProps> {
} }
} }
export default React.forwardRef((props, ref) => <ChatMessage {...props} innerRef={ref} />); export default React.forwardRef((props, ref) => (
<ChatMessage {...props} innerRef={ref} />
));
export const MessageAuthor = ({ export const MessageAuthor = ({
timestamp, timestamp,
contacts,
msg, msg,
group, group,
api, api,
associations,
groups,
history, history,
scrollWindow, scrollWindow,
showOurContact,
...rest ...rest
}) => { }) => {
const osDark = useLocalState((state) => state.dark); const osDark = useLocalState((state) => state.dark);
const theme = useSettingsState(s => s.display.theme); const theme = useSettingsState((s) => s.display.theme);
const dark = theme === 'dark' || (theme === 'auto' && osDark) const dark = theme === 'dark' || (theme === 'auto' && osDark);
const contacts = useContactState((state) => state.contacts);
const datestamp = moment const datestamp = moment
.unix(msg['time-sent'] / 1000) .unix(msg['time-sent'] / 1000)
.format(DATESTAMP_FORMAT); .format(DATESTAMP_FORMAT);
const contact = const contact =
`~${msg.author}` in contacts ? contacts[`~${msg.author}`] : false; ((msg.author === window.ship && showOurContact) ||
msg.author !== window.ship) &&
`~${msg.author}` in contacts
? contacts[`~${msg.author}`]
: false;
const showNickname = useShowNickname(contact); const showNickname = useShowNickname(contact);
const { hideAvatars } = useSettingsState(selectCalmState); const { hideAvatars } = useSettingsState(selectCalmState);
const shipName = showNickname ? contact.nickname : cite(msg.author); const shipName = showNickname ? contact.nickname : cite(msg.author);
@ -297,31 +436,44 @@ export const MessageAuthor = ({
contact?.avatar && !hideAvatars ? ( contact?.avatar && !hideAvatars ? (
<BaseImage <BaseImage
display='inline-block' display='inline-block'
referrerPolicy='no-referrer'
style={{ objectFit: 'cover' }} style={{ objectFit: 'cover' }}
src={contact.avatar} src={contact.avatar}
height={16} height={24}
width={16} width={24}
borderRadius={1} borderRadius={1}
/> />
) : ( ) : (
<Box
width={24}
height={24}
display='flex'
justifyContent='center'
alignItems='center'
backgroundColor={color}
borderRadius={1}
>
<Sigil <Sigil
ship={msg.author} ship={msg.author}
size={16} size={12}
display='block'
color={color} color={color}
classes={sigilClass} classes={sigilClass}
icon icon
padding={2} padding={0}
/> />
</Box>
); );
return ( return (
<Box display='flex' alignItems='center' {...rest}> <Box display='flex' alignItems='flex-start' {...rest}>
<Box <Box
onClick={() => { onClick={() => {
setShowOverlay(true); setShowOverlay(true);
}} }}
height={16} height={24}
pr={2} pr={2}
pl={2} mt={'1px'}
pl={'12px'}
cursor='pointer' cursor='pointer'
position='relative' position='relative'
> >
@ -348,12 +500,12 @@ export const MessageAuthor = ({
pt={1} pt={1}
pb={1} pb={1}
display='flex' display='flex'
alignItems='center' alignItems='baseline'
> >
<Text <Text
fontSize={0} fontSize={1}
mr={2} mr={2}
flexShrink={0} flexShrink={1}
mono={nameMono} mono={nameMono}
fontWeight={nameMono ? '400' : '500'} fontWeight={nameMono ? '400' : '500'}
cursor='pointer' cursor='pointer'
@ -385,23 +537,23 @@ export const MessageAuthor = ({
export const Message = ({ export const Message = ({
timestamp, timestamp,
contacts,
msg, msg,
group, group,
api, api,
associations,
groups,
scrollWindow, scrollWindow,
timestampHover, timestampHover,
...rest ...rest
}) => { }) => {
const { hovering, bind } = useHovering(); const { hovering, bind } = useHovering();
const contacts = useContactState((state) => state.contacts);
return ( return (
<Box position='relative' {...rest}> <Box position='relative' {...rest}>
{timestampHover ? ( {timestampHover ? (
<Text <Text
display={hovering ? 'block' : 'none'} display={hovering ? 'block' : 'none'}
position='absolute' position='absolute'
width='36px'
textAlign='right'
left='0' left='0'
top='3px' top='3px'
fontSize={0} fontSize={0}
@ -418,8 +570,7 @@ export const Message = ({
case 'text': case 'text':
return ( return (
<TextContent <TextContent
associations={associations} key={i}
groups={groups}
api={api} api={api}
fontSize={1} fontSize={1}
lineHeight={'20px'} lineHeight={'20px'}
@ -427,10 +578,11 @@ export const Message = ({
/> />
); );
case 'code': case 'code':
return <CodeContent content={content} />; return <CodeContent key={i} content={content} />;
case 'url': case 'url':
return ( return (
<Box <Box
key={i}
flexShrink={0} flexShrink={0}
fontSize={1} fontSize={1}
lineHeight='20px' lineHeight='20px'
@ -464,9 +616,10 @@ export const Message = ({
</Box> </Box>
); );
case 'mention': case 'mention':
const first = (i) => (i === 0); const first = (i) => i === 0;
return ( return (
<Mention <Mention
key={i}
first={first(i)} first={first(i)}
group={group} group={group}
scrollWindow={scrollWindow} scrollWindow={scrollWindow}

View File

@ -20,6 +20,10 @@ import VirtualScroller from '~/views/components/VirtualScroller';
import ChatMessage, { MessagePlaceholder } from './ChatMessage'; import ChatMessage, { MessagePlaceholder } from './ChatMessage';
import { UnreadNotice } from './unread-notice'; import { UnreadNotice } from './unread-notice';
import withState from '~/logic/lib/withState';
import useGroupState from '~/logic/state/group';
import useMetadataState from '~/logic/state/metadata';
import useGraphState from '~/logic/state/graph';
const INITIAL_LOAD = 20; const INITIAL_LOAD = 20;
const DEFAULT_BACKLOG_SIZE = 100; const DEFAULT_BACKLOG_SIZE = 100;
@ -32,15 +36,13 @@ type ChatWindowProps = RouteComponentProps<{
}> & { }> & {
unreadCount: number; unreadCount: number;
graph: Graph; graph: Graph;
contacts: Contacts; graphSize: number;
association: Association; association: Association;
group: Group; group: Group;
ship: Patp; ship: Patp;
station: any; station: any;
api: GlobalApi; api: GlobalApi;
scrollTo?: number; scrollTo?: number;
associations: Associations;
groups: Groups;
}; };
interface ChatWindowState { interface ChatWindowState {
@ -52,16 +54,14 @@ interface ChatWindowState {
const virtScrollerStyle = { height: '100%' }; const virtScrollerStyle = { height: '100%' };
export default class ChatWindow extends Component< class ChatWindow extends Component<
ChatWindowProps, ChatWindowProps,
ChatWindowState ChatWindowState
> { > {
private virtualList: VirtualScroller | null; private virtualList: VirtualScroller | null;
private unreadMarkerRef: React.RefObject<HTMLDivElement>; private unreadMarkerRef: React.RefObject<HTMLDivElement>;
private prevSize = 0; private prevSize = 0;
private loadedNewest = false; private unreadSet = false;
private loadedOldest = false;
private fetchPending = false;
INITIALIZATION_MAX_TIME = 100; INITIALIZATION_MAX_TIME = 100;
@ -99,6 +99,10 @@ export default class ChatWindow extends Component<
calculateUnreadIndex() { calculateUnreadIndex() {
const { graph, unreadCount } = this.props; const { graph, unreadCount } = this.props;
const { state } = this;
if(state.unreadIndex.neq(bigInt.zero)) {
return;
}
const unreadIndex = graph.keys()[unreadCount]; const unreadIndex = graph.keys()[unreadCount];
if (!unreadIndex || unreadCount === 0) { if (!unreadIndex || unreadCount === 0) {
this.setState({ this.setState({
@ -111,6 +115,13 @@ export default class ChatWindow extends Component<
}); });
} }
dismissedInitialUnread() {
const { unreadCount, graph } = this.props;
return this.state.unreadIndex.neq(bigInt.zero) &&
this.state.unreadIndex.neq(graph.keys()?.[unreadCount]?.[0] ?? bigInt.zero);
}
handleWindowBlur() { handleWindowBlur() {
this.setState({ idle: true }); this.setState({ idle: true });
} }
@ -123,10 +134,22 @@ export default class ChatWindow extends Component<
} }
componentDidUpdate(prevProps: ChatWindowProps, prevState) { componentDidUpdate(prevProps: ChatWindowProps, prevState) {
const { history, graph, unreadCount, station } = this.props; const { history, graph, unreadCount, graphSize, station } = this.props;
if(unreadCount === 0 && prevProps.unreadCount !== unreadCount) {
this.unreadSet = true;
}
if(this.prevSize !== graphSize) {
this.prevSize = graphSize;
if(this.state.unreadIndex.eq(bigInt.zero)) {
this.calculateUnreadIndex();
}
if(this.unreadSet &&
this.dismissedInitialUnread() &&
this.virtualList?.startOffset() < 5) {
this.dismissUnread();
}
if (graph.size !== prevProps.graph.size && this.fetchPending) {
this.fetchPending = false;
} }
if (unreadCount > prevProps.unreadCount) { if (unreadCount > prevProps.unreadCount) {
@ -146,6 +169,12 @@ export default class ChatWindow extends Component<
} }
} }
onBottomLoaded = () => {
if(this.state.unreadIndex.eq(bigInt.zero)) {
this.calculateUnreadIndex();
}
}
scrollToUnread() { scrollToUnread() {
const { unreadIndex } = this.state; const { unreadIndex } = this.state;
if (unreadIndex.eq(bigInt.zero)) { if (unreadIndex.eq(bigInt.zero)) {
@ -170,30 +199,28 @@ export default class ChatWindow extends Component<
fetchMessages = async (newer: boolean): Promise<boolean> => { fetchMessages = async (newer: boolean): Promise<boolean> => {
const { api, station, graph } = this.props; const { api, station, graph } = this.props;
if(this.fetchPending) { const pageSize = 100;
return false;
}
this.fetchPending = true;
const [, , ship, name] = station.split('/'); const [, , ship, name] = station.split('/');
const currSize = graph.size; const expectedSize = graph.size + pageSize;
if (newer) { if (newer) {
const [index] = graph.peekLargest()!; const [index] = graph.peekLargest()!;
await api.graph.getYoungerSiblings( await api.graph.getYoungerSiblings(
ship, ship,
name, name,
100, pageSize,
`/${index.toString()}` `/${index.toString()}`
); );
return expectedSize !== graph.size;
} else { } else {
const [index] = graph.peekSmallest()!; const [index] = graph.peekSmallest()!;
await api.graph.getOlderSiblings(ship, name, 100, `/${index.toString()}`); await api.graph.getOlderSiblings(ship, name, pageSize, `/${index.toString()}`);
const done = expectedSize !== graph.size;
if(done) {
this.calculateUnreadIndex(); this.calculateUnreadIndex();
} }
this.fetchPending = false; return done;
return currSize === graph.size; }
} }
onScroll = ({ scrollTop, scrollHeight, windowHeight }) => { onScroll = ({ scrollTop, scrollHeight, windowHeight }) => {
@ -208,7 +235,7 @@ export default class ChatWindow extends Component<
api, api,
association, association,
group, group,
contacts, showOurContact,
graph, graph,
history, history,
groups, groups,
@ -218,13 +245,14 @@ export default class ChatWindow extends Component<
const messageProps = { const messageProps = {
association, association,
group, group,
contacts, showOurContact,
unreadMarkerRef, unreadMarkerRef,
history, history,
api, api,
groups, groups,
associations associations
}; };
const msg = graph.get(index)?.post; const msg = graph.get(index)?.post;
if (!msg) return null; if (!msg) return null;
if (!this.state.initialized) { if (!this.state.initialized) {
@ -255,6 +283,7 @@ export default class ChatWindow extends Component<
msg, msg,
...messageProps ...messageProps
}; };
return ( return (
<ChatMessage <ChatMessage
key={index.toString()} key={index.toString()}
@ -270,15 +299,13 @@ export default class ChatWindow extends Component<
const { const {
unreadCount, unreadCount,
api, api,
ship,
station,
association, association,
group, group,
contacts,
graph, graph,
history, history,
groups, groups,
associations, associations,
showOurContact,
pendingSize pendingSize
} = this.props; } = this.props;
@ -286,19 +313,21 @@ export default class ChatWindow extends Component<
const messageProps = { const messageProps = {
association, association,
group, group,
contacts,
unreadMarkerRef, unreadMarkerRef,
history, history,
api, api,
groups,
associations associations
}; };
const unreadIndex = graph.keys()[this.props.unreadCount]; const unreadMsg = graph.get(this.state.unreadIndex);
const unreadMsg = unreadIndex && graph.get(unreadIndex);
// hack to force a re-render when we toggle showing contact
const contactsModified =
showOurContact ? 0 : 100;
return ( return (
<Col height='100%' overflow='hidden' position='relative'> <Col height='100%' overflow='hidden' position='relative'>
<UnreadNotice { this.dismissedInitialUnread() &&
(<UnreadNotice
unreadCount={unreadCount} unreadCount={unreadCount}
unreadMsg={ unreadMsg={
unreadCount === 1 && unreadCount === 1 &&
@ -309,7 +338,7 @@ export default class ChatWindow extends Component<
} }
dismissUnread={this.dismissUnread} dismissUnread={this.dismissUnread}
onClick={this.scrollToUnread} onClick={this.scrollToUnread}
/> />)}
<VirtualScroller <VirtualScroller
ref={(list) => { ref={(list) => {
this.virtualList = list; this.virtualList = list;
@ -318,10 +347,11 @@ export default class ChatWindow extends Component<
origin='bottom' origin='bottom'
style={virtScrollerStyle} style={virtScrollerStyle}
onStartReached={this.setActive} onStartReached={this.setActive}
onBottomLoaded={this.onBottomLoaded}
onScroll={this.onScroll} onScroll={this.onScroll}
data={graph} data={graph}
size={graph.size} size={graph.size}
pendingSize={pendingSize} pendingSize={pendingSize + contactsModified}
id={association.resource} id={association.resource}
averageHeight={22} averageHeight={22}
renderer={this.renderer} renderer={this.renderer}
@ -331,3 +361,9 @@ export default class ChatWindow extends Component<
); );
} }
} }
export default withState(ChatWindow, [
[useGroupState, ['groups']],
[useMetadataState, ['associations']],
[useGraphState, ['pendingSize']]
]);

View File

@ -199,6 +199,7 @@ export default class ChatEditor extends Component {
width='calc(100% - 88px)' width='calc(100% - 88px)'
className={inCodeMode ? 'chat code' : 'chat'} className={inCodeMode ? 'chat code' : 'chat'}
color="black" color="black"
overflow='auto'
> >
{MOBILE_BROWSER_REGEX.test(navigator.userAgent) {MOBILE_BROWSER_REGEX.test(navigator.userAgent)
? <MobileBox ? <MobileBox

View File

@ -91,7 +91,7 @@ const MessageMarkdown = React.memo((props) => {
}, []); }, []);
return lines.map((line, i) => ( return lines.map((line, i) => (
<> <React.Fragment key={i}>
{i !== 0 && <Row height={2} />} {i !== 0 && <Row height={2} />}
<ReactMarkdown <ReactMarkdown
{...rest} {...rest}
@ -123,7 +123,7 @@ const MessageMarkdown = React.memo((props) => {
] ]
]} ]}
/> />
</> </React.Fragment>
)); ));
}); });
@ -145,8 +145,6 @@ export default function TextContent(props) {
<GroupLink <GroupLink
resource={resource} resource={resource}
api={props.api} api={props.api}
associations={props.associations}
groups={props.groups}
pl='2' pl='2'
border='1' border='1'
borderRadius='2' borderRadius='2'

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import moment from 'moment'; import moment from 'moment';
import { Box, Text } from '@tlon/indigo-react'; import { Box, Text, Center, Icon } from '@tlon/indigo-react';
import VisibilitySensor from 'react-visibility-sensor'; import VisibilitySensor from 'react-visibility-sensor';
import Timestamp from '~/views/components/Timestamp'; import Timestamp from '~/views/components/Timestamp';
@ -8,51 +8,67 @@ import Timestamp from '~/views/components/Timestamp';
export const UnreadNotice = (props) => { export const UnreadNotice = (props) => {
const { unreadCount, unreadMsg, dismissUnread, onClick } = props; const { unreadCount, unreadMsg, dismissUnread, onClick } = props;
if (!unreadMsg || (unreadCount === 0)) { if (!unreadMsg || unreadCount === 0) {
return null; return null;
} }
const stamp = moment.unix(unreadMsg.post['time-sent'] / 1000); const stamp = moment.unix(unreadMsg.post['time-sent'] / 1000);
let datestamp = moment.unix(unreadMsg.post['time-sent'] / 1000).format('YYYY.M.D'); let datestamp = moment
const timestamp = moment.unix(unreadMsg.post['time-sent'] / 1000).format('HH:mm'); .unix(unreadMsg.post['time-sent'] / 1000)
.format('YYYY.M.D');
const timestamp = moment
.unix(unreadMsg.post['time-sent'] / 1000)
.format('HH:mm');
if (datestamp === moment().format('YYYY.M.D')) { if (datestamp === moment().format('YYYY.M.D')) {
datestamp = null; datestamp = null;
} }
return ( return (
<Box style={{ left: '0px', top: '0px' }} <Box
p='4' style={{ left: '0px', top: '0px' }}
p='12px'
width='100%' width='100%'
position='absolute' position='absolute'
zIndex='1' zIndex='1'
className='unread-notice' className='unread-notice'
> >
<Center>
<Box backgroundColor='white' borderRadius='2'>
<Box <Box
backgroundColor='white' backgroundColor='washedBlue'
display='flex' display='flex'
alignItems='center' alignItems='center'
p='2' p='2'
fontSize='0' fontSize='0'
justifyContent='space-between' justifyContent='space-between'
borderRadius='1' borderRadius='3'
border='1' border='1'
borderColor='blue'> borderColor='lightBlue'
<Text flexShrink='1' textOverflow='ellipsis' whiteSpace='pre' overflow='hidden' display='flex' cursor='pointer' onClick={onClick}> >
{unreadCount} new message{unreadCount > 1 ? 's' : ''} since{' '}
<Timestamp stamp={stamp} color='blue' date={true} fontSize={1} />
</Text>
<Text <Text
textOverflow='ellipsis'
whiteSpace='pre'
overflow='hidden'
display='flex'
cursor='pointer'
onClick={onClick}
>
{unreadCount} new message{unreadCount > 1 ? 's' : ''} since{' '}
<Timestamp stamp={stamp} color='black' date={true} fontSize={1} />
</Text>
<Icon
icon='X'
ml='4' ml='4'
color='blue' color='black'
cursor='pointer' cursor='pointer'
textAlign='right' textAlign='right'
flexShrink='0' onClick={dismissUnread}
onClick={dismissUnread}> />
Mark as Read
</Text>
</Box> </Box>
</Box> </Box>
</Center>
</Box>
); );
} };

View File

@ -1,20 +1,16 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { Switch, Route } from 'react-router-dom'; import { Switch, Route, useHistory } from 'react-router-dom';
import { Center, Text } from "@tlon/indigo-react"; import { Center, Text } from "@tlon/indigo-react";
import { deSig } from '~/logic/lib/util'; import { deSig } from '~/logic/lib/util';
import useGraphState from '~/logic/state/graph';
import useMetadataState from '~/logic/state/metadata';
const GraphApp = (props) => {
const associations= useMetadataState(state => state.associations);
const graphKeys = useGraphState(state => state.graphKeys);
const history = useHistory();
export default class GraphApp extends PureComponent { const { api } = props;
render() {
const { props } = this;
const contacts = props.contacts ? props.contacts : {};
const groups = props.groups ? props.groups : {};
const associations =
props.associations ? props.associations : { graph: {}, contacts: {} };
const graphKeys = props.graphKeys || new Set([]);
const graphs = props.graphs || {};
const { api } = this.props;
return ( return (
<Switch> <Switch>
@ -43,7 +39,7 @@ export default class GraphApp extends PureComponent {
if(!graphKeys.has(resource)) { if(!graphKeys.has(resource)) {
autoJoin(); autoJoin();
} else if(!!association) { } else if(!!association) {
props.history.push(`/~landscape/home/resource/${association.metadata.module}${path}`); history.push(`/~landscape/home/resource/${association.metadata.module}${path}`);
} }
return ( return (
<Center width="100%" height="100%"> <Center width="100%" height="100%">
@ -55,5 +51,5 @@ export default class GraphApp extends PureComponent {
</Switch> </Switch>
); );
} }
}
export default GraphApp;

View File

@ -1,13 +1,12 @@
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useHistory } from 'react-router-dom';
import f from 'lodash/fp'; import f from 'lodash/fp';
import _ from 'lodash'; import _ from 'lodash';
import { Col, Button, Box, Row, Icon, Text } from '@tlon/indigo-react'; import { Col, Button, Box, Row, Icon, Text } from '@tlon/indigo-react';
import './css/custom.css'; import './css/custom.css';
import useContactState from '~/logic/state/contact';
import Tiles from './components/tiles'; import Tiles from './components/tiles';
import Tile from './components/tiles/tile'; import Tile from './components/tiles/tile';
import Groups from './components/Groups'; import Groups from './components/Groups';
@ -20,6 +19,7 @@ import { NewGroup } from "~/views/landscape/components/NewGroup";
import { JoinGroup } from "~/views/landscape/components/JoinGroup"; import { JoinGroup } from "~/views/landscape/components/JoinGroup";
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import useLocalState from "~/logic/state/local"; import useLocalState from "~/logic/state/local";
import useHarkState from '~/logic/state/hark';
import { useWaitForProps } from '~/logic/lib/useWaitForProps'; import { useWaitForProps } from '~/logic/lib/useWaitForProps';
import { useQuery } from "~/logic/lib/useQuery"; import { useQuery } from "~/logic/lib/useQuery";
import { import {
@ -30,7 +30,9 @@ import {
TUTORIAL_CHAT, TUTORIAL_CHAT,
TUTORIAL_LINKS TUTORIAL_LINKS
} from '~/logic/lib/tutorialModal'; } from '~/logic/lib/tutorialModal';
import useLaunchState from '~/logic/state/launch';
import useSettingsState, { selectCalmState } from '~/logic/state/settings'; import useSettingsState, { selectCalmState } from '~/logic/state/settings';
import useMetadataState from '~/logic/state/metadata';
const ScrollbarLessBox = styled(Box)` const ScrollbarLessBox = styled(Box)`
@ -44,9 +46,22 @@ const ScrollbarLessBox = styled(Box)`
const tutSelector = f.pick(['tutorialProgress', 'nextTutStep', 'hideGroups']); const tutSelector = f.pick(['tutorialProgress', 'nextTutStep', 'hideGroups']);
export default function LaunchApp(props) { export default function LaunchApp(props) {
const history = useHistory(); const connection = { props };
const [hashText, setHashText] = useState(props.baseHash); const baseHash = useLaunchState(state => state.baseHash);
const [hashText, setHashText] = useState(baseHash);
const [exitingTut, setExitingTut] = useState(false); const [exitingTut, setExitingTut] = useState(false);
const seen = useSettingsState(s => s?.tutorial?.seen) ?? true;
const associations = useMetadataState(s => s.associations);
const contacts = useContactState(state => state.contacts);
const hasLoaded = useMemo(() => Boolean(connection === "connected"), [connection]);
const notificationsCount = useHarkState(state => state.notificationsCount);
const calmState = useSettingsState(selectCalmState);
const { hideUtilities } = calmState;
const { tutorialProgress, nextTutStep } = useLocalState(tutSelector);
let { hideGroups } = useLocalState(tutSelector);
!hideGroups ? { hideGroups } = calmState : null;
const waiter = useWaitForProps({ ...props, associations });
const hashBox = ( const hashBox = (
<Box <Box
position={["relative", "absolute"]} position={["relative", "absolute"]}
@ -60,15 +75,15 @@ export default function LaunchApp(props) {
fontSize={0} fontSize={0}
cursor="pointer" cursor="pointer"
onClick={() => { onClick={() => {
writeText(props.baseHash); writeText(baseHash);
setHashText('copied'); setHashText('copied');
setTimeout(() => { setTimeout(() => {
setHashText(props.baseHash); setHashText(baseHash);
}, 2000); }, 2000);
}} }}
> >
<Box backgroundColor="washedGray" p={2}> <Box backgroundColor="washedGray" p={2}>
<Text mono bold>{hashText || props.baseHash}</Text> <Text mono bold>{hashText || baseHash}</Text>
</Box> </Box>
</Box> </Box>
); );
@ -77,7 +92,7 @@ export default function LaunchApp(props) {
useEffect(() => { useEffect(() => {
if(query.get('tutorial')) { if(query.get('tutorial')) {
if(hasTutorialGroup(props)) { if(hasTutorialGroup({ associations })) {
nextTutStep(); nextTutStep();
} else { } else {
showModal(); showModal();
@ -85,13 +100,6 @@ export default function LaunchApp(props) {
} }
}, [query]); }, [query]);
const { hideUtilities } = useSettingsState(selectCalmState);
const { tutorialProgress, nextTutStep } = useLocalState(tutSelector);
let { hideGroups } = useLocalState(tutSelector);
!hideGroups ? { hideGroups } = useSettingsState(selectCalmState) : null;
const waiter = useWaitForProps(props);
const { modal, showModal } = useModal({ const { modal, showModal } = useModal({
position: 'relative', position: 'relative',
maxWidth: '350px', maxWidth: '350px',
@ -103,7 +111,7 @@ export default function LaunchApp(props) {
}; };
const onContinue = async (e) => { const onContinue = async (e) => {
e.stopPropagation(); e.stopPropagation();
if(!hasTutorialGroup(props)) { if(!hasTutorialGroup({ associations })) {
await props.api.groups.join(TUTORIAL_HOST, TUTORIAL_GROUP); await props.api.groups.join(TUTORIAL_HOST, TUTORIAL_GROUP);
await props.api.settings.putEntry('tutorial', 'joined', Date.now()); await props.api.settings.putEntry('tutorial', 'joined', Date.now());
await waiter(hasTutorialGroup); await waiter(hasTutorialGroup);
@ -154,19 +162,17 @@ export default function LaunchApp(props) {
</Col> </Col>
)} )}
}); });
const hasLoaded = useMemo(() => Object.keys(props.contacts).length > 0, [props.contacts]);
useEffect(() => { useEffect(() => {
const seenTutorial = _.get(props.settings, ['tutorial', 'seen'], true); if(hasLoaded && !seen && tutorialProgress === 'hidden') {
if(hasLoaded && !seenTutorial && tutorialProgress === 'hidden') {
showModal(); showModal();
} }
}, [props.settings, hasLoaded]); }, [seen, hasLoaded]);
return ( return (
<> <>
<Helmet defer={false}> <Helmet defer={false}>
<title>{ props.notificationsCount ? `(${String(props.notificationsCount) }) `: '' }Landscape</title> <title>{ notificationsCount ? `(${String(notificationsCount) }) `: '' }Landscape</title>
</Helmet> </Helmet>
<ScrollbarLessBox height='100%' overflowY='scroll' display="flex" flexDirection="column"> <ScrollbarLessBox height='100%' overflowY='scroll' display="flex" flexDirection="column">
{modal} {modal}
@ -196,11 +202,7 @@ export default function LaunchApp(props) {
</Box> </Box>
</Tile> </Tile>
<Tiles <Tiles
tiles={props.launch.tiles}
tileOrdering={props.launch.tileOrdering}
api={props.api} api={props.api}
location={props.userLocation}
weather={props.weather}
/> />
<ModalButton <ModalButton
icon="Plus" icon="Plus"
@ -221,7 +223,7 @@ export default function LaunchApp(props) {
</ModalButton> </ModalButton>
</>} </>}
{!hideGroups && {!hideGroups &&
(<Groups unreads={props.unreads} groups={props.groups} associations={props.associations} />) (<Groups />)
} }
</Box> </Box>
<Box alignSelf="flex-start" display={["block", "none"]}>{hashBox}</Box> <Box alignSelf="flex-start" display={["block", "none"]}>{hashBox}</Box>

View File

@ -9,12 +9,13 @@ import { alphabeticalOrder } from '~/logic/lib/util';
import { getUnreadCount, getNotificationCount } from '~/logic/lib/hark'; import { getUnreadCount, getNotificationCount } from '~/logic/lib/hark';
import Tile from '../components/tiles/tile'; import Tile from '../components/tiles/tile';
import { useTutorialModal } from '~/views/components/useTutorialModal'; import { useTutorialModal } from '~/views/components/useTutorialModal';
import useGroupState from '~/logic/state/group';
import useHarkState from '~/logic/state/hark';
import useMetadataState from '~/logic/state/metadata';
import { TUTORIAL_HOST, TUTORIAL_GROUP, TUTORIAL_GROUP_RESOURCE } from '~/logic/lib/tutorialModal'; import { TUTORIAL_HOST, TUTORIAL_GROUP, TUTORIAL_GROUP_RESOURCE } from '~/logic/lib/tutorialModal';
import useSettingsState, { selectCalmState, SettingsState } from '~/logic/state/settings'; import useSettingsState, { selectCalmState, SettingsState } from '~/logic/state/settings';
interface GroupsProps { interface GroupsProps {}
associations: Associations;
}
const sortGroupsAlph = (a: Association, b: Association) => const sortGroupsAlph = (a: Association, b: Association) =>
a.group === TUTORIAL_GROUP_RESOURCE a.group === TUTORIAL_GROUP_RESOURCE
@ -40,10 +41,13 @@ const getGraphNotifications = (associations: Associations, unreads: Unreads) =>
)(associations.graph); )(associations.graph);
export default function Groups(props: GroupsProps & Parameters<typeof Box>[0]) { export default function Groups(props: GroupsProps & Parameters<typeof Box>[0]) {
const { associations, unreads, inbox, ...boxProps } = props; const { inbox, ...boxProps } = props;
const unreads = useHarkState(state => state.unreads);
const groupState = useGroupState(state => state.groups);
const associations = useMetadataState(state => state.associations);
const groups = Object.values(associations?.groups || {}) const groups = Object.values(associations?.groups || {})
.filter(e => e?.group in props.groups) .filter(e => e?.group in groupState)
.sort(sortGroupsAlph); .sort(sortGroupsAlph);
const graphUnreads = getGraphUnreads(associations || {}, unreads); const graphUnreads = getGraphUnreads(associations || {}, unreads);
const graphNotifications = getGraphNotifications(associations || {}, unreads); const graphNotifications = getGraphNotifications(associations || {}, unreads);
@ -87,15 +91,19 @@ function Group(props: GroupProps) {
isTutorialGroup, isTutorialGroup,
anchorRef anchorRef
); );
const { hideUnreads } = useSettingsState(selectCalmState) const { hideUnreads } = useSettingsState(selectCalmState);
const joined = useSettingsState(selectJoined); const joined = useSettingsState(selectJoined);
const days = Math.max(0, Math.floor(moment.duration(moment(joined)
.add(14, 'days')
.diff(moment()))
.as('days'))) || 0;
return ( return (
<Tile ref={anchorRef} position="relative" bg={isTutorialGroup ? 'lightBlue' : undefined} to={`/~landscape${path}`} gridColumnStart={first ? '1' : null}> <Tile ref={anchorRef} position="relative" bg={isTutorialGroup ? 'lightBlue' : undefined} to={`/~landscape${path}`} gridColumnStart={first ? '1' : null}>
<Col height="100%" justifyContent="space-between"> <Col height="100%" justifyContent="space-between">
<Text>{title}</Text> <Text>{title}</Text>
{!hideUnreads && (<Col> {!hideUnreads && (<Col>
{isTutorialGroup && joined && {isTutorialGroup && joined &&
(<Text>{Math.floor(moment.duration(moment(joined).add(14, 'days').diff(moment())).as('days'))} days remaining</Text>) (<Text>{days} day{days !== 1 && 's'} remaining</Text>)
} }
{updates > 0 && {updates > 0 &&
(<Text mt="1" color="blue">{updates} update{updates !== 1 && 's'} </Text>) (<Text mt="1" color="blue">{updates} update{updates !== 1 && 's'} </Text>)

View File

@ -5,16 +5,18 @@ import CustomTile from './tiles/custom';
import ClockTile from './tiles/clock'; import ClockTile from './tiles/clock';
import WeatherTile from './tiles/weather'; import WeatherTile from './tiles/weather';
export default class Tiles extends React.PureComponent { import useLaunchState from '~/logic/state/launch';
render() {
const { props } = this;
const tiles = props.tileOrdering.filter((key) => { const Tiles = (props) => {
const tile = props.tiles[key]; const weather = useLaunchState(state => state.weather);
const tileOrdering = useLaunchState(state => state.tileOrdering);
const tileState = useLaunchState(state => state.tiles);
const tiles = tileOrdering.filter((key) => {
const tile = tileState[key];
return tile.isShown; return tile.isShown;
}).map((key) => { }).map((key) => {
const tile = props.tiles[key]; const tile = tileState[key];
if ('basic' in tile.type) { if ('basic' in tile.type) {
const basic = tile.type.basic; const basic = tile.type.basic;
return ( return (
@ -31,12 +33,10 @@ export default class Tiles extends React.PureComponent {
<WeatherTile <WeatherTile
key={key} key={key}
api={props.api} api={props.api}
weather={props.weather}
location={props.location}
/> />
); );
} else if (key === 'clock') { } else if (key === 'clock') {
const location = 'nearest-area' in props.weather ? props.weather['nearest-area'][0] : ''; const location = weather && 'nearest-area' in weather ? weather['nearest-area'][0] : '';
return ( return (
<ClockTile key={key} location={location} /> <ClockTile key={key} location={location} />
); );
@ -47,8 +47,8 @@ export default class Tiles extends React.PureComponent {
}); });
return ( return (
<React.Fragment>{tiles}</React.Fragment> <>{tiles}</>
); );
} }
}
export default Tiles;

View File

@ -2,6 +2,8 @@ import React from 'react';
import moment from 'moment'; import moment from 'moment';
import { Box, Icon, Text, BaseAnchor, BaseInput } from '@tlon/indigo-react'; import { Box, Icon, Text, BaseAnchor, BaseInput } from '@tlon/indigo-react';
import ErrorBoundary from '~/views/components/ErrorBoundary'; import ErrorBoundary from '~/views/components/ErrorBoundary';
import withState from '~/logic/lib/withState';
import useLaunchState from '~/logic/state/launch';
import Tile from './tile'; import Tile from './tile';
@ -34,7 +36,7 @@ const imperialCountries = [
'Liberia', 'Liberia',
]; ];
export default class WeatherTile extends React.Component { class WeatherTile extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
@ -289,3 +291,4 @@ export default class WeatherTile extends React.Component {
} }
} }
export default withState(WeatherTile, [[useLaunchState]]);

View File

@ -8,11 +8,14 @@ import { StoreState } from '~/logic/store/type';
import { RouteComponentProps } from 'react-router-dom'; import { RouteComponentProps } from 'react-router-dom';
import { LinkItem } from './components/LinkItem'; import { LinkItem } from './components/LinkItem';
import { LinkWindow } from './LinkWindow'; import LinkWindow from './LinkWindow';
import { Comments } from '~/views/components/Comments'; import { Comments } from '~/views/components/Comments';
import './css/custom.css'; import './css/custom.css';
import { Association } from '@urbit/api/metadata'; import { Association } from '@urbit/api/metadata';
import useGraphState from '~/logic/state/graph';
import useMetadataState from '~/logic/state/metadata';
import useGroupState from '../../../logic/state/group';
const emptyMeasure = () => {}; const emptyMeasure = () => {};
@ -27,29 +30,24 @@ export function LinkResource(props: LinkResourceProps) {
association, association,
api, api,
baseUrl, baseUrl,
graphs,
contacts,
groups,
associations,
graphKeys,
unreads,
graphTimesentMap,
storage,
history
} = props; } = props;
const rid = association.resource; const rid = association.resource;
const relativePath = (p: string) => `${baseUrl}/resource/link${rid}${p}`; const relativePath = (p: string) => `${baseUrl}/resource/link${rid}${p}`;
const associations = useMetadataState(state => state.associations);
const [, , ship, name] = rid.split('/'); const [, , ship, name] = rid.split('/');
const resourcePath = `${ship.slice(1)}/${name}`; const resourcePath = `${ship.slice(1)}/${name}`;
const resource = associations.graph[rid] const resource = associations.graph[rid]
? associations.graph[rid] ? associations.graph[rid]
: { metadata: {} }; : { metadata: {} };
const groups = useGroupState(state => state.groups);
const group = groups[resource?.group] || {}; const group = groups[resource?.group] || {};
const graphs = useGraphState(state => state.graphs);
const graph = graphs[resourcePath] || null; const graph = graphs[resourcePath] || null;
const graphTimesentMap = useGraphState(state => state.graphTimesentMap);
useEffect(() => { useEffect(() => {
api.graph.getGraph(ship, name); api.graph.getGraph(ship, name);
@ -70,12 +68,9 @@ export function LinkResource(props: LinkResourceProps) {
return ( return (
<LinkWindow <LinkWindow
key={rid} key={rid}
storage={storage}
association={resource} association={resource}
contacts={contacts}
resource={resourcePath} resource={resourcePath}
graph={graph} graph={graph}
unreads={unreads}
baseUrl={resourceUrl} baseUrl={resourceUrl}
group={group} group={group}
path={resource.group} path={resource.group}
@ -106,12 +101,10 @@ export function LinkResource(props: LinkResourceProps) {
<Col width="100%" p={3} maxWidth="768px"> <Col width="100%" p={3} maxWidth="768px">
<Link to={resourceUrl}><Text px={3} bold>{'<- Back'}</Text></Link> <Link to={resourceUrl}><Text px={3} bold>{'<- Back'}</Text></Link>
<LinkItem <LinkItem
contacts={contacts}
key={node.post.index} key={node.post.index}
resource={resourcePath} resource={resourcePath}
node={node} node={node}
baseUrl={resourceUrl} baseUrl={resourceUrl}
unreads={unreads}
group={group} group={group}
path={resource?.group} path={resource?.group}
api={api} api={api}
@ -124,8 +117,6 @@ export function LinkResource(props: LinkResourceProps) {
comments={node} comments={node}
resource={resourcePath} resource={resourcePath}
association={association} association={association}
unreads={unreads}
contacts={contacts}
api={api} api={api}
editCommentId={editCommentId} editCommentId={editCommentId}
history={props.history} history={props.history}

View File

@ -16,20 +16,20 @@ import { LinkItem } from "./components/LinkItem";
import LinkSubmit from "./components/LinkSubmit"; import LinkSubmit from "./components/LinkSubmit";
import { isWriter } from "~/logic/lib/group"; import { isWriter } from "~/logic/lib/group";
import { StorageState } from "~/types"; import { StorageState } from "~/types";
import withState from "~/logic/lib/withState";
import useGraphState from "~/logic/state/graph";
interface LinkWindowProps { interface LinkWindowProps {
association: Association; association: Association;
contacts: Rolodex;
resource: string; resource: string;
graph: Graph; graph: Graph;
unreads: Unreads;
hideNicknames: boolean; hideNicknames: boolean;
hideAvatars: boolean; hideAvatars: boolean;
baseUrl: string; baseUrl: string;
group: Group; group: Group;
path: string; path: string;
api: GlobalApi; api: GlobalApi;
storage: StorageState; pendingSize: number;
} }
const style = { const style = {
@ -40,7 +40,7 @@ const style = {
alignItems: "center", alignItems: "center",
}; };
export class LinkWindow extends Component<LinkWindowProps, {}> { class LinkWindow extends Component<LinkWindowProps, {}> {
fetchLinks = async () => true; fetchLinks = async () => true;
canWrite() { canWrite() {
@ -75,7 +75,6 @@ export class LinkWindow extends Component<LinkWindowProps, {}> {
px={3} px={3}
> >
<LinkSubmit <LinkSubmit
storage={props.storage}
name={name} name={name}
ship={ship.slice(1)} ship={ship.slice(1)}
api={api} api={api}
@ -89,7 +88,7 @@ export class LinkWindow extends Component<LinkWindowProps, {}> {
}; };
render() { render() {
const { graph, api, association, storage, pendingSize } = this.props; const { graph, api, association } = this.props;
const first = graph.peekLargest()?.[0]; const first = graph.peekLargest()?.[0];
const [, , ship, name] = association.resource.split("/"); const [, , ship, name] = association.resource.split("/");
if (!first) { if (!first) {
@ -105,7 +104,6 @@ export class LinkWindow extends Component<LinkWindowProps, {}> {
> >
{this.canWrite() ? ( {this.canWrite() ? (
<LinkSubmit <LinkSubmit
storage={storage}
name={name} name={name}
ship={ship.slice(1)} ship={ship.slice(1)}
api={api} api={api}
@ -129,7 +127,7 @@ export class LinkWindow extends Component<LinkWindowProps, {}> {
data={graph} data={graph}
averageHeight={100} averageHeight={100}
size={graph.size} size={graph.size}
pendingSize={pendingSize} pendingSize={this.props.pendingSize}
renderer={this.renderItem} renderer={this.renderItem}
loadRows={this.fetchLinks} loadRows={this.fetchLinks}
/> />
@ -137,3 +135,5 @@ export class LinkWindow extends Component<LinkWindowProps, {}> {
); );
} }
} }
export default LinkWindow;

View File

@ -10,6 +10,7 @@ import { roleForShip } from '~/logic/lib/group';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { Dropdown } from '~/views/components/Dropdown'; import { Dropdown } from '~/views/components/Dropdown';
import RemoteContent from '~/views/components/RemoteContent'; import RemoteContent from '~/views/components/RemoteContent';
import useHarkState from '~/logic/state/hark';
interface LinkItemProps { interface LinkItemProps {
node: GraphNode; node: GraphNode;
@ -17,8 +18,6 @@ interface LinkItemProps {
api: GlobalApi; api: GlobalApi;
group: Group; group: Group;
path: string; path: string;
contacts: Rolodex;
unreads: Unreads;
} }
export const LinkItem = (props: LinkItemProps): ReactElement => { export const LinkItem = (props: LinkItemProps): ReactElement => {
@ -28,7 +27,6 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
api, api,
group, group,
path, path,
contacts,
...rest ...rest
} = props; } = props;
@ -89,8 +87,9 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
}; };
const appPath = `/ship/~${resource}`; const appPath = `/ship/~${resource}`;
const commColor = (props.unreads.graph?.[appPath]?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray'; const unreads = useHarkState(state => state.unreads);
const isUnread = props.unreads.graph?.[appPath]?.['/']?.unreads?.has(node.post.index); const commColor = (unreads.graph?.[appPath]?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray';
const isUnread = unreads.graph?.[appPath]?.['/']?.unreads?.has(node.post.index);
return ( return (
<Box <Box
@ -149,18 +148,13 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
</Anchor> </Anchor>
</Text> </Text>
</Box> </Box>
<Row minWidth='0' flexShrink={0} width="100%" justifyContent="space-between" py={3} bg="white"> <Row minWidth='0' flexShrink={0} width="100%" justifyContent="space-between" py={3} bg="white">
<Author <Author
showImage showImage
contacts={contacts}
ship={author} ship={author}
date={node.post['time-sent']} date={node.post['time-sent']}
group={group} group={group}
api={api} />
></Author>
<Box ml="auto"> <Box ml="auto">
<Link <Link
to={node.post.pending ? '#' : `${baseUrl}/${index}`} to={node.post.pending ? '#' : `${baseUrl}/${index}`}

View File

@ -10,14 +10,13 @@ import { hasProvider } from 'oembed-parser';
interface LinkSubmitProps { interface LinkSubmitProps {
api: GlobalApi; api: GlobalApi;
storage: StorageState;
name: string; name: string;
ship: string; ship: string;
} }
const LinkSubmit = (props: LinkSubmitProps) => { const LinkSubmit = (props: LinkSubmitProps) => {
const { canUpload, uploadDefault, uploading, promptUpload } = const { canUpload, uploadDefault, uploading, promptUpload } =
useStorage(props.storage); useStorage();
const [submitFocused, setSubmitFocused] = useState(false); const [submitFocused, setSubmitFocused] = useState(false);
const [urlFocused, setUrlFocused] = useState(false); const [urlFocused, setUrlFocused] = useState(false);

View File

@ -18,6 +18,8 @@ import { getSnippet } from '~/logic/lib/publish';
import styled from 'styled-components'; import styled from 'styled-components';
import { MentionText } from '~/views/components/MentionText'; import { MentionText } from '~/views/components/MentionText';
import ChatMessage from '../chat/components/ChatMessage'; import ChatMessage from '../chat/components/ChatMessage';
import useContactState from '~/logic/state/contact';
import useGroupState from '~/logic/state/group';
function getGraphModuleIcon(module: string) { function getGraphModuleIcon(module: string) {
if (module === 'link') { if (module === 'link') {
@ -30,7 +32,7 @@ const FilterBox = styled(Box)`
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
transparent, transparent,
${p => p.theme.colors.white} ${(p) => p.theme.colors.white}
); );
`; `;
@ -67,7 +69,6 @@ const GraphUrl = ({ url, title }) => (
const GraphNodeContent = ({ const GraphNodeContent = ({
group, group,
post, post,
contacts,
mod, mod,
description, description,
index, index,
@ -80,9 +81,7 @@ const GraphNodeContent = ({
const [{ text }, { url }] = contents; const [{ text }, { url }] = contents;
return <GraphUrl title={text} url={url} />; return <GraphUrl title={text} url={url} />;
} else if (idx.length === 3) { } else if (idx.length === 3) {
return ( return <MentionText content={contents} group={group} />;
<MentionText content={contents} contacts={contacts} group={group} />
);
} }
return null; return null;
} }
@ -92,7 +91,6 @@ const GraphNodeContent = ({
<MentionText <MentionText
content={contents} content={contents}
group={group} group={group}
contacts={contacts}
fontSize='14px' fontSize='14px'
lineHeight='tall' lineHeight='tall'
/> />
@ -133,12 +131,12 @@ const GraphNodeContent = ({
renderSigil={false} renderSigil={false}
containerClass='items-top cf hide-child' containerClass='items-top cf hide-child'
group={group} group={group}
contacts={contacts}
groups={{}} groups={{}}
associations={{ graph: {}, groups: {} }} associations={{ graph: {}, groups: {} }}
msg={post} msg={post}
fontSize='0' fontSize='0'
pt='2' pt='2'
hideHover={true}
/> />
</Row> </Row>
); );
@ -173,7 +171,6 @@ function getNodeUrl(
} }
const GraphNode = ({ const GraphNode = ({
post, post,
contacts,
author, author,
mod, mod,
description, description,
@ -184,10 +181,11 @@ const GraphNode = ({
group, group,
read, read,
onRead, onRead,
showContact = false, showContact = false
}) => { }) => {
author = deSig(author); author = deSig(author);
const history = useHistory(); const history = useHistory();
const contacts = useContactState((state) => state.contacts);
const nodeUrl = getNodeUrl(mod, group?.hidden, groupPath, graph, index); const nodeUrl = getNodeUrl(mod, group?.hidden, groupPath, graph, index);
@ -199,22 +197,18 @@ const GraphNode = ({
}, [read, onRead]); }, [read, onRead]);
const showNickname = useShowNickname(contacts?.[`~${author}`]); const showNickname = useShowNickname(contacts?.[`~${author}`]);
const nickname = (contacts?.[`~${author}`]?.nickname && showNickname) ? contacts[`~${author}`].nickname : cite(author); const nickname =
contacts?.[`~${author}`]?.nickname && showNickname
? contacts[`~${author}`].nickname
: cite(author);
return ( return (
<Row onClick={onClick} gapX='2' pt={showContact ? 2 : 0}> <Row onClick={onClick} gapX='2' pt={showContact ? 2 : 0}>
<Col flexGrow={1} alignItems='flex-start'> <Col flexGrow={1} alignItems='flex-start'>
{showContact && ( {showContact && (
<Author <Author showImage ship={author} date={time} group={group} />
showImage
contacts={contacts}
ship={author}
date={time}
group={group}
/>
)} )}
<Row width='100%' p='1' flexDirection='column'> <Row width='100%' p='1' flexDirection='column'>
<GraphNodeContent <GraphNodeContent
contacts={contacts}
post={post} post={post}
mod={mod} mod={mod}
description={description} description={description}
@ -235,12 +229,9 @@ export function GraphNotification(props: {
read: boolean; read: boolean;
time: number; time: number;
timebox: BigInteger; timebox: BigInteger;
associations: Associations;
groups: Groups;
contacts: Rolodex;
api: GlobalApi; api: GlobalApi;
}) { }) {
const { contents, index, read, time, api, timebox, groups } = props; const { contents, index, read, time, api, timebox } = props;
const authors = _.map(contents, 'author'); const authors = _.map(contents, 'author');
const { graph, group } = index; const { graph, group } = index;
@ -255,6 +246,8 @@ export function GraphNotification(props: {
return api.hark['read'](timebox, { graph: index }); return api.hark['read'](timebox, { graph: index });
}, [api, timebox, index, read]); }, [api, timebox, index, read]);
const groups = useGroupState((state) => state.groups);
return ( return (
<> <>
<Header <Header
@ -265,17 +258,14 @@ export function GraphNotification(props: {
authors={authors} authors={authors}
moduleIcon={icon} moduleIcon={icon}
channel={graph} channel={graph}
contacts={props.contacts}
group={group} group={group}
description={desc} description={desc}
associations={props.associations}
/> />
<Box flexGrow={1} width='100%' pl={5} gridArea='main'> <Box flexGrow={1} width='100%' pl={5} gridArea='main'>
{_.map(contents, (content, idx) => ( {_.map(contents, (content, idx) => (
<GraphNode <GraphNode
post={content} post={content}
author={content.author} author={content.author}
contacts={props.contacts}
mod={index.module} mod={index.module}
time={content?.['time-sent']} time={content?.['time-sent']}
description={index.description} description={index.description}

View File

@ -41,13 +41,11 @@ interface GroupNotificationProps {
read: boolean; read: boolean;
time: number; time: number;
timebox: BigInteger; timebox: BigInteger;
associations: Associations;
contacts: Rolodex;
api: GlobalApi; api: GlobalApi;
} }
export function GroupNotification(props: GroupNotificationProps): ReactElement { export function GroupNotification(props: GroupNotificationProps): ReactElement {
const { contents, index, read, time, api, timebox, associations } = props; const { contents, index, read, time, api, timebox } = props;
const authors = _.flatten(_.map(contents, getGroupUpdateParticipants)); const authors = _.flatten(_.map(contents, getGroupUpdateParticipants));
@ -69,10 +67,8 @@ export function GroupNotification(props: GroupNotificationProps): ReactElement {
time={time} time={time}
read={read} read={read}
group={group} group={group}
contacts={props.contacts}
authors={authors} authors={authors}
description={desc} description={desc}
associations={associations}
/> />
</Col> </Col>
); );

View File

@ -9,16 +9,19 @@ import { Associations, Contact, Contacts, Rolodex } from '@urbit/api';
import { PropFunc } from '~/types/util'; import { PropFunc } from '~/types/util';
import { useShowNickname } from '~/logic/lib/util'; import { useShowNickname } from '~/logic/lib/util';
import Timestamp from '~/views/components/Timestamp'; import Timestamp from '~/views/components/Timestamp';
import useContactState from '~/logic/state/contact';
import useMetadataState from '~/logic/state/metadata';
const Text = (props: PropFunc<typeof Text>) => ( const Text = (props: PropFunc<typeof Text>) => (
<NormalText fontWeight="500" {...props} /> <NormalText fontWeight="500" {...props} />
); );
function Author(props: { patp: string; contacts: Contacts; last?: boolean }): ReactElement { function Author(props: { patp: string; last?: boolean }): ReactElement {
const contact: Contact | undefined = props.contacts?.[`~${props.patp}`]; const contacts = useContactState(state => state.contacts);
const contact: Contact | undefined = contacts?.[`~${props.patp}`];
const showNickname = useShowNickname(contact); const showNickname = useShowNickname(contact);
const name = contact?.nickname || `~${props.patp}`; const name = showNickname ? contact.nickname : `~${props.patp}`;
return ( return (
<Text mono={!showNickname}> <Text mono={!showNickname}>
@ -33,14 +36,13 @@ export function Header(props: {
archived?: boolean; archived?: boolean;
channel?: string; channel?: string;
group: string; group: string;
contacts: Rolodex;
description: string; description: string;
moduleIcon?: string; moduleIcon?: string;
time: number; time: number;
read: boolean; read: boolean;
associations: Associations;
} & PropFunc<typeof Row> ): ReactElement { } & PropFunc<typeof Row> ): ReactElement {
const { description, channel, contacts, moduleIcon, read } = props; const { description, channel, moduleIcon, read } = props;
const associations = useMetadataState(state => state.associations);
const authors = _.uniq(props.authors); const authors = _.uniq(props.authors);
@ -50,7 +52,7 @@ export function Header(props: {
f.map(([idx, p]: [string, string]) => { f.map(([idx, p]: [string, string]) => {
const lent = Math.min(3, authors.length); const lent = Math.min(3, authors.length);
const last = lent - 1 === parseInt(idx, 10); const last = lent - 1 === parseInt(idx, 10);
return <Author key={idx} contacts={contacts} patp={p} last={last} />; return <Author key={idx} patp={p} last={last} />;
}), }),
auths => ( auths => (
<React.Fragment> <React.Fragment>
@ -64,11 +66,11 @@ export function Header(props: {
const time = moment(props.time).format('HH:mm'); const time = moment(props.time).format('HH:mm');
const groupTitle = const groupTitle =
props.associations.groups?.[props.group]?.metadata?.title; associations.groups?.[props.group]?.metadata?.title;
const app = 'graph'; const app = 'graph';
const channelTitle = const channelTitle =
(channel && props.associations?.[app]?.[channel]?.metadata?.title) || (channel && associations?.[app]?.[channel]?.metadata?.title) ||
channel; channel;
return ( return (

View File

@ -23,10 +23,12 @@ import GlobalApi from '~/logic/api/global';
import { Notification } from './notification'; import { Notification } from './notification';
import { Invites } from './invites'; import { Invites } from './invites';
import { useLazyScroll } from '~/logic/lib/useLazyScroll'; import { useLazyScroll } from '~/logic/lib/useLazyScroll';
import useHarkState from '~/logic/state/hark';
import useInviteState from '~/logic/state/invite';
type DatedTimebox = [BigInteger, Timebox]; type DatedTimebox = [BigInteger, Timebox];
function filterNotification(associations: Associations, groups: string[]) { function filterNotification(groups: string[]) {
if (groups.length === 0) { if (groups.length === 0) {
return () => true; return () => true;
} }
@ -43,21 +45,13 @@ function filterNotification(associations: Associations, groups: string[]) {
} }
export default function Inbox(props: { export default function Inbox(props: {
notifications: Notifications;
notificationsSize: number;
archive: Notifications; archive: Notifications;
groups: Groups;
showArchive?: boolean; showArchive?: boolean;
api: GlobalApi; api: GlobalApi;
associations: Associations;
contacts: Rolodex;
filter: string[]; filter: string[];
invites: InviteType;
pendingJoin: JoinRequests; pendingJoin: JoinRequests;
notificationsGroupConfig: GroupNotificationsConfig;
notificationsGraphConfig: NotificationGraphConfig;
}) { }) {
const { api, associations, invites } = props; const { api } = props;
useEffect(() => { useEffect(() => {
let seen = false; let seen = false;
setTimeout(() => { setTimeout(() => {
@ -70,8 +64,11 @@ export default function Inbox(props: {
}; };
}, []); }, []);
const notificationState = useHarkState(state => state.notifications);
const archivedNotifications = useHarkState(state => state.archivedNotifications);
const notifications = const notifications =
Array.from(props.showArchive ? props.archive : props.notifications) || []; Array.from(props.showArchive ? archivedNotifications : notificationState) || [];
const calendar = { const calendar = {
...MOMENT_CALENDAR_DATE, sameDay: function (now) { ...MOMENT_CALENDAR_DATE, sameDay: function (now) {
@ -86,7 +83,7 @@ export default function Inbox(props: {
const notificationsByDay = f.flow( const notificationsByDay = f.flow(
f.map<DatedTimebox, DatedTimebox>(([date, nots]) => [ f.map<DatedTimebox, DatedTimebox>(([date, nots]) => [
date, date,
nots.filter(filterNotification(associations, props.filter)) nots.filter(filterNotification(props.filter))
]), ]),
f.groupBy<DatedTimebox>(([d]) => { f.groupBy<DatedTimebox>(([d]) => {
const date = moment(daToUnix(d)); const date = moment(daToUnix(d));
@ -119,7 +116,7 @@ export default function Inbox(props: {
return ( return (
<Col ref={scrollRef} position="relative" height="100%" overflowY="auto"> <Col ref={scrollRef} position="relative" height="100%" overflowY="auto">
<Invites groups={props.groups} pendingJoin={props.pendingJoin} invites={invites} api={api} associations={associations} /> <Invites pendingJoin={props.pendingJoin} api={api} />
{[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => { {[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => {
const timeboxes = notificationsByDayMap.get(day)!; const timeboxes = notificationsByDayMap.get(day)!;
return timeboxes.length > 0 && ( return timeboxes.length > 0 && (
@ -127,13 +124,8 @@ export default function Inbox(props: {
key={day} key={day}
label={day === 'latest' ? 'Today' : moment(day).calendar(null, calendar)} label={day === 'latest' ? 'Today' : moment(day).calendar(null, calendar)}
timeboxes={timeboxes} timeboxes={timeboxes}
contacts={props.contacts}
archive={Boolean(props.showArchive)} archive={Boolean(props.showArchive)}
associations={props.associations}
api={api} api={api}
groups={props.groups}
graphConfig={props.notificationsGraphConfig}
groupConfig={props.notificationsGroupConfig}
/> />
); );
})} })}
@ -165,14 +157,9 @@ function sortIndexedNotification(
function DaySection({ function DaySection({
label, label,
contacts,
groups,
archive, archive,
timeboxes, timeboxes,
associations,
api, api,
groupConfig,
graphConfig
}) { }) {
const lent = timeboxes.map(([,nots]) => nots.length).reduce(f.add, 0); const lent = timeboxes.map(([,nots]) => nots.length).reduce(f.add, 0);
if (lent === 0 || timeboxes.length === 0) { if (lent === 0 || timeboxes.length === 0) {
@ -195,14 +182,9 @@ function DaySection({
<Box flexShrink={0} height="4px" bg="scales.black05" /> <Box flexShrink={0} height="4px" bg="scales.black05" />
)} )}
<Notification <Notification
graphConfig={graphConfig}
groupConfig={groupConfig}
api={api} api={api}
associations={associations}
notification={not} notification={not}
archived={archive} archived={archive}
contacts={contacts}
groups={groups}
time={date} time={date}
/> />
</React.Fragment> </React.Fragment>

View File

@ -7,14 +7,11 @@ import { Invites as IInvites, Associations, Invite, JoinRequests, Groups, Contac
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { resourceAsPath, alphabeticalOrder } from '~/logic/lib/util'; import { resourceAsPath, alphabeticalOrder } from '~/logic/lib/util';
import InviteItem from '~/views/components/Invite'; import InviteItem from '~/views/components/Invite';
import useInviteState from '~/logic/state/invite';
import useGroupState from '~/logic/state/group';
interface InvitesProps { interface InvitesProps {
api: GlobalApi; api: GlobalApi;
invites: IInvites;
groups: Groups;
contacts: Contacts;
associations: Associations;
pendingJoin: JoinRequests;
} }
interface InviteRef { interface InviteRef {
@ -24,7 +21,9 @@ interface InviteRef {
} }
export function Invites(props: InvitesProps): ReactElement { export function Invites(props: InvitesProps): ReactElement {
const { api, invites, pendingJoin } = props; const { api } = props;
const pendingJoin = useGroupState(s => s.pendingJoin);
const invites = useInviteState(state => state.invites);
const inviteArr: InviteRef[] = _.reduce(invites, (acc: InviteRef[], val: AppInvites, app: string) => { const inviteArr: InviteRef[] = _.reduce(invites, (acc: InviteRef[], val: AppInvites, app: string) => {
const appInvites = _.reduce(val, (invs: InviteRef[], invite: Invite, uid: string) => { const appInvites = _.reduce(val, (invs: InviteRef[], invite: Invite, uid: string) => {
@ -34,7 +33,7 @@ export function Invites(props: InvitesProps): ReactElement {
}, []); }, []);
const invitesAndStatus: { [rid: string]: JoinProgress | InviteRef } = const invitesAndStatus: { [rid: string]: JoinProgress | InviteRef } =
{ ..._.keyBy(inviteArr, ({ invite }) => resourceAsPath(invite.resource)), ...props.pendingJoin }; { ..._.keyBy(inviteArr, ({ invite }) => resourceAsPath(invite.resource)), ...pendingJoin };
return ( return (
<Col <Col
@ -50,33 +49,27 @@ export function Invites(props: InvitesProps): ReactElement {
.sort(alphabeticalOrder) .sort(alphabeticalOrder)
.map((resource) => { .map((resource) => {
const inviteOrStatus = invitesAndStatus[resource]; const inviteOrStatus = invitesAndStatus[resource];
const join = pendingJoin[resource];
if(typeof inviteOrStatus === 'string') { if(typeof inviteOrStatus === 'string') {
return ( return (
<InviteItem <InviteItem
key={resource} key={resource}
contacts={props.contacts}
groups={props.groups}
associations={props.associations}
resource={resource} resource={resource}
pendingJoin={pendingJoin} pendingJoin={join}
api={api} api={api}
/> />
); );
} else { } else {
const { app, uid, invite } = inviteOrStatus; const { app, uid, invite } = inviteOrStatus;
console.log(inviteOrStatus);
return ( return (
<InviteItem <InviteItem
key={resource} key={resource}
api={api} api={api}
invite={invite} invite={invite}
pendingJoin={join}
app={app} app={app}
uid={uid} uid={uid}
pendingJoin={pendingJoin}
resource={resource} resource={resource}
contacts={props.contacts}
groups={props.groups}
associations={props.associations}
/> />
); );
} }

View File

@ -18,17 +18,13 @@ import { GroupNotification } from './group';
import { GraphNotification } from './graph'; import { GraphNotification } from './graph';
import { BigInteger } from 'big-integer'; import { BigInteger } from 'big-integer';
import { useHovering } from '~/logic/lib/util'; import { useHovering } from '~/logic/lib/util';
import useHarkState from '~/logic/state/hark';
interface NotificationProps { interface NotificationProps {
notification: IndexedNotification; notification: IndexedNotification;
time: BigInteger; time: BigInteger;
associations: Associations;
api: GlobalApi; api: GlobalApi;
archived: boolean; archived: boolean;
groups: Groups;
contacts: Contacts;
graphConfig: NotificationGraphConfig;
groupConfig: GroupNotificationsConfig;
} }
function getMuted( function getMuted(
@ -61,8 +57,6 @@ function NotificationWrapper(props: {
notif: IndexedNotification; notif: IndexedNotification;
children: ReactNode; children: ReactNode;
archived: boolean; archived: boolean;
graphConfig: NotificationGraphConfig;
groupConfig: GroupNotificationsConfig;
}) { }) {
const { api, time, notif, children } = props; const { api, time, notif, children } = props;
@ -70,10 +64,13 @@ function NotificationWrapper(props: {
return api.hark.archive(time, notif.index); return api.hark.archive(time, notif.index);
}, [time, notif]); }, [time, notif]);
const groupConfig = useHarkState(state => state.notificationsGroupConfig);
const graphConfig = useHarkState(state => state.notificationsGraphConfig);
const isMuted = getMuted( const isMuted = getMuted(
notif, notif,
props.groupConfig, groupConfig,
props.graphConfig graphConfig
); );
const onChangeMute = useCallback(async () => { const onChangeMute = useCallback(async () => {
@ -119,8 +116,6 @@ export function Notification(props: NotificationProps) {
notif={notification} notif={notification}
time={props.time} time={props.time}
api={props.api} api={props.api}
graphConfig={props.graphConfig}
groupConfig={props.groupConfig}
> >
{children} {children}
</NotificationWrapper> </NotificationWrapper>
@ -136,13 +131,10 @@ export function Notification(props: NotificationProps) {
api={props.api} api={props.api}
index={index} index={index}
contents={c} contents={c}
contacts={props.contacts}
groups={props.groups}
read={read} read={read}
archived={archived} archived={archived}
timebox={props.time} timebox={props.time}
time={time} time={time}
associations={associations}
/> />
</Wrapper> </Wrapper>
); );
@ -156,13 +148,10 @@ export function Notification(props: NotificationProps) {
api={props.api} api={props.api}
index={index} index={index}
contents={c} contents={c}
contacts={props.contacts}
groups={props.groups}
read={read} read={read}
timebox={props.time} timebox={props.time}
archived={archived} archived={archived}
time={time} time={time}
associations={associations}
/> />
</Wrapper> </Wrapper>
); );

View File

@ -12,6 +12,8 @@ import { Dropdown } from '~/views/components/Dropdown';
import { FormikOnBlur } from '~/views/components/FormikOnBlur'; import { FormikOnBlur } from '~/views/components/FormikOnBlur';
import GroupSearch from '~/views/components/GroupSearch'; import GroupSearch from '~/views/components/GroupSearch';
import { useTutorialModal } from '~/views/components/useTutorialModal'; import { useTutorialModal } from '~/views/components/useTutorialModal';
import useHarkState from '~/logic/state/hark';
import useMetadataState from '~/logic/state/metadata';
const baseUrl = '/~notifications'; const baseUrl = '/~notifications';
@ -38,6 +40,7 @@ export default function NotificationsScreen(props: any): ReactElement {
const relativePath = (p: string) => baseUrl + p; const relativePath = (p: string) => baseUrl + p;
const [filter, setFilter] = useState<NotificationFilter>({ groups: [] }); const [filter, setFilter] = useState<NotificationFilter>({ groups: [] });
const associations = useMetadataState(state => state.associations);
const onSubmit = async ({ groups } : NotificationFilter) => { const onSubmit = async ({ groups } : NotificationFilter) => {
setFilter({ groups }); setFilter({ groups });
}; };
@ -48,10 +51,11 @@ export default function NotificationsScreen(props: any): ReactElement {
filter.groups.length === 0 filter.groups.length === 0
? 'All' ? 'All'
: filter.groups : filter.groups
.map(g => props.associations?.groups?.[g]?.metadata?.title) .map(g => associations.groups?.[g]?.metadata?.title)
.join(', '); .join(', ');
const anchorRef = useRef<HTMLElement | null>(null); const anchorRef = useRef<HTMLElement | null>(null);
useTutorialModal('notifications', true, anchorRef); useTutorialModal('notifications', true, anchorRef);
const notificationsCount = useHarkState(state => state.notificationsCount);
return ( return (
<Switch> <Switch>
<Route <Route
@ -61,7 +65,7 @@ export default function NotificationsScreen(props: any): ReactElement {
return ( return (
<> <>
<Helmet defer={false}> <Helmet defer={false}>
<title>{ props.notificationsCount ? `(${String(props.notificationsCount) }) `: '' }Landscape - Notifications</title> <title>{ notificationsCount ? `(${String(notificationsCount) }) `: '' }Landscape - Notifications</title>
</Helmet> </Helmet>
<Body> <Body>
<Col overflowY="hidden" height="100%"> <Col overflowY="hidden" height="100%">
@ -110,7 +114,6 @@ export default function NotificationsScreen(props: any): ReactElement {
id="groups" id="groups"
label="Filter Groups" label="Filter Groups"
caption="Only show notifications from this group" caption="Only show notifications from this group"
associations={props.associations}
/> />
</FormikOnBlur> </FormikOnBlur>
</Col> </Col>

View File

@ -21,6 +21,7 @@ import { ImageInput } from '~/views/components/ImageInput';
import { MarkdownField } from '~/views/apps/publish/components/MarkdownField'; import { MarkdownField } from '~/views/apps/publish/components/MarkdownField';
import { resourceFromPath } from '~/logic/lib/group'; import { resourceFromPath } from '~/logic/lib/group';
import GroupSearch from '~/views/components/GroupSearch'; import GroupSearch from '~/views/components/GroupSearch';
import useContactState from '~/logic/state/contact';
import { import {
ProfileHeader, ProfileHeader,
ProfileControls, ProfileControls,
@ -48,7 +49,7 @@ const emptyContact = {
}; };
export function ProfileHeaderImageEdit(props: any): ReactElement { export function ProfileHeaderImageEdit(props: any): ReactElement {
const { contact, storage, setFieldValue, handleHideCover } = { ...props }; const { contact, setFieldValue, handleHideCover } = props;
const [editCover, setEditCover] = useState(false); const [editCover, setEditCover] = useState(false);
const [removedCoverLabel, setRemovedCoverLabel] = useState('Remove Header'); const [removedCoverLabel, setRemovedCoverLabel] = useState('Remove Header');
const handleClear = (e) => { const handleClear = (e) => {
@ -63,7 +64,7 @@ export function ProfileHeaderImageEdit(props: any): ReactElement {
{contact?.cover ? ( {contact?.cover ? (
<div> <div>
{editCover ? ( {editCover ? (
<ImageInput id='cover' storage={storage} marginTop='-8px' /> <ImageInput id='cover' marginTop='-8px' />
) : ( ) : (
<Row> <Row>
<Button mr='2' onClick={() => setEditCover(true)}> <Button mr='2' onClick={() => setEditCover(true)}>
@ -76,14 +77,15 @@ export function ProfileHeaderImageEdit(props: any): ReactElement {
)} )}
</div> </div>
) : ( ) : (
<ImageInput id='cover' storage={storage} marginTop='-8px' /> <ImageInput id='cover' marginTop='-8px' />
)} )}
</> </>
); );
} }
export function EditProfile(props: any): ReactElement { export function EditProfile(props: any): ReactElement {
const { contact, storage, ship, api, isPublic } = props; const { contact, ship, api } = props;
const isPublic = useContactState((state) => state.isContactPublic);
const [hideCover, setHideCover] = useState(false); const [hideCover, setHideCover] = useState(false);
const handleHideCover = (value) => { const handleHideCover = (value) => {
@ -148,7 +150,7 @@ export function EditProfile(props: any): ReactElement {
<Form width='100%' height='100%'> <Form width='100%' height='100%'>
<ProfileHeader> <ProfileHeader>
<ProfileControls> <ProfileControls>
<Row> <Row alignItems='baseline'>
<Button <Button
type='submit' type='submit'
display='inline' display='inline'
@ -176,10 +178,13 @@ export function EditProfile(props: any): ReactElement {
</Row> </Row>
<ProfileStatus contact={contact} /> <ProfileStatus contact={contact} />
</ProfileControls> </ProfileControls>
<ProfileImages hideCover={hideCover} contact={contact} ship={ship}> <ProfileImages
hideCover={hideCover}
contact={contact}
ship={ship}
>
<ProfileHeaderImageEdit <ProfileHeaderImageEdit
contact={contact} contact={contact}
storage={storage}
setFieldValue={setFieldValue} setFieldValue={setFieldValue}
handleHideCover={handleHideCover} handleHideCover={handleHideCover}
/> />
@ -193,23 +198,16 @@ export function EditProfile(props: any): ReactElement {
<ImageInput <ImageInput
id='avatar' id='avatar'
label='Overlay Avatar (may be hidden by other users)' label='Overlay Avatar (may be hidden by other users)'
storage={storage}
/> />
</Col> </Col>
</Row> </Row>
<Input id='nickname' label='Custom Name' mb={3} /> <Input id='nickname' label='Custom Name' mb={3} />
<Col width='100%'> <Col width='100%'>
<Text mb={2}>Description</Text> <Text mb={2}>Description</Text>
<MarkdownField id='bio' mb={3} storage={storage} /> <MarkdownField id='bio' mb={3} />
</Col> </Col>
<Checkbox mb={3} id='isPublic' label='Public Profile' /> <Checkbox mb={3} id='isPublic' label='Public Profile' />
<GroupSearch <GroupSearch label='Pinned Groups' id='groups' publicOnly />
label='Pinned Groups'
id='groups'
groups={props.groups}
associations={props.associations}
publicOnly
/>
<AsyncButton primary loadingText='Updating...' border mt={3}> <AsyncButton primary loadingText='Updating...' border mt={3}>
Submit Submit
</AsyncButton> </AsyncButton>

View File

@ -9,13 +9,14 @@ import { EditProfile } from './EditProfile';
import { SetStatusBarModal } from '~/views/components/SetStatusBarModal'; import { SetStatusBarModal } from '~/views/components/SetStatusBarModal';
import { uxToHex } from '~/logic/lib/util'; import { uxToHex } from '~/logic/lib/util';
import { useTutorialModal } from '~/views/components/useTutorialModal'; import { useTutorialModal } from '~/views/components/useTutorialModal';
import useContactState from '~/logic/state/contact';
export function ProfileHeader(props: any): ReactElement { export function ProfileHeader(props: any): ReactElement {
return ( return (
<Box <Box
border='1px solid' border='1px solid'
borderColor='lightGray' borderColor='washedGray'
borderRadius='2' borderRadius='3'
overflow='hidden' overflow='hidden'
marginBottom='calc(64px + 2rem)' marginBottom='calc(64px + 2rem)'
> >
@ -39,6 +40,7 @@ export function ProfileImages(props: any): ReactElement {
src={contact.cover} src={contact.cover}
width='100%' width='100%'
height='100%' height='100%'
referrerPolicy="no-referrer"
style={{ objectFit: 'cover' }} style={{ objectFit: 'cover' }}
/> />
) : ( ) : (
@ -56,6 +58,7 @@ export function ProfileImages(props: any): ReactElement {
src={contact.avatar} src={contact.avatar}
width='100%' width='100%'
height='100%' height='100%'
referrerPolicy="no-referrer"
style={{ objectFit: 'cover' }} style={{ objectFit: 'cover' }}
/> />
) : ( ) : (
@ -64,7 +67,7 @@ export function ProfileImages(props: any): ReactElement {
return ( return (
<> <>
<Row ref={anchorRef} width='100%' height='300px' position='relative'> <Row ref={anchorRef} width='100%' height='400px' position='relative'>
{cover} {cover}
<Center position='absolute' width='100%' height='100%'> <Center position='absolute' width='100%' height='100%'>
{props.children} {props.children}
@ -73,7 +76,7 @@ export function ProfileImages(props: any): ReactElement {
<Box <Box
height='128px' height='128px'
width='128px' width='128px'
borderRadius='2' borderRadius='3'
overflow='hidden' overflow='hidden'
position='absolute' position='absolute'
left='50%' left='50%'
@ -109,6 +112,7 @@ export function ProfileStatus(props: any): ReactElement {
display='inline-block' display='inline-block'
verticalAlign='middle' verticalAlign='middle'
color='gray' color='gray'
title={contact?.status ?? ''}
> >
{contact?.status ?? ''} {contact?.status ?? ''}
</RichText> </RichText>
@ -157,10 +161,12 @@ export function ProfileActions(props: any): ReactElement {
); );
} }
export function Profile(props: any): ReactElement { export function Profile(props: any): ReactElement | null {
const { hideAvatars } = useSettingsState(selectCalmState);
const history = useHistory(); const history = useHistory();
const nackedContacts = useContactState(state => state.nackedContacts);
const { contact, nackedContacts, hasLoaded, isPublic, isEdit, ship } = props; const { contact, hasLoaded, isEdit, ship } = props;
const nacked = nackedContacts.has(ship); const nacked = nackedContacts.has(ship);
const formRef = useRef(null); const formRef = useRef(null);
@ -183,21 +189,14 @@ export function Profile(props: any): ReactElement {
<EditProfile <EditProfile
ship={ship} ship={ship}
contact={contact} contact={contact}
storage={props.storage}
api={props.api} api={props.api}
groups={props.groups}
associations={props.associations}
isPublic={isPublic}
/> />
) : ( ) : (
<ViewProfile <ViewProfile
nacked={nacked} nacked={nacked}
ship={ship} ship={ship}
contact={contact}
isPublic={isPublic}
api={props.api} api={props.api}
groups={props.groups} contact={contact}
associations={props.associations}
/> />
)} )}
</Box> </Box>

View File

@ -2,17 +2,19 @@ import React, {
useState, useState,
useCallback, useCallback,
useEffect, useEffect,
ChangeEvent ChangeEvent,
useRef
} from 'react'; } from 'react';
import { import {
Row, Row,
Text,
Button, Button,
StatelessTextInput as Input StatelessTextInput as Input
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
export function SetStatus(props: any) { export function SetStatus(props: any) {
const { contact, ship, api, callback } = props; const { contact, ship, api, callback } = props;
const inputRef = useRef(null);
const [_status, setStatus] = useState(''); const [_status, setStatus] = useState('');
const onStatusChange = useCallback( const onStatusChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => { (e: ChangeEvent<HTMLInputElement>) => {
@ -27,19 +29,20 @@ export function SetStatus(props: any) {
const editStatus = () => { const editStatus = () => {
api.contacts.edit(ship, { status: _status }); api.contacts.edit(ship, { status: _status });
inputRef.current.blur();
if (callback) { if (callback) {
callback(); callback();
} }
}; };
return ( return (
<Row width="100%" my={3}> <Row width='100%' my={3}>
<Input <Input
ref={inputRef}
onChange={onStatusChange} onChange={onStatusChange}
value={_status} value={_status}
autocomplete="off" autocomplete='off'
width="75%" width='75%'
mr={2} mr={2}
onKeyPress={(evt) => { onKeyPress={(evt) => {
if (evt.key === 'Enter') { if (evt.key === 'Enter') {
@ -47,16 +50,9 @@ export function SetStatus(props: any) {
} }
}} }}
/> />
<Button <Button primary color='white' ml={2} width='25%' onClick={editStatus}>
primary
color="white"
ml={2}
width="25%"
onClick={editStatus}
>
Set Status Set Status
</Button> </Button>
</Row> </Row>
); );
} }

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { ReactElement } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { Center, Box, Text, Row, Col } from '@tlon/indigo-react'; import { Center, Box, Text, Row, Col } from '@tlon/indigo-react';
@ -15,11 +15,13 @@ import {
ProfileStatus, ProfileStatus,
ProfileImages ProfileImages
} from './Profile'; } from './Profile';
import useContactState from '~/logic/state/contact';
export function ViewProfile(props: any) { export function ViewProfile(props: any): ReactElement {
const history = useHistory();
const { hideNicknames } = useSettingsState(selectCalmState); const { hideNicknames } = useSettingsState(selectCalmState);
const { api, contact, nacked, isPublic, ship, associations, groups } = props; const { api, contact, nacked, ship } = props;
const isPublic = useContactState(state => state.isContactPublic);
return ( return (
<> <>
@ -37,7 +39,7 @@ export function ViewProfile(props: any) {
</ProfileHeader> </ProfileHeader>
<Row pb={2} alignItems='center' width='100%'> <Row pb={2} alignItems='center' width='100%'>
<Center width='100%'> <Center width='100%'>
<Text> <Text fontWeight='500'>
{!hideNicknames && contact?.nickname ? contact.nickname : ''} {!hideNicknames && contact?.nickname ? contact.nickname : ''}
</Text> </Text>
</Center> </Center>
@ -49,7 +51,7 @@ export function ViewProfile(props: any) {
</Text> </Text>
</Center> </Center>
</Row> </Row>
<Col pb={2} alignItems='center' justifyContent='center' width='100%'> <Col pb={2} mt='3' alignItems='center' justifyContent='center' width='100%'>
<Center flexDirection='column' maxWidth='32rem'> <Center flexDirection='column' maxWidth='32rem'>
<RichText width='100%' disableRemoteContent> <RichText width='100%' disableRemoteContent>
{contact?.bio ? contact.bio : ''} {contact?.bio ? contact.bio : ''}
@ -64,8 +66,6 @@ export function ViewProfile(props: any) {
<GroupLink <GroupLink
api={api} api={api}
resource={g} resource={g}
groups={groups}
associations={associations}
measure={() => {}} measure={() => {}}
/> />
))} ))}

View File

@ -4,16 +4,19 @@ import Helmet from 'react-helmet';
import { Box } from '@tlon/indigo-react'; import { Box } from '@tlon/indigo-react';
import { Profile } from "./components/Profile"; import { Profile } from './components/Profile';
import useContactState from '~/logic/state/contact';
import useHarkState from '~/logic/state/hark';
export default function ProfileScreen(props: any) { export default function ProfileScreen(props: any) {
const { dark } = props; const contacts = useContactState(state => state.contacts);
const notificationsCount = useHarkState(state => state.notificationsCount);
return ( return (
<> <>
<Helmet defer={false}> <Helmet defer={false}>
<title> <title>
{props.notificationsCount {notificationsCount
? `(${String(props.notificationsCount)}) ` ? `(${String(notificationsCount)}) `
: ''} : ''}
Landscape - Profile Landscape - Profile
</title> </title>
@ -23,8 +26,7 @@ export default function ProfileScreen(props: any) {
render={({ match }) => { render={({ match }) => {
const ship = match.params.ship; const ship = match.params.ship;
const isEdit = match.url.includes('edit'); const isEdit = match.url.includes('edit');
const isPublic = props.isContactPublic; const contact = contacts?.[ship];
const contact = props.contacts?.[ship];
return ( return (
<Box height='100%' px={[0, 3]} pb={[0, 3]} borderRadius={2}> <Box height='100%' px={[0, 3]} pb={[0, 3]} borderRadius={2}>
@ -41,15 +43,10 @@ export default function ProfileScreen(props: any) {
<Box> <Box>
<Profile <Profile
ship={ship} ship={ship}
hasLoaded={Object.keys(props.contacts).length !== 0} hasLoaded={Object.keys(contacts).length !== 0}
associations={props.associations}
groups={props.groups}
contact={contact} contact={contact}
api={props.api} api={props.api}
storage={props.storage}
isEdit={isEdit} isEdit={isEdit}
isPublic={isPublic}
nackedContacts={props.nackedContacts}
/> />
</Box> </Box>
</Box> </Box>

View File

@ -24,18 +24,12 @@ export function PublishResource(props: PublishResourceProps) {
api={api} api={api}
ship={ship} ship={ship}
book={book} book={book}
contacts={props.contacts}
groups={props.groups}
associations={props.associations}
association={association} association={association}
rootUrl={baseUrl} rootUrl={baseUrl}
baseUrl={`${baseUrl}/resource/publish/ship/${ship}/${book}`} baseUrl={`${baseUrl}/resource/publish/ship/${ship}/${book}`}
history={props.history} history={props.history}
match={props.match} match={props.match}
location={props.location} location={props.location}
unreads={props.unreads}
graphs={props.graphs}
storage={props.storage}
/> />
</Box> </Box>
); );

View File

@ -9,7 +9,6 @@ import { PostFormSchema, PostForm } from './NoteForm';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { getLatestRevision, editPost } from '~/logic/lib/publish'; import { getLatestRevision, editPost } from '~/logic/lib/publish';
import { useWaitForProps } from '~/logic/lib/useWaitForProps'; import { useWaitForProps } from '~/logic/lib/useWaitForProps';
import { StorageState } from '~/types';
interface EditPostProps { interface EditPostProps {
ship: string; ship: string;
@ -17,11 +16,10 @@ interface EditPostProps {
note: GraphNode; note: GraphNode;
api: GlobalApi; api: GlobalApi;
book: string; book: string;
storage: StorageState;
} }
export function EditPost(props: EditPostProps & RouteComponentProps): ReactElement { export function EditPost(props: EditPostProps & RouteComponentProps): ReactElement {
const { note, book, noteId, api, ship, history, storage } = props; const { note, book, noteId, api, ship, history } = props;
const [revNum, title, body] = getLatestRevision(note); const [revNum, title, body] = getLatestRevision(note);
const location = useLocation(); const location = useLocation();
@ -58,7 +56,6 @@ export function EditPost(props: EditPostProps & RouteComponentProps): ReactEleme
cancel cancel
history={history} history={history}
onSubmit={onSubmit} onSubmit={onSubmit}
storage={storage}
submitLabel="Update" submitLabel="Update"
loadingText="Updating..." loadingText="Updating..."
/> />

View File

@ -28,7 +28,6 @@ interface MarkdownEditorProps {
value: string; value: string;
onChange: (s: string) => void; onChange: (s: string) => void;
onBlur?: (e: any) => void; onBlur?: (e: any) => void;
storage: StorageState;
} }
const PromptIfDirty = () => { const PromptIfDirty = () => {
@ -74,7 +73,7 @@ export function MarkdownEditor(
[onBlur] [onBlur]
); );
const { uploadDefault, canUpload } = useStorage(props.storage); const { uploadDefault, canUpload } = useStorage();
const onFileDrag = useCallback( const onFileDrag = useCallback(
async (files: FileList | File[], e: DragEvent) => { async (files: FileList | File[], e: DragEvent) => {

View File

@ -6,7 +6,6 @@ import { MarkdownEditor } from './MarkdownEditor';
export const MarkdownField = ({ export const MarkdownField = ({
id, id,
storage,
...rest ...rest
}: { id: string } & Parameters<typeof Box>[0]) => { }: { id: string } & Parameters<typeof Box>[0]) => {
const [{ value, onBlur }, { error, touched }, { setValue }] = useField(id); const [{ value, onBlur }, { error, touched }, { setValue }] = useField(id);
@ -36,7 +35,6 @@ export const MarkdownField = ({
onBlur={handleBlur} onBlur={handleBlur}
value={value} value={value}
onChange={setValue} onChange={setValue}
storage={storage}
/> />
<ErrorLabel mt="2" hasError={Boolean(error && touched)}> <ErrorLabel mt="2" hasError={Boolean(error && touched)}>
{error} {error}

View File

@ -22,7 +22,6 @@ interface MetadataFormProps {
host: string; host: string;
book: string; book: string;
association: Association; association: Association;
contacts: Contacts;
api: GlobalApi; api: GlobalApi;
} }

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Box, Text, Col, Anchor } from '@tlon/indigo-react'; import { Box, Text, Col, Anchor, Row } from '@tlon/indigo-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import bigInt from 'big-integer'; import bigInt from 'big-integer';
@ -9,6 +9,7 @@ import { Comments } from '~/views/components/Comments';
import { NoteNavigation } from './NoteNavigation'; import { NoteNavigation } from './NoteNavigation';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { getLatestRevision, getComments } from '~/logic/lib/publish'; import { getLatestRevision, getComments } from '~/logic/lib/publish';
import { roleForShip } from '~/logic/lib/group';
import Author from '~/views/components/Author'; import Author from '~/views/components/Author';
import { Contacts, GraphNode, Graph, Association, Unreads, Group } from '@urbit/api'; import { Contacts, GraphNode, Graph, Association, Unreads, Group } from '@urbit/api';
@ -16,10 +17,8 @@ interface NoteProps {
ship: string; ship: string;
book: string; book: string;
note: GraphNode; note: GraphNode;
unreads: Unreads;
association: Association; association: Association;
notebook: Graph; notebook: Graph;
contacts: Contacts;
api: GlobalApi; api: GlobalApi;
rootUrl: string; rootUrl: string;
baseUrl: string; baseUrl: string;
@ -29,7 +28,7 @@ interface NoteProps {
export function Note(props: NoteProps & RouteComponentProps) { export function Note(props: NoteProps & RouteComponentProps) {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const { notebook, note, contacts, ship, book, api, rootUrl, baseUrl, group } = props; const { notebook, note, ship, book, api, rootUrl, baseUrl, group } = props;
const editCommentId = props.match.params.commentId; const editCommentId = props.match.params.commentId;
const renderers = { const renderers = {
@ -56,29 +55,37 @@ export function Note(props: NoteProps & RouteComponentProps) {
api.hark.markEachAsRead(props.association, '/',`/${index[1]}/1/1`, 'note', 'publish'); api.hark.markEachAsRead(props.association, '/',`/${index[1]}/1/1`, 'note', 'publish');
}, [props.association, props.note]); }, [props.association, props.note]);
let adminLinks: JSX.Element | null = null; let adminLinks: JSX.Element[] = [];
const ourRole = roleForShip(group, window.ship);
if (window.ship === note?.post?.author) { if (window.ship === note?.post?.author) {
adminLinks = ( adminLinks.push(
<Box display="inline-block" verticalAlign="middle"> <Link
<Link to={`${baseUrl}/edit`}> style={{ 'display': 'inline-block' }}
to={`${baseUrl}/edit`}
>
<Text <Text
color="green" color="blue"
ml={2} ml={2}
> >
Update Update
</Text> </Text>
</Link> </Link>
)
};
if (window.ship === note?.post?.author || ourRole === "admin") {
adminLinks.push(
<Text <Text
color="red" color="red"
display='inline-block'
ml={2} ml={2}
onClick={deletePost} onClick={deletePost}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
Delete Delete
</Text> </Text>
</Box> )
); };
}
const windowRef = React.useRef(null); const windowRef = React.useRef(null);
useEffect(() => { useEffect(() => {
@ -105,14 +112,15 @@ export function Note(props: NoteProps & RouteComponentProps) {
</Link> </Link>
<Col> <Col>
<Text display="block" mb={2}>{title || ''}</Text> <Text display="block" mb={2}>{title || ''}</Text>
<Box display="flex"> <Row alignItems="center">
<Author <Author
showImage
ship={post?.author} ship={post?.author}
contacts={contacts}
date={post?.['time-sent']} date={post?.['time-sent']}
group={group}
/> />
<Text ml={2}>{adminLinks}</Text> <Text ml={1}>{adminLinks}</Text>
</Box> </Row>
</Col> </Col>
<Box color="black" className="md" style={{ overflowWrap: 'break-word', overflow: 'hidden' }}> <Box color="black" className="md" style={{ overflowWrap: 'break-word', overflow: 'hidden' }}>
<ReactMarkdown source={body} linkTarget={'_blank'} renderers={renderers} /> <ReactMarkdown source={body} linkTarget={'_blank'} renderers={renderers} />
@ -126,9 +134,7 @@ export function Note(props: NoteProps & RouteComponentProps) {
<Comments <Comments
ship={ship} ship={ship}
name={props.book} name={props.book}
unreads={props.unreads}
comments={comments} comments={comments}
contacts={props.contacts}
association={props.association} association={props.association}
api={props.api} api={props.api}
baseUrl={baseUrl} baseUrl={baseUrl}

View File

@ -9,7 +9,6 @@ import {
import { AsyncButton } from '../../../components/AsyncButton'; import { AsyncButton } from '../../../components/AsyncButton';
import { Formik, Form, FormikHelpers } from 'formik'; import { Formik, Form, FormikHelpers } from 'formik';
import { MarkdownField } from './MarkdownField'; import { MarkdownField } from './MarkdownField';
import { StorageState } from '~/types';
interface PostFormProps { interface PostFormProps {
initial: PostFormSchema; initial: PostFormSchema;
@ -21,7 +20,6 @@ interface PostFormProps {
) => Promise<any>; ) => Promise<any>;
submitLabel: string; submitLabel: string;
loadingText: string; loadingText: string;
storage: StorageState;
} }
const formSchema = Yup.object({ const formSchema = Yup.object({
@ -35,7 +33,7 @@ export interface PostFormSchema {
} }
export function PostForm(props: PostFormProps) { export function PostForm(props: PostFormProps) {
const { initial, onSubmit, submitLabel, loadingText, storage, cancel, history } = props; const { initial, onSubmit, submitLabel, loadingText, cancel, history } = props;
return ( return (
<Col width="100%" height="100%" p={[2, 4]}> <Col width="100%" height="100%" p={[2, 4]}>
@ -67,7 +65,7 @@ export function PostForm(props: PostFormProps) {
>Cancel</Button>} >Cancel</Button>}
</Row> </Row>
</Row> </Row>
<MarkdownField flexGrow={1} id="body" storage={storage} /> <MarkdownField flexGrow={1} id="body" />
</Form> </Form>
</Formik> </Formik>
</Col> </Col>

View File

@ -12,17 +12,14 @@ import {
getSnippet getSnippet
} from '~/logic/lib/publish'; } from '~/logic/lib/publish';
import { Unreads } from '@urbit/api'; import { Unreads } from '@urbit/api';
import GlobalApi from '~/logic/api/global';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import useHarkState from '~/logic/state/hark';
interface NotePreviewProps { interface NotePreviewProps {
host: string; host: string;
book: string; book: string;
node: GraphNode; node: GraphNode;
baseUrl: string; baseUrl: string;
unreads: Unreads;
contacts: Contacts;
api: GlobalApi;
group: Group; group: Group;
} }
@ -31,7 +28,7 @@ const WrappedBox = styled(Box)`
`; `;
export function NotePreview(props: NotePreviewProps) { export function NotePreview(props: NotePreviewProps) {
const { node, contacts, group } = props; const { node, group } = props;
const { post } = node; const { post } = node;
if (!post) { if (!post) {
return null; return null;
@ -43,11 +40,12 @@ export function NotePreview(props: NotePreviewProps) {
const [rev, title, body, content] = getLatestRevision(node); const [rev, title, body, content] = getLatestRevision(node);
const appPath = `/ship/${props.host}/${props.book}`; const appPath = `/ship/${props.host}/${props.book}`;
const isUnread = props.unreads.graph?.[appPath]?.['/']?.unreads?.has(`/${noteId}/1/1`); const unreads = useHarkState(state => state.unreads);
const isUnread = unreads.graph?.[appPath]?.['/']?.unreads?.has(`/${noteId}/1/1`);
const snippet = getSnippet(body); const snippet = getSnippet(body);
const commColor = (props.unreads.graph?.[appPath]?.[`/${noteId}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray'; const commColor = (unreads.graph?.[appPath]?.[`/${noteId}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray';
const cursorStyle = post.pending ? 'default' : 'pointer'; const cursorStyle = post.pending ? 'default' : 'pointer';
@ -92,12 +90,10 @@ export function NotePreview(props: NotePreviewProps) {
<Row minWidth='0' flexShrink={0} width="100%" justifyContent="space-between" py={3} bg="white"> <Row minWidth='0' flexShrink={0} width="100%" justifyContent="space-between" py={3} bg="white">
<Author <Author
showImage showImage
contacts={contacts}
ship={post?.author} ship={post?.author}
date={post?.['time-sent']} date={post?.['time-sent']}
group={group} group={group}
unread={isUnread} unread={isUnread}
api={props.api}
/> />
<Box ml="auto" mr={1}> <Box ml="auto" mr={1}>
<Link to={url}> <Link to={url}>

View File

@ -15,13 +15,11 @@ interface NoteRoutesProps {
note: GraphNode; note: GraphNode;
noteId: number; noteId: number;
notebook: Graph; notebook: Graph;
contacts: Contacts;
api: GlobalApi; api: GlobalApi;
association: Association; association: Association;
baseUrl?: string; baseUrl?: string;
rootUrl?: string; rootUrl?: string;
group: Group; group: Group
storage: StorageState;
} }
export function NoteRoutes(props: NoteRoutesProps & RouteComponentProps) { export function NoteRoutes(props: NoteRoutesProps & RouteComponentProps) {

View File

@ -5,45 +5,42 @@ import { Col, Box, Text, Row } from '@tlon/indigo-react';
import { Contacts, Rolodex, Groups, Associations, Graph, Association, Unreads } from '@urbit/api'; import { Contacts, Rolodex, Groups, Associations, Graph, Association, Unreads } from '@urbit/api';
import { NotebookPosts } from './NotebookPosts'; import { NotebookPosts } from './NotebookPosts';
import GlobalApi from '~/logic/api/global';
import { useShowNickname } from '~/logic/lib/util'; import { useShowNickname } from '~/logic/lib/util';
import useContactState from '~/logic/state/contact';
import useGroupState from '~/logic/state/group';
interface NotebookProps { interface NotebookProps {
api: GlobalApi;
ship: string; ship: string;
book: string; book: string;
graph: Graph; graph: Graph;
association: Association; association: Association;
associations: Associations;
contacts: Rolodex;
groups: Groups;
baseUrl: string; baseUrl: string;
rootUrl: string; rootUrl: string;
unreads: Unreads; unreads: Unreads;
} }
export function Notebook(props: NotebookProps & RouteComponentProps): ReactElement { export function Notebook(props: NotebookProps & RouteComponentProps): ReactElement | null {
const { const {
ship, ship,
book, book,
contacts,
groups,
association, association,
graph graph
} = props; } = props;
const group = groups[association?.group]; const groups = useGroupState(state => state.groups);
if (!group) { const contacts = useContactState(state => state.contacts);
return null; // Waiting on groups to populate
}
const group = groups[association?.group];
const relativePath = (p: string) => props.baseUrl + p; const relativePath = (p: string) => props.baseUrl + p;
const contact = contacts?.[`~${ship}`]; const contact = contacts?.[`~${ship}`];
console.log(association.resource);
const showNickname = useShowNickname(contact); const showNickname = useShowNickname(contact);
if (!group) {
return null; // Waiting on groups to populate
}
return ( return (
<Col gapY="4" pt={4} mx="auto" px={3} maxWidth="768px"> <Col gapY="4" pt={4} mx="auto" px={3} maxWidth="768px">
<Row justifyContent="space-between"> <Row justifyContent="space-between">
@ -60,10 +57,7 @@ export function Notebook(props: NotebookProps & RouteComponentProps): ReactEleme
graph={graph} graph={graph}
host={ship} host={ship}
book={book} book={book}
contacts={contacts}
unreads={props.unreads}
baseUrl={props.baseUrl} baseUrl={props.baseUrl}
api={props.api}
group={group} group={group}
/> />
</Col> </Col>

View File

@ -2,21 +2,20 @@ import React, { Component } from 'react';
import { Col } from '@tlon/indigo-react'; import { Col } from '@tlon/indigo-react';
import { NotePreview } from './NotePreview'; import { NotePreview } from './NotePreview';
import { Contacts, Graph, Unreads, Group } from '@urbit/api'; import { Contacts, Graph, Unreads, Group } from '@urbit/api';
import useContactState from '~/logic/state/contact';
interface NotebookPostsProps { interface NotebookPostsProps {
contacts: Contacts;
graph: Graph; graph: Graph;
host: string; host: string;
book: string; book: string;
baseUrl: string; baseUrl: string;
unreads: Unreads;
hideAvatars?: boolean; hideAvatars?: boolean;
hideNicknames?: boolean; hideNicknames?: boolean;
api: GlobalApi;
group: Group; group: Group;
} }
export function NotebookPosts(props: NotebookPostsProps) { export function NotebookPosts(props: NotebookPostsProps) {
const contacts = useContactState(state => state.contacts);
return ( return (
<Col> <Col>
{Array.from(props.graph || []).map( {Array.from(props.graph || []).map(
@ -26,12 +25,9 @@ export function NotebookPosts(props: NotebookPostsProps) {
key={date.toString()} key={date.toString()}
host={props.host} host={props.host}
book={props.book} book={props.book}
unreads={props.unreads} contact={contacts[`~${node.post.author}`]}
contact={props.contacts[`~${node.post.author}`]}
contacts={props.contacts}
node={node} node={node}
baseUrl={props.baseUrl} baseUrl={props.baseUrl}
api={props.api}
group={props.group} group={props.group}
/> />
) )

View File

@ -17,32 +17,32 @@ import bigInt from 'big-integer';
import Notebook from './Notebook'; import Notebook from './Notebook';
import NewPost from './new-post'; import NewPost from './new-post';
import { NoteRoutes } from './NoteRoutes'; import { NoteRoutes } from './NoteRoutes';
import useGraphState from '~/logic/state/graph';
import useGroupState from '~/logic/state/group';
interface NotebookRoutesProps { interface NotebookRoutesProps {
api: GlobalApi; api: GlobalApi;
ship: string; ship: string;
book: string; book: string;
graphs: Graphs;
unreads: Unreads;
contacts: Rolodex;
groups: Groups;
baseUrl: string; baseUrl: string;
rootUrl: string; rootUrl: string;
association: Association; association: Association;
associations: Associations;
storage: StorageState;
} }
export function NotebookRoutes( export function NotebookRoutes(
props: NotebookRoutesProps & RouteComponentProps props: NotebookRoutesProps & RouteComponentProps
) { ) {
const { ship, book, api, contacts, baseUrl, rootUrl, groups } = props; const { ship, book, api, baseUrl, rootUrl } = props;
useEffect(() => { useEffect(() => {
ship && book && api.graph.getGraph(ship, book); ship && book && api.graph.getGraph(ship, book);
}, [ship, book]); }, [ship, book]);
const graph = props.graphs[`${ship.slice(1)}/${book}`]; const graphs = useGraphState(state => state.graphs);
const graph = graphs[`${ship.slice(1)}/${book}`];
const groups = useGroupState(state => state.groups);
const group = groups?.[props.association?.group]; const group = groups?.[props.association?.group];
@ -59,7 +59,6 @@ export function NotebookRoutes(
return <Notebook return <Notebook
{...props} {...props}
graph={graph} graph={graph}
contacts={contacts}
association={props.association} association={props.association}
rootUrl={rootUrl} rootUrl={rootUrl}
baseUrl={baseUrl} baseUrl={baseUrl}
@ -77,7 +76,6 @@ export function NotebookRoutes(
association={props.association} association={props.association}
graph={graph} graph={graph}
baseUrl={baseUrl} baseUrl={baseUrl}
storage={props.storage}
/> />
)} )}
/> />
@ -104,12 +102,9 @@ export function NotebookRoutes(
ship={ship} ship={ship}
note={note} note={note}
notebook={graph} notebook={graph}
unreads={props.unreads}
noteId={noteIdNum} noteId={noteIdNum}
contacts={contacts}
association={props.association} association={props.association}
group={group} group={group}
storage={props.storage}
{...routeProps} {...routeProps}
/> />
); );

View File

@ -7,7 +7,7 @@ import { AsyncButton } from '~/views/components/AsyncButton';
export class Writers extends Component { export class Writers extends Component {
render() { render() {
const { association, groups, contacts, api } = this.props; const { association, groups, api } = this.props;
const resource = resourceFromPath(association?.group); const resource = resourceFromPath(association?.group);
@ -39,8 +39,6 @@ export class Writers extends Component {
> >
<Form> <Form>
<ShipSearch <ShipSearch
groups={groups}
contacts={contacts}
id="ships" id="ships"
label="" label=""
maxLength={undefined} maxLength={undefined}

View File

@ -17,7 +17,6 @@ interface NewPostProps {
graph: Graph; graph: Graph;
association: Association; association: Association;
baseUrl: string; baseUrl: string;
storage: StorageState;
} }
export default function NewPost(props: NewPostProps & RouteComponentProps) { export default function NewPost(props: NewPostProps & RouteComponentProps) {
@ -51,7 +50,6 @@ export default function NewPost(props: NewPostProps & RouteComponentProps) {
onSubmit={onSubmit} onSubmit={onSubmit}
submitLabel="Publish" submitLabel="Publish"
loadingText="Posting..." loadingText="Posting..."
storage={props.storage}
/> />
); );
} }

View File

@ -142,6 +142,10 @@
margin-bottom: 16px; margin-bottom: 16px;
} }
.md ul ul {
margin-bottom: 0px;
}
.md h2, .md h3, .md h4, .md h5, .md p, .md a, .md ul { .md h2, .md h3, .md h4, .md h5, .md p, .md a, .md ul {
font-weight: 400; font-weight: 400;
} }

View File

@ -4,8 +4,16 @@ import { Text } from '@tlon/indigo-react';
export function BackButton(props: {}) { export function BackButton(props: {}) {
return ( return (
<Link to="/~settings"> <Link to='/~settings'>
<Text display={["block", "none"]} fontSize="2" fontWeight="medium">{"<- Back to System Preferences"}</Text> <Text
display={['block', 'none']}
fontSize='2'
fontWeight='medium'
p={4}
pb={0}
>
{'<- Back to System Preferences'}
</Text>
</Link> </Link>
); );
} }

View File

@ -20,13 +20,11 @@ export function BackgroundPicker({
bgType, bgType,
bgUrl, bgUrl,
api, api,
storage
}: { }: {
bgType: BgType; bgType: BgType;
bgUrl?: string; bgUrl?: string;
api: GlobalApi; api: GlobalApi;
storage: StorageState; }): ReactElement {
}) {
const rowSpace = { my: 0, alignItems: 'center' }; const rowSpace = { my: 0, alignItems: 'center' };
const colProps = { my: 3, mr: 4, gapY: 1 }; const colProps = { my: 3, mr: 4, gapY: 1 };
return ( return (
@ -39,7 +37,6 @@ export function BackgroundPicker({
<ImageInput <ImageInput
ml="5" ml="5"
api={api} api={api}
storage={storage}
id="bgUrl" id="bgUrl"
placeholder="Drop or upload a file, or paste a link here" placeholder="Drop or upload a file, or paste a link here"
name="bgUrl" name="bgUrl"

View File

@ -54,10 +54,10 @@ export function CalmPrefs(props: {
hideUnreads, hideUnreads,
hideGroups, hideGroups,
hideUtilities, hideUtilities,
imageShown, imageShown: !imageShown,
videoShown, videoShown: !videoShown,
oembedShown, oembedShown: !oembedShown,
audioShown, audioShown: !audioShown
}; };
const onSubmit = useCallback(async (v: FormSchema, actions: FormikHelpers<FormSchema>) => { const onSubmit = useCallback(async (v: FormSchema, actions: FormikHelpers<FormSchema>) => {
@ -67,10 +67,10 @@ export function CalmPrefs(props: {
api.settings.putEntry('calm', 'hideUnreads', v.hideUnreads), api.settings.putEntry('calm', 'hideUnreads', v.hideUnreads),
api.settings.putEntry('calm', 'hideGroups', v.hideGroups), api.settings.putEntry('calm', 'hideGroups', v.hideGroups),
api.settings.putEntry('calm', 'hideUtilities', v.hideUtilities), api.settings.putEntry('calm', 'hideUtilities', v.hideUtilities),
api.settings.putEntry('remoteContentPolicy', 'imageShown', v.imageShown), api.settings.putEntry('remoteContentPolicy', 'imageShown', !v.imageShown),
api.settings.putEntry('remoteContentPolicy', 'videoShown', v.videoShown), api.settings.putEntry('remoteContentPolicy', 'videoShown', !v.videoShown),
api.settings.putEntry('remoteContentPolicy', 'audioShown', v.audioShown), api.settings.putEntry('remoteContentPolicy', 'audioShown', !v.audioShown),
api.settings.putEntry('remoteContentPolicy', 'oembedShown', v.oembedShown), api.settings.putEntry('remoteContentPolicy', 'oembedShown', !v.oembedShown),
]); ]);
actions.setStatus({ success: null }); actions.setStatus({ success: null });
}, [api]); }, [api]);
@ -115,24 +115,24 @@ export function CalmPrefs(props: {
id="hideNicknames" id="hideNicknames"
caption="Do not show user-set nicknames" caption="Do not show user-set nicknames"
/> />
<Text fontWeight="medium">Remote Content</Text> <Text fontWeight="medium">Remote content</Text>
<Toggle <Toggle
label="Load images" label="Disable images"
id="imageShown" id="imageShown"
caption="Images will be replaced with an inline placeholder that must be clicked to be viewed" caption="Images will be replaced with an inline placeholder that must be clicked to be viewed"
/> />
<Toggle <Toggle
label="Load audio files" label="Disable audio files"
id="audioShown" id="audioShown"
caption="Audio content will be replaced with an inline placeholder that must be clicked to be viewed" caption="Audio content will be replaced with an inline placeholder that must be clicked to be viewed"
/> />
<Toggle <Toggle
label="Load video files" label="Disable video files"
id="videoShown" id="videoShown"
caption="Video content will be replaced with an inline placeholder that must be clicked to be viewed" caption="Video content will be replaced with an inline placeholder that must be clicked to be viewed"
/> />
<Toggle <Toggle
label="Load embedded content" label="Disable embedded content"
id="oembedShown" id="oembedShown"
caption="Embedded content may contain scripts that can track you" caption="Embedded content may contain scripts that can track you"
/> />

View File

@ -0,0 +1,101 @@
import { BaseInput, Box, Col, Text } from "@tlon/indigo-react";
import _ from "lodash";
import React, { useCallback, useState } from "react";
import { UseStore } from "zustand";
import { BaseState } from "~/logic/state/base";
import useContactState from "~/logic/state/contact";
import useGraphState from "~/logic/state/graph";
import useGroupState from "~/logic/state/group";
import useHarkState from "~/logic/state/hark";
import useInviteState from "~/logic/state/invite";
import useLaunchState from "~/logic/state/launch";
import useMetadataState from "~/logic/state/metadata";
import useSettingsState from "~/logic/state/settings";
import useStorageState from "~/logic/state/storage";
import { BackButton } from "./BackButton";
interface StoreDebuggerProps {
name: string;
useStore: UseStore<BaseState<any>>;
}
const objectToString = (obj: any): string => JSON.stringify(obj, null, ' ');
const StoreDebugger = (props: StoreDebuggerProps) => {
const name = props.name;
const state = props.useStore();
const [filter, setFilter] = useState('');
const [text, setText] = useState(objectToString(state));
const [visible, setVisible] = useState(false);
const tryFilter = useCallback((filterToTry) => {
let output: any = false;
try {
output = _.get(state, filterToTry, undefined);
} catch (e) { }
if (output) {
console.log(output);
setText(objectToString(output));
setFilter(filterToTry);
}
}, [state, filter, text]);
return (
<Box p={1}>
<Text cursor="pointer" onClick={() => setVisible(!visible)}>{name}</Text>
{visible && <Box>
<BaseInput
position="sticky"
top={0}
my={1}
p={2}
backgroundColor='white'
color='black'
border='1px solid transparent'
borderRadius='2'
fontSize={1}
placeholder="Drill Down"
width="100%"
onKeyUp={event => {
if (event.target.value) {
tryFilter(event.target.value);
} else {
setFilter('');
setText(objectToString(state));
}
}} />
<Text mono p='1' borderRadius='1' display='block' overflow='auto' backgroundColor='washedGray' style={{ whiteSpace: 'pre', wordWrap: 'break-word' }}>{text}</Text>
</Box>}
</Box>
);
};
const DebugPane = () => {
return (
<>
<BackButton />
<Col borderBottom="1" borderBottomColor="washedGray" p="5" pt="4" gapY="5">
<Col gapY="1" mt="0">
<Text color="black" fontSize={2} fontWeight="medium">
Debug Menu
</Text>
<Text gray>
Debug Landscape state. Click any state to see its contents and drill down.
</Text>
</Col>
<StoreDebugger name="Contacts" useStore={useContactState} />
<StoreDebugger name="Graph" useStore={useGraphState} />
<StoreDebugger name="Group" useStore={useGroupState} />
<StoreDebugger name="Hark" useStore={useHarkState} />
<StoreDebugger name="Invite" useStore={useInviteState} />
<StoreDebugger name="Launch" useStore={useLaunchState} />
<StoreDebugger name="Metadata" useStore={useMetadataState} />
<StoreDebugger name="Settings" useStore={useSettingsState} />
<StoreDebugger name="Storage" useStore={useStorageState} />
</Col>
</>
)
};
export default DebugPane;

View File

@ -36,13 +36,12 @@ interface FormSchema {
interface DisplayFormProps { interface DisplayFormProps {
api: GlobalApi; api: GlobalApi;
storage: StorageState;
} }
const settingsSel = selectSettingsState(["display"]); const settingsSel = selectSettingsState(["display"]);
export default function DisplayForm(props: DisplayFormProps) { export default function DisplayForm(props: DisplayFormProps) {
const { api, storage } = props; const { api } = props;
const { const {
display: { display: {
@ -108,7 +107,6 @@ export default function DisplayForm(props: DisplayFormProps) {
bgType={props.values.bgType} bgType={props.values.bgType}
bgUrl={props.values.bgUrl} bgUrl={props.values.bgUrl}
api={api} api={api}
storage={storage}
/> />
<Label>Theme</Label> <Label>Theme</Label>
<Radio name="theme" id="light" label="Light"/> <Radio name="theme" id="light" label="Light"/>

View File

@ -4,11 +4,12 @@ import {
Text, Text,
ManagedToggleSwitchField as Toggle, ManagedToggleSwitchField as Toggle,
} from "@tlon/indigo-react"; } from "@tlon/indigo-react";
import { Form, FormikHelpers } from "formik"; import { Formik, Form, FormikHelpers } from "formik";
import { FormikOnBlur } from "~/views/components/FormikOnBlur";
import { BackButton } from "./BackButton"; import { BackButton } from "./BackButton";
import GlobalApi from "~/logic/api/global"; import GlobalApi from "~/logic/api/global";
import {NotificationGraphConfig} from "~/types"; import useHarkState from "~/logic/state/hark";
import _ from "lodash";
import {AsyncButton} from "~/views/components/AsyncButton";
interface FormSchema { interface FormSchema {
mentions: boolean; mentions: boolean;
@ -18,10 +19,10 @@ interface FormSchema {
export function NotificationPreferences(props: { export function NotificationPreferences(props: {
api: GlobalApi; api: GlobalApi;
graphConfig: NotificationGraphConfig;
dnd: boolean;
}) { }) {
const { graphConfig, api, dnd } = props; const { api } = props;
const dnd = useHarkState(state => state.doNotDisturb);
const graphConfig = useHarkState(state => state.notificationsGraphConfig);
const initialValues = { const initialValues = {
mentions: graphConfig.mentions, mentions: graphConfig.mentions,
dnd: dnd, dnd: dnd,
@ -43,12 +44,11 @@ export function NotificationPreferences(props: {
await Promise.all(promises); await Promise.all(promises);
actions.setStatus({ success: null }); actions.setStatus({ success: null });
actions.resetForm({ values: initialValues });
} catch (e) { } catch (e) {
console.error(e); console.error(e);
actions.setStatus({ error: e.message }); actions.setStatus({ error: e.message });
} }
}, [api]); }, [api, graphConfig, dnd]);
return ( return (
<> <>
@ -63,7 +63,7 @@ export function NotificationPreferences(props: {
messaging messaging
</Text> </Text>
</Col> </Col>
<FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}> <Formik initialValues={initialValues} onSubmit={onSubmit}>
<Form> <Form>
<Col gapY="4"> <Col gapY="4">
<Toggle <Toggle
@ -81,9 +81,12 @@ export function NotificationPreferences(props: {
id="mentions" id="mentions"
caption="Notify me if someone mentions my @p in a channel I've joined" caption="Notify me if someone mentions my @p in a channel I've joined"
/> />
<AsyncButton primary width="fit-content">
Save
</AsyncButton>
</Col> </Col>
</Form> </Form>
</FormikOnBlur> </Formik>
</Col> </Col>
</> </>
); );

View File

@ -1,85 +0,0 @@
import React from 'react';
import {
Box,
Button,
ManagedCheckboxField as Checkbox
} from '@tlon/indigo-react';
import { Formik, Form } from 'formik';
import * as Yup from 'yup';
import GlobalApi from '~/logic/api/global';
import useSettingsState, {selectSettingsState} from '~/logic/state/settings';
const formSchema = Yup.object().shape({
imageShown: Yup.boolean(),
audioShown: Yup.boolean(),
videoShown: Yup.boolean(),
oembedShown: Yup.boolean()
});
interface FormSchema {
imageShown: boolean;
audioShown: boolean;
videoShown: boolean;
oembedShown: boolean;
}
interface RemoteContentFormProps {
api: GlobalApi;
}
const selState = selectSettingsState(['remoteContentPolicy', 'set']);
export default function RemoteContentForm(props: RemoteContentFormProps) {
const { api } = props;
const { remoteContentPolicy, set: setRemoteContentPolicy} = useSettingsState(selState);
const imageShown = remoteContentPolicy.imageShown;
const audioShown = remoteContentPolicy.audioShown;
const videoShown = remoteContentPolicy.videoShown;
const oembedShown = remoteContentPolicy.oembedShown;
return (
<Formik
validationSchema={formSchema}
initialValues={
{
imageShown,
audioShown,
videoShown,
oembedShown
} as FormSchema
}
onSubmit={(values, actions) => {
setRemoteContentPolicy((state) => {
Object.assign(state.remoteContentPolicy, values);
});
actions.setSubmitting(false);
}}
>
{props => (
<Form>
<Box
display="grid"
gridTemplateColumns="1fr"
gridTemplateRows="audio"
gridRowGap={5}
>
<Box color="black" fontSize={1} fontWeight={900}>
Remote Content
</Box>
<Checkbox label="Load images" id="imageShown" />
<Checkbox label="Load audio files" id="audioShown" />
<Checkbox label="Load video files" id="videoShown" />
<Checkbox
label="Load embedded content"
id="oembedShown"
caption="Embedded content may contain scripts"
/>
<Button style={{ cursor: 'pointer' }} border={1} borderColor="washedGray" type="submit">
Save
</Button>
</Box>
</Form>
)}
</Formik>
);
}

View File

@ -1,5 +1,5 @@
import React, { ReactElement, useCallback } from 'react'; import React, { ReactElement, useCallback } from 'react';
import { Formik } from 'formik'; import { Formik, FormikHelpers } from 'formik';
import { import {
ManagedTextInputField as Input, ManagedTextInputField as Input,
@ -10,12 +10,15 @@ import {
Col, Col,
Anchor Anchor
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
import { AsyncButton } from "~/views/components/AsyncButton";
import GlobalApi from "~/logic/api/global"; import GlobalApi from '~/logic/api/global';
import { BucketList } from "./BucketList"; import { BucketList } from './BucketList';
import { S3State } from '~/types/s3-update'; import { S3State } from '~/types/s3-update';
import useS3State from '~/logic/state/storage';
import { BackButton } from './BackButton'; import { BackButton } from './BackButton';
import { StorageState } from '~/types'; import { StorageState } from '~/types';
import useStorageState from '~/logic/state/storage';
interface FormSchema { interface FormSchema {
s3bucket: string; s3bucket: string;
@ -27,33 +30,33 @@ interface FormSchema {
interface S3FormProps { interface S3FormProps {
api: GlobalApi; api: GlobalApi;
storage: StorageState;
} }
export default function S3Form(props: S3FormProps): ReactElement { export default function S3Form(props: S3FormProps): ReactElement {
const { api, storage } = props; const { api } = props;
const { s3 } = storage; const s3 = useStorageState((state) => state.s3);
const onSubmit = useCallback( const onSubmit = useCallback(async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
(values: FormSchema) => {
if (values.s3secretAccessKey !== s3.credentials?.secretAccessKey) { if (values.s3secretAccessKey !== s3.credentials?.secretAccessKey) {
api.s3.setSecretAccessKey(values.s3secretAccessKey); await api.s3.setSecretAccessKey(values.s3secretAccessKey);
} }
if (values.s3endpoint !== s3.credentials?.endpoint) { if (values.s3endpoint !== s3.credentials?.endpoint) {
api.s3.setEndpoint(values.s3endpoint); await api.s3.setEndpoint(values.s3endpoint);
} }
if (values.s3accessKeyId !== s3.credentials?.accessKeyId) { if (values.s3accessKeyId !== s3.credentials?.accessKeyId) {
api.s3.setAccessKeyId(values.s3accessKeyId); await api.s3.setAccessKeyId(values.s3accessKeyId);
} }
actions.setStatus({ success: null });
}, },
[api, s3] [api, s3]
); );
return ( return (
<> <>
<Col p="5" pt="4" borderBottom="1" borderBottomColor="washedGray"> <BackButton />
<Col p='5' pt='4' borderBottom='1' borderBottomColor='washedGray'>
<Formik <Formik
initialValues={ initialValues={
{ {
@ -67,42 +70,42 @@ export default function S3Form(props: S3FormProps): ReactElement {
onSubmit={onSubmit} onSubmit={onSubmit}
> >
<Form> <Form>
<BackButton/> <Col maxWidth='600px' gapY='5'>
<Col maxWidth="600px" gapY="5"> <Col gapY='1' mt='0'>
<Col gapY="1" mt="0"> <Text color='black' fontSize={2} fontWeight='medium'>
<Text color="black" fontSize={2} fontWeight="medium">
S3 Storage Setup S3 Storage Setup
</Text> </Text>
<Text gray> <Text gray>
Store credentials for your S3 object storage buckets on your Store credentials for your S3 object storage buckets on your
Urbit ship, and upload media freely to various modules. Urbit ship, and upload media freely to various modules.
<Anchor <Anchor
target="_blank" target='_blank'
style={{ textDecoration: 'none' }} style={{ textDecoration: 'none' }}
borderBottom="1" borderBottom='1'
ml="1" ml='1'
href="https://urbit.org/using/operations/using-your-ship/#bucket-setup"> href='https://urbit.org/using/operations/using-your-ship/#bucket-setup'
>
Learn more Learn more
</Anchor> </Anchor>
</Text> </Text>
</Col> </Col>
<Input label="Endpoint" id="s3endpoint" /> <Input label='Endpoint' id='s3endpoint' />
<Input label="Access Key ID" id="s3accessKeyId" /> <Input label='Access Key ID' id='s3accessKeyId' />
<Input <Input
type="password" type='password'
label="Secret Access Key" label='Secret Access Key'
id="s3secretAccessKey" id='s3secretAccessKey'
/> />
<Button style={{ cursor: "pointer" }} type="submit"> <AsyncButton primary style={{ cursor: 'pointer' }} type='submit'>
Submit Submit
</Button> </AsyncButton>
</Col> </Col>
</Form> </Form>
</Formik> </Formik>
</Col> </Col>
<Col maxWidth="600px" p="5" gapY="4"> <Col maxWidth='600px' p='5' gapY='4'>
<Col gapY="1"> <Col gapY='1'>
<Text color="black" mb={4} fontSize={2} fontWeight="medium"> <Text color='black' mb={4} fontSize={2} fontWeight='medium'>
S3 Buckets S3 Buckets
</Text> </Text>
<Text gray> <Text gray>

View File

@ -7,7 +7,6 @@ import { StoreState } from "~/logic/store/type";
import DisplayForm from "./lib/DisplayForm"; import DisplayForm from "./lib/DisplayForm";
import S3Form from "./lib/S3Form"; import S3Form from "./lib/S3Form";
import SecuritySettings from "./lib/Security"; import SecuritySettings from "./lib/Security";
import RemoteContentForm from "./lib/RemoteContent";
import { NotificationPreferences } from "./lib/NotificationPref"; import { NotificationPreferences } from "./lib/NotificationPref";
import { CalmPrefs } from "./lib/CalmPref"; import { CalmPrefs } from "./lib/CalmPref";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";

View File

@ -1,35 +1,44 @@
import React, { ReactNode } from "react"; import React, { ReactNode, useEffect } from 'react';
import { useLocation } from "react-router-dom"; import { useLocation } from 'react-router-dom';
import Helmet from "react-helmet"; import Helmet from 'react-helmet';
import { Text, Box, Col, Row } from '@tlon/indigo-react'; import { Text, Box, Col, Row } from '@tlon/indigo-react';
import { NotificationPreferences } from "./components/lib/NotificationPref"; import { NotificationPreferences } from './components/lib/NotificationPref';
import DisplayForm from "./components/lib/DisplayForm"; import DisplayForm from './components/lib/DisplayForm';
import S3Form from "./components/lib/S3Form"; import S3Form from './components/lib/S3Form';
import { CalmPrefs } from "./components/lib/CalmPref"; import { CalmPrefs } from './components/lib/CalmPref';
import SecuritySettings from "./components/lib/Security"; import SecuritySettings from './components/lib/Security';
import { LeapSettings } from "./components/lib/LeapSettings"; import { LeapSettings } from './components/lib/LeapSettings';
import { useHashLink } from "~/logic/lib/useHashLink"; import { useHashLink } from '~/logic/lib/useHashLink';
import { SidebarItem as BaseSidebarItem } from "~/views/landscape/components/SidebarItem"; import { SidebarItem as BaseSidebarItem } from '~/views/landscape/components/SidebarItem';
import { PropFunc } from "~/types"; import { PropFunc } from '~/types';
import DebugPane from './components/lib/Debug';
import useHarkState from '~/logic/state/hark';
export const Skeleton = (props: { children: ReactNode }) => ( export const Skeleton = (props: { children: ReactNode }) => (
<Box height="100%" width="100%" px={[0, 3]} pb={[0, 3]} borderRadius={1}> <Box height='100%' width='100%' px={[0, 3]} pb={[0, 3]} borderRadius={1}>
<Box <Box
height="100%" display='grid'
width="100%" gridTemplateColumns={[
borderRadius={1} '100%',
bg="white" 'minmax(150px, 1fr) 3fr',
'minmax(250px, 1fr) 4fr'
]}
gridTemplateRows='100%'
height='100%'
width='100%'
borderRadius={2}
bg='white'
border={1} border={1}
borderColor="washedGray" borderColor='washedGray'
> >
{props.children} {props.children}
</Box> </Box>
</Box> </Box>
); );
type ProvSideProps = "to" | "selected"; type ProvSideProps = 'to' | 'selected';
type BaseProps = PropFunc<typeof BaseSidebarItem>; type BaseProps = PropFunc<typeof BaseSidebarItem>;
function SidebarItem(props: { hash: string } & Omit<BaseProps, ProvSideProps>) { function SidebarItem(props: { hash: string } & Omit<BaseProps, ProvSideProps>) {
const { hash, icon, text, ...rest } = props; const { hash, icon, text, ...rest } = props;
@ -54,85 +63,81 @@ function SettingsItem(props: { children: ReactNode }) {
const { children } = props; const { children } = props;
return ( return (
<Box borderBottom="1" borderBottomColor="washedGray"> <Box borderBottom='1' borderBottomColor='washedGray'>
{children} {children}
</Box> </Box>
); );
} }
export default function SettingsScreen(props: any) { export default function SettingsScreen(props: any) {
const location = useLocation(); const location = useLocation();
const hash = location.hash.slice(1) const hash = location.hash.slice(1);
const notificationsCount = useHarkState(state => state.notificationsCount);
useEffect(() => {
const debugShower = (event) => {
if (hash) return;
if (event.key === '~') {
window.location.hash = 'debug';
}
};
document.addEventListener('keyup', debugShower);
return () => {
document.removeEventListener('keyup', debugShower);
}
}, [hash]);
return ( return (
<> <>
<Helmet defer={false}> <Helmet defer={false}>
<title>Landscape - Settings</title> <title>{ notificationsCount ? `(${String(notificationsCount) }) `: '' }Landscape - Settings</title>
</Helmet> </Helmet>
<Skeleton> <Skeleton>
<Row height="100%" overflow="hidden">
<Col <Col
height="100%" height='100%'
borderRight="1" borderRight='1'
borderRightColor="washedGray" borderRightColor='washedGray'
display={hash === "" ? "flex" : ["none", "flex"]} display={hash === '' ? 'flex' : ['none', 'flex']}
minWidth="250px" width='100%'
width="100%" overflowY='auto'
maxWidth={["100vw", "350px"]}
>
<Text
display="block"
my="4"
mx="3"
fontSize="2"
fontWeight="medium"
> >
<Text display='block' mt='4' mb='3' mx='3' fontSize='2' fontWeight='700'>
System Preferences System Preferences
</Text> </Text>
<Col gapY="1"> <Col>
<SidebarItem <SidebarItem
icon="Inbox" icon='Inbox'
text="Notifications" text='Notifications'
hash="notifications" hash='notifications'
/> />
<SidebarItem icon="Image" text="Display" hash="display" /> <SidebarItem icon='Image' text='Display' hash='display' />
<SidebarItem icon="Upload" text="Remote Storage" hash="s3" /> <SidebarItem icon='Upload' text='Remote Storage' hash='s3' />
<SidebarItem icon="LeapArrow" text="Leap" hash="leap" /> <SidebarItem icon='LeapArrow' text='Leap' hash='leap' />
<SidebarItem icon="Node" text="CalmEngine" hash="calm" /> <SidebarItem icon='Node' text='CalmEngine' hash='calm' />
<SidebarItem <SidebarItem
icon="Locked" icon='Locked'
text="Devices + Security" text='Devices + Security'
hash="security" hash='security'
/> />
</Col> </Col>
</Col> </Col>
<Col flexGrow={1} overflowY="auto"> <Col flexGrow={1} overflowY='auto'>
<SettingsItem> <SettingsItem>
{hash === "notifications" && ( {hash === 'notifications' && (
<NotificationPreferences <NotificationPreferences
{...props} {...props}
graphConfig={props.notificationsGraphConfig} graphConfig={props.notificationsGraphConfig}
/> />
)} )}
{hash === "display" && ( {hash === 'display' && <DisplayForm api={props.api} />}
<DisplayForm storage={props.storage} api={props.api} /> {hash === 's3' && <S3Form api={props.api} />}
)} {hash === 'leap' && <LeapSettings api={props.api} />}
{hash === "s3" && ( {hash === 'calm' && <CalmPrefs api={props.api} />}
<S3Form storage={props.storage} api={props.api} /> {hash === 'security' && <SecuritySettings api={props.api} />}
)} {hash === 'debug' && <DebugPane />}
{hash === "leap" && (
<LeapSettings api={props.api} />
)}
{hash === "calm" && (
<CalmPrefs api={props.api} />
)}
{hash === "security" && (
<SecuritySettings api={props.api} />
)}
</SettingsItem> </SettingsItem>
</Col> </Col>
</Row>
</Skeleton> </Skeleton>
</> </>
); );

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