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

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

View File

@ -32,7 +32,29 @@
name: build
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:

View File

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

View File

@ -45,17 +45,16 @@ Most parts of Arvo have dedicated maintainers.
* `/sys/vane/ames`: @belisarius222 (~rovnys-ricfer) & @philipcmonk (~wicdev-wisryt)
* `/sys/vane/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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(
<Box
backgroundColor={color}
borderRadius={icon ? '1' : '0'}
display='inline-block'
display={display}
height={size}
width={size}
className={classes}
/>
) : (
<Box
display='inline-block'
display={display}
borderRadius={icon ? '1' : '0'}
flexBasis={size}
backgroundColor={color}

View File

@ -8,6 +8,7 @@ import S3 from 'aws-sdk/clients/s3';
import GcpClient from './GcpClient';
import { StorageClient, StorageAcl } from './StorageClient';
import { dateToDa, deSig } from './util';
import useStorageState from '../state/storage';
export interface IuseStorage {
@ -18,9 +19,10 @@ export interface IuseStorage {
promptUpload: () => Promise<string | undefined>;
}
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<StorageClient | null>(null);

View File

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

View File

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

View File

@ -1,44 +1,51 @@
import _ from 'lodash';
import { 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<StoreState, 'contacts'>;
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<ContactState, ContactUpdate>(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;
};

View File

@ -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<StoreState, 'gcp'>;
export default class GcpReducer<S extends GcpState>{
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<StorageState, any>(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');
}

View File

@ -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<GraphState, any>(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;
};

View File

@ -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<StoreState, 'groups' | 'groupKeys'>;
import useGroupState, { GroupState } from '../state/group';
import { compose } from 'lodash/fp';
import { reduceState } from '../state/base';
function decodeGroup(group: Enc<Group>): Group {
const members = new Set(group.members);
@ -57,186 +57,195 @@ function decodeTags(tags: Enc<Tags>): Tags {
);
}
export default class GroupReducer<S extends GroupState> {
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<GroupState, GroupUpdate>(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');
}
}

View File

@ -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<GroupState, GroupUpdate>(useGroupState, data, [
progress,
initial
]);
}
};
};

View File

@ -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<StoreState, 'notifications' | 'notificationsGraphConfig' | 'notificationsGroupConfig' | 'unreads' >;
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<HarkState, any>(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<HarkState, any>(useHarkState, groupHookData, [
groupInitial,
groupListen,
groupIgnore,
]);
}
};
function groupInitial(json: any, state: HarkState) {
function reduce(data) {
reduceState<HarkState, any>(useHarkState, data, [
unread,
read,
archive,
timebox,
more,
dnd,
added,
unreads,
readEach,
readSince,
unreadSince,
unreadEach,
seenIndex,
removeGraph,
readAll,
calculateCount
]);
}
function calculateCount(json: any, state: HarkState) {
let count = 0;
_.forEach(state.unreads.graph, (graphs) => {
_.forEach(graphs, graph => {
count += (graph?.notifications || []).length;
});
});
_.forEach(state.unreads.group, group => {
count += (group?.notifications || []).length;
})
state.notificationsCount = count;
return state;
}
function groupInitial(json: any, state: HarkState): HarkState {
const data = _.get(json, 'initial', false);
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<Timebox>(),
archivedNotifications: new BigIntOrderedMap<Timebox>(),
@ -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<string>) => void) {
function updateUnreads(state: HarkState, index: NotifIndex, f: (us: Set<string>) => void): HarkState {
if(!('graph' in index)) {
return;
return state;
}
const unreads = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], new Set<string>());
const oldSize = unreads.size;
let unreads = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], new Set<string>());
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;
}

View File

@ -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<StoreState, 'invites'>;
import { Cage } from '~/types/cage';
import useInviteState, { InviteState } from '../state/invite';
import { reduceState } from '../state/base';
export default class InviteReducer<S extends InviteState> {
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<InviteState, InviteUpdate>(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;
}

View File

@ -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<StoreState, 'launch' | 'weather' | 'userLocation'>;
export default class LaunchReducer<S extends LaunchState> {
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<LaunchState, LaunchUpdate>(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;
}

View File

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

View File

@ -1,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<StoreState, 'associations'>;
export default class MetadataReducer<S extends MetadataState> {
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<MetadataState, MetadataUpdate>(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;
}

View File

@ -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<StoreState, 's3'>;
export default class S3Reducer<S extends S3State> {
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<StorageState, S3Update>(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;
}

View File

@ -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<SettingsState, SettingsUpdate>(useSettingsState, data, [
this.putBucket,
this.delBucket,
this.putEntry,
this.delEntry,
]);
}
data = json["settings-data"];
if (data) {
reduceState<SettingsState, SettingsUpdate>(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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,9 @@
import React, { ReactNode } from "react";
import f from 'lodash/fp';
import 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<SettingsState> {
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 =
<K extends keyof SettingsState>(keys: K[]) => f.pick<SettingsState, K>(keys);
@ -40,7 +34,7 @@ export const selectCalmState = (s: SettingsState) => s.calm;
export const selectDisplayState = (s: SettingsState) => s.display;
const useSettingsState = create<SettingsStateZus>((set) => ({
const useSettingsState = createState<SettingsState>('Settings', {
display: {
backgroundType: 'none',
background: undefined,
@ -66,17 +60,7 @@ const useSettingsState = create<SettingsStateZus>((set) => ({
tutorial: {
seen: false,
joined: undefined
},
set: (fn: (state: SettingsState) => void) => set(produce(fn))
}));
}
});
function withSettingsState<P, S extends keyof SettingsState>(Component: any, stateMemberKeys?: S[]) {
return React.forwardRef((props: Omit<P, S>, ref) => {
const localState = stateMemberKeys
? useSettingsState(selectSettingsState(stateMemberKeys))
: useSettingsState();
return <Component ref={ref} {...localState} {...props} />
});
}
export { useSettingsState as default, withSettingsState };
export default useSettingsState;

View File

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

View File

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

View File

@ -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<StoreState> {
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<StoreState> {
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<Timebox>(),
archivedNotifications: new BigIntOrderedMap<Timebox>(),
notificationsGroupConfig: [],
notificationsGraphConfig: {
watchOnSelf: false,
mentions: false,
watching: []
},
unreads: {
graph: {},
group: {}
},
notificationsCount: 0,
settings: {},
pendingJoin: {},
graphTimesentMap: {}
};
}
reduce(data: Cage, state: StoreState) {
// 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);
});
}
}

View File

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

View File

@ -27,12 +27,15 @@ import GlobalSubscription from '~/logic/subscription/global';
import GlobalApi from '~/logic/api/global';
import { 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 (
<ThemeProvider theme={theme}>
<Helmet>
@ -158,27 +158,17 @@ class App extends React.Component {
<ErrorBoundary>
<StatusBarWithRouter
props={this.props}
associations={associations}
invites={this.state.invites}
ourContact={ourContact}
api={this.api}
connection={this.state.connection}
subscription={this.subscription}
ship={this.ship}
doNotDisturb={doNotDisturb}
notificationsCount={notificationsCount}
/>
</ErrorBoundary>
<ErrorBoundary>
<Omnibox
associations={state.associations}
apps={state.launch}
tiles={state.launch.tiles}
api={this.api}
contacts={state.contacts}
notifications={state.notificationsCount}
invites={state.invites}
groups={state.groups}
show={this.props.omniboxShown}
toggle={this.props.toggleOmnibox}
/>
@ -188,7 +178,7 @@ class App extends React.Component {
ship={this.ship}
api={this.api}
subscription={this.subscription}
{...state}
connection={this.state.connection}
/>
</ErrorBoundary>
</Router>
@ -199,4 +189,9 @@ class App extends React.Component {
}
}
export default withSettingsState(withLocalState(process.env.NODE_ENV === 'production' ? App : hot(App)), ['display']);
export default withState(process.env.NODE_ENV === 'production' ? App : hot(App), [
[useGroupState],
[useContactState],
[useSettingsState, ['display']],
[useLocalState]
]);

View File

@ -16,6 +16,10 @@ import { Loading } from '~/views/components/Loading';
import { isWriter, resourceFromPath } from '~/logic/lib/group';
import './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<ChatInput>();
@ -132,9 +139,6 @@ export function ChatResource(props: ChatResourceProps) {
return <Loading />;
}
const modifiedContacts = { ...contacts };
delete modifiedContacts[`~${window.ship}`];
return (
<Col {...bind} height="100%" overflow="hidden" position="relative">
<ShareProfile
@ -152,15 +156,11 @@ export function ChatResource(props: ChatResourceProps) {
key={station}
history={props.history}
graph={graph}
graphSize={graph.size}
unreadCount={unreadCount}
contacts={
(!showBanner && hasLoadedAllowed) ?
contacts : modifiedContacts
}
showOurContact={ !showBanner && hasLoadedAllowed }
association={props.association}
associations={props.associations}
groups={props.groups}
pendingSize={Object.keys(props.graphTimesentMap[graphPath] || {}).length}
pendingSize={Object.keys(graphTimesentMap[graphPath] || {}).length}
group={group}
ship={owner}
station={station}
@ -176,11 +176,7 @@ export function ChatResource(props: ChatResourceProps) {
(!showBanner && hasLoadedAllowed) ? ourContact : null
}
envelopes={[]}
contacts={
(!showBanner && hasLoadedAllowed) ? contacts : modifiedContacts
}
onUnmount={appendUnsent}
storage={props.storage}
placeholder="Message..."
message={unsent[station] || ''}
deleteMessage={clearUnsent}

View File

@ -19,14 +19,12 @@ type ChatInputProps = IuseStorage & {
station: unknown;
ourContact: unknown;
envelopes: Envelope[];
contacts: Contacts;
onUnmount(msg: string): void;
storage: StorageState;
placeholder: string;
message: string;
deleteMessage(): void;
hideAvatars: boolean;
}
};
interface ChatInputState {
inCodeMode: boolean;
@ -62,20 +60,23 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
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<ChatInputProps, ChatInputState> {
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<ChatInputProps, ChatInputState> {
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<ChatInputProps, ChatInputState> {
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)
)
? <BaseImage
const avatar =
props.ourContact && props.ourContact?.avatar && !props.hideAvatars ? (
<BaseImage
src={props.ourContact.avatar}
height={16}
width={16}
height={24}
width={24}
style={{ objectFit: 'cover' }}
borderRadius={1}
display='inline-block'
/>
: <Sigil
ship={window.ship}
size={16}
color={`#${color}`}
classes={sigilClass}
icon
padding={2}
/>;
) : (
<Box
width={24}
height={24}
display='flex'
justifyContent='center'
alignItems='center'
backgroundColor={`#${color}`}
borderRadius={1}
>
<Sigil
ship={window.ship}
size={16}
color={`#${color}`}
classes={sigilClass}
icon
padding={2}
/>
</Box>
);
return (
<Row
@ -160,7 +170,7 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
className='cf'
zIndex={0}
>
<Row p='2' alignItems='center'>
<Row p='12px 4px 12px 12px' alignItems='center'>
{avatar}
</Row>
<ChatEditor
@ -172,31 +182,23 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
onPaste={this.onPaste.bind(this)}
placeholder='Message...'
/>
<Box
mx={2}
flexShrink={0}
height='16px'
width='16px'
flexBasis='16px'
>
{this.props.canUpload
? this.props.uploading
? <LoadingSpinner />
: <Icon icon='Links'
width="16"
height="16"
onClick={() => this.props.promptUpload().then(this.uploadSuccess)}
/>
: null
}
<Box mx={2} flexShrink={0} height='16px' width='16px' flexBasis='16px'>
{this.props.canUpload ? (
this.props.uploading ? (
<LoadingSpinner />
) : (
<Icon
icon='Links'
width='16'
height='16'
onClick={() =>
this.props.promptUpload().then(this.uploadSuccess)
}
/>
)
) : null}
</Box>
<Box
mr={2}
flexShrink={0}
height='16px'
width='16px'
flexBasis='16px'
>
<Box mr={2} flexShrink={0} height='16px' width='16px' flexBasis='16px'>
<Icon
icon='Dojo'
onClick={this.toggleCode}
@ -208,4 +210,6 @@ class ChatInput extends Component<ChatInputProps, ChatInputState> {
}
}
export default withLocalState(withStorage(ChatInput, { accept: 'image/*' }), ['hideAvatars']);
export default withLocalState(withStorage(ChatInput, { accept: 'image/*' }), [
'hideAvatars'
]);

View File

@ -10,7 +10,7 @@ import React, {
import moment from 'moment';
import _ 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'}
>
<Rule borderColor='lightGray' />
<Text gray flexShrink='0' fontSize={0} px={2}>
<Text
gray
flexShrink='0'
whiteSpace='nowrap'
textAlign='center'
fontSize={0}
px={2}
>
{moment(when).calendar(null, { sameElse: DATESTAMP_FORMAT })}
</Text>
<Rule borderColor='lightGray' />
</Row>
);
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 (
<Row
position='absolute'
ref={ref}
px={2}
mt={0}
height={5}
justifyContent='center'
alignItems='center'
width='100%'
>
<Rule borderColor='lightBlue' />
<VisibilitySensor onChange={setVisible}>
<Text
color='blue'
fontSize={0}
flexShrink='0'
whiteSpace='nowrap'
textAlign='center'
px={2}
>
New messages below
</Text>
</VisibilitySensor>
<Rule borderColor='lightBlue' />
</Row>
);
}
);
const MessageActionItem = (props) => {
return (
<Row
position='absolute'
ref={ref}
px={2}
mt={2}
height={5}
justifyContent='center'
alignItems='center'
width='100%'
>
<Rule borderColor='lightBlue' />
<VisibilitySensor onChange={setVisible}>
<Text color='blue' fontSize={0} flexShrink='0' px={2}>
New messages below
</Text>
</VisibilitySensor>
<Rule borderColor='lightBlue' />
</Row>
)});
<Row
color='black'
cursor='pointer'
fontSize={1}
fontWeight='500'
px={3}
py={2}
onClick={props.onClick}
>
<Text fontWeight='500' color={props.color}>
{props.children}
</Text>
</Row>
);
};
const MessageActions = ({ api, history, msg, group }) => {
const isAdmin = () => group.tags.role.admin.has(window.ship);
const isOwn = () => msg.author === window.ship;
return (
<Box
borderRadius={1}
background='white'
border='1px solid'
borderColor='lightGray'
position='absolute'
top='-12px'
right={2}
>
<Row>
{isOwn() ? (
<Box
padding={1}
size={'24px'}
cursor='pointer'
onClick={(e) => console.log(e)}
>
<Icon icon='NullIcon' size={3} />
</Box>
) : null}
<Box
padding={1}
size={'24px'}
cursor='pointer'
onClick={(e) => console.log(e)}
>
<Icon icon='Chat' size={3} />
</Box>
<Dropdown
dropWidth='250px'
width='auto'
alignY='top'
alignX='right'
flexShrink={'0'}
offsetY={8}
offsetX={-24}
options={
<Col
py={2}
backgroundColor='white'
color='washedGray'
border={1}
borderRadius={2}
borderColor='lightGray'
boxShadow='0px 0px 0px 3px'
>
{isOwn() ? (
<MessageActionItem onClick={(e) => console.log(e)}>
Edit Message
</MessageActionItem>
) : null}
<MessageActionItem onClick={(e) => console.log(e)}>
Reply
</MessageActionItem>
<MessageActionItem onClick={(e) => console.log(e)}>
Copy Message Link
</MessageActionItem>
{isAdmin() || isOwn() ? (
<MessageActionItem onClick={(e) => console.log(e)} color='red'>
Delete Message
</MessageActionItem>
) : null}
<MessageActionItem onClick={(e) => console.log(e)}>
View Signature
</MessageActionItem>
</Col>
}
>
<Box padding={1} size={'24px'} cursor='pointer'>
<Icon icon='Menu' size={3} />
</Box>
</Dropdown>
</Row>
</Box>
);
};
const MessageWrapper = (props) => {
const { hovering, bind } = useHovering();
return (
<Box
py='1'
backgroundColor={
hovering && !props.hideHover ? 'washedGray' : 'transparent'
}
position='relative'
{...bind}
>
{props.children}
{/* {hovering ? <MessageActions {...props} /> : null} */}
</Box>
);
};
interface ChatMessageProps {
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<ChatMessageProps> {
this.divRef = React.createRef();
}
componentDidMount() {
}
componentDidMount() {}
render() {
const {
@ -137,7 +271,6 @@ class ChatMessage extends Component<ChatMessageProps> {
isLastRead,
group,
association,
contacts,
className = '',
isPending,
style,
@ -147,9 +280,9 @@ class ChatMessage extends Component<ChatMessageProps> {
history,
api,
highlighted,
showOurContact,
fontSize,
groups,
associations
hideHover
} = this.props;
let { renderSigil } = this.props;
@ -173,23 +306,21 @@ class ChatMessage extends Component<ChatMessageProps> {
.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<ChatMessageProps> {
<Box
ref={this.props.innerRef}
pt={renderSigil ? 2 : 0}
pb={isLastMessage ? 4 : 2}
pb={isLastMessage ? '20px' : 0}
className={containerClass}
backgroundColor={highlighted ? 'blue' : 'white'}
style={style}
@ -209,12 +340,14 @@ class ChatMessage extends Component<ChatMessageProps> {
<DayBreak when={msg['time-sent']} shimTop={renderSigil} />
) : null}
{renderSigil ? (
<>
<MessageAuthor pb={'2px'} {...messageProps} />
<Message pl={5} pr={4} {...messageProps} />
</>
<MessageWrapper {...messageProps}>
<MessageAuthor pb={1} {...messageProps} />
<Message pl={'44px'} pr={4} {...messageProps} />
</MessageWrapper>
) : (
<Message pl={5} pr={4} timestampHover {...messageProps} />
<MessageWrapper {...messageProps}>
<Message pl={'44px'} pr={4} timestampHover {...messageProps} />
</MessageWrapper>
)}
<Box style={unreadContainerStyle}>
{isLastRead ? (
@ -232,30 +365,36 @@ class ChatMessage extends Component<ChatMessageProps> {
}
}
export default React.forwardRef((props, ref) => <ChatMessage {...props} innerRef={ref} />);
export default React.forwardRef((props, ref) => (
<ChatMessage {...props} innerRef={ref} />
));
export const MessageAuthor = ({
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 ? (
<BaseImage
display='inline-block'
referrerPolicy='no-referrer'
style={{ objectFit: 'cover' }}
src={contact.avatar}
height={16}
width={16}
height={24}
width={24}
borderRadius={1}
/>
) : (
<Sigil
ship={msg.author}
size={16}
color={color}
classes={sigilClass}
icon
padding={2}
/>
<Box
width={24}
height={24}
display='flex'
justifyContent='center'
alignItems='center'
backgroundColor={color}
borderRadius={1}
>
<Sigil
ship={msg.author}
size={12}
display='block'
color={color}
classes={sigilClass}
icon
padding={0}
/>
</Box>
);
return (
<Box display='flex' alignItems='center' {...rest}>
<Box display='flex' alignItems='flex-start' {...rest}>
<Box
onClick={() => {
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'
>
<Text
fontSize={0}
fontSize={1}
mr={2}
flexShrink={0}
flexShrink={1}
mono={nameMono}
fontWeight={nameMono ? '400' : '500'}
cursor='pointer'
@ -385,23 +537,23 @@ export const MessageAuthor = ({
export const Message = ({
timestamp,
contacts,
msg,
group,
api,
associations,
groups,
scrollWindow,
timestampHover,
...rest
}) => {
const { hovering, bind } = useHovering();
const contacts = useContactState((state) => state.contacts);
return (
<Box position='relative' {...rest}>
{timestampHover ? (
<Text
display={hovering ? 'block' : 'none'}
position='absolute'
width='36px'
textAlign='right'
left='0'
top='3px'
fontSize={0}
@ -418,8 +570,7 @@ export const Message = ({
case 'text':
return (
<TextContent
associations={associations}
groups={groups}
key={i}
api={api}
fontSize={1}
lineHeight={'20px'}
@ -427,10 +578,11 @@ export const Message = ({
/>
);
case 'code':
return <CodeContent content={content} />;
return <CodeContent key={i} content={content} />;
case 'url':
return (
<Box
key={i}
flexShrink={0}
fontSize={1}
lineHeight='20px'
@ -464,9 +616,10 @@ export const Message = ({
</Box>
);
case 'mention':
const first = (i) => (i === 0);
const first = (i) => i === 0;
return (
<Mention
key={i}
first={first(i)}
group={group}
scrollWindow={scrollWindow}

View File

@ -20,6 +20,10 @@ import VirtualScroller from '~/views/components/VirtualScroller';
import ChatMessage, { MessagePlaceholder } from './ChatMessage';
import { UnreadNotice } from './unread-notice';
import withState from '~/logic/lib/withState';
import useGroupState from '~/logic/state/group';
import useMetadataState from '~/logic/state/metadata';
import useGraphState from '~/logic/state/graph';
const INITIAL_LOAD = 20;
const DEFAULT_BACKLOG_SIZE = 100;
@ -32,15 +36,13 @@ type ChatWindowProps = RouteComponentProps<{
}> & {
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<HTMLDivElement>;
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<boolean> => {
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 (
<ChatMessage
key={index.toString()}
@ -270,15 +299,13 @@ export default class ChatWindow extends Component<
const {
unreadCount,
api,
ship,
station,
association,
group,
contacts,
graph,
history,
groups,
associations,
showOurContact,
pendingSize
} = this.props;
@ -286,19 +313,21 @@ export default class ChatWindow extends Component<
const messageProps = {
association,
group,
contacts,
unreadMarkerRef,
history,
api,
groups,
associations
};
const unreadIndex = graph.keys()[this.props.unreadCount];
const unreadMsg = unreadIndex && graph.get(unreadIndex);
const unreadMsg = graph.get(this.state.unreadIndex);
// hack to force a re-render when we toggle showing contact
const contactsModified =
showOurContact ? 0 : 100;
return (
<Col height='100%' overflow='hidden' position='relative'>
<UnreadNotice
{ this.dismissedInitialUnread() &&
(<UnreadNotice
unreadCount={unreadCount}
unreadMsg={
unreadCount === 1 &&
@ -309,7 +338,7 @@ export default class ChatWindow extends Component<
}
dismissUnread={this.dismissUnread}
onClick={this.scrollToUnread}
/>
/>)}
<VirtualScroller
ref={(list) => {
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']]
]);

View File

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

View File

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

View File

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

View File

@ -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 (
<Switch>
<Route exact path="/~graph/join/ship/:ship/:name/:module?"
render={(props) => {
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 (
<Switch>
<Route exact path="/~graph/join/ship/:ship/:name/:module?"
render={ (props) => {
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 (
<Center width="100%" height="100%">
<Text fontSize={1}>Redirecting...</Text>
</Center>
);
}}
/>
</Switch>
);
}
};
if(!graphKeys.has(resource)) {
autoJoin();
} else if(!!association) {
history.push(`/~landscape/home/resource/${association.metadata.module}${path}`);
}
return (
<Center width="100%" height="100%">
<Text fontSize={1}>Redirecting...</Text>
</Center>
);
}}
/>
</Switch>
);
}
export default GraphApp;

View File

@ -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 = (
<Box
position={["relative", "absolute"]}
@ -60,15 +75,15 @@ export default function LaunchApp(props) {
fontSize={0}
cursor="pointer"
onClick={() => {
writeText(props.baseHash);
writeText(baseHash);
setHashText('copied');
setTimeout(() => {
setHashText(props.baseHash);
setHashText(baseHash);
}, 2000);
}}
>
<Box backgroundColor="washedGray" p={2}>
<Text mono bold>{hashText || props.baseHash}</Text>
<Text mono bold>{hashText || baseHash}</Text>
</Box>
</Box>
);
@ -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) {
</Box>
<Text mb="3" lineHeight="tall" fontWeight="medium">Welcome</Text>
<Text mb="3" lineHeight="tall">
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
<br />
Would you like a tour of Landscape?
</Text>
<Row gapX="2" justifyContent="flex-end">
<Button
backgroundColor="washedGray"
backgroundColor="washedGray"
onClick={() => setExitingTut(true)}
>Skip</Button>
<StatelessAsyncButton primary onClick={onContinue}>
@ -154,19 +162,17 @@ export default function LaunchApp(props) {
</Col>
)}
});
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 (
<>
<Helmet defer={false}>
<title>{ props.notificationsCount ? `(${String(props.notificationsCount) }) `: '' }Landscape</title>
<title>{ notificationsCount ? `(${String(notificationsCount) }) `: '' }Landscape</title>
</Helmet>
<ScrollbarLessBox height='100%' overflowY='scroll' display="flex" flexDirection="column">
{modal}
@ -196,11 +202,7 @@ export default function LaunchApp(props) {
</Box>
</Tile>
<Tiles
tiles={props.launch.tiles}
tileOrdering={props.launch.tileOrdering}
api={props.api}
location={props.userLocation}
weather={props.weather}
/>
<ModalButton
icon="Plus"
@ -221,7 +223,7 @@ export default function LaunchApp(props) {
</ModalButton>
</>}
{!hideGroups &&
(<Groups unreads={props.unreads} groups={props.groups} associations={props.associations} />)
(<Groups />)
}
</Box>
<Box alignSelf="flex-start" display={["block", "none"]}>{hashBox}</Box>

View File

@ -9,12 +9,13 @@ import { alphabeticalOrder } from '~/logic/lib/util';
import { getUnreadCount, getNotificationCount } from '~/logic/lib/hark';
import 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<typeof Box>[0]) {
const { associations, unreads, inbox, ...boxProps } = props;
const { inbox, ...boxProps } = props;
const unreads = useHarkState(state => state.unreads);
const groupState = useGroupState(state => state.groups);
const associations = useMetadataState(state => state.associations);
const groups = Object.values(associations?.groups || {})
.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 (
<Tile ref={anchorRef} position="relative" bg={isTutorialGroup ? 'lightBlue' : undefined} to={`/~landscape${path}`} gridColumnStart={first ? '1' : null}>
<Col height="100%" justifyContent="space-between">
<Text>{title}</Text>
{!hideUnreads && (<Col>
{isTutorialGroup && joined &&
(<Text>{Math.floor(moment.duration(moment(joined).add(14, 'days').diff(moment())).as('days'))} days remaining</Text>)
{isTutorialGroup && joined &&
(<Text>{days} day{days !== 1 && 's'} remaining</Text>)
}
{updates > 0 &&
(<Text mt="1" color="blue">{updates} update{updates !== 1 && 's'} </Text>)

View File

@ -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 (
<BasicTile
key={key}
title={basic.title}
iconUrl={basic.iconUrl}
linkedUrl={basic.linkedUrl}
/>
);
} else if ('custom' in tile.type) {
if (key === 'weather') {
return (
<BasicTile
<WeatherTile
key={key}
title={basic.title}
iconUrl={basic.iconUrl}
linkedUrl={basic.linkedUrl}
api={props.api}
/>
);
} else if ('custom' in tile.type) {
if (key === 'weather') {
return (
<WeatherTile
key={key}
api={props.api}
weather={props.weather}
location={props.location}
/>
);
} else if (key === 'clock') {
const location = 'nearest-area' in props.weather ? props.weather['nearest-area'][0] : '';
return (
<ClockTile key={key} location={location} />
);
}
} else {
return <CustomTile key={key} />;
} else if (key === 'clock') {
const location = weather && 'nearest-area' in weather ? weather['nearest-area'][0] : '';
return (
<ClockTile key={key} location={location} />
);
}
});
} else {
return <CustomTile key={key} />;
}
});
return (
<React.Fragment>{tiles}</React.Fragment>
);
}
return (
<>{tiles}</>
);
}
export default Tiles;

View File

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

View File

@ -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 (
<LinkWindow
key={rid}
storage={storage}
association={resource}
contacts={contacts}
resource={resourcePath}
graph={graph}
unreads={unreads}
baseUrl={resourceUrl}
group={group}
path={resource.group}
@ -106,12 +101,10 @@ export function LinkResource(props: LinkResourceProps) {
<Col width="100%" p={3} maxWidth="768px">
<Link to={resourceUrl}><Text px={3} bold>{'<- Back'}</Text></Link>
<LinkItem
contacts={contacts}
key={node.post.index}
resource={resourcePath}
node={node}
baseUrl={resourceUrl}
unreads={unreads}
group={group}
path={resource?.group}
api={api}
@ -124,8 +117,6 @@ export function LinkResource(props: LinkResourceProps) {
comments={node}
resource={resourcePath}
association={association}
unreads={unreads}
contacts={contacts}
api={api}
editCommentId={editCommentId}
history={props.history}

View File

@ -16,20 +16,20 @@ import { LinkItem } from "./components/LinkItem";
import LinkSubmit from "./components/LinkSubmit";
import { isWriter } from "~/logic/lib/group";
import { StorageState } from "~/types";
import withState from "~/logic/lib/withState";
import useGraphState from "~/logic/state/graph";
interface LinkWindowProps {
association: Association;
contacts: Rolodex;
resource: string;
graph: Graph;
unreads: Unreads;
hideNicknames: boolean;
hideAvatars: boolean;
baseUrl: string;
group: Group;
path: string;
api: GlobalApi;
storage: StorageState;
pendingSize: number;
}
const style = {
@ -40,7 +40,7 @@ const style = {
alignItems: "center",
};
export class LinkWindow extends Component<LinkWindowProps, {}> {
class LinkWindow extends Component<LinkWindowProps, {}> {
fetchLinks = async () => true;
canWrite() {
@ -75,7 +75,6 @@ export class LinkWindow extends Component<LinkWindowProps, {}> {
px={3}
>
<LinkSubmit
storage={props.storage}
name={name}
ship={ship.slice(1)}
api={api}
@ -89,7 +88,7 @@ export class LinkWindow extends Component<LinkWindowProps, {}> {
};
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<LinkWindowProps, {}> {
>
{this.canWrite() ? (
<LinkSubmit
storage={storage}
name={name}
ship={ship.slice(1)}
api={api}
@ -129,7 +127,7 @@ export class LinkWindow extends Component<LinkWindowProps, {}> {
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<LinkWindowProps, {}> {
);
}
}
export default LinkWindow;

View File

@ -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 (
<Box
@ -149,18 +148,13 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
</Anchor>
</Text>
</Box>
<Row minWidth='0' flexShrink={0} width="100%" justifyContent="space-between" py={3} bg="white">
<Author
showImage
contacts={contacts}
ship={author}
date={node.post['time-sent']}
group={group}
api={api}
></Author>
/>
<Box ml="auto">
<Link
to={node.post.pending ? '#' : `${baseUrl}/${index}`}

View File

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

View File

@ -18,6 +18,8 @@ import { getSnippet } from '~/logic/lib/publish';
import styled from 'styled-components';
import { 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 <GraphUrl title={text} url={url} />;
} else if (idx.length === 3) {
return (
<MentionText content={contents} contacts={contacts} group={group} />
);
return <MentionText content={contents} group={group} />;
}
return null;
}
@ -92,7 +91,6 @@ const GraphNodeContent = ({
<MentionText
content={contents}
group={group}
contacts={contacts}
fontSize='14px'
lineHeight='tall'
/>
@ -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}
/>
</Row>
);
@ -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 (
<Row onClick={onClick} gapX='2' pt={showContact ? 2 : 0}>
<Col flexGrow={1} alignItems='flex-start'>
{showContact && (
<Author
showImage
contacts={contacts}
ship={author}
date={time}
group={group}
/>
<Author showImage ship={author} date={time} group={group} />
)}
<Row width='100%' p='1' flexDirection='column'>
<GraphNodeContent
contacts={contacts}
post={post}
mod={mod}
description={description}
@ -235,12 +229,9 @@ export function GraphNotification(props: {
read: boolean;
time: number;
timebox: BigInteger;
associations: Associations;
groups: Groups;
contacts: Rolodex;
api: GlobalApi;
}) {
const { contents, index, read, time, api, timebox, groups } = props;
const { contents, index, read, time, api, timebox } = props;
const authors = _.map(contents, 'author');
const { graph, group } = index;
@ -255,6 +246,8 @@ export function GraphNotification(props: {
return api.hark['read'](timebox, { graph: index });
}, [api, timebox, index, read]);
const groups = useGroupState((state) => state.groups);
return (
<>
<Header
@ -265,17 +258,14 @@ export function GraphNotification(props: {
authors={authors}
moduleIcon={icon}
channel={graph}
contacts={props.contacts}
group={group}
description={desc}
associations={props.associations}
/>
<Box flexGrow={1} width='100%' pl={5} gridArea='main'>
{_.map(contents, (content, idx) => (
<GraphNode
post={content}
author={content.author}
contacts={props.contacts}
mod={index.module}
time={content?.['time-sent']}
description={index.description}

View File

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

View File

@ -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<typeof Text>) => (
<NormalText fontWeight="500" {...props} />
);
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 (
<Text mono={!showNickname}>
@ -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<typeof Row> ): ReactElement {
const { description, channel, contacts, moduleIcon, read } = props;
const { description, channel, moduleIcon, read } = props;
const associations = useMetadataState(state => state.associations);
const authors = _.uniq(props.authors);
@ -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 <Author key={idx} contacts={contacts} patp={p} last={last} />;
return <Author key={idx} patp={p} last={last} />;
}),
auths => (
<React.Fragment>
@ -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 (

View File

@ -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<DatedTimebox, DatedTimebox>(([date, nots]) => [
date,
nots.filter(filterNotification(associations, props.filter))
nots.filter(filterNotification(props.filter))
]),
f.groupBy<DatedTimebox>(([d]) => {
const date = moment(daToUnix(d));
@ -119,7 +116,7 @@ export default function Inbox(props: {
return (
<Col ref={scrollRef} position="relative" height="100%" overflowY="auto">
<Invites groups={props.groups} pendingJoin={props.pendingJoin} invites={invites} api={api} associations={associations} />
<Invites pendingJoin={props.pendingJoin} api={api} />
{[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => {
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({
<Box flexShrink={0} height="4px" bg="scales.black05" />
)}
<Notification
graphConfig={graphConfig}
groupConfig={groupConfig}
api={api}
associations={associations}
notification={not}
archived={archive}
contacts={contacts}
groups={groups}
time={date}
/>
</React.Fragment>

View File

@ -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 (
<Col
@ -50,33 +49,27 @@ export function Invites(props: InvitesProps): ReactElement {
.sort(alphabeticalOrder)
.map((resource) => {
const inviteOrStatus = invitesAndStatus[resource];
if(typeof inviteOrStatus === 'string') {
const join = pendingJoin[resource];
if(typeof inviteOrStatus === 'string') {
return (
<InviteItem
key={resource}
contacts={props.contacts}
groups={props.groups}
associations={props.associations}
resource={resource}
pendingJoin={pendingJoin}
pendingJoin={join}
api={api}
/>
);
} else {
const { app, uid, invite } = inviteOrStatus;
console.log(inviteOrStatus);
return (
<InviteItem
key={resource}
api={api}
invite={invite}
pendingJoin={join}
app={app}
uid={uid}
pendingJoin={pendingJoin}
resource={resource}
contacts={props.contacts}
groups={props.groups}
associations={props.associations}
/>
);
}

View File

@ -18,17 +18,13 @@ import { GroupNotification } from './group';
import { GraphNotification } from './graph';
import { 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}
</NotificationWrapper>
@ -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}
/>
</Wrapper>
);
@ -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}
/>
</Wrapper>
);

View File

@ -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<NotificationFilter>({ 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<HTMLElement | null>(null);
useTutorialModal('notifications', true, anchorRef);
const notificationsCount = useHarkState(state => state.notificationsCount);
return (
<Switch>
<Route
@ -61,7 +65,7 @@ export default function NotificationsScreen(props: any): ReactElement {
return (
<>
<Helmet defer={false}>
<title>{ props.notificationsCount ? `(${String(props.notificationsCount) }) `: '' }Landscape - Notifications</title>
<title>{ notificationsCount ? `(${String(notificationsCount) }) `: '' }Landscape - Notifications</title>
</Helmet>
<Body>
<Col overflowY="hidden" height="100%">
@ -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}
/>
</FormikOnBlur>
</Col>

View File

@ -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 ? (
<div>
{editCover ? (
<ImageInput id='cover' storage={storage} marginTop='-8px' />
<ImageInput id='cover' marginTop='-8px' />
) : (
<Row>
<Button mr='2' onClick={() => setEditCover(true)}>
@ -76,14 +77,15 @@ export function ProfileHeaderImageEdit(props: any): ReactElement {
)}
</div>
) : (
<ImageInput id='cover' storage={storage} marginTop='-8px' />
<ImageInput id='cover' marginTop='-8px' />
)}
</>
);
}
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 {
<Form width='100%' height='100%'>
<ProfileHeader>
<ProfileControls>
<Row>
<Row alignItems='baseline'>
<Button
type='submit'
display='inline'
@ -176,10 +178,13 @@ export function EditProfile(props: any): ReactElement {
</Row>
<ProfileStatus contact={contact} />
</ProfileControls>
<ProfileImages hideCover={hideCover} contact={contact} ship={ship}>
<ProfileImages
hideCover={hideCover}
contact={contact}
ship={ship}
>
<ProfileHeaderImageEdit
contact={contact}
storage={storage}
setFieldValue={setFieldValue}
handleHideCover={handleHideCover}
/>
@ -193,23 +198,16 @@ export function EditProfile(props: any): ReactElement {
<ImageInput
id='avatar'
label='Overlay Avatar (may be hidden by other users)'
storage={storage}
/>
</Col>
</Row>
<Input id='nickname' label='Custom Name' mb={3} />
<Col width='100%'>
<Text mb={2}>Description</Text>
<MarkdownField id='bio' mb={3} storage={storage} />
<MarkdownField id='bio' mb={3} />
</Col>
<Checkbox mb={3} id='isPublic' label='Public Profile' />
<GroupSearch
label='Pinned Groups'
id='groups'
groups={props.groups}
associations={props.associations}
publicOnly
/>
<GroupSearch label='Pinned Groups' id='groups' publicOnly />
<AsyncButton primary loadingText='Updating...' border mt={3}>
Submit
</AsyncButton>

View File

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

View File

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

View File

@ -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) {
</ProfileHeader>
<Row pb={2} alignItems='center' width='100%'>
<Center width='100%'>
<Text>
<Text fontWeight='500'>
{!hideNicknames && contact?.nickname ? contact.nickname : ''}
</Text>
</Center>
@ -49,7 +51,7 @@ export function ViewProfile(props: any) {
</Text>
</Center>
</Row>
<Col pb={2} alignItems='center' justifyContent='center' width='100%'>
<Col pb={2} mt='3' alignItems='center' justifyContent='center' width='100%'>
<Center flexDirection='column' maxWidth='32rem'>
<RichText width='100%' disableRemoteContent>
{contact?.bio ? contact.bio : ''}
@ -64,8 +66,6 @@ export function ViewProfile(props: any) {
<GroupLink
api={api}
resource={g}
groups={groups}
associations={associations}
measure={() => {}}
/>
))}

View File

@ -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 (
<>
<Helmet defer={false}>
<title>
{props.notificationsCount
? `(${String(props.notificationsCount)}) `
{notificationsCount
? `(${String(notificationsCount)}) `
: ''}
Landscape - Profile
</title>
@ -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 (
<Box height='100%' px={[0, 3]} pb={[0, 3]} borderRadius={2}>
@ -41,15 +43,10 @@ export default function ProfileScreen(props: any) {
<Box>
<Profile
ship={ship}
hasLoaded={Object.keys(props.contacts).length !== 0}
associations={props.associations}
groups={props.groups}
hasLoaded={Object.keys(contacts).length !== 0}
contact={contact}
api={props.api}
storage={props.storage}
isEdit={isEdit}
isPublic={isPublic}
nackedContacts={props.nackedContacts}
/>
</Box>
</Box>

View File

@ -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}
/>
</Box>
);

View File

@ -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..."
/>

View File

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

View File

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

View File

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

View File

@ -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 = (
<Box display="inline-block" verticalAlign="middle">
<Link to={`${baseUrl}/edit`}>
<Text
color="green"
ml={2}
adminLinks.push(
<Link
style={{ 'display': 'inline-block' }}
to={`${baseUrl}/edit`}
>
Update
</Text>
<Text
color="blue"
ml={2}
>
Update
</Text>
</Link>
<Text
color="red"
ml={2}
onClick={deletePost}
style={{ cursor: 'pointer' }}
>
Delete
</Text>
</Box>
);
}
)
};
if (window.ship === note?.post?.author || ourRole === "admin") {
adminLinks.push(
<Text
color="red"
display='inline-block'
ml={2}
onClick={deletePost}
style={{ cursor: 'pointer' }}
>
Delete
</Text>
)
};
const windowRef = React.useRef(null);
useEffect(() => {
@ -105,14 +112,15 @@ export function Note(props: NoteProps & RouteComponentProps) {
</Link>
<Col>
<Text display="block" mb={2}>{title || ''}</Text>
<Box display="flex">
<Row alignItems="center">
<Author
showImage
ship={post?.author}
contacts={contacts}
date={post?.['time-sent']}
group={group}
/>
<Text ml={2}>{adminLinks}</Text>
</Box>
<Text ml={1}>{adminLinks}</Text>
</Row>
</Col>
<Box color="black" className="md" style={{ overflowWrap: 'break-word', overflow: 'hidden' }}>
<ReactMarkdown source={body} linkTarget={'_blank'} renderers={renderers} />
@ -126,9 +134,7 @@ export function Note(props: NoteProps & RouteComponentProps) {
<Comments
ship={ship}
name={props.book}
unreads={props.unreads}
comments={comments}
contacts={props.contacts}
association={props.association}
api={props.api}
baseUrl={baseUrl}

View File

@ -9,7 +9,6 @@ import {
import { AsyncButton } from '../../../components/AsyncButton';
import { Formik, Form, FormikHelpers } from 'formik';
import { MarkdownField } from './MarkdownField';
import { StorageState } from '~/types';
interface PostFormProps {
initial: PostFormSchema;
@ -21,7 +20,6 @@ interface PostFormProps {
) => Promise<any>;
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 (
<Col width="100%" height="100%" p={[2, 4]}>
@ -67,7 +65,7 @@ export function PostForm(props: PostFormProps) {
>Cancel</Button>}
</Row>
</Row>
<MarkdownField flexGrow={1} id="body" storage={storage} />
<MarkdownField flexGrow={1} id="body" />
</Form>
</Formik>
</Col>

View File

@ -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) {
<Row minWidth='0' flexShrink={0} width="100%" justifyContent="space-between" py={3} bg="white">
<Author
showImage
contacts={contacts}
ship={post?.author}
date={post?.['time-sent']}
group={group}
unread={isUnread}
api={props.api}
/>
<Box ml="auto" mr={1}>
<Link to={url}>

View File

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

View File

@ -5,45 +5,42 @@ import { Col, Box, Text, Row } from '@tlon/indigo-react';
import { Contacts, Rolodex, Groups, Associations, Graph, Association, Unreads } from '@urbit/api';
import { 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 (
<Col gapY="4" pt={4} mx="auto" px={3} maxWidth="768px">
<Row justifyContent="space-between">
@ -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}
/>
</Col>

View File

@ -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 (
<Col>
{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}
/>
)

View File

@ -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 <Notebook
{...props}
graph={graph}
contacts={contacts}
association={props.association}
rootUrl={rootUrl}
baseUrl={baseUrl}
@ -77,7 +76,6 @@ export function NotebookRoutes(
association={props.association}
graph={graph}
baseUrl={baseUrl}
storage={props.storage}
/>
)}
/>
@ -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}
/>
);

View File

@ -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 {
>
<Form>
<ShipSearch
groups={groups}
contacts={contacts}
id="ships"
label=""
maxLength={undefined}

View File

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

View File

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

View File

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

View File

@ -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({
<ImageInput
ml="5"
api={api}
storage={storage}
id="bgUrl"
placeholder="Drop or upload a file, or paste a link here"
name="bgUrl"

View File

@ -54,10 +54,10 @@ export function CalmPrefs(props: {
hideUnreads,
hideGroups,
hideUtilities,
imageShown,
videoShown,
oembedShown,
audioShown,
imageShown: !imageShown,
videoShown: !videoShown,
oembedShown: !oembedShown,
audioShown: !audioShown
};
const onSubmit = useCallback(async (v: FormSchema, actions: FormikHelpers<FormSchema>) => {
@ -67,10 +67,10 @@ export function CalmPrefs(props: {
api.settings.putEntry('calm', 'hideUnreads', v.hideUnreads),
api.settings.putEntry('calm', '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: {
<Form>
<BackButton/>
<Col borderBottom="1" borderBottomColor="washedGray" p="5" pt="4" gapY="5">
<Col gapY="1" mt="0">
<Col gapY="1" mt="0">
<Text color="black" fontSize={2} fontWeight="medium">
CalmEngine
</Text>
@ -115,24 +115,24 @@ export function CalmPrefs(props: {
id="hideNicknames"
caption="Do not show user-set nicknames"
/>
<Text fontWeight="medium">Remote Content</Text>
<Text fontWeight="medium">Remote content</Text>
<Toggle
label="Load images"
label="Disable images"
id="imageShown"
caption="Images will be replaced with an inline placeholder that must be clicked to be viewed"
/>
<Toggle
label="Load audio files"
label="Disable audio files"
id="audioShown"
caption="Audio content will be replaced with an inline placeholder that must be clicked to be viewed"
/>
<Toggle
label="Load video files"
label="Disable video files"
id="videoShown"
caption="Video content will be replaced with an inline placeholder that must be clicked to be viewed"
/>
<Toggle
label="Load embedded content"
label="Disable embedded content"
id="oembedShown"
caption="Embedded content may contain scripts that can track you"
/>

View File

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

View File

@ -36,13 +36,12 @@ interface FormSchema {
interface DisplayFormProps {
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}
/>
<Label>Theme</Label>
<Radio name="theme" id="light" label="Light"/>

View File

@ -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
</Text>
</Col>
<FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
<Formik initialValues={initialValues} onSubmit={onSubmit}>
<Form>
<Col gapY="4">
<Toggle
@ -81,9 +81,12 @@ export function NotificationPreferences(props: {
id="mentions"
caption="Notify me if someone mentions my @p in a channel I've joined"
/>
<AsyncButton primary width="fit-content">
Save
</AsyncButton>
</Col>
</Form>
</FormikOnBlur>
</Formik>
</Col>
</>
);

View File

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

View File

@ -1,5 +1,5 @@
import React, { ReactElement, useCallback } from 'react';
import { 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<FormSchema>) => {
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 (
<>
<Col p="5" pt="4" borderBottom="1" borderBottomColor="washedGray">
<BackButton />
<Col p='5' pt='4' borderBottom='1' borderBottomColor='washedGray'>
<Formik
initialValues={
{
@ -67,42 +70,42 @@ export default function S3Form(props: S3FormProps): ReactElement {
onSubmit={onSubmit}
>
<Form>
<BackButton/>
<Col maxWidth="600px" gapY="5">
<Col gapY="1" mt="0">
<Text color="black" fontSize={2} fontWeight="medium">
<Col maxWidth='600px' gapY='5'>
<Col gapY='1' mt='0'>
<Text color='black' fontSize={2} fontWeight='medium'>
S3 Storage Setup
</Text>
<Text gray>
Store credentials for your S3 object storage buckets on your
Urbit ship, and upload media freely to various modules.
<Anchor
target="_blank"
target='_blank'
style={{ textDecoration: 'none' }}
borderBottom="1"
ml="1"
href="https://urbit.org/using/operations/using-your-ship/#bucket-setup">
borderBottom='1'
ml='1'
href='https://urbit.org/using/operations/using-your-ship/#bucket-setup'
>
Learn more
</Anchor>
</Text>
</Col>
<Input label="Endpoint" id="s3endpoint" />
<Input label="Access Key ID" id="s3accessKeyId" />
<Input label='Endpoint' id='s3endpoint' />
<Input label='Access Key ID' id='s3accessKeyId' />
<Input
type="password"
label="Secret Access Key"
id="s3secretAccessKey"
type='password'
label='Secret Access Key'
id='s3secretAccessKey'
/>
<Button style={{ cursor: "pointer" }} type="submit">
<AsyncButton primary style={{ cursor: 'pointer' }} type='submit'>
Submit
</Button>
</AsyncButton>
</Col>
</Form>
</Formik>
</Col>
<Col maxWidth="600px" p="5" gapY="4">
<Col gapY="1">
<Text color="black" mb={4} fontSize={2} fontWeight="medium">
<Col maxWidth='600px' p='5' gapY='4'>
<Col gapY='1'>
<Text color='black' mb={4} fontSize={2} fontWeight='medium'>
S3 Buckets
</Text>
<Text gray>

View File

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

View File

@ -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 }) => (
<Box height="100%" width="100%" px={[0, 3]} pb={[0, 3]} borderRadius={1}>
<Box height='100%' width='100%' px={[0, 3]} pb={[0, 3]} borderRadius={1}>
<Box
height="100%"
width="100%"
borderRadius={1}
bg="white"
display='grid'
gridTemplateColumns={[
'100%',
'minmax(150px, 1fr) 3fr',
'minmax(250px, 1fr) 4fr'
]}
gridTemplateRows='100%'
height='100%'
width='100%'
borderRadius={2}
bg='white'
border={1}
borderColor="washedGray"
borderColor='washedGray'
>
{props.children}
</Box>
</Box>
);
type ProvSideProps = "to" | "selected";
type ProvSideProps = 'to' | 'selected';
type BaseProps = PropFunc<typeof BaseSidebarItem>;
function SidebarItem(props: { hash: string } & Omit<BaseProps, ProvSideProps>) {
const { hash, icon, text, ...rest } = props;
@ -54,85 +63,81 @@ function SettingsItem(props: { children: ReactNode }) {
const { children } = props;
return (
<Box borderBottom="1" borderBottomColor="washedGray">
<Box borderBottom='1' borderBottomColor='washedGray'>
{children}
</Box>
);
}
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 (
<>
<Helmet defer={false}>
<title>Landscape - Settings</title>
<title>{ notificationsCount ? `(${String(notificationsCount) }) `: '' }Landscape - Settings</title>
</Helmet>
<Skeleton>
<Row height="100%" overflow="hidden">
<Col
height="100%"
borderRight="1"
borderRightColor="washedGray"
display={hash === "" ? "flex" : ["none", "flex"]}
minWidth="250px"
width="100%"
maxWidth={["100vw", "350px"]}
>
<Text
display="block"
my="4"
mx="3"
fontSize="2"
fontWeight="medium"
>
System Preferences
</Text>
<Col gapY="1">
<SidebarItem
icon="Inbox"
text="Notifications"
hash="notifications"
/>
<SidebarItem icon="Image" text="Display" hash="display" />
<SidebarItem icon="Upload" text="Remote Storage" hash="s3" />
<SidebarItem icon="LeapArrow" text="Leap" hash="leap" />
<SidebarItem icon="Node" text="CalmEngine" hash="calm" />
<SidebarItem
icon="Locked"
text="Devices + Security"
hash="security"
/>
</Col>
<Col
height='100%'
borderRight='1'
borderRightColor='washedGray'
display={hash === '' ? 'flex' : ['none', 'flex']}
width='100%'
overflowY='auto'
>
<Text display='block' mt='4' mb='3' mx='3' fontSize='2' fontWeight='700'>
System Preferences
</Text>
<Col>
<SidebarItem
icon='Inbox'
text='Notifications'
hash='notifications'
/>
<SidebarItem icon='Image' text='Display' hash='display' />
<SidebarItem icon='Upload' text='Remote Storage' hash='s3' />
<SidebarItem icon='LeapArrow' text='Leap' hash='leap' />
<SidebarItem icon='Node' text='CalmEngine' hash='calm' />
<SidebarItem
icon='Locked'
text='Devices + Security'
hash='security'
/>
</Col>
<Col flexGrow={1} overflowY="auto">
<SettingsItem>
{hash === "notifications" && (
<NotificationPreferences
{...props}
graphConfig={props.notificationsGraphConfig}
/>
)}
{hash === "display" && (
<DisplayForm storage={props.storage} api={props.api} />
)}
{hash === "s3" && (
<S3Form storage={props.storage} api={props.api} />
)}
{hash === "leap" && (
<LeapSettings api={props.api} />
)}
{hash === "calm" && (
<CalmPrefs api={props.api} />
)}
{hash === "security" && (
<SecuritySettings api={props.api} />
)}
</SettingsItem>
</Col>
</Row>
</Col>
<Col flexGrow={1} overflowY='auto'>
<SettingsItem>
{hash === 'notifications' && (
<NotificationPreferences
{...props}
graphConfig={props.notificationsGraphConfig}
/>
)}
{hash === 'display' && <DisplayForm api={props.api} />}
{hash === 's3' && <S3Form api={props.api} />}
{hash === 'leap' && <LeapSettings api={props.api} />}
{hash === 'calm' && <CalmPrefs api={props.api} />}
{hash === 'security' && <SecuritySettings api={props.api} />}
{hash === 'debug' && <DebugPane />}
</SettingsItem>
</Col>
</Skeleton>
</>
);

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