mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-12-15 01:52:42 +03:00
Merge remote-tracking branch 'origin/release/next-js' into james/profile-tweaks
This commit is contained in:
commit
9c3995024d
27
.github/workflows/merge-master.yml
vendored
Normal file
27
.github/workflows/merge-master.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
name: merge
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
jobs:
|
||||
merge-to-next-js:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Merge master to release/next-js"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: devmasx/merge-branch@v1.3.1
|
||||
with:
|
||||
type: now
|
||||
target_branch: release/next-js
|
||||
github_token: ${{ secrets.JANEWAY_BOT_TOKEN }}
|
||||
|
||||
merge-to-group-timer:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Merge master to ops/group-timer"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: devmasx/merge-branch@v1.3.1
|
||||
with:
|
||||
type: now
|
||||
target_branch: ops/group-timer
|
||||
github_token: ${{ secrets.JANEWAY_BOT_TOKEN }}
|
@ -1,17 +1,17 @@
|
||||
name: merge
|
||||
name: ops-merge
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
- 'release/*'
|
||||
jobs:
|
||||
merge-to-next-js:
|
||||
merge-release-to-ops:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Merge master to release/next-js"
|
||||
name: "Merge to ops-tlon"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: devmasx/merge-branch@v1.3.1
|
||||
with:
|
||||
type: now
|
||||
target_branch: release/next-js
|
||||
target_branch: ops-tlon
|
||||
github_token: ${{ secrets.JANEWAY_BOT_TOKEN }}
|
||||
|
20
.github/workflows/ops-group-timer.yml
vendored
Normal file
20
.github/workflows/ops-group-timer.yml
vendored
Normal file
@ -0,0 +1,20 @@
|
||||
name: group-timer
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'ops/group-timer'
|
||||
jobs:
|
||||
glob:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Create and deploy a glob to ~difmex-passed"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
lfs: true
|
||||
- uses: ./.github/actions/glob
|
||||
with:
|
||||
ship: 'difmex-passed'
|
||||
credentials: ${{ secrets.JANEWAY_SERVICE_KEY }}
|
||||
ssh-sec-key: ${{ secrets.JANEWAY_SSH_SEC_KEY }}
|
||||
ssh-pub-key: ${{ secrets.JANEWAY_SSH_PUB_KEY }}
|
||||
|
60
.github/workflows/publish-npm-packages.yml
vendored
Normal file
60
.github/workflows/publish-npm-packages.yml
vendored
Normal file
@ -0,0 +1,60 @@
|
||||
name: publish-npm-packages
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'master'
|
||||
jobs:
|
||||
publish-api:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Publish '@urbit/api' if a new version is available"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
lfs: true
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '14'
|
||||
- run: 'npm install'
|
||||
working-directory: 'pkg/npm/api'
|
||||
- uses: JS-DevTools/npm-publish@v1
|
||||
with:
|
||||
check-version: true
|
||||
package: './pkg/npm/api/package.json'
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
publish-http-api:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Publish '@urbit/http-api' if a new version is available"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
lfs: true
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '14'
|
||||
- run: 'npm install'
|
||||
working-directory: 'pkg/npm/http-api'
|
||||
- uses: JS-DevTools/npm-publish@v1
|
||||
with:
|
||||
check-version: true
|
||||
package: './pkg/npm/http-api/package.json'
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
publish-eslint-config:
|
||||
runs-on: ubuntu-latest
|
||||
name: "Publish '@urbit/eslint-config' if a new version is available"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
lfs: true
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '14'
|
||||
- run: 'npm install'
|
||||
working-directory: 'pkg/npm/eslint-config'
|
||||
- uses: JS-DevTools/npm-publish@v1
|
||||
with:
|
||||
check-version: true
|
||||
package: './pkg/npm/eslint-config/package.json'
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
|
@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7bf07220286010c21998e38497be5f2c771219bbcb6dac3e2c7bb0af5dbf4fb8
|
||||
size 9668893
|
||||
oid sha256:9812a52d34be0d6d47ca60b23d3386e7db296ff61fac7c4b1f33a35806f8cb7c
|
||||
size 9751012
|
||||
|
@ -5,7 +5,7 @@
|
||||
/- glob
|
||||
/+ default-agent, verb, dbug
|
||||
|%
|
||||
++ hash 0v3.10f5l.mmsef.76usq.9a3gk.0rmog
|
||||
++ hash 0v9flom.311gv.90jce.591n4.d09bf
|
||||
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
|
||||
+$ all-states
|
||||
$% state-0
|
||||
|
@ -767,6 +767,8 @@
|
||||
++ inflate-cache
|
||||
|= state-4
|
||||
^+ +.state
|
||||
=. +.state
|
||||
*cache
|
||||
=/ nots=(list [p=@da =timebox:store])
|
||||
(tap:orm notifications)
|
||||
|- =* outer $
|
||||
|
@ -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.27d9bb22f3eebe7228b8.js"></script>
|
||||
<script src="/~landscape/js/bundle/index.579404e0378c0c8cd2fe.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -4,13 +4,12 @@
|
||||
+$ card card:agent:gall
|
||||
+$ versioned-state
|
||||
$% state-0
|
||||
state-1
|
||||
==
|
||||
+$ state-0
|
||||
$: %0
|
||||
=settings
|
||||
==
|
||||
+$ state-0 [%0 settings=settings-0]
|
||||
+$ state-1 [%1 =settings]
|
||||
--
|
||||
=| state-0
|
||||
=| state-1
|
||||
=* state -
|
||||
::
|
||||
%- agent:dbug
|
||||
@ -32,8 +31,10 @@
|
||||
|= =old=vase
|
||||
^- (quip card _this)
|
||||
=/ old !<(versioned-state old-vase)
|
||||
|-
|
||||
?- -.old
|
||||
%0 [~ this(state old)]
|
||||
%0 $(old [%1 +.old])
|
||||
%1 [~ this(state old)]
|
||||
==
|
||||
::
|
||||
++ on-poke
|
||||
|
@ -50,7 +50,7 @@
|
||||
%- pairs
|
||||
:~ bucket-key+s+b
|
||||
entry-key+s+k
|
||||
value+(val v)
|
||||
value+(value v)
|
||||
==
|
||||
::
|
||||
++ del-entry
|
||||
@ -68,6 +68,7 @@
|
||||
%s val
|
||||
%b val
|
||||
%n (numb p.val)
|
||||
%a [%a (turn p.val value)]
|
||||
==
|
||||
::
|
||||
++ bucket
|
||||
@ -105,7 +106,7 @@
|
||||
%- ot
|
||||
:~ bucket-key+so
|
||||
entry-key+so
|
||||
value+val
|
||||
value+value
|
||||
==
|
||||
::
|
||||
++ del-entry
|
||||
@ -121,6 +122,7 @@
|
||||
%s jon
|
||||
%b jon
|
||||
%n [%n (rash p.jon dem)]
|
||||
%a [%a (turn p.jon value)]
|
||||
==
|
||||
::
|
||||
++ bucket
|
||||
|
@ -1,11 +1,21 @@
|
||||
|%
|
||||
+$ settings-0 (map key bucket-0)
|
||||
+$ bucket-0 (map key val-0)
|
||||
+$ val-0
|
||||
$% [%s p=@t]
|
||||
[%b p=?]
|
||||
[%n p=@]
|
||||
==
|
||||
::
|
||||
+$ settings (map key bucket)
|
||||
+$ bucket (map key val)
|
||||
+$ key term
|
||||
+$ val
|
||||
$~ [%n 0]
|
||||
$% [%s p=@t]
|
||||
[%b p=?]
|
||||
[%n p=@]
|
||||
[%a p=(list val)]
|
||||
==
|
||||
+$ event
|
||||
$% [%put-bucket =key =bucket]
|
||||
|
@ -1217,7 +1217,7 @@
|
||||
on-hear-forward
|
||||
::
|
||||
?: ?& ?=(%pawn (clan:title sndr.packet))
|
||||
!(~(has by peers.ames-state) sndr.packet)
|
||||
!?=([~ %known *] (~(get by peers.ames-state) sndr.packet))
|
||||
==
|
||||
on-hear-open
|
||||
on-hear-shut
|
||||
@ -1289,14 +1289,9 @@
|
||||
|= [=lane =packet dud=(unit goof)]
|
||||
^+ event-core
|
||||
=/ sndr-state (~(get by peers.ames-state) sndr.packet)
|
||||
:: if we don't know them, maybe enqueue a jael %public-keys request
|
||||
::
|
||||
:: Ignore encrypted packets from alien comets.
|
||||
:: TODO: maybe crash?
|
||||
:: if we don't know them, ask jael for their keys and enqueue
|
||||
::
|
||||
?. ?=([~ %known *] sndr-state)
|
||||
?: =(%pawn (clan:title sndr.packet))
|
||||
event-core
|
||||
(enqueue-alien-todo sndr.packet |=(alien-agenda +<))
|
||||
:: decrypt packet contents using symmetric-key.channel
|
||||
::
|
||||
|
15
pkg/interface/package-lock.json
generated
15
pkg/interface/package-lock.json
generated
@ -1782,31 +1782,26 @@
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
"version": "7.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz",
|
||||
"integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"regenerator-runtime": "^0.13.4"
|
||||
}
|
||||
},
|
||||
"@urbit/eslint-config": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@urbit/eslint-config/-/eslint-config-1.0.0.tgz",
|
||||
"integrity": "sha512-Xmzb6MvM7KorlPJEq/hURZZ4BHSVy/7CoQXWogsBSTv5MOZnMqwNKw6yt24k2AO/2UpHwjGptimaNLqFfesJbw=="
|
||||
"bundled": true
|
||||
},
|
||||
"big-integer": {
|
||||
"version": "1.6.48",
|
||||
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.48.tgz",
|
||||
"integrity": "sha512-j51egjPa7/i+RdiRuJbPdJ2FIUYYPhvYLjzoYbcMMm62ooO6F94fETG4MTs46zPAF9Brs04OajboA/qTGuz78w=="
|
||||
"bundled": true
|
||||
},
|
||||
"lodash": {
|
||||
"version": "4.17.20",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
|
||||
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
|
||||
"bundled": true
|
||||
},
|
||||
"regenerator-runtime": {
|
||||
"version": "0.13.7",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
|
||||
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
|
||||
"bundled": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -95,7 +95,7 @@
|
||||
"tsc": "tsc",
|
||||
"tsc:watch": "tsc --watch",
|
||||
"build:dev": "cross-env NODE_ENV=development webpack --config config/webpack.dev.js",
|
||||
"build:prod": "cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
|
||||
"build:prod": "cd ../npm/api && npm i && cd ../../interface && cross-env NODE_ENV=production webpack --config config/webpack.prod.js",
|
||||
"start": "webpack-dev-server --config config/webpack.dev.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
|
@ -60,7 +60,6 @@ export default class BaseApi<S extends object = {}> {
|
||||
}
|
||||
|
||||
scry<T>(app: string, path: Path): Promise<T> {
|
||||
console.log(path);
|
||||
return fetch(`/~/scry/${app}${path}.json`).then(r => r.json() as Promise<T>);
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,7 @@ export default class SettingsApi extends BaseApi<StoreState> {
|
||||
}
|
||||
|
||||
putBucket(key: Key, bucket: Bucket) {
|
||||
this.storeAction({
|
||||
return this.storeAction({
|
||||
'put-bucket': {
|
||||
'bucket-key': key,
|
||||
'bucket': bucket
|
||||
@ -20,7 +20,7 @@ export default class SettingsApi extends BaseApi<StoreState> {
|
||||
}
|
||||
|
||||
delBucket(key: Key) {
|
||||
this.storeAction({
|
||||
return this.storeAction({
|
||||
'del-bucket': {
|
||||
'bucket-key': key
|
||||
}
|
||||
@ -38,7 +38,7 @@ export default class SettingsApi extends BaseApi<StoreState> {
|
||||
}
|
||||
|
||||
delEntry(buc: Key, key: Key) {
|
||||
this.storeAction({
|
||||
return this.storeAction({
|
||||
'put-entry': {
|
||||
'bucket-key': buc,
|
||||
'entry-key': key
|
||||
@ -47,8 +47,10 @@ export default class SettingsApi extends BaseApi<StoreState> {
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
const data = await this.scry('settings-store', '/all');
|
||||
this.store.handleEvent({ data: { 'settings-data': data.all } });
|
||||
const { all } = await this.scry("settings-store", "/all");
|
||||
this.store.handleEvent({data:
|
||||
{"settings-data": { all } }
|
||||
});
|
||||
}
|
||||
|
||||
async getBucket(bucket: Key) {
|
||||
|
72
pkg/interface/src/logic/lib/migrateSettings.ts
Normal file
72
pkg/interface/src/logic/lib/migrateSettings.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import useLocalState, { LocalState } from "~/logic/state/local";
|
||||
import useSettingsState from "~/logic/state/settings";
|
||||
import GlobalApi from "../api/global";
|
||||
import { BackgroundConfig, RemoteContentPolicy } from "~/types";
|
||||
|
||||
const getBackgroundString = (bg: BackgroundConfig) => {
|
||||
if (bg?.type === "url") {
|
||||
return bg.url;
|
||||
} else if (bg?.type === "color") {
|
||||
return bg.color;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
export function useMigrateSettings(api: GlobalApi) {
|
||||
const local = useLocalState();
|
||||
const { display, remoteContentPolicy, calm } = useSettingsState();
|
||||
|
||||
return async () => {
|
||||
if (!localStorage?.has("localReducer")) {
|
||||
return;
|
||||
}
|
||||
|
||||
let promises: Promise<any>[] = [];
|
||||
|
||||
if (local.hideAvatars !== calm.hideAvatars) {
|
||||
promises.push(
|
||||
api.settings.putEntry("calm", "hideAvatars", local.hideAvatars)
|
||||
);
|
||||
}
|
||||
|
||||
if (local.hideNicknames !== calm.hideNicknames) {
|
||||
promises.push(
|
||||
api.settings.putEntry("calm", "hideNicknames", local.hideNicknames)
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
local?.background?.type &&
|
||||
display.background !== getBackgroundString(local.background)
|
||||
) {
|
||||
promises.push(
|
||||
api.settings.putEntry(
|
||||
"display",
|
||||
"background",
|
||||
getBackgroundString(local.background)
|
||||
)
|
||||
);
|
||||
promises.push(
|
||||
api.settings.putEntry(
|
||||
"display",
|
||||
"backgroundType",
|
||||
local.background?.type
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Object.keys(local.remoteContentPolicy).forEach((_key) => {
|
||||
const key = _key as keyof RemoteContentPolicy;
|
||||
const localVal = local.remoteContentPolicy[key];
|
||||
if (localVal !== remoteContentPolicy[key]) {
|
||||
promises.push(
|
||||
api.settings.putEntry("remoteContentPolicy", key, localVal)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
localStorage.removeItem("localReducer");
|
||||
};
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { cite } from '~/logic/lib/util';
|
||||
import { isChannelAdmin } from '~/logic/lib/group';
|
||||
|
||||
const indexes = new Map([
|
||||
const makeIndexes = () => new Map([
|
||||
['ships', []],
|
||||
['commands', []],
|
||||
['subscriptions', []],
|
||||
@ -70,18 +70,27 @@ const appIndex = function (apps) {
|
||||
return applications;
|
||||
};
|
||||
|
||||
const otherIndex = function() {
|
||||
const otherIndex = function(config) {
|
||||
const other = [];
|
||||
other.push(result('My Channels', '/~landscape/home', 'home', null));
|
||||
other.push(result('Notifications', '/~notifications', 'inbox', null));
|
||||
other.push(result('Profile and Settings', `/~profile/~${window.ship}`, 'profile', null));
|
||||
other.push(result('Messages', '/~landscape/messages', 'messages', null));
|
||||
other.push(result('Log Out', '/~/logout', 'logout', null));
|
||||
const idx = {
|
||||
mychannel: result('My Channels', '/~landscape/home', 'home', null),
|
||||
updates: result('Notifications', '/~notifications', 'inbox', null),
|
||||
profile: result('Profile', `/~profile/~${window.ship}`, 'profile', null),
|
||||
messages: result('Messages', '/~landscape/messages', 'messages', null),
|
||||
logout: result('Log Out', '/~/logout', 'logout', null)
|
||||
};
|
||||
|
||||
for(let cat of config.categories) {
|
||||
if(idx[cat]) {
|
||||
other.push(idx[cat]);
|
||||
}
|
||||
}
|
||||
|
||||
return other;
|
||||
};
|
||||
|
||||
export default function index(contacts, associations, apps, currentGroup, groups) {
|
||||
export default function index(contacts, associations, apps, currentGroup, groups, hide) {
|
||||
const indexes = makeIndexes();
|
||||
indexes.set('ships', shipIndex(contacts));
|
||||
// all metadata from all apps is indexed
|
||||
// into subscriptions and landscape
|
||||
@ -141,7 +150,7 @@ export default function index(contacts, associations, apps, currentGroup, groups
|
||||
indexes.set('subscriptions', subscriptions);
|
||||
indexes.set('groups', landscape);
|
||||
indexes.set('apps', appIndex(apps));
|
||||
indexes.set('other', otherIndex());
|
||||
indexes.set('other', otherIndex(hide));
|
||||
|
||||
return indexes;
|
||||
};
|
||||
|
6
pkg/interface/src/logic/lib/platform.ts
Normal file
6
pkg/interface/src/logic/lib/platform.ts
Normal file
@ -0,0 +1,6 @@
|
||||
|
||||
const ua = window.navigator.userAgent;
|
||||
|
||||
export const IS_IOS = ua.includes('iPhone');
|
||||
|
||||
console.log(IS_IOS);
|
@ -1,6 +1,6 @@
|
||||
import urbitOb from 'urbit-ob';
|
||||
|
||||
const URL_REGEX = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+)/.source));
|
||||
const URL_REGEX = new RegExp(String(/^((\w+:\/\/)[-a-zA-Z0-9:@;?&=\/%\+\.\*!'\(\),\$_\{\}\^~\[\]`#|]+\w)/.source));
|
||||
|
||||
const isUrl = (string) => {
|
||||
try {
|
||||
@ -52,16 +52,13 @@ const tokenizeMessage = (text) => {
|
||||
}
|
||||
messages.push({ url: str });
|
||||
message = [];
|
||||
} else if (urbitOb.isValidPatp(str.replace(/[^a-z\-\~]/g, '')) && !isInCodeBlock) {
|
||||
} else if(urbitOb.isValidPatp(str) && !isInCodeBlock) {
|
||||
if (message.length > 0) {
|
||||
// If we're in the middle of a message, add it to the stack and reset
|
||||
messages.push({ text: message.join(' ') });
|
||||
message = [];
|
||||
}
|
||||
messages.push({ mention: str.replace(/[^a-z\-\~]/g, '') });
|
||||
if (str.replace(/[a-z\-\~]/g, '').length > 0) {
|
||||
messages.push({ text: str.replace(/[a-z\-\~]/g, '') });
|
||||
}
|
||||
messages.push({ mention: str });
|
||||
message = [];
|
||||
|
||||
} else {
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import _ from 'lodash';
|
||||
import f, { memoize } from 'lodash/fp';
|
||||
import bigInt, { BigInteger } from 'big-integer';
|
||||
import { Contact } from '@urbit/api';
|
||||
import useLocalState from '../state/local';
|
||||
import _ from "lodash";
|
||||
import f, { memoize } from "lodash/fp";
|
||||
import bigInt, { BigInteger } from "big-integer";
|
||||
import { Contact } from '~/types';
|
||||
import useSettingsState from '../state/settings';
|
||||
|
||||
export const MOBILE_BROWSER_REGEX = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i;
|
||||
|
||||
@ -376,8 +376,8 @@ export function pluralize(text: string, isPlural = false, vowel = false) {
|
||||
|
||||
// Hide is an optional second parameter for when this function is used in class components
|
||||
export function useShowNickname(contact: Contact | null, hide?: boolean): boolean {
|
||||
const hideNicknames = typeof hide !== 'undefined' ? hide : useLocalState(state => state.hideNicknames);
|
||||
return Boolean(contact && contact.nickname && !hideNicknames);
|
||||
const hideNicknames = typeof hide !== 'undefined' ? hide : useSettingsState(state => state.calm.hideNicknames);
|
||||
return !!(contact && contact.nickname && !hideNicknames);
|
||||
}
|
||||
|
||||
interface useHoveringInterface {
|
||||
|
47
pkg/interface/src/logic/lib/virtualContext.tsx
Normal file
47
pkg/interface/src/logic/lib/virtualContext.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import React, {
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
|
||||
export interface VirtualContextProps {
|
||||
save: () => void;
|
||||
restore: () => void;
|
||||
}
|
||||
const fallback: VirtualContextProps = {
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
};
|
||||
|
||||
export const VirtualContext = React.createContext(fallback);
|
||||
|
||||
export function useVirtual() {
|
||||
return useContext(VirtualContext);
|
||||
}
|
||||
|
||||
export const withVirtual = <P extends {}>(Component: React.ComponentType<P>) =>
|
||||
React.forwardRef((props: P, ref) => (
|
||||
<VirtualContext.Consumer>
|
||||
{(context) => <Component ref={ref} {...props} {...context} />}
|
||||
</VirtualContext.Consumer>
|
||||
));
|
||||
|
||||
export function useVirtualResizeState(s: boolean) {
|
||||
const [state, _setState] = useState(s);
|
||||
const { save, restore } = useVirtual();
|
||||
|
||||
const setState = useCallback(
|
||||
(sta: boolean) => {
|
||||
save();
|
||||
_setState(sta);
|
||||
},
|
||||
[_setState, save]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
restore();
|
||||
}, [state]);
|
||||
|
||||
return [state, setState] as const;
|
||||
}
|
@ -61,6 +61,7 @@ export default class GroupReducer<S extends GroupState> {
|
||||
reduce(json: Cage, state: S) {
|
||||
const data = json.groupUpdate;
|
||||
if (data) {
|
||||
console.log(data);
|
||||
this.initial(data, state);
|
||||
this.addMembers(data, state);
|
||||
this.addTag(data, state);
|
||||
@ -116,6 +117,12 @@ export default class GroupReducer<S extends GroupState> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,77 +1,83 @@
|
||||
import _ from 'lodash';
|
||||
import { StoreState } from '../../store/type';
|
||||
import {
|
||||
SettingsUpdate
|
||||
} from '@urbit/api/settings';
|
||||
import { SettingsUpdate } from '~/types/settings';
|
||||
import useSettingsState, { SettingsStateZus } from "~/logic/state/settings";
|
||||
import produce from 'immer';
|
||||
|
||||
type SettingsState = Pick<StoreState, 'settings'>;
|
||||
|
||||
export default class SettingsReducer<S extends SettingsState> {
|
||||
reduce(json: Cage, state: S) {
|
||||
let data = json['settings-event'];
|
||||
if (data) {
|
||||
this.putBucket(data, state);
|
||||
this.delBucket(data, state);
|
||||
this.putEntry(data, state);
|
||||
this.delEntry(data, state);
|
||||
}
|
||||
data = json['settings-data'];
|
||||
if (data) {
|
||||
this.getAll(data, state);
|
||||
this.getBucket(data, state);
|
||||
this.getEntry(data, state);
|
||||
}
|
||||
export default class SettingsStateZusettingsReducer{
|
||||
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);
|
||||
}
|
||||
|
||||
putBucket(json: SettingsUpdate, state: S) {
|
||||
putBucket(json: SettingsUpdate, state: SettingsStateZus) {
|
||||
const data = _.get(json, 'put-bucket', false);
|
||||
if (data) {
|
||||
state.settings[data['bucket-key']] = data.bucket;
|
||||
state[data["bucket-key"]] = data.bucket;
|
||||
}
|
||||
}
|
||||
|
||||
delBucket(json: SettingsUpdate, state: S) {
|
||||
delBucket(json: SettingsUpdate, state: SettingsStateZus) {
|
||||
const data = _.get(json, 'del-bucket', false);
|
||||
if (data) {
|
||||
delete state.settings[data['bucket-key']];
|
||||
delete settings[data['bucket-key']];
|
||||
}
|
||||
}
|
||||
|
||||
putEntry(json: SettingsUpdate, state: S) {
|
||||
putEntry(json: SettingsUpdate, state: SettingsStateZus) {
|
||||
const data = _.get(json, 'put-entry', false);
|
||||
if (data) {
|
||||
if (!state.settings[data['bucket-key']]) {
|
||||
state.settings[data['bucket-key']] = {};
|
||||
if (!state[data["bucket-key"]]) {
|
||||
state[data["bucket-key"]] = {};
|
||||
}
|
||||
state.settings[data['bucket-key']][data['entry-key']] = data.value;
|
||||
state[data["bucket-key"]][data["entry-key"]] = data.value;
|
||||
}
|
||||
}
|
||||
|
||||
delEntry(json: SettingsUpdate, state: S) {
|
||||
delEntry(json: SettingsUpdate, state: SettingsStateZus) {
|
||||
const data = _.get(json, 'del-entry', false);
|
||||
if (data) {
|
||||
delete state.settings[data['bucket-key']][data['entry-key']];
|
||||
delete state[data["bucket-key"]][data["entry-key"]];
|
||||
}
|
||||
}
|
||||
|
||||
getAll(json: any, state: S) {
|
||||
state.settings = json;
|
||||
getAll(json: any, state: SettingsStateZus) {
|
||||
const data = _.get(json, 'all');
|
||||
if(data) {
|
||||
_.merge(state, data);
|
||||
}
|
||||
}
|
||||
|
||||
getBucket(json: any, state: S) {
|
||||
getBucket(json: any, state: SettingsStateZus) {
|
||||
const key = _.get(json, 'bucket-key', false);
|
||||
const bucket = _.get(json, 'bucket', false);
|
||||
if (key && bucket) {
|
||||
state.settings[key] = bucket;
|
||||
state[key] = bucket;
|
||||
}
|
||||
}
|
||||
|
||||
getEntry(json: any, state: S) {
|
||||
getEntry(json: any, state: SettingsStateZus) {
|
||||
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.settings[bucketKey][entryKey] = entry;
|
||||
state[bucketKey][entryKey] = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,17 +3,21 @@ import f from 'lodash/fp';
|
||||
import create, { State } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import produce from 'immer';
|
||||
import { BackgroundConfig, RemoteContentPolicy, TutorialProgress, tutorialProgress } from '~/types/local-update';
|
||||
import { BackgroundConfig, RemoteContentPolicy, TutorialProgress, tutorialProgress, LeapCategories } from "~/types/local-update";
|
||||
|
||||
export interface LocalState extends State {
|
||||
|
||||
export interface LocalState {
|
||||
hideAvatars: boolean;
|
||||
hideNicknames: boolean;
|
||||
remoteContentPolicy: RemoteContentPolicy;
|
||||
tutorialProgress: TutorialProgress;
|
||||
hideGroups: boolean;
|
||||
hideUtilities: boolean;
|
||||
tutorialRef: HTMLElement | null,
|
||||
hideTutorial: () => void;
|
||||
nextTutStep: () => void;
|
||||
prevTutStep: () => void;
|
||||
hideLeapCats: LeapCategories[];
|
||||
setTutorialRef: (el: HTMLElement | null) => void;
|
||||
dark: boolean;
|
||||
background: BackgroundConfig;
|
||||
@ -21,15 +25,21 @@ export interface LocalState extends State {
|
||||
suspendedFocus?: HTMLElement;
|
||||
toggleOmnibox: () => void;
|
||||
set: (fn: (state: LocalState) => void) => void
|
||||
}
|
||||
};
|
||||
|
||||
type LocalStateZus = LocalState & State;
|
||||
|
||||
export const selectLocalState =
|
||||
<K extends keyof LocalState>(keys: K[]) => f.pick<LocalState, K>(keys);
|
||||
|
||||
const useLocalState = create<LocalState>(persist((set, get) => ({
|
||||
const useLocalState = create<LocalStateZus>(persist((set, get) => ({
|
||||
dark: false,
|
||||
background: undefined,
|
||||
hideAvatars: false,
|
||||
hideNicknames: false,
|
||||
hideLeapCats: [],
|
||||
hideGroups: false,
|
||||
hideUtilities: false,
|
||||
tutorialProgress: 'hidden',
|
||||
tutorialRef: null,
|
||||
setTutorialRef: (el: HTMLElement | null) => set(produce((state) => {
|
||||
|
70
pkg/interface/src/logic/state/settings.tsx
Normal file
70
pkg/interface/src/logic/state/settings.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
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";
|
||||
|
||||
|
||||
export interface SettingsState {
|
||||
display: {
|
||||
backgroundType: 'none' | 'url' | 'color';
|
||||
background?: string;
|
||||
dark: boolean;
|
||||
};
|
||||
calm: {
|
||||
hideNicknames: boolean;
|
||||
hideAvatars: boolean;
|
||||
hideUnreads: boolean;
|
||||
hideGroups: boolean;
|
||||
hideUtilities: boolean;
|
||||
};
|
||||
remoteContentPolicy: RemoteContentPolicy;
|
||||
leap: {
|
||||
categories: LeapCategories[];
|
||||
}
|
||||
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);
|
||||
|
||||
export const selectCalmState = (s: SettingsState) => s.calm;
|
||||
|
||||
const useSettingsState = create<SettingsStateZus>((set) => ({
|
||||
display: {
|
||||
backgroundType: 'none',
|
||||
background: undefined,
|
||||
dark: false,
|
||||
},
|
||||
calm: {
|
||||
hideNicknames: false,
|
||||
hideAvatars: false,
|
||||
hideUnreads: false,
|
||||
hideGroups: false,
|
||||
hideUtilities: false
|
||||
},
|
||||
remoteContentPolicy: {
|
||||
imageShown: true,
|
||||
oembedShown: true,
|
||||
audioShown: true,
|
||||
videoShown: true
|
||||
},
|
||||
leap: {
|
||||
categories: leapCategories,
|
||||
},
|
||||
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 };
|
@ -114,7 +114,7 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
GraphReducer(data, this.state);
|
||||
HarkReducer(data, this.state);
|
||||
ContactReducer(data, this.state);
|
||||
this.settingsReducer.reduce(data, this.state);
|
||||
this.settingsReducer.reduce(data);
|
||||
GroupViewReducer(data, this.state);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,9 @@
|
||||
export const tutorialProgress = ['hidden', 'start', 'group-desc', 'channels', 'chat', 'link', 'publish', 'profile', 'leap', 'notifications', 'done', 'exit'] as const;
|
||||
|
||||
export const leapCategories = ["mychannel", "messages", "updates", "profile", "logout"] as const;
|
||||
|
||||
export type LeapCategories = typeof leapCategories[number];
|
||||
|
||||
export type TutorialProgress = typeof tutorialProgress[number];
|
||||
interface LocalUpdateSetDark {
|
||||
setDark: boolean;
|
||||
|
@ -28,19 +28,20 @@ import GlobalApi from '~/logic/api/global';
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { foregroundFromBackground } from '~/logic/lib/sigil';
|
||||
import { withLocalState } from '~/logic/state/local';
|
||||
import { withSettingsState } from '~/logic/state/settings';
|
||||
|
||||
|
||||
const Root = styled.div`
|
||||
const Root = withSettingsState(styled.div`
|
||||
font-family: ${p => p.theme.fonts.sans};
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
${p => p.background?.type === 'url' ? `
|
||||
background-image: url('${p.background?.url}');
|
||||
${p => p.display.backgroundType === 'url' ? `
|
||||
background-image: url('${p.display.background}');
|
||||
background-size: cover;
|
||||
` : p.background?.type === 'color' ? `
|
||||
background-color: ${p.background.color};
|
||||
` : p.display.backgroundType === 'color' ? `
|
||||
background-color: ${p.display.background};
|
||||
` : `background-color: ${p.theme.colors.white};`
|
||||
}
|
||||
display: flex;
|
||||
@ -64,7 +65,7 @@ const Root = styled.div`
|
||||
border-radius: 1rem;
|
||||
border: 0px solid transparent;
|
||||
}
|
||||
`;
|
||||
`, ['display']);
|
||||
|
||||
const StatusBarWithRouter = withRouter(StatusBar);
|
||||
|
||||
@ -148,7 +149,7 @@ class App extends React.Component {
|
||||
? <link rel="icon" type="image/svg+xml" href={this.faviconString()} />
|
||||
: null}
|
||||
</Helmet>
|
||||
<Root background={background}>
|
||||
<Root>
|
||||
<Router>
|
||||
<TutorialModal api={this.api} />
|
||||
<ErrorBoundary>
|
||||
|
@ -38,7 +38,7 @@ export function ChatResource(props: ChatResourceProps) {
|
||||
const canWrite = isWriter(group, station);
|
||||
|
||||
useEffect(() => {
|
||||
const count = Math.min(50, unreadCount + 15);
|
||||
const count = 100 + unreadCount;
|
||||
props.api.graph.getNewest(owner, name, count);
|
||||
}, [station]);
|
||||
|
||||
@ -149,6 +149,7 @@ export function ChatResource(props: ChatResourceProps) {
|
||||
/>
|
||||
{dragging && <SubmitDragger />}
|
||||
<ChatWindow
|
||||
key={station}
|
||||
history={props.history}
|
||||
graph={graph}
|
||||
unreadCount={unreadCount}
|
||||
|
@ -4,10 +4,12 @@ import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
Component,
|
||||
PureComponent
|
||||
PureComponent,
|
||||
useCallback
|
||||
} from '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 { Sigil } from '~/logic/lib/sigil';
|
||||
import OverlaySigil from '~/views/components/OverlaySigil';
|
||||
@ -16,6 +18,7 @@ import {
|
||||
cite,
|
||||
writeText,
|
||||
useShowNickname,
|
||||
useHideAvatar,
|
||||
useHovering
|
||||
} from '~/logic/lib/util';
|
||||
import {
|
||||
@ -32,6 +35,7 @@ import RemoteContent from '~/views/components/RemoteContent';
|
||||
import { Mention } from '~/views/components/MentionText';
|
||||
import styled from 'styled-components';
|
||||
import useLocalState from '~/logic/state/local';
|
||||
import useSettingsState, {selectCalmState} from "~/logic/state/settings";
|
||||
import Timestamp from '~/views/components/Timestamp';
|
||||
|
||||
export const DATESTAMP_FORMAT = '[~]YYYY.M.D';
|
||||
@ -58,7 +62,20 @@ export const DayBreak = ({ when, shimTop = false }: DayBreakProps) => (
|
||||
</Row>
|
||||
);
|
||||
|
||||
export const UnreadMarker = React.forwardRef(({ dayBreak, when }, ref) => (
|
||||
export const UnreadMarker = React.forwardRef(({ dayBreak, when, api, association }, ref) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const dismiss = useCallback(() => {
|
||||
api.hark.markCountAsRead(association, '/', 'message');
|
||||
}, [api, association]);
|
||||
|
||||
useEffect(() => {
|
||||
if(visible) {
|
||||
console.log('dismissing');
|
||||
dismiss();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<Row
|
||||
position='absolute'
|
||||
ref={ref}
|
||||
@ -70,15 +87,16 @@ export const UnreadMarker = React.forwardRef(({ dayBreak, when }, ref) => (
|
||||
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>
|
||||
));
|
||||
)});
|
||||
|
||||
interface ChatMessageProps {
|
||||
measure(element): void;
|
||||
msg: Post;
|
||||
previousMsg?: Post;
|
||||
nextMsg?: Post;
|
||||
@ -96,9 +114,10 @@ interface ChatMessageProps {
|
||||
api: GlobalApi;
|
||||
highlighted?: boolean;
|
||||
renderSigil?: boolean;
|
||||
innerRef: (el: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
class ChatMessage extends Component<ChatMessageProps> {
|
||||
private divRef: React.RefObject<HTMLDivElement>;
|
||||
|
||||
constructor(props) {
|
||||
@ -107,9 +126,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.divRef.current) {
|
||||
this.props.measure(this.divRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -124,7 +140,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
className = '',
|
||||
isPending,
|
||||
style,
|
||||
measure,
|
||||
scrollWindow,
|
||||
isLastMessage,
|
||||
unreadMarkerRef,
|
||||
@ -157,9 +172,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
.unix(msg['time-sent'] / 1000)
|
||||
.format(renderSigil ? 'h:mm A' : 'h:mm');
|
||||
|
||||
const reboundMeasure = (event) => {
|
||||
return measure(this.divRef.current);
|
||||
};
|
||||
|
||||
const messageProps = {
|
||||
msg,
|
||||
@ -167,7 +179,6 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
contacts,
|
||||
association,
|
||||
group,
|
||||
measure: reboundMeasure.bind(this),
|
||||
style,
|
||||
containerClass,
|
||||
isPending,
|
||||
@ -177,7 +188,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
highlighted,
|
||||
fontSize,
|
||||
associations,
|
||||
groups
|
||||
groups,
|
||||
};
|
||||
|
||||
const unreadContainerStyle = {
|
||||
@ -186,10 +197,11 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={this.divRef}
|
||||
ref={this.props.innerRef}
|
||||
pt={renderSigil ? 2 : 0}
|
||||
pb={isLastMessage ? 4 : 2}
|
||||
className={containerClass}
|
||||
backgroundColor={highlighted ? 'blue' : 'white'}
|
||||
style={style}
|
||||
>
|
||||
{dayBreak && !isLastRead ? (
|
||||
@ -206,6 +218,8 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
<Box style={unreadContainerStyle}>
|
||||
{isLastRead ? (
|
||||
<UnreadMarker
|
||||
association={association}
|
||||
api={api}
|
||||
dayBreak={dayBreak}
|
||||
when={msg['time-sent']}
|
||||
ref={unreadMarkerRef}
|
||||
@ -217,11 +231,12 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
}
|
||||
}
|
||||
|
||||
export default React.forwardRef((props, ref) => <ChatMessage {...props} innerRef={ref} />);
|
||||
|
||||
export const MessageAuthor = ({
|
||||
timestamp,
|
||||
contacts,
|
||||
msg,
|
||||
measure,
|
||||
group,
|
||||
api,
|
||||
associations,
|
||||
@ -238,7 +253,7 @@ export const MessageAuthor = ({
|
||||
const contact =
|
||||
`~${msg.author}` in contacts ? contacts[`~${msg.author}`] : false;
|
||||
const showNickname = useShowNickname(contact);
|
||||
const { hideAvatars } = useLocalState(({ hideAvatars }) => ({ hideAvatars }));
|
||||
const { hideAvatars } = useSettingsState(selectCalmState);
|
||||
const shipName = showNickname ? contact.nickname : cite(msg.author);
|
||||
const copyNotice = 'Copied';
|
||||
const color = contact
|
||||
@ -366,7 +381,6 @@ export const Message = ({
|
||||
timestamp,
|
||||
contacts,
|
||||
msg,
|
||||
measure,
|
||||
group,
|
||||
api,
|
||||
associations,
|
||||
@ -400,7 +414,6 @@ export const Message = ({
|
||||
<TextContent
|
||||
associations={associations}
|
||||
groups={groups}
|
||||
measure={measure}
|
||||
api={api}
|
||||
fontSize={1}
|
||||
lineHeight={'20px'}
|
||||
@ -418,8 +431,8 @@ export const Message = ({
|
||||
color='black'
|
||||
>
|
||||
<RemoteContent
|
||||
key={content.url}
|
||||
url={content.url}
|
||||
onLoad={measure}
|
||||
imageProps={{
|
||||
style: {
|
||||
maxWidth: 'min(100%,18rem)',
|
||||
|
@ -50,6 +50,8 @@ interface ChatWindowState {
|
||||
unreadIndex: BigInteger;
|
||||
}
|
||||
|
||||
const virtScrollerStyle = { height: '100%' };
|
||||
|
||||
export default class ChatWindow extends Component<
|
||||
ChatWindowProps,
|
||||
ChatWindowState
|
||||
@ -59,6 +61,7 @@ export default class ChatWindow extends Component<
|
||||
private prevSize = 0;
|
||||
private loadedNewest = false;
|
||||
private loadedOldest = false;
|
||||
private fetchPending = false;
|
||||
|
||||
INITIALIZATION_MAX_TIME = 100;
|
||||
|
||||
@ -77,7 +80,6 @@ export default class ChatWindow extends Component<
|
||||
this.handleWindowBlur = this.handleWindowBlur.bind(this);
|
||||
this.handleWindowFocus = this.handleWindowFocus.bind(this);
|
||||
this.stayLockedIfActive = this.stayLockedIfActive.bind(this);
|
||||
this.dismissIfLineVisible = this.dismissIfLineVisible.bind(this);
|
||||
|
||||
this.virtualList = null;
|
||||
this.unreadMarkerRef = React.createRef();
|
||||
@ -86,15 +88,14 @@ export default class ChatWindow extends Component<
|
||||
|
||||
componentDidMount() {
|
||||
this.calculateUnreadIndex();
|
||||
this.virtualList?.calculateVisibleItems();
|
||||
window.addEventListener('blur', this.handleWindowBlur);
|
||||
window.addEventListener('focus', this.handleWindowFocus);
|
||||
setTimeout(() => {
|
||||
if (this.props.scrollTo) {
|
||||
this.scrollToUnread();
|
||||
}
|
||||
|
||||
this.setState({ initialized: true });
|
||||
|
||||
}, this.INITIALIZATION_MAX_TIME);
|
||||
}
|
||||
|
||||
@ -112,6 +113,7 @@ export default class ChatWindow extends Component<
|
||||
});
|
||||
return;
|
||||
}
|
||||
console.log(`found unread: ${unreadIndex}`);
|
||||
this.setState({
|
||||
unreadIndex
|
||||
});
|
||||
@ -131,24 +133,14 @@ export default class ChatWindow extends Component<
|
||||
componentDidUpdate(prevProps: ChatWindowProps, prevState) {
|
||||
const { history, graph, unreadCount, station } = this.props;
|
||||
|
||||
if (graph.size !== prevProps.graph.size && this.state.fetchPending) {
|
||||
this.setState({ fetchPending: false });
|
||||
if (graph.size !== prevProps.graph.size && this.fetchPending) {
|
||||
this.fetchPending = false;
|
||||
}
|
||||
|
||||
if (unreadCount > prevProps.unreadCount && this.state.idle) {
|
||||
if (unreadCount > prevProps.unreadCount) {
|
||||
this.calculateUnreadIndex();
|
||||
}
|
||||
|
||||
if (this.prevSize !== graph.size) {
|
||||
if (this.state.unreadIndex.eq(bigInt.zero)) {
|
||||
this.calculateUnreadIndex();
|
||||
this.scrollToUnread();
|
||||
}
|
||||
this.prevSize = graph.size;
|
||||
this.virtualList?.calculateVisibleItems();
|
||||
this.stayLockedIfActive();
|
||||
}
|
||||
|
||||
if (station !== prevProps.station) {
|
||||
this.virtualList?.resetScroll();
|
||||
this.calculateUnreadIndex();
|
||||
@ -168,7 +160,7 @@ export default class ChatWindow extends Component<
|
||||
return;
|
||||
}
|
||||
|
||||
this.virtualList?.scrollToData(unreadIndex);
|
||||
this.virtualList?.scrollToIndex(this.state.unreadIndex);
|
||||
}
|
||||
|
||||
dismissUnread() {
|
||||
@ -176,65 +168,112 @@ export default class ChatWindow extends Component<
|
||||
if (this.state.fetchPending) return;
|
||||
if (this.props.unreadCount === 0) return;
|
||||
this.props.api.hark.markCountAsRead(association, '/', 'message');
|
||||
this.props.api.hark.markCountAsRead(association, '/', 'mention');
|
||||
}
|
||||
|
||||
async fetchMessages(newer: boolean, force = false): Promise<void> {
|
||||
const { api, station, graph } = this.props;
|
||||
|
||||
if (this.state.fetchPending && !force) {
|
||||
return new Promise((resolve, reject) => {});
|
||||
setActive = () => {
|
||||
if(this.state.idle) {
|
||||
this.setState({ idle: false });
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({ fetchPending: true });
|
||||
fetchMessages = async (newer: boolean): Promise<boolean> => {
|
||||
const { api, station, graph } = this.props;
|
||||
if(this.fetchPending) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
this.fetchPending = true;
|
||||
|
||||
const [, , ship, name] = station.split('/');
|
||||
const currSize = graph.size;
|
||||
if (newer && !this.loadedNewest) {
|
||||
if (newer) {
|
||||
const [index] = graph.peekLargest()!;
|
||||
await api.graph.getYoungerSiblings(
|
||||
ship,
|
||||
name,
|
||||
20,
|
||||
100,
|
||||
`/${index.toString()}`
|
||||
);
|
||||
if (currSize === graph.size) {
|
||||
console.log('loaded all newest');
|
||||
this.loadedNewest = true;
|
||||
}
|
||||
} else if (!newer && !this.loadedOldest) {
|
||||
} else {
|
||||
const [index] = graph.peekSmallest()!;
|
||||
await api.graph.getOlderSiblings(ship, name, 20, `/${index.toString()}`);
|
||||
await api.graph.getOlderSiblings(ship, name, 100, `/${index.toString()}`);
|
||||
this.calculateUnreadIndex();
|
||||
if (currSize === graph.size) {
|
||||
console.log('loaded all oldest');
|
||||
this.loadedOldest = true;
|
||||
}
|
||||
}
|
||||
this.setState({ fetchPending: false });
|
||||
this.fetchPending = false;
|
||||
console.log(currSize, graph.size);
|
||||
return currSize === graph.size;
|
||||
}
|
||||
|
||||
onScroll({ scrollTop, scrollHeight, windowHeight }) {
|
||||
onScroll = ({ scrollTop, scrollHeight, windowHeight }) => {
|
||||
if (!this.state.idle && scrollTop > IDLE_THRESHOLD) {
|
||||
this.setState({ idle: true });
|
||||
}
|
||||
|
||||
this.dismissIfLineVisible();
|
||||
}
|
||||
|
||||
dismissIfLineVisible() {
|
||||
if (this.props.unreadCount === 0) return;
|
||||
if (!this.unreadMarkerRef.current || !this.virtualList?.window) return;
|
||||
const parent = this.unreadMarkerRef.current.parentElement?.parentElement;
|
||||
if (!parent) return;
|
||||
const { scrollTop, scrollHeight, offsetHeight } = this.virtualList.window;
|
||||
if (
|
||||
scrollHeight - parent.offsetTop > scrollTop &&
|
||||
scrollHeight - parent.offsetTop < scrollTop + offsetHeight
|
||||
) {
|
||||
this.dismissUnread();
|
||||
|
||||
renderer = React.forwardRef(({ index, scrollWindow }, ref) => {
|
||||
const {
|
||||
api,
|
||||
association,
|
||||
group,
|
||||
contacts,
|
||||
graph,
|
||||
history,
|
||||
groups,
|
||||
associations
|
||||
} = this.props;
|
||||
const { unreadMarkerRef } = this;
|
||||
const messageProps = {
|
||||
association,
|
||||
group,
|
||||
contacts,
|
||||
unreadMarkerRef,
|
||||
history,
|
||||
api,
|
||||
groups,
|
||||
associations
|
||||
};
|
||||
const msg = graph.get(index)?.post;
|
||||
if (!msg) return null;
|
||||
if (!this.state.initialized) {
|
||||
return (
|
||||
<MessagePlaceholder
|
||||
key={index.toString()}
|
||||
height='64px'
|
||||
index={index}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
const isPending: boolean = 'pending' in msg && Boolean(msg.pending);
|
||||
const isLastMessage = index.eq(
|
||||
graph.peekLargest()?.[0] ?? bigInt.zero
|
||||
);
|
||||
const highlighted = false; // this.state.unreadIndex.eq(index);
|
||||
const keys = graph.keys().reverse();
|
||||
const graphIdx = keys.findIndex((idx) => idx.eq(index));
|
||||
const prevIdx = keys[graphIdx + 1];
|
||||
const nextIdx = keys[graphIdx - 1];
|
||||
const isLastRead: boolean = this.state.unreadIndex.eq(index);
|
||||
const props = {
|
||||
highlighted,
|
||||
scrollWindow,
|
||||
isPending,
|
||||
isLastRead,
|
||||
isLastMessage,
|
||||
msg,
|
||||
...messageProps
|
||||
};
|
||||
return (
|
||||
<ChatMessage
|
||||
key={index.toString()}
|
||||
ref={ref}
|
||||
previousMsg={prevIdx && graph.get(prevIdx)?.post}
|
||||
nextMsg={nextIdx && graph.get(nextIdx)?.post}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
render() {
|
||||
const {
|
||||
@ -262,7 +301,6 @@ export default class ChatWindow extends Component<
|
||||
groups,
|
||||
associations
|
||||
};
|
||||
const keys = graph.keys().reverse();
|
||||
const unreadIndex = graph.keys()[this.props.unreadCount];
|
||||
const unreadMsg = unreadIndex && graph.get(unreadIndex);
|
||||
|
||||
@ -284,58 +322,17 @@ export default class ChatWindow extends Component<
|
||||
ref={(list) => {
|
||||
this.virtualList = list;
|
||||
}}
|
||||
offset={unreadCount}
|
||||
origin='bottom'
|
||||
style={{ height: '100%' }}
|
||||
onStartReached={() => {
|
||||
this.setState({ idle: false });
|
||||
this.dismissUnread();
|
||||
}}
|
||||
onScroll={this.onScroll.bind(this)}
|
||||
style={virtScrollerStyle}
|
||||
onStartReached={this.setActive}
|
||||
onScroll={this.onScroll}
|
||||
data={graph}
|
||||
size={graph.size}
|
||||
renderer={({ index, measure, scrollWindow }) => {
|
||||
const msg = graph.get(index)?.post;
|
||||
if (!msg) return null;
|
||||
if (!this.state.initialized) {
|
||||
return (
|
||||
<MessagePlaceholder
|
||||
key={index.toString()}
|
||||
height='64px'
|
||||
index={index}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isPending: boolean = 'pending' in msg && Boolean(msg.pending);
|
||||
const isLastMessage = index.eq(
|
||||
graph.peekLargest()?.[0] ?? bigInt.zero
|
||||
);
|
||||
const highlighted = bigInt(this.props.scrollTo || -1).eq(index);
|
||||
const graphIdx = keys.findIndex((idx) => idx.eq(index));
|
||||
const prevIdx = keys[graphIdx + 1];
|
||||
const nextIdx = keys[graphIdx - 1];
|
||||
const isLastRead: boolean = this.state.unreadIndex.eq(index);
|
||||
const props = {
|
||||
measure,
|
||||
highlighted,
|
||||
scrollWindow,
|
||||
isPending,
|
||||
isLastRead,
|
||||
isLastMessage,
|
||||
msg,
|
||||
...messageProps
|
||||
};
|
||||
return (
|
||||
<ChatMessage
|
||||
key={index.toString()}
|
||||
previousMsg={prevIdx && graph.get(prevIdx)?.post}
|
||||
nextMsg={nextIdx && graph.get(nextIdx)?.post}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
loadRows={(newer) => {
|
||||
this.fetchMessages(newer);
|
||||
}}
|
||||
id={association.resource}
|
||||
averageHeight={22}
|
||||
renderer={this.renderer}
|
||||
loadRows={this.fetchMessages}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
|
@ -131,7 +131,6 @@ export default function TextContent(props) {
|
||||
const resource = `/ship/${content.text}`;
|
||||
return (
|
||||
<GroupLink
|
||||
measure={props.measure}
|
||||
resource={resource}
|
||||
api={props.api}
|
||||
associations={props.associations}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import moment from 'moment';
|
||||
import { Box, Text } from '@tlon/indigo-react';
|
||||
import VisibilitySensor from 'react-visibility-sensor';
|
||||
|
||||
import Timestamp from '~/views/components/Timestamp';
|
||||
|
||||
|
@ -29,6 +29,8 @@ import {
|
||||
TUTORIAL_CHAT,
|
||||
TUTORIAL_LINKS
|
||||
} from '~/logic/lib/tutorialModal';
|
||||
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
|
||||
|
||||
|
||||
const ScrollbarLessBox = styled(Box)`
|
||||
scrollbar-width: none !important;
|
||||
@ -38,7 +40,7 @@ const ScrollbarLessBox = styled(Box)`
|
||||
}
|
||||
`;
|
||||
|
||||
const tutSelector = f.pick(['tutorialProgress', 'nextTutStep']);
|
||||
const tutSelector = f.pick(['tutorialProgress', 'nextTutStep', 'hideGroups']);
|
||||
|
||||
export default function LaunchApp(props) {
|
||||
const history = useHistory();
|
||||
@ -81,7 +83,10 @@ 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);
|
||||
|
||||
@ -157,6 +162,7 @@ export default function LaunchApp(props) {
|
||||
p={2}
|
||||
pt={0}
|
||||
>
|
||||
{!hideUtilities && <>
|
||||
<Tile
|
||||
bg="white"
|
||||
color="scales.black20"
|
||||
@ -197,8 +203,10 @@ export default function LaunchApp(props) {
|
||||
>
|
||||
<JoinGroup {...props} />
|
||||
</ModalButton>
|
||||
|
||||
<Groups unreads={props.unreads} groups={props.groups} associations={props.associations} />
|
||||
</>}
|
||||
{!hideGroups &&
|
||||
(<Groups unreads={props.unreads} groups={props.groups} associations={props.associations} />)
|
||||
}
|
||||
</Box>
|
||||
<Box alignSelf="flex-start" display={["block", "none"]}>{hashBox}</Box>
|
||||
</ScrollbarLessBox>
|
||||
|
@ -9,6 +9,7 @@ import { getUnreadCount, getNotificationCount } from '~/logic/lib/hark';
|
||||
import Tile from '../components/tiles/tile';
|
||||
import { useTutorialModal } from '~/views/components/useTutorialModal';
|
||||
import { TUTORIAL_HOST, TUTORIAL_GROUP } from '~/logic/lib/tutorialModal';
|
||||
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
|
||||
|
||||
interface GroupsProps {
|
||||
associations: Associations;
|
||||
@ -80,11 +81,12 @@ function Group(props: GroupProps) {
|
||||
isTutorialGroup,
|
||||
anchorRef.current
|
||||
);
|
||||
const { hideUnreads } = useSettingsState(selectCalmState)
|
||||
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>
|
||||
<Col>
|
||||
{!hideUnreads && (<Col>
|
||||
{updates > 0 &&
|
||||
(<Text mt="1" color="blue">{updates} update{updates !== 1 && 's'} </Text>)
|
||||
}
|
||||
@ -92,7 +94,7 @@ function Group(props: GroupProps) {
|
||||
(<Text color="lightGray">{unreads}</Text>)
|
||||
}
|
||||
</Col>
|
||||
|
||||
)}
|
||||
</Col>
|
||||
</Tile>
|
||||
);
|
||||
|
@ -33,20 +33,13 @@ interface LinkWindowProps {
|
||||
}
|
||||
export function LinkWindow(props: LinkWindowProps) {
|
||||
const { graph, api, association } = props;
|
||||
const virtualList = useRef<VirtualScroller>();
|
||||
const fetchLinks = useCallback(
|
||||
async (newer: boolean) => {
|
||||
return true;
|
||||
/* stubbed, should we generalize the display of graphs in virtualscroller? */
|
||||
}, []
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const list = virtualList?.current;
|
||||
if(!list)
|
||||
return;
|
||||
list.calculateVisibleItems();
|
||||
}, [graph.size]);
|
||||
|
||||
const first = graph.peekLargest()?.[0];
|
||||
const [,,ship, name] = association.resource.split('/');
|
||||
const canWrite = isWriter(props.group, association.resource);
|
||||
@ -74,15 +67,16 @@ return;
|
||||
}
|
||||
|
||||
return (
|
||||
<Col width="100%" height="100%" position="relative">
|
||||
<VirtualScroller
|
||||
ref={l => (virtualList.current = l ?? undefined)}
|
||||
origin="top"
|
||||
style={style}
|
||||
onStartReached={() => {}}
|
||||
onScroll={() => {}}
|
||||
data={graph}
|
||||
averageHeight={100}
|
||||
size={graph.size}
|
||||
renderer={({ index, measure, scrollWindow }) => {
|
||||
renderer={({ index, scrollWindow }) => {
|
||||
const node = graph.get(index);
|
||||
const post = node?.post;
|
||||
if (!node || !post)
|
||||
@ -90,7 +84,6 @@ return null;
|
||||
const linkProps = {
|
||||
...props,
|
||||
node,
|
||||
measure
|
||||
};
|
||||
if(canWrite && index.eq(first ?? bigInt.zero)) {
|
||||
return (
|
||||
@ -106,5 +99,6 @@ return null;
|
||||
}}
|
||||
loadRows={fetchLinks}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
@ -19,7 +19,6 @@ interface LinkItemProps {
|
||||
path: string;
|
||||
contacts: Rolodex;
|
||||
unreads: Unreads;
|
||||
measure: (el: any) => void;
|
||||
}
|
||||
|
||||
export const LinkItem = (props: LinkItemProps): ReactElement => {
|
||||
@ -30,7 +29,6 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
|
||||
group,
|
||||
path,
|
||||
contacts,
|
||||
measure,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
@ -67,6 +65,7 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
|
||||
const size = node.children ? node.children.size : 0;
|
||||
const contents = node.post.contents;
|
||||
const hostname = URLparser.exec(contents[1].url) ? URLparser.exec(contents[1].url)[4] : null;
|
||||
const href = URLparser.exec(contents[1].url) ? contents[1].url : `http://${contents[1].url}`
|
||||
|
||||
const baseUrl = props.baseUrl || `/~404/${resource}`;
|
||||
|
||||
@ -93,14 +92,6 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
|
||||
const commColor = (props.unreads.graph?.[appPath]?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray';
|
||||
const isUnread = props.unreads.graph?.[appPath]?.['/']?.unreads?.has(node.post.index);
|
||||
|
||||
const onMeasure = useCallback(() => {
|
||||
ref.current && measure(ref.current);
|
||||
}, [ref.current, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
onMeasure();
|
||||
}, [onMeasure]);
|
||||
|
||||
return (
|
||||
<Box mx="auto" px={3} maxWidth="768px" ref={ref} width="100%" {...rest}>
|
||||
<Box
|
||||
@ -120,10 +111,9 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
|
||||
<RemoteContent
|
||||
ref={r => { remoteRef.current = r }}
|
||||
renderUrl={false}
|
||||
url={contents[1].url}
|
||||
url={href}
|
||||
text={contents[0].text}
|
||||
unfold={true}
|
||||
onLoad={onMeasure}
|
||||
style={{ alignSelf: 'center' }}
|
||||
oembedProps={{
|
||||
p: 2,
|
||||
@ -145,7 +135,7 @@ export const LinkItem = (props: LinkItemProps): ReactElement => {
|
||||
}}
|
||||
/>
|
||||
<Text color="gray" p={2} flexShrink={0}>
|
||||
<Anchor target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }} href={contents[1].url}>
|
||||
<Anchor target="_blank" rel="noopener noreferrer" style={{ textDecoration: 'none' }} href={href}>
|
||||
<Box display='flex'>
|
||||
<Icon icon='ArrowExternal' mr={1} />{hostname}
|
||||
</Box>
|
||||
|
@ -132,7 +132,6 @@ const GraphNodeContent = ({
|
||||
<ChatMessage
|
||||
renderSigil={false}
|
||||
containerClass='items-top cf hide-child'
|
||||
measure={() => {}}
|
||||
group={group}
|
||||
contacts={contacts}
|
||||
groups={{}}
|
||||
|
@ -8,7 +8,6 @@ import { Box, Col, Text, Row } from '@tlon/indigo-react';
|
||||
import { Body } from '~/views/components/Body';
|
||||
import { PropFunc } from '~/types/util';
|
||||
import Inbox from './inbox';
|
||||
import NotificationPreferences from './preferences';
|
||||
import { Dropdown } from '~/views/components/Dropdown';
|
||||
import { FormikOnBlur } from '~/views/components/FormikOnBlur';
|
||||
import GroupSearch from '~/views/components/GroupSearch';
|
||||
@ -76,18 +75,6 @@ export default function NotificationsScreen(props: any): ReactElement {
|
||||
borderBottomColor="washedGray"
|
||||
>
|
||||
<Text>Updates</Text>
|
||||
<Row>
|
||||
<Box>
|
||||
<HeaderLink ref={anchorRef} current={view} view="">
|
||||
Inbox
|
||||
</HeaderLink>
|
||||
</Box>
|
||||
<Box>
|
||||
<HeaderLink current={view} view="preferences">
|
||||
Preferences
|
||||
</HeaderLink>
|
||||
</Box>
|
||||
</Row>
|
||||
<Row
|
||||
justifyContent="space-between"
|
||||
>
|
||||
@ -137,13 +124,6 @@ export default function NotificationsScreen(props: any): ReactElement {
|
||||
</Dropdown>
|
||||
</Row>
|
||||
</Row>
|
||||
{view === 'preferences' && (
|
||||
<NotificationPreferences
|
||||
graphConfig={props.notificationsGraphConfig}
|
||||
api={props.api}
|
||||
dnd={props.doNotDisturb}
|
||||
/>
|
||||
)}
|
||||
{!view && <Inbox {...props} filter={filter.groups} />}
|
||||
</Col>
|
||||
</Body>
|
||||
|
@ -1,88 +0,0 @@
|
||||
import React, { ReactElement, useCallback } from 'react';
|
||||
import { Form, FormikHelpers } from 'formik';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Col, ManagedCheckboxField as Checkbox } from '@tlon/indigo-react';
|
||||
import { NotificationGraphConfig } from '@urbit/api';
|
||||
|
||||
import { FormikOnBlur } from '~/views/components/FormikOnBlur';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
|
||||
interface FormSchema {
|
||||
mentions: boolean;
|
||||
dnd: boolean;
|
||||
watchOnSelf: boolean;
|
||||
watching: string[];
|
||||
}
|
||||
|
||||
interface NotificationPreferencesProps {
|
||||
graphConfig: NotificationGraphConfig;
|
||||
dnd: boolean;
|
||||
api: GlobalApi;
|
||||
}
|
||||
|
||||
export default function NotificationPreferences(
|
||||
props: NotificationPreferencesProps
|
||||
): ReactElement {
|
||||
const { graphConfig, api, dnd } = props;
|
||||
|
||||
const initialValues: FormSchema = {
|
||||
mentions: graphConfig.mentions,
|
||||
watchOnSelf: graphConfig.watchOnSelf,
|
||||
dnd,
|
||||
watching: graphConfig.watching
|
||||
};
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
|
||||
console.log(values);
|
||||
try {
|
||||
const promises: Promise<any>[] = [];
|
||||
if (values.mentions !== graphConfig.mentions) {
|
||||
promises.push(api.hark.setMentions(values.mentions));
|
||||
}
|
||||
if (values.watchOnSelf !== graphConfig.watchOnSelf) {
|
||||
promises.push(api.hark.setWatchOnSelf(values.watchOnSelf));
|
||||
}
|
||||
if (values.dnd !== dnd && !_.isUndefined(values.dnd)) {
|
||||
promises.push(api.hark.setDoNotDisturb(values.dnd));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
actions.setStatus({ success: null });
|
||||
actions.resetForm({ values: initialValues });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
actions.setStatus({ error: e.message });
|
||||
}
|
||||
},
|
||||
[api, graphConfig]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormikOnBlur
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form>
|
||||
<Col maxWidth="384px" p="3" gapY="4">
|
||||
<Checkbox
|
||||
label="Do not disturb"
|
||||
id="dnd"
|
||||
caption="You won't see the notification badge, but notifications will still appear in your inbox."
|
||||
/>
|
||||
<Checkbox
|
||||
label="Watch for replies"
|
||||
id="watchOnSelf"
|
||||
caption="Automatically follow a post for notifications when it's yours"
|
||||
/>
|
||||
<Checkbox
|
||||
label="Watch for mentions"
|
||||
id="mentions"
|
||||
caption="Notify me if someone mentions my @p in a channel I've joined"
|
||||
/>
|
||||
</Col>
|
||||
</Form>
|
||||
</FormikOnBlur>
|
||||
);
|
||||
}
|
@ -1,10 +1,8 @@
|
||||
import React, { ReactElement, useEffect, useRef, useState } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { Center, Box, Row, BaseImage, Text } from '@tlon/indigo-react';
|
||||
|
||||
import RichText from '~/views/components/RichText';
|
||||
import useLocalState from '~/logic/state/local';
|
||||
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import { ViewProfile } from './ViewProfile';
|
||||
import { EditProfile } from './EditProfile';
|
||||
@ -27,9 +25,7 @@ export function ProfileHeader(props: any): ReactElement {
|
||||
}
|
||||
|
||||
export function ProfileImages(props: any): ReactElement {
|
||||
const { hideAvatars } = useLocalState(({ hideAvatars }) => ({
|
||||
hideAvatars
|
||||
}));
|
||||
const { hideAvatars } = useSettingsState(selectCalmState);
|
||||
const { contact, hideCover } = { ...props };
|
||||
const hexColor = contact?.color ? `#${uxToHex(contact.color)}` : '#000000';
|
||||
|
||||
|
@ -3,6 +3,8 @@ import _ from 'lodash';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { Center, Box, Text, Row, Col } from '@tlon/indigo-react';
|
||||
import RichText from '~/views/components/RichText';
|
||||
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import { GroupLink } from '~/views/components/GroupLink';
|
||||
import { lengthOrder } from '~/logic/lib/util';
|
||||
import useLocalState from '~/logic/state/local';
|
||||
@ -14,10 +16,9 @@ import {
|
||||
ProfileImages
|
||||
} from './Profile';
|
||||
|
||||
export function ViewProfile(props: any): ReactElement {
|
||||
const { hideNicknames } = useLocalState(({ hideNicknames }) => ({
|
||||
hideNicknames
|
||||
}));
|
||||
export function ViewProfile(props: any) {
|
||||
const history = useHistory();
|
||||
const { hideNicknames } = useSettingsState(selectCalmState);
|
||||
const { api, contact, nacked, isPublic, ship, associations, groups } = props;
|
||||
|
||||
return (
|
||||
|
@ -4,9 +4,10 @@ import Helmet from 'react-helmet';
|
||||
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
|
||||
import { Profile } from './components/Profile';
|
||||
import { Profile } from "./components/Profile";
|
||||
|
||||
export default function ProfileScreen(props: any) {
|
||||
const { dark } = props;
|
||||
return (
|
||||
<>
|
||||
<Helmet defer={false}>
|
||||
|
@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
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>
|
||||
);
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Row,
|
||||
Label,
|
||||
Col,
|
||||
@ -26,31 +28,38 @@ export function BackgroundPicker({
|
||||
s3: S3State;
|
||||
}): ReactElement {
|
||||
const rowSpace = { my: 0, alignItems: 'center' };
|
||||
const radioProps = { my: 4, mr: 4, name: 'bgType' };
|
||||
const colProps = { my: 3, mr: 4, gapY: 1 };
|
||||
return (
|
||||
<Col>
|
||||
<Label mb="2">Landscape Background</Label>
|
||||
<Label>Landscape Background</Label>
|
||||
<Row flexWrap="wrap" {...rowSpace}>
|
||||
<Radio {...radioProps} label="Image" id="url" />
|
||||
{bgType === 'url' && (
|
||||
<Col {...colProps}>
|
||||
<Radio mb="1" name="bgType" label="Image" id="url" />
|
||||
<Text ml="5" gray>Set an image background</Text>
|
||||
<ImageInput
|
||||
ml="3"
|
||||
ml="5"
|
||||
api={api}
|
||||
s3={s3}
|
||||
id="bgUrl"
|
||||
placeholder="Drop or upload a file, or paste a link here"
|
||||
name="bgUrl"
|
||||
label="URL"
|
||||
url={bgUrl || ''}
|
||||
url={bgUrl || ""}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<Row {...rowSpace}>
|
||||
<Radio label="Color" id="color" {...radioProps} />
|
||||
{bgType === 'color' && (
|
||||
<ColorInput id="bgColor" label="Color" />
|
||||
)}
|
||||
<Col {...colProps}>
|
||||
<Radio mb="1" label="Color" id="color" name="bgType" />
|
||||
<Text ml="5" gray>Set a hex-based background</Text>
|
||||
<ColorInput placeholder="FFFFFF" ml="5" id="bgColor" />
|
||||
</Col>
|
||||
</Row>
|
||||
<Radio label="None" id="none" {...radioProps} />
|
||||
<Radio
|
||||
my="3"
|
||||
caption="Your home screen will simply render as its respective day/night mode color"
|
||||
name="bgType"
|
||||
label="None"
|
||||
id="none" />
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { ReactElement, useCallback } from 'react';
|
||||
import { Formik } from 'formik';
|
||||
import React, { ReactElement, useCallback, useState } from "react";
|
||||
import { Formik, FormikHelpers } from 'formik';
|
||||
|
||||
import {
|
||||
ManagedTextInputField as Input,
|
||||
@ -10,8 +10,9 @@ import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem
|
||||
} from '@tlon/indigo-react';
|
||||
MenuItem,
|
||||
Row,
|
||||
} from "@tlon/indigo-react";
|
||||
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
|
||||
@ -26,9 +27,12 @@ export function BucketList({
|
||||
}): ReactElement {
|
||||
const _buckets = Array.from(buckets);
|
||||
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(values: { newBucket: string }) => {
|
||||
(values: { newBucket: string }, actions: FormikHelpers<any>) => {
|
||||
api.s3.addBucket(values.newBucket);
|
||||
actions.resetForm({ values: { newBucket: "" } });
|
||||
},
|
||||
[api]
|
||||
);
|
||||
@ -67,7 +71,7 @@ export function BucketList({
|
||||
alignItems="center"
|
||||
borderRadius={1}
|
||||
border={1}
|
||||
borderColor="washedGray"
|
||||
borderColor="lightGray"
|
||||
fontSize={1}
|
||||
pl={2}
|
||||
mb={2}
|
||||
@ -91,10 +95,27 @@ export function BucketList({
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Input mt="2" label="New Bucket" id="newBucket" />
|
||||
<Button mt="2" style={{ cursor: 'pointer' }} borderColor="washedGray" type="submit">
|
||||
Add
|
||||
</Button>
|
||||
{adding && (
|
||||
<Input
|
||||
placeholder="Enter your new bucket"
|
||||
mt="2"
|
||||
label="New Bucket"
|
||||
id="newBucket"
|
||||
/>
|
||||
)}
|
||||
<Row gapX="3" mt="3">
|
||||
<Button type="button" onClick={() => setAdding(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
width="fit-content"
|
||||
primary
|
||||
type={adding ? "submit" : "button"}
|
||||
onClick={() => setAdding((s) => !s)}
|
||||
>
|
||||
{adding ? "Submit" : "Add new bucket"}
|
||||
</Button>
|
||||
</Row>
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
|
@ -0,0 +1,147 @@
|
||||
import React, {useCallback} from "react";
|
||||
import {
|
||||
Box,
|
||||
ManagedToggleSwitchField as Toggle,
|
||||
Button,
|
||||
Col,
|
||||
Text,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Formik, Form, FormikHelpers } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { BackButton } from "./BackButton";
|
||||
import useSettingsState, {selectSettingsState} from "~/logic/state/settings";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import {AsyncButton} from "~/views/components/AsyncButton";
|
||||
|
||||
interface FormSchema {
|
||||
hideAvatars: boolean;
|
||||
hideNicknames: boolean;
|
||||
hideUnreads: boolean;
|
||||
hideGroups: boolean;
|
||||
hideUtilities: boolean;
|
||||
imageShown: boolean;
|
||||
audioShown: boolean;
|
||||
oembedShown: boolean;
|
||||
videoShown: boolean;
|
||||
}
|
||||
|
||||
const settingsSel = selectSettingsState(["calm", "remoteContentPolicy"]);
|
||||
|
||||
export function CalmPrefs(props: {
|
||||
api: GlobalApi;
|
||||
}) {
|
||||
const { api } = props;
|
||||
const {
|
||||
calm: {
|
||||
hideAvatars,
|
||||
hideNicknames,
|
||||
hideUnreads,
|
||||
hideGroups,
|
||||
hideUtilities
|
||||
},
|
||||
remoteContentPolicy: {
|
||||
imageShown,
|
||||
videoShown,
|
||||
oembedShown,
|
||||
audioShown,
|
||||
}
|
||||
} = useSettingsState(settingsSel);
|
||||
|
||||
|
||||
const initialValues: FormSchema = {
|
||||
hideAvatars,
|
||||
hideNicknames,
|
||||
hideUnreads,
|
||||
hideGroups,
|
||||
hideUtilities,
|
||||
imageShown,
|
||||
videoShown,
|
||||
oembedShown,
|
||||
audioShown,
|
||||
};
|
||||
|
||||
const onSubmit = useCallback(async (v: FormSchema, actions: FormikHelpers<FormSchema>) => {
|
||||
await Promise.all([
|
||||
api.settings.putEntry('calm', 'hideAvatars', v.hideAvatars),
|
||||
api.settings.putEntry('calm', 'hideNicknames', v.hideNicknames),
|
||||
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),
|
||||
]);
|
||||
actions.setStatus({ success: null });
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<Formik initialValues={initialValues} onSubmit={onSubmit}>
|
||||
<Form>
|
||||
<Col borderBottom="1" borderBottomColor="washedGray" p="5" pt="4" gapY="5">
|
||||
<BackButton/>
|
||||
<Col gapY="1">
|
||||
<Text color="black" fontSize={2} fontWeight="medium">
|
||||
CalmEngine
|
||||
</Text>
|
||||
<Text gray>
|
||||
Modulate various elements across Landscape to maximize calmness
|
||||
</Text>
|
||||
</Col>
|
||||
<Text fontWeight="medium">Home screen</Text>
|
||||
<Toggle
|
||||
label="Hide unread counts"
|
||||
id="hideUnreads"
|
||||
caption="Do not show unread counts on group tiles"
|
||||
/>
|
||||
<Toggle
|
||||
label="Hide utility tiles"
|
||||
id="hideUtilities"
|
||||
caption="Do not show home screen utilities"
|
||||
/>
|
||||
<Toggle
|
||||
label="Hide group tiles"
|
||||
id="hideGroups"
|
||||
caption="Do not show group tiles"
|
||||
/>
|
||||
<Text fontWeight="medium">User-set identity</Text>
|
||||
<Toggle
|
||||
label="Disable avatars"
|
||||
id="hideAvatars"
|
||||
caption="Do not show user-set avatars"
|
||||
/>
|
||||
<Toggle
|
||||
label="Disable nicknames"
|
||||
id="hideNicknames"
|
||||
caption="Do not show user-set nicknames"
|
||||
/>
|
||||
<Text fontWeight="medium">Remote Content</Text>
|
||||
<Toggle
|
||||
label="Load images"
|
||||
id="imageShown"
|
||||
caption="Images will be replaced with an inline placeholder that must be clicked to be viewed"
|
||||
/>
|
||||
<Toggle
|
||||
label="Load 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"
|
||||
id="videoShown"
|
||||
caption="Video content will be replaced with an inline placeholder that must be clicked to be viewed"
|
||||
/>
|
||||
<Toggle
|
||||
label="Load embedded content"
|
||||
id="oembedShown"
|
||||
caption="Embedded content may contain scripts that can track you"
|
||||
/>
|
||||
|
||||
<AsyncButton primary width="fit-content" type="submit">
|
||||
Save
|
||||
</AsyncButton>
|
||||
</Col>
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
}
|
@ -1,35 +1,32 @@
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
ManagedCheckboxField as Checkbox,
|
||||
Button
|
||||
} from '@tlon/indigo-react';
|
||||
import { Formik, Form } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
Col,
|
||||
Text,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Formik, Form } from "formik";
|
||||
import * as Yup from "yup";
|
||||
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { S3State, BackgroundConfig } from '@urbit/api';
|
||||
import { BackgroundPicker, BgType } from './BackgroundPicker';
|
||||
import useLocalState, { LocalState } from '~/logic/state/local';
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { uxToHex } from "~/logic/lib/util";
|
||||
import { S3State, BackgroundConfig } from "~/types";
|
||||
import { BackgroundPicker, BgType } from "./BackgroundPicker";
|
||||
import useSettingsState, { SettingsState, selectSettingsState } from "~/logic/state/settings";
|
||||
import {AsyncButton} from "~/views/components/AsyncButton";
|
||||
import { BackButton } from "./BackButton";
|
||||
|
||||
const formSchema = Yup.object().shape({
|
||||
bgType: Yup.string()
|
||||
.oneOf(['none', 'color', 'url'], 'invalid')
|
||||
.required('Required'),
|
||||
bgUrl: Yup.string().url(),
|
||||
bgColor: Yup.string(),
|
||||
avatars: Yup.boolean(),
|
||||
nicknames: Yup.boolean()
|
||||
.oneOf(["none", "color", "url"], "invalid")
|
||||
.required("Required"),
|
||||
background: Yup.string(),
|
||||
|
||||
});
|
||||
|
||||
interface FormSchema {
|
||||
bgType: BgType;
|
||||
bgColor: string | undefined;
|
||||
bgUrl: string | undefined;
|
||||
avatars: boolean;
|
||||
nicknames: boolean;
|
||||
}
|
||||
|
||||
interface DisplayFormProps {
|
||||
@ -37,79 +34,79 @@ interface DisplayFormProps {
|
||||
s3: S3State;
|
||||
}
|
||||
|
||||
const settingsSel = selectSettingsState(["display"]);
|
||||
|
||||
export default function DisplayForm(props: DisplayFormProps) {
|
||||
const { api, s3 } = props;
|
||||
|
||||
const { hideAvatars, hideNicknames, background, set: setLocalState } = useLocalState();
|
||||
const {
|
||||
display: {
|
||||
background,
|
||||
backgroundType,
|
||||
}
|
||||
} = useSettingsState(settingsSel);
|
||||
|
||||
|
||||
let bgColor, bgUrl;
|
||||
if (background?.type === 'url') {
|
||||
bgUrl = background.url;
|
||||
if (backgroundType === "url") {
|
||||
bgUrl = background;
|
||||
}
|
||||
if (background?.type === 'color') {
|
||||
bgColor = background.color;
|
||||
if (backgroundType === "color") {
|
||||
bgColor = background;
|
||||
}
|
||||
const bgType = background?.type || 'none';
|
||||
const bgType = backgroundType || "none";
|
||||
|
||||
return (
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
initialValues={
|
||||
{
|
||||
bgType,
|
||||
bgColor: bgColor || '',
|
||||
bgUrl,
|
||||
avatars: hideAvatars,
|
||||
nicknames: hideNicknames
|
||||
bgType: backgroundType,
|
||||
bgColor: bgColor || "",
|
||||
bgUrl
|
||||
} as FormSchema
|
||||
}
|
||||
onSubmit={(values, actions) => {
|
||||
const bgConfig: BackgroundConfig =
|
||||
values.bgType === 'color'
|
||||
? { type: 'color', color: `#${uxToHex(values.bgColor || '0x0')}` }
|
||||
: values.bgType === 'url'
|
||||
? { type: 'url', url: values.bgUrl || '' }
|
||||
: undefined;
|
||||
onSubmit={async (values, actions) => {
|
||||
let promises = [] as Promise<any>[];
|
||||
promises.push(api.settings.putEntry('display', 'backgroundType', values.bgType));
|
||||
|
||||
promises.push(
|
||||
api.settings.putEntry('display', 'background',
|
||||
values.bgType === "color"
|
||||
? `#${uxToHex(values.bgColor || "0x0")}`
|
||||
: values.bgType === "url"
|
||||
? values.bgUrl || ""
|
||||
: false
|
||||
));
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
actions.setStatus({ success: null });
|
||||
|
||||
setLocalState((state: LocalState) => {
|
||||
state.background = bgConfig;
|
||||
state.hideAvatars = values.avatars;
|
||||
state.hideNicknames = values.nicknames;
|
||||
});
|
||||
actions.setSubmitting(false);
|
||||
}}
|
||||
>
|
||||
{props => (
|
||||
{(props) => (
|
||||
<Form>
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateColumns="100%"
|
||||
gridTemplateRows="auto"
|
||||
gridRowGap={5}
|
||||
>
|
||||
<Box color="black" fontSize={1} mb={3} fontWeight={900}>
|
||||
Display Preferences
|
||||
</Box>
|
||||
<Col p="5" pt="4" gapY="5">
|
||||
<BackButton/>
|
||||
<Col gapY="2">
|
||||
<Text color="black" fontSize={2} fontWeight="medium">
|
||||
Display Preferences
|
||||
</Text>
|
||||
<Text gray>
|
||||
Customize visual interfaces across your Landscape
|
||||
</Text>
|
||||
</Col>
|
||||
<BackgroundPicker
|
||||
bgType={props.values.bgType}
|
||||
bgUrl={props.values.bgUrl}
|
||||
api={api}
|
||||
s3={s3}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Disable avatars"
|
||||
id="avatars"
|
||||
caption="Do not show user-set avatars"
|
||||
/>
|
||||
<Checkbox
|
||||
label="Disable nicknames"
|
||||
id="nicknames"
|
||||
caption="Do not show user-set nicknames"
|
||||
/>
|
||||
<Button border={1} style={{ cursor: 'pointer' }} borderColor="washedGray" type="submit">
|
||||
<AsyncButton primary width="fit-content" type="submit">
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</AsyncButton>
|
||||
</Col>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
|
@ -0,0 +1,101 @@
|
||||
import React, { useCallback } from "react";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
Col,
|
||||
Text,
|
||||
ManagedToggleSwitchField as Toggle,
|
||||
ManagedCheckboxField,
|
||||
BaseInput,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Form, FormikHelpers, useField, useFormikContext } from "formik";
|
||||
import { FormikOnBlur } from "~/views/components/FormikOnBlur";
|
||||
import { BackButton } from "./BackButton";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import {
|
||||
NotificationGraphConfig,
|
||||
LeapCategories,
|
||||
leapCategories,
|
||||
} from "~/types";
|
||||
import useSettingsState, { selectSettingsState } from "~/logic/state/settings";
|
||||
import { ShuffleFields } from "~/views/components/ShuffleFields";
|
||||
|
||||
const labels: Record<LeapCategories, string> = {
|
||||
mychannel: "My Channel",
|
||||
updates: "Notifications",
|
||||
profile: "Profile",
|
||||
messages: "Messages",
|
||||
logout: "Log Out",
|
||||
};
|
||||
|
||||
interface FormSchema {
|
||||
categories: { display: boolean; category: LeapCategories }[];
|
||||
}
|
||||
|
||||
function CategoryCheckbox(props: { index: number }) {
|
||||
const { index } = props;
|
||||
const { values } = useFormikContext<FormSchema>();
|
||||
const cats = values.categories;
|
||||
const catNameId = `categories[${index}].category`;
|
||||
const [field] = useField(catNameId);
|
||||
|
||||
const { category } = cats[index];
|
||||
const label = labels[category];
|
||||
|
||||
return (
|
||||
<ManagedCheckboxField id={`categories[${index}].display`} label={label} />
|
||||
);
|
||||
}
|
||||
|
||||
const settingsSel = selectSettingsState(["leap", "set"]);
|
||||
|
||||
export function LeapSettings(props: { api: GlobalApi; }) {
|
||||
const { api } = props;
|
||||
const { leap, set: setSettingsState } = useSettingsState(settingsSel);
|
||||
const categories = leap.categories as LeapCategories[];
|
||||
const missing = _.difference(leapCategories, categories);
|
||||
console.log(categories);
|
||||
|
||||
const initialValues = {
|
||||
categories: [
|
||||
...categories.map((cat) => ({
|
||||
category: cat,
|
||||
display: true,
|
||||
})),
|
||||
...missing.map((cat) => ({ category: cat, display: false })),
|
||||
],
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormSchema) => {
|
||||
const result = values.categories.reduce(
|
||||
(acc, { display, category }) => (display ? [...acc, category] : acc),
|
||||
[] as LeapCategories[]
|
||||
);
|
||||
await api.settings.putEntry('leap', 'categories', result);
|
||||
};
|
||||
|
||||
return (
|
||||
<Col p="5" pt="4" gapY="5">
|
||||
<BackButton/>
|
||||
<Col gapY="1">
|
||||
<Text fontSize="2" fontWeight="medium">
|
||||
Leap
|
||||
</Text>
|
||||
<Text gray>
|
||||
Customize Leap ordering, omit modules or results
|
||||
</Text>
|
||||
</Col>
|
||||
<FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
|
||||
<Form>
|
||||
<Col gapY="4">
|
||||
<Text fontWeight="medium">
|
||||
Customize default Leap sections
|
||||
</Text>
|
||||
<ShuffleFields name="categories">
|
||||
{(index, helpers) => <CategoryCheckbox index={index} />}
|
||||
</ShuffleFields>
|
||||
</Col>
|
||||
</Form>
|
||||
</FormikOnBlur>
|
||||
</Col>
|
||||
);
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
import React, { useCallback } from "react";
|
||||
import {
|
||||
Col,
|
||||
Text,
|
||||
ManagedToggleSwitchField as Toggle,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Form, FormikHelpers } from "formik";
|
||||
import { FormikOnBlur } from "~/views/components/FormikOnBlur";
|
||||
import { BackButton } from "./BackButton";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import {NotificationGraphConfig} from "~/types";
|
||||
|
||||
interface FormSchema {
|
||||
mentions: boolean;
|
||||
dnd: boolean;
|
||||
watchOnSelf: boolean;
|
||||
}
|
||||
|
||||
export function NotificationPreferences(props: {
|
||||
api: GlobalApi;
|
||||
graphConfig: NotificationGraphConfig;
|
||||
dnd: boolean;
|
||||
}) {
|
||||
const { graphConfig, api, dnd } = props;
|
||||
const initialValues = {
|
||||
mentions: graphConfig.mentions,
|
||||
dnd: dnd,
|
||||
watchOnSelf: graphConfig.watchOnSelf,
|
||||
};
|
||||
|
||||
const onSubmit = useCallback(async (values: FormSchema, actions: FormikHelpers<FormSchema>) => {
|
||||
try {
|
||||
let promises: Promise<any>[] = [];
|
||||
if (values.mentions !== graphConfig.mentions) {
|
||||
promises.push(api.hark.setMentions(values.mentions));
|
||||
}
|
||||
if (values.watchOnSelf !== graphConfig.watchOnSelf) {
|
||||
promises.push(api.hark.setWatchOnSelf(values.watchOnSelf));
|
||||
}
|
||||
if (values.dnd !== dnd && !_.isUndefined(values.dnd)) {
|
||||
promises.push(api.hark.setDoNotDisturb(values.dnd))
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
actions.setStatus({ success: null });
|
||||
actions.resetForm({ values: initialValues });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
actions.setStatus({ error: e.message });
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<Col p="5" pt="4" gapY="5">
|
||||
<BackButton/>
|
||||
<Col gapY="1">
|
||||
<Text fontSize="2" fontWeight="medium">
|
||||
Notification Preferences
|
||||
</Text>
|
||||
<Text gray>
|
||||
Set notification visibility and default behaviours for groups and
|
||||
messaging
|
||||
</Text>
|
||||
</Col>
|
||||
<FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
|
||||
<Form>
|
||||
<Col gapY="4">
|
||||
<Toggle
|
||||
label="Do not disturb"
|
||||
id="dnd"
|
||||
caption="You won't see the notification badge, but notifications will still appear in your inbox."
|
||||
/>
|
||||
<Toggle
|
||||
label="Watch for replies"
|
||||
id="watchOnSelf"
|
||||
caption="Automatically follow a post for notifications when it's yours"
|
||||
/>
|
||||
<Toggle
|
||||
label="Watch for mentions"
|
||||
id="mentions"
|
||||
caption="Notify me if someone mentions my @p in a channel I've joined"
|
||||
/>
|
||||
</Col>
|
||||
</Form>
|
||||
</FormikOnBlur>
|
||||
</Col>
|
||||
);
|
||||
}
|
@ -5,13 +5,16 @@ import {
|
||||
ManagedTextInputField as Input,
|
||||
ManagedForm as Form,
|
||||
Box,
|
||||
Text,
|
||||
Button,
|
||||
Col
|
||||
Col,
|
||||
Anchor
|
||||
} from '@tlon/indigo-react';
|
||||
|
||||
import { BucketList } from './BucketList';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { BucketList } from "./BucketList";
|
||||
import { S3State } from '~/types/s3-update';
|
||||
import { BackButton } from './BackButton';
|
||||
|
||||
interface FormSchema {
|
||||
s3bucket: string;
|
||||
@ -47,7 +50,7 @@ export default function S3Form(props: S3FormProps): ReactElement {
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Col>
|
||||
<Col p="5" pt="4" borderBottom="1" borderBottomColor="washedGray">
|
||||
<Formik
|
||||
initialValues={
|
||||
{
|
||||
@ -60,30 +63,50 @@ export default function S3Form(props: S3FormProps): ReactElement {
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form
|
||||
display="grid"
|
||||
gridTemplateColumns="100%"
|
||||
gridAutoRows="auto"
|
||||
gridRowGap={5}
|
||||
>
|
||||
<Box color="black" fontSize={1} fontWeight={900}>
|
||||
S3 Credentials
|
||||
</Box>
|
||||
<Input label="Endpoint" id="s3endpoint" />
|
||||
<Input label="Access Key ID" id="s3accessKeyId" />
|
||||
<Input
|
||||
type="password"
|
||||
label="Secret Access Key"
|
||||
id="s3secretAccessKey"
|
||||
/>
|
||||
<Button style={{ cursor: 'pointer' }} type="submit">Submit</Button>
|
||||
<Form>
|
||||
<Col maxWidth="600px" gapY="5">
|
||||
<BackButton/>
|
||||
<Col gapY="1">
|
||||
<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"
|
||||
style={{ textDecoration: 'none' }}
|
||||
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
|
||||
type="password"
|
||||
label="Secret Access Key"
|
||||
id="s3secretAccessKey"
|
||||
/>
|
||||
<Button style={{ cursor: "pointer" }} type="submit">
|
||||
Submit
|
||||
</Button>
|
||||
</Col>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Col>
|
||||
<Col>
|
||||
<Box color="black" mb={4} fontSize={1} fontWeight={700}>
|
||||
S3 Buckets
|
||||
</Box>
|
||||
<Col maxWidth="600px" p="5" gapY="4">
|
||||
<Col gapY="1">
|
||||
<Text color="black" mb={4} fontSize={2} fontWeight="medium">
|
||||
S3 Buckets
|
||||
</Text>
|
||||
<Text gray>
|
||||
Your 'active' bucket will be the one used when Landscape uploads a
|
||||
file
|
||||
</Text>
|
||||
</Col>
|
||||
<BucketList
|
||||
buckets={s3.configuration.buckets}
|
||||
selected={s3.configuration.currentBucket}
|
||||
|
@ -1,41 +1,60 @@
|
||||
import React from 'react';
|
||||
import { Box, Button } from '@tlon/indigo-react';
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Button,
|
||||
Col,
|
||||
StatelessCheckboxField,
|
||||
} from "@tlon/indigo-react";
|
||||
|
||||
import GlobalApi from '../../../../api/global';
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { BackButton } from "./BackButton";
|
||||
|
||||
interface SecuritySettingsProps {
|
||||
api: GlobalApi;
|
||||
}
|
||||
|
||||
export default function SecuritySettings({ api }: SecuritySettingsProps) {
|
||||
const [allSessions, setAllSessions] = useState(false);
|
||||
return (
|
||||
<Box display="grid" gridTemplateRows="auto" gridTemplateColumns="1fr" gridRowGap={2}>
|
||||
<Box color="black" fontSize={1} mb={4} fontWeight={900}>
|
||||
Security
|
||||
</Box>
|
||||
<Box color="black" fontSize={0} fontWeight={700}>
|
||||
Log out of this session
|
||||
</Box>
|
||||
<Box fontSize={0} mt={2} color="gray">
|
||||
You will be logged out of your Urbit on this browser.
|
||||
<Col gapY="5" p="5" pt="4">
|
||||
<BackButton/>
|
||||
<Col gapY="1">
|
||||
<Text fontSize={2} fontWeight="medium">
|
||||
Security Preferences
|
||||
</Text>
|
||||
<Text gray>
|
||||
Manage sessions, login credentials and Landscape access
|
||||
</Text>
|
||||
</Col>
|
||||
<Col gapY="1">
|
||||
<Text color="black">
|
||||
Log out of this session
|
||||
</Text>
|
||||
<Text mb="3" gray>
|
||||
{allSessions
|
||||
? "You will be logged out of all browsers that have currently logged into your Urbit."
|
||||
: "You will be logged out of your Urbit on this browser."}
|
||||
</Text>
|
||||
<StatelessCheckboxField
|
||||
mb="3"
|
||||
selected={allSessions}
|
||||
onChange={() => setAllSessions((s) => !s)}
|
||||
>
|
||||
<Text>Log out of all sessions</Text>
|
||||
</StatelessCheckboxField>
|
||||
<form method="post" action="/~/logout">
|
||||
<Button mt='4' border={1} style={{ cursor: 'pointer' }}>
|
||||
{allSessions && <input type="hidden" name="all" />}
|
||||
<Button
|
||||
primary
|
||||
destructive
|
||||
border={1}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
<Box color="black" fontSize={0} mt={4} fontWeight={700}>
|
||||
Log out of all sessions
|
||||
</Box>
|
||||
<Box fontSize={0} mt={2} color="gray">
|
||||
You will be logged out of all browsers that have currently logged into your Urbit.
|
||||
<form method="post" action="/~/logout">
|
||||
<input type="hidden" name="all" />
|
||||
<Button destructive mt={4} border={1} style={{ cursor: 'pointer' }}>
|
||||
Logout
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
</Box>
|
||||
</Col>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
@ -1,37 +1,97 @@
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
import { Row, Icon, Box, Col, Text } from "@tlon/indigo-react";
|
||||
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
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 GlobalApi from "~/logic/api/global";
|
||||
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";
|
||||
|
||||
type ProfileProps = StoreState & { api: GlobalApi; ship: string };
|
||||
|
||||
export default function Settings({
|
||||
api,
|
||||
s3
|
||||
}: ProfileProps) {
|
||||
export function SettingsItem(props: {
|
||||
title: string;
|
||||
description: string;
|
||||
to: string;
|
||||
}) {
|
||||
const { to, title, description } = props;
|
||||
return (
|
||||
<Box
|
||||
backgroundColor="white"
|
||||
display="grid"
|
||||
gridTemplateRows="auto"
|
||||
gridTemplateColumns="1fr"
|
||||
gridRowGap={7}
|
||||
p={4}
|
||||
maxWidth="500px"
|
||||
>
|
||||
<DisplayForm
|
||||
api={api}
|
||||
s3={s3}
|
||||
/>
|
||||
<RemoteContentForm api={api} />
|
||||
<S3Form api={api} s3={s3} />
|
||||
<SecuritySettings api={api} />
|
||||
</Box>
|
||||
<Link to={`/~settings/${to}`}>
|
||||
<Row alignItems="center" gapX="3">
|
||||
<Box
|
||||
borderRadius="2"
|
||||
backgroundColor="blue"
|
||||
width="64px"
|
||||
height="64px"
|
||||
/>
|
||||
<Col gapY="2">
|
||||
<Text>{title}</Text>
|
||||
<Text gray>{description}</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Settings(props: {}) {
|
||||
return (
|
||||
<Col gapY="5" p="5">
|
||||
<Col gapY="1">
|
||||
<Text fontSize="2">System Preferences</Text>
|
||||
<Text gray>Configure and customize Landscape</Text>
|
||||
</Col>
|
||||
<Box
|
||||
display="grid"
|
||||
width="100%"
|
||||
height="100%"
|
||||
gridTemplateColumns={["100%", "1fr 1fr"]}
|
||||
gridGap="3"
|
||||
>
|
||||
<SettingsItem
|
||||
to="notifications"
|
||||
title="Notifications"
|
||||
description="Set notification visibility and default behaviours for groups and messaging"
|
||||
/>
|
||||
<SettingsItem
|
||||
to="display"
|
||||
title="Display"
|
||||
description="Customize visual interfaces across your Landscape"
|
||||
/>
|
||||
<SettingsItem
|
||||
to="calm"
|
||||
title="CalmEngine"
|
||||
description="Modulate vearious elements across Landscape to maximize calmness"
|
||||
/>
|
||||
<SettingsItem
|
||||
to="s3"
|
||||
title="Remote Storage"
|
||||
description="Configure S3-compatible storage solutions"
|
||||
/>
|
||||
<SettingsItem
|
||||
to="security"
|
||||
title="Security"
|
||||
description="Manage sessions, login credentials, and Landscape access"
|
||||
/>
|
||||
{/*
|
||||
<SettingsItem
|
||||
to="keyboard"
|
||||
title="Keyboard"
|
||||
description="Shortcuts, Keyboard Settings, Meta Key Assignments, etc."
|
||||
/>
|
||||
<SettingsItem
|
||||
to="hosting"
|
||||
title="Hosting Services"
|
||||
description="Hosting-specific service configuration"
|
||||
/>*/}
|
||||
<SettingsItem
|
||||
to="leap"
|
||||
title="Leap"
|
||||
description="Customize Leap ordering, omit modules or results"
|
||||
/>
|
||||
</Box>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
@ -1,47 +1,139 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import { Route } from 'react-router-dom';
|
||||
import Helmet from 'react-helmet';
|
||||
import React, { ReactNode } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import Helmet from "react-helmet";
|
||||
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
import { Text, Box, Col, Row } from '@tlon/indigo-react';
|
||||
|
||||
import Settings from './components/settings';
|
||||
import useLocalState from '~/logic/state/local';
|
||||
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";
|
||||
|
||||
export const Skeleton = (props: { children: ReactNode }) => (
|
||||
<Box height="100%" width="100%" px={[0, 3]} pb={[0, 3]} borderRadius={1}>
|
||||
<Box
|
||||
height="100%"
|
||||
width="100%"
|
||||
borderRadius={1}
|
||||
bg="white"
|
||||
border={1}
|
||||
borderColor="washedGray"
|
||||
>
|
||||
{props.children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
type ProvSideProps = "to" | "selected";
|
||||
type BaseProps = PropFunc<typeof BaseSidebarItem>;
|
||||
function SidebarItem(props: { hash: string } & Omit<BaseProps, ProvSideProps>) {
|
||||
const { hash, icon, text, ...rest } = props;
|
||||
|
||||
const to = `/~settings#${hash}`;
|
||||
|
||||
const location = useLocation();
|
||||
const selected = location.hash.slice(1) === hash;
|
||||
|
||||
return (
|
||||
<BaseSidebarItem
|
||||
{...rest}
|
||||
icon={icon}
|
||||
text={text}
|
||||
to={to}
|
||||
selected={selected}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsItem(props: { children: ReactNode }) {
|
||||
const { children } = props;
|
||||
|
||||
return (
|
||||
<Box borderBottom="1" borderBottomColor="washedGray">
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsScreen(props: any) {
|
||||
|
||||
const location = useLocation();
|
||||
const hash = location.hash.slice(1)
|
||||
|
||||
export default function SettingsScreen(props: any): ReactElement {
|
||||
return (
|
||||
<>
|
||||
<Helmet defer={false}>
|
||||
<title>Landscape - Settings</title>
|
||||
</Helmet>
|
||||
<Route
|
||||
path={['/~settings']}
|
||||
render={() => {
|
||||
return (
|
||||
<Box height="100%"
|
||||
width="100%"
|
||||
px={[0, 3]}
|
||||
pb={[0, 3]}
|
||||
borderRadius={1}
|
||||
<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"
|
||||
>
|
||||
<Box
|
||||
height="100%"
|
||||
width="100%"
|
||||
display="grid"
|
||||
gridTemplateColumns={['100%', '400px 1fr']}
|
||||
gridTemplateRows={['48px 1fr', '1fr']}
|
||||
borderRadius={1}
|
||||
bg="white"
|
||||
border={1}
|
||||
borderColor="washedGray"
|
||||
overflowY="auto"
|
||||
flexGrow
|
||||
>
|
||||
<Settings {...props} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
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>
|
||||
<Col flexGrow={1} overflowY="auto">
|
||||
<SettingsItem>
|
||||
{hash === "notifications" && (
|
||||
<NotificationPreferences
|
||||
{...props}
|
||||
graphConfig={props.notificationsGraphConfig}
|
||||
/>
|
||||
)}
|
||||
{hash === "display" && (
|
||||
<DisplayForm s3={props.s3} api={props.api} />
|
||||
)}
|
||||
{hash === "s3" && (
|
||||
<S3Form s3={props.s3} api={props.api} />
|
||||
)}
|
||||
{hash === "leap" && (
|
||||
<LeapSettings api={props.api} />
|
||||
)}
|
||||
{hash === "calm" && (
|
||||
<CalmPrefs api={props.api} />
|
||||
)}
|
||||
{hash === "security" && (
|
||||
<SecuritySettings api={props.api} />
|
||||
)}
|
||||
</SettingsItem>
|
||||
</Col>
|
||||
</Row>
|
||||
</Skeleton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { Box } from '@tlon/indigo-react';
|
||||
export function Body(
|
||||
props: { children: ReactNode } & Parameters<typeof Box>[0]
|
||||
) {
|
||||
const { children, ...boxProps } = props;
|
||||
const { children, border, ...boxProps } = props;
|
||||
return (
|
||||
<Box fontSize={0} px={[0, 3]} pb={[0, 3]} height="100%" width="100%">
|
||||
<Box
|
||||
@ -13,11 +13,11 @@ export function Body(
|
||||
height="100%"
|
||||
width="100%"
|
||||
borderRadius={2}
|
||||
border={[0, 1]}
|
||||
border={border ? border : [0, 1]}
|
||||
borderColor={['washedGray', 'washedGray']}
|
||||
{...boxProps}
|
||||
>
|
||||
{props.children}
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
@ -14,12 +14,13 @@ import { hexToUx } from '~/logic/lib/util';
|
||||
|
||||
type ColorInputProps = Parameters<typeof Col>[0] & {
|
||||
id: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ColorInput(props: ColorInputProps): ReactElement {
|
||||
const { id, label, caption, disabled, ...rest } = props;
|
||||
export function ColorInput(props: ColorInputProps) {
|
||||
const { id, placeholder, label, caption, disabled, ...rest } = props;
|
||||
const [{ value, onBlur }, meta, { setValue }] = useField(id);
|
||||
|
||||
const hex = value.replace('#', '').replace('0x', '').replace('.', '');
|
||||
@ -55,6 +56,7 @@ export function ColorInput(props: ColorInputProps): ReactElement {
|
||||
value={hex}
|
||||
disabled={disabled || false}
|
||||
borderRight={0}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<Box
|
||||
borderBottomRightRadius={1}
|
||||
|
@ -9,6 +9,7 @@ import { JoinGroup } from '../landscape/components/JoinGroup';
|
||||
import { useModal } from '~/logic/lib/useModal';
|
||||
import { GroupSummary } from '../landscape/components/GroupSummary';
|
||||
import { PropFunc } from '~/types';
|
||||
import {useVirtual} from '~/logic/lib/virtualContext';
|
||||
|
||||
export function GroupLink(
|
||||
props: {
|
||||
@ -16,16 +17,17 @@ export function GroupLink(
|
||||
resource: string;
|
||||
associations: Associations;
|
||||
groups: Groups;
|
||||
measure: () => void;
|
||||
detailed?: boolean;
|
||||
} & PropFunc<typeof Row>
|
||||
): ReactElement {
|
||||
const { resource, api, associations, groups, measure, ...rest } = props;
|
||||
const { resource, api, associations, groups, ...rest } = props;
|
||||
const name = resource.slice(6);
|
||||
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
|
||||
|
||||
const joined = resource in props.associations.groups;
|
||||
|
||||
const { save, restore } = useVirtual();
|
||||
|
||||
const { modal, showModal } = useModal({
|
||||
modal:
|
||||
joined && preview ? (
|
||||
@ -48,16 +50,19 @@ export function GroupLink(
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setPreview(await api.metadata.preview(resource));
|
||||
const prev = await api.metadata.preview(resource);
|
||||
save();
|
||||
setPreview(prev);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
save();
|
||||
setPreview(null);
|
||||
};
|
||||
}, [resource]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
restore();
|
||||
}, [preview]);
|
||||
|
||||
return (
|
||||
|
@ -41,6 +41,7 @@ const Candidate = ({ title, selected, onClick }): ReactElement => (
|
||||
<CandidateBox
|
||||
onClick={onClick}
|
||||
selected={selected}
|
||||
cursor="pointer"
|
||||
borderColor="washedGray"
|
||||
color="black"
|
||||
fontSize={0}
|
||||
|
@ -8,10 +8,10 @@ interface LoadingProps {
|
||||
}
|
||||
export function Loading({ text }: LoadingProps) {
|
||||
return (
|
||||
<Body>
|
||||
<Body border="0">
|
||||
<Center height="100%">
|
||||
<LoadingSpinner />
|
||||
{Boolean(text) && <Text>{text}</Text>}
|
||||
{Boolean(text) && <Text ml={4}>{text}</Text>}
|
||||
</Center>
|
||||
</Body>
|
||||
);
|
||||
|
@ -42,7 +42,7 @@ export function Mention(props: {
|
||||
}) {
|
||||
const { contacts, ship, scrollWindow, first, ...rest } = props;
|
||||
let { contact } = props;
|
||||
contact = contact?.color ? contact : contacts?.[ship];
|
||||
contact = contact?.color ? contact : contacts?.[`~${ship}`];
|
||||
const history = useHistory();
|
||||
const showNickname = useShowNickname(contact);
|
||||
const name = showNickname ? contact?.nickname : cite(ship);
|
||||
|
@ -1,11 +1,13 @@
|
||||
import React, { PureComponent, Fragment } from 'react';
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import { BaseAnchor, BaseImage, Box, Button, Text } from '@tlon/indigo-react';
|
||||
import { hasProvider } from 'oembed-parser';
|
||||
import EmbedContainer from 'react-oembed-container';
|
||||
import { withLocalState } from '~/logic/state/local';
|
||||
import { RemoteContentPolicy } from '~/types/local-update';
|
||||
import { VirtualContextProps, withVirtual } from "~/logic/lib/virtualContext";
|
||||
import { IS_IOS } from '~/logic/lib/platform';
|
||||
|
||||
interface RemoteContentProps {
|
||||
type RemoteContentProps = VirtualContextProps & {
|
||||
url: string;
|
||||
text?: string;
|
||||
unfold?: boolean;
|
||||
@ -17,7 +19,6 @@ interface RemoteContentProps {
|
||||
oembedProps?: any;
|
||||
textProps?: any;
|
||||
style?: any;
|
||||
onLoad?(): void;
|
||||
}
|
||||
|
||||
interface RemoteContentState {
|
||||
@ -30,9 +31,11 @@ const IMAGE_REGEX = new RegExp(/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i);
|
||||
const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg)$/i);
|
||||
const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i);
|
||||
|
||||
class RemoteContent extends PureComponent<RemoteContentProps, RemoteContentState> {
|
||||
|
||||
class RemoteContent extends Component<RemoteContentProps, RemoteContentState> {
|
||||
private fetchController: AbortController | undefined;
|
||||
containerRef: HTMLDivElement | null = null;
|
||||
private saving = false;
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@ -46,7 +49,25 @@ class RemoteContent extends PureComponent<RemoteContentProps, RemoteContentState
|
||||
this.onError = this.onError.bind(this);
|
||||
}
|
||||
|
||||
save = () => {
|
||||
console.log(`saving for: ${this.props.url}`);
|
||||
if(this.saving) {
|
||||
return;
|
||||
}
|
||||
this.saving = true;
|
||||
this.props.save();
|
||||
};
|
||||
|
||||
restore = () => {
|
||||
console.log(`restoring for: ${this.props.url}`);
|
||||
this.saving = false;
|
||||
this.props.restore();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if(this.saving) {
|
||||
this.restore();
|
||||
}
|
||||
if (this.fetchController) {
|
||||
this.fetchController.abort();
|
||||
}
|
||||
@ -56,8 +77,35 @@ class RemoteContent extends PureComponent<RemoteContentProps, RemoteContentState
|
||||
event.stopPropagation();
|
||||
let unfoldState = this.state.unfold;
|
||||
unfoldState = !unfoldState;
|
||||
this.save();
|
||||
this.setState({ unfold: unfoldState });
|
||||
setTimeout(this.props.onLoad, 500);
|
||||
requestAnimationFrame(() => {
|
||||
this.restore();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if(prevState.embed !== this.state.embed) {
|
||||
//console.log('remotecontent: restoring');
|
||||
//prevProps.shiftLayout.restore();
|
||||
}
|
||||
const { url } = this.props;
|
||||
if(url !== prevProps.url && (IMAGE_REGEX.test(url) || AUDIO_REGEX.test(url) || VIDEO_REGEX.test(url))) {
|
||||
this.save();
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
}
|
||||
|
||||
onLoad = () => {
|
||||
window.requestAnimationFrame(() => {
|
||||
const { restore } = this;
|
||||
restore();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
loadOembed() {
|
||||
@ -91,6 +139,7 @@ return;
|
||||
}
|
||||
|
||||
onError(e: Event) {
|
||||
this.restore();
|
||||
this.setState({ noCors: true });
|
||||
}
|
||||
|
||||
@ -107,9 +156,9 @@ return;
|
||||
oembedProps = {},
|
||||
textProps = {},
|
||||
style = {},
|
||||
onLoad = () => {},
|
||||
...props
|
||||
} = this.props;
|
||||
const { onLoad } = this;
|
||||
const { noCors } = this.state;
|
||||
const isImage = IMAGE_REGEX.test(url);
|
||||
const isAudio = AUDIO_REGEX.test(url);
|
||||
@ -140,6 +189,7 @@ return;
|
||||
className="db"
|
||||
src={url}
|
||||
style={style}
|
||||
onLoad={onLoad}
|
||||
{...audioProps}
|
||||
{...props}
|
||||
/>
|
||||
@ -193,13 +243,14 @@ return;
|
||||
className='embed-container'
|
||||
style={style}
|
||||
flexShrink={0}
|
||||
onLoad={onLoad}
|
||||
onLoad={this.onLoad}
|
||||
{...oembedProps}
|
||||
{...props}
|
||||
>
|
||||
{this.state.embed && this.state.embed.html && this.state.unfold
|
||||
? <EmbedContainer markup={this.state.embed.html}>
|
||||
<div className="embed-container" ref={(el) => {
|
||||
this.onLoad();
|
||||
this.containerRef = el;
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: this.state.embed.html }}
|
||||
@ -217,4 +268,4 @@ return;
|
||||
}
|
||||
}
|
||||
|
||||
export default withLocalState(RemoteContent, ['remoteContentPolicy']);
|
||||
export default withLocalState(withVirtual(RemoteContent), ['remoteContentPolicy']);
|
||||
|
@ -78,6 +78,7 @@ const Candidate = ({ title, detail, selected, onClick }): ReactElement => (
|
||||
bg="white"
|
||||
color="black"
|
||||
fontSize={0}
|
||||
cursor="pointer"
|
||||
p={1}
|
||||
width="100%"
|
||||
>
|
||||
|
56
pkg/interface/src/views/components/ShuffleFields.tsx
Normal file
56
pkg/interface/src/views/components/ShuffleFields.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import React, { ReactNode, useMemo, useCallback } from "react";
|
||||
|
||||
import {
|
||||
FieldArray,
|
||||
FieldArrayRenderProps,
|
||||
Field,
|
||||
useFormikContext,
|
||||
} from "formik";
|
||||
import { Icon, Col, Row, Box } from "@tlon/indigo-react";
|
||||
|
||||
interface ShuffleFieldsProps<N extends string> {
|
||||
name: N;
|
||||
children: (index: number, props: FieldArrayRenderProps) => ReactNode;
|
||||
}
|
||||
|
||||
type Value<I extends string, T> = {
|
||||
[k in I]: T[];
|
||||
};
|
||||
|
||||
export function ShuffleFields<N extends string, T, F extends Value<N, T>>(
|
||||
props: ShuffleFieldsProps<N>
|
||||
) {
|
||||
const { name, children } = props;
|
||||
const { values } = useFormikContext<F>();
|
||||
const fields: T[] = useMemo(() => values[name], [values, name]);
|
||||
|
||||
return (
|
||||
<FieldArray
|
||||
name={name}
|
||||
render={(arrayHelpers) => {
|
||||
const goUp = (i: number) => () => {
|
||||
if(i > 0) {
|
||||
arrayHelpers.swap(i - 1, i);
|
||||
}
|
||||
};
|
||||
const goDown = (i: number) => () => {
|
||||
if(i < fields.length - 1) {
|
||||
arrayHelpers.swap(i + 1, i);
|
||||
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Box gridColumnGap="2" gridRowGap="3" display="grid" gridAutoRows="auto" gridTemplateColumns="32px 32px 1fr">
|
||||
{fields.map((field, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<Icon width="3" height="3" icon="ChevronNorth" onClick={goUp(i)} />
|
||||
<Icon width="3" height="3" icon="ChevronSouth" onClick={goDown(i)} />
|
||||
{children(i, arrayHelpers)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
@ -21,17 +21,18 @@ import { uxToHex } from "~/logic/lib/util";
|
||||
import { SetStatusBarModal } from './SetStatusBarModal';
|
||||
import { useTutorialModal } from './useTutorialModal';
|
||||
|
||||
import useLocalState from '~/logic/state/local';
|
||||
import useLocalState, { selectLocalState } from '~/logic/state/local';
|
||||
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
|
||||
|
||||
|
||||
const localSel = selectLocalState(['toggleOmnibox']);
|
||||
|
||||
const StatusBar = (props) => {
|
||||
const { ourContact, api, ship } = props;
|
||||
const invites = [].concat(...Object.values(props.invites).map(obj => Object.values(obj)));
|
||||
const metaKey = (window.navigator.platform.includes('Mac')) ? '⌘' : 'Ctrl+';
|
||||
const { toggleOmnibox, hideAvatars } =
|
||||
useLocalState(({ toggleOmnibox, hideAvatars }) =>
|
||||
({ toggleOmnibox, hideAvatars })
|
||||
);
|
||||
const { toggleOmnibox } = useLocalState(localSel);
|
||||
const { hideAvatars } = useSettingsState(selectCalmState);
|
||||
|
||||
const color = !!ourContact ? `#${uxToHex(props.ourContact.color)}` : '#000';
|
||||
const xPadding = (!hideAvatars && ourContact?.avatar) ? '0' : '2';
|
||||
|
@ -1,196 +1,220 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { Component, useCallback } from 'react';
|
||||
import _ from 'lodash';
|
||||
import normalizeWheel from 'normalize-wheel';
|
||||
import bigInt, { BigInteger } from 'big-integer';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
import { Box, LoadingSpinner, Row, Center } from '@tlon/indigo-react';
|
||||
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
|
||||
import {VirtualContext} from '~/logic/lib/virtualContext';
|
||||
import { IS_IOS } from '~/logic/lib/platform';
|
||||
const ScrollbarLessBox = styled(Box)`
|
||||
scrollbar-width: none !important;
|
||||
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
interface RendererProps {
|
||||
index: BigInteger;
|
||||
measure: (el: any) => void;
|
||||
scrollWindow: any
|
||||
scrollWindow: any;
|
||||
ref: (el: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
interface VirtualScrollerProps {
|
||||
interface VirtualScrollerProps<T> {
|
||||
origin: 'top' | 'bottom';
|
||||
loadRows(newer: boolean): void;
|
||||
data: BigIntOrderedMap<BigInteger, any>;
|
||||
loadRows(newer: boolean): Promise<boolean>;
|
||||
data: BigIntOrderedMap<T>;
|
||||
id: string;
|
||||
renderer: (props: RendererProps) => JSX.Element | null;
|
||||
onStartReached?(): void;
|
||||
onEndReached?(): void;
|
||||
size: number;
|
||||
onCalculateVisibleItems?(visibleItems: BigIntOrderedMap<BigInteger, JSX.Element>): void;
|
||||
totalSize: number;
|
||||
averageHeight: number;
|
||||
offset: number;
|
||||
onCalculateVisibleItems?(visibleItems: BigIntOrderedMap<T>): void;
|
||||
onScroll?({ scrollTop, scrollHeight, windowHeight }): void;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
interface VirtualScrollerState {
|
||||
startgap: number | undefined;
|
||||
visibleItems: BigIntOrderedMap<BigInteger, Element>;
|
||||
endgap: number | undefined;
|
||||
totalHeight: number;
|
||||
averageHeight: number;
|
||||
scrollTop: number;
|
||||
interface VirtualScrollerState<T> {
|
||||
visibleItems: BigIntOrderedMap<T>;
|
||||
scrollbar: number;
|
||||
}
|
||||
|
||||
export default class VirtualScroller extends Component<VirtualScrollerProps, VirtualScrollerState> {
|
||||
private scrollContainer: React.RefObject<HTMLDivElement>;
|
||||
public window: HTMLDivElement | null;
|
||||
private cache: BigIntOrderedMap<any>;
|
||||
private pendingLoad: {
|
||||
start: BigInteger;
|
||||
end: BigInteger
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
} | undefined;
|
||||
type LogLevel = 'scroll' | 'network' | 'bail' | 'reflow';
|
||||
let logLevel = ['bail', 'scroll', 'reflow'] as LogLevel[];
|
||||
|
||||
overscan = 150;
|
||||
const log = (level: LogLevel, message: string) => {
|
||||
if(logLevel.includes(level)) {
|
||||
console.log(`[${level}]: ${message}`);
|
||||
}
|
||||
|
||||
OVERSCAN_SIZE = 100; // Minimum number of messages on either side before loadRows is called
|
||||
}
|
||||
|
||||
constructor(props: VirtualScrollerProps) {
|
||||
const ZONE_SIZE = IS_IOS ? 10 : 40;
|
||||
|
||||
|
||||
// nb: in this file, an index refers to a BigInteger and an offset refers to a
|
||||
// number used to index a listified BigIntOrderedMap
|
||||
|
||||
export default class VirtualScroller<T> extends Component<VirtualScrollerProps<T>, VirtualScrollerState<T>> {
|
||||
/**
|
||||
* A reference to our scroll container
|
||||
*/
|
||||
private window: HTMLDivElement | null = null;
|
||||
/**
|
||||
* A map of child refs, used to calculate scroll position
|
||||
*/
|
||||
private childRefs = new BigIntOrderedMap<HTMLElement>();
|
||||
/**
|
||||
* If saving, the bottommost visible element that we pin our scroll to
|
||||
*/
|
||||
private savedIndex: BigInteger | null = null;
|
||||
/**
|
||||
* If saving, the distance between the top of `this.savedEl` and the bottom
|
||||
* of the screen
|
||||
*/
|
||||
private savedDistance = 0;
|
||||
|
||||
/**
|
||||
* If saving, the number of requested saves. If several images are loading
|
||||
* at once, we save the scroll pos the first time we see it and restore
|
||||
* once the number of requested saves is zero
|
||||
*/
|
||||
private saveDepth = 0;
|
||||
|
||||
private isUpdating = false;
|
||||
|
||||
private scrollLocked = true;
|
||||
|
||||
private pageSize = 50;
|
||||
|
||||
private pageDelta = 15;
|
||||
|
||||
private scrollRef: HTMLElement | null = null;
|
||||
|
||||
|
||||
private loaded = {
|
||||
top: false,
|
||||
bottom: false
|
||||
};
|
||||
|
||||
constructor(props: VirtualScrollerProps<T>) {
|
||||
super(props);
|
||||
this.state = {
|
||||
startgap: props.origin === 'top' ? 0 : undefined,
|
||||
visibleItems: new BigIntOrderedMap(),
|
||||
endgap: props.origin === 'bottom' ? 0 : undefined,
|
||||
totalHeight: 0,
|
||||
averageHeight: 130,
|
||||
scrollTop: props.origin === 'top' ? 0 : undefined
|
||||
scrollbar: 0
|
||||
};
|
||||
|
||||
this.scrollContainer = React.createRef();
|
||||
this.window = null;
|
||||
this.cache = new BigIntOrderedMap();
|
||||
this.updateVisible = IS_IOS
|
||||
? _.debounce(this.updateVisible.bind(this), 100)
|
||||
: this.updateVisible.bind(this);
|
||||
|
||||
this.recalculateTotalHeight = _.throttle(this.recalculateTotalHeight.bind(this), 200);
|
||||
this.calculateVisibleItems = _.throttle(this.calculateVisibleItems.bind(this), 200);
|
||||
this.estimateIndexFromScrollTop = this.estimateIndexFromScrollTop.bind(this);
|
||||
this.invertedKeyHandler = this.invertedKeyHandler.bind(this);
|
||||
this.heightOf = this.heightOf.bind(this);
|
||||
this.setScrollTop = this.setScrollTop.bind(this);
|
||||
this.scrollToData = this.scrollToData.bind(this);
|
||||
this.onScroll = IS_IOS ? _.debounce(this.onScroll.bind(this), 150) : this.onScroll.bind(this);
|
||||
this.scrollKeyMap = this.scrollKeyMap.bind(this);
|
||||
this.loadRows = _.debounce(this.loadRows, 300, { leading: true }).bind(this);
|
||||
this.setWindow = this.setWindow.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.calculateVisibleItems();
|
||||
|
||||
this.recalculateTotalHeight();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: VirtualScrollerProps, prevState: VirtualScrollerState) {
|
||||
const {
|
||||
scrollContainer, window,
|
||||
props: { origin },
|
||||
state: { totalHeight, scrollTop }
|
||||
} = this;
|
||||
}
|
||||
|
||||
scrollToData(targetIndex: BigInteger): Promise<void> {
|
||||
if (!this.window) {
|
||||
return new Promise((resolve, reject) => {
|
||||
reject();
|
||||
});
|
||||
}
|
||||
const { offsetHeight } = this.window;
|
||||
let scrollTop = 0;
|
||||
let itemHeight = 0;
|
||||
new BigIntOrderedMap([...this.props.data].reverse()).forEach((datum, index) => {
|
||||
const height = this.heightOf(index);
|
||||
if (index.geq(targetIndex)) {
|
||||
scrollTop += height;
|
||||
if (index.eq(targetIndex)) {
|
||||
itemHeight = height;
|
||||
}
|
||||
}
|
||||
});
|
||||
return this.setScrollTop(scrollTop - (offsetHeight / 2) + itemHeight);
|
||||
}
|
||||
|
||||
recalculateTotalHeight() {
|
||||
let { averageHeight } = this.state;
|
||||
let totalHeight = 0;
|
||||
this.props.data.forEach((datum, index) => {
|
||||
totalHeight += Math.max(this.heightOf(index), 0);
|
||||
});
|
||||
averageHeight = Number((totalHeight / this.props.data.size).toFixed());
|
||||
totalHeight += (this.props.size - this.props.data.size) * averageHeight;
|
||||
this.setState({ totalHeight, averageHeight });
|
||||
}
|
||||
|
||||
estimateIndexFromScrollTop(targetScrollTop: number): BigInteger | undefined {
|
||||
if (!this.window)
|
||||
return undefined;
|
||||
const index = bigInt(this.props.size);
|
||||
const { averageHeight } = this.state;
|
||||
let height = 0;
|
||||
while (height < targetScrollTop) {
|
||||
const itemHeight = this.cache.has(index) ? this.cache.get(index).height : averageHeight;
|
||||
height += itemHeight;
|
||||
index.subtract(bigInt.one);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
heightOf(index: BigInteger): number {
|
||||
return this.cache.has(index) ? this.cache.get(index).height : this.state.averageHeight;
|
||||
}
|
||||
|
||||
calculateVisibleItems() {
|
||||
if (!this.window)
|
||||
return;
|
||||
let startgap = 0, heightShown = 0, endgap = 0;
|
||||
let startGapFilled = false;
|
||||
const visibleItems = new BigIntOrderedMap<any>();
|
||||
const { scrollTop, offsetHeight: windowHeight } = this.window;
|
||||
const { averageHeight, totalHeight } = this.state;
|
||||
const { data, size: totalSize, onCalculateVisibleItems } = this.props;
|
||||
|
||||
[...data].forEach(([index, datum]) => {
|
||||
const height = this.heightOf(index);
|
||||
if (startgap < (scrollTop - this.overscan) && !startGapFilled) {
|
||||
startgap += height;
|
||||
} else if (heightShown < (windowHeight + this.overscan)) {
|
||||
startGapFilled = true;
|
||||
visibleItems.set(index, datum);
|
||||
heightShown += height;
|
||||
}
|
||||
});
|
||||
|
||||
endgap = totalHeight - heightShown - startgap;
|
||||
|
||||
const firstVisibleKey = visibleItems.peekSmallest()?.[0] ?? this.estimateIndexFromScrollTop(scrollTop)!;
|
||||
const smallest = data.peekSmallest();
|
||||
if (smallest && smallest[0].eq(firstVisibleKey)) {
|
||||
if(true) {
|
||||
this.updateVisible(0);
|
||||
this.resetScroll();
|
||||
this.loadRows(false);
|
||||
return;
|
||||
}
|
||||
const lastVisibleKey =
|
||||
visibleItems.peekLargest()?.[0]
|
||||
?? bigInt(this.estimateIndexFromScrollTop(scrollTop + windowHeight)!);
|
||||
}
|
||||
|
||||
const largest = data.peekLargest();
|
||||
|
||||
if (largest && largest[0].eq(lastVisibleKey)) {
|
||||
this.loadRows(true);
|
||||
// manipulate scrollbar manually, to dodge change detection
|
||||
updateScroll = IS_IOS ? () => {} : _.throttle(() => {
|
||||
if(!this.window || !this.scrollRef) {
|
||||
return;
|
||||
}
|
||||
const { scrollTop, scrollHeight, offsetHeight } = this.window;
|
||||
|
||||
const unloaded = (this.startOffset() / this.pageSize);
|
||||
const totalpages = this.props.size / this.pageSize;
|
||||
|
||||
const loaded = (scrollTop / scrollHeight);
|
||||
const total = unloaded + loaded;
|
||||
const result = ((unloaded + loaded) / totalpages) *this.window.offsetHeight;
|
||||
this.scrollRef.style[this.props.origin] = `${result}px`;
|
||||
}, 50);
|
||||
|
||||
|
||||
|
||||
componentDidUpdate(prevProps: VirtualScrollerProps<T>, _prevState: VirtualScrollerState<T>) {
|
||||
const { id, size, data, offset } = this.props;
|
||||
const { visibleItems } = this.state;
|
||||
if(size !== prevProps.size) {
|
||||
if(this.scrollLocked) {
|
||||
this.updateVisible(0);
|
||||
if(IS_IOS) {
|
||||
(this.updateVisible as any).flush();
|
||||
|
||||
}
|
||||
this.resetScroll();
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('keydown', this.invertedKeyHandler);
|
||||
}
|
||||
|
||||
startOffset() {
|
||||
const startIndex = this.state?.visibleItems?.peekLargest()?.[0];
|
||||
if(!startIndex) {
|
||||
return 0;
|
||||
}
|
||||
const offset = [...this.props.data].findIndex(([i]) => i.eq(startIndex))
|
||||
if(offset === -1) {
|
||||
throw new Error("a");
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the `startOffset` and adjusts visible items accordingly.
|
||||
* Saves the scroll positions before repainting and restores it afterwards
|
||||
*/
|
||||
updateVisible(newOffset: number) {
|
||||
if (!this.window) {
|
||||
return;
|
||||
}
|
||||
log('reflow', `from: ${this.startOffset()} to: ${newOffset}`);
|
||||
this.isUpdating = true;
|
||||
|
||||
const { data, onCalculateVisibleItems } = this.props;
|
||||
const visibleItems = new BigIntOrderedMap<any>(
|
||||
[...data].slice(newOffset, newOffset + this.pageSize)
|
||||
);
|
||||
|
||||
this.save();
|
||||
|
||||
onCalculateVisibleItems ? onCalculateVisibleItems(visibleItems) : null;
|
||||
this.setState({
|
||||
startgap: Number(startgap.toFixed()),
|
||||
visibleItems,
|
||||
endgap: Number(endgap.toFixed())
|
||||
});
|
||||
}
|
||||
}, () => {
|
||||
requestAnimationFrame(() => {
|
||||
this.restore();
|
||||
requestAnimationFrame(() => {
|
||||
this.isUpdating = false;
|
||||
|
||||
loadRows(newer: boolean) {
|
||||
this.props.loadRows(newer);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
scrollKeyMap(): Map<string, number> {
|
||||
return new Map([
|
||||
['ArrowUp', this.state.averageHeight],
|
||||
['ArrowDown', this.state.averageHeight * -1],
|
||||
['ArrowUp', this.props.averageHeight],
|
||||
['ArrowDown', this.props.averageHeight * -1],
|
||||
['PageUp', this.window!.offsetHeight],
|
||||
['PageDown', this.window!.offsetHeight * -1],
|
||||
['Home', this.window!.scrollHeight],
|
||||
@ -213,13 +237,12 @@ return;
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('keydown', this.invertedKeyHandler);
|
||||
}
|
||||
|
||||
setWindow(element) {
|
||||
if (!element)
|
||||
return;
|
||||
return;
|
||||
console.log('resetting window');
|
||||
this.save();
|
||||
|
||||
if (this.window) {
|
||||
if (this.window.isSameNode(element)) {
|
||||
return;
|
||||
@ -227,10 +250,11 @@ return;
|
||||
window.removeEventListener('keydown', this.invertedKeyHandler);
|
||||
}
|
||||
}
|
||||
|
||||
this.overscan = Math.max(element.offsetHeight * 3, 500);
|
||||
const { averageHeight } = this.props;
|
||||
|
||||
this.window = element;
|
||||
this.pageSize = Math.floor(element.offsetHeight / Math.floor(averageHeight / 5.5));
|
||||
this.pageDelta = Math.floor(this.pageSize / 3);
|
||||
if (this.props.origin === 'bottom') {
|
||||
element.addEventListener('wheel', (event) => {
|
||||
event.preventDefault();
|
||||
@ -241,48 +265,177 @@ return;
|
||||
|
||||
window.addEventListener('keydown', this.invertedKeyHandler, { passive: false });
|
||||
}
|
||||
this.resetScroll();
|
||||
this.restore();
|
||||
}
|
||||
|
||||
resetScroll(): Promise<void> {
|
||||
if (!this.window)
|
||||
return new Promise((resolve, reject) => {
|
||||
reject();
|
||||
});
|
||||
return this.setScrollTop(0);
|
||||
resetScroll() {
|
||||
if (!this.window) {
|
||||
return;
|
||||
}
|
||||
this.window.scrollTop = 0;
|
||||
this.savedIndex = null;
|
||||
this.savedDistance = 0;
|
||||
this.saveDepth = 0;
|
||||
}
|
||||
|
||||
setScrollTop(distance: number, delay = 100): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
if (!this.window) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
this.window.scrollTop = distance;
|
||||
resolve();
|
||||
}, delay);
|
||||
loadRows = _.throttle(async (newer: boolean) => {
|
||||
const dir = newer ? 'bottom' : 'top';
|
||||
if(this.loaded[dir]) {
|
||||
return;
|
||||
}
|
||||
log('network', `loading more at ${dir}`);
|
||||
const done = await this.props.loadRows(newer);
|
||||
if(done) {
|
||||
this.loaded[dir] = true;
|
||||
}
|
||||
}, 100);
|
||||
|
||||
onScroll(event: UIEvent) {
|
||||
this.updateScroll();
|
||||
if(!this.window) {
|
||||
// bail if we're going to adjust scroll anyway
|
||||
return;
|
||||
}
|
||||
if(this.saveDepth > 0) {
|
||||
log('bail', 'deep scroll queue');
|
||||
return;
|
||||
}
|
||||
const { onStartReached, onEndReached } = this.props;
|
||||
const windowHeight = this.window.offsetHeight;
|
||||
const { scrollTop, scrollHeight } = this.window;
|
||||
|
||||
const startOffset = this.startOffset();
|
||||
if (scrollTop < ZONE_SIZE) {
|
||||
log('scroll', `Entered start zone ${scrollTop}`);
|
||||
if (startOffset === 0 && onStartReached) {
|
||||
onStartReached();
|
||||
}
|
||||
const newOffset = Math.max(0, startOffset - this.pageDelta);
|
||||
if(newOffset < 10) {
|
||||
this.loadRows(true);
|
||||
}
|
||||
|
||||
if(newOffset === 0) {
|
||||
this.scrollLocked = true;
|
||||
}
|
||||
if(newOffset !== startOffset) {
|
||||
this.updateVisible(newOffset);
|
||||
}
|
||||
}
|
||||
else if (scrollTop + windowHeight >= scrollHeight - ZONE_SIZE) {
|
||||
this.scrollLocked = false;
|
||||
log('scroll', `Entered end zone ${scrollTop}`);
|
||||
|
||||
const newOffset = Math.min(startOffset + this.pageDelta, this.props.data.size - this.pageSize);
|
||||
if (onEndReached && startOffset === 0) {
|
||||
onEndReached();
|
||||
}
|
||||
|
||||
if((newOffset + (3 * this.pageSize) > this.props.data.size)) {
|
||||
this.loadRows(false)
|
||||
}
|
||||
|
||||
if(newOffset !== startOffset) {
|
||||
this.updateVisible(newOffset);
|
||||
}
|
||||
} else {
|
||||
this.scrollLocked = false;
|
||||
}
|
||||
}
|
||||
|
||||
restore() {
|
||||
if(!this.window || !this.savedIndex) {
|
||||
return;
|
||||
}
|
||||
if(this.saveDepth !== 1) {
|
||||
log('bail', 'Deep restore');
|
||||
return;
|
||||
}
|
||||
|
||||
const ref = this.childRefs.get(this.savedIndex)!;
|
||||
const newScrollTop = this.window.scrollHeight - ref.offsetTop - this.savedDistance;
|
||||
|
||||
this.window.scrollTo(0, newScrollTop);
|
||||
requestAnimationFrame(() => {
|
||||
this.savedIndex = null;
|
||||
this.savedDistance = 0;
|
||||
this.saveDepth--;
|
||||
});
|
||||
}
|
||||
|
||||
onScroll(event) {
|
||||
if (!this.window)
|
||||
return;
|
||||
const { onStartReached, onEndReached, onScroll } = this.props;
|
||||
const windowHeight = this.window.offsetHeight;
|
||||
const { scrollTop, scrollHeight } = this.window;
|
||||
if (scrollTop !== scrollHeight) {
|
||||
this.setState({ scrollTop });
|
||||
scrollToIndex = (index: BigInteger) => {
|
||||
let ref = this.childRefs.get(index);
|
||||
if(!ref) {
|
||||
const offset = [...this.props.data].findIndex(([idx]) => idx.eq(index));
|
||||
if(offset === -1) {
|
||||
return;
|
||||
}
|
||||
this.updateVisible(Math.max(offset - this.pageDelta, 0));
|
||||
if(IS_IOS) {
|
||||
(this.updateVisible as any).flush();
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
ref = this.childRefs.get(index);
|
||||
this.savedIndex = null;
|
||||
this.savedDistance = 0;
|
||||
this.saveDepth = 0;
|
||||
|
||||
ref?.scrollIntoView({ block: 'center' });
|
||||
});
|
||||
} else {
|
||||
this.savedIndex = null;
|
||||
this.savedDistance = 0;
|
||||
this.saveDepth = 0;
|
||||
|
||||
ref?.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
};
|
||||
|
||||
save() {
|
||||
if(!this.window || this.savedIndex) {
|
||||
return;
|
||||
}
|
||||
this.saveDepth++;
|
||||
if(this.saveDepth !== 1) {
|
||||
console.log('bail', 'deep save');
|
||||
return;
|
||||
}
|
||||
|
||||
this.calculateVisibleItems();
|
||||
onScroll ? onScroll({ scrollTop, scrollHeight, windowHeight }) : null;
|
||||
if (scrollTop === 0) {
|
||||
if (onStartReached)
|
||||
onStartReached();
|
||||
} else if (scrollTop + windowHeight >= scrollHeight) {
|
||||
if (onEndReached)
|
||||
onEndReached();
|
||||
let bottomIndex: BigInteger | null = null;
|
||||
const { scrollTop, scrollHeight } = this.window;
|
||||
const topSpacing = scrollHeight - scrollTop;
|
||||
[...Array.from(this.state.visibleItems)].reverse().forEach(([index, datum]) => {
|
||||
const el = this.childRefs.get(index);
|
||||
if(!el) {
|
||||
return;
|
||||
}
|
||||
const { offsetTop } = el;
|
||||
if(offsetTop < topSpacing) {
|
||||
bottomIndex = index;
|
||||
}
|
||||
});
|
||||
|
||||
if(!bottomIndex) {
|
||||
// weird, shouldn't really happen
|
||||
this.saveDepth--;
|
||||
return;
|
||||
}
|
||||
|
||||
this.savedIndex = bottomIndex;
|
||||
const ref = this.childRefs.get(bottomIndex)!;
|
||||
const { offsetTop } = ref;
|
||||
this.savedDistance = topSpacing - offsetTop
|
||||
}
|
||||
|
||||
shiftLayout = { save: this.save.bind(this), restore: this.restore.bind(this) };
|
||||
|
||||
setRef = (element: HTMLElement | null, index: BigInteger) => {
|
||||
if(element) {
|
||||
this.childRefs.set(index, element);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.childRefs.delete(index);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -295,37 +448,64 @@ onEndReached();
|
||||
|
||||
const {
|
||||
origin = 'top',
|
||||
loadRows,
|
||||
renderer,
|
||||
style,
|
||||
data
|
||||
} = this.props;
|
||||
|
||||
const indexesToRender = origin === 'top' ? visibleItems.keys() : visibleItems.keys().reverse();
|
||||
const isTop = origin === 'top';
|
||||
|
||||
const transform = origin === 'top' ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
|
||||
const indexesToRender = isTop ? visibleItems.keys() : visibleItems.keys().reverse();
|
||||
|
||||
const render = (index: BigInteger) => {
|
||||
const measure = (element: any) => {
|
||||
if (element) {
|
||||
this.cache.set(index, {
|
||||
height: element.offsetHeight,
|
||||
element
|
||||
});
|
||||
this.recalculateTotalHeight();
|
||||
}
|
||||
};
|
||||
return renderer({ index, measure, scrollWindow: this.window });
|
||||
};
|
||||
const transform = isTop ? 'scale3d(1, 1, 1)' : 'scale3d(1, -1, 1)';
|
||||
|
||||
const atStart = this.props.data.peekLargest()?.[0].eq(visibleItems.peekLargest()?.[0] || bigInt.zero);
|
||||
const atEnd = false;
|
||||
|
||||
return (
|
||||
<Box overflowY='scroll' ref={this.setWindow.bind(this)} onScroll={this.onScroll.bind(this)} style={{ ...style, ...{ transform } }}>
|
||||
<Box ref={this.scrollContainer} style={{ transform, width: '100%' }}>
|
||||
<Box style={{ height: `${origin === 'top' ? startgap : endgap}px` }}></Box>
|
||||
{indexesToRender.map(render)}
|
||||
<Box style={{ height: `${origin === 'top' ? endgap : startgap}px` }}></Box>
|
||||
<>
|
||||
{!IS_IOS && (<Box borderRadius="3" top ={isTop ? "0" : undefined} bottom={!isTop ? "0" : undefined} ref={el => { this.scrollRef = el; }} right="0" height="50px" position="absolute" width="4px" backgroundColor="lightGray" />)}
|
||||
|
||||
<ScrollbarLessBox overflowY='scroll' ref={this.setWindow} onScroll={this.onScroll} style={{ ...style, ...{ transform }, "-webkit-overflow-scrolling": "auto" }}>
|
||||
<Box style={{ transform, width: 'calc(100% - 4px)' }}>
|
||||
{(isTop ? !atStart : !atEnd) && (<Center height="5">
|
||||
<LoadingSpinner />
|
||||
</Center>)}
|
||||
<VirtualContext.Provider value={this.shiftLayout}>
|
||||
{indexesToRender.map(index => (
|
||||
<VirtualChild
|
||||
key={index.toString()}
|
||||
setRef={this.setRef}
|
||||
index={index}
|
||||
scrollWindow={this.window}
|
||||
renderer={renderer}
|
||||
/>
|
||||
))}
|
||||
</VirtualContext.Provider>
|
||||
{(!isTop ? !atStart : !atEnd) &&
|
||||
(<Center height="5">
|
||||
<LoadingSpinner />
|
||||
</Center>)}
|
||||
</Box>
|
||||
</Box>
|
||||
</ScrollbarLessBox>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface VirtualChildProps {
|
||||
index: BigInteger;
|
||||
scrollWindow: any;
|
||||
setRef: (el: HTMLElement | null, index: BigInteger) => void;
|
||||
renderer: (p: RendererProps) => JSX.Element | null;
|
||||
}
|
||||
|
||||
function VirtualChild(props: VirtualChildProps) {
|
||||
const { setRef, renderer: Renderer, ...rest } = props;
|
||||
|
||||
const ref = useCallback((el: HTMLElement | null) => {
|
||||
setRef(el, props.index);
|
||||
}, [setRef, props.index])
|
||||
|
||||
return (<Renderer ref={ref} {...rest} />);
|
||||
};
|
||||
|
||||
|
@ -9,12 +9,13 @@ import { Associations, Contacts, Groups, Invites } from '@urbit/api';
|
||||
import makeIndex from '~/logic/lib/omnibox';
|
||||
import OmniboxInput from './OmniboxInput';
|
||||
import OmniboxResult from './OmniboxResult';
|
||||
import { withLocalState } from '~/logic/state/local';
|
||||
import { deSig } from '~/logic/lib/util';
|
||||
import { withLocalState } from '~/logic/state/local';
|
||||
|
||||
import defaultApps from '~/logic/lib/default-apps';
|
||||
import { useOutsideClick } from '~/logic/lib/useOutsideClick';
|
||||
import { Portal } from '../Portal';
|
||||
import {useOutsideClick} from '~/logic/lib/useOutsideClick';
|
||||
import {Portal} from '../Portal';
|
||||
import useSettingsState, {SettingsState} from '~/logic/state/settings';
|
||||
import { Tile } from '~/types';
|
||||
|
||||
interface OmniboxProps {
|
||||
@ -31,11 +32,13 @@ interface OmniboxProps {
|
||||
}
|
||||
|
||||
const SEARCHED_CATEGORIES = ['ships', 'other', 'commands', 'groups', 'subscriptions', 'apps'];
|
||||
const settingsSel = (s: SettingsState) => s.leap;
|
||||
|
||||
export function Omnibox(props: OmniboxProps) {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const omniboxRef = useRef<HTMLDivElement | null>(null);
|
||||
const leapConfig = useSettingsState(settingsSel);
|
||||
const omniboxRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
@ -48,18 +51,30 @@ export function Omnibox(props: OmniboxProps) {
|
||||
: props.contacts;
|
||||
}, [props.contacts, query]);
|
||||
|
||||
const index = useMemo(() => {
|
||||
const selectedGroup = location.pathname.startsWith('/~landscape/ship/')
|
||||
const selectedGroup = useMemo(
|
||||
() => location.pathname.startsWith('/~landscape/ship/')
|
||||
? '/' + location.pathname.split('/').slice(2,5).join('/')
|
||||
: null;
|
||||
: null,
|
||||
[location.pathname]
|
||||
);
|
||||
|
||||
const index = useMemo(() => {
|
||||
return makeIndex(
|
||||
contacts,
|
||||
props.associations,
|
||||
props.tiles,
|
||||
selectedGroup,
|
||||
props.groups
|
||||
props.groups,
|
||||
leapConfig,
|
||||
);
|
||||
}, [location.pathname, contacts, props.associations, props.groups, props.tiles]);
|
||||
}, [
|
||||
selectedGroup,
|
||||
leapConfig,
|
||||
contacts,
|
||||
props.associations,
|
||||
props.groups,
|
||||
props.tiles
|
||||
]);
|
||||
|
||||
const onOutsideClick = useCallback(() => {
|
||||
props.show && props.toggle();
|
||||
|
@ -2,7 +2,7 @@ import React, { Component } from 'react';
|
||||
import { Box, Row, Icon, Text } from '@tlon/indigo-react';
|
||||
import defaultApps from '~/logic/lib/default-apps';
|
||||
import Sigil from '~/logic/lib/sigil';
|
||||
import { uxToHex } from '~/logic/lib/util';
|
||||
import { uxToHex, cite } from '~/logic/lib/util';
|
||||
|
||||
export class OmniboxResult extends Component {
|
||||
constructor(props) {
|
||||
@ -87,25 +87,29 @@ export class OmniboxResult extends Component {
|
||||
}
|
||||
onClick={navigate}
|
||||
width="100%"
|
||||
justifyContent="space-between"
|
||||
ref={this.result}
|
||||
>
|
||||
<Box display="flex" verticalAlign="middle" maxWidth="60%" flexShrink={0}>
|
||||
{graphic}
|
||||
<Text
|
||||
display="inline-block"
|
||||
verticalAlign="middle"
|
||||
mono={(icon == 'profile' && text.startsWith('~'))}
|
||||
color={this.state.hovered || selected === link ? 'white' : 'black'}
|
||||
maxWidth="60%"
|
||||
style={{ flexShrink: 0 }}
|
||||
mr='1'
|
||||
>
|
||||
{text}
|
||||
{text.startsWith("~") ? cite(text) : text}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text pr='2'
|
||||
display="inline-block"
|
||||
verticalAlign="middle"
|
||||
color={this.state.hovered || selected === link ? 'white' : 'black'}
|
||||
width='100%'
|
||||
minWidth={0}
|
||||
textOverflow="ellipsis"
|
||||
whiteSpace="pre"
|
||||
overflow="hidden"
|
||||
maxWidth="40%"
|
||||
textAlign='right'
|
||||
>
|
||||
{subtext}
|
||||
|
@ -151,7 +151,7 @@ export function GraphPermissions(props: GraphPermissionsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const schema = formSchema(Array.from(group.members));
|
||||
const schema = formSchema(Array.from(group?.members ?? []));
|
||||
|
||||
return (
|
||||
<Formik
|
||||
|
@ -22,6 +22,7 @@ import { isChannelAdmin, isHost } from '~/logic/lib/group';
|
||||
|
||||
interface ChannelPopoverRoutesProps {
|
||||
baseUrl: string;
|
||||
rootUrl: string;
|
||||
association: Association;
|
||||
group: Group;
|
||||
groups: Groups;
|
||||
@ -51,8 +52,8 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
|
||||
};
|
||||
const handleArchive = async () => {
|
||||
const [,,,name] = association.resource.split('/');
|
||||
await api.graph.deleteGraph(name);
|
||||
history.push(props.baseUrl);
|
||||
api.graph.deleteGraph(name);
|
||||
return history.push(props.rootUrl);
|
||||
};
|
||||
|
||||
const canAdmin = isChannelAdmin(group, association.resource);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { Component } from 'react';
|
||||
import React, { Component, useEffect } from 'react';
|
||||
import { Box } from '@tlon/indigo-react';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import styled from 'styled-components';
|
||||
@ -12,6 +12,8 @@ import ErrorComponent from '~/views/components/Error';
|
||||
import Notifications from '~/views/apps/notifications/notifications';
|
||||
import GraphApp from '../../apps/graph/app';
|
||||
|
||||
import { useMigrateSettings } from '~/logic/lib/migrateSettings';
|
||||
|
||||
|
||||
export const Container = styled(Box)`
|
||||
flex-grow: 1;
|
||||
@ -22,6 +24,14 @@ export const Container = styled(Box)`
|
||||
|
||||
|
||||
export const Content = (props) => {
|
||||
|
||||
const doMigrate = useMigrateSettings();
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
doMigrate();
|
||||
}, 10000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Switch>
|
||||
|
@ -206,7 +206,7 @@ export function GroupsPane(props: GroupsPaneProps) {
|
||||
resource={groupAssociation.group}
|
||||
/>;
|
||||
} else {
|
||||
summary = (<Box p="4"><Text fontSize="0" color='gray'>
|
||||
summary = (<Box p="4"><Text color='gray'>
|
||||
Create or select a channel to get started
|
||||
</Text></Box>);
|
||||
}
|
||||
|
@ -115,10 +115,14 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps): ReactE
|
||||
|
||||
return (
|
||||
<Col overflowY="auto" p={3} backgroundColor="white">
|
||||
<Box pb='3' display={['block', 'none']} onClick={() => history.push(props.baseUrl)}>
|
||||
<Text fontSize='0' bold>{'<- Back'}</Text>
|
||||
<Box
|
||||
pb='3'
|
||||
display={workspace?.type === 'messages' ? 'none' : ['block', 'none']}
|
||||
onClick={() => history.push(props.baseUrl)}
|
||||
>
|
||||
<Text>{'<- Back'}</Text>
|
||||
</Box>
|
||||
<Box color="black">
|
||||
<Box>
|
||||
<Text fontSize={2} bold>{workspace?.type === 'messages' ? 'Direct Message' : 'New Channel'}</Text>
|
||||
</Box>
|
||||
<Formik
|
||||
|
@ -29,7 +29,7 @@ import { roleForShip, resourceFromPath } from '~/logic/lib/group';
|
||||
import { Dropdown } from '~/views/components/Dropdown';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction';
|
||||
import useLocalState from '~/logic/state/local';
|
||||
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
|
||||
|
||||
const TruncText = styled(Text)`
|
||||
white-space: nowrap;
|
||||
@ -79,13 +79,14 @@ function getParticipants(cs: Contacts, group: Group) {
|
||||
|
||||
const emptyContact = (patp: string, pending: boolean): Participant => ({
|
||||
nickname: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
bio: '',
|
||||
status: '',
|
||||
color: '',
|
||||
avatar: null,
|
||||
notes: '',
|
||||
website: '',
|
||||
cover: null,
|
||||
groups: [],
|
||||
patp,
|
||||
'last-updated': 0,
|
||||
pending
|
||||
});
|
||||
|
||||
@ -256,9 +257,7 @@ function Participant(props: {
|
||||
}) {
|
||||
const { contact, association, group, api } = props;
|
||||
const { title } = association.metadata;
|
||||
const { hideAvatars, hideNicknames } = useLocalState(
|
||||
({ hideAvatars, hideNicknames }) => ({ hideAvatars, hideNicknames })
|
||||
);
|
||||
const { hideAvatars, hideNicknames } = useSettingsState(selectCalmState);
|
||||
|
||||
const color = uxToHex(contact.color);
|
||||
const isInvite = 'invite' in group.policy;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { ReactElement, ReactNode } from 'react';
|
||||
import { Icon, Box, Col, Text } from '@tlon/indigo-react';
|
||||
import { Icon, Box, Col, Row, Text } from '@tlon/indigo-react';
|
||||
import styled from 'styled-components';
|
||||
import { Link } from 'react-router-dom';
|
||||
import urbitOb from 'urbit-ob';
|
||||
@ -12,7 +12,7 @@ import GlobalApi from '~/logic/api/global';
|
||||
import { isWriter } from '~/logic/lib/group';
|
||||
import { getItemTitle } from '~/logic/lib/util';
|
||||
|
||||
const TruncatedBox = styled(Box)`
|
||||
const TruncatedText = styled(RichText)`
|
||||
white-space: pre;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
@ -46,11 +46,13 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
|
||||
? getItemTitle(association)
|
||||
: association?.metadata?.title;
|
||||
|
||||
let recipient = false;
|
||||
let recipient = "";
|
||||
|
||||
if (urbitOb.isValidPatp(title)) {
|
||||
recipient = title;
|
||||
title = (props.contacts?.[title]?.nickname) ? props.contacts[title].nickname : title;
|
||||
} else {
|
||||
recipient = Array.from(group.members).map(e => `~${e}`).join(", ")
|
||||
}
|
||||
|
||||
const [, , ship, resource] = rid.split('/');
|
||||
@ -88,7 +90,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
|
||||
>
|
||||
<Link to={`/~landscape${workspace}`}> {'<- Back'}</Link>
|
||||
</Box>
|
||||
<Box px={1} mr={2} minWidth={0} display="flex">
|
||||
<Box px={1} mr={2} minWidth={0} display="flex" flexShrink={[1, 0]}>
|
||||
<Text
|
||||
mono={urbitOb.isValidPatp(title)}
|
||||
fontSize='2'
|
||||
@ -99,29 +101,31 @@ export function ResourceSkeleton(props: ResourceSkeletonProps): ReactElement {
|
||||
overflow="hidden"
|
||||
whiteSpace="pre"
|
||||
minWidth={0}
|
||||
flexShrink={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<TruncatedBox
|
||||
display={['none', 'block']}
|
||||
<Row
|
||||
display={['none', 'flex']}
|
||||
verticalAlign="middle"
|
||||
maxWidth='60%'
|
||||
flexShrink={1}
|
||||
flexShrink={2}
|
||||
minWidth={0}
|
||||
title={association?.metadata?.description}
|
||||
color="gray"
|
||||
>
|
||||
<RichText
|
||||
<TruncatedText
|
||||
display={(workspace === '/messages' && (urbitOb.isValidPatp(title))) ? 'none' : 'inline-block'}
|
||||
mono={(workspace === '/messages' && !(urbitOb.isValidPatp(title)))}
|
||||
color="gray"
|
||||
minWidth={0}
|
||||
width="100%"
|
||||
mb="0"
|
||||
disableRemoteContent
|
||||
>
|
||||
{(workspace === '/messages') ? recipient : association?.metadata?.description}
|
||||
</RichText>
|
||||
</TruncatedBox>
|
||||
<Box flexGrow={1} />
|
||||
</TruncatedText>
|
||||
</Row>
|
||||
<Box flexGrow={1} flexShrink={0} />
|
||||
{canWrite && (
|
||||
<Link to={resourcePath('/new')} style={{ flexShrink: '0' }}>
|
||||
<Text bold pr='3' color='blue'>+ New Post</Text>
|
||||
|
@ -8,10 +8,11 @@ import { HoverBoxLink } from '~/views/components/HoverBox';
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import { getModuleIcon, getItemTitle, uxToHex } from '~/logic/lib/util';
|
||||
import { useTutorialModal } from '~/views/components/useTutorialModal';
|
||||
import useLocalState from '~/logic/state/local';
|
||||
import { TUTORIAL_HOST, TUTORIAL_GROUP } from '~/logic/lib/tutorialModal';
|
||||
import { SidebarAppConfigs, SidebarItemStatus } from './types';
|
||||
import { Workspace } from '~/types/workspace';
|
||||
import useSettingsState, { selectCalmState } from '~/logic/state/settings';
|
||||
|
||||
|
||||
function SidebarItemIndicator(props: { status?: SidebarItemStatus }) {
|
||||
switch (props.status) {
|
||||
@ -56,9 +57,8 @@ export function SidebarItem(props: {
|
||||
return null;
|
||||
}
|
||||
const DM = (isUnmanaged && props.workspace?.type === 'messages');
|
||||
const { hideAvatars, hideNicknames } = useLocalState(({ hideAvatars, hideNicknames }) => ({
|
||||
hideAvatars, hideNicknames
|
||||
}));
|
||||
const { hideAvatars, hideNicknames } = useSettingsState(selectCalmState);
|
||||
|
||||
const itemStatus = app.getStatus(path);
|
||||
const hasUnread = itemStatus === 'unread' || itemStatus === 'mention';
|
||||
|
||||
|
@ -34,7 +34,7 @@ export const SidebarItem = ({
|
||||
justifyContent="space-between"
|
||||
{...rest}
|
||||
>
|
||||
<Row>
|
||||
<Row alignItems="center">
|
||||
<Icon color={color} icon={icon as any} mr="2" />
|
||||
<Text color={color}>{text}</Text>
|
||||
</Row>
|
||||
|
@ -1,50 +1,2 @@
|
||||
import { Enc, Path, Patp, Poke } from "..";
|
||||
import {
|
||||
Contact,
|
||||
ContactUpdateAdd,
|
||||
ContactUpdateEdit,
|
||||
ContactUpdateRemove,
|
||||
ContactEditField,
|
||||
ContactShare,
|
||||
ContactUpdate,
|
||||
} from "./index.d";
|
||||
|
||||
export const storeAction = <T extends ContactUpdate>(data: T): Poke<T> => ({
|
||||
app: "contact-store",
|
||||
mark: "contact-action",
|
||||
json: data,
|
||||
});
|
||||
|
||||
export const add = (ship: Patp, contact: Contact): Poke<ContactUpdateAdd> => {
|
||||
contact["last-updated"] = Date.now();
|
||||
|
||||
return storeAction({
|
||||
add: { ship, contact },
|
||||
});
|
||||
};
|
||||
|
||||
export const remove = (ship: Patp): Poke<ContactUpdateRemove> =>
|
||||
storeAction({
|
||||
remove: { ship },
|
||||
});
|
||||
|
||||
export const share = (recipient: Patp): Poke<ContactShare> => ({
|
||||
app: "contact-push-hook",
|
||||
mark: "contact-action",
|
||||
json: { share: recipient },
|
||||
});
|
||||
|
||||
export const edit = (
|
||||
path: Path,
|
||||
ship: Patp,
|
||||
editField: ContactEditField
|
||||
): Poke<ContactUpdateEdit> =>
|
||||
storeAction({
|
||||
edit: {
|
||||
path,
|
||||
ship,
|
||||
"edit-field": editField,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
export * from './types';
|
||||
export * from './lib';
|
94
pkg/npm/api/contacts/lib.ts
Normal file
94
pkg/npm/api/contacts/lib.ts
Normal file
@ -0,0 +1,94 @@
|
||||
|
||||
import { Path, Patp, Poke, resourceAsPath } from "../lib";
|
||||
import {
|
||||
Contact,
|
||||
ContactUpdateAdd,
|
||||
ContactUpdateEdit,
|
||||
ContactUpdateRemove,
|
||||
ContactEditField,
|
||||
ContactShare,
|
||||
ContactUpdate,
|
||||
ContactUpdateAllowShips,
|
||||
ContactUpdateAllowGroup,
|
||||
ContactUpdateSetPublic,
|
||||
} from "./types";
|
||||
|
||||
const storeAction = <T extends ContactUpdate>(data: T): Poke<T> => ({
|
||||
app: "contact-store",
|
||||
mark: "contact-action",
|
||||
json: data,
|
||||
});
|
||||
|
||||
export { storeAction as contactStoreAction };
|
||||
|
||||
export const addContact = (ship: Patp, contact: Contact): Poke<ContactUpdateAdd> => {
|
||||
contact["last-updated"] = Date.now();
|
||||
|
||||
return storeAction({
|
||||
add: { ship, contact },
|
||||
});
|
||||
};
|
||||
|
||||
export const removeContact = (ship: Patp): Poke<ContactUpdateRemove> =>
|
||||
storeAction({
|
||||
remove: { ship },
|
||||
});
|
||||
|
||||
export const share = (recipient: Patp): Poke<ContactShare> => ({
|
||||
app: "contact-push-hook",
|
||||
mark: "contact-action",
|
||||
json: { share: recipient },
|
||||
});
|
||||
|
||||
export const editContact = (
|
||||
ship: Patp,
|
||||
editField: ContactEditField
|
||||
): Poke<ContactUpdateEdit> =>
|
||||
storeAction({
|
||||
edit: {
|
||||
ship,
|
||||
"edit-field": editField,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
export const allowShips = (
|
||||
ships: Patp[]
|
||||
): Poke<ContactUpdateAllowShips> => storeAction({
|
||||
allow: {
|
||||
ships
|
||||
}
|
||||
});
|
||||
|
||||
export const allowGroup = (
|
||||
ship: string,
|
||||
name: string
|
||||
): Poke<ContactUpdateAllowGroup> => storeAction({
|
||||
allow: {
|
||||
group: resourceAsPath({ ship, name })
|
||||
}
|
||||
});
|
||||
|
||||
export const setPublic = (
|
||||
setPublic: any
|
||||
): Poke<ContactUpdateSetPublic> => {
|
||||
return storeAction({
|
||||
'set-public': setPublic
|
||||
});
|
||||
}
|
||||
|
||||
export const retrieve = (
|
||||
ship: string
|
||||
) => {
|
||||
const resource = { ship, name: '' };
|
||||
return {
|
||||
app: 'contact-pull-hook',
|
||||
mark: 'pull-hook-action',
|
||||
json: {
|
||||
add: {
|
||||
resource,
|
||||
ship
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
@ -1,47 +1,49 @@
|
||||
import { Path, Patp } from "..";
|
||||
import {Resource} from "../groups/update.d";
|
||||
import { Resource } from "../groups";
|
||||
|
||||
export type ContactUpdate =
|
||||
| ContactUpdateAdd
|
||||
| ContactUpdateRemove
|
||||
| ContactUpdateEdit
|
||||
| ContactUpdateInitial
|
||||
| ContactUpdateAllowGroup
|
||||
| ContactUpdateAllowShips
|
||||
| ContactUpdateSetPublic;
|
||||
|
||||
interface ContactUpdateAdd {
|
||||
export interface ContactUpdateAdd {
|
||||
add: {
|
||||
ship: Patp;
|
||||
contact: Contact;
|
||||
};
|
||||
}
|
||||
|
||||
interface ContactUpdateRemove {
|
||||
export interface ContactUpdateRemove {
|
||||
remove: {
|
||||
ship: Patp;
|
||||
};
|
||||
}
|
||||
|
||||
interface ContactUpdateEdit {
|
||||
export interface ContactUpdateEdit {
|
||||
edit: {
|
||||
path: Path;
|
||||
ship: Patp;
|
||||
"edit-field": ContactEditField;
|
||||
timestamp: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ContactUpdateAllowShips {
|
||||
export interface ContactUpdateAllowShips {
|
||||
allow: {
|
||||
ships: Patp[];
|
||||
}
|
||||
}
|
||||
|
||||
interface ContactUpdateAllowGroup {
|
||||
export interface ContactUpdateAllowGroup {
|
||||
allow: {
|
||||
group: Path;
|
||||
}
|
||||
}
|
||||
|
||||
interface ContactUpdateSetPublic {
|
||||
export interface ContactUpdateSetPublic {
|
||||
'set-public': boolean;
|
||||
}
|
||||
|
||||
@ -49,7 +51,7 @@ export interface ContactShare {
|
||||
share: Patp;
|
||||
}
|
||||
|
||||
interface ContactUpdateInitial {
|
||||
export interface ContactUpdateInitial {
|
||||
initial: Rolodex;
|
||||
}
|
||||
|
||||
@ -57,6 +59,8 @@ export type Rolodex = {
|
||||
[p in Patp]: Contact;
|
||||
};
|
||||
|
||||
export type Contacts = Rolodex;
|
||||
|
||||
export interface Contact {
|
||||
nickname: string;
|
||||
bio: string;
|
@ -1,370 +1,2 @@
|
||||
import _ from 'lodash';
|
||||
import { PatpNoSig, Patp, Poke, Thread, Path, Enc } from '..';
|
||||
import { Content, GraphNode, Post, GraphNodePoke, GraphChildrenPoke } from './index.d';
|
||||
import { deSig, unixToDa } from '../lib/util';
|
||||
import { makeResource, resourceFromPath } from '../groups/index';
|
||||
import { GroupPolicy } from '../groups/update.d';
|
||||
|
||||
export const createBlankNodeWithChildPost = (
|
||||
ship: PatpNoSig,
|
||||
parentIndex: string = '',
|
||||
childIndex: string = '',
|
||||
contents: Content[]
|
||||
): GraphNodePoke => {
|
||||
const date = unixToDa(Date.now()).toString();
|
||||
const nodeIndex = parentIndex + '/' + date;
|
||||
|
||||
const childGraph: GraphChildrenPoke = {};
|
||||
childGraph[childIndex] = {
|
||||
post: {
|
||||
author: `~${ship}`,
|
||||
index: nodeIndex + '/' + childIndex,
|
||||
'time-sent': Date.now(),
|
||||
contents,
|
||||
hash: null,
|
||||
signatures: []
|
||||
},
|
||||
children: null
|
||||
};
|
||||
|
||||
return {
|
||||
post: {
|
||||
author: `~${ship}`,
|
||||
index: nodeIndex,
|
||||
'time-sent': Date.now(),
|
||||
contents: [],
|
||||
hash: null,
|
||||
signatures: []
|
||||
},
|
||||
children: childGraph
|
||||
};
|
||||
};
|
||||
|
||||
function markPending(nodes: any) {
|
||||
_.forEach(nodes, node => {
|
||||
node.post.author = deSig(node.post.author);
|
||||
node.post.pending = true;
|
||||
markPending(node.children || {});
|
||||
});
|
||||
}
|
||||
|
||||
export const createPost = (
|
||||
ship: PatpNoSig,
|
||||
contents: Content[],
|
||||
parentIndex: string = '',
|
||||
childIndex:string = 'DATE_PLACEHOLDER'
|
||||
): Post => {
|
||||
if (childIndex === 'DATE_PLACEHOLDER') {
|
||||
childIndex = unixToDa(Date.now()).toString();
|
||||
}
|
||||
return {
|
||||
author: `~${ship}`,
|
||||
index: parentIndex + '/' + childIndex,
|
||||
'time-sent': Date.now(),
|
||||
contents,
|
||||
hash: null,
|
||||
signatures: []
|
||||
};
|
||||
};
|
||||
|
||||
function moduleToMark(mod: string): string | undefined {
|
||||
if(mod === 'link') {
|
||||
return 'graph-validator-link';
|
||||
}
|
||||
if(mod === 'publish') {
|
||||
return 'graph-validator-publish';
|
||||
}
|
||||
if(mod === 'chat') {
|
||||
return 'graph-validator-chat';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const storeAction = <T>(data: T): Poke<T> => ({
|
||||
app: 'graph-store',
|
||||
mark: 'graph-update',
|
||||
json: data
|
||||
});
|
||||
|
||||
export { storeAction as graphStoreAction };
|
||||
|
||||
const viewAction = <T>(threadName: string, action: T): Thread<T> => ({
|
||||
inputMark: 'graph-view-action',
|
||||
outputMark: 'json',
|
||||
threadName,
|
||||
body: action
|
||||
});
|
||||
|
||||
export { viewAction as graphViewAction };
|
||||
|
||||
const hookAction = <T>(data: T): Poke<T> => ({
|
||||
app: 'graph-push-hook',
|
||||
mark: 'graph-update',
|
||||
json: data
|
||||
});
|
||||
|
||||
export { hookAction as graphHookAction };
|
||||
|
||||
|
||||
export const createManagedGraph = (
|
||||
ship: PatpNoSig,
|
||||
name: string,
|
||||
title: string,
|
||||
description: string,
|
||||
group: Path,
|
||||
mod: string
|
||||
): Thread<any> => {
|
||||
const associated = { group: resourceFromPath(group) };
|
||||
const resource = makeResource(`~${ship}`, name);
|
||||
|
||||
return viewAction('graph-create', {
|
||||
create: {
|
||||
resource,
|
||||
title,
|
||||
description,
|
||||
associated,
|
||||
module: mod,
|
||||
mark: moduleToMark(mod)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const createUnmanagedGraph = (
|
||||
ship: PatpNoSig,
|
||||
name: string,
|
||||
title: string,
|
||||
description: string,
|
||||
policy: Enc<GroupPolicy>,
|
||||
mod: string
|
||||
): Thread<any> => {
|
||||
const resource = makeResource(`~${ship}`, name);
|
||||
|
||||
return viewAction('graph-create', {
|
||||
create: {
|
||||
resource,
|
||||
title,
|
||||
description,
|
||||
associated: { policy },
|
||||
module: mod,
|
||||
mark: moduleToMark(mod)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const joinGraph = (
|
||||
ship: Patp,
|
||||
name: string
|
||||
): Thread<any> => {
|
||||
const resource = makeResource(ship, name);
|
||||
return viewAction('graph-join', {
|
||||
join: {
|
||||
resource,
|
||||
ship,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const deleteGraph = (
|
||||
ship: PatpNoSig,
|
||||
name: string
|
||||
): Thread<any> => {
|
||||
const resource = makeResource(`~${ship}`, name);
|
||||
return viewAction('graph-delete', {
|
||||
"delete": {
|
||||
resource
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const leaveGraph = (
|
||||
ship: Patp,
|
||||
name: string
|
||||
): Thread<any> => {
|
||||
const resource = makeResource(ship, name);
|
||||
return viewAction('graph-leave', {
|
||||
"leave": {
|
||||
resource
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const groupifyGraph = (
|
||||
ship: Patp,
|
||||
name: string,
|
||||
toPath?: string
|
||||
): Thread<any> => {
|
||||
const resource = makeResource(ship, name);
|
||||
const to = toPath && resourceFromPath(toPath);
|
||||
|
||||
return viewAction('graph-groupify', {
|
||||
groupify: {
|
||||
resource,
|
||||
to
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const evalCord = (
|
||||
cord: string
|
||||
): Thread<any> => {
|
||||
return ({
|
||||
inputMark: 'graph-view-action',
|
||||
outputMark: 'tang',
|
||||
threadName: 'graph-eval',
|
||||
body: {
|
||||
eval: cord
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const addGraph = (
|
||||
ship: Patp,
|
||||
name: string,
|
||||
graph: any,
|
||||
mark: any
|
||||
): Poke<any> => {
|
||||
return storeAction({
|
||||
'add-graph': {
|
||||
resource: { ship, name },
|
||||
graph,
|
||||
mark
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const addPost = (
|
||||
ship: Patp,
|
||||
name: string,
|
||||
post: Post
|
||||
) => {
|
||||
let nodes = {};
|
||||
nodes[post.index] = {
|
||||
post,
|
||||
children: null
|
||||
};
|
||||
return addNodes(ship, name, nodes);
|
||||
}
|
||||
|
||||
export const addNode = (
|
||||
ship: Patp,
|
||||
name: string,
|
||||
node: GraphNode
|
||||
) => {
|
||||
let nodes = {};
|
||||
nodes[node.post.index] = node;
|
||||
|
||||
return addNodes(ship, name, nodes);
|
||||
}
|
||||
|
||||
export const addNodes = (
|
||||
ship: Patp,
|
||||
name: string,
|
||||
nodes: Object
|
||||
): Poke<any> => {
|
||||
const action = {
|
||||
'add-nodes': {
|
||||
resource: { ship, name },
|
||||
nodes
|
||||
}
|
||||
};
|
||||
|
||||
markPending(action['add-nodes'].nodes);
|
||||
action['add-nodes'].resource.ship = action['add-nodes'].resource.ship.slice(1);
|
||||
// this.store.handleEvent({ data: { 'graph-update': action } });// TODO address this.store
|
||||
return hookAction(action);
|
||||
}
|
||||
|
||||
export const removeNodes = (
|
||||
ship: Patp,
|
||||
name: string,
|
||||
indices: string[]
|
||||
): Poke<any> => {
|
||||
return hookAction({
|
||||
'remove-nodes': {
|
||||
resource: { ship, name },
|
||||
indices
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO these abominations
|
||||
// getKeys() {
|
||||
// return this.scry<any>('graph-store', '/keys')
|
||||
// .then((keys) => {
|
||||
// this.store.handleEvent({
|
||||
// data: keys
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
// getTags() {
|
||||
// return this.scry<any>('graph-store', '/tags')
|
||||
// .then((tags) => {
|
||||
// this.store.handleEvent({
|
||||
// data: tags
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
// getTagQueries() {
|
||||
// return this.scry<any>('graph-store', '/tag-queries')
|
||||
// .then((tagQueries) => {
|
||||
// this.store.handleEvent({
|
||||
// data: tagQueries
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
// getGraph(ship: string, resource: string) {
|
||||
// return this.scry<any>('graph-store', `/graph/${ship}/${resource}`)
|
||||
// .then((graph) => {
|
||||
// this.store.handleEvent({
|
||||
// data: graph
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
// async getNewest(ship: string, resource: string, count: number, index = '') {
|
||||
// const data = await this.scry<any>('graph-store', `/newest/${ship}/${resource}/${count}${index}`);
|
||||
// this.store.handleEvent({ data });
|
||||
// }
|
||||
|
||||
// async getOlderSiblings(ship: string, resource: string, count: number, index = '') {
|
||||
// const idx = index.split('/').map(decToUd).join('/');
|
||||
// const data = await this.scry<any>('graph-store',
|
||||
// `/node-siblings/older/${ship}/${resource}/${count}${idx}`
|
||||
// );
|
||||
// this.store.handleEvent({ data });
|
||||
// }
|
||||
|
||||
// async getYoungerSiblings(ship: string, resource: string, count: number, index = '') {
|
||||
// const idx = index.split('/').map(decToUd).join('/');
|
||||
// const data = await this.scry<any>('graph-store',
|
||||
// `/node-siblings/younger/${ship}/${resource}/${count}${idx}`
|
||||
// );
|
||||
// this.store.handleEvent({ data });
|
||||
// }
|
||||
|
||||
|
||||
// getGraphSubset(ship: string, resource: string, start: string, end: string) {
|
||||
// return this.scry<any>(
|
||||
// 'graph-store',
|
||||
// `/graph-subset/${ship}/${resource}/${end}/${start}`
|
||||
// ).then((subset) => {
|
||||
// this.store.handleEvent({
|
||||
// data: subset
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
|
||||
// getNode(ship: string, resource: string, index: string) {
|
||||
// const idx = index.split('/').map(numToUd).join('/');
|
||||
// return this.scry<any>(
|
||||
// 'graph-store',
|
||||
// `/node/${ship}/${resource}${idx}`
|
||||
// ).then((node) => {
|
||||
// this.store.handleEvent({
|
||||
// data: node
|
||||
// });
|
||||
// });
|
||||
// }
|
||||
export * from './lib';
|
||||
export * from './types';
|
271
pkg/npm/api/graph/lib.ts
Normal file
271
pkg/npm/api/graph/lib.ts
Normal file
@ -0,0 +1,271 @@
|
||||
import _ from 'lodash';
|
||||
import { GroupPolicy, makeResource, resourceFromPath } from '../groups';
|
||||
|
||||
import { deSig, unixToDa } from '../lib';
|
||||
import { Enc, Path, Patp, PatpNoSig, Poke, Thread } from '../lib/types';
|
||||
import { Content, GraphChildrenPoke, GraphNode, GraphNodePoke, Post } from './types';
|
||||
|
||||
export const createBlankNodeWithChildPost = (
|
||||
ship: PatpNoSig,
|
||||
parentIndex: string = '',
|
||||
childIndex: string = '',
|
||||
contents: Content[]
|
||||
): any => { // TODO should be GraphNode
|
||||
const date = unixToDa(Date.now()).toString();
|
||||
const nodeIndex = parentIndex + '/' + date;
|
||||
|
||||
const childGraph: GraphChildrenPoke = {};
|
||||
childGraph[childIndex] = {
|
||||
post: {
|
||||
author: `~${ship}`,
|
||||
index: nodeIndex + '/' + childIndex,
|
||||
'time-sent': Date.now(),
|
||||
contents,
|
||||
hash: null,
|
||||
signatures: []
|
||||
},
|
||||
children: null
|
||||
};
|
||||
|
||||
return {
|
||||
post: {
|
||||
author: `~${ship}`,
|
||||
index: nodeIndex,
|
||||
'time-sent': Date.now(),
|
||||
contents: [],
|
||||
hash: null,
|
||||
signatures: []
|
||||
},
|
||||
children: childGraph
|
||||
};
|
||||
};
|
||||
|
||||
export const markPending = (nodes: any): void => {
|
||||
_.forEach(nodes, node => {
|
||||
node.post.author = deSig(node.post.author);
|
||||
node.post.pending = true;
|
||||
markPending(node.children || {});
|
||||
});
|
||||
};
|
||||
|
||||
export const createPost = (
|
||||
ship: PatpNoSig,
|
||||
contents: Content[],
|
||||
parentIndex: string = '',
|
||||
childIndex:string = 'DATE_PLACEHOLDER'
|
||||
): Post => {
|
||||
if (childIndex === 'DATE_PLACEHOLDER') {
|
||||
childIndex = unixToDa(Date.now()).toString();
|
||||
}
|
||||
return {
|
||||
author: `~${ship}`,
|
||||
index: parentIndex + '/' + childIndex,
|
||||
'time-sent': Date.now(),
|
||||
contents,
|
||||
hash: null,
|
||||
signatures: []
|
||||
};
|
||||
};
|
||||
|
||||
function moduleToMark(mod: string): string | undefined {
|
||||
if(mod === 'link') {
|
||||
return 'graph-validator-link';
|
||||
}
|
||||
if(mod === 'publish') {
|
||||
return 'graph-validator-publish';
|
||||
}
|
||||
if(mod === 'chat') {
|
||||
return 'graph-validator-chat';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const storeAction = <T>(data: T): Poke<T> => ({
|
||||
app: 'graph-store',
|
||||
mark: 'graph-update',
|
||||
json: data
|
||||
});
|
||||
|
||||
export { storeAction as graphStoreAction };
|
||||
|
||||
const viewAction = <T>(threadName: string, action: T): Thread<T> => ({
|
||||
inputMark: 'graph-view-action',
|
||||
outputMark: 'json',
|
||||
threadName,
|
||||
body: action
|
||||
});
|
||||
|
||||
export { viewAction as graphViewAction };
|
||||
|
||||
const hookAction = <T>(data: T): Poke<T> => ({
|
||||
app: 'graph-push-hook',
|
||||
mark: 'graph-update',
|
||||
json: data
|
||||
});
|
||||
|
||||
export { hookAction as graphHookAction };
|
||||
|
||||
|
||||
export const createManagedGraph = (
|
||||
ship: PatpNoSig,
|
||||
name: string,
|
||||
title: string,
|
||||
description: string,
|
||||
group: Path,
|
||||
mod: string
|
||||
): Thread<any> => {
|
||||
const associated = { group: resourceFromPath(group) };
|
||||
const resource = makeResource(`~${ship}`, name);
|
||||
|
||||
return viewAction('graph-create', {
|
||||
create: {
|
||||
resource,
|
||||
title,
|
||||
description,
|
||||
associated,
|
||||
module: mod,
|
||||
mark: moduleToMark(mod)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const createUnmanagedGraph = (
|
||||
ship: PatpNoSig,
|
||||
name: string,
|
||||
title: string,
|
||||
description: string,
|
||||
policy: Enc<GroupPolicy>,
|
||||
mod: string
|
||||
): Thread<any> => viewAction('graph-create', {
|
||||
create: {
|
||||
resource: makeResource(`~${ship}`, name),
|
||||
title,
|
||||
description,
|
||||
associated: { policy },
|
||||
module: mod,
|
||||
mark: moduleToMark(mod)
|
||||
}
|
||||
});
|
||||
|
||||
export const joinGraph = (
|
||||
ship: Patp,
|
||||
name: string
|
||||
): Thread<any> => viewAction('graph-join', {
|
||||
join: {
|
||||
resource: makeResource(ship, name),
|
||||
ship,
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteGraph = (
|
||||
ship: PatpNoSig,
|
||||
name: string
|
||||
): Thread<any> => viewAction('graph-delete', {
|
||||
"delete": {
|
||||
resource: makeResource(`~${ship}`, name)
|
||||
}
|
||||
});
|
||||
|
||||
export const leaveGraph = (
|
||||
ship: Patp,
|
||||
name: string
|
||||
): Thread<any> => viewAction('graph-leave', {
|
||||
"leave": {
|
||||
resource: makeResource(ship, name)
|
||||
}
|
||||
});
|
||||
|
||||
export const groupifyGraph = (
|
||||
ship: Patp,
|
||||
name: string,
|
||||
toPath?: string
|
||||
): Thread<any> => {
|
||||
const resource = makeResource(ship, name);
|
||||
const to = toPath && resourceFromPath(toPath);
|
||||
|
||||
return viewAction('graph-groupify', {
|
||||
groupify: {
|
||||
resource,
|
||||
to
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const evalCord = (
|
||||
cord: string
|
||||
): Thread<any> => {
|
||||
return ({
|
||||
inputMark: 'graph-view-action',
|
||||
outputMark: 'tang',
|
||||
threadName: 'graph-eval',
|
||||
body: {
|
||||
eval: cord
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const addGraph = (
|
||||
ship: Patp,
|
||||
name: string,
|
||||
graph: any,
|
||||
mark: any
|
||||
): Poke<any> => {
|
||||
return storeAction({
|
||||
'add-graph': {
|
||||
resource: { ship, name },
|
||||
graph,
|
||||
mark
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const addNodes = (
|
||||
ship: Patp,
|
||||
name: string,
|
||||
nodes: Object
|
||||
): Poke<any> => {
|
||||
const action = {
|
||||
'add-nodes': {
|
||||
resource: { ship, name },
|
||||
nodes
|
||||
}
|
||||
};
|
||||
|
||||
return hookAction(action);
|
||||
};
|
||||
|
||||
export const addPost = (
|
||||
ship: Patp,
|
||||
name: string,
|
||||
post: Post
|
||||
) => {
|
||||
let nodes: Record<string, GraphNode> = {};
|
||||
nodes[post.index] = {
|
||||
post,
|
||||
children: null
|
||||
};
|
||||
return addNodes(ship, name, nodes);
|
||||
}
|
||||
|
||||
export const addNode = (
|
||||
ship: Patp,
|
||||
name: string,
|
||||
node: GraphNode
|
||||
): Poke<any> => {
|
||||
let nodes: Record<string, GraphNode> = {};
|
||||
nodes[node.post.index] = node;
|
||||
|
||||
return addNodes(ship, name, nodes);
|
||||
}
|
||||
|
||||
|
||||
export const removeNodes = (
|
||||
ship: Patp,
|
||||
name: string,
|
||||
indices: string[]
|
||||
): Poke<any> => hookAction({
|
||||
'remove-nodes': {
|
||||
resource: { ship, name },
|
||||
indices
|
||||
}
|
||||
});
|
@ -9,8 +9,8 @@ export interface UrlContent {
|
||||
}
|
||||
export interface CodeContent {
|
||||
code: {
|
||||
expresssion: string;
|
||||
output: string | undefined;
|
||||
expression: string;
|
||||
output: string[] | undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ export interface GraphChildrenPoke {
|
||||
}
|
||||
|
||||
export interface GraphNode {
|
||||
children: Graph;
|
||||
children: Graph | null;
|
||||
post: Post;
|
||||
}
|
||||
|
2
pkg/npm/api/groups/index.d.ts
vendored
2
pkg/npm/api/groups/index.d.ts
vendored
@ -1,2 +0,0 @@
|
||||
export * from './update.d';
|
||||
export * from './view.d';
|
@ -1,117 +1,2 @@
|
||||
import { Enc, Path, Patp, PatpNoSig, Poke } from "..";
|
||||
import {
|
||||
Group,
|
||||
GroupAction,
|
||||
GroupPolicyDiff,
|
||||
GroupUpdateAddMembers,
|
||||
GroupUpdateAddTag,
|
||||
GroupUpdateChangePolicy,
|
||||
GroupUpdateRemoveGroup,
|
||||
GroupUpdateRemoveMembers,
|
||||
GroupUpdateRemoveTag,
|
||||
Resource,
|
||||
Tag
|
||||
} from "./index.d";
|
||||
import { GroupPolicy } from "./update";
|
||||
|
||||
export const proxyAction = <T>(data: T): Poke<T> => ({
|
||||
app: 'group-push-hook',
|
||||
mark: 'group-update',
|
||||
json: data
|
||||
});
|
||||
|
||||
export const storeAction = <T>(data: T): Poke<T> => ({
|
||||
app: 'group-store',
|
||||
mark: 'group-update',
|
||||
json: data
|
||||
});
|
||||
|
||||
export const remove = (
|
||||
resource: Resource,
|
||||
ships: PatpNoSig[]
|
||||
): Poke<GroupUpdateRemoveMembers> => proxyAction({
|
||||
removeMembers: {
|
||||
resource,
|
||||
ships
|
||||
}
|
||||
});
|
||||
|
||||
export const addTag = (
|
||||
resource: Resource,
|
||||
tag: Tag,
|
||||
ships: Patp[]
|
||||
): Poke<GroupUpdateAddTag> => proxyAction({
|
||||
addTag: {
|
||||
resource,
|
||||
tag,
|
||||
ships
|
||||
}
|
||||
});
|
||||
|
||||
export const removeTag = (
|
||||
tag: Tag,
|
||||
resource: Resource,
|
||||
ships: PatpNoSig[]
|
||||
): Poke<GroupUpdateRemoveTag> => proxyAction({
|
||||
removeTag: {
|
||||
tag,
|
||||
resource,
|
||||
ships
|
||||
}
|
||||
});
|
||||
|
||||
export const add = (
|
||||
resource: Resource,
|
||||
ships: PatpNoSig[]
|
||||
): Poke<GroupUpdateAddMembers> => proxyAction({
|
||||
addMembers: {
|
||||
resource,
|
||||
ships
|
||||
}
|
||||
});
|
||||
|
||||
export const removeGroup = (
|
||||
resource: Resource
|
||||
): Poke<GroupUpdateRemoveGroup> => storeAction({
|
||||
removeGroup: {
|
||||
resource
|
||||
}
|
||||
});
|
||||
|
||||
export const changePolicy = (
|
||||
resource: Resource,
|
||||
diff: GroupPolicyDiff
|
||||
): Poke<GroupUpdateChangePolicy> => proxyAction({
|
||||
changePolicy: {
|
||||
resource,
|
||||
diff
|
||||
}
|
||||
});
|
||||
|
||||
export const roleTags = ['janitor', 'moderator', 'admin'];
|
||||
// TODO make this type better?
|
||||
|
||||
export function roleForShip(group: Group, ship: PatpNoSig): string | undefined {
|
||||
return roleTags.reduce((currRole, role) => {
|
||||
const roleShips = group?.tags?.role?.[role];
|
||||
return roleShips && roleShips.has(ship) ? role : currRole;
|
||||
}, undefined as string | undefined);
|
||||
}
|
||||
|
||||
export function resourceFromPath(path: Path): Resource {
|
||||
const [, , ship, name] = path.split('/');
|
||||
return { ship, name }
|
||||
}
|
||||
|
||||
export function makeResource(ship: string, name:string) {
|
||||
return { ship, name };
|
||||
}
|
||||
|
||||
export const groupBunts = {
|
||||
group: (): Group => ({ members: new Set(), tags: { role: {} }, hidden: false, policy: groupBunts.policy() }),
|
||||
policy: (): GroupPolicy => ({ open: { banned: new Set(), banRanks: new Set() } })
|
||||
};
|
||||
|
||||
export const joinError = ['no-perms', 'strange'] as const;
|
||||
export const joinResult = ['done', ...joinError] as const;
|
||||
export const joinProgress = ['start', 'added', ...joinResult] as const;
|
||||
export * from './types';
|
||||
export * from './lib';
|
215
pkg/npm/api/groups/lib.ts
Normal file
215
pkg/npm/api/groups/lib.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Enc, Path, Patp, PatpNoSig, Poke, Thread } from '../lib/types';
|
||||
import { Group, GroupPolicy, GroupPolicyDiff, GroupUpdateAddMembers, GroupUpdateAddTag, GroupUpdateChangePolicy, GroupUpdateRemoveGroup, GroupUpdateRemoveMembers, GroupUpdateRemoveTag, Resource, RoleTags, Tag } from './types';
|
||||
import { GroupUpdate } from './update';
|
||||
|
||||
export const proxyAction = <T>(data: T): Poke<T> => ({
|
||||
app: 'group-push-hook',
|
||||
mark: 'group-update',
|
||||
json: data
|
||||
});
|
||||
|
||||
const storeAction = <T extends GroupUpdate>(data: T): Poke<T> => ({
|
||||
app: 'group-store',
|
||||
mark: 'group-update',
|
||||
json: data
|
||||
});
|
||||
|
||||
export { storeAction as groupStoreAction };
|
||||
|
||||
const viewAction = <T>(data: T): Poke<T> => ({
|
||||
app: 'group-view',
|
||||
mark: 'group-view-action',
|
||||
json: data
|
||||
});
|
||||
|
||||
export { viewAction as groupViewAction };
|
||||
|
||||
export const viewThread = <T>(thread: string, action: T): Thread<T> => ({
|
||||
inputMark: 'group-view-action',
|
||||
outputMark: 'json',
|
||||
threadName: thread,
|
||||
body: action
|
||||
});
|
||||
|
||||
export const removeMembers = (
|
||||
resource: Resource,
|
||||
ships: PatpNoSig[]
|
||||
): Poke<GroupUpdateRemoveMembers> => proxyAction({
|
||||
removeMembers: {
|
||||
resource,
|
||||
ships
|
||||
}
|
||||
});
|
||||
|
||||
export const addTag = (
|
||||
resource: Resource,
|
||||
tag: Tag,
|
||||
ships: Patp[]
|
||||
): Poke<GroupUpdateAddTag> => proxyAction({
|
||||
addTag: {
|
||||
resource,
|
||||
tag,
|
||||
ships
|
||||
}
|
||||
});
|
||||
|
||||
export const removeTag = (
|
||||
tag: Tag,
|
||||
resource: Resource,
|
||||
ships: PatpNoSig[]
|
||||
): Poke<GroupUpdateRemoveTag> => proxyAction({
|
||||
removeTag: {
|
||||
tag,
|
||||
resource,
|
||||
ships
|
||||
}
|
||||
});
|
||||
|
||||
export const addMembers = (
|
||||
resource: Resource,
|
||||
ships: PatpNoSig[]
|
||||
): Poke<GroupUpdateAddMembers> => proxyAction({
|
||||
addMembers: {
|
||||
resource,
|
||||
ships
|
||||
}
|
||||
});
|
||||
|
||||
export const removeGroup = (
|
||||
resource: Resource
|
||||
): Poke<GroupUpdateRemoveGroup> => storeAction({
|
||||
removeGroup: {
|
||||
resource
|
||||
}
|
||||
});
|
||||
|
||||
export const changePolicy = (
|
||||
resource: Resource,
|
||||
diff: GroupPolicyDiff
|
||||
): Poke<GroupUpdateChangePolicy> => proxyAction({
|
||||
changePolicy: {
|
||||
resource,
|
||||
diff
|
||||
}
|
||||
});
|
||||
|
||||
export const join = (
|
||||
ship: string,
|
||||
name: string
|
||||
): Poke<any> => viewAction({
|
||||
join: {
|
||||
resource: makeResource(ship, name),
|
||||
ship
|
||||
}
|
||||
});
|
||||
|
||||
export const createGroup = (
|
||||
name: string,
|
||||
policy: Enc<GroupPolicy>,
|
||||
title: string,
|
||||
description: string
|
||||
): Thread<any> => viewThread('group-create', {
|
||||
create: {
|
||||
name,
|
||||
policy,
|
||||
title,
|
||||
description
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteGroup = (
|
||||
ship: string,
|
||||
name: string
|
||||
): Thread<any> => viewThread('group-delete', {
|
||||
remove: makeResource(ship, name)
|
||||
});
|
||||
|
||||
export const leaveGroup = (
|
||||
ship: string,
|
||||
name: string
|
||||
): Thread<any> => viewThread('group-leave', {
|
||||
leave: makeResource(ship, name)
|
||||
});
|
||||
|
||||
export const invite = (
|
||||
ship: string,
|
||||
name: string,
|
||||
ships: Patp[],
|
||||
description: string
|
||||
): Thread<any> => viewThread('group-invite', {
|
||||
invite: {
|
||||
resource: makeResource(ship, name),
|
||||
ships,
|
||||
description
|
||||
}
|
||||
});
|
||||
|
||||
export const roleTags = ['janitor', 'moderator', 'admin'];
|
||||
// TODO make this type better?
|
||||
|
||||
export const groupBunts = {
|
||||
group: (): Group => ({ members: new Set(), tags: { role: {} }, hidden: false, policy: groupBunts.policy() }),
|
||||
policy: (): GroupPolicy => ({ open: { banned: new Set(), banRanks: new Set() } })
|
||||
};
|
||||
|
||||
export const joinError = ['no-perms', 'strange'] as const;
|
||||
export const joinResult = ['done', ...joinError] as const;
|
||||
export const joinProgress = ['start', 'added', ...joinResult] as const;
|
||||
|
||||
export const roleForShip = (
|
||||
group: Group,
|
||||
ship: PatpNoSig
|
||||
): RoleTags | undefined => {
|
||||
return roleTags.reduce((currRole, role) => {
|
||||
const roleShips = group?.tags?.role?.[role];
|
||||
return roleShips && roleShips.has(ship) ? role : currRole;
|
||||
}, undefined as RoleTags | undefined);
|
||||
}
|
||||
|
||||
export const resourceFromPath = (path: Path): Resource => {
|
||||
const [, , ship, name] = path.split('/');
|
||||
return { ship, name };
|
||||
}
|
||||
|
||||
export const makeResource = (ship: string, name: string) => {
|
||||
return { ship, name };
|
||||
}
|
||||
|
||||
export const isWriter = (group: Group, resource: string, ship: string) => {
|
||||
const writers: Set<string> | undefined = _.get(
|
||||
group,
|
||||
['tags', 'graph', resource, 'writers'],
|
||||
undefined
|
||||
);
|
||||
const admins = group?.tags?.role?.admin ?? new Set();
|
||||
if (_.isUndefined(writers)) {
|
||||
return true;
|
||||
} else {
|
||||
return writers.has(ship) || admins.has(ship);
|
||||
}
|
||||
}
|
||||
|
||||
export const isChannelAdmin = (
|
||||
group: Group,
|
||||
resource: string,
|
||||
ship: string
|
||||
): boolean => {
|
||||
const role = roleForShip(group, ship.slice(1));
|
||||
|
||||
return (
|
||||
isHost(resource, ship) ||
|
||||
role === 'admin' ||
|
||||
role === 'moderator'
|
||||
);
|
||||
}
|
||||
|
||||
export const isHost = (
|
||||
resource: string,
|
||||
ship: string
|
||||
): boolean => {
|
||||
const [, , host] = resource.split('/');
|
||||
|
||||
return ship === host;
|
||||
}
|
2
pkg/npm/api/groups/types.ts
Normal file
2
pkg/npm/api/groups/types.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './update';
|
||||
export * from './view';
|
@ -38,33 +38,33 @@ export type OpenPolicyDiff =
|
||||
| AllowShipsDiff
|
||||
| BanShipsDiff;
|
||||
|
||||
interface AllowRanksDiff {
|
||||
export interface AllowRanksDiff {
|
||||
allowRanks: ShipRank[];
|
||||
}
|
||||
|
||||
interface BanRanksDiff {
|
||||
export interface BanRanksDiff {
|
||||
banRanks: ShipRank[];
|
||||
}
|
||||
|
||||
interface AllowShipsDiff {
|
||||
export interface AllowShipsDiff {
|
||||
allowShips: PatpNoSig[];
|
||||
}
|
||||
|
||||
interface BanShipsDiff {
|
||||
export interface BanShipsDiff {
|
||||
banShips: PatpNoSig[];
|
||||
}
|
||||
|
||||
export type InvitePolicyDiff = AddInvitesDiff | RemoveInvitesDiff;
|
||||
|
||||
interface AddInvitesDiff {
|
||||
export interface AddInvitesDiff {
|
||||
addInvites: PatpNoSig[];
|
||||
}
|
||||
|
||||
interface RemoveInvitesDiff {
|
||||
export interface RemoveInvitesDiff {
|
||||
removeInvites: PatpNoSig[];
|
||||
}
|
||||
|
||||
interface ReplacePolicyDiff {
|
||||
export interface ReplacePolicyDiff {
|
||||
replace: GroupPolicy;
|
||||
}
|
||||
|
||||
@ -75,7 +75,7 @@ export type GroupPolicyDiff =
|
||||
|
||||
export type GroupPolicy = OpenPolicy | InvitePolicy;
|
||||
|
||||
interface TaggedShips {
|
||||
export interface TaggedShips {
|
||||
[tag: string]: Set<PatpNoSig>;
|
||||
}
|
||||
|
||||
@ -95,11 +95,11 @@ export type Groups = {
|
||||
[p in Path]: Group;
|
||||
};
|
||||
|
||||
interface GroupUpdateInitial {
|
||||
export interface GroupUpdateInitial {
|
||||
initial: Enc<Groups>;
|
||||
}
|
||||
|
||||
interface GroupUpdateAddGroup {
|
||||
export interface GroupUpdateAddGroup {
|
||||
addGroup: {
|
||||
resource: Resource;
|
||||
policy: Enc<GroupPolicy>;
|
||||
@ -107,21 +107,21 @@ interface GroupUpdateAddGroup {
|
||||
};
|
||||
}
|
||||
|
||||
interface GroupUpdateAddMembers {
|
||||
export interface GroupUpdateAddMembers {
|
||||
addMembers: {
|
||||
ships: PatpNoSig[];
|
||||
resource: Resource;
|
||||
};
|
||||
}
|
||||
|
||||
interface GroupUpdateRemoveMembers {
|
||||
export interface GroupUpdateRemoveMembers {
|
||||
removeMembers: {
|
||||
ships: PatpNoSig[];
|
||||
resource: Resource;
|
||||
};
|
||||
}
|
||||
|
||||
interface GroupUpdateAddTag {
|
||||
export interface GroupUpdateAddTag {
|
||||
addTag: {
|
||||
tag: Tag;
|
||||
resource: Resource;
|
||||
@ -129,7 +129,7 @@ interface GroupUpdateAddTag {
|
||||
};
|
||||
}
|
||||
|
||||
interface GroupUpdateRemoveTag {
|
||||
export interface GroupUpdateRemoveTag {
|
||||
removeTag: {
|
||||
tag: Tag;
|
||||
resource: Resource;
|
||||
@ -137,23 +137,23 @@ interface GroupUpdateRemoveTag {
|
||||
};
|
||||
}
|
||||
|
||||
interface GroupUpdateChangePolicy {
|
||||
export interface GroupUpdateChangePolicy {
|
||||
changePolicy: { resource: Resource; diff: GroupPolicyDiff };
|
||||
}
|
||||
|
||||
interface GroupUpdateRemoveGroup {
|
||||
export interface GroupUpdateRemoveGroup {
|
||||
removeGroup: {
|
||||
resource: Resource;
|
||||
};
|
||||
}
|
||||
|
||||
interface GroupUpdateExpose {
|
||||
export interface GroupUpdateExpose {
|
||||
expose: {
|
||||
resource: Resource;
|
||||
};
|
||||
}
|
||||
|
||||
interface GroupUpdateInitialGroup {
|
||||
export interface GroupUpdateInitialGroup {
|
||||
initialGroup: {
|
||||
resource: Resource;
|
||||
group: Enc<Group>;
|
@ -0,0 +1,2 @@
|
||||
export * from './types';
|
||||
export * from './lib';
|
250
pkg/npm/api/hark/lib.ts
Normal file
250
pkg/npm/api/hark/lib.ts
Normal file
@ -0,0 +1,250 @@
|
||||
import f from 'lodash/fp';
|
||||
import bigInt, { BigInteger } from 'big-integer';
|
||||
|
||||
import { Poke } from '../lib/types';
|
||||
import { GraphNotifDescription, GraphNotificationContents, GraphNotifIndex, IndexedNotification, NotifIndex, Unreads } from './types';
|
||||
import { decToUd } from '../lib';
|
||||
import { Association } from '../metadata/types';
|
||||
|
||||
export const harkAction = <T>(data: T): Poke<T> => ({
|
||||
app: 'hark-store',
|
||||
mark: 'hark-action',
|
||||
json: data
|
||||
});
|
||||
|
||||
const graphHookAction = <T>(data: T): Poke<T> => ({
|
||||
app: 'hark-graph-hook',
|
||||
mark: 'hark-graph-hook-action',
|
||||
json: data
|
||||
});
|
||||
|
||||
export { graphHookAction as harkGraphHookAction };
|
||||
|
||||
const groupHookAction = <T>(data: T): Poke<T> => ({
|
||||
app: 'hark-group-hook',
|
||||
mark: 'hark-group-hook-action',
|
||||
json: data
|
||||
});
|
||||
|
||||
export { groupHookAction as harkGroupHookAction };
|
||||
|
||||
export const actOnNotification = (
|
||||
frond: string,
|
||||
intTime: BigInteger,
|
||||
index: NotifIndex
|
||||
): Poke<unknown> => harkAction({
|
||||
[frond]: {
|
||||
time: decToUd(intTime.toString()),
|
||||
index
|
||||
}
|
||||
});
|
||||
|
||||
export const getParentIndex = (
|
||||
idx: GraphNotifIndex,
|
||||
contents: GraphNotificationContents
|
||||
): string | undefined => {
|
||||
const origIndex = contents[0].index.slice(1).split('/');
|
||||
const ret = (i: string[]) => `/${i.join('/')}`;
|
||||
switch (idx.description) {
|
||||
case 'link':
|
||||
return '/';
|
||||
case 'comment':
|
||||
return ret(origIndex.slice(0, 1));
|
||||
case 'note':
|
||||
return '/';
|
||||
case 'mention':
|
||||
return undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const setMentions = (
|
||||
mentions: boolean
|
||||
): Poke<unknown> => graphHookAction({
|
||||
'set-mentions': mentions
|
||||
});
|
||||
|
||||
export const setWatchOnSelf = (
|
||||
watchSelf: boolean
|
||||
): Poke<unknown> => graphHookAction({
|
||||
'set-watch-on-self': watchSelf
|
||||
});
|
||||
|
||||
export const setDoNotDisturb = (
|
||||
dnd: boolean
|
||||
): Poke<unknown> => harkAction({
|
||||
'set-dnd': dnd
|
||||
});
|
||||
|
||||
export const archive = (
|
||||
time: BigInteger,
|
||||
index: NotifIndex
|
||||
): Poke<unknown> => actOnNotification('archive', time, index);
|
||||
|
||||
export const read = (
|
||||
time: BigInteger,
|
||||
index: NotifIndex
|
||||
): Poke<unknown> => actOnNotification('read-note', time, index);
|
||||
|
||||
export const readIndex = (
|
||||
index: NotifIndex
|
||||
): Poke<unknown> => harkAction({
|
||||
'read-index': index
|
||||
});
|
||||
|
||||
export const unread = (
|
||||
time: BigInteger,
|
||||
index: NotifIndex
|
||||
): Poke<unknown> => actOnNotification('unread-note', time, index);
|
||||
|
||||
export const markCountAsRead = (
|
||||
association: Association,
|
||||
parent: string,
|
||||
description: GraphNotifDescription
|
||||
): Poke<unknown> => harkAction({
|
||||
'read-count': {
|
||||
graph: {
|
||||
graph: association.resource,
|
||||
group: association.group,
|
||||
module: association.metadata.module,
|
||||
description: description,
|
||||
index: parent
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const markEachAsRead = (
|
||||
association: Association,
|
||||
parent: string,
|
||||
child: string,
|
||||
description: GraphNotifDescription,
|
||||
module: string
|
||||
): Poke<unknown> => harkAction({
|
||||
'read-each': {
|
||||
index: {
|
||||
graph: {
|
||||
graph: association.resource,
|
||||
group: association.group,
|
||||
description: description,
|
||||
module: module,
|
||||
index: parent
|
||||
}
|
||||
},
|
||||
target: child
|
||||
}
|
||||
});
|
||||
|
||||
export const dec = (
|
||||
index: NotifIndex,
|
||||
ref: string
|
||||
): Poke<unknown> => harkAction({
|
||||
dec: {
|
||||
index,
|
||||
ref
|
||||
}
|
||||
});
|
||||
|
||||
export const seen = () => harkAction({ seen: null });
|
||||
|
||||
export const readAll = () => harkAction({ 'read-all': null });
|
||||
|
||||
export const ignoreGroup = (
|
||||
group: string
|
||||
): Poke<unknown> => groupHookAction({
|
||||
ignore: group
|
||||
});
|
||||
|
||||
export const ignoreGraph = (
|
||||
graph: string,
|
||||
index: string
|
||||
): Poke<unknown> => graphHookAction({
|
||||
ignore: {
|
||||
graph,
|
||||
index
|
||||
}
|
||||
});
|
||||
|
||||
export const listenGroup = (
|
||||
group: string
|
||||
): Poke<unknown> => groupHookAction({
|
||||
listen: group
|
||||
});
|
||||
|
||||
export const listenGraph = (
|
||||
graph: string,
|
||||
index: string
|
||||
): Poke<unknown> => graphHookAction({
|
||||
listen: {
|
||||
graph,
|
||||
index
|
||||
}
|
||||
});
|
||||
|
||||
export const mute = (
|
||||
notif: IndexedNotification
|
||||
): Poke<any> | {} => {
|
||||
if('graph' in notif.index && 'graph' in notif.notification.contents) {
|
||||
const { index } = notif;
|
||||
const parentIndex = getParentIndex(index.graph, notif.notification.contents.graph);
|
||||
if(!parentIndex) {
|
||||
return {};
|
||||
}
|
||||
return ignoreGraph(index.graph.graph, parentIndex);
|
||||
}
|
||||
if('group' in notif.index) {
|
||||
const { group } = notif.index.group;
|
||||
return ignoreGroup(group);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export const unmute = (
|
||||
notif: IndexedNotification
|
||||
): Poke<any> | {} => {
|
||||
if('graph' in notif.index && 'graph' in notif.notification.contents) {
|
||||
const { index } = notif;
|
||||
const parentIndex = getParentIndex(index.graph, notif.notification.contents.graph);
|
||||
if(!parentIndex) {
|
||||
return {};
|
||||
}
|
||||
return listenGraph(index.graph.graph, parentIndex);
|
||||
}
|
||||
if('group' in notif.index) {
|
||||
return listenGroup(notif.index.group.group);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export const getLastSeen = (
|
||||
unreads: Unreads,
|
||||
path: string,
|
||||
index: string
|
||||
): BigInteger | undefined => {
|
||||
const lastSeenIdx = unreads.graph?.[path]?.[index]?.unreads;
|
||||
if (!(typeof lastSeenIdx === 'string')) {
|
||||
return bigInt.zero;
|
||||
}
|
||||
return f.flow(f.split('/'), f.last, x => (x ? bigInt(x) : undefined))(
|
||||
lastSeenIdx
|
||||
);
|
||||
}
|
||||
|
||||
export const getUnreadCount = (
|
||||
unreads: Unreads,
|
||||
path: string,
|
||||
index: string
|
||||
): number => {
|
||||
const graphUnreads = unreads.graph?.[path]?.[index]?.unreads ?? 0;
|
||||
return typeof graphUnreads === 'number' ? graphUnreads : graphUnreads.size;
|
||||
}
|
||||
|
||||
export const getNotificationCount = (
|
||||
unreads: Unreads,
|
||||
path: string
|
||||
): number => {
|
||||
const unread = unreads.graph?.[path] || {};
|
||||
return Object.keys(unread)
|
||||
.map(index => unread[index]?.notifications || 0)
|
||||
.reduce(f.add, 0);
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
import { Post } from "../graph/index.d";
|
||||
import { GroupUpdate } from "../groups/index.d";
|
||||
import { Post } from "../graph/types";
|
||||
import { GroupUpdate } from "../groups/types";
|
||||
import BigIntOrderedMap from "../lib/BigIntOrderedMap";
|
||||
|
||||
export type GraphNotifDescription = "link" | "comment" | "note" | "mention";
|
||||
export type GraphNotifDescription = "link" | "comment" | "note" | "mention" | "message";
|
||||
|
||||
export interface UnreadStats {
|
||||
unreads: Set<string> | number;
|
7
pkg/npm/api/index.d.ts
vendored
7
pkg/npm/api/index.d.ts
vendored
@ -1,7 +0,0 @@
|
||||
export * from './contacts/index.d'
|
||||
export * from './graph/index.d';
|
||||
export * from './groups/index.d';
|
||||
export * from './hark/index.d';
|
||||
export * from './invite/index.d';
|
||||
export * from './lib/index.d';
|
||||
export * from './metadata/index.d';
|
@ -1,9 +1,3 @@
|
||||
import BigIntOrderedMap from './lib/BigIntOrderedMap';
|
||||
|
||||
export {
|
||||
BigIntOrderedMap
|
||||
};
|
||||
|
||||
export * from './contacts';
|
||||
export * from './graph';
|
||||
export * from './groups';
|
||||
@ -11,4 +5,5 @@ export * from './hark';
|
||||
export * from './invite';
|
||||
export * from './metadata';
|
||||
export * from './settings';
|
||||
export * from './index.d';
|
||||
export * from './lib';
|
||||
export * from './lib/BigIntOrderedMap';
|
@ -1,28 +1,2 @@
|
||||
import { InviteUpdate, InviteUpdateAccept, InviteUpdateDecline } from "./index.d";
|
||||
import { Poke, Serial } from "..";
|
||||
|
||||
export const action = <T extends InviteUpdate>(data: T): Poke<T> => ({
|
||||
app: 'invite-store',
|
||||
mark: 'invite-action',
|
||||
json: data
|
||||
});
|
||||
|
||||
export const accept = (
|
||||
app: string,
|
||||
uid: Serial
|
||||
): Poke<InviteUpdateAccept> => action({
|
||||
accept: {
|
||||
term: app,
|
||||
uid
|
||||
}
|
||||
});
|
||||
|
||||
export const decline = (
|
||||
app: string,
|
||||
uid: Serial
|
||||
): Poke<InviteUpdateDecline> => action({
|
||||
decline: {
|
||||
term: app,
|
||||
uid
|
||||
}
|
||||
});
|
||||
export * from './types';
|
||||
export * from './lib';
|
28
pkg/npm/api/invite/lib.ts
Normal file
28
pkg/npm/api/invite/lib.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Poke, Serial } from "..";
|
||||
import { InviteUpdate, InviteUpdateAccept, InviteUpdateDecline } from "./types";
|
||||
|
||||
export const inviteAction = <T extends InviteUpdate>(data: T): Poke<T> => ({
|
||||
app: 'invite-store',
|
||||
mark: 'invite-action',
|
||||
json: data
|
||||
});
|
||||
|
||||
export const accept = (
|
||||
app: string,
|
||||
uid: Serial
|
||||
): Poke<InviteUpdateAccept> => inviteAction({
|
||||
accept: {
|
||||
term: app,
|
||||
uid
|
||||
}
|
||||
});
|
||||
|
||||
export const decline = (
|
||||
app: string,
|
||||
uid: Serial
|
||||
): Poke<InviteUpdateDecline> => inviteAction({
|
||||
decline: {
|
||||
term: app,
|
||||
uid
|
||||
}
|
||||
});
|
@ -1,5 +1,5 @@
|
||||
import { Serial, PatpNoSig, Path } from '..';
|
||||
import { Resource } from "../groups/update.d";
|
||||
import { Resource } from "../groups";
|
||||
|
||||
export type InviteUpdate =
|
||||
InviteUpdateInitial
|
||||
@ -10,30 +10,30 @@ export type InviteUpdate =
|
||||
| InviteUpdateAccepted
|
||||
| InviteUpdateDecline;
|
||||
|
||||
interface InviteUpdateAccept {
|
||||
export interface InviteUpdateAccept {
|
||||
accept: {
|
||||
term: string;
|
||||
uid: Serial;
|
||||
}
|
||||
}
|
||||
|
||||
interface InviteUpdateInitial {
|
||||
export interface InviteUpdateInitial {
|
||||
initial: Invites;
|
||||
}
|
||||
|
||||
interface InviteUpdateCreate {
|
||||
export interface InviteUpdateCreate {
|
||||
create: {
|
||||
term: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface InviteUpdateDelete {
|
||||
export interface InviteUpdateDelete {
|
||||
delete: {
|
||||
term: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface InviteUpdateInvite {
|
||||
export interface InviteUpdateInvite {
|
||||
invite: {
|
||||
term: string;
|
||||
uid: Serial;
|
||||
@ -41,14 +41,14 @@ interface InviteUpdateInvite {
|
||||
};
|
||||
}
|
||||
|
||||
interface InviteUpdateAccepted {
|
||||
export interface InviteUpdateAccepted {
|
||||
accepted: {
|
||||
term: string;
|
||||
uid: Serial;
|
||||
};
|
||||
}
|
||||
|
||||
interface InviteUpdateDecline {
|
||||
export interface InviteUpdateDecline {
|
||||
decline: {
|
||||
term: string;
|
||||
uid: Serial;
|
2
pkg/npm/api/lib/index.ts
Normal file
2
pkg/npm/api/lib/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './lib';
|
||||
export * from './types';
|
@ -1,23 +1,14 @@
|
||||
import _ from "lodash";
|
||||
import f from "lodash/fp";
|
||||
import bigInt, { BigInteger } from "big-integer";
|
||||
import { Resource } from "../groups/index.d";
|
||||
|
||||
import { Resource } from "../groups/types";
|
||||
import { Post, GraphNode } from "../graph/types";
|
||||
|
||||
const DA_UNIX_EPOCH = bigInt("170141184475152167957503069145530368000"); // `@ud` ~1970.1.1
|
||||
|
||||
const DA_SECOND = bigInt("18446744073709551616"); // `@ud` ~s1
|
||||
|
||||
/**
|
||||
* Returns true if an app uses a graph backend
|
||||
*
|
||||
* @param {string} app The name of the app
|
||||
*
|
||||
* @return {boolean} Whether or not it uses a graph backend
|
||||
*/
|
||||
export function appIsGraph(app: string): boolean {
|
||||
return app === 'publish' || app == 'link';
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a bigint representing an urbit date, returns a unix timestamp.
|
||||
*
|
||||
@ -136,28 +127,49 @@ export function deSig(ship: string): string | null {
|
||||
}
|
||||
|
||||
// trim patps to match dojo, chat-cli
|
||||
export function cite(ship: string): string {
|
||||
export function cite(ship: string) {
|
||||
let patp = ship,
|
||||
shortened = "";
|
||||
if (patp === null || patp === "") {
|
||||
return "";
|
||||
shortened = '';
|
||||
if (patp === null || patp === '') {
|
||||
return null;
|
||||
}
|
||||
if (patp.startsWith("~")) {
|
||||
if (patp.startsWith('~')) {
|
||||
patp = patp.substr(1);
|
||||
}
|
||||
// comet
|
||||
if (patp.length === 56) {
|
||||
shortened = "~" + patp.slice(0, 6) + "_" + patp.slice(50, 56);
|
||||
shortened = '~' + patp.slice(0, 6) + '_' + patp.slice(50, 56);
|
||||
return shortened;
|
||||
}
|
||||
// moon
|
||||
if (patp.length === 27) {
|
||||
shortened = "~" + patp.slice(14, 20) + "^" + patp.slice(21, 27);
|
||||
shortened = '~' + patp.slice(14, 20) + '^' + patp.slice(21, 27);
|
||||
return shortened;
|
||||
}
|
||||
return `~${patp}`;
|
||||
}
|
||||
|
||||
|
||||
export function uxToHex(ux: string) {
|
||||
if (ux.length > 2 && ux.substr(0, 2) === '0x') {
|
||||
const value = ux.substr(2).replace('.', '').padStart(6, '0');
|
||||
return value;
|
||||
}
|
||||
|
||||
const value = ux.replace('.', '').padStart(6, '0');
|
||||
return value;
|
||||
}
|
||||
|
||||
export const hexToUx = (hex: string): string => {
|
||||
const ux = f.flow(
|
||||
f.chunk(4),
|
||||
f.map(x => _.dropWhile(x, (y: unknown) => y === 0).join('')),
|
||||
f.join('.')
|
||||
)(hex.split(''));
|
||||
return `0x${ux}`;
|
||||
};
|
||||
|
||||
|
||||
// encode the string into @ta-safe format, using logic from +wood.
|
||||
// for example, 'some Chars!' becomes '~.some.~43.hars~21.'
|
||||
//
|
||||
@ -209,3 +221,20 @@ export function numToUd(num: number): string {
|
||||
f.join('.')
|
||||
)(num.toString())
|
||||
}
|
||||
|
||||
export const buntPost = (): Post => ({
|
||||
author: '',
|
||||
contents: [],
|
||||
hash: null,
|
||||
index: '',
|
||||
signatures: [],
|
||||
'time-sent': 0
|
||||
});
|
||||
|
||||
export function makeNodeMap(posts: Post[]): Record<string, GraphNode> {
|
||||
const nodes: Record<string, GraphNode> = {};
|
||||
posts.forEach((p: Post) => {
|
||||
nodes[String(p.index)] = { children: null, post: p };
|
||||
});
|
||||
return nodes;
|
||||
}
|
@ -20,7 +20,7 @@ export type Serial = string;
|
||||
export type Jug<K,V> = Map<K,Set<V>>;
|
||||
|
||||
// name of app
|
||||
export type AppName = 'chat' | 'link' | 'contacts' | 'publish' | 'graph';
|
||||
export type AppName = 'chat' | 'link' | 'contacts' | 'publish' | 'graph' | 'groups';
|
||||
|
||||
export type ShipRank = 'czar' | 'king' | 'duke' | 'earl' | 'pawn';
|
||||
|
||||
@ -54,6 +54,11 @@ export interface Poke<Action> {
|
||||
json: Action;
|
||||
}
|
||||
|
||||
export interface Scry {
|
||||
app: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface Thread<Action> {
|
||||
inputMark: string;
|
||||
outputMark: string;
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user