diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b7b9542271..166bc72afa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,7 +32,29 @@ 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: urbit: diff --git a/bin/solid.pill b/bin/solid.pill index 3df1568804..039d1675f3 100644 --- a/bin/solid.pill +++ b/bin/solid.pill @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a8b19cbe89f770f8d6c1e05972be7a3a01545b93b0f2d4523809e7df18635f3c -size 9462938 +oid sha256:59285407abdc63642ff71384d922f63f4b2c82b3a0daa3673a861c97c59e292f +size 9729397 diff --git a/pkg/arvo/README.md b/pkg/arvo/README.md index 3074d5ba78..206e47dffc 100644 --- a/pkg/arvo/README.md +++ b/pkg/arvo/README.md @@ -45,17 +45,16 @@ Most parts of Arvo have dedicated maintainers. * `/sys/vane/ames`: @belisarius222 (~rovnys-ricfer) & @philipcmonk (~wicdev-wisryt) * `/sys/vane/behn`: @belisarius222 (~rovnys-ricfer) * `/sys/vane/clay`: @philipcmonk (~wicdev-wisryt) & @belisarius222 (~rovnys-ricfer) -* `/sys/vane/dill`: @joemfb (~master-morzod) -* `/sys/vane/eyre`: @eglaysher (~littel-ponnys) +* `/sys/vane/dill`: @fang- (~palfun-foslup) +* `/sys/vane/eyre`: @fang- (~palfun-foslup) * `/sys/vane/gall`: @philipcmonk (~wicdev-wisryt) * `/sys/vane/jael`: @fang- (~palfun-foslup) & @philipcmonk (~wicdev-wisryt) * `/app/acme`: @joemfb (~master-morzod) * `/app/dns`: @joemfb (~master-morzod) * `/app/aqua`: @philipcmonk (~wicdev-wisryt) * `/app/hood`: @belisarius222 (~rovnys-ricfer) -* `/lib/hood/drum`: @philipcmonk (~wicdev-wisryt) +* `/lib/hood/drum`: @fang- (~palfun-foslup) * `/lib/hood/kiln`: @philipcmonk (~wicdev-wisryt) -* `/lib/test`: @eglaysher (~littel-ponnys) ## Contributing diff --git a/pkg/arvo/app/glob.hoon b/pkg/arvo/app/glob.hoon index 22e1985ca4..ae3b984489 100644 --- a/pkg/arvo/app/glob.hoon +++ b/pkg/arvo/app/glob.hoon @@ -5,7 +5,7 @@ /- glob /+ 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))] +$ all-states $% state-0 diff --git a/pkg/arvo/app/hark-store.hoon b/pkg/arvo/app/hark-store.hoon index fd1ab4f965..5f425725c0 100644 --- a/pkg/arvo/app/hark-store.hoon +++ b/pkg/arvo/app/hark-store.hoon @@ -293,7 +293,7 @@ ~(tap by unreads-count) |= [=stats-index:store count=@ud] :* stats-index - ~(wyt in (~(gut by by-index) stats-index ~)) + (~(gut by by-index) stats-index ~) [%count count] (~(gut by last-seen) stats-index *time) == @@ -304,7 +304,7 @@ ~(tap by unreads-each) |= [=stats-index:store indices=(set index:graph-store)] :* stats-index - ~(wyt in (~(gut by by-index) stats-index ~)) + (~(gut by by-index) stats-index ~) [%each indices] (~(gut by last-seen) stats-index *time) == @@ -317,7 +317,7 @@ ~ :- ~ :* stats-index - ~(wyt in nots) + nots [%count 0] *time == diff --git a/pkg/arvo/app/landscape/index.html b/pkg/arvo/app/landscape/index.html index 7ee648afb9..142b4457fb 100644 --- a/pkg/arvo/app/landscape/index.html +++ b/pkg/arvo/app/landscape/index.html @@ -24,6 +24,6 @@
- + diff --git a/pkg/arvo/lib/hark/store.hoon b/pkg/arvo/lib/hark/store.hoon index 0edc658f04..99b2f38df6 100644 --- a/pkg/arvo/lib/hark/store.hoon +++ b/pkg/arvo/lib/hark/store.hoon @@ -151,7 +151,7 @@ ^- json %- pairs :~ unreads+(unread unreads.s) - notifications+(numb notifications.s) + notifications+a+(turn ~(tap in notifications.s) notif-ref) last+(time last-seen.s) == ++ added diff --git a/pkg/arvo/sur/hark-store.hoon b/pkg/arvo/sur/hark-store.hoon index e9e1abbaef..69446ae4d5 100644 --- a/pkg/arvo/sur/hark-store.hoon +++ b/pkg/arvo/sur/hark-store.hoon @@ -150,7 +150,7 @@ [index notification] :: +$ stats - [notifications=@ud =unreads last-seen=@da] + [notifications=(set [time index]) =unreads last-seen=@da] :: +$ unreads $% [%count num=@ud] diff --git a/pkg/interface/config/webpack.dev.js b/pkg/interface/config/webpack.dev.js index 66bf83ce9d..0f85b58a96 100644 --- a/pkg/interface/config/webpack.dev.js +++ b/pkg/interface/config/webpack.dev.js @@ -94,7 +94,11 @@ module.exports = { use: { loader: 'babel-loader', 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: [ '@babel/transform-runtime', '@babel/plugin-proposal-object-rest-spread', diff --git a/pkg/interface/package-lock.json b/pkg/interface/package-lock.json index 5d920b44d7..bf6648ae4d 100644 --- a/pkg/interface/package-lock.json +++ b/pkg/interface/package-lock.json @@ -1783,30 +1783,36 @@ "dependencies": { "@babel/runtime": { "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": { "regenerator-runtime": "^0.13.4" } }, "@types/lodash": { "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": { "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": { "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": { "version": "4.17.20", - "bundled": true + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" }, "regenerator-runtime": { "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" } }, + "@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": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -2071,6 +2086,11 @@ "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.1.tgz", @@ -3974,6 +3994,14 @@ "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": { "version": "1.18.0-next.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0-next.2.tgz", @@ -8743,6 +8771,45 @@ "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": { "version": "1.0.3", "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", "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", "dev": true, + "optional": true, "requires": { "is-extendable": "^0.1.0" } @@ -9921,6 +9989,7 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", "dev": true, + "optional": true, "requires": { "kind-of": "^3.0.2" } diff --git a/pkg/interface/package.json b/pkg/interface/package.json index 5f11a51e4d..ff6dd60dd9 100644 --- a/pkg/interface/package.json +++ b/pkg/interface/package.json @@ -13,6 +13,7 @@ "@tlon/indigo-react": "^1.2.19", "@tlon/sigil-js": "^1.4.3", "@urbit/api": "file:../npm/api", + "any-ascii": "^0.1.7", "aws-sdk": "^2.830.0", "big-integer": "^1.6.48", "classnames": "^2.2.6", @@ -41,6 +42,7 @@ "react-visibility-sensor": "^5.1.1", "remark-breaks": "^2.0.1", "remark-disable-tokenizers": "^1.0.24", + "stacktrace-js": "^2.0.2", "style-loader": "^1.3.0", "styled-components": "^5.1.1", "styled-system": "^5.1.5", @@ -71,6 +73,7 @@ "@types/yup": "^0.29.11", "@typescript-eslint/eslint-plugin": "^4.15.0", "@urbit/eslint-config": "file:../npm/eslint-config", + "@welldone-software/why-did-you-render": "^6.1.0", "babel-eslint": "^10.1.0", "babel-loader": "^8.2.2", "babel-plugin-lodash": "^3.3.4", diff --git a/pkg/interface/src/index.js b/pkg/interface/src/index.js index ded85a8f58..e62b46b2f9 100644 --- a/pkg/interface/src/index.js +++ b/pkg/interface/src/index.js @@ -1,3 +1,4 @@ +import './wdyr'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; diff --git a/pkg/interface/src/logic/api/local.ts b/pkg/interface/src/logic/api/local.ts index e5bad49a3f..305ac71f89 100644 --- a/pkg/interface/src/logic/api/local.ts +++ b/pkg/interface/src/logic/api/local.ts @@ -4,7 +4,7 @@ import { StoreState } from '../store/type'; export default class LocalApi extends BaseApi { getBaseHash() { this.scry('file-server', '/clay/base/hash').then((baseHash) => { - this.store.handleEvent({ data: { local: { baseHash } } }); + this.store.handleEvent({ data: { baseHash } }); }); } diff --git a/pkg/interface/src/logic/api/metadata.ts b/pkg/interface/src/logic/api/metadata.ts index 5389f2e7de..82807dad9c 100644 --- a/pkg/interface/src/logic/api/metadata.ts +++ b/pkg/interface/src/logic/api/metadata.ts @@ -77,7 +77,6 @@ export default class MetadataApi extends BaseApi { tempChannel.delete(); }, (ev: any) => { - console.log(ev); if ('metadata-hook-update' in ev) { done = true; tempChannel.delete(); diff --git a/pkg/interface/src/logic/lib/gcpManager.ts b/pkg/interface/src/logic/lib/gcpManager.ts index 69f157c2c5..4831ffc5c1 100644 --- a/pkg/interface/src/logic/lib/gcpManager.ts +++ b/pkg/interface/src/logic/lib/gcpManager.ts @@ -14,16 +14,14 @@ // // import GlobalApi from '../api/global'; -import GlobalStore from '../store/store'; +import useStorageState from '../state/storage'; class GcpManager { #api: GlobalApi | null = null; - #store: GlobalStore | null = null; - configure(api: GlobalApi, store: GlobalStore) { + configure(api: GlobalApi) { this.#api = api; - this.#store = store; } #running = false; @@ -34,8 +32,8 @@ class GcpManager { console.warn('GcpManager already running'); return; } - if (!this.#api || !this.#store) { - console.error('GcpManager must have api and store set'); + if (!this.#api) { + console.error('GcpManager must have api set'); return; } this.#running = true; @@ -65,7 +63,7 @@ class GcpManager { #consecutiveFailures: number = 0; private isConfigured() { - return this.#store.state.storage.gcp.configured; + return useStorageState.getState().gcp.configured; } private refreshLoop() { @@ -78,7 +76,8 @@ class GcpManager { if (this.isConfigured()) { this.refreshLoop(); } else { - this.refreshAfter(10_000); + console.log('GcpManager: GCP storage not configured; stopping.'); + this.stop(); } }) .catch((reason) => { @@ -89,7 +88,7 @@ class GcpManager { } this.#api.gcp.getToken() .then(() => { - const token = this.#store.state.storage.gcp?.token; + const token = useStorageState.getState().gcp.token; if (token) { this.#consecutiveFailures = 0; const interval = this.refreshInterval(token.expiresIn); diff --git a/pkg/interface/src/logic/lib/migrateSettings.ts b/pkg/interface/src/logic/lib/migrateSettings.ts index bcfe44bfcb..ec841af18f 100644 --- a/pkg/interface/src/logic/lib/migrateSettings.ts +++ b/pkg/interface/src/logic/lib/migrateSettings.ts @@ -18,10 +18,6 @@ export function useMigrateSettings(api: GlobalApi) { const { display, remoteContentPolicy, calm } = useSettingsState(); return async () => { - if (!localStorage?.has("localReducer")) { - return; - } - let promises: Promise[] = []; if (local.hideAvatars !== calm.hideAvatars) { diff --git a/pkg/interface/src/logic/lib/publish.ts b/pkg/interface/src/logic/lib/publish.ts index ce616b3cf5..ec2fc05fd9 100644 --- a/pkg/interface/src/logic/lib/publish.ts +++ b/pkg/interface/src/logic/lib/publish.ts @@ -107,7 +107,9 @@ export function getComments(node: GraphNode): GraphNode { } 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}...`; } - diff --git a/pkg/interface/src/logic/lib/sigil.js b/pkg/interface/src/logic/lib/sigil.js index f2444068ed..e47eff93f1 100644 --- a/pkg/interface/src/logic/lib/sigil.js +++ b/pkg/interface/src/logic/lib/sigil.js @@ -23,9 +23,10 @@ export const Sigil = memo( size, svgClass = '', 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 foregroundColor = foreground ? foreground @@ -34,14 +35,14 @@ export const Sigil = memo( ) : ( Promise; } -const useStorage = ({gcp, s3}: StorageState, - { accept = '*' } = { accept: '*' }): IuseStorage => { +const useStorage = ({ accept = '*' } = { accept: '*' }): IuseStorage => { const [uploading, setUploading] = useState(false); + const gcp = useStorageState(state => state.gcp); + const s3 = useStorageState(state => state.s3); const client = useRef(null); diff --git a/pkg/interface/src/logic/lib/util.ts b/pkg/interface/src/logic/lib/util.ts index d15be08cb3..1aeff63760 100644 --- a/pkg/interface/src/logic/lib/util.ts +++ b/pkg/interface/src/logic/lib/util.ts @@ -1,9 +1,17 @@ import { useEffect, useState } from 'react'; -import _ from "lodash"; -import f, { memoize } from "lodash/fp"; -import bigInt, { BigInteger } from "big-integer"; -import { Contact } from '~/types'; +import _ from 'lodash'; +import f, { compose, memoize } from 'lodash/fp'; +import bigInt, { BigInteger } from 'big-integer'; +import { Association, Contact } from '@urbit/api'; +import useLocalState from '../state/local'; +import produce, { enableMapSet } from 'immer'; 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; @@ -17,7 +25,7 @@ export const MOMENT_CALENDAR_DATE = { }; export const getModuleIcon = (mod: string) => { - if (mod === 'link') { + if (mod === 'link') { return 'Collection'; } return _.capitalize(mod); @@ -50,7 +58,7 @@ export function daToUnix(da: BigInteger) { } export function unixToDa(unix: number) { - const timeSinceEpoch = bigInt(unix).multiply(DA_SECOND).divide(bigInt(1000)); + const timeSinceEpoch = bigInt(unix).multiply(DA_SECOND).divide(bigInt(1000)); return DA_UNIX_EPOCH.add(timeSinceEpoch); } @@ -300,7 +308,7 @@ export function stringToTa(str: string) { export function amOwnerOfGroup(groupPath: string) { if (!groupPath) -return false; + return false; const groupOwner = /(\/~)?\/~([a-z-]{3,})\/.*/.exec(groupPath)?.[2]; return window.ship === groupOwner; } @@ -319,11 +327,12 @@ export function getContactDetails(contact: any) { } export function stringToSymbol(str: string) { + const ascii = anyAscii(str); let result = ''; - for (let i = 0; i < str.length; i++) { - const n = str.charCodeAt(i); + for (let i = 0; i < ascii.length; i++) { + const n = ascii.charCodeAt(i); if ((n >= 97 && n <= 122) || (n >= 48 && n <= 57)) { - result += str[i]; + result += ascii[i]; } else if (n >= 65 && n <= 90) { result += String.fromCharCode(n + 32); } else { @@ -337,7 +346,6 @@ export function stringToSymbol(str: string) { } return result; } - /** * Formats a numbers as a `@ud` inserting dot where needed */ @@ -355,7 +363,7 @@ export function numToUd(num: number) { export function usePreventWindowUnload(shouldPreventDefault: boolean, message = 'You have unsaved changes. Are you sure you want to exit?') { useEffect(() => { if (!shouldPreventDefault) -return; + return; const handleBeforeUnload = (event) => { event.preventDefault(); return message; @@ -371,12 +379,13 @@ return; } export function pluralize(text: string, isPlural = false, vowel = false) { - return isPlural ? `${text}s`: `${vowel ? 'an' : 'a'} ${text}`; + return isPlural ? `${text}s` : `${vowel ? 'an' : 'a'} ${text}`; } // Hide is an optional second parameter for when this function is used in class components 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); } @@ -399,12 +408,13 @@ export const useHovering = (): useHoveringInterface => { const DM_REGEX = /ship\/~([a-z]|-)*\/dm--/; export function getItemTitle(association: Association) { - if(DM_REGEX.test(association.resource)) { - const [,,ship,name] = association.resource.split('/'); - if(ship.slice(1) === window.ship) { + if (DM_REGEX.test(association.resource)) { + const [, , ship, name] = association.resource.split('/'); + if (ship.slice(1) === window.ship) { return cite(`~${name.slice(4)}`); } return cite(ship); } return association.metadata.title || association.resource; } + diff --git a/pkg/interface/src/logic/lib/withState.tsx b/pkg/interface/src/logic/lib/withState.tsx new file mode 100644 index 0000000000..6ada75c616 --- /dev/null +++ b/pkg/interface/src/logic/lib/withState.tsx @@ -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 +>( + useState: UseStore, + 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 + }) +}; + +const withState = < + StateType extends BaseState, + stateKey extends keyof StateType + >( + Component: any, + stores: ([UseStore, 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 + }); +} + +export default withState; \ No newline at end of file diff --git a/pkg/interface/src/logic/reducers/contact-update.ts b/pkg/interface/src/logic/reducers/contact-update.ts index 21b83a0044..160aff708e 100644 --- a/pkg/interface/src/logic/reducers/contact-update.ts +++ b/pkg/interface/src/logic/reducers/contact-update.ts @@ -1,44 +1,51 @@ import _ from 'lodash'; -import { StoreState } from '../../store/type'; -import { Cage } from '~/types/cage'; -import { ContactUpdate } from '@urbit/api/contacts'; -import { resourceAsPath } from '../lib/util'; +import { compose } from 'lodash/fp'; -type ContactState = Pick; +import { ContactUpdate } from '@urbit/api'; -export const ContactReducer = (json, state) => { - const data = _.get(json, 'contact-update', false); +import useContactState, { ContactState } from '../state/contact'; +import { reduceState } from '../state/base'; + + +export const ContactReducer = (json) => { + const data: ContactUpdate = _.get(json, 'contact-update', false); if (data) { - initial(data, state); - add(data, state); - remove(data, state); - edit(data, state); - setPublic(data, state); + reduceState(useContactState, data, [ + initial, + add, + remove, + edit, + setPublic + ]); } // TODO: better isolation const res = _.get(json, 'resource', false); - if(res) { - state.nackedContacts = state.nackedContacts.add(`~${res.ship}`); + if (res) { + 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); if (data) { state.contacts = data.rolodex; 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); if (data) { 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); if ( data && @@ -46,9 +53,10 @@ const remove = (json: ContactUpdate, state: S) => { ) { 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 ship = `~${data.ship}`; if ( @@ -57,7 +65,7 @@ const edit = (json: ContactUpdate, state: S) => { ) { const [field] = Object.keys(data['edit-field']); if (!field) { - return; + return state; } const value = data['edit-field'][field]; @@ -71,10 +79,12 @@ const edit = (json: ContactUpdate, state: S) => { 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); state.isContactPublic = data; + return state; }; diff --git a/pkg/interface/src/logic/reducers/gcp-reducer.ts b/pkg/interface/src/logic/reducers/gcp-reducer.ts index 2900df976d..c7280d6cf8 100644 --- a/pkg/interface/src/logic/reducers/gcp-reducer.ts +++ b/pkg/interface/src/logic/reducers/gcp-reducer.ts @@ -1,37 +1,43 @@ import _ from 'lodash'; import {StoreState} from '../store/type'; 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; - -export default class GcpReducer{ - reduce(json: Cage, state: S) { - this.reduceConfigured(json, state); - this.reduceToken(json, state); - } - - reduceConfigured(json, state) { - let data = json['gcp-configured']; - if (data !== undefined) { - state.storage.gcp.configured = data; - } - } - - reduceToken(json: Cage, state: S) { - let data = json['gcp-token']; - if (data) { - this.setToken(data, state); - } - } - - setToken(data: any, state: S) { - if (this.isToken(data)) { - state.storage.gcp.token = data; - } - } - - isToken(token: any): token is GcpToken { - return (typeof(token.accessKey) === 'string' && - typeof(token.expiresIn) === 'number'); +export default class GcpReducer { + reduce(json: Cage) { + reduceState(useStorageState, json, [ + reduceConfigured, + reduceToken + ]); } } + +const reduceConfigured = (json, state: StorageState): StorageState => { + let data = json['gcp-configured']; + if (data !== undefined) { + state.gcp.configured = data; + } + return state; +} + +const reduceToken = (json: Cage, state: StorageState): StorageState => { + let data = json['gcp-token']; + if (data) { + state = setToken(data, state); + } + return state; +} + +const setToken = (data: any, state: StorageState): StorageState => { + if (isToken(data)) { + state.gcp.token = data; + } + return state; +} + +const isToken = (token: any): boolean => { + return (typeof(token.accessKey) === 'string' && + typeof(token.expiresIn) === 'number'); +} diff --git a/pkg/interface/src/logic/reducers/graph-update.js b/pkg/interface/src/logic/reducers/graph-update.ts similarity index 87% rename from pkg/interface/src/logic/reducers/graph-update.js rename to pkg/interface/src/logic/reducers/graph-update.ts index 6def8b85c4..ef3854cfd4 100644 --- a/pkg/interface/src/logic/reducers/graph-update.js +++ b/pkg/interface/src/logic/reducers/graph-update.ts @@ -1,20 +1,24 @@ import _ from 'lodash'; import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap"; 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); if (data) { - keys(data, state); - addGraph(data, state); - removeGraph(data, state); - addNodes(data, state); - removeNodes(data, state); + reduceState(useGraphState, data, [ + keys, + addGraph, + removeGraph, + addNodes, + removeNodes + ]); } }; -const keys = (json, state) => { +const keys = (json, state: GraphState): GraphState => { const data = _.get(json, 'keys', false); if (data) { state.graphKeys = new Set(data.map((res) => { @@ -22,9 +26,10 @@ const keys = (json, state) => { return resource; })); } + return state; }; -const addGraph = (json, state) => { +const addGraph = (json, state: GraphState): GraphState => { const _processNode = (node) => { // is empty @@ -72,10 +77,10 @@ const addGraph = (json, state) => { } state.graphKeys.add(resource); } - + return state; }; -const removeGraph = (json, state) => { +const removeGraph = (json, state: GraphState): GraphState => { const data = _.get(json, 'remove-graph', false); if (data) { @@ -86,6 +91,7 @@ const removeGraph = (json, state) => { state.graphKeys.delete(resource); delete state.graphs[resource]; } + return state; }; const mapifyChildren = (children) => { @@ -98,7 +104,7 @@ const mapifyChildren = (children) => { }; const addNodes = (json, state) => { - const _addNode = (graph, index, node, resource) => { + const _addNode = (graph, index, node) => { // set child of graph if (index.length === 1) { graph.set(index[0], node); @@ -160,7 +166,7 @@ const addNodes = (json, state) => { const data = _.get(json, 'add-nodes', false); if (data) { - if (!('graphs' in state)) { return; } + if (!('graphs' in state)) { return state; } let resource = data.resource.ship + '/' + data.resource.name; if (!(resource in state.graphs)) { @@ -192,7 +198,7 @@ const addNodes = (json, state) => { return bigInt(ind); }); - if (indexArr.length === 0) { return; } + if (indexArr.length === 0) { return state; } if (node.post.pending) { state.graphTimesentMap[resource][node.post['time-sent']] = index; @@ -210,10 +216,10 @@ const addNodes = (json, state) => { state.graphs[resource] = graph; } + return state; }; - -const removeNodes = (json, state) => { +const removeNodes = (json, state: GraphState): GraphState => { const _remove = (graph, index) => { if (index.length === 1) { graph.delete(index[0]); @@ -230,7 +236,7 @@ const removeNodes = (json, state) => { if (data) { const { ship, name } = data.resource; const res = `${ship}/${name}`; - if (!(res in state.graphs)) { return; } + if (!(res in state.graphs)) { return state; } data.indices.forEach((index) => { if (index.split('/').length === 0) { return; } @@ -240,4 +246,5 @@ const removeNodes = (json, state) => { _remove(state.graphs[res], indexArr); }); } + return state; }; diff --git a/pkg/interface/src/logic/reducers/group-update.ts b/pkg/interface/src/logic/reducers/group-update.ts index 49a620edf2..6b8c25c92d 100644 --- a/pkg/interface/src/logic/reducers/group-update.ts +++ b/pkg/interface/src/logic/reducers/group-update.ts @@ -1,5 +1,4 @@ import _ from 'lodash'; -import { StoreState } from '../../store/type'; import { Cage } from '~/types/cage'; import { GroupUpdate, @@ -14,8 +13,9 @@ import { } from '@urbit/api/groups'; import { Enc, PatpNoSig } from '@urbit/api'; import { resourceAsPath } from '../lib/util'; - -type GroupState = Pick; +import useGroupState, { GroupState } from '../state/group'; +import { compose } from 'lodash/fp'; +import { reduceState } from '../state/base'; function decodeGroup(group: Enc): Group { const members = new Set(group.members); @@ -57,186 +57,195 @@ function decodeTags(tags: Enc): Tags { ); } -export default class GroupReducer { - reduce(json: Cage, state: S) { +export default class GroupReducer { + reduce(json: Cage) { const data = json.groupUpdate; if (data) { - console.log(data); - this.initial(data, state); - this.addMembers(data, state); - this.addTag(data, state); - this.removeMembers(data, state); - this.initialGroup(data, state); - this.removeTag(data, state); - this.initial(data, state); - this.addGroup(data, state); - this.removeGroup(data, state); - this.changePolicy(data, state); - this.expose(data, state); + reduceState(useGroupState, data, [ + initial, + addMembers, + addTag, + removeMembers, + initialGroup, + removeTag, + addGroup, + removeGroup, + changePolicy, + expose, + ]); } } - initial(json: GroupUpdate, state: S) { - const data = json['initial']; - if (data) { - state.groups = _.mapValues(data, decodeGroup); +} +const initial = (json: GroupUpdate, state: GroupState): GroupState => { + const data = json['initial']; + if (data) { + state.groups = _.mapValues(data, decodeGroup); + } + return state; +} + +const initialGroup = (json: GroupUpdate, state: GroupState): GroupState => { + if ('initialGroup' in json) { + const { resource, group } = json.initialGroup; + const path = resourceAsPath(resource); + state.groups[path] = decodeGroup(group); + } + return state; +} + +const addGroup = (json: GroupUpdate, state: GroupState): GroupState => { + if ('addGroup' in json) { + const { resource, policy, hidden } = json.addGroup; + const resourcePath = resourceAsPath(resource); + state.groups[resourcePath] = { + members: new Set(), + tags: { role: { admin: new Set([window.ship]) } }, + policy: decodePolicy(policy), + hidden + }; + } + return state; +} + +const removeGroup = (json: GroupUpdate, state: GroupState): GroupState => { + if('removeGroup' in json) { + const { resource } = json.removeGroup; + const resourcePath = resourceAsPath(resource); + delete state.groups[resourcePath]; + } + return state; +} + +const addMembers = (json: GroupUpdate, state: GroupState): GroupState => { + if ('addMembers' in json) { + const { resource, ships } = json.addMembers; + const resourcePath = resourceAsPath(resource); + for (const member of ships) { + state.groups[resourcePath].members.add(member); + if ( + 'invite' in state.groups[resourcePath].policy && + state.groups[resourcePath].policy.invite.pending.has(member) + ) { + state.groups[resourcePath].policy.invite.pending.delete(member) + } } } + return state; +} - initialGroup(json: GroupUpdate, state: S) { - if ('initialGroup' in json) { - const { resource, group } = json.initialGroup; - const path = resourceAsPath(resource); - state.groups[path] = decodeGroup(group); +const removeMembers = (json: GroupUpdate, state: GroupState): GroupState => { + if ('removeMembers' in json) { + const { resource, ships } = json.removeMembers; + const resourcePath = resourceAsPath(resource); + for (const member of ships) { + state.groups[resourcePath].members.delete(member); } } + return state; +} - addGroup(json: GroupUpdate, state: S) { - if ('addGroup' in json) { - const { resource, policy, hidden } = json.addGroup; - const resourcePath = resourceAsPath(resource); - state.groups[resourcePath] = { - members: new Set(), - tags: { role: { admin: new Set([window.ship]) } }, - policy: decodePolicy(policy), - hidden - }; +const addTag = (json: GroupUpdate, state: GroupState): GroupState => { + if ('addTag' in json) { + const { resource, tag, ships } = json.addTag; + const resourcePath = resourceAsPath(resource); + const tags = state.groups[resourcePath].tags; + const tagAccessors = + 'app' in tag ? [tag.app,tag.resource, tag.tag] : ['role', tag.tag]; + const tagged = _.get(tags, tagAccessors, new Set()); + for (const ship of ships) { + tagged.add(ship); } + _.set(tags, tagAccessors, tagged); } - removeGroup(json: GroupUpdate, state: S) { - if('removeGroup' in json) { - const { resource } = json.removeGroup; - const resourcePath = resourceAsPath(resource); - delete state.groups[resourcePath]; + return state; +} + +const removeTag = (json: GroupUpdate, state: GroupState): GroupState => { + if ('removeTag' in json) { + const { resource, tag, ships } = json.removeTag; + const resourcePath = resourceAsPath(resource); + const tags = state.groups[resourcePath].tags; + const tagAccessors = + 'app' in tag ? [tag.app, tag.resource, tag.tag] : ['role', tag.tag]; + const tagged = _.get(tags, tagAccessors, new Set()); + + if (!tagged) { + return state; } - } - - addMembers(json: GroupUpdate, state: S) { - if ('addMembers' in json) { - const { resource, ships } = json.addMembers; - const resourcePath = resourceAsPath(resource); - for (const member of ships) { - state.groups[resourcePath].members.add(member); - if ( - 'invite' in state.groups[resourcePath].policy && - state.groups[resourcePath].policy.invite.pending.has(member) - ) { - state.groups[resourcePath].policy.invite.pending.delete(member) - } - } + for (const ship of ships) { + tagged.delete(ship); } + _.set(tags, tagAccessors, tagged); } + return state; +} - removeMembers(json: GroupUpdate, state: S) { - if ('removeMembers' in json) { - const { resource, ships } = json.removeMembers; - const resourcePath = resourceAsPath(resource); - for (const member of ships) { - state.groups[resourcePath].members.delete(member); - } - } - } - - addTag(json: GroupUpdate, state: S) { - if ('addTag' in json) { - const { resource, tag, ships } = json.addTag; - const resourcePath = resourceAsPath(resource); - const tags = state.groups[resourcePath].tags; - const tagAccessors = - 'app' in tag ? [tag.app,tag.resource, tag.tag] : ['role', tag.tag]; - const tagged = _.get(tags, tagAccessors, new Set()); - for (const ship of ships) { - tagged.add(ship); - } - _.set(tags, tagAccessors, tagged); - } - } - - removeTag(json: GroupUpdate, state: S) { - if ('removeTag' in json) { - const { resource, tag, ships } = json.removeTag; - const resourcePath = resourceAsPath(resource); - const tags = state.groups[resourcePath].tags; - const tagAccessors = - 'app' in tag ? [tag.app, tag.resource, tag.tag] : ['role', tag.tag]; - const tagged = _.get(tags, tagAccessors, new Set()); - - if (!tagged) { - return; - } - for (const ship of ships) { - tagged.delete(ship); - } - _.set(tags, tagAccessors, tagged); - } - } - - changePolicy(json: GroupUpdate, state: S) { - if ('changePolicy' in json && state) { - const { resource, diff } = json.changePolicy; - const resourcePath = resourceAsPath(resource); - const policy = state.groups[resourcePath].policy; - if ('open' in policy && 'open' in diff) { - this.openChangePolicy(diff.open, policy); - } else if ('invite' in policy && 'invite' in diff) { - this.inviteChangePolicy(diff.invite, policy); - } else if ('replace' in diff) { - state.groups[resourcePath].policy = diff.replace; - } else { - console.log('bad policy diff'); - } - } - } - - expose(json: GroupUpdate, state: S) { - if( 'expose' in json && state) { - const { resource } = json.expose; - const resourcePath = resourceAsPath(resource); - state.groups[resourcePath].hidden = false; - } - } - - private inviteChangePolicy(diff: InvitePolicyDiff, policy: InvitePolicy) { - if ('addInvites' in diff) { - const { addInvites } = diff; - for (const ship of addInvites) { - policy.invite.pending.add(ship); - } - } else if ('removeInvites' in diff) { - const { removeInvites } = diff; - for (const ship of removeInvites) { - policy.invite.pending.delete(ship); - } +const changePolicy = (json: GroupUpdate, state: GroupState): GroupState => { + if ('changePolicy' in json && state) { + const { resource, diff } = json.changePolicy; + const resourcePath = resourceAsPath(resource); + const policy = state.groups[resourcePath].policy; + if ('open' in policy && 'open' in diff) { + openChangePolicy(diff.open, policy); + } else if ('invite' in policy && 'invite' in diff) { + inviteChangePolicy(diff.invite, policy); + } else if ('replace' in diff) { + state.groups[resourcePath].policy = diff.replace; } else { - console.log('bad policy change'); + console.log('bad policy diff'); } } + return state; +} - private openChangePolicy(diff: OpenPolicyDiff, policy: OpenPolicy) { - if ('allowRanks' in diff) { - const { allowRanks } = diff; - for (const rank of allowRanks) { - policy.open.banRanks.delete(rank); - } - } else if ('banRanks' in diff) { - const { banRanks } = diff; - for (const rank of banRanks) { - policy.open.banRanks.delete(rank); - } - } else if ('allowShips' in diff) { - console.log('allowing ships'); - const { allowShips } = diff; - for (const ship of allowShips) { - policy.open.banned.delete(ship); - } - } else if ('banShips' in diff) { - console.log('banning ships'); - const { banShips } = diff; - for (const ship of banShips) { - policy.open.banned.add(ship); - } - } else { - console.log('bad policy change'); +const expose = (json: GroupUpdate, state: GroupState): GroupState => { + if( 'expose' in json && state) { + const { resource } = json.expose; + const resourcePath = resourceAsPath(resource); + state.groups[resourcePath].hidden = false; + } + return state; +} + +const inviteChangePolicy = (diff: InvitePolicyDiff, policy: InvitePolicy) => { + if ('addInvites' in diff) { + const { addInvites } = diff; + for (const ship of addInvites) { + policy.invite.pending.add(ship); } + } else if ('removeInvites' in diff) { + const { removeInvites } = diff; + for (const ship of removeInvites) { + policy.invite.pending.delete(ship); + } + } else { + console.log('bad policy change'); + } +} + +const openChangePolicy = (diff: OpenPolicyDiff, policy: OpenPolicy) => { + if ('allowRanks' in diff) { + const { allowRanks } = diff; + for (const rank of allowRanks) { + policy.open.banRanks.delete(rank); + } + } else if ('banRanks' in diff) { + const { banRanks } = diff; + for (const rank of banRanks) { + policy.open.banRanks.delete(rank); + } + } else if ('allowShips' in diff) { + const { allowShips } = diff; + for (const ship of allowShips) { + policy.open.banned.delete(ship); + } + } else if ('banShips' in diff) { + const { banShips } = diff; + for (const ship of banShips) { + policy.open.banned.add(ship); + } + } else { + console.log('bad policy change'); } } diff --git a/pkg/interface/src/logic/reducers/group-view.ts b/pkg/interface/src/logic/reducers/group-view.ts index dd8763c6d6..b6f5ec5725 100644 --- a/pkg/interface/src/logic/reducers/group-view.ts +++ b/pkg/interface/src/logic/reducers/group-view.ts @@ -1,13 +1,17 @@ +import { GroupUpdate } from '@urbit/api/groups'; 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; if(data) { state.pendingJoin = data; } + return state; }; -const progress = (json: any, state: any) => { +const progress = (json: any, state: GroupState): GroupState => { const data = json.progress; if(data) { const { progress, resource } = data; @@ -18,12 +22,15 @@ const progress = (json: any, state: any) => { }, 10000); } } + return state; }; -export const GroupViewReducer = (json: any, state: any) => { +export const GroupViewReducer = (json: any) => { const data = json['group-view-update']; - if(data) { - progress(data, state); - initial(data, state); + if (data) { + reduceState(useGroupState, data, [ + progress, + initial + ]); } -}; +}; \ No newline at end of file diff --git a/pkg/interface/src/logic/reducers/hark-update.ts b/pkg/interface/src/logic/reducers/hark-update.ts index c2dce2959c..0ba83484ea 100644 --- a/pkg/interface/src/logic/reducers/hark-update.ts +++ b/pkg/interface/src/logic/reducers/hark-update.ts @@ -3,51 +3,94 @@ import { NotifIndex, NotificationGraphConfig, GroupNotificationsConfig, - UnreadStats + UnreadStats, + Timebox } from '@urbit/api'; import { makePatDa } from '~/logic/lib/util'; import _ from 'lodash'; -import { StoreState } from '../store/type'; 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; - -export const HarkReducer = (json: any, state: HarkState) => { +export const HarkReducer = (json: any) => { const data = _.get(json, 'harkUpdate', false); if (data) { - reduce(data, state); + reduce(data); } const graphHookData = _.get(json, 'hark-graph-hook-update', false); if (graphHookData) { - graphInitial(graphHookData, state); - graphIgnore(graphHookData, state); - graphListen(graphHookData, state); - graphWatchSelf(graphHookData, state); - graphMentions(graphHookData, state); + reduceState(useHarkState, graphHookData, [ + graphInitial, + graphIgnore, + graphListen, + graphWatchSelf, + graphMentions, + ]); } const groupHookData = _.get(json, 'hark-group-hook-update', false); if (groupHookData) { - groupInitial(groupHookData, state); - groupListen(groupHookData, state); - groupIgnore(groupHookData, state); + reduceState(useHarkState, groupHookData, [ + groupInitial, + groupListen, + groupIgnore, + ]); } }; -function groupInitial(json: any, state: HarkState) { +function reduce(data) { + reduceState(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); if (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); if (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); if (data) { state.notificationsGraphConfig.watching = [ @@ -55,134 +98,133 @@ function graphListen(json: any, state: HarkState) { data ]; } + return state; } -function graphIgnore(json: any, state: HarkState) { +function graphIgnore(json: any, state: HarkState): HarkState { const data = _.get(json, 'ignore', false); if (data) { state.notificationsGraphConfig.watching = state.notificationsGraphConfig.watching.filter( ({ 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); if (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); if (data) { state.notificationsGroupConfig = state.notificationsGroupConfig.filter( 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); if (!_.isUndefined(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); if (!_.isUndefined(data)) { state.notificationsGraphConfig.watchOnSelf = data; } + return state; } -function reduce(data: any, state: 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) { +function readAll(json: any, state: HarkState): HarkState { const data = _.get(json, 'read-all'); 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'); if(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'); 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'); - if(data) { - updateUnreads(state, data.index, u => u.delete(data.target)); + if (data) { + 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'); 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'); 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'); 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'); if(data) { - clearState(state); + state = clearState(state); data.forEach(({ index, stats }) => { const { unreads, notifications, last } = stats; - updateNotificationStats(state, index, 'notifications', x => x + notifications); updateNotificationStats(state, index, 'last', () => last); + _.each(notifications, ({ time, index }) => { + addNotificationToUnread(state, index, makePatDa(time)); + }); if('count' in unreads) { - updateUnreadCount(state, index, (u = 0) => u + unreads.count); + state = updateUnreadCount(state, index, (u = 0) => u + unreads.count); } else { + state = updateUnreads(state, index, s => new Set()); 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 = { notifications: new BigIntOrderedMap(), archivedNotifications: new BigIntOrderedMap(), @@ -202,73 +244,110 @@ function clearState(state) { Object.keys(initialState).forEach((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)) { - return; + return state; } const property = [index.graph.graph, index.graph.index, 'unreads']; const curr = _.get(state.unreads.graph, property, 0); const newCount = count(curr); _.set(state.unreads.graph, property, newCount); + return state; } -function updateUnreads(state: HarkState, index: NotifIndex, f: (us: Set) => void) { +function updateUnreads(state: HarkState, index: NotifIndex, f: (us: Set) => void): HarkState { if(!('graph' in index)) { - return; + return state; } - const unreads = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], new Set()); - const oldSize = unreads.size; + let unreads = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], new Set()); f(unreads); - const newSize = unreads.size; + _.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) { - if(statField === 'notifications') { - state.notificationsCount = f(state.notificationsCount); - } +function addNotificationToUnread(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))), + { 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) { 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)); } else if('group' in index) { - const curr = _.get(state.unreads.group, [index.group, statField], 0); - _.set(state.unreads.group, [index.group, statField], f(curr)); + const curr = _.get(state.unreads.group, [index.group.group, statField], 0); + _.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); if (data) { const { index, notification } = data; const time = makePatDa(data.time); const timebox = state.notifications.get(time) || []; + addNotificationToUnread(state, index, time); const arrIdx = timebox.findIndex(idxNotif => notifIdxEqual(index, idxNotif.index) ); if (arrIdx !== -1) { - if(timebox[arrIdx]?.notification?.read) { - updateNotificationStats(state, index, 'notifications', x => x+1); - } timebox[arrIdx] = { index, notification }; state.notifications.set(time, timebox); } else { - updateNotificationStats(state, index, 'notifications', x => x+1); 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); if (!_.isUndefined(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); if (data) { const time = makePatDa(data.time); @@ -276,13 +355,15 @@ const timebox = (json: any, state: HarkState) => { 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); if (data) { - _.forEach(data, d => reduce(d, state)); + _.forEach(data, d => reduce(d)); } + return state; } function notifIdxEqual(a: NotifIndex, b: NotifIndex) { @@ -307,51 +388,55 @@ function setRead( index: NotifIndex, read: boolean, state: HarkState -) { +): HarkState { const patDa = makePatDa(time); const timebox = state.notifications.get(patDa); if (_.isNull(timebox)) { console.warn('Modifying nonexistent timebox'); - return; + return state; } const arrIdx = timebox.findIndex(idxNotif => notifIdxEqual(index, idxNotif.index) ); if (arrIdx === -1) { console.warn('Modifying nonexistent index'); - return; + return state; } timebox[arrIdx].notification.read = read; 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); if (data) { const { time, index } = data; - updateNotificationStats(state, index, 'notifications', x => x-1); + removeNotificationFromUnread(state, index, makePatDa(time)); 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); if (data) { const { time, index } = data; - updateNotificationStats(state, index, 'notifications', x => x+1); + addNotificationToUnread(state, index, makePatDa(time)); 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); if (data) { const { index } = data; + removeNotificationFromUnread(state, index, makePatDa(data.time)) const time = makePatDa(data.time); const timebox = state.notifications.get(time); if (!timebox) { console.warn('Modifying nonexistent timebox'); - return; + return state; } const [archived, unarchived] = _.partition(timebox, idxNotif => notifIdxEqual(index, idxNotif.index) @@ -362,7 +447,6 @@ function archive(json: any, state: HarkState) { } else { state.notifications.set(time, unarchived); } - const newlyRead = archived.filter(x => !x.notification.read).length; - updateNotificationStats(state, index, 'notifications', x => x - newlyRead); } + return state; } diff --git a/pkg/interface/src/logic/reducers/invite-update.ts b/pkg/interface/src/logic/reducers/invite-update.ts index c4f472be08..2dff0e740f 100644 --- a/pkg/interface/src/logic/reducers/invite-update.ts +++ b/pkg/interface/src/logic/reducers/invite-update.ts @@ -1,62 +1,72 @@ import _ from 'lodash'; -import { StoreState } from '../../store/type'; -import { Cage } from '~/types/cage'; +import { compose } from 'lodash/fp'; + import { InviteUpdate } from '@urbit/api/invite'; -type InviteState = Pick; +import { Cage } from '~/types/cage'; +import useInviteState, { InviteState } from '../state/invite'; +import { reduceState } from '../state/base'; -export default class InviteReducer { - reduce(json: Cage, state: S) { +export default class InviteReducer { + reduce(json: Cage) { const data = json['invite-update']; if (data) { - this.initial(data, state); - this.create(data, state); - this.delete(data, state); - this.invite(data, state); - this.accepted(data, state); - this.decline(data, state); - } - } - - initial(json: InviteUpdate, state: S) { - const data = _.get(json, 'initial', false); - if (data) { - state.invites = data; - } - } - - create(json: InviteUpdate, state: S) { - const data = _.get(json, 'create', false); - if (data) { - state.invites[data] = {}; - } - } - - delete(json: InviteUpdate, state: S) { - const data = _.get(json, 'delete', false); - if (data) { - delete state.invites[data]; - } - } - - invite(json: InviteUpdate, state: S) { - const data = _.get(json, 'invite', false); - if (data) { - state.invites[data.term][data.uid] = data.invite; - } - } - - accepted(json: InviteUpdate, state: S) { - const data = _.get(json, 'accepted', false); - if (data) { - delete state.invites[data.term][data.uid]; - } - } - - decline(json: InviteUpdate, state: S) { - const data = _.get(json, 'decline', false); - if (data) { - delete state.invites[data.term][data.uid]; + reduceState(useInviteState, data, [ + initial, + create, + deleteInvite, + invite, + accepted, + decline, + ]); } } } + +const initial = (json: InviteUpdate, state: InviteState): InviteState => { + const data = _.get(json, 'initial', false); + if (data) { + state.invites = data; + } + return state; +} + +const create = (json: InviteUpdate, state: InviteState): InviteState => { + const data = _.get(json, 'create', false); + if (data) { + state.invites[data] = {}; + } + return state; +} + +const deleteInvite = (json: InviteUpdate, state: InviteState): InviteState => { + const data = _.get(json, 'delete', false); + if (data) { + delete state.invites[data]; + } + return state; +} + +const invite = (json: InviteUpdate, state: InviteState): InviteState => { + const data = _.get(json, 'invite', false); + if (data) { + state.invites[data.term][data.uid] = data.invite; + } + return state; +} + +const accepted = (json: InviteUpdate, state: InviteState): InviteState => { + const data = _.get(json, 'accepted', false); + if (data) { + delete state.invites[data.term][data.uid]; + } + return state; +} + +const decline = (json: InviteUpdate, state: InviteState): InviteState => { + const data = _.get(json, 'decline', false); + if (data) { + delete state.invites[data.term][data.uid]; + } + return state; +} \ No newline at end of file diff --git a/pkg/interface/src/logic/reducers/launch-update.ts b/pkg/interface/src/logic/reducers/launch-update.ts index 56a00efcea..598731bf82 100644 --- a/pkg/interface/src/logic/reducers/launch-update.ts +++ b/pkg/interface/src/logic/reducers/launch-update.ts @@ -1,61 +1,79 @@ import _ from 'lodash'; -import { LaunchUpdate } from '~/types/launch-update'; +import { LaunchUpdate, WeatherState } from '~/types/launch-update'; 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; - -export default class LaunchReducer { - reduce(json: Cage, state: S) { +export default class LaunchReducer { + reduce(json: Cage) { const data = _.get(json, 'launch-update', false); if (data) { - this.initial(data, state); - this.changeFirstTime(data, state); - this.changeOrder(data, state); - this.changeFirstTime(data, state); - this.changeIsShown(data, state); + reduceState(useLaunchState, data, [ + initial, + changeFirstTime, + changeOrder, + changeFirstTime, + changeIsShown, + ]); } - const weatherData = _.get(json, 'weather', false); + const weatherData: WeatherState = _.get(json, 'weather', false); if (weatherData) { - state.weather = weatherData; + useLaunchState.getState().set(state => { + state.weather = weatherData; + }); } const locationData = _.get(json, 'location', false); if (locationData) { - state.userLocation = locationData; + useLaunchState.getState().set(state => { + state.userLocation = locationData; + }); } - } - initial(json: LaunchUpdate, state: S) { - const data = _.get(json, 'initial', false); - if (data) { - state.launch = data; - } - } - - changeFirstTime(json: LaunchUpdate, state: S) { - const data = _.get(json, 'changeFirstTime', false); - if (data) { - state.launch.firstTime = data; - } - } - - changeOrder(json: LaunchUpdate, state: S) { - const data = _.get(json, 'changeOrder', false); - if (data) { - state.launch.tileOrdering = data; - } - } - - changeIsShown(json: LaunchUpdate, state: S) { - const data = _.get(json, 'changeIsShown', false); - if (data) { - const tile = state.launch.tiles[data.name]; - console.log(tile); - if (tile) { - tile.isShown = data.isShown; - } + const baseHash = _.get(json, 'baseHash', false); + if (baseHash) { + useLaunchState.getState().set(state => { + state.baseHash = baseHash; + }) } } } + +export const initial = (json: LaunchUpdate, state: LaunchState): LaunchState => { + const data = _.get(json, 'initial', false); + if (data) { + Object.keys(data).forEach(key => { + state[key] = data[key]; + }); + } + return state; +} + +export const changeFirstTime = (json: LaunchUpdate, state: LaunchState): LaunchState => { + const data = _.get(json, 'changeFirstTime', false); + if (data) { + state.firstTime = data; + } + return state; +} + +export const changeOrder = (json: LaunchUpdate, state: LaunchState): LaunchState => { + const data = _.get(json, 'changeOrder', false); + if (data) { + state.tileOrdering = data; + } + return state; +} + +export const changeIsShown = (json: LaunchUpdate, state: LaunchState): LaunchState => { + const data = _.get(json, 'changeIsShown', false); + if (data) { + const tile = state.tiles[data.name]; + if (tile) { + tile.isShown = data.isShown; + } + } + return state; +} \ No newline at end of file diff --git a/pkg/interface/src/logic/reducers/local.ts b/pkg/interface/src/logic/reducers/local.ts deleted file mode 100644 index c10455278d..0000000000 --- a/pkg/interface/src/logic/reducers/local.ts +++ /dev/null @@ -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; - -export default class LocalReducer { - 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; - } - } -} diff --git a/pkg/interface/src/logic/reducers/metadata-update.ts b/pkg/interface/src/logic/reducers/metadata-update.ts index 41e6d79855..6001bc6bc4 100644 --- a/pkg/interface/src/logic/reducers/metadata-update.ts +++ b/pkg/interface/src/logic/reducers/metadata-update.ts @@ -1,103 +1,108 @@ import _ from 'lodash'; - -import { StoreState } from '../../store/type'; +import { compose } from 'lodash/fp'; import { MetadataUpdate } from '@urbit/api/metadata'; + import { Cage } from '~/types/cage'; +import useMetadataState, { MetadataState } from '../state/metadata'; +import { reduceState } from '../state/base'; -type MetadataState = Pick; - -export default class MetadataReducer { - reduce(json: Cage, state: S) { +export default class MetadataReducer { + reduce(json: Cage) { const data = json['metadata-update']; if (data) { - console.log(data); - this.associations(data, state); - this.add(data, state); - this.update(data, state); - this.remove(data, state); - this.groupInitial(data, state); + reduceState(useMetadataState, data, [ + associations, + add, + update, + remove, + groupInitial, + ]); } } +} - groupInitial(json: MetadataUpdate, state: S) { - const data = _.get(json, 'initial-group', false); - console.log(data); - if(data) { - this.associations(data, state); - } +const groupInitial = (json: MetadataUpdate, state: MetadataState): MetadataState => { + const data = _.get(json, 'initial-group', false); + if(data) { + state = associations(data, state); } + return state; +} - associations(json: MetadataUpdate, state: S) { - const data = _.get(json, 'associations', false); - if (data) { - const metadata = state.associations; - Object.keys(data).forEach((key) => { - const val = data[key]; - const appName = val['app-name']; - const rid = val.resource; - if (!(appName in metadata)) { - metadata[appName] = {}; - } - if (!(rid in metadata[appName])) { - metadata[appName][rid] = {}; - } - metadata[appName][rid] = val; - }); - - state.associations = metadata; - } - } - - add(json: MetadataUpdate, state: S) { - const data = _.get(json, 'add', false); - if (data) { - const metadata = state.associations; - const appName = data['app-name']; - const appPath = data.resource; - - if (!(appName in metadata)) { - metadata[appName] = {}; - } - if (!(appPath in metadata[appName])) { - metadata[appName][appPath] = {}; - } - metadata[appName][appPath] = data; - - state.associations = metadata; - } - } - - update(json: MetadataUpdate, state: S) { - const data = _.get(json, 'update-metadata', false); - if (data) { - const metadata = state.associations; - const appName = data['app-name']; - const rid = data.resource; - +const associations = (json: MetadataUpdate, state: MetadataState): MetadataState => { + const data = _.get(json, 'associations', false); + if (data) { + const metadata = state.associations; + Object.keys(data).forEach((key) => { + const val = data[key]; + const appName = val['app-name']; + const rid = val.resource; if (!(appName in metadata)) { metadata[appName] = {}; } if (!(rid in metadata[appName])) { metadata[appName][rid] = {}; } - metadata[appName][rid] = data; + metadata[appName][rid] = val; + }); - state.associations = metadata; - } - } - - remove(json: MetadataUpdate, state: S) { - const data = _.get(json, 'remove', false); - if (data) { - const metadata = state.associations; - const appName = data['app-name']; - const rid = data.resource; - - if (appName in metadata && rid in metadata[appName]) { - delete metadata[appName][rid]; - } - state.associations = metadata; - } + state.associations = metadata; } + return state; +} + +const add = (json: MetadataUpdate, state: MetadataState): MetadataState => { + const data = _.get(json, 'add', false); + if (data) { + const metadata = state.associations; + const appName = data['app-name']; + const appPath = data.resource; + + if (!(appName in metadata)) { + metadata[appName] = {}; + } + if (!(appPath in metadata[appName])) { + metadata[appName][appPath] = {}; + } + metadata[appName][appPath] = data; + + state.associations = metadata; + } + return state; +} + +const update = (json: MetadataUpdate, state: MetadataState): MetadataState => { + const data = _.get(json, 'update-metadata', false); + if (data) { + const metadata = state.associations; + const appName = data['app-name']; + const rid = data.resource; + + if (!(appName in metadata)) { + metadata[appName] = {}; + } + if (!(rid in metadata[appName])) { + metadata[appName][rid] = {}; + } + metadata[appName][rid] = data; + + state.associations = metadata; + } + return state; +} + +const remove = (json: MetadataUpdate, state: MetadataState): MetadataState => { + const data = _.get(json, 'remove', false); + if (data) { + const metadata = state.associations; + const appName = data['app-name']; + const rid = data.resource; + + if (appName in metadata && rid in metadata[appName]) { + delete metadata[appName][rid]; + } + state.associations = metadata; + } + return state; } diff --git a/pkg/interface/src/logic/reducers/s3-update.ts b/pkg/interface/src/logic/reducers/s3-update.ts index ba224cd9bf..538f12d5cb 100644 --- a/pkg/interface/src/logic/reducers/s3-update.ts +++ b/pkg/interface/src/logic/reducers/s3-update.ts @@ -1,83 +1,93 @@ import _ from 'lodash'; -import { StoreState } from '../../store/type'; +import { compose } from 'lodash/fp'; import { Cage } from '~/types/cage'; import { S3Update } from '~/types/s3-update'; +import { reduceState } from '../state/base'; +import useStorageState, { StorageState } from '../state/storage'; -type S3State = Pick; -export default class S3Reducer { - reduce(json: Cage, state: S) { +export default class S3Reducer { + reduce(json: Cage) { const data = _.get(json, 's3-update', false); if (data) { - this.credentials(data, state); - this.configuration(data, state); - this.currentBucket(data, state); - this.addBucket(data, state); - this.removeBucket(data, state); - this.endpoint(data, state); - this.accessKeyId(data, state); - this.secretAccessKey(data, state); - } - } - - credentials(json: S3Update, state: S) { - const data = _.get(json, 'credentials', false); - if (data) { - state.storage.s3.credentials = data; - } - } - - configuration(json: S3Update, state: S) { - const data = _.get(json, 'configuration', false); - if (data) { - state.storage.s3.configuration = { - buckets: new Set(data.buckets), - currentBucket: data.currentBucket - }; - } - } - - currentBucket(json: S3Update, state: S) { - const data = _.get(json, 'setCurrentBucket', false); - if (data && state.storage.s3) { - state.storage.s3.configuration.currentBucket = data; - } - } - - addBucket(json: S3Update, state: S) { - const data = _.get(json, 'addBucket', false); - if (data) { - state.storage.s3.configuration.buckets = - state.storage.s3.configuration.buckets.add(data); - } - } - - removeBucket(json: S3Update, state: S) { - const data = _.get(json, 'removeBucket', false); - if (data) { - state.storage.s3.configuration.buckets.delete(data); - } - } - - endpoint(json: S3Update, state: S) { - const data = _.get(json, 'setEndpoint', false); - if (data && state.storage.s3.credentials) { - state.storage.s3.credentials.endpoint = data; - } - } - - accessKeyId(json: S3Update , state: S) { - const data = _.get(json, 'setAccessKeyId', false); - if (data && state.storage.s3.credentials) { - state.storage.s3.credentials.accessKeyId = data; - } - } - - secretAccessKey(json: S3Update, state: S) { - const data = _.get(json, 'setSecretAccessKey', false); - if (data && state.storage.s3.credentials) { - state.storage.s3.credentials.secretAccessKey = data; + reduceState(useStorageState, data, [ + credentials, + configuration, + currentBucket, + addBucket, + removeBucket, + endpoint, + accessKeyId, + secretAccessKey, + ]); } } } +const credentials = (json: S3Update, state: StorageState): StorageState => { + const data = _.get(json, 'credentials', false); + if (data) { + state.s3.credentials = data; + } + return state; +} + +const configuration = (json: S3Update, state: StorageState): StorageState => { + const data = _.get(json, 'configuration', false); + if (data) { + state.s3.configuration = { + buckets: new Set(data.buckets), + currentBucket: data.currentBucket + }; + } + return state; +} + +const currentBucket = (json: S3Update, state: StorageState): StorageState => { + const data = _.get(json, 'setCurrentBucket', false); + if (data && state.s3) { + state.s3.configuration.currentBucket = data; + } + return state; +} + +const addBucket = (json: S3Update, state: StorageState): StorageState => { + const data = _.get(json, 'addBucket', false); + if (data) { + state.s3.configuration.buckets = + state.s3.configuration.buckets.add(data); + } + return state; +} + +const removeBucket = (json: S3Update, state: StorageState): StorageState => { + const data = _.get(json, 'removeBucket', false); + if (data) { + state.s3.configuration.buckets.delete(data); + } + return state; +} + +const endpoint = (json: S3Update, state: StorageState): StorageState => { + const data = _.get(json, 'setEndpoint', false); + if (data && state.s3.credentials) { + state.s3.credentials.endpoint = data; + } + return state; +} + +const accessKeyId = (json: S3Update , state: StorageState): StorageState => { + const data = _.get(json, 'setAccessKeyId', false); + if (data && state.s3.credentials) { + state.s3.credentials.accessKeyId = data; + } + return state; +} + +const secretAccessKey = (json: S3Update, state: StorageState): StorageState => { + const data = _.get(json, 'setSecretAccessKey', false); + if (data && state.s3.credentials) { + state.s3.credentials.secretAccessKey = data; + } + return state; +} \ No newline at end of file diff --git a/pkg/interface/src/logic/reducers/settings-update.ts b/pkg/interface/src/logic/reducers/settings-update.ts index 8e3eacc78b..1d43f517b0 100644 --- a/pkg/interface/src/logic/reducers/settings-update.ts +++ b/pkg/interface/src/logic/reducers/settings-update.ts @@ -1,46 +1,46 @@ import _ from 'lodash'; -import { SettingsUpdate } from '~/types/settings'; -import useSettingsState, { SettingsStateZus } from "~/logic/state/settings"; -import produce from 'immer'; +import useSettingsState, { SettingsState } from "~/logic/state/settings"; +import { SettingsUpdate } from '@urbit/api/dist/settings'; +import { reduceState } from '../state/base'; -export default class SettingsStateZusettingsReducer{ +export default class SettingsReducer { reduce(json: any) { - const old = useSettingsState.getState(); - const newState = produce(old, state => { - let data = json["settings-event"]; - if (data) { - console.log(data); - this.putBucket(data, state); - this.delBucket(data, state); - this.putEntry(data, state); - this.delEntry(data, state); - } - data = json["settings-data"]; - if (data) { - console.log(data); - this.getAll(data, state); - this.getBucket(data, state); - this.getEntry(data, state); - } - }); - useSettingsState.setState(newState); + let data = json["settings-event"]; + if (data) { + reduceState(useSettingsState, data, [ + this.putBucket, + this.delBucket, + this.putEntry, + this.delEntry, + ]); + } + data = json["settings-data"]; + if (data) { + reduceState(useSettingsState, data, [ + this.getAll, + this.getBucket, + this.getEntry, + ]); + } } - putBucket(json: SettingsUpdate, state: SettingsStateZus) { + putBucket(json: SettingsUpdate, state: SettingsState): SettingsState { const data = _.get(json, 'put-bucket', false); if (data) { 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); 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); if (data) { if (!state[data["bucket-key"]]) { @@ -48,36 +48,41 @@ export default class SettingsStateZusettingsReducer{ } 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); if (data) { 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'); if(data) { _.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 bucket = _.get(json, 'bucket', false); if (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 entryKey = _.get(json, 'entry-key', false); const entry = _.get(json, 'entry', false); if (bucketKey && entryKey && entry) { state[bucketKey][entryKey] = entry; } + return state; } } diff --git a/pkg/interface/src/logic/state/base.ts b/pkg/interface/src/logic/state/base.ts new file mode 100644 index 0000000000..72af05e92b --- /dev/null +++ b/pkg/interface/src/logic/state/base.ts @@ -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 = ( + 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, + UpdateType +>( + state: UseStore, + 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 extends State { + set: (fn: (state: StateType) => void) => void; +} + +export const createState = >( + name: string, + properties: Omit, + blacklist: string[] = [] +): UseStore => 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 +})); \ No newline at end of file diff --git a/pkg/interface/src/logic/state/contact.ts b/pkg/interface/src/logic/state/contact.ts new file mode 100644 index 0000000000..eaceac3e6c --- /dev/null +++ b/pkg/interface/src/logic/state/contact.ts @@ -0,0 +1,31 @@ +import { Patp, Rolodex, Scry } from "@urbit/api"; + +import { BaseState, createState } from "./base"; + +export interface ContactState extends BaseState { + contacts: Rolodex; + isContactPublic: boolean; + nackedContacts: Set; + // fetchIsAllowed: (entity, name, ship, personal) => Promise; +}; + +const useContactState = createState('Contact', { + contacts: {}, + nackedContacts: new Set(), + isContactPublic: false, + // fetchIsAllowed: async ( + // entity, + // name, + // ship, + // personal + // ): Promise => { + // 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; \ No newline at end of file diff --git a/pkg/interface/src/logic/state/graph.ts b/pkg/interface/src/logic/state/graph.ts new file mode 100644 index 0000000000..2f8aab6920 --- /dev/null +++ b/pkg/interface/src/logic/state/graph.ts @@ -0,0 +1,127 @@ +import { Graphs, decToUd, numToUd } from "@urbit/api"; + +import { BaseState, createState } from "./base"; + +export interface GraphState extends BaseState { + graphs: Graphs; + graphKeys: Set; + pendingIndices: Record; + graphTimesentMap: Record; + // getKeys: () => Promise; + // getTags: () => Promise; + // getTagQueries: () => Promise; + // getGraph: (ship: string, resource: string) => Promise; + // getNewest: (ship: string, resource: string, count: number, index?: string) => Promise; + // getOlderSiblings: (ship: string, resource: string, count: number, index?: string) => Promise; + // getYoungerSiblings: (ship: string, resource: string, count: number, index?: string) => Promise; + // getGraphSubset: (ship: string, resource: string, start: string, end: string) => Promise; + // getNode: (ship: string, resource: string, index: string) => Promise; +}; + +const useGraphState = createState('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; \ No newline at end of file diff --git a/pkg/interface/src/logic/state/group.ts b/pkg/interface/src/logic/state/group.ts new file mode 100644 index 0000000000..218567ce20 --- /dev/null +++ b/pkg/interface/src/logic/state/group.ts @@ -0,0 +1,15 @@ +import { Path, JoinRequests } from "@urbit/api"; + +import { BaseState, createState } from "./base"; + +export interface GroupState extends BaseState { + groups: Set; + pendingJoin: JoinRequests; +}; + +const useGroupState = createState('Group', { + groups: new Set(), + pendingJoin: {}, +}, ['groups']); + +export default useGroupState; \ No newline at end of file diff --git a/pkg/interface/src/logic/state/hark.ts b/pkg/interface/src/logic/state/hark.ts new file mode 100644 index 0000000000..d02402c098 --- /dev/null +++ b/pkg/interface/src/logic/state/hark.ts @@ -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 { + archivedNotifications: BigIntOrderedMap; + doNotDisturb: boolean; + // getMore: () => Promise; + // getSubset: (offset: number, count: number, isArchive: boolean) => Promise; + // getTimeSubset: (start?: Date, end?: Date) => Promise; + notifications: BigIntOrderedMap; + notificationsCount: number; + notificationsGraphConfig: NotificationGraphConfig; // TODO unthread this everywhere + notificationsGroupConfig: []; // TODO type this + unreads: Unreads; +}; + +const useHarkState = createState('Hark', { + archivedNotifications: new BigIntOrderedMap(), + doNotDisturb: false, + // getMore: async (): Promise => { + // 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 => { + // 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 => { + // 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(), + notificationsCount: 0, + notificationsGraphConfig: { + watchOnSelf: false, + mentions: false, + watching: [] + }, + notificationsGroupConfig: [], + unreads: { + graph: {}, + group: {} + }, +}, ['notifications', 'archivedNotifications', 'unreads', 'notificationsCount']); + + +export default useHarkState; diff --git a/pkg/interface/src/logic/state/invite.ts b/pkg/interface/src/logic/state/invite.ts new file mode 100644 index 0000000000..4da81eb53a --- /dev/null +++ b/pkg/interface/src/logic/state/invite.ts @@ -0,0 +1,12 @@ +import { Invites } from '@urbit/api'; +import { BaseState, createState } from './base'; + +export interface InviteState extends BaseState { + invites: Invites; +}; + +const useInviteState = createState('Invite', { + invites: {}, +}); + +export default useInviteState; \ No newline at end of file diff --git a/pkg/interface/src/logic/state/launch.ts b/pkg/interface/src/logic/state/launch.ts new file mode 100644 index 0000000000..14f2113e58 --- /dev/null +++ b/pkg/interface/src/logic/state/launch.ts @@ -0,0 +1,27 @@ +import { Tile, WeatherState } from "~/types/launch-update"; + +import { BaseState, createState } from "./base"; + + +export interface LaunchState extends BaseState { + firstTime: boolean; + tileOrdering: string[]; + tiles: { + [app: string]: Tile; + }, + weather: WeatherState | null, + userLocation: string | null; + baseHash: string | null; +}; + +const useLaunchState = createState('Launch', { + firstTime: true, + tileOrdering: [], + tiles: {}, + weather: null, + userLocation: null, + baseHash: null +}); + + +export default useLaunchState; \ No newline at end of file diff --git a/pkg/interface/src/logic/state/metadata.ts b/pkg/interface/src/logic/state/metadata.ts new file mode 100644 index 0000000000..fabffca86e --- /dev/null +++ b/pkg/interface/src/logic/state/metadata.ts @@ -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 { + associations: Associations; + // preview: (group: string) => Promise; +}; + +const useMetadataState = createState('Metadata', { + associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} }, + // preview: async (group): Promise => { + // return new Promise((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; \ No newline at end of file diff --git a/pkg/interface/src/logic/state/settings.tsx b/pkg/interface/src/logic/state/settings.ts similarity index 54% rename from pkg/interface/src/logic/state/settings.tsx rename to pkg/interface/src/logic/state/settings.ts index 2c68be37fd..02e96fffea 100644 --- a/pkg/interface/src/logic/state/settings.tsx +++ b/pkg/interface/src/logic/state/settings.ts @@ -1,12 +1,9 @@ -import React, { ReactNode } from "react"; import f from 'lodash/fp'; -import create, { State } from 'zustand'; -import { persist } from 'zustand/middleware'; -import produce from 'immer'; -import { BackgroundConfig, RemoteContentPolicy, TutorialProgress, tutorialProgress, LeapCategories, leapCategories } from "~/types/local-update"; +import { RemoteContentPolicy, LeapCategories, leapCategories } from "~/types/local-update"; +import { BaseState, createState } from '~/logic/state/base'; -export interface SettingsState { +export interface SettingsState extends BaseState { display: { backgroundType: 'none' | 'url' | 'color'; background?: string; @@ -28,11 +25,8 @@ export interface SettingsState { seen: boolean; joined?: number; }; - set: (fn: (state: SettingsState) => void) => void }; -export type SettingsStateZus = SettingsState & State; - export const selectSettingsState = (keys: K[]) => f.pick(keys); @@ -40,7 +34,7 @@ export const selectCalmState = (s: SettingsState) => s.calm; export const selectDisplayState = (s: SettingsState) => s.display; -const useSettingsState = create((set) => ({ +const useSettingsState = createState('Settings', { display: { backgroundType: 'none', background: undefined, @@ -66,17 +60,7 @@ const useSettingsState = create((set) => ({ tutorial: { seen: false, joined: undefined - }, - set: (fn: (state: SettingsState) => void) => set(produce(fn)) -})); + } +}); -function withSettingsState(Component: any, stateMemberKeys?: S[]) { - return React.forwardRef((props: Omit, ref) => { - const localState = stateMemberKeys - ? useSettingsState(selectSettingsState(stateMemberKeys)) - : useSettingsState(); - return - }); -} - -export { useSettingsState as default, withSettingsState }; +export default useSettingsState; diff --git a/pkg/interface/src/logic/state/storage.ts b/pkg/interface/src/logic/state/storage.ts new file mode 100644 index 0000000000..bceb96f46e --- /dev/null +++ b/pkg/interface/src/logic/state/storage.ts @@ -0,0 +1,33 @@ +import { BaseState, createState } from "./base"; + +export interface GcpToken { + accessKey: string; + expiresIn: number; +} + +export interface StorageState extends BaseState { + gcp: { + configured?: boolean; + token?: GcpToken; + }, + s3: { + configuration: { + buckets: Set; + currentBucket: string; + }; + credentials: any | null; // TODO better type + } +}; + +const useStorageState = createState('Storage', { + gcp: {}, + s3: { + configuration: { + buckets: new Set(), + currentBucket: '' + }, + credentials: null, + } +}, ['s3']); + +export default useStorageState; \ No newline at end of file diff --git a/pkg/interface/src/logic/store/base.ts b/pkg/interface/src/logic/store/base.ts index faeacca1e9..b2c894f34e 100644 --- a/pkg/interface/src/logic/store/base.ts +++ b/pkg/interface/src/logic/store/base.ts @@ -5,10 +5,6 @@ export default class BaseStore { this.state = this.initialState(); } - dehydrate() {} - - rehydrate() {} - initialState() { return {} as S; } diff --git a/pkg/interface/src/logic/store/store.ts b/pkg/interface/src/logic/store/store.ts index 599054b87c..3f77beb9ce 100644 --- a/pkg/interface/src/logic/store/store.ts +++ b/pkg/interface/src/logic/store/store.ts @@ -20,11 +20,11 @@ import GcpReducer from '../reducers/gcp-reducer'; import { OrderedMap } from '../lib/OrderedMap'; import { BigIntOrderedMap } from '../lib/BigIntOrderedMap'; import { GroupViewReducer } from '../reducers/group-view'; +import { unstable_batchedUpdates } from 'react-dom'; export default class GlobalStore extends BaseStore { inviteReducer = new InviteReducer(); metadataReducer = new MetadataReducer(); - localReducer = new LocalReducer(); s3Reducer = new S3Reducer(); groupReducer = new GroupReducer(); launchReducer = new LaunchReducer(); @@ -44,84 +44,30 @@ export default class GlobalStore extends BaseStore { console.log(_.pick(this.state, stateKeys)); } - rehydrate() { - this.localReducer.rehydrate(this.state); - } - - dehydrate() { - this.localReducer.dehydrate(this.state); - } - initialState(): StoreState { return { 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(), - archivedNotifications: new BigIntOrderedMap(), - notificationsGroupConfig: [], - notificationsGraphConfig: { - watchOnSelf: false, - mentions: false, - watching: [] - }, - unreads: { - graph: {}, - group: {} - }, - notificationsCount: 0, - settings: {}, - pendingJoin: {}, - graphTimesentMap: {} }; } reduce(data: Cage, state: StoreState) { - // debug shim - const tag = Object.keys(data)[0]; - const oldActions = this.pastActions[tag] || []; - this.pastActions[tag] = [data[tag], ...oldActions.slice(0,14)]; - - this.inviteReducer.reduce(data, this.state); - this.metadataReducer.reduce(data, this.state); - this.localReducer.reduce(data, this.state); - this.s3Reducer.reduce(data, this.state); - this.groupReducer.reduce(data, this.state); - this.launchReducer.reduce(data, this.state); - this.connReducer.reduce(data, this.state); - GraphReducer(data, this.state); - HarkReducer(data, this.state); - ContactReducer(data, this.state); - this.settingsReducer.reduce(data); - this.gcpReducer.reduce(data, this.state); - GroupViewReducer(data, this.state); + unstable_batchedUpdates(() => { + // debug shim + const tag = Object.keys(data)[0]; + const oldActions = this.pastActions[tag] || []; + this.pastActions[tag] = [data[tag], ...oldActions.slice(0, 14)]; + this.inviteReducer.reduce(data); + this.metadataReducer.reduce(data); + this.s3Reducer.reduce(data); + this.groupReducer.reduce(data); + GroupViewReducer(data); + this.launchReducer.reduce(data); + this.connReducer.reduce(data, this.state); + GraphReducer(data); + HarkReducer(data); + ContactReducer(data); + this.settingsReducer.reduce(data); + this.gcpReducer.reduce(data); + }); } } diff --git a/pkg/interface/src/logic/store/type.ts b/pkg/interface/src/logic/store/type.ts index 7c847c5c40..568cd20411 100644 --- a/pkg/interface/src/logic/store/type.ts +++ b/pkg/interface/src/logic/store/type.ts @@ -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 { Graphs } from '@urbit/api/graph'; -import { - Notifications, - NotificationGraphConfig, - GroupNotificationsConfig, - Unreads, - JoinRequests, - Patp -} from '@urbit/api'; export interface StoreState { // local state connection: ConnectionStatus; - baseHash: string | null; - - // invite state - invites: Invites; - // metadata state - associations: Associations; - // contact state - contacts: Rolodex; - // groups state - groups: Groups; - groupKeys: Set; - nackedContacts: Set - storage: StorageState; - graphs: Graphs; - graphKeys: Set; - - // 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; } diff --git a/pkg/interface/src/views/App.js b/pkg/interface/src/views/App.js index 77b89f0428..7374365768 100644 --- a/pkg/interface/src/views/App.js +++ b/pkg/interface/src/views/App.js @@ -27,12 +27,15 @@ import GlobalSubscription from '~/logic/subscription/global'; import GlobalApi from '~/logic/api/global'; import { uxToHex } from '~/logic/lib/util'; 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 { 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}; height: 100%; width: 100%; @@ -66,7 +69,9 @@ const Root = withSettingsState(styled.div` border-radius: 1rem; border: 0px solid transparent; } -`, ['display']); +`, [ + [useSettingsState, ['display']] +]); const StatusBarWithRouter = withRouter(StatusBar); class App extends React.Component { @@ -79,7 +84,7 @@ class App extends React.Component { this.appChannel = new window.channel(); this.api = new GlobalApi(this.ship, this.appChannel, this.store); - gcpManager.configure(this.api, this.store); + gcpManager.configure(this.api); this.subscription = new GlobalSubscription(this.store, this.api, this.appChannel); @@ -98,7 +103,6 @@ class App extends React.Component { }, 500); this.api.local.getBaseHash(); this.api.settings.getAll(); - this.store.rehydrate(); gcpManager.start(); Mousetrap.bindGlobal(['command+/', 'ctrl+/'], (e) => { e.preventDefault(); @@ -119,8 +123,8 @@ class App extends React.Component { faviconString() { let background = '#ffffff'; - if (this.state.contacts.hasOwnProperty(`~${window.ship}`)) { - background = `#${uxToHex(this.state.contacts[`~${window.ship}`].color)}`; + if (this.props.contacts.hasOwnProperty(`~${window.ship}`)) { + background = `#${uxToHex(this.props.contacts[`~${window.ship}`].color)}`; } const foreground = foregroundFromBackground(background); const svg = sigiljs({ @@ -135,16 +139,12 @@ class App extends React.Component { render() { const { state, props } = this; - const associations = state.associations ? - state.associations : { contacts: {} }; const theme = ((props.dark && props?.display?.theme == "auto") || props?.display?.theme == "dark" ) ? dark : light; - const notificationsCount = state.notificationsCount || 0; - const doNotDisturb = state.doNotDisturb || false; - const ourContact = this.state.contacts[`~${this.ship}`] || null; + const ourContact = this.props.contacts[`~${this.ship}`] || null; return ( @@ -158,27 +158,17 @@ class App extends React.Component { @@ -188,7 +178,7 @@ class App extends React.Component { ship={this.ship} api={this.api} subscription={this.subscription} - {...state} + connection={this.state.connection} /> @@ -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] +]); \ No newline at end of file diff --git a/pkg/interface/src/views/apps/chat/ChatResource.tsx b/pkg/interface/src/views/apps/chat/ChatResource.tsx index ba9daa6312..fe224165ea 100644 --- a/pkg/interface/src/views/apps/chat/ChatResource.tsx +++ b/pkg/interface/src/views/apps/chat/ChatResource.tsx @@ -16,6 +16,10 @@ import { Loading } from '~/views/components/Loading'; import { isWriter, resourceFromPath } from '~/logic/lib/group'; 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 & { association: Association; @@ -26,12 +30,15 @@ type ChatResourceProps = StoreState & { export function ChatResource(props: ChatResourceProps) { const station = props.association.resource; const groupPath = props.association.group; - const group = props.groups[groupPath]; - const contacts = props.contacts; + const groups = useGroupState(state => state.groups); + const group = groups[groupPath]; + const contacts = useContactState(state => state.contacts); + const graphs = useGraphState(state => state.graphs); const graphPath = station.slice(7); - const graph = props.graphs[station.slice(7)]; - const isChatMissing = !props.graphKeys.has(station.slice(7)); - const unreadCount = props.unreads.graph?.[station]?.['/']?.unreads || 0; + const graph = graphs[graphPath]; + const unreads = useHarkState(state => state.unreads); + const unreadCount = unreads.graph?.[station]?.['/']?.unreads || 0; + const graphTimesentMap = useGraphState(state => state.graphTimesentMap); const [,, owner, name] = station.split('/'); const ourContact = contacts?.[`~${window.ship}`]; const chatInput = useRef(); @@ -132,9 +139,6 @@ export function ChatResource(props: ChatResourceProps) { return ; } - const modifiedContacts = { ...contacts }; - delete modifiedContacts[`~${window.ship}`]; - return ( { submit(text) { const { props, state } = this; - const [,,ship,name] = props.station.split('/'); + const [, , ship, name] = props.station.split('/'); if (state.inCodeMode) { - this.setState({ - inCodeMode: false - }, async () => { - const output = await props.api.graph.eval(text); - const contents: Content[] = [{ code: { output, expression: text } }]; - const post = createPost(contents); - props.api.graph.addPost(ship, name, post); - }); + this.setState( + { + inCodeMode: false + }, + async () => { + const output = await props.api.graph.eval(text); + const contents: Content[] = [{ code: { output, expression: text } }]; + const post = createPost(contents); + props.api.graph.addPost(ship, name, post); + } + ); return; } - const post = createPost(tokenizeMessage((text))); + const post = createPost(tokenizeMessage(text)); props.deleteMessage(); @@ -88,8 +89,8 @@ class ChatInput extends Component { this.chatEditor.current.editor.setValue(url); this.setState({ uploadingPaste: false }); } else { - const [,,ship,name] = props.station.split('/'); - props.api.graph.addPost(ship,name, createPost([{ url }])); + const [, , ship, name] = props.station.split('/'); + props.api.graph.addPost(ship, name, createPost([{ url }])); } } @@ -112,7 +113,8 @@ class ChatInput extends Component { return; } Array.from(files).forEach((file) => { - this.props.uploadDefault(file) + this.props + .uploadDefault(file) .then(this.uploadSuccess) .catch(this.uploadError); }); @@ -121,32 +123,40 @@ class ChatInput extends Component { render() { const { props, state } = this; - const color = props.ourContact - ? uxToHex(props.ourContact.color) : '000000'; + const color = props.ourContact ? uxToHex(props.ourContact.color) : '000000'; - const sigilClass = props.ourContact - ? '' : 'mix-blend-diff'; + const sigilClass = props.ourContact ? '' : 'mix-blend-diff'; - const avatar = ( - props.ourContact && - ((props.ourContact?.avatar) && !props.hideAvatars) - ) - ? - : ; + ) : ( + + + + ); return ( { className='cf' zIndex={0} > - + {avatar} { onPaste={this.onPaste.bind(this)} placeholder='Message...' /> - - {this.props.canUpload - ? this.props.uploading - ? - : this.props.promptUpload().then(this.uploadSuccess)} - /> - : null - } + + {this.props.canUpload ? ( + this.props.uploading ? ( + + ) : ( + + this.props.promptUpload().then(this.uploadSuccess) + } + /> + ) + ) : null} - + { } } -export default withLocalState(withStorage(ChatInput, { accept: 'image/*' }), ['hideAvatars']); +export default withLocalState(withStorage(ChatInput, { accept: 'image/*' }), [ + 'hideAvatars' +]); diff --git a/pkg/interface/src/views/apps/chat/components/ChatMessage.tsx b/pkg/interface/src/views/apps/chat/components/ChatMessage.tsx index 2d4ca543aa..51abf71dbc 100644 --- a/pkg/interface/src/views/apps/chat/components/ChatMessage.tsx +++ b/pkg/interface/src/views/apps/chat/components/ChatMessage.tsx @@ -10,7 +10,7 @@ import React, { import moment from 'moment'; import _ from 'lodash'; 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 OverlaySigil from '~/views/components/OverlaySigil'; import { @@ -33,11 +33,13 @@ import TextContent from './content/text'; import CodeContent from './content/code'; import RemoteContent from '~/views/components/RemoteContent'; import { Mention } from '~/views/components/MentionText'; +import { Dropdown } from '~/views/components/Dropdown'; import styled from 'styled-components'; 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 {useIdlingState} from '~/logic/lib/idling'; +import useContactState from '~/logic/state/contact'; +import { useIdlingState } from '~/logic/lib/idling'; export const DATESTAMP_FORMAT = '[~]YYYY.M.D'; @@ -56,46 +58,179 @@ export const DayBreak = ({ when, shimTop = false }: DayBreakProps) => ( mt={shimTop ? '-8px' : '0'} > - + {moment(when).calendar(null, { sameElse: DATESTAMP_FORMAT })} ); -export const UnreadMarker = React.forwardRef(({ dayBreak, when, api, association }, ref) => { - const [visible, setVisible] = useState(false); - const idling = useIdlingState(); - const dismiss = useCallback(() => { - api.hark.markCountAsRead(association, '/', 'message'); - }, [api, association]); +export const UnreadMarker = React.forwardRef( + ({ dayBreak, when, api, association }, ref) => { + const [visible, setVisible] = useState(false); + const idling = useIdlingState(); + const dismiss = useCallback(() => { + api.hark.markCountAsRead(association, '/', 'message'); + }, [api, association]); - useEffect(() => { - if(visible && !idling) { - dismiss(); - } - }, [visible, idling]); + useEffect(() => { + if (visible && !idling) { + dismiss(); + } + }, [visible, idling]); + return ( + + + + + New messages below + + + + + ); + } +); + +const MessageActionItem = (props) => { return ( - - - - - New messages below - - - - -)}); + + + {props.children} + + + ); +}; + +const MessageActions = ({ api, history, msg, group }) => { + const isAdmin = () => group.tags.role.admin.has(window.ship); + const isOwn = () => msg.author === window.ship; + return ( + + + {isOwn() ? ( + console.log(e)} + > + + + ) : null} + console.log(e)} + > + + + + {isOwn() ? ( + console.log(e)}> + Edit Message + + ) : null} + console.log(e)}> + Reply + + console.log(e)}> + Copy Message Link + + {isAdmin() || isOwn() ? ( + console.log(e)} color='red'> + Delete Message + + ) : null} + console.log(e)}> + View Signature + + + } + > + + + + + + + ); +}; + +const MessageWrapper = (props) => { + const { hovering, bind } = useHovering(); + return ( + + {props.children} + {/* {hovering ? : null} */} + + ); +}; interface ChatMessageProps { msg: Post; @@ -104,7 +239,6 @@ interface ChatMessageProps { isLastRead: boolean; group: Group; association: Association; - contacts: Contacts; className?: string; isPending: boolean; style?: unknown; @@ -115,6 +249,7 @@ interface ChatMessageProps { api: GlobalApi; highlighted?: boolean; renderSigil?: boolean; + hideHover?: boolean; innerRef: (el: HTMLDivElement | null) => void; } @@ -126,8 +261,7 @@ class ChatMessage extends Component { this.divRef = React.createRef(); } - componentDidMount() { - } + componentDidMount() {} render() { const { @@ -137,7 +271,6 @@ class ChatMessage extends Component { isLastRead, group, association, - contacts, className = '', isPending, style, @@ -147,9 +280,9 @@ class ChatMessage extends Component { history, api, highlighted, + showOurContact, fontSize, - groups, - associations + hideHover } = this.props; let { renderSigil } = this.props; @@ -173,23 +306,21 @@ class ChatMessage extends Component { .unix(msg['time-sent'] / 1000) .format(renderSigil ? 'h:mm A' : 'h:mm'); - const messageProps = { msg, timestamp, - contacts, association, group, style, containerClass, isPending, + showOurContact, history, api, scrollWindow, highlighted, fontSize, - associations, - groups, + hideHover }; const unreadContainerStyle = { @@ -200,7 +331,7 @@ class ChatMessage extends Component { { ) : null} {renderSigil ? ( - <> - - - + + + + ) : ( - + + + )} {isLastRead ? ( @@ -232,30 +365,36 @@ class ChatMessage extends Component { } } -export default React.forwardRef((props, ref) => ); +export default React.forwardRef((props, ref) => ( + +)); export const MessageAuthor = ({ timestamp, - contacts, msg, group, api, - associations, - groups, history, scrollWindow, + showOurContact, ...rest }) => { const osDark = useLocalState((state) => state.dark); - const theme = useSettingsState(s => s.display.theme); - const dark = theme === 'dark' || (theme === 'auto' && osDark) + const theme = useSettingsState((s) => s.display.theme); + const dark = theme === 'dark' || (theme === 'auto' && osDark); + const contacts = useContactState((state) => state.contacts); const datestamp = moment .unix(msg['time-sent'] / 1000) .format(DATESTAMP_FORMAT); 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 { hideAvatars } = useSettingsState(selectCalmState); const shipName = showNickname ? contact.nickname : cite(msg.author); @@ -297,31 +436,44 @@ export const MessageAuthor = ({ contact?.avatar && !hideAvatars ? ( ) : ( - + + + ); return ( - + { setShowOverlay(true); }} - height={16} + height={24} pr={2} - pl={2} + mt={'1px'} + pl={'12px'} cursor='pointer' position='relative' > @@ -348,12 +500,12 @@ export const MessageAuthor = ({ pt={1} pb={1} display='flex' - alignItems='center' + alignItems='baseline' > { const { hovering, bind } = useHovering(); + const contacts = useContactState((state) => state.contacts); return ( {timestampHover ? ( ); case 'code': - return ; + return ; case 'url': return ( ); case 'mention': - const first = (i) => (i === 0); + const first = (i) => i === 0; return ( & { unreadCount: number; graph: Graph; - contacts: Contacts; + graphSize: number; association: Association; group: Group; ship: Patp; station: any; api: GlobalApi; scrollTo?: number; - associations: Associations; - groups: Groups; }; interface ChatWindowState { @@ -52,16 +54,14 @@ interface ChatWindowState { const virtScrollerStyle = { height: '100%' }; -export default class ChatWindow extends Component< +class ChatWindow extends Component< ChatWindowProps, ChatWindowState > { private virtualList: VirtualScroller | null; private unreadMarkerRef: React.RefObject; private prevSize = 0; - private loadedNewest = false; - private loadedOldest = false; - private fetchPending = false; + private unreadSet = false; INITIALIZATION_MAX_TIME = 100; @@ -99,6 +99,10 @@ export default class ChatWindow extends Component< calculateUnreadIndex() { const { graph, unreadCount } = this.props; + const { state } = this; + if(state.unreadIndex.neq(bigInt.zero)) { + return; + } const unreadIndex = graph.keys()[unreadCount]; if (!unreadIndex || unreadCount === 0) { 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() { this.setState({ idle: true }); } @@ -123,10 +134,22 @@ export default class ChatWindow extends Component< } 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) { @@ -146,6 +169,12 @@ export default class ChatWindow extends Component< } } + onBottomLoaded = () => { + if(this.state.unreadIndex.eq(bigInt.zero)) { + this.calculateUnreadIndex(); + } + } + scrollToUnread() { const { unreadIndex } = this.state; if (unreadIndex.eq(bigInt.zero)) { @@ -170,30 +199,28 @@ export default class ChatWindow extends Component< fetchMessages = async (newer: boolean): Promise => { const { api, station, graph } = this.props; - if(this.fetchPending) { - return false; - } - - - this.fetchPending = true; + const pageSize = 100; const [, , ship, name] = station.split('/'); - const currSize = graph.size; + const expectedSize = graph.size + pageSize; if (newer) { const [index] = graph.peekLargest()!; await api.graph.getYoungerSiblings( ship, name, - 100, + pageSize, `/${index.toString()}` ); + return expectedSize !== graph.size; } else { const [index] = graph.peekSmallest()!; - await api.graph.getOlderSiblings(ship, name, 100, `/${index.toString()}`); - this.calculateUnreadIndex(); + await api.graph.getOlderSiblings(ship, name, pageSize, `/${index.toString()}`); + const done = expectedSize !== graph.size; + if(done) { + this.calculateUnreadIndex(); + } + return done; } - this.fetchPending = false; - return currSize === graph.size; } onScroll = ({ scrollTop, scrollHeight, windowHeight }) => { @@ -208,7 +235,7 @@ export default class ChatWindow extends Component< api, association, group, - contacts, + showOurContact, graph, history, groups, @@ -218,13 +245,14 @@ export default class ChatWindow extends Component< const messageProps = { association, group, - contacts, + showOurContact, unreadMarkerRef, history, api, groups, associations }; + const msg = graph.get(index)?.post; if (!msg) return null; if (!this.state.initialized) { @@ -255,6 +283,7 @@ export default class ChatWindow extends Component< msg, ...messageProps }; + return ( - + />)} { this.virtualList = list; @@ -318,10 +347,11 @@ export default class ChatWindow extends Component< origin='bottom' style={virtScrollerStyle} onStartReached={this.setActive} + onBottomLoaded={this.onBottomLoaded} onScroll={this.onScroll} data={graph} size={graph.size} - pendingSize={pendingSize} + pendingSize={pendingSize + contactsModified} id={association.resource} averageHeight={22} renderer={this.renderer} @@ -331,3 +361,9 @@ export default class ChatWindow extends Component< ); } } + +export default withState(ChatWindow, [ + [useGroupState, ['groups']], + [useMetadataState, ['associations']], + [useGraphState, ['pendingSize']] +]); diff --git a/pkg/interface/src/views/apps/chat/components/chat-editor.js b/pkg/interface/src/views/apps/chat/components/chat-editor.js index 03910afc2b..4b304e9b69 100644 --- a/pkg/interface/src/views/apps/chat/components/chat-editor.js +++ b/pkg/interface/src/views/apps/chat/components/chat-editor.js @@ -199,6 +199,7 @@ export default class ChatEditor extends Component { width='calc(100% - 88px)' className={inCodeMode ? 'chat code' : 'chat'} color="black" + overflow='auto' > {MOBILE_BROWSER_REGEX.test(navigator.userAgent) ? { }, []); return lines.map((line, i) => ( - <> + {i !== 0 && } { ] ]} /> - + )); }); @@ -145,8 +145,6 @@ export default function TextContent(props) { { const { unreadCount, unreadMsg, dismissUnread, onClick } = props; - if (!unreadMsg || (unreadCount === 0)) { + if (!unreadMsg || unreadCount === 0) { return null; } const stamp = moment.unix(unreadMsg.post['time-sent'] / 1000); - let datestamp = moment.unix(unreadMsg.post['time-sent'] / 1000).format('YYYY.M.D'); - const timestamp = moment.unix(unreadMsg.post['time-sent'] / 1000).format('HH:mm'); + let datestamp = moment + .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')) { datestamp = null; } return ( - - - - {unreadCount} new message{unreadCount > 1 ? 's' : ''} since{' '} - - - - Mark as Read - - +
+ + + + {unreadCount} new message{unreadCount > 1 ? 's' : ''} since{' '} + + + + + +
); -} +}; diff --git a/pkg/interface/src/views/apps/graph/app.js b/pkg/interface/src/views/apps/graph/app.js index b9c58f3adb..6daf7c449c 100644 --- a/pkg/interface/src/views/apps/graph/app.js +++ b/pkg/interface/src/views/apps/graph/app.js @@ -1,59 +1,55 @@ 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 { 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(); + + const { api } = props; + + return ( + + { + const resource = + `${deSig(props.match.params.ship)}/${props.match.params.name}`; + const { ship, name } = props.match.params; + const path = `/ship/~${deSig(ship)}/${name}`; + const association = associations.graph[path]; -export default class GraphApp extends PureComponent { - 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 ( - - { - const resource = - `${deSig(props.match.params.ship)}/${props.match.params.name}`; - const { ship, name } = props.match.params; - const path = `/ship/~${deSig(ship)}/${name}`; - const association = associations.graph[path]; - - - const autoJoin = () => { - try { - api.graph.joinGraph( - `~${deSig(props.match.params.ship)}`, - props.match.params.name - ); - - - } catch(err) { - setTimeout(autoJoin, 2000); - } - }; - - if(!graphKeys.has(resource)) { - autoJoin(); - } else if(!!association) { - props.history.push(`/~landscape/home/resource/${association.metadata.module}${path}`); + const autoJoin = () => { + try { + api.graph.joinGraph( + `~${deSig(props.match.params.ship)}`, + props.match.params.name + ); + + + } catch(err) { + setTimeout(autoJoin, 2000); } - return ( -
- Redirecting... -
- ); - }} - /> -
- ); - } + }; + + if(!graphKeys.has(resource)) { + autoJoin(); + } else if(!!association) { + history.push(`/~landscape/home/resource/${association.metadata.module}${path}`); + } + return ( +
+ Redirecting... +
+ ); + }} + /> +
+ ); } +export default GraphApp; \ No newline at end of file diff --git a/pkg/interface/src/views/apps/launch/app.js b/pkg/interface/src/views/apps/launch/app.js index 62b7ef30c9..f953a075de 100644 --- a/pkg/interface/src/views/apps/launch/app.js +++ b/pkg/interface/src/views/apps/launch/app.js @@ -1,13 +1,12 @@ import React, { useState, useMemo, useEffect } from 'react'; import styled from 'styled-components'; -import { useHistory } from 'react-router-dom'; import f from 'lodash/fp'; import _ from 'lodash'; import { Col, Button, Box, Row, Icon, Text } from '@tlon/indigo-react'; import './css/custom.css'; - +import useContactState from '~/logic/state/contact'; import Tiles from './components/tiles'; import Tile from './components/tiles/tile'; import Groups from './components/Groups'; @@ -20,6 +19,7 @@ import { NewGroup } from "~/views/landscape/components/NewGroup"; import { JoinGroup } from "~/views/landscape/components/JoinGroup"; import { Helmet } from 'react-helmet'; import useLocalState from "~/logic/state/local"; +import useHarkState from '~/logic/state/hark'; import { useWaitForProps } from '~/logic/lib/useWaitForProps'; import { useQuery } from "~/logic/lib/useQuery"; import { @@ -30,7 +30,9 @@ import { TUTORIAL_CHAT, TUTORIAL_LINKS } from '~/logic/lib/tutorialModal'; +import useLaunchState from '~/logic/state/launch'; import useSettingsState, { selectCalmState } from '~/logic/state/settings'; +import useMetadataState from '~/logic/state/metadata'; const ScrollbarLessBox = styled(Box)` @@ -44,9 +46,22 @@ const ScrollbarLessBox = styled(Box)` const tutSelector = f.pick(['tutorialProgress', 'nextTutStep', 'hideGroups']); export default function LaunchApp(props) { - const history = useHistory(); - const [hashText, setHashText] = useState(props.baseHash); + const connection = { props }; + const baseHash = useLaunchState(state => state.baseHash); + const [hashText, setHashText] = useState(baseHash); 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 = ( { - writeText(props.baseHash); + writeText(baseHash); setHashText('copied'); setTimeout(() => { - setHashText(props.baseHash); + setHashText(baseHash); }, 2000); }} > - {hashText || props.baseHash} + {hashText || baseHash} ); @@ -77,7 +92,7 @@ export default function LaunchApp(props) { useEffect(() => { if(query.get('tutorial')) { - if(hasTutorialGroup(props)) { + if(hasTutorialGroup({ associations })) { nextTutStep(); } else { showModal(); @@ -85,13 +100,6 @@ export default function LaunchApp(props) { } }, [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({ position: 'relative', maxWidth: '350px', @@ -103,7 +111,7 @@ export default function LaunchApp(props) { }; const onContinue = async (e) => { e.stopPropagation(); - if(!hasTutorialGroup(props)) { + if(!hasTutorialGroup({ associations })) { await props.api.groups.join(TUTORIAL_HOST, TUTORIAL_GROUP); await props.api.settings.putEntry('tutorial', 'joined', Date.now()); await waiter(hasTutorialGroup); @@ -137,14 +145,14 @@ export default function LaunchApp(props) {
Welcome - You have been invited to use Landscape, an interface to chat + You have been invited to use Landscape, an interface to chat and interact with communities
Would you like a tour of Landscape?
@@ -154,19 +162,17 @@ export default function LaunchApp(props) { )} }); - const hasLoaded = useMemo(() => Object.keys(props.contacts).length > 0, [props.contacts]); useEffect(() => { - const seenTutorial = _.get(props.settings, ['tutorial', 'seen'], true); - if(hasLoaded && !seenTutorial && tutorialProgress === 'hidden') { + if(hasLoaded && !seen && tutorialProgress === 'hidden') { showModal(); } - }, [props.settings, hasLoaded]); + }, [seen, hasLoaded]); return ( <> - { props.notificationsCount ? `(${String(props.notificationsCount) }) `: '' }Landscape + { notificationsCount ? `(${String(notificationsCount) }) `: '' }Landscape {modal} @@ -196,11 +202,7 @@ export default function LaunchApp(props) {
} {!hideGroups && - () + () }
{hashBox} diff --git a/pkg/interface/src/views/apps/launch/components/Groups.tsx b/pkg/interface/src/views/apps/launch/components/Groups.tsx index 798ab983cc..e93016e359 100644 --- a/pkg/interface/src/views/apps/launch/components/Groups.tsx +++ b/pkg/interface/src/views/apps/launch/components/Groups.tsx @@ -9,12 +9,13 @@ import { alphabeticalOrder } from '~/logic/lib/util'; import { getUnreadCount, getNotificationCount } from '~/logic/lib/hark'; import Tile from '../components/tiles/tile'; 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 useSettingsState, { selectCalmState, SettingsState } from '~/logic/state/settings'; -interface GroupsProps { - associations: Associations; -} +interface GroupsProps {} const sortGroupsAlph = (a: Association, b: Association) => a.group === TUTORIAL_GROUP_RESOURCE @@ -40,10 +41,13 @@ const getGraphNotifications = (associations: Associations, unreads: Unreads) => )(associations.graph); export default function Groups(props: GroupsProps & Parameters[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 || {}) - .filter(e => e?.group in props.groups) + .filter(e => e?.group in groupState) .sort(sortGroupsAlph); const graphUnreads = getGraphUnreads(associations || {}, unreads); const graphNotifications = getGraphNotifications(associations || {}, unreads); @@ -87,15 +91,19 @@ function Group(props: GroupProps) { isTutorialGroup, anchorRef ); - const { hideUnreads } = useSettingsState(selectCalmState) + const { hideUnreads } = useSettingsState(selectCalmState); const joined = useSettingsState(selectJoined); + const days = Math.max(0, Math.floor(moment.duration(moment(joined) + .add(14, 'days') + .diff(moment())) + .as('days'))) || 0; return ( {title} {!hideUnreads && ( - {isTutorialGroup && joined && - ({Math.floor(moment.duration(moment(joined).add(14, 'days').diff(moment())).as('days'))} days remaining) + {isTutorialGroup && joined && + ({days} day{days !== 1 && 's'} remaining) } {updates > 0 && ({updates} update{updates !== 1 && 's'} ) diff --git a/pkg/interface/src/views/apps/launch/components/tiles.js b/pkg/interface/src/views/apps/launch/components/tiles.js index d1bb7fc9db..da8b2326ac 100644 --- a/pkg/interface/src/views/apps/launch/components/tiles.js +++ b/pkg/interface/src/views/apps/launch/components/tiles.js @@ -5,50 +5,50 @@ import CustomTile from './tiles/custom'; import ClockTile from './tiles/clock'; import WeatherTile from './tiles/weather'; -export default class Tiles extends React.PureComponent { - render() { - const { props } = this; +import useLaunchState from '~/logic/state/launch'; - const tiles = props.tileOrdering.filter((key) => { - const tile = props.tiles[key]; +const Tiles = (props) => { + 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; - }).map((key) => { - const tile = props.tiles[key]; - if ('basic' in tile.type) { - const basic = tile.type.basic; + return tile.isShown; + }).map((key) => { + const tile = tileState[key]; + if ('basic' in tile.type) { + const basic = tile.type.basic; + return ( + + ); + } else if ('custom' in tile.type) { + if (key === 'weather') { return ( - ); - } else if ('custom' in tile.type) { - if (key === 'weather') { - return ( - - ); - } else if (key === 'clock') { - const location = 'nearest-area' in props.weather ? props.weather['nearest-area'][0] : ''; - return ( - - ); - } - } else { - return ; + } else if (key === 'clock') { + const location = weather && 'nearest-area' in weather ? weather['nearest-area'][0] : ''; + return ( + + ); } - }); + } else { + return ; + } + }); - return ( - {tiles} - ); - } + return ( + <>{tiles} + ); } +export default Tiles; diff --git a/pkg/interface/src/views/apps/launch/components/tiles/weather.js b/pkg/interface/src/views/apps/launch/components/tiles/weather.js index 7e409edd84..04c44726a7 100644 --- a/pkg/interface/src/views/apps/launch/components/tiles/weather.js +++ b/pkg/interface/src/views/apps/launch/components/tiles/weather.js @@ -2,6 +2,8 @@ import React from 'react'; import moment from 'moment'; import { Box, Icon, Text, BaseAnchor, BaseInput } from '@tlon/indigo-react'; import ErrorBoundary from '~/views/components/ErrorBoundary'; +import withState from '~/logic/lib/withState'; +import useLaunchState from '~/logic/state/launch'; import Tile from './tile'; @@ -34,7 +36,7 @@ const imperialCountries = [ 'Liberia', ]; -export default class WeatherTile extends React.Component { +class WeatherTile extends React.Component { constructor(props) { super(props); this.state = { @@ -289,3 +291,4 @@ export default class WeatherTile extends React.Component { } } +export default withState(WeatherTile, [[useLaunchState]]); diff --git a/pkg/interface/src/views/apps/links/LinkResource.tsx b/pkg/interface/src/views/apps/links/LinkResource.tsx index 091abb2a4b..b45ffa31ca 100644 --- a/pkg/interface/src/views/apps/links/LinkResource.tsx +++ b/pkg/interface/src/views/apps/links/LinkResource.tsx @@ -8,11 +8,14 @@ import { StoreState } from '~/logic/store/type'; import { RouteComponentProps } from 'react-router-dom'; import { LinkItem } from './components/LinkItem'; -import { LinkWindow } from './LinkWindow'; +import LinkWindow from './LinkWindow'; import { Comments } from '~/views/components/Comments'; import './css/custom.css'; 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 = () => {}; @@ -27,29 +30,24 @@ export function LinkResource(props: LinkResourceProps) { association, api, baseUrl, - graphs, - contacts, - groups, - associations, - graphKeys, - unreads, - graphTimesentMap, - storage, - history } = props; const rid = association.resource; const relativePath = (p: string) => `${baseUrl}/resource/link${rid}${p}`; + const associations = useMetadataState(state => state.associations); const [, , ship, name] = rid.split('/'); const resourcePath = `${ship.slice(1)}/${name}`; const resource = associations.graph[rid] ? associations.graph[rid] : { metadata: {} }; + const groups = useGroupState(state => state.groups); const group = groups[resource?.group] || {}; + const graphs = useGraphState(state => state.graphs); const graph = graphs[resourcePath] || null; + const graphTimesentMap = useGraphState(state => state.graphTimesentMap); useEffect(() => { api.graph.getGraph(ship, name); @@ -70,12 +68,9 @@ export function LinkResource(props: LinkResourceProps) { return ( {'<- Back'} { +class LinkWindow extends Component { fetchLinks = async () => true; canWrite() { @@ -75,7 +75,6 @@ export class LinkWindow extends Component { px={3} > { }; render() { - const { graph, api, association, storage, pendingSize } = this.props; + const { graph, api, association } = this.props; const first = graph.peekLargest()?.[0]; const [, , ship, name] = association.resource.split("/"); if (!first) { @@ -105,7 +104,6 @@ export class LinkWindow extends Component { > {this.canWrite() ? ( { data={graph} averageHeight={100} size={graph.size} - pendingSize={pendingSize} + pendingSize={this.props.pendingSize} renderer={this.renderItem} loadRows={this.fetchLinks} /> @@ -137,3 +135,5 @@ export class LinkWindow extends Component { ); } } + +export default LinkWindow; \ No newline at end of file diff --git a/pkg/interface/src/views/apps/links/components/LinkItem.tsx b/pkg/interface/src/views/apps/links/components/LinkItem.tsx index 94d961f5db..9579f4f540 100644 --- a/pkg/interface/src/views/apps/links/components/LinkItem.tsx +++ b/pkg/interface/src/views/apps/links/components/LinkItem.tsx @@ -10,6 +10,7 @@ import { roleForShip } from '~/logic/lib/group'; import GlobalApi from '~/logic/api/global'; import { Dropdown } from '~/views/components/Dropdown'; import RemoteContent from '~/views/components/RemoteContent'; +import useHarkState from '~/logic/state/hark'; interface LinkItemProps { node: GraphNode; @@ -17,8 +18,6 @@ interface LinkItemProps { api: GlobalApi; group: Group; path: string; - contacts: Rolodex; - unreads: Unreads; } export const LinkItem = (props: LinkItemProps): ReactElement => { @@ -28,7 +27,6 @@ export const LinkItem = (props: LinkItemProps): ReactElement => { api, group, path, - contacts, ...rest } = props; @@ -89,8 +87,9 @@ export const LinkItem = (props: LinkItemProps): ReactElement => { }; const appPath = `/ship/~${resource}`; - const commColor = (props.unreads.graph?.[appPath]?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray'; - const isUnread = props.unreads.graph?.[appPath]?.['/']?.unreads?.has(node.post.index); + const unreads = useHarkState(state => state.unreads); + const commColor = (unreads.graph?.[appPath]?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray'; + const isUnread = unreads.graph?.[appPath]?.['/']?.unreads?.has(node.post.index); return ( { - - - + /> { const { canUpload, uploadDefault, uploading, promptUpload } = - useStorage(props.storage); + useStorage(); const [submitFocused, setSubmitFocused] = useState(false); const [urlFocused, setUrlFocused] = useState(false); diff --git a/pkg/interface/src/views/apps/notifications/graph.tsx b/pkg/interface/src/views/apps/notifications/graph.tsx index 6092359b00..b469de907a 100644 --- a/pkg/interface/src/views/apps/notifications/graph.tsx +++ b/pkg/interface/src/views/apps/notifications/graph.tsx @@ -18,6 +18,8 @@ import { getSnippet } from '~/logic/lib/publish'; import styled from 'styled-components'; import { MentionText } from '~/views/components/MentionText'; import ChatMessage from '../chat/components/ChatMessage'; +import useContactState from '~/logic/state/contact'; +import useGroupState from '~/logic/state/group'; function getGraphModuleIcon(module: string) { if (module === 'link') { @@ -30,7 +32,7 @@ const FilterBox = styled(Box)` background: linear-gradient( to bottom, transparent, - ${p => p.theme.colors.white} + ${(p) => p.theme.colors.white} ); `; @@ -67,7 +69,6 @@ const GraphUrl = ({ url, title }) => ( const GraphNodeContent = ({ group, post, - contacts, mod, description, index, @@ -80,9 +81,7 @@ const GraphNodeContent = ({ const [{ text }, { url }] = contents; return ; } else if (idx.length === 3) { - return ( - - ); + return ; } return null; } @@ -92,7 +91,6 @@ const GraphNodeContent = ({ @@ -133,12 +131,12 @@ const GraphNodeContent = ({ renderSigil={false} containerClass='items-top cf hide-child' group={group} - contacts={contacts} groups={{}} associations={{ graph: {}, groups: {} }} msg={post} fontSize='0' pt='2' + hideHover={true} /> ); @@ -173,7 +171,6 @@ function getNodeUrl( } const GraphNode = ({ post, - contacts, author, mod, description, @@ -184,10 +181,11 @@ const GraphNode = ({ group, read, onRead, - showContact = false, + showContact = false }) => { author = deSig(author); const history = useHistory(); + const contacts = useContactState((state) => state.contacts); const nodeUrl = getNodeUrl(mod, group?.hidden, groupPath, graph, index); @@ -199,22 +197,18 @@ const GraphNode = ({ }, [read, onRead]); 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 ( {showContact && ( - + )} state.groups); + return ( <>
{_.map(contents, (content, idx) => ( ); diff --git a/pkg/interface/src/views/apps/notifications/header.tsx b/pkg/interface/src/views/apps/notifications/header.tsx index 76168137c0..f57d881887 100644 --- a/pkg/interface/src/views/apps/notifications/header.tsx +++ b/pkg/interface/src/views/apps/notifications/header.tsx @@ -9,16 +9,19 @@ import { Associations, Contact, Contacts, Rolodex } from '@urbit/api'; import { PropFunc } from '~/types/util'; import { useShowNickname } from '~/logic/lib/util'; import Timestamp from '~/views/components/Timestamp'; +import useContactState from '~/logic/state/contact'; +import useMetadataState from '~/logic/state/metadata'; const Text = (props: PropFunc) => ( ); -function Author(props: { patp: string; contacts: Contacts; last?: boolean }): ReactElement { - const contact: Contact | undefined = props.contacts?.[`~${props.patp}`]; +function Author(props: { patp: string; last?: boolean }): ReactElement { + const contacts = useContactState(state => state.contacts); + const contact: Contact | undefined = contacts?.[`~${props.patp}`]; const showNickname = useShowNickname(contact); - const name = contact?.nickname || `~${props.patp}`; + const name = showNickname ? contact.nickname : `~${props.patp}`; return ( @@ -33,14 +36,13 @@ export function Header(props: { archived?: boolean; channel?: string; group: string; - contacts: Rolodex; description: string; moduleIcon?: string; time: number; read: boolean; - associations: Associations; } & PropFunc ): 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); @@ -50,7 +52,7 @@ export function Header(props: { f.map(([idx, p]: [string, string]) => { const lent = Math.min(3, authors.length); const last = lent - 1 === parseInt(idx, 10); - return ; + return ; }), auths => ( @@ -64,11 +66,11 @@ export function Header(props: { const time = moment(props.time).format('HH:mm'); const groupTitle = - props.associations.groups?.[props.group]?.metadata?.title; + associations.groups?.[props.group]?.metadata?.title; const app = 'graph'; const channelTitle = - (channel && props.associations?.[app]?.[channel]?.metadata?.title) || + (channel && associations?.[app]?.[channel]?.metadata?.title) || channel; return ( diff --git a/pkg/interface/src/views/apps/notifications/inbox.tsx b/pkg/interface/src/views/apps/notifications/inbox.tsx index 2f05b736e8..4843a22486 100644 --- a/pkg/interface/src/views/apps/notifications/inbox.tsx +++ b/pkg/interface/src/views/apps/notifications/inbox.tsx @@ -23,10 +23,12 @@ import GlobalApi from '~/logic/api/global'; import { Notification } from './notification'; import { Invites } from './invites'; import { useLazyScroll } from '~/logic/lib/useLazyScroll'; +import useHarkState from '~/logic/state/hark'; +import useInviteState from '~/logic/state/invite'; type DatedTimebox = [BigInteger, Timebox]; -function filterNotification(associations: Associations, groups: string[]) { +function filterNotification(groups: string[]) { if (groups.length === 0) { return () => true; } @@ -43,21 +45,13 @@ function filterNotification(associations: Associations, groups: string[]) { } export default function Inbox(props: { - notifications: Notifications; - notificationsSize: number; archive: Notifications; - groups: Groups; showArchive?: boolean; api: GlobalApi; - associations: Associations; - contacts: Rolodex; filter: string[]; - invites: InviteType; pendingJoin: JoinRequests; - notificationsGroupConfig: GroupNotificationsConfig; - notificationsGraphConfig: NotificationGraphConfig; }) { - const { api, associations, invites } = props; + const { api } = props; useEffect(() => { let seen = false; setTimeout(() => { @@ -70,8 +64,11 @@ export default function Inbox(props: { }; }, []); + const notificationState = useHarkState(state => state.notifications); + const archivedNotifications = useHarkState(state => state.archivedNotifications); + const notifications = - Array.from(props.showArchive ? props.archive : props.notifications) || []; + Array.from(props.showArchive ? archivedNotifications : notificationState) || []; const calendar = { ...MOMENT_CALENDAR_DATE, sameDay: function (now) { @@ -86,7 +83,7 @@ export default function Inbox(props: { const notificationsByDay = f.flow( f.map(([date, nots]) => [ date, - nots.filter(filterNotification(associations, props.filter)) + nots.filter(filterNotification(props.filter)) ]), f.groupBy(([d]) => { const date = moment(daToUnix(d)); @@ -119,7 +116,7 @@ export default function Inbox(props: { return ( - + {[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => { const timeboxes = notificationsByDayMap.get(day)!; return timeboxes.length > 0 && ( @@ -127,13 +124,8 @@ export default function Inbox(props: { key={day} label={day === 'latest' ? 'Today' : moment(day).calendar(null, calendar)} timeboxes={timeboxes} - contacts={props.contacts} archive={Boolean(props.showArchive)} - associations={props.associations} api={api} - groups={props.groups} - graphConfig={props.notificationsGraphConfig} - groupConfig={props.notificationsGroupConfig} /> ); })} @@ -165,14 +157,9 @@ function sortIndexedNotification( function DaySection({ label, - contacts, - groups, archive, timeboxes, - associations, api, - groupConfig, - graphConfig }) { const lent = timeboxes.map(([,nots]) => nots.length).reduce(f.add, 0); if (lent === 0 || timeboxes.length === 0) { @@ -195,14 +182,9 @@ function DaySection({ )} diff --git a/pkg/interface/src/views/apps/notifications/invites.tsx b/pkg/interface/src/views/apps/notifications/invites.tsx index 829a383bc7..6c217d3365 100644 --- a/pkg/interface/src/views/apps/notifications/invites.tsx +++ b/pkg/interface/src/views/apps/notifications/invites.tsx @@ -7,14 +7,11 @@ import { Invites as IInvites, Associations, Invite, JoinRequests, Groups, Contac import GlobalApi from '~/logic/api/global'; import { resourceAsPath, alphabeticalOrder } from '~/logic/lib/util'; import InviteItem from '~/views/components/Invite'; +import useInviteState from '~/logic/state/invite'; +import useGroupState from '~/logic/state/group'; interface InvitesProps { api: GlobalApi; - invites: IInvites; - groups: Groups; - contacts: Contacts; - associations: Associations; - pendingJoin: JoinRequests; } interface InviteRef { @@ -24,7 +21,9 @@ interface InviteRef { } 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 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 } = - { ..._.keyBy(inviteArr, ({ invite }) => resourceAsPath(invite.resource)), ...props.pendingJoin }; + { ..._.keyBy(inviteArr, ({ invite }) => resourceAsPath(invite.resource)), ...pendingJoin }; return ( { const inviteOrStatus = invitesAndStatus[resource]; - if(typeof inviteOrStatus === 'string') { + const join = pendingJoin[resource]; + if(typeof inviteOrStatus === 'string') { return ( ); } else { const { app, uid, invite } = inviteOrStatus; - console.log(inviteOrStatus); return ( ); } diff --git a/pkg/interface/src/views/apps/notifications/notification.tsx b/pkg/interface/src/views/apps/notifications/notification.tsx index 8c550257d1..e8aba56163 100644 --- a/pkg/interface/src/views/apps/notifications/notification.tsx +++ b/pkg/interface/src/views/apps/notifications/notification.tsx @@ -18,17 +18,13 @@ import { GroupNotification } from './group'; import { GraphNotification } from './graph'; import { BigInteger } from 'big-integer'; import { useHovering } from '~/logic/lib/util'; +import useHarkState from '~/logic/state/hark'; interface NotificationProps { notification: IndexedNotification; time: BigInteger; - associations: Associations; api: GlobalApi; archived: boolean; - groups: Groups; - contacts: Contacts; - graphConfig: NotificationGraphConfig; - groupConfig: GroupNotificationsConfig; } function getMuted( @@ -61,8 +57,6 @@ function NotificationWrapper(props: { notif: IndexedNotification; children: ReactNode; archived: boolean; - graphConfig: NotificationGraphConfig; - groupConfig: GroupNotificationsConfig; }) { const { api, time, notif, children } = props; @@ -70,10 +64,13 @@ function NotificationWrapper(props: { return api.hark.archive(time, notif.index); }, [time, notif]); + const groupConfig = useHarkState(state => state.notificationsGroupConfig); + const graphConfig = useHarkState(state => state.notificationsGraphConfig); + const isMuted = getMuted( notif, - props.groupConfig, - props.graphConfig + groupConfig, + graphConfig ); const onChangeMute = useCallback(async () => { @@ -119,8 +116,6 @@ export function Notification(props: NotificationProps) { notif={notification} time={props.time} api={props.api} - graphConfig={props.graphConfig} - groupConfig={props.groupConfig} > {children} @@ -136,13 +131,10 @@ export function Notification(props: NotificationProps) { api={props.api} index={index} contents={c} - contacts={props.contacts} - groups={props.groups} read={read} archived={archived} timebox={props.time} time={time} - associations={associations} /> ); @@ -156,13 +148,10 @@ export function Notification(props: NotificationProps) { api={props.api} index={index} contents={c} - contacts={props.contacts} - groups={props.groups} read={read} timebox={props.time} archived={archived} time={time} - associations={associations} /> ); diff --git a/pkg/interface/src/views/apps/notifications/notifications.tsx b/pkg/interface/src/views/apps/notifications/notifications.tsx index 592e458c60..97c1682b9a 100644 --- a/pkg/interface/src/views/apps/notifications/notifications.tsx +++ b/pkg/interface/src/views/apps/notifications/notifications.tsx @@ -12,6 +12,8 @@ import { Dropdown } from '~/views/components/Dropdown'; import { FormikOnBlur } from '~/views/components/FormikOnBlur'; import GroupSearch from '~/views/components/GroupSearch'; import { useTutorialModal } from '~/views/components/useTutorialModal'; +import useHarkState from '~/logic/state/hark'; +import useMetadataState from '~/logic/state/metadata'; const baseUrl = '/~notifications'; @@ -38,6 +40,7 @@ export default function NotificationsScreen(props: any): ReactElement { const relativePath = (p: string) => baseUrl + p; const [filter, setFilter] = useState({ groups: [] }); + const associations = useMetadataState(state => state.associations); const onSubmit = async ({ groups } : NotificationFilter) => { setFilter({ groups }); }; @@ -48,10 +51,11 @@ export default function NotificationsScreen(props: any): ReactElement { filter.groups.length === 0 ? 'All' : filter.groups - .map(g => props.associations?.groups?.[g]?.metadata?.title) + .map(g => associations.groups?.[g]?.metadata?.title) .join(', '); const anchorRef = useRef(null); useTutorialModal('notifications', true, anchorRef); + const notificationsCount = useHarkState(state => state.notificationsCount); return ( - { props.notificationsCount ? `(${String(props.notificationsCount) }) `: '' }Landscape - Notifications + { notificationsCount ? `(${String(notificationsCount) }) `: '' }Landscape - Notifications @@ -110,7 +114,6 @@ export default function NotificationsScreen(props: any): ReactElement { id="groups" label="Filter Groups" caption="Only show notifications from this group" - associations={props.associations} /> diff --git a/pkg/interface/src/views/apps/profile/components/EditProfile.tsx b/pkg/interface/src/views/apps/profile/components/EditProfile.tsx index b36dbe2631..d59bd98a8c 100644 --- a/pkg/interface/src/views/apps/profile/components/EditProfile.tsx +++ b/pkg/interface/src/views/apps/profile/components/EditProfile.tsx @@ -21,6 +21,7 @@ import { ImageInput } from '~/views/components/ImageInput'; import { MarkdownField } from '~/views/apps/publish/components/MarkdownField'; import { resourceFromPath } from '~/logic/lib/group'; import GroupSearch from '~/views/components/GroupSearch'; +import useContactState from '~/logic/state/contact'; import { ProfileHeader, ProfileControls, @@ -48,7 +49,7 @@ const emptyContact = { }; export function ProfileHeaderImageEdit(props: any): ReactElement { - const { contact, storage, setFieldValue, handleHideCover } = { ...props }; + const { contact, setFieldValue, handleHideCover } = props; const [editCover, setEditCover] = useState(false); const [removedCoverLabel, setRemovedCoverLabel] = useState('Remove Header'); const handleClear = (e) => { @@ -63,7 +64,7 @@ export function ProfileHeaderImageEdit(props: any): ReactElement { {contact?.cover ? (
{editCover ? ( - + ) : (
) : ( - + )} ); } 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 handleHideCover = (value) => { @@ -148,7 +150,7 @@ export function EditProfile(props: any): ReactElement {
- + ); } - diff --git a/pkg/interface/src/views/apps/profile/components/ViewProfile.tsx b/pkg/interface/src/views/apps/profile/components/ViewProfile.tsx index 523306bb18..ddd2de9c4e 100644 --- a/pkg/interface/src/views/apps/profile/components/ViewProfile.tsx +++ b/pkg/interface/src/views/apps/profile/components/ViewProfile.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { ReactElement } from 'react'; import _ from 'lodash'; import { useHistory } from 'react-router-dom'; import { Center, Box, Text, Row, Col } from '@tlon/indigo-react'; @@ -15,11 +15,13 @@ import { ProfileStatus, ProfileImages } from './Profile'; +import useContactState from '~/logic/state/contact'; -export function ViewProfile(props: any) { - const history = useHistory(); +export function ViewProfile(props: any): ReactElement { 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 ( <> @@ -37,7 +39,7 @@ export function ViewProfile(props: any) {
- + {!hideNicknames && contact?.nickname ? contact.nickname : ''}
@@ -49,7 +51,7 @@ export function ViewProfile(props: any) {
- +
{contact?.bio ? contact.bio : ''} @@ -64,8 +66,6 @@ export function ViewProfile(props: any) { {}} /> ))} diff --git a/pkg/interface/src/views/apps/profile/profile.tsx b/pkg/interface/src/views/apps/profile/profile.tsx index dee3dc4d84..20f2dabc87 100644 --- a/pkg/interface/src/views/apps/profile/profile.tsx +++ b/pkg/interface/src/views/apps/profile/profile.tsx @@ -4,16 +4,19 @@ import Helmet from 'react-helmet'; 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) { - const { dark } = props; + const contacts = useContactState(state => state.contacts); + const notificationsCount = useHarkState(state => state.notificationsCount); return ( <> - {props.notificationsCount - ? `(${String(props.notificationsCount)}) ` + {notificationsCount + ? `(${String(notificationsCount)}) ` : ''} Landscape - Profile @@ -23,8 +26,7 @@ export default function ProfileScreen(props: any) { render={({ match }) => { const ship = match.params.ship; const isEdit = match.url.includes('edit'); - const isPublic = props.isContactPublic; - const contact = props.contacts?.[ship]; + const contact = contacts?.[ship]; return ( @@ -41,15 +43,10 @@ export default function ProfileScreen(props: any) { diff --git a/pkg/interface/src/views/apps/publish/PublishResource.tsx b/pkg/interface/src/views/apps/publish/PublishResource.tsx index 56821cda87..619b7502a8 100644 --- a/pkg/interface/src/views/apps/publish/PublishResource.tsx +++ b/pkg/interface/src/views/apps/publish/PublishResource.tsx @@ -24,18 +24,12 @@ export function PublishResource(props: PublishResourceProps) { api={api} ship={ship} book={book} - contacts={props.contacts} - groups={props.groups} - associations={props.associations} association={association} rootUrl={baseUrl} baseUrl={`${baseUrl}/resource/publish/ship/${ship}/${book}`} history={props.history} match={props.match} location={props.location} - unreads={props.unreads} - graphs={props.graphs} - storage={props.storage} /> ); diff --git a/pkg/interface/src/views/apps/publish/components/EditPost.tsx b/pkg/interface/src/views/apps/publish/components/EditPost.tsx index 58047fc745..dd73baa5b0 100644 --- a/pkg/interface/src/views/apps/publish/components/EditPost.tsx +++ b/pkg/interface/src/views/apps/publish/components/EditPost.tsx @@ -9,7 +9,6 @@ import { PostFormSchema, PostForm } from './NoteForm'; import GlobalApi from '~/logic/api/global'; import { getLatestRevision, editPost } from '~/logic/lib/publish'; import { useWaitForProps } from '~/logic/lib/useWaitForProps'; -import { StorageState } from '~/types'; interface EditPostProps { ship: string; @@ -17,11 +16,10 @@ interface EditPostProps { note: GraphNode; api: GlobalApi; book: string; - storage: StorageState; } 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 location = useLocation(); @@ -58,7 +56,6 @@ export function EditPost(props: EditPostProps & RouteComponentProps): ReactEleme cancel history={history} onSubmit={onSubmit} - storage={storage} submitLabel="Update" loadingText="Updating..." /> diff --git a/pkg/interface/src/views/apps/publish/components/MarkdownEditor.tsx b/pkg/interface/src/views/apps/publish/components/MarkdownEditor.tsx index 95bcf55247..47d69f8f6e 100644 --- a/pkg/interface/src/views/apps/publish/components/MarkdownEditor.tsx +++ b/pkg/interface/src/views/apps/publish/components/MarkdownEditor.tsx @@ -28,7 +28,6 @@ interface MarkdownEditorProps { value: string; onChange: (s: string) => void; onBlur?: (e: any) => void; - storage: StorageState; } const PromptIfDirty = () => { @@ -74,7 +73,7 @@ export function MarkdownEditor( [onBlur] ); - const { uploadDefault, canUpload } = useStorage(props.storage); + const { uploadDefault, canUpload } = useStorage(); const onFileDrag = useCallback( async (files: FileList | File[], e: DragEvent) => { diff --git a/pkg/interface/src/views/apps/publish/components/MarkdownField.tsx b/pkg/interface/src/views/apps/publish/components/MarkdownField.tsx index 0a1a1125a5..aa67e27dec 100644 --- a/pkg/interface/src/views/apps/publish/components/MarkdownField.tsx +++ b/pkg/interface/src/views/apps/publish/components/MarkdownField.tsx @@ -6,7 +6,6 @@ import { MarkdownEditor } from './MarkdownEditor'; export const MarkdownField = ({ id, - storage, ...rest }: { id: string } & Parameters[0]) => { const [{ value, onBlur }, { error, touched }, { setValue }] = useField(id); @@ -36,7 +35,6 @@ export const MarkdownField = ({ onBlur={handleBlur} value={value} onChange={setValue} - storage={storage} /> {error} diff --git a/pkg/interface/src/views/apps/publish/components/MetadataForm.tsx b/pkg/interface/src/views/apps/publish/components/MetadataForm.tsx index 7a3bb39ae4..6c70c105ce 100644 --- a/pkg/interface/src/views/apps/publish/components/MetadataForm.tsx +++ b/pkg/interface/src/views/apps/publish/components/MetadataForm.tsx @@ -22,7 +22,6 @@ interface MetadataFormProps { host: string; book: string; association: Association; - contacts: Contacts; api: GlobalApi; } diff --git a/pkg/interface/src/views/apps/publish/components/Note.tsx b/pkg/interface/src/views/apps/publish/components/Note.tsx index ba1c8dc538..8d5277cc3f 100644 --- a/pkg/interface/src/views/apps/publish/components/Note.tsx +++ b/pkg/interface/src/views/apps/publish/components/Note.tsx @@ -1,5 +1,5 @@ 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 bigInt from 'big-integer'; @@ -9,6 +9,7 @@ import { Comments } from '~/views/components/Comments'; import { NoteNavigation } from './NoteNavigation'; import GlobalApi from '~/logic/api/global'; import { getLatestRevision, getComments } from '~/logic/lib/publish'; +import { roleForShip } from '~/logic/lib/group'; import Author from '~/views/components/Author'; import { Contacts, GraphNode, Graph, Association, Unreads, Group } from '@urbit/api'; @@ -16,10 +17,8 @@ interface NoteProps { ship: string; book: string; note: GraphNode; - unreads: Unreads; association: Association; notebook: Graph; - contacts: Contacts; api: GlobalApi; rootUrl: string; baseUrl: string; @@ -29,7 +28,7 @@ interface NoteProps { export function Note(props: NoteProps & RouteComponentProps) { 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 renderers = { @@ -56,29 +55,37 @@ export function Note(props: NoteProps & RouteComponentProps) { api.hark.markEachAsRead(props.association, '/',`/${index[1]}/1/1`, 'note', 'publish'); }, [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) { - adminLinks = ( - - - - Update - + + Update + - - Delete - - - ); - } + ) + }; + + if (window.ship === note?.post?.author || ourRole === "admin") { + adminLinks.push( + + Delete + + ) + }; const windowRef = React.useRef(null); useEffect(() => { @@ -105,14 +112,15 @@ export function Note(props: NoteProps & RouteComponentProps) { {title || ''} - + - {adminLinks} - + {adminLinks} + @@ -126,9 +134,7 @@ export function Note(props: NoteProps & RouteComponentProps) { Promise; submitLabel: string; loadingText: string; - storage: StorageState; } const formSchema = Yup.object({ @@ -35,7 +33,7 @@ export interface PostFormSchema { } export function PostForm(props: PostFormProps) { - const { initial, onSubmit, submitLabel, loadingText, storage, cancel, history } = props; + const { initial, onSubmit, submitLabel, loadingText, cancel, history } = props; return ( @@ -67,7 +65,7 @@ export function PostForm(props: PostFormProps) { >Cancel} - + diff --git a/pkg/interface/src/views/apps/publish/components/NotePreview.tsx b/pkg/interface/src/views/apps/publish/components/NotePreview.tsx index 8036f317f2..6dc7d2052d 100644 --- a/pkg/interface/src/views/apps/publish/components/NotePreview.tsx +++ b/pkg/interface/src/views/apps/publish/components/NotePreview.tsx @@ -12,17 +12,14 @@ import { getSnippet } from '~/logic/lib/publish'; import { Unreads } from '@urbit/api'; -import GlobalApi from '~/logic/api/global'; import ReactMarkdown from 'react-markdown'; +import useHarkState from '~/logic/state/hark'; interface NotePreviewProps { host: string; book: string; node: GraphNode; baseUrl: string; - unreads: Unreads; - contacts: Contacts; - api: GlobalApi; group: Group; } @@ -31,7 +28,7 @@ const WrappedBox = styled(Box)` `; export function NotePreview(props: NotePreviewProps) { - const { node, contacts, group } = props; + const { node, group } = props; const { post } = node; if (!post) { return null; @@ -43,11 +40,12 @@ export function NotePreview(props: NotePreviewProps) { const [rev, title, body, content] = getLatestRevision(node); 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 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'; @@ -92,12 +90,10 @@ export function NotePreview(props: NotePreviewProps) { diff --git a/pkg/interface/src/views/apps/publish/components/NoteRoutes.tsx b/pkg/interface/src/views/apps/publish/components/NoteRoutes.tsx index f56668c81a..8ad5c74653 100644 --- a/pkg/interface/src/views/apps/publish/components/NoteRoutes.tsx +++ b/pkg/interface/src/views/apps/publish/components/NoteRoutes.tsx @@ -15,13 +15,11 @@ interface NoteRoutesProps { note: GraphNode; noteId: number; notebook: Graph; - contacts: Contacts; api: GlobalApi; association: Association; baseUrl?: string; rootUrl?: string; - group: Group; - storage: StorageState; + group: Group } export function NoteRoutes(props: NoteRoutesProps & RouteComponentProps) { diff --git a/pkg/interface/src/views/apps/publish/components/Notebook.tsx b/pkg/interface/src/views/apps/publish/components/Notebook.tsx index d350d648b4..885844f30f 100644 --- a/pkg/interface/src/views/apps/publish/components/Notebook.tsx +++ b/pkg/interface/src/views/apps/publish/components/Notebook.tsx @@ -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 { NotebookPosts } from './NotebookPosts'; -import GlobalApi from '~/logic/api/global'; import { useShowNickname } from '~/logic/lib/util'; +import useContactState from '~/logic/state/contact'; +import useGroupState from '~/logic/state/group'; interface NotebookProps { - api: GlobalApi; ship: string; book: string; graph: Graph; association: Association; - associations: Associations; - contacts: Rolodex; - groups: Groups; baseUrl: string; rootUrl: string; unreads: Unreads; } -export function Notebook(props: NotebookProps & RouteComponentProps): ReactElement { +export function Notebook(props: NotebookProps & RouteComponentProps): ReactElement | null { const { ship, book, - contacts, - groups, association, graph } = props; - const group = groups[association?.group]; - if (!group) { - return null; // Waiting on groups to populate - } + const groups = useGroupState(state => state.groups); + const contacts = useContactState(state => state.contacts); + const group = groups[association?.group]; const relativePath = (p: string) => props.baseUrl + p; const contact = contacts?.[`~${ship}`]; - console.log(association.resource); const showNickname = useShowNickname(contact); + if (!group) { + return null; // Waiting on groups to populate + } + return ( @@ -60,10 +57,7 @@ export function Notebook(props: NotebookProps & RouteComponentProps): ReactEleme graph={graph} host={ship} book={book} - contacts={contacts} - unreads={props.unreads} baseUrl={props.baseUrl} - api={props.api} group={group} /> diff --git a/pkg/interface/src/views/apps/publish/components/NotebookPosts.tsx b/pkg/interface/src/views/apps/publish/components/NotebookPosts.tsx index 53d551e486..b18e74eed0 100644 --- a/pkg/interface/src/views/apps/publish/components/NotebookPosts.tsx +++ b/pkg/interface/src/views/apps/publish/components/NotebookPosts.tsx @@ -2,21 +2,20 @@ import React, { Component } from 'react'; import { Col } from '@tlon/indigo-react'; import { NotePreview } from './NotePreview'; import { Contacts, Graph, Unreads, Group } from '@urbit/api'; +import useContactState from '~/logic/state/contact'; interface NotebookPostsProps { - contacts: Contacts; graph: Graph; host: string; book: string; baseUrl: string; - unreads: Unreads; hideAvatars?: boolean; hideNicknames?: boolean; - api: GlobalApi; group: Group; } export function NotebookPosts(props: NotebookPostsProps) { + const contacts = useContactState(state => state.contacts); return ( {Array.from(props.graph || []).map( @@ -26,12 +25,9 @@ export function NotebookPosts(props: NotebookPostsProps) { key={date.toString()} host={props.host} book={props.book} - unreads={props.unreads} - contact={props.contacts[`~${node.post.author}`]} - contacts={props.contacts} + contact={contacts[`~${node.post.author}`]} node={node} baseUrl={props.baseUrl} - api={props.api} group={props.group} /> ) diff --git a/pkg/interface/src/views/apps/publish/components/NotebookRoutes.tsx b/pkg/interface/src/views/apps/publish/components/NotebookRoutes.tsx index 51efad37a6..56c6b3b23f 100644 --- a/pkg/interface/src/views/apps/publish/components/NotebookRoutes.tsx +++ b/pkg/interface/src/views/apps/publish/components/NotebookRoutes.tsx @@ -17,32 +17,32 @@ import bigInt from 'big-integer'; import Notebook from './Notebook'; import NewPost from './new-post'; import { NoteRoutes } from './NoteRoutes'; +import useGraphState from '~/logic/state/graph'; +import useGroupState from '~/logic/state/group'; interface NotebookRoutesProps { api: GlobalApi; ship: string; book: string; - graphs: Graphs; - unreads: Unreads; - contacts: Rolodex; - groups: Groups; baseUrl: string; rootUrl: string; association: Association; - associations: Associations; - storage: StorageState; } export function NotebookRoutes( props: NotebookRoutesProps & RouteComponentProps ) { - const { ship, book, api, contacts, baseUrl, rootUrl, groups } = props; + const { ship, book, api, baseUrl, rootUrl } = props; useEffect(() => { ship && book && api.graph.getGraph(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]; @@ -59,7 +59,6 @@ export function NotebookRoutes( return )} /> @@ -104,12 +102,9 @@ export function NotebookRoutes( ship={ship} note={note} notebook={graph} - unreads={props.unreads} noteId={noteIdNum} - contacts={contacts} association={props.association} group={group} - storage={props.storage} {...routeProps} /> ); diff --git a/pkg/interface/src/views/apps/publish/components/Writers.js b/pkg/interface/src/views/apps/publish/components/Writers.js index e8ff547af0..d06e1a78bd 100644 --- a/pkg/interface/src/views/apps/publish/components/Writers.js +++ b/pkg/interface/src/views/apps/publish/components/Writers.js @@ -7,7 +7,7 @@ import { AsyncButton } from '~/views/components/AsyncButton'; export class Writers extends Component { render() { - const { association, groups, contacts, api } = this.props; + const { association, groups, api } = this.props; const resource = resourceFromPath(association?.group); @@ -39,8 +39,6 @@ export class Writers extends Component { >
); } diff --git a/pkg/interface/src/views/apps/publish/css/custom.css b/pkg/interface/src/views/apps/publish/css/custom.css index 920c889471..e3768772c5 100644 --- a/pkg/interface/src/views/apps/publish/css/custom.css +++ b/pkg/interface/src/views/apps/publish/css/custom.css @@ -142,6 +142,10 @@ margin-bottom: 16px; } +.md ul ul { + margin-bottom: 0px; +} + .md h2, .md h3, .md h4, .md h5, .md p, .md a, .md ul { font-weight: 400; } diff --git a/pkg/interface/src/views/apps/settings/components/lib/BackButton.tsx b/pkg/interface/src/views/apps/settings/components/lib/BackButton.tsx index 570618c641..1fdcc633cf 100644 --- a/pkg/interface/src/views/apps/settings/components/lib/BackButton.tsx +++ b/pkg/interface/src/views/apps/settings/components/lib/BackButton.tsx @@ -4,8 +4,16 @@ import { Text } from '@tlon/indigo-react'; export function BackButton(props: {}) { return ( - - {"<- Back to System Preferences"} + + + {'<- Back to System Preferences'} + ); } diff --git a/pkg/interface/src/views/apps/settings/components/lib/BackgroundPicker.tsx b/pkg/interface/src/views/apps/settings/components/lib/BackgroundPicker.tsx index b897aca394..698843ae44 100644 --- a/pkg/interface/src/views/apps/settings/components/lib/BackgroundPicker.tsx +++ b/pkg/interface/src/views/apps/settings/components/lib/BackgroundPicker.tsx @@ -20,13 +20,11 @@ export function BackgroundPicker({ bgType, bgUrl, api, - storage }: { bgType: BgType; bgUrl?: string; api: GlobalApi; - storage: StorageState; -}) { +}): ReactElement { const rowSpace = { my: 0, alignItems: 'center' }; const colProps = { my: 3, mr: 4, gapY: 1 }; return ( @@ -39,7 +37,6 @@ export function BackgroundPicker({ ) => { @@ -67,10 +67,10 @@ export function CalmPrefs(props: { api.settings.putEntry('calm', 'hideUnreads', v.hideUnreads), api.settings.putEntry('calm', 'hideGroups', v.hideGroups), api.settings.putEntry('calm', 'hideUtilities', v.hideUtilities), - api.settings.putEntry('remoteContentPolicy', 'imageShown', v.imageShown), - api.settings.putEntry('remoteContentPolicy', 'videoShown', v.videoShown), - api.settings.putEntry('remoteContentPolicy', 'audioShown', v.audioShown), - api.settings.putEntry('remoteContentPolicy', 'oembedShown', v.oembedShown), + api.settings.putEntry('remoteContentPolicy', 'imageShown', !v.imageShown), + api.settings.putEntry('remoteContentPolicy', 'videoShown', !v.videoShown), + api.settings.putEntry('remoteContentPolicy', 'audioShown', !v.audioShown), + api.settings.putEntry('remoteContentPolicy', 'oembedShown', !v.oembedShown), ]); actions.setStatus({ success: null }); }, [api]); @@ -80,7 +80,7 @@ export function CalmPrefs(props: { - + CalmEngine @@ -115,24 +115,24 @@ export function CalmPrefs(props: { id="hideNicknames" caption="Do not show user-set nicknames" /> - Remote Content + Remote content diff --git a/pkg/interface/src/views/apps/settings/components/lib/Debug.tsx b/pkg/interface/src/views/apps/settings/components/lib/Debug.tsx new file mode 100644 index 0000000000..c667ee2bcd --- /dev/null +++ b/pkg/interface/src/views/apps/settings/components/lib/Debug.tsx @@ -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>; +} + +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 ( + + setVisible(!visible)}>{name} + {visible && + { + if (event.target.value) { + tryFilter(event.target.value); + } else { + setFilter(''); + setText(objectToString(state)); + } + }} /> + {text} + } + + ); +}; + +const DebugPane = () => { + return ( + <> + + + + + Debug Menu + + + Debug Landscape state. Click any state to see its contents and drill down. + + + + + + + + + + + + + + ) +}; + +export default DebugPane; \ No newline at end of file diff --git a/pkg/interface/src/views/apps/settings/components/lib/DisplayForm.tsx b/pkg/interface/src/views/apps/settings/components/lib/DisplayForm.tsx index a673dc06a9..34b9862452 100644 --- a/pkg/interface/src/views/apps/settings/components/lib/DisplayForm.tsx +++ b/pkg/interface/src/views/apps/settings/components/lib/DisplayForm.tsx @@ -36,13 +36,12 @@ interface FormSchema { interface DisplayFormProps { api: GlobalApi; - storage: StorageState; } const settingsSel = selectSettingsState(["display"]); export default function DisplayForm(props: DisplayFormProps) { - const { api, storage } = props; + const { api } = props; const { display: { @@ -108,7 +107,6 @@ export default function DisplayForm(props: DisplayFormProps) { bgType={props.values.bgType} bgUrl={props.values.bgUrl} api={api} - storage={storage} /> diff --git a/pkg/interface/src/views/apps/settings/components/lib/NotificationPref.tsx b/pkg/interface/src/views/apps/settings/components/lib/NotificationPref.tsx index ae04e14ae9..c673f326a8 100644 --- a/pkg/interface/src/views/apps/settings/components/lib/NotificationPref.tsx +++ b/pkg/interface/src/views/apps/settings/components/lib/NotificationPref.tsx @@ -4,11 +4,12 @@ import { Text, ManagedToggleSwitchField as Toggle, } from "@tlon/indigo-react"; -import { Form, FormikHelpers } from "formik"; -import { FormikOnBlur } from "~/views/components/FormikOnBlur"; +import { Formik, Form, FormikHelpers } from "formik"; import { BackButton } from "./BackButton"; 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 { mentions: boolean; @@ -18,10 +19,10 @@ interface FormSchema { export function NotificationPreferences(props: { 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 = { mentions: graphConfig.mentions, dnd: dnd, @@ -43,12 +44,11 @@ export function NotificationPreferences(props: { await Promise.all(promises); actions.setStatus({ success: null }); - actions.resetForm({ values: initialValues }); } catch (e) { console.error(e); actions.setStatus({ error: e.message }); } - }, [api]); + }, [api, graphConfig, dnd]); return ( <> @@ -63,7 +63,7 @@ export function NotificationPreferences(props: { messaging - + + + Save + - + ); diff --git a/pkg/interface/src/views/apps/settings/components/lib/RemoteContent.tsx b/pkg/interface/src/views/apps/settings/components/lib/RemoteContent.tsx deleted file mode 100644 index 1ab87e54bd..0000000000 --- a/pkg/interface/src/views/apps/settings/components/lib/RemoteContent.tsx +++ /dev/null @@ -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 ( - { - setRemoteContentPolicy((state) => { - Object.assign(state.remoteContentPolicy, values); - }); - actions.setSubmitting(false); - }} - > - {props => ( -
- - - Remote Content - - - - - - - -
- )} -
- ); -} - diff --git a/pkg/interface/src/views/apps/settings/components/lib/S3Form.tsx b/pkg/interface/src/views/apps/settings/components/lib/S3Form.tsx index 417dea9ad5..005aa563f7 100644 --- a/pkg/interface/src/views/apps/settings/components/lib/S3Form.tsx +++ b/pkg/interface/src/views/apps/settings/components/lib/S3Form.tsx @@ -1,5 +1,5 @@ import React, { ReactElement, useCallback } from 'react'; -import { Formik } from 'formik'; +import { Formik, FormikHelpers } from 'formik'; import { ManagedTextInputField as Input, @@ -10,12 +10,15 @@ import { Col, Anchor } from '@tlon/indigo-react'; +import { AsyncButton } from "~/views/components/AsyncButton"; -import GlobalApi from "~/logic/api/global"; -import { BucketList } from "./BucketList"; +import GlobalApi from '~/logic/api/global'; +import { BucketList } from './BucketList'; import { S3State } from '~/types/s3-update'; +import useS3State from '~/logic/state/storage'; import { BackButton } from './BackButton'; -import {StorageState} from '~/types'; +import { StorageState } from '~/types'; +import useStorageState from '~/logic/state/storage'; interface FormSchema { s3bucket: string; @@ -27,33 +30,33 @@ interface FormSchema { interface S3FormProps { api: GlobalApi; - storage: StorageState; } export default function S3Form(props: S3FormProps): ReactElement { - const { api, storage } = props; - const { s3 } = storage; + const { api } = props; + const s3 = useStorageState((state) => state.s3); - const onSubmit = useCallback( - (values: FormSchema) => { + const onSubmit = useCallback(async (values: FormSchema, actions: FormikHelpers) => { if (values.s3secretAccessKey !== s3.credentials?.secretAccessKey) { - api.s3.setSecretAccessKey(values.s3secretAccessKey); + await api.s3.setSecretAccessKey(values.s3secretAccessKey); } if (values.s3endpoint !== s3.credentials?.endpoint) { - api.s3.setEndpoint(values.s3endpoint); + await api.s3.setEndpoint(values.s3endpoint); } if (values.s3accessKeyId !== s3.credentials?.accessKeyId) { - api.s3.setAccessKeyId(values.s3accessKeyId); + await api.s3.setAccessKeyId(values.s3accessKeyId); } + actions.setStatus({ success: null }); }, [api, s3] ); return ( <> - + +
- - - - + + + S3 Storage Setup Store credentials for your S3 object storage buckets on your Urbit ship, and upload media freely to various modules. + borderBottom='1' + ml='1' + href='https://urbit.org/using/operations/using-your-ship/#bucket-setup' + > Learn more - - + + - +
- - - + + + S3 Buckets diff --git a/pkg/interface/src/views/apps/settings/components/settings.tsx b/pkg/interface/src/views/apps/settings/components/settings.tsx index 9deeec6eb3..9c49fd414f 100644 --- a/pkg/interface/src/views/apps/settings/components/settings.tsx +++ b/pkg/interface/src/views/apps/settings/components/settings.tsx @@ -7,7 +7,6 @@ import { StoreState } from "~/logic/store/type"; import DisplayForm from "./lib/DisplayForm"; import S3Form from "./lib/S3Form"; import SecuritySettings from "./lib/Security"; -import RemoteContentForm from "./lib/RemoteContent"; import { NotificationPreferences } from "./lib/NotificationPref"; import { CalmPrefs } from "./lib/CalmPref"; import { Link } from "react-router-dom"; diff --git a/pkg/interface/src/views/apps/settings/settings.tsx b/pkg/interface/src/views/apps/settings/settings.tsx index 38572086f6..a4a293820f 100644 --- a/pkg/interface/src/views/apps/settings/settings.tsx +++ b/pkg/interface/src/views/apps/settings/settings.tsx @@ -1,35 +1,44 @@ -import React, { ReactNode } from "react"; -import { useLocation } from "react-router-dom"; -import Helmet from "react-helmet"; +import React, { ReactNode, useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import Helmet from 'react-helmet'; import { Text, Box, Col, Row } from '@tlon/indigo-react'; -import { NotificationPreferences } from "./components/lib/NotificationPref"; -import DisplayForm from "./components/lib/DisplayForm"; -import S3Form from "./components/lib/S3Form"; -import { CalmPrefs } from "./components/lib/CalmPref"; -import SecuritySettings from "./components/lib/Security"; -import { LeapSettings } from "./components/lib/LeapSettings"; -import { useHashLink } from "~/logic/lib/useHashLink"; -import { SidebarItem as BaseSidebarItem } from "~/views/landscape/components/SidebarItem"; -import { PropFunc } from "~/types"; +import { NotificationPreferences } from './components/lib/NotificationPref'; +import DisplayForm from './components/lib/DisplayForm'; +import S3Form from './components/lib/S3Form'; +import { CalmPrefs } from './components/lib/CalmPref'; +import SecuritySettings from './components/lib/Security'; +import { LeapSettings } from './components/lib/LeapSettings'; +import { useHashLink } from '~/logic/lib/useHashLink'; +import { SidebarItem as BaseSidebarItem } from '~/views/landscape/components/SidebarItem'; +import { PropFunc } from '~/types'; +import DebugPane from './components/lib/Debug'; +import useHarkState from '~/logic/state/hark'; export const Skeleton = (props: { children: ReactNode }) => ( - + {props.children} ); -type ProvSideProps = "to" | "selected"; +type ProvSideProps = 'to' | 'selected'; type BaseProps = PropFunc; function SidebarItem(props: { hash: string } & Omit) { const { hash, icon, text, ...rest } = props; @@ -54,85 +63,81 @@ function SettingsItem(props: { children: ReactNode }) { const { children } = props; return ( - + {children} ); } export default function SettingsScreen(props: any) { - 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 ( <> - Landscape - Settings + { notificationsCount ? `(${String(notificationsCount) }) `: '' }Landscape - Settings - - - - System Preferences - - - - - - - - - + + + System Preferences + + + + + + + + - - - {hash === "notifications" && ( - - )} - {hash === "display" && ( - - )} - {hash === "s3" && ( - - )} - {hash === "leap" && ( - - )} - {hash === "calm" && ( - - )} - {hash === "security" && ( - - )} - - - + + + + {hash === 'notifications' && ( + + )} + {hash === 'display' && } + {hash === 's3' && } + {hash === 'leap' && } + {hash === 'calm' && } + {hash === 'security' && } + {hash === 'debug' && } + + ); diff --git a/pkg/interface/src/views/apps/term/app.js b/pkg/interface/src/views/apps/term/app.js index b63a950028..eeef6d461e 100644 --- a/pkg/interface/src/views/apps/term/app.js +++ b/pkg/interface/src/views/apps/term/app.js @@ -10,10 +10,12 @@ import { Box, Col } from '@tlon/indigo-react'; import Api from './api'; import Store from './store'; import Subscription from './subscription'; +import withState from '~/logic/lib/withState'; +import useHarkState from '~/logic/state/hark'; import './css/custom.css'; -export default class TermApp extends Component { +class TermApp extends Component { constructor(props) { super(props); this.store = new Store(); @@ -93,3 +95,5 @@ export default class TermApp extends Component { ); } } + +export default withState(TermApp, [[useHarkState]]); diff --git a/pkg/interface/src/views/components/Author.tsx b/pkg/interface/src/views/components/Author.tsx index ec5a418457..754071fa56 100644 --- a/pkg/interface/src/views/components/Author.tsx +++ b/pkg/interface/src/views/components/Author.tsx @@ -11,23 +11,21 @@ import useSettingsState, {selectCalmState} from "~/logic/state/settings"; import useLocalState from "~/logic/state/local"; import OverlaySigil from './OverlaySigil'; import { Sigil } from '~/logic/lib/sigil'; -import GlobalApi from '~/logic/api/global'; import Timestamp from './Timestamp'; +import useContactState from '~/logic/state/contact'; interface AuthorProps { - contacts: Contacts; ship: string; date: number; showImage?: boolean; children?: ReactNode; unread?: boolean; group: Group; - api?: GlobalApi; } // eslint-disable-next-line max-lines-per-function export default function Author(props: AuthorProps): ReactElement { - const { contacts, ship = '', date, showImage, group } = props; + const { ship = '', date, showImage, group } = props; const history = useHistory(); const osDark = useLocalState((state) => state.dark); @@ -35,6 +33,7 @@ export default function Author(props: AuthorProps): ReactElement { const dark = theme === 'dark' || (theme === 'auto' && osDark) let contact; + const contacts = useContactState(state => state.contacts); if (contacts) { contact = `~${deSig(ship)}` in contacts ? contacts[`~${deSig(ship)}`] : null; } @@ -53,6 +52,7 @@ export default function Author(props: AuthorProps): ReactElement { const img = contact?.avatar && !hideAvatars ? ( { await api.graph.removeNodes(ship, name, [comment.post?.index]); @@ -42,38 +42,46 @@ export function CommentItem(props: CommentItemProps): ReactElement { const commentIndex = commentIndexArray[commentIndexArray.length - 1]; const updateUrl = `${props.baseUrl}/${commentIndex}`; + const adminLinks: JSX.Element[] = []; + const ourRole = roleForShip(group, window.ship); + if (window.ship == post?.author && !disabled) { + adminLinks.push( + + + Update + + + ) + }; + + if ((window.ship == post?.author || ourRole == "admin") && !disabled) { + adminLinks.push( + + Delete + + ) + }; + return ( - {!disabled && ( - - - - Update - - - - Delete - - - )} + + {adminLinks} + diff --git a/pkg/interface/src/views/components/Comments.tsx b/pkg/interface/src/views/components/Comments.tsx index 6c6d135133..f234999bd1 100644 --- a/pkg/interface/src/views/components/Comments.tsx +++ b/pkg/interface/src/views/components/Comments.tsx @@ -13,6 +13,7 @@ import tokenizeMessage from '~/logic/lib/tokenizeMessage'; import { getUnreadCount } from '~/logic/lib/hark'; import { PropFunc } from '~/types/util'; import { isWriter } from '~/logic/lib/group'; +import useHarkState from '~/logic/state/hark'; interface CommentsProps { comments: GraphNode; @@ -21,7 +22,6 @@ interface CommentsProps { ship: string; editCommentId: string; baseUrl: string; - contacts: Contacts; api: GlobalApi; group: Group; } @@ -109,7 +109,8 @@ export function Comments(props: CommentsProps & PropFunc) { }; }, [comments.post.index]); - const readCount = children.length - getUnreadCount(props?.unreads, association.resource, parentIndex); + const unreads = useHarkState(state => state.unreads); + const readCount = children.length - getUnreadCount(unreads, association.resource, parentIndex); const canComment = isWriter(group, association.resource) || association.metadata.vip === 'reader-comments'; @@ -130,7 +131,6 @@ export function Comments(props: CommentsProps & PropFunc) { { + return ( + + ); +}; + +export default Dot; diff --git a/pkg/interface/src/views/components/Error.tsx b/pkg/interface/src/views/components/Error.tsx index 0ff060e05b..7f0b954266 100644 --- a/pkg/interface/src/views/components/Error.tsx +++ b/pkg/interface/src/views/components/Error.tsx @@ -18,6 +18,16 @@ const Details = styled.details``; class ErrorComponent extends Component { render () { const { code, error, history, description } = this.props; + let title = ''; + if (error) { + title = error.message; + } else if (description) { + title = description; + } + let body = ''; + if (error) { + body =`\`\`\`%0A${error.stack?.replaceAll('\n', '%0A')}%0A\`\`\``; + } return ( @@ -32,12 +42,21 @@ class ErrorComponent extends Component { “{error.message}”
- Stack trace - {error.stack} + Stack trace + {error.stack}
)} - If this is unexpected, email support@tlon.io or submit an issue. + If this is unexpected, email support@tlon.io or submit an issue. {history.length > 1 ? : diff --git a/pkg/interface/src/views/components/ErrorBoundary.tsx b/pkg/interface/src/views/components/ErrorBoundary.tsx index c166dfe6b9..6ab7516a6c 100644 --- a/pkg/interface/src/views/components/ErrorBoundary.tsx +++ b/pkg/interface/src/views/components/ErrorBoundary.tsx @@ -1,10 +1,12 @@ import React, { Component } from 'react'; import { RouteComponentProps, withRouter } from 'react-router-dom'; +import StackTrace from 'stacktrace-js'; import ErrorComponent from './Error'; +import { Spinner } from '~/views/components/Spinner'; class ErrorBoundary extends Component< RouteComponentProps, - { error?: Error } + { error?: Error | true} > { constructor(props) { super(props); @@ -19,13 +21,31 @@ class ErrorBoundary extends Component< }); } - componentDidCatch(error) { - this.setState({ error }); + componentDidCatch(error: Error) { + this.setState({ error: true }); + StackTrace.fromError(error).then(stackframes => { + const stack = stackframes.map(frame => { + return `${frame.functionName} (${frame.fileName} ${frame.lineNumber}:${frame.columnNumber})`; + }).join('\n'); + error = { name: error.name, message: error.message, stack }; + this.setState({ error }) + }); return false; } render() { if (this.state.error) { + if (this.state.error === true) { + return ( +
+ +
+ ); + } return (); } return this.props.children; diff --git a/pkg/interface/src/views/components/GroupLink.tsx b/pkg/interface/src/views/components/GroupLink.tsx index 276574eb6a..97cb25628d 100644 --- a/pkg/interface/src/views/components/GroupLink.tsx +++ b/pkg/interface/src/views/components/GroupLink.tsx @@ -9,22 +9,22 @@ import { JoinGroup } from '../landscape/components/JoinGroup'; import { useModal } from '~/logic/lib/useModal'; import { GroupSummary } from '../landscape/components/GroupSummary'; import { PropFunc } from '~/types'; +import useMetadataState from '~/logic/state/metadata'; import {useVirtual} from '~/logic/lib/virtualContext'; export function GroupLink( props: { api: GlobalApi; resource: string; - associations: Associations; - groups: Groups; detailed?: boolean; } & PropFunc ): ReactElement { - const { resource, api, associations, groups, ...rest } = props; + const { resource, api, ...rest } = props; const name = resource.slice(6); const [preview, setPreview] = useState(null); + const associations = useMetadataState(state => state.associations); - const joined = resource in props.associations.groups; + const joined = resource in associations.groups; const { save, restore } = useVirtual(); @@ -40,8 +40,6 @@ export function GroupLink(
) : ( diff --git a/pkg/interface/src/views/components/GroupSearch.tsx b/pkg/interface/src/views/components/GroupSearch.tsx index fe887e17e7..1bf50137ce 100644 --- a/pkg/interface/src/views/components/GroupSearch.tsx +++ b/pkg/interface/src/views/components/GroupSearch.tsx @@ -18,13 +18,13 @@ import { Associations, Association } from '@urbit/api/metadata'; import { roleForShip } from '~/logic/lib/group'; import { DropdownSearch } from './DropdownSearch'; +import useGroupState from '~/logic/state/group'; +import useMetadataState from '~/logic/state/metadata'; interface GroupSearchProps { disabled?: boolean; adminOnly?: boolean; publicOnly?: boolean; - groups: Groups; - associations: Associations; label: string; caption?: string; id: I; @@ -86,34 +86,36 @@ export function GroupSearch>(props: Gr const value: string[] = values[id]; const touched = touchedFields[id] ?? false; const error = _.compact(errors[id] as string[]); + const groupState = useGroupState(state => state.groups); + const associations = useMetadataState(state => state.associations); const groups: Association[] = useMemo(() => { if (props.adminOnly) { return Object.values( - Object.keys(props.associations?.groups) + Object.keys(associations.groups) .filter( - e => roleForShip(props.groups[e], window.ship) === 'admin' + e => roleForShip(groupState[e], window.ship) === 'admin' ) .reduce((obj, key) => { - obj[key] = props.associations?.groups[key]; + obj[key] = associations.groups[key]; return obj; }, {}) || {} ); } else if (props.publicOnly) { return Object.values( - Object.keys(props.associations?.groups) + Object.keys(associations.groups) .filter( - e => props.groups?.[e]?.policy?.open + e => groupState?.[e]?.policy?.open ) .reduce((obj, key) => { - obj[key] = props.associations?.groups[key]; + obj[key] = associations.groups[key]; return obj; }, {}) || {} ); } else { - return Object.values(props.associations?.groups || {}); + return Object.values(associations.groups || {}); } - }, [props.associations?.groups]); + }, [associations.groups]); return ( >(props: Gr {value?.length > 0 && ( value.map((e, idx: number) => { const { title } = - props.associations.groups?.[e]?.metadata || {}; + associations.groups?.[e]?.metadata || {}; return ( [0] & { id: string; label: string; - storage: StorageState; placeholder?: string; }; export function ImageInput(props: ImageInputProps): ReactElement { - const { id, label, storage, caption, placeholder } = props; + const { id, label, caption, placeholder } = props; - const { uploadDefault, canUpload, uploading } = useStorage(storage); + const { uploadDefault, canUpload, uploading } = useStorage(); const [field, meta, { setValue, setError }] = useField(id); diff --git a/pkg/interface/src/views/components/Invite/index.tsx b/pkg/interface/src/views/components/Invite/index.tsx index d57cdc1b85..073abc2ae1 100644 --- a/pkg/interface/src/views/components/Invite/index.tsx +++ b/pkg/interface/src/views/components/Invite/index.tsx @@ -11,46 +11,54 @@ import { import { Invite } from '@urbit/api/invite'; import { Text, Icon, Row } from '@tlon/indigo-react'; -import { cite } from '~/logic/lib/util'; +import { cite, useShowNickname } from '~/logic/lib/util'; import GlobalApi from '~/logic/api/global'; import { resourceFromPath } from '~/logic/lib/group'; import { GroupInvite } from './Group'; import { InviteSkeleton } from './InviteSkeleton'; import { JoinSkeleton } from './JoinSkeleton'; import { useWaitForProps } from '~/logic/lib/useWaitForProps'; +import useGroupState from '~/logic/state/group'; +import useContactState from '~/logic/state/contact'; +import useMetadataState from '~/logic/state/metadata'; +import useGraphState from '~/logic/state/graph'; interface InviteItemProps { invite?: Invite; resource: string; - groups: Groups; - associations: Associations; - - pendingJoin: JoinRequests; + pendingJoin?: string; app?: string; uid?: string; api: GlobalApi; - contacts: Contacts; } export function InviteItem(props: InviteItemProps) { const [preview, setPreview] = useState(null); - const { associations, pendingJoin, invite, resource, uid, app, api } = props; + const { pendingJoin, invite, resource, uid, app, api } = props; const { ship, name } = resourceFromPath(resource); - const waiter = useWaitForProps(props, 50000); - const status = pendingJoin[resource]; + const groups = useGroupState(state => state.groups); + const graphKeys = useGraphState(s => s.graphKeys); + const associations = useMetadataState(state => state.associations); + const contacts = useContactState(state => state.contacts); + const contact = contacts?.[`~${invite?.ship}`] ?? {}; + const showNickname = useShowNickname(contact); + const waiter = useWaitForProps( + { associations, groups, pendingJoin, graphKeys: Array.from(graphKeys) }, + 50000 + ); const history = useHistory(); const inviteAccept = useCallback(async () => { if (!(app && invite && uid)) { return; } - if(resource in props.groups) { + if(resource in groups) { await api.invite.decline(app, uid); return; } api.groups.join(ship, name); - await waiter(p => resource in p.pendingJoin); + await waiter(p => !!p.pendingJoin); api.invite.accept(app, uid); await waiter((p) => { @@ -61,7 +69,8 @@ export function InviteItem(props: InviteItemProps) { ); }); - if (props.groups?.[resource]?.hidden) { + if (groups?.[resource]?.hidden) { + await waiter(p => p.graphKeys.includes(resource.slice(7))); const { metadata } = associations.graph[resource]; if (metadata?.module === 'chat') { history.push(`/~landscape/messages/resource/${metadata.module}${resource}`); @@ -71,7 +80,7 @@ export function InviteItem(props: InviteItemProps) { } else { history.push(`/~landscape${resource}`); } - }, [app, invite, uid, resource, props.groups, associations]); + }, [app, history, waiter, invite, uid, resource, groups, associations]); const inviteDecline = useCallback(async () => { if(!(app && uid)) { @@ -114,8 +123,10 @@ export function InviteItem(props: InviteItemProps) { > - - {cite(`~${invite!.ship}`)} + + {showNickname ? contact?.nickname : cite(`~${invite!.ship}`)} invited you to a DM @@ -140,8 +151,10 @@ export function InviteItem(props: InviteItemProps) { > - - {cite(`~${invite!.ship}`)} + + {showNickname ? contact?.nickname : cite(`~${invite!.ship}`)} invited you to ~{invite.resource.ship}/{invite.resource.name} @@ -149,10 +162,10 @@ export function InviteItem(props: InviteItemProps) { ); - } else if (status) { + } else if (pendingJoin) { const [, , ship, name] = resource.split('/'); return ( - + diff --git a/pkg/interface/src/views/components/MentionText.tsx b/pkg/interface/src/views/components/MentionText.tsx index d6a0adb86e..ac9f5755ba 100644 --- a/pkg/interface/src/views/components/MentionText.tsx +++ b/pkg/interface/src/views/components/MentionText.tsx @@ -6,18 +6,18 @@ import RichText from '~/views/components/RichText'; import { cite, useShowNickname, uxToHex } from '~/logic/lib/util'; import OverlaySigil from '~/views/components/OverlaySigil'; import { useHistory } from 'react-router-dom'; +import useContactState from '~/logic/state/contact'; interface MentionTextProps { contact?: Contact; - contacts?: Contacts; content: Content[]; group: Group; } export function MentionText(props: MentionTextProps) { - const { content, contacts, contact, group, ...rest } = props; + const { content, contact, group, ...rest } = props; return ( - + {content.reduce((accum, c) => { if ('text' in c) { return accum + c.text; @@ -34,14 +34,14 @@ export function MentionText(props: MentionTextProps) { export function Mention(props: { contact: Contact; - contacts?: Contacts; group: Group; scrollWindow?: HTMLElement; ship: string; first?: Boolean; }) { - const { contacts, ship, scrollWindow, first, ...rest } = props; + const { ship, scrollWindow, first, ...rest } = props; let { contact } = props; + const contacts = useContactState(state => state.contacts); contact = contact?.color ? contact : contacts?.[`~${ship}`]; const history = useHistory(); const showNickname = useShowNickname(contact); diff --git a/pkg/interface/src/views/components/ProfileOverlay.tsx b/pkg/interface/src/views/components/ProfileOverlay.tsx index 283dad6247..6d653b8aef 100644 --- a/pkg/interface/src/views/components/ProfileOverlay.tsx +++ b/pkg/interface/src/views/components/ProfileOverlay.tsx @@ -1,4 +1,4 @@ -import React, { PureComponent } from 'react'; +import React, { PureComponent, useCallback, useEffect, useRef } from 'react'; import { Contact, Group } from '@urbit/api'; import { cite, useShowNickname } from '~/logic/lib/util'; import { Sigil } from '~/logic/lib/sigil'; @@ -11,11 +11,12 @@ import { Text, BaseImage, ColProps, - Icon + Icon, + Center } from '@tlon/indigo-react'; import RichText from './RichText'; -import { withLocalState } from '~/logic/state/local'; import { ProfileStatus } from './ProfileStatus'; +import useSettingsState from '~/logic/state/settings'; export const OVERLAY_HEIGHT = 250; @@ -33,167 +34,156 @@ type ProfileOverlayProps = ColProps & { api: any; }; -class ProfileOverlay extends PureComponent< - ProfileOverlayProps, - Record -> { - public popoverRef: React.Ref; +const ProfileOverlay = (props: ProfileOverlayProps) => { + const { + contact, + ship, + color, + topSpace, + bottomSpace, + history, + onDismiss, + ...rest + } = props; + const hideAvatars = useSettingsState((state) => state.calm.hideAvatars); + const hideNicknames = useSettingsState((state) => state.calm.hideNicknames); + const popoverRef = useRef(null); - constructor(props) { - super(props); + const onDocumentClick = useCallback( + (event) => { + if (!popoverRef.current || popoverRef?.current?.contains(event.target)) { + return; + } + onDismiss(); + }, + [onDismiss, popoverRef] + ); - this.popoverRef = React.createRef(); - this.onDocumentClick = this.onDocumentClick.bind(this); + useEffect(() => { + document.addEventListener('mousedown', onDocumentClick); + document.addEventListener('touchstart', onDocumentClick); + + return () => { + document.removeEventListener('mousedown', onDocumentClick); + document.removeEventListener('touchstart', onDocumentClick); + }; + }, [onDocumentClick]); + + let top, bottom; + if (topSpace < OVERLAY_HEIGHT / 2) { + top = '0px'; } - - componentDidMount() { - document.addEventListener('mousedown', this.onDocumentClick); - document.addEventListener('touchstart', this.onDocumentClick); + if (bottomSpace < OVERLAY_HEIGHT / 2) { + bottom = '0px'; } - - componentWillUnmount() { - document.removeEventListener('mousedown', this.onDocumentClick); - document.removeEventListener('touchstart', this.onDocumentClick); + if (!(top || bottom)) { + bottom = `-${Math.round(OVERLAY_HEIGHT / 2)}px`; } + const containerStyle = { top, bottom, left: '100%' }; - onDocumentClick(event) { - const { popoverRef } = this; - // Do nothing if clicking ref's element or descendent elements - if (!popoverRef.current || popoverRef?.current?.contains(event.target)) { - return; - } + const isOwn = window.ship === ship; - this.props.onDismiss(); - } - - render() { - const { - contact, - ship, - color, - topSpace, - bottomSpace, - hideAvatars, - hideNicknames, - history, - onDismiss, - ...rest - } = this.props; - - let top, bottom; - if (topSpace < OVERLAY_HEIGHT / 2) { - top = '0px'; - } - if (bottomSpace < OVERLAY_HEIGHT / 2) { - bottom = '0px'; - } - if (!(top || bottom)) { - bottom = `-${Math.round(OVERLAY_HEIGHT / 2)}px`; - } - const containerStyle = { top, bottom, left: '100%' }; - - const isOwn = window.ship === ship; - - const img = - contact?.avatar && !hideAvatars ? ( - - ) : ( - - ); - const showNickname = useShowNickname(contact, hideNicknames); - - return ( - - - {!isOwn && ( - history.push(`/~landscape/dm/${ship}`)} - /> - )} - - history.push(`/~profile/~${ship}`)} - overflow='hidden' - borderRadius={2} - > - {img} - - - - - {showNickname ? contact?.nickname : cite(ship)} - - - {isOwn ? ( - - ) : ( - - {contact?.status ? contact.status : ''} - - )} - - + /> + ) : ( + +
+ +
+
); - } -} + const showNickname = useShowNickname(contact, hideNicknames); -export default withLocalState(ProfileOverlay, ['hideAvatars', 'hideNicknames']); + return ( + + + {!isOwn && ( + history.push(`/~landscape/dm/${ship}`)} + /> + )} + + history.push(`/~profile/~${ship}`)} + overflow='hidden' + borderRadius={2} + > + {img} + + + + + {showNickname ? contact?.nickname : cite(ship)} + + + {isOwn ? ( + + ) : ( + + {contact?.status ?? ''} + + )} + + + ); +}; + +export default ProfileOverlay; diff --git a/pkg/interface/src/views/components/RemoteContent.tsx b/pkg/interface/src/views/components/RemoteContent.tsx index 614c804bbe..0895aba8aa 100644 --- a/pkg/interface/src/views/components/RemoteContent.tsx +++ b/pkg/interface/src/views/components/RemoteContent.tsx @@ -2,10 +2,11 @@ import React, { Component, Fragment } from 'react'; import { BaseAnchor, BaseImage, Box, Button, Text } from '@tlon/indigo-react'; import { hasProvider } from 'oembed-parser'; import EmbedContainer from 'react-oembed-container'; -import { withSettingsState } from '~/logic/state/settings'; +import useSettingsState from '~/logic/state/settings'; import { RemoteContentPolicy } from '~/types/local-update'; import { VirtualContextProps, withVirtual } from "~/logic/lib/virtualContext"; import { IS_IOS } from '~/logic/lib/platform'; +import withState from '~/logic/lib/withState'; type RemoteContentProps = VirtualContextProps & { url: string; @@ -50,7 +51,6 @@ class RemoteContent extends Component { } save = () => { - console.log(`saving for: ${this.props.url}`); if(this.saving) { return; } @@ -59,7 +59,6 @@ class RemoteContent extends Component { }; restore = () => { - console.log(`restoring for: ${this.props.url}`); this.saving = false; this.props.restore(); } @@ -169,6 +168,7 @@ return; return this.wrapInLink( ( linkReference: (linkProps) => { const linkText = String(linkProps.children[0].props.children); if (isValidPatp(linkText)) { - return ; + return ; } return linkText; }, diff --git a/pkg/interface/src/views/components/ShipSearch.tsx b/pkg/interface/src/views/components/ShipSearch.tsx index d80c4ed09f..a33b8745bc 100644 --- a/pkg/interface/src/views/components/ShipSearch.tsx +++ b/pkg/interface/src/views/components/ShipSearch.tsx @@ -24,6 +24,8 @@ import { Rolodex, Groups } from '@urbit/api'; import { DropdownSearch } from './DropdownSearch'; import { cite, deSig } from '~/logic/lib/util'; import { HoverBox } from './HoverBox'; +import useContactState from '~/logic/state/contact'; +import useGroupState from '~/logic/state/group'; interface InviteSearchProps { autoFocus?: boolean; @@ -31,8 +33,6 @@ interface InviteSearchProps { label?: string; caption?: string; id: I; - contacts: Rolodex; - groups: Groups; hideSelection?: boolean; maxLength?: number; } @@ -124,9 +124,12 @@ export function ShipSearch>( const pills = selected.slice(0, inputIdx.current); + const contacts = useContactState(state => state.contacts); + const groups = useGroupState(state => state.groups); + const [peers, nicknames] = useMemo( - () => getNicknameForShips(props.groups, props.contacts, selected), - [props.contacts, props.groups, selected] + () => getNicknameForShips(groups, contacts, selected), + [contacts, groups, selected] ); const renderCandidate = useCallback( @@ -156,6 +159,14 @@ export function ShipSearch>( const error = _.compact(errors[id] as string[]); + const isExact = useCallback((s: string) => { + const ship = `~${deSig(s)}`; + const result = ob.isValidPatp(ship); + return (result && !selected.includes(deSig(s))) + ? deSig(s) ?? undefined + : undefined; + }, [selected]); + return ( >( mt="2" - isExact={(s) => { - const ship = `~${deSig(s)}`; - const result = ob.isValidPatp(ship); - return result ? deSig(s) ?? undefined : undefined; - }} + isExact={isExact} placeholder="Search for ships" candidates={peers} renderCandidate={renderCandidate} diff --git a/pkg/interface/src/views/components/StatusBar.js b/pkg/interface/src/views/components/StatusBar.js index f02ec43ce0..478d98d224 100644 --- a/pkg/interface/src/views/components/StatusBar.js +++ b/pkg/interface/src/views/components/StatusBar.js @@ -1,8 +1,4 @@ -import React, { - useState, - useEffect, - useRef -} from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import { Col, @@ -13,66 +9,89 @@ import { Button, BaseImage } from '@tlon/indigo-react'; + import ReconnectButton from './ReconnectButton'; import { Dropdown } from './Dropdown'; import { StatusBarItem } from './StatusBarItem'; import { Sigil } from '~/logic/lib/sigil'; -import { uxToHex } from "~/logic/lib/util"; -import { SetStatusBarModal } from './SetStatusBarModal'; +import { uxToHex } from '~/logic/lib/util'; +import { ProfileStatus } from './ProfileStatus'; import { useTutorialModal } from './useTutorialModal'; +import useHarkState from '~/logic/state/hark'; +import useInviteState from '~/logic/state/invite'; +import useContactState from '~/logic/state/contact'; +import { useHistory } from 'react-router-dom'; import useLocalState, { selectLocalState } from '~/logic/state/local'; import useSettingsState, { selectCalmState } from '~/logic/state/settings'; - const localSel = selectLocalState(['toggleOmnibox']); const StatusBar = (props) => { - const { ourContact, api, ship } = props; - const invites = [].concat(...Object.values(props.invites).map(obj => Object.values(obj))); - const metaKey = (window.navigator.platform.includes('Mac')) ? '⌘' : 'Ctrl+'; + const { api, ship } = props; + const history = useHistory(); + const ourContact = useContactState((state) => state.contacts[`~${ship}`]); + const notificationsCount = useHarkState((state) => state.notificationsCount); + const doNotDisturb = useHarkState((state) => state.doNotDisturb); + const inviteState = useInviteState((state) => state.invites); + const invites = [].concat( + ...Object.values(inviteState).map((obj) => Object.values(obj)) + ); + const metaKey = window.navigator.platform.includes('Mac') ? '⌘' : 'Ctrl+'; const { toggleOmnibox } = useLocalState(localSel); const { hideAvatars } = useSettingsState(selectCalmState); - const color = !!ourContact ? `#${uxToHex(props.ourContact.color)}` : '#000'; - const xPadding = (!hideAvatars && ourContact?.avatar) ? '0' : '2'; - const bgColor = (!hideAvatars && ourContact?.avatar) ? '' : color; - const profileImage = (!hideAvatars && ourContact?.avatar) ? ( - - ) : ; + const color = !!ourContact ? `#${uxToHex(ourContact.color)}` : '#000'; + const xPadding = !hideAvatars && ourContact?.avatar ? '0' : '2'; + const bgColor = !hideAvatars && ourContact?.avatar ? '' : color; + const profileImage = + !hideAvatars && ourContact?.avatar ? ( + + ) : ( + + ); const anchorRef = useRef(null); const leapHighlight = useTutorialModal('leap', true, anchorRef); - const floatLeap = leapHighlight && window.matchMedia('(max-width: 550px)').matches; + const floatLeap = + leapHighlight && window.matchMedia('(max-width: 550px)').matches; return ( + > - + toggleOmnibox()}> - { !props.doNotDisturb && (props.notificationsCount > 0 || invites.length > 0) && - ( - - - )} - + {!doNotDisturb && (notificationsCount > 0 || invites.length > 0) && ( + + + + )} + Leap @@ -85,68 +104,96 @@ const StatusBar = (props) => { subscription={props.subscription} /> - + window.open( - 'https://github.com/urbit/landscape/issues/new' + - '?assignees=&labels=development-stream&title=&' + - `body=commit:%20urbit/urbit@${process.env.LANDSCAPE_SHORTHASH}` - )} - > - Submit an issue + onClick={() => + window.open( + 'https://github.com/urbit/landscape/issues/new' + + '?assignees=&labels=development-stream&title=&' + + `body=commit:%20urbit/urbit@${process.env.LANDSCAPE_SHORTHASH}` + ) + } + > + + Submit{' '} + + an + {' '} + issue + - props.history.push('/~landscape/messages')}> - + props.history.push('/~landscape/messages')} + > + + borderColor='lightGray' + boxShadow='0px 0px 0px 3px' + > props.history.push(`/~profile/~${ship}`)}> + fontWeight='500' + px={3} + py={2} + onClick={() => history.push(`/~profile/~${ship}`)} + > View Profile - props.history.push('/~settings')}> - System Settings + fontWeight='500' + px={3} + py={2} + onClick={() => history.push('/~settings')} + > + System Preferences + + + + Set Status: + + - }> + } + > + backgroundColor={bgColor} + > {profileImage} diff --git a/pkg/interface/src/views/components/Timestamp.tsx b/pkg/interface/src/views/components/Timestamp.tsx index 8ae91336de..a73ce72eb7 100644 --- a/pkg/interface/src/views/components/Timestamp.tsx +++ b/pkg/interface/src/views/components/Timestamp.tsx @@ -11,20 +11,24 @@ export type TimestampProps = BoxProps & { stamp: MomentType; date?: boolean; time?: boolean; -} +}; -const Timestamp = (props: TimestampProps): ReactElement | null=> { +const Timestamp = (props: TimestampProps): ReactElement | null => { const { stamp, date, time, color, fontSize, ...rest } = { - time: true, color: 'gray', fontSize: 0, ...props + time: true, + color: 'gray', + fontSize: 0, + ...props }; if (!stamp) return null; - const { hovering, bind } = date === true - ? { hovering: true, bind: {} } - : useHovering(); + const { hovering, bind } = + date === true ? { hovering: true, bind: {} } : useHovering(); let datestamp = stamp.format(DateFormat); if (stamp.format(DateFormat) === moment().format(DateFormat)) { datestamp = 'Today'; - } else if (stamp.format(DateFormat) === moment().subtract(1, 'day').format(DateFormat)) { + } else if ( + stamp.format(DateFormat) === moment().subtract(1, 'day').format(DateFormat) + ) { datestamp = 'Yesterday'; } const timestamp = stamp.format(TimeFormat); @@ -33,22 +37,28 @@ const Timestamp = (props: TimestampProps): ReactElement | null=> { {...bind} display='flex' flex='row' - flexWrap="nowrap" + flexWrap='nowrap' {...rest} title={stamp.format(DateFormat + ' ' + TimeFormat)} > - {time && {timestamp}} - {date !== false && - {datestamp} - } + {time && ( + + {timestamp} + + )} + {date !== false && ( + + {time ? '\u00A0' : ''} + {datestamp} + + )} - ) -} + ); +}; -export default Timestamp; \ No newline at end of file +export default Timestamp; diff --git a/pkg/interface/src/views/components/UnjoinedResource.tsx b/pkg/interface/src/views/components/UnjoinedResource.tsx index 6782cd365e..8e9156a7c0 100644 --- a/pkg/interface/src/views/components/UnjoinedResource.tsx +++ b/pkg/interface/src/views/components/UnjoinedResource.tsx @@ -9,34 +9,35 @@ import { StatelessAsyncButton as AsyncButton, StatelessAsyncButton } from './StatelessAsyncButton'; -import { Notebooks, Graphs, Inbox } from '@urbit/api'; +import { Graphs } from '@urbit/api'; +import useGraphState from '~/logic/state/graph'; interface UnjoinedResourceProps { association: Association; api: GlobalApi; baseUrl: string; - notebooks: Notebooks; - graphKeys: Set; - inbox: Inbox; } function isJoined(path: string) { return function ( props: Pick ) { + const graphKey = path.substr(7); return props.graphKeys.has(graphKey); }; } export function UnjoinedResource(props: UnjoinedResourceProps) { - const { api, notebooks, graphKeys, inbox } = props; + const { api } = props; const history = useHistory(); const rid = props.association.resource; const appName = props.association['app-name']; - const { title, description, module } = props.association.metadata; - const waiter = useWaitForProps(props); - const app = useMemo(() => module || appName, [props.association]); + const { title, description, module: mod } = props.association.metadata; + const graphKeys = useGraphState(state => state.graphKeys); + + const waiter = useWaitForProps({...props, graphKeys }); + const app = useMemo(() => mod || appName, [props.association]); const onJoin = async () => { const [, , ship, name] = rid.split('/'); @@ -49,7 +50,7 @@ export function UnjoinedResource(props: UnjoinedResourceProps) { if (isJoined(rid)({ graphKeys })) { history.push(`${props.baseUrl}/resource/${app}${rid}`); } - }, [props.association, inbox, graphKeys, notebooks]); + }, [props.association, graphKeys]); return (
diff --git a/pkg/interface/src/views/components/VirtualScroller.tsx b/pkg/interface/src/views/components/VirtualScroller.tsx index f36fd0334c..bb764c7ddf 100644 --- a/pkg/interface/src/views/components/VirtualScroller.tsx +++ b/pkg/interface/src/views/components/VirtualScroller.tsx @@ -40,7 +40,7 @@ interface VirtualScrollerProps { data: BigIntOrderedMap; /** * The component to render the items - * + * * @remarks * * This component must be referentially stable, so either use `useCallback` or @@ -69,6 +69,10 @@ interface VirtualScrollerProps { */ offset: number; style?: any; + /** + * Callback to execute when finished loading from start + */ + onBottomLoaded?: () => void; } interface VirtualScrollerState { @@ -153,14 +157,10 @@ export default class VirtualScroller extends Component extends Component this.loadRows(false), 100); + loadBottom = _.throttle(() => this.loadRows(true), 100); - loadRows = _.throttle(async (newer: boolean) => { + loadRows = async (newer: boolean) => { const dir = newer ? 'bottom' : 'top'; if(this.loaded[dir]) { return; @@ -317,8 +319,11 @@ export default class VirtualScroller extends Component extends Component extends Component this.props.data.size)) { - this.loadRows(false) + this.loadTop(); } if(newOffset !== startOffset) { @@ -485,7 +490,6 @@ export default class VirtualScroller extends Component extends Component {!IS_IOS && ( { this.scrollRef = el; }} right="0" height="50px" position="absolute" width="4px" backgroundColor="lightGray" />)} - + {(isTop ? !atStart : !atEnd) && (
diff --git a/pkg/interface/src/views/components/leap/Omnibox.tsx b/pkg/interface/src/views/components/leap/Omnibox.tsx index f6fa1957bb..6884a16928 100644 --- a/pkg/interface/src/views/components/leap/Omnibox.tsx +++ b/pkg/interface/src/views/components/leap/Omnibox.tsx @@ -17,21 +17,20 @@ import {useOutsideClick} from '~/logic/lib/useOutsideClick'; import {Portal} from '../Portal'; import useSettingsState, {SettingsState} from '~/logic/state/settings'; import { Tile } from '~/types'; +import useContactState from '~/logic/state/contact'; +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'; interface OmniboxProps { - associations: Associations; - contacts: Contacts; - groups: Groups; - tiles: { - [app: string]: Tile; - }; show: boolean; toggle: () => void; notifications: number; - invites: Invites; } -const SEARCHED_CATEGORIES = ['ships', 'other', 'commands', 'groups', 'subscriptions', 'apps']; +const SEARCHED_CATEGORIES = ['commands', 'ships', 'other', 'groups', 'subscriptions', 'apps']; const settingsSel = (s: SettingsState) => s.leap; export function Omnibox(props: OmniboxProps) { @@ -43,13 +42,20 @@ export function Omnibox(props: OmniboxProps) { const [query, setQuery] = useState(''); const [selected, setSelected] = useState<[] | [string, string]>([]); + const contactState = useContactState(state => state.contacts); + const notifications = useHarkState(state => state.notifications); + const invites = useInviteState(state => state.invites); + const tiles = useLaunchState(state => state.tiles); const contacts = useMemo(() => { const maybeShip = `~${deSig(query)}`; return ob.isValidPatp(maybeShip) - ? { ...props.contacts, [maybeShip]: {} } - : props.contacts; - }, [props.contacts, query]); + ? { ...contactState, [maybeShip]: {} } + : contactState; + }, [contactState, query]); + + const groups = useGroupState(state => state.groups); + const associations = useMetadataState(state => state.associations); const selectedGroup = useMemo( () => location.pathname.startsWith('/~landscape/ship/') @@ -61,19 +67,19 @@ export function Omnibox(props: OmniboxProps) { const index = useMemo(() => { return makeIndex( contacts, - props.associations, - props.tiles, + associations, + tiles, selectedGroup, - props.groups, + groups, leapConfig, ); }, [ selectedGroup, leapConfig, contacts, - props.associations, - props.groups, - props.tiles + associations, + groups, + tiles ]); const onOutsideClick = useCallback(() => { @@ -245,6 +251,15 @@ export function Omnibox(props: OmniboxProps) { setQuery(event.target.value); }, []); + // Sort Omnibox results alphabetically + const sortResults = (a: Record<'title', string>, b: Record<'title', string>) => { + // Do not sort unless searching (preserves order of menu actions) + if (query === '') { return 0 }; + if (a.title < b.title) { return -1 }; + if (a.title > b.title) { return 1 }; + return 0; + } + const renderResults = useCallback(() => { return {categoryTitle} - {categoryResults.map((result, i2) => ( - navigate(result.app, result.link)} - selected={sel} - invites={props.invites} - notifications={props.notifications} - contacts={props.contacts} - /> + {categoryResults + .sort(sortResults) + .map((result, i2) => ( + navigate(result.app, result.link)} + selected={sel} + /> ))} ); }) } ; - }, [results, navigate, selected, props.contacts, props.notifications, props.invites]); + }, [results, navigate, selected, contactState, notifications, invites]); return ( diff --git a/pkg/interface/src/views/components/leap/OmniboxResult.js b/pkg/interface/src/views/components/leap/OmniboxResult.js index b1357eb92f..c44114688c 100644 --- a/pkg/interface/src/views/components/leap/OmniboxResult.js +++ b/pkg/interface/src/views/components/leap/OmniboxResult.js @@ -3,6 +3,10 @@ import { Box, Row, Icon, Text } from '@tlon/indigo-react'; import defaultApps from '~/logic/lib/default-apps'; import Sigil from '~/logic/lib/sigil'; import { uxToHex, cite } from '~/logic/lib/util'; +import withState from '~/logic/lib/withState'; +import useHarkState from '~/logic/state/hark'; +import useContactState from '~/logic/state/contact'; +import useInviteState from '~/logic/state/invite'; export class OmniboxResult extends Component { constructor(props) { @@ -60,7 +64,7 @@ export class OmniboxResult extends Component { graphic = ; } else if (icon === 'tutorial') { graphic = ; - } + } else { graphic = ; } @@ -73,10 +77,10 @@ export class OmniboxResult extends Component { } render() { - const { icon, text, subtext, link, navigate, selected, invites, notifications, contacts } = this.props; + const { icon, text, subtext, link, navigate, selected, invites, notificationsCount, contacts } = this.props; const color = contacts?.[text] ? `#${uxToHex(contacts[text].color)}` : "#000000"; - const graphic = this.getIcon(icon, selected, link, invites, notifications, text, color); + const graphic = this.getIcon(icon, selected, link, invites, notificationsCount, text, color); return ( {text.startsWith("~") ? cite(text) : text} @@ -122,4 +132,8 @@ export class OmniboxResult extends Component { } } -export default OmniboxResult; +export default withState(OmniboxResult, [ + [useInviteState], + [useHarkState, ['notificationsCount']], + [useContactState] +]); \ No newline at end of file diff --git a/pkg/interface/src/views/components/useTutorialModal.tsx b/pkg/interface/src/views/components/useTutorialModal.tsx index e9a2162ad6..94aad20be5 100644 --- a/pkg/interface/src/views/components/useTutorialModal.tsx +++ b/pkg/interface/src/views/components/useTutorialModal.tsx @@ -16,9 +16,7 @@ export function useTutorialModal( setTutorialRef(anchorRef.current); } - return () => { - console.log(tutorialProgress); - } + return () => {} }, [tutorialProgress, show, anchorRef]); return show && onProgress === tutorialProgress; diff --git a/pkg/interface/src/views/components/withStorage.tsx b/pkg/interface/src/views/components/withStorage.tsx index 1577ef996d..257da79673 100644 --- a/pkg/interface/src/views/components/withStorage.tsx +++ b/pkg/interface/src/views/components/withStorage.tsx @@ -3,7 +3,7 @@ import useStorage from '~/logic/lib/useStorage'; const withStorage = (Component, params = {}) => { return React.forwardRef((props: any, ref) => { - const storage = useStorage(props.storage, params); + const storage = useStorage(params); return ; }); diff --git a/pkg/interface/src/views/css/fonts.css b/pkg/interface/src/views/css/fonts.css index 9880e92de8..1b50126e0e 100644 --- a/pkg/interface/src/views/css/fonts.css +++ b/pkg/interface/src/views/css/fonts.css @@ -10,16 +10,14 @@ font-family: 'Inter'; font-style: normal; font-weight: 500; - src: url("/~landscape/fonts/inter-medium.woff2") format("woff2"), - url("https://media.urbit.org/fonts/Inter-Medium.woff2") format("woff2"); + src: url("https://media.urbit.org/fonts/Inter-Medium.woff2") format("woff2"); } @font-face { font-family: 'Inter'; font-style: normal; font-weight: 600; - src: url("/~landscape/fonts/inter-semibold.woff2") format("woff2"), - url("https://media.urbit.org/fonts/Inter-SemiBold.woff2") format("woff2"); + src: url("https://media.urbit.org/fonts/Inter-SemiBold.woff2") format("woff2"); } @font-face { diff --git a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx index a5d85a1dda..b8a1b03ecd 100644 --- a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx +++ b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx @@ -53,8 +53,6 @@ function PermissionsSummary(props: { interface GraphPermissionsProps { association: Association; group: Group; - groups: Groups; - contacts: Rolodex; api: GlobalApi; } @@ -177,7 +175,7 @@ export function GraphPermissions(props: GraphPermissionsProps) { vip={association.metadata.vip} /> - + {association.metadata.module !== 'chat' && ( state.notificationsGraphConfig); const isMuted = - props.notificationsGraphConfig.watching.findIndex( + notificationsGraphConfig.watching.findIndex( a => a.graph === rid && a.index === '/' ) === -1; diff --git a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/index.tsx b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/index.tsx index b0ab86a6f8..d6e385f28e 100644 --- a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/index.tsx +++ b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/index.tsx @@ -25,10 +25,7 @@ interface ChannelPopoverRoutesProps { rootUrl: string; association: Association; group: Group; - groups: Groups; - contacts: Rolodex; api: GlobalApi; - notificationsGraphConfig: NotificationGraphConfig; } export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) { diff --git a/pkg/interface/src/views/landscape/components/ChannelWritePerms.tsx b/pkg/interface/src/views/landscape/components/ChannelWritePerms.tsx index 80517aab15..e7ccd9774e 100644 --- a/pkg/interface/src/views/landscape/components/ChannelWritePerms.tsx +++ b/pkg/interface/src/views/landscape/components/ChannelWritePerms.tsx @@ -16,8 +16,6 @@ export interface ChannelWriteFieldSchema { } interface ChannelWritePermsProps { - groups: Groups; - contacts: Rolodex; } export function ChannelWritePerms< @@ -33,8 +31,6 @@ export function ChannelWritePerms< {values.writePerms === 'subset' && ( state.associations); const channels = Object.values(associations.graph).filter( ({ group }) => association.group === group ); diff --git a/pkg/interface/src/views/landscape/components/GroupSettings/GroupSettings.tsx b/pkg/interface/src/views/landscape/components/GroupSettings/GroupSettings.tsx index 41b15a7438..b567474d65 100644 --- a/pkg/interface/src/views/landscape/components/GroupSettings/GroupSettings.tsx +++ b/pkg/interface/src/views/landscape/components/GroupSettings/GroupSettings.tsx @@ -20,10 +20,7 @@ const Section = ({ children }) => ( interface GroupSettingsProps { group: Group; association: Association; - associations: Associations; api: GlobalApi; - notificationsGroupConfig: GroupNotificationsConfig; - storage: StorageState; baseUrl: string; } export function GroupSettings(props: GroupSettingsProps) { diff --git a/pkg/interface/src/views/landscape/components/GroupSettings/Personal.tsx b/pkg/interface/src/views/landscape/components/GroupSettings/Personal.tsx index cf32e156a3..c5190d4a59 100644 --- a/pkg/interface/src/views/landscape/components/GroupSettings/Personal.tsx +++ b/pkg/interface/src/views/landscape/components/GroupSettings/Personal.tsx @@ -11,15 +11,17 @@ import { Association } from '@urbit/api/metadata'; import GlobalApi from '~/logic/api/global'; import { StatelessAsyncToggle } from '~/views/components/StatelessAsyncToggle'; +import useHarkState from '~/logic/state/hark'; export function GroupPersonalSettings(props: { api: GlobalApi; association: Association; - notificationsGroupConfig: GroupNotificationsConfig; }) { const groupPath = props.association.group; - const watching = props.notificationsGroupConfig.findIndex(g => g === groupPath) !== -1; + const notificationsGroupConfig = useHarkState(state => state.notificationsGroupConfig); + + const watching = notificationsGroupConfig.findIndex(g => g === groupPath) !== -1; const onClick = async () => { const func = !watching ? 'listenGroup' : 'ignoreGroup'; diff --git a/pkg/interface/src/views/landscape/components/GroupSwitcher.tsx b/pkg/interface/src/views/landscape/components/GroupSwitcher.tsx index 245d639708..57fcc8d3cf 100644 --- a/pkg/interface/src/views/landscape/components/GroupSwitcher.tsx +++ b/pkg/interface/src/views/landscape/components/GroupSwitcher.tsx @@ -14,6 +14,7 @@ import { Dropdown } from '~/views/components/Dropdown'; import { getTitleFromWorkspace } from '~/logic/lib/workspace'; import { MetadataIcon } from './MetadataIcon'; import { Workspace } from '~/types/workspace'; +import useMetadataState from '~/logic/state/metadata'; const GroupSwitcherItem = ({ to, children, bottom = false, ...rest }) => ( @@ -31,10 +32,11 @@ const GroupSwitcherItem = ({ to, children, bottom = false, ...rest }) => ( ); function RecentGroups(props: { recent: string[]; associations: Associations }) { - const { associations, recent } = props; + const { recent } = props; if (recent.length < 2) { return null; } + const associations = useMetadataState(state => state.associations); return ( @@ -70,13 +72,13 @@ function RecentGroups(props: { recent: string[]; associations: Associations }) { } export function GroupSwitcher(props: { - associations: Associations; workspace: Workspace; baseUrl: string; recentGroups: string[]; isAdmin: any; }) { - const { associations, workspace, isAdmin } = props; + const { workspace, isAdmin } = props; + const associations = useMetadataState(state => state.associations); const title = getTitleFromWorkspace(associations, workspace); const metadata = (workspace.type === 'home' || workspace.type === 'messages') ? undefined @@ -136,7 +138,6 @@ export function GroupSwitcher(props: { } diff --git a/pkg/interface/src/views/landscape/components/GroupifyForm.tsx b/pkg/interface/src/views/landscape/components/GroupifyForm.tsx index 7fa3bc5a0b..62a58cd35f 100644 --- a/pkg/interface/src/views/landscape/components/GroupifyForm.tsx +++ b/pkg/interface/src/views/landscape/components/GroupifyForm.tsx @@ -9,6 +9,7 @@ import { Groups, Associations, Association } from '@urbit/api'; import GlobalApi from '~/logic/api/global'; import GroupSearch from '~/views/components/GroupSearch'; import { AsyncButton } from '~/views/components/AsyncButton'; +import useGroupState from '~/logic/state/group'; const formSchema = Yup.object({ group: Yup.string().nullable() @@ -19,9 +20,7 @@ interface FormSchema { } interface GroupifyFormProps { - groups: Groups; api: GlobalApi; - associations: Associations; association: Association; } @@ -51,8 +50,9 @@ export function GroupifyForm(props: GroupifyFormProps) { }; const groupPath = props.association?.group; + const groups = useGroupState(state => state.groups); - const isUnmanaged = props.groups?.[groupPath]?.hidden || false; + const isUnmanaged = groups?.[groupPath]?.hidden || false; if (!isUnmanaged) { return null; @@ -77,8 +77,6 @@ export function GroupifyForm(props: GroupifyFormProps) { id="group" label="Group" caption="Optionally, if you have admin privileges, you can add this channel to a group, or leave this blank to place the channel in its own group" - groups={props.groups} - associations={props.associations} adminOnly maxLength={1} /> diff --git a/pkg/interface/src/views/landscape/components/GroupsPane.tsx b/pkg/interface/src/views/landscape/components/GroupsPane.tsx index bdd43dc522..658f0e5999 100644 --- a/pkg/interface/src/views/landscape/components/GroupsPane.tsx +++ b/pkg/interface/src/views/landscape/components/GroupsPane.tsx @@ -27,6 +27,10 @@ import '~/views/apps/publish/css/custom.css'; import { getGroupFromWorkspace } from '~/logic/lib/workspace'; import { GroupSummary } from './GroupSummary'; import { Workspace } from '~/types/workspace'; +import useContactState from '~/logic/state/contact'; +import useGroupState from '~/logic/state/group'; +import useHarkState from '~/logic/state/hark'; +import useMetadataState from '~/logic/state/metadata'; type GroupsPaneProps = StoreState & { baseUrl: string; @@ -35,9 +39,13 @@ type GroupsPaneProps = StoreState & { }; export function GroupsPane(props: GroupsPaneProps) { - const { baseUrl, associations, groups, contacts, api, workspace } = props; + const { baseUrl, api, workspace } = props; + const associations = useMetadataState(state => state.associations); + const contacts = useContactState(state => state.contacts); + const notificationsCount = useHarkState(state => state.notificationsCount); const relativePath = (path: string) => baseUrl + path; const groupPath = getGroupFromWorkspace(workspace); + const groups = useGroupState(state => state.groups); const groupContacts = Object.assign({}, ...Array.from(groups?.[groupPath]?.members ?? []).filter(e => contacts[`~${e}`]).map(e => { return {[e]: contacts[`~${e}`]}; @@ -70,9 +78,6 @@ export function GroupsPane(props: GroupsPaneProps) { association={groupAssociation!} group={group!} api={api} - storage={props.storage} - notificationsGroupConfig={props.notificationsGroupConfig} - associations={associations} {...routeProps} baseUrl={baseUrl} @@ -81,8 +86,6 @@ export function GroupsPane(props: GroupsPaneProps) { api={api} association={groupAssociation!} baseUrl={baseUrl} - groups={props.groups} - contacts={props.contacts} workspace={workspace} /> @@ -145,7 +148,7 @@ export function GroupsPane(props: GroupsPaneProps) { return ( <> - {props.notificationsCount ? `(${String(props.notificationsCount)}) ` : ''}{ title } + {notificationsCount ? `(${String(notificationsCount)}) ` : ''}{ title } {popovers(routeProps, baseUrl)} @@ -192,13 +189,12 @@ export function GroupsPane(props: GroupsPaneProps) { { - const hasDescription = groupAssociation?.metadata?.description; - const channelCount = Object.keys(props?.associations?.graph ?? {}).filter((e) => { - return props?.associations?.graph?.[e]?.['group'] === groupPath; + const channelCount = Object.keys(associations?.graph ?? {}).filter((e) => { + return associations?.graph?.[e]?.['group'] === groupPath; }).length; let summary: ReactNode; if(groupAssociation?.group) { - const memberCount = props.groups[groupAssociation.group].members.size; + const memberCount = groups[groupAssociation.group].members.size; summary = - {props.notificationsCount ? `(${String(props.notificationsCount)}) ` : ''}{ title } + {notificationsCount ? `(${String(notificationsCount)}) ` : ''}{ title } {title} state.associations); + const groups = useGroupState(state => state.groups); const history = useHistory(); const initialValues: FormSchema = { group: autojoin || '' @@ -69,7 +71,7 @@ export function JoinGroup(props: JoinGroupProps): ReactElement { MetadataUpdatePreview | string | null >(null); - const waiter = useWaitForProps(props, _.isString(preview) ? 1 : 5000); + const waiter = useWaitForProps({ associations, groups }, _.isString(preview) ? 1 : 5000); const onConfirm = useCallback(async (group: string) => { const [,,ship,name] = group.split('/'); @@ -78,13 +80,13 @@ export function JoinGroup(props: JoinGroupProps): ReactElement { } await api.groups.join(ship, name); try { - await waiter((p: JoinGroupProps) => { + await waiter((p) => { return group in p.groups && (group in (p.associations?.graph ?? {}) || group in (p.associations?.groups ?? {})); }); - if(props.groups?.[group]?.hidden) { + if(groups?.[group]?.hidden) { const { metadata } = associations.graph[group]; history.push(`/~landscape/home/resource/${metadata.module}${group}`); return; diff --git a/pkg/interface/src/views/landscape/components/MetadataIcon.tsx b/pkg/interface/src/views/landscape/components/MetadataIcon.tsx index e4ab45d633..97ef544265 100644 --- a/pkg/interface/src/views/landscape/components/MetadataIcon.tsx +++ b/pkg/interface/src/views/landscape/components/MetadataIcon.tsx @@ -15,7 +15,7 @@ export function MetadataIcon(props: MetadataIconProps) { const bgColor = metadata.picture ? {} : { bg: `#${uxToHex(metadata.color)}` }; return ( - + {metadata.picture && } ); diff --git a/pkg/interface/src/views/landscape/components/NewChannel.tsx b/pkg/interface/src/views/landscape/components/NewChannel.tsx index cf82256d9c..cad416e2c2 100644 --- a/pkg/interface/src/views/landscape/components/NewChannel.tsx +++ b/pkg/interface/src/views/landscape/components/NewChannel.tsx @@ -22,6 +22,7 @@ import { Rolodex } from '@urbit/api'; import { IconRadio } from '~/views/components/IconRadio'; import { ChannelWriteFieldSchema, ChannelWritePerms } from './ChannelWritePerms'; import { Workspace } from '~/types/workspace'; +import useGroupState from '~/logic/state/group'; type FormSchema = { name: string; @@ -41,18 +42,16 @@ const formSchema = (members?: string[]) => Yup.object({ interface NewChannelProps { api: GlobalApi; - associations: Associations; - contacts: Rolodex; - groups: Groups; group?: string; workspace: Workspace; } export function NewChannel(props: NewChannelProps & RouteComponentProps): ReactElement { - const { history, api, group, workspace, groups } = props; - - const waiter = useWaitForProps(props, 5000); + const { history, api, group, workspace } = props; + const groups = useGroupState(state => state.groups); + const waiter = useWaitForProps({ groups }, 5000); + const onSubmit = async (values: FormSchema, actions) => { const name = (values.name) ? values.name : values.moduleType; const resId: string = stringToSymbol(values.name) @@ -98,7 +97,7 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps): ReactE } if (!group) { - await waiter(p => Boolean(p?.groups?.[`/ship/~${window.ship}/${resId}`])); + await waiter(p => Boolean(p.groups?.[`/ship/~${window.ship}/${resId}`])); } actions.setStatus({ success: null }); const resourceUrl = (location.pathname.includes("/messages")) ? "/~landscape/messages" : parentPath(location.pathname); @@ -181,15 +180,10 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps): ReactE /> {(workspace?.type === 'home' || workspace?.type === 'messages') ? ( ) : ( - + )} state.groups); + const associations = useMetadataState(state => state.associations); + const waiter = useWaitForProps({ groups, associations }); const onSubmit = useCallback( async (values: FormSchema, actions: FormikHelpers) => { @@ -65,8 +66,8 @@ export function NewGroup(props: NewGroupProps & RouteComponentProps): ReactEleme }; await api.groups.create(name, policy, title, description); const path = `/ship/~${window.ship}/${name}`; - await waiter(({ groups, associations }) => { - return path in groups && path in associations.groups; + await waiter((p) => { + return path in p.groups && path in p.associations.groups; }); actions.setStatus({ success: null }); diff --git a/pkg/interface/src/views/landscape/components/Participants.tsx b/pkg/interface/src/views/landscape/components/Participants.tsx index 41ca3050d4..b9179ce722 100644 --- a/pkg/interface/src/views/landscape/components/Participants.tsx +++ b/pkg/interface/src/views/landscape/components/Participants.tsx @@ -30,7 +30,10 @@ import { roleForShip, resourceFromPath } from '~/logic/lib/group'; import { Dropdown } from '~/views/components/Dropdown'; import GlobalApi from '~/logic/api/global'; import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction'; +import useLocalState from '~/logic/state/local'; +import useContactState from '~/logic/state/contact'; import useSettingsState, { selectCalmState } from '~/logic/state/settings'; +import {deSig} from '@urbit/api'; const TruncText = styled(Text)` white-space: nowrap; @@ -53,15 +56,18 @@ const searchParticipant = (search: string) => (p: Participant) => { }; function getParticipants(cs: Contacts, group: Group) { - const contacts: Participant[] = _.map(cs, (c, patp) => ({ - ...c, - patp, - pending: false - })); + const contacts: Participant[] = _.flow( + f.omitBy((_c, patp) => !group.members.has(patp.slice(1))), + f.toPairs, + f.map(([patp, c]: [string, Contact]) => ({ + ...c, + patp: patp.slice(1), + pending: false + })) + )(cs); const members: Participant[] = _.map( - Array.from(group.members) - .filter(e => group?.policy?.invite?.pending ? !group.policy.invite.pending.has(e) : true), m => - emptyContact(m, false) + Array.from(group.members), + s => contacts[s] ?? emptyContact(s, false) ); const allMembers = _.unionBy(contacts, members, 'patp'); const pending: Participant[] = @@ -71,10 +77,11 @@ function getParticipants(cs: Contacts, group: Group) { ) : []; + const incPending = _.unionBy(allMembers, pending, 'patp'); return [ - _.unionBy(allMembers, pending, 'patp'), - pending.length, - allMembers.length + incPending, + incPending.length - group.members.size, + group.members.size ] as const; } @@ -82,7 +89,7 @@ const emptyContact = (patp: string, pending: boolean): Participant => ({ nickname: '', bio: '', status: '', - color: '', + color: '0x0', avatar: null, cover: null, groups: [], @@ -105,7 +112,6 @@ const Tab = ({ selected, id, label, setSelected }) => ( ); export function Participants(props: { - contacts: Contacts; group: Group; association: Association; api: GlobalApi; @@ -138,9 +144,10 @@ export function Participants(props: { const adminCount = props.group.tags?.role?.admin?.size || 0; const isInvite = 'invite' in props.group.policy; + const contacts = useContactState(state => state.contacts); const [participants, pendingCount, memberCount] = getParticipants( - props.contacts, + contacts, props.group ); diff --git a/pkg/interface/src/views/landscape/components/PopoverRoutes.tsx b/pkg/interface/src/views/landscape/components/PopoverRoutes.tsx index 03cec5da20..1c01d356bb 100644 --- a/pkg/interface/src/views/landscape/components/PopoverRoutes.tsx +++ b/pkg/interface/src/views/landscape/components/PopoverRoutes.tsx @@ -20,11 +20,8 @@ import { StorageState } from '~/types'; export function PopoverRoutes( props: { baseUrl: string; - contacts: Contacts; group: Group; association: Association; - associations: Associations; - storage: StorageState; api: GlobalApi; notificationsGroupConfig: GroupNotificationsConfig; rootIdentity: Contact; @@ -126,15 +123,11 @@ export function PopoverRoutes( group={props.group} association={props.association} api={props.api} - notificationsGroupConfig={props.notificationsGroupConfig} - associations={props.associations} - storage={props.storage} /> )} {view === 'participants' && ( diff --git a/pkg/interface/src/views/landscape/components/Resource.tsx b/pkg/interface/src/views/landscape/components/Resource.tsx index 41fc642eaf..c4c085b2a9 100644 --- a/pkg/interface/src/views/landscape/components/Resource.tsx +++ b/pkg/interface/src/views/landscape/components/Resource.tsx @@ -11,6 +11,10 @@ import { StoreState } from '~/logic/store/type'; import GlobalApi from '~/logic/api/global'; import { ResourceSkeleton } from './ResourceSkeleton'; import { ChannelPopoverRoutes } from './ChannelPopoverRoutes'; +import useGroupState from '~/logic/state/group'; +import useContactState from '~/logic/state/contact'; +import useHarkState from '~/logic/state/hark'; +import useMetadataState from '~/logic/state/metadata'; type ResourceProps = StoreState & { association: Association; @@ -19,7 +23,11 @@ type ResourceProps = StoreState & { } & RouteComponentProps; export function Resource(props: ResourceProps): ReactElement { - const { association, api, notificationsGraphConfig, groups, contacts } = props; + const { association, api, notificationsGraphConfig } = props; + const groups = useGroupState(state => state.groups); + const notificationsCount = useHarkState(state => state.notificationsCount); + const associations = useMetadataState(state => state.associations); + const contacts = useContactState(state => state.contacts); const app = association.metadata.module || association['app-name']; const rid = association.resource; const selectedGroup = association.group; @@ -28,14 +36,14 @@ export function Resource(props: ResourceProps): ReactElement { const skelProps = { api, association, groups, contacts }; let title = props.association.metadata.title; if ('workspace' in props) { - if ('group' in props.workspace && props.workspace.group in props.associations.groups) { - title = `${props.associations.groups[props.workspace.group].metadata.title} - ${props.association.metadata.title}`; + if ('group' in props.workspace && props.workspace.group in associations.groups) { + title = `${associations.groups[props.workspace.group].metadata.title} - ${props.association.metadata.title}`; } } return ( <> - {props.notificationsCount ? `(${String(props.notificationsCount)}) ` : ''}{ title } + {notificationsCount ? `(${String(notificationsCount)}) ` : ''}{ title } ); }} diff --git a/pkg/interface/src/views/landscape/components/ResourceSkeleton.tsx b/pkg/interface/src/views/landscape/components/ResourceSkeleton.tsx index fb95b50410..5cda15f7b9 100644 --- a/pkg/interface/src/views/landscape/components/ResourceSkeleton.tsx +++ b/pkg/interface/src/views/landscape/components/ResourceSkeleton.tsx @@ -11,6 +11,8 @@ import RichText from '~/views/components/RichText'; import GlobalApi from '~/logic/api/global'; import { isWriter } from '~/logic/lib/group'; import { getItemTitle } from '~/logic/lib/util'; +import useContactState from '~/logic/state/contact'; +import useGroupState from '~/logic/state/group'; const TruncatedText = styled(RichText)` white-space: pre; @@ -19,8 +21,6 @@ const TruncatedText = styled(RichText)` `; type ResourceSkeletonProps = { - groups: Groups; - contacts: Rolodex; association: Association; api: GlobalApi; baseUrl: string; @@ -30,9 +30,10 @@ type ResourceSkeletonProps = { }; export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement { - const { association, baseUrl, children, groups } = props; + const { association, baseUrl, children } = props; const app = association?.metadata?.module || association['app-name']; const rid = association.resource; + const groups = useGroupState(state => state.groups); const group = groups[association.group]; let workspace = association.group; @@ -48,11 +49,13 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement { let recipient = ""; + const contacts = useContactState(state => state.contacts); + if (urbitOb.isValidPatp(title)) { recipient = title; - title = (props.contacts?.[title]?.nickname) ? props.contacts[title].nickname : title; + title = (contacts?.[title]?.nickname) ? contacts[title].nickname : title; } else { - recipient = Array.from(group.members).map(e => `~${e}`).join(", ") + recipient = Array.from(group ? group.members : []).map(e => `~${e}`).join(", ") } const [, , ship, resource] = rid.split('/'); diff --git a/pkg/interface/src/views/landscape/components/Sidebar/Apps.tsx b/pkg/interface/src/views/landscape/components/Sidebar/Apps.tsx index fd32f998ad..7326e210fb 100644 --- a/pkg/interface/src/views/landscape/components/Sidebar/Apps.tsx +++ b/pkg/interface/src/views/landscape/components/Sidebar/Apps.tsx @@ -17,6 +17,11 @@ export function useGraphModule( return 'unsubscribed'; } + const notifications = graphUnreads?.[s]?.['/']?.notifications; + if ( notifications > 0 ) { + return 'notification'; + } + const unreads = graphUnreads?.[s]?.['/']?.unreads; if (typeof unreads === 'number' ? unreads > 0 : unreads?.size ?? 0 > 0) { return 'unread'; diff --git a/pkg/interface/src/views/landscape/components/Sidebar/Sidebar.tsx b/pkg/interface/src/views/landscape/components/Sidebar/Sidebar.tsx index 5049243220..ceb741a0eb 100644 --- a/pkg/interface/src/views/landscape/components/Sidebar/Sidebar.tsx +++ b/pkg/interface/src/views/landscape/components/Sidebar/Sidebar.tsx @@ -20,6 +20,8 @@ import { SidebarAppConfigs } from './types'; import { SidebarList } from './SidebarList'; import { roleForShip } from '~/logic/lib/group'; import { useTutorialModal } from '~/views/components/useTutorialModal'; +import useGroupState from '~/logic/state/group'; +import useMetadataState from '~/logic/state/metadata'; const ScrollbarLessCol = styled(Col)` scrollbar-width: none !important; @@ -30,29 +32,21 @@ const ScrollbarLessCol = styled(Col)` `; interface SidebarProps { - contacts: Rolodex; children: ReactNode; recentGroups: string[]; - invites: Invites ; api: GlobalApi; - associations: Associations; selected?: string; selectedGroup?: string; - includeUnmanaged?: boolean; - groups: Groups; apps: SidebarAppConfigs; baseUrl: string; mobileHide?: boolean; workspace: Workspace; } -export function Sidebar(props: SidebarProps): ReactElement { - const { associations, selected, workspace } = props; +export function Sidebar(props: SidebarProps): ReactElement | null { + const { selected, workspace } = props; const groupPath = getGroupFromWorkspace(workspace); const display = props.mobileHide ? ['none', 'flex'] : 'flex'; - if (!associations) { - return null; - } const [config, setConfig] = useLocalStorageState( `group-config:${groupPath || 'home'}`, @@ -62,7 +56,9 @@ export function Sidebar(props: SidebarProps): ReactElement { } ); - const role = props.groups?.[groupPath] ? roleForShip(props.groups[groupPath], window.ship) : undefined; + const groups = useGroupState(state => state.groups); + + const role = groups?.[groupPath] ? roleForShip(groups[groupPath], window.ship) : undefined; const isAdmin = (role === 'admin') || (workspace?.type === 'home'); const anchorRef = useRef(null); @@ -83,17 +79,13 @@ export function Sidebar(props: SidebarProps): ReactElement { position="relative" > ); diff --git a/pkg/interface/src/views/landscape/components/Sidebar/SidebarItem.tsx b/pkg/interface/src/views/landscape/components/Sidebar/SidebarItem.tsx index 17d9086734..d59ed161bb 100644 --- a/pkg/interface/src/views/landscape/components/Sidebar/SidebarItem.tsx +++ b/pkg/interface/src/views/landscape/components/Sidebar/SidebarItem.tsx @@ -11,7 +11,10 @@ import { useTutorialModal } from '~/views/components/useTutorialModal'; import { TUTORIAL_HOST, TUTORIAL_GROUP } from '~/logic/lib/tutorialModal'; import { SidebarAppConfigs, SidebarItemStatus } from './types'; import { Workspace } from '~/types/workspace'; +import useContactState from '~/logic/state/contact'; +import useGroupState from '~/logic/state/group'; import useSettingsState, { selectCalmState } from '~/logic/state/settings'; +import Dot from '~/views/components/Dot'; function SidebarItemIndicator(props: { status?: SidebarItemStatus }) { @@ -29,23 +32,25 @@ function SidebarItemIndicator(props: { status?: SidebarItemStatus }) { } } +// eslint-disable-next-line max-lines-per-function export function SidebarItem(props: { hideUnjoined: boolean; association: Association; - contacts: Rolodex; - groups: Groups; path: string; selected: boolean; apps: SidebarAppConfigs; workspace: Workspace; -}): ReactElement { - const { association, path, selected, apps, groups } = props; - let title = getItemTitle(association); +}): ReactElement | null { + const { association, path, selected, apps } = props; + let title = getItemTitle(association) || ''; const appName = association?.['app-name']; const mod = association?.metadata?.module || appName; const rid = association?.resource; const groupPath = association?.group; + const groups = useGroupState(state => state.groups); const anchorRef = useRef(null); + const { hideAvatars, hideNicknames } = useSettingsState(selectCalmState); + const contacts = useContactState(state => state.contacts); useTutorialModal( mod as any, groupPath === `/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`, @@ -57,10 +62,12 @@ export function SidebarItem(props: { return null; } const DM = (isUnmanaged && props.workspace?.type === 'messages'); - const { hideAvatars, hideNicknames } = useSettingsState(selectCalmState); const itemStatus = app.getStatus(path); - const hasUnread = itemStatus === 'unread' || itemStatus === 'mention'; + + const hasNotification = itemStatus === 'notification'; + + const hasUnread = itemStatus === 'unread'; const isSynced = itemStatus !== 'unsubscribed'; @@ -76,7 +83,17 @@ export function SidebarItem(props: { ? `${baseUrl}/resource/${mod}${rid}` : `${baseUrl}/join/${mod}${rid}`; - const color = selected ? 'black' : isSynced ? 'gray' : 'lightGray'; + let color = 'lightGray'; + + if (isSynced) { + if (hasUnread || hasNotification) { + color = 'black'; + } else { + color = 'gray'; + } + } + + const fontWeight = (hasUnread || hasNotification) ? '500' : 'normal'; if (props.hideUnjoined && !isSynced) { return null; @@ -85,13 +102,13 @@ export function SidebarItem(props: { let img = null; if (urbitOb.isValidPatp(title)) { - if (props.contacts?.[title]?.avatar && !hideAvatars) { - img = ; + if (contacts?.[title]?.avatar && !hideAvatars) { + img = ; } else { - img = ; + img = ; } - if (props.contacts?.[title]?.nickname && !hideNicknames) { - title = props.contacts[title].nickname; + if (contacts?.[title]?.nickname && !hideNicknames) { + title = contacts[title].nickname; } } else { img = ; @@ -113,10 +130,13 @@ export function SidebarItem(props: { selected={selected} > + {hasNotification && + + } {DM ? img : ( ) @@ -129,8 +149,8 @@ export function SidebarItem(props: { overflow='hidden' width='100%' mono={urbitOb.isValidPatp(title)} - fontWeight={hasUnread ? 'bold' : 'regular'} - color={selected || isSynced ? 'black' : 'lightGray'} + color={color} + fontWeight={fontWeight} style={{ textOverflow: 'ellipsis', whiteSpace: 'pre' }} > {title} diff --git a/pkg/interface/src/views/landscape/components/Sidebar/SidebarList.tsx b/pkg/interface/src/views/landscape/components/Sidebar/SidebarList.tsx index 8264b5d303..f4ad659ab1 100644 --- a/pkg/interface/src/views/landscape/components/Sidebar/SidebarList.tsx +++ b/pkg/interface/src/views/landscape/components/Sidebar/SidebarList.tsx @@ -5,6 +5,7 @@ import { alphabeticalOrder } from '~/logic/lib/util'; import { SidebarAppConfigs, SidebarListConfig, SidebarSort } from './types'; import { SidebarItem } from './SidebarItem'; import { Workspace } from '~/types/workspace'; +import useMetadataState from '~/logic/state/metadata'; function sidebarSort( associations: AppAssociations, @@ -39,27 +40,25 @@ function sidebarSort( export function SidebarList(props: { apps: SidebarAppConfigs; - contacts: Rolodex; config: SidebarListConfig; - associations: Associations; - groups: Groups; baseUrl: string; group?: string; selected?: string; workspace: Workspace; }): ReactElement { const { selected, group, config, workspace } = props; - const associations = { ...props.associations.graph }; + const associationState = useMetadataState(state => state.associations); + const associations = { ...associationState.graph }; const ordered = Object.keys(associations) .filter((a) => { const assoc = associations[a]; if (workspace?.type === 'messages') { - return (!(assoc.group in props.associations.groups) && assoc.metadata.module === 'chat'); + return (!(assoc.group in associationState.groups) && assoc.metadata.module === 'chat'); } else { return group ? assoc.group === group - : (!(assoc.group in props.associations.groups) && assoc.metadata.module !== 'chat'); + : (!(assoc.group in associationState.groups) && assoc.metadata.module !== 'chat'); } }) .sort(sidebarSort(associations, props.apps)[config.sortBy]); @@ -76,8 +75,6 @@ export function SidebarList(props: { association={assoc} apps={props.apps} hideUnjoined={config.hideUnjoined} - groups={props.groups} - contacts={props.contacts} workspace={workspace} /> ); diff --git a/pkg/interface/src/views/landscape/components/Sidebar/SidebarListHeader.tsx b/pkg/interface/src/views/landscape/components/Sidebar/SidebarListHeader.tsx index 829e21e9c7..6272148207 100644 --- a/pkg/interface/src/views/landscape/components/Sidebar/SidebarListHeader.tsx +++ b/pkg/interface/src/views/landscape/components/Sidebar/SidebarListHeader.tsx @@ -21,13 +21,12 @@ import { roleForShip } from '~/logic/lib/group'; import { NewChannel } from '~/views/landscape/components/NewChannel'; import GlobalApi from '~/logic/api/global'; import { Workspace } from '~/types/workspace'; +import useGroupState from '~/logic/state/group'; +import useMetadataState from '~/logic/state/metadata'; export function SidebarListHeader(props: { api: GlobalApi; initialValues: SidebarListConfig; - associations: Associations; - groups: Groups; - contacts: Rolodex; baseUrl: string; selected: string; workspace: Workspace; @@ -40,11 +39,13 @@ export function SidebarListHeader(props: { }, [props.handleSubmit] ); + const groups = useGroupState(state => state.groups); const groupPath = getGroupFromWorkspace(props.workspace); - const role = groupPath && props.groups?.[groupPath] ? roleForShip(props.groups[groupPath], window.ship) : undefined; + const role = groupPath && groups?.[groupPath] ? roleForShip(groups[groupPath], window.ship) : undefined; + const associations = useMetadataState(state => state.associations); const memberMetadata = - groupPath ? props.associations.groups?.[groupPath].metadata.vip === 'member-metadata' : false; + groupPath ? associations.groups?.[groupPath].metadata.vip === 'member-metadata' : false; const isAdmin = memberMetadata || (role === 'admin') || (props.workspace?.type === 'home') || (props.workspace?.type === 'messages'); @@ -86,9 +87,6 @@ export function SidebarListHeader(props: { diff --git a/pkg/interface/src/views/landscape/components/Sidebar/types.ts b/pkg/interface/src/views/landscape/components/Sidebar/types.ts index 56ecda5512..5cfd2634a2 100644 --- a/pkg/interface/src/views/landscape/components/Sidebar/types.ts +++ b/pkg/interface/src/views/landscape/components/Sidebar/types.ts @@ -1,6 +1,6 @@ export type SidebarItemStatus = | 'unread' - | 'mention' + | 'notification' | 'unsubscribed' | 'disconnected' | 'loading'; diff --git a/pkg/interface/src/views/landscape/components/SidebarItem.tsx b/pkg/interface/src/views/landscape/components/SidebarItem.tsx index 667ed2fed3..74cacbff8f 100644 --- a/pkg/interface/src/views/landscape/components/SidebarItem.tsx +++ b/pkg/interface/src/views/landscape/components/SidebarItem.tsx @@ -30,7 +30,7 @@ export const SidebarItem = ({ bgActive="washedGray" display="flex" px="3" - py="1" + py="2" justifyContent="space-between" {...rest} > diff --git a/pkg/interface/src/views/landscape/components/Skeleton.tsx b/pkg/interface/src/views/landscape/components/Skeleton.tsx index ddfb5b0880..ea1280a92e 100644 --- a/pkg/interface/src/views/landscape/components/Skeleton.tsx +++ b/pkg/interface/src/views/landscape/components/Skeleton.tsx @@ -5,34 +5,29 @@ import { Associations } from '@urbit/api/metadata'; import { Sidebar } from './Sidebar/Sidebar'; import GlobalApi from '~/logic/api/global'; -import GlobalSubscription from '~/logic/subscription/global'; import { useGraphModule } from './Sidebar/Apps'; import { Body } from '~/views/components/Body'; import { Workspace } from '~/types/workspace'; +import useGraphState from '~/logic/state/graph'; +import useHarkState from '~/logic/state/hark'; +import ErrorBoundary from '~/views/components/ErrorBoundary'; interface SkeletonProps { - contacts: Rolodex; children: ReactNode; recentGroups: string[]; - groups: Groups; - associations: Associations; - graphKeys: Set; - graphs: Graphs; - linkListening: Set; - invites: Invites; selected?: string; selectedApp?: AppName; baseUrl: string; mobileHide?: boolean; api: GlobalApi; - subscription: GlobalSubscription; - includeUnmanaged: boolean; workspace: Workspace; - unreads: unknown; } export function Skeleton(props: SkeletonProps): ReactElement { - const graphConfig = useGraphModule(props.graphKeys, props.graphs, props.unreads.graph); + const graphs = useGraphState(state => state.graphs); + const graphKeys = useGraphState(state => state.graphKeys); + const unreads = useHarkState(state => state.unreads); + const graphConfig = useGraphModule(graphKeys, graphs, unreads.graph); const config = useMemo( () => ({ graph: graphConfig @@ -48,20 +43,18 @@ export function Skeleton(props: SkeletonProps): ReactElement { } gridTemplateRows="100%" > - + + + {props.children} ); diff --git a/pkg/interface/src/views/landscape/index.tsx b/pkg/interface/src/views/landscape/index.tsx index d64511cd8e..0e85f556a1 100644 --- a/pkg/interface/src/views/landscape/index.tsx +++ b/pkg/interface/src/views/landscape/index.tsx @@ -17,6 +17,9 @@ import { Box } from '@tlon/indigo-react'; import { Loading } from '../components/Loading'; import { Workspace } from '~/types/workspace'; import GlobalSubscription from '~/logic/subscription/global'; +import useGraphState from '~/logic/state/graph'; +import useHarkState, { withHarkState } from '~/logic/state/hark'; +import withState from '~/logic/lib/withState'; type LandscapeProps = StoreState & { ship: PatpNoSig; @@ -25,10 +28,11 @@ type LandscapeProps = StoreState & { } export function DMRedirect(props: LandscapeProps & RouteComponentProps & { ship: string; }): ReactElement { - const { ship, api, history, graphKeys } = props; + const { ship, api, history } = props; const goToGraph = useCallback((graph: string) => { history.push(`/~landscape/messages/resource/chat/ship/~${graph}`); }, [history]); + const graphKeys = useGraphState(state => state.graphKeys); useEffect(() => { const station = `${window.ship}/dm--${ship}`; @@ -63,7 +67,7 @@ export function DMRedirect(props: LandscapeProps & RouteComponentProps & { ship: ); } -export default class Landscape extends Component> { +class Landscape extends Component> { componentDidMount(): void { this.props.subscription.startApp('groups'); this.props.subscription.startApp('graph'); @@ -115,9 +119,6 @@ export default class Landscape extends Component @@ -140,8 +141,6 @@ export default class Landscape extends Component | number; - notifications: number; + notifications: NotifRef[]; last: number; } +interface NotifRef { + time: BigInteger; + index: NotifIndex; +} + export interface GraphNotifIndex { graph: string; group: string;