Merge remote-tracking branch 'origin/release/2021-5-27' into lf/hark-boxing

This commit is contained in:
Liam Fitzgerald 2021-05-18 10:46:31 +10:00
commit 55ad8e22ae
No known key found for this signature in database
GPG Key ID: D390E12C61D1CFFB
99 changed files with 912 additions and 333 deletions

14
.github/workflows/typescript-check.yml vendored Normal file
View File

@ -0,0 +1,14 @@
name: typescript-check
on:
pull_request:
paths:
- 'pkg/interface/**'
jobs:
typescript-check:
runs-on: ubuntu-latest
name: "Check pkg/interface types"
steps:
- uses: actions/checkout@v2
- run: cd 'pkg/interface' && npm i && npm run tsc

View File

@ -5,7 +5,7 @@
/- glob /- glob
/+ default-agent, verb, dbug /+ default-agent, verb, dbug
|% |%
++ hash 0v4.vrvkt.4gcnm.dgg5o.e73d6.kqnaq ++ hash 0v2.rvlfs.f97fq.hjrpe.d3h68.n54sj
+$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))] +$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))]
+$ all-states +$ all-states
$% state-0 $% state-0

View File

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

View File

@ -1551,6 +1551,15 @@
"integrity": "sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==", "integrity": "sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==",
"dev": true "dev": true
}, },
"@types/mdast": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.3.tgz",
"integrity": "sha512-SXPBMnFVQg1s00dlMCc/jCdvPqdE4mXaMMCeRlxLDmTAEoegHT53xKtkDnzDTOcmMHUfcjyf36/YYZ6SxRdnsw==",
"dev": true,
"requires": {
"@types/unist": "*"
}
},
"@types/minimatch": { "@types/minimatch": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@ -6643,9 +6652,9 @@
"dev": true "dev": true
}, },
"immer": { "immer": {
"version": "8.0.1", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/immer/-/immer-8.0.1.tgz", "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.2.tgz",
"integrity": "sha512-aqXhGP7//Gui2+UrEtvxZxSquQVXTpZ7KDxfCcKAF3Vysvw0CViVaW9RZ1j1xlIYqaaaipBoqdqeibkc18PNvA==" "integrity": "sha512-mkcmzLtIfSp40vAqteRr1MbWNSoI7JE+/PB36FNPoSfJ9RQRmNKuTYCjKkyXyuq3Dgn07HuJBrwJd4ZSk2yUbw=="
}, },
"import-fresh": { "import-fresh": {
"version": "3.3.0", "version": "3.3.0",
@ -10804,6 +10813,16 @@
"resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz",
"integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==" "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA=="
}, },
"ts-mdast": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ts-mdast/-/ts-mdast-1.0.0.tgz",
"integrity": "sha512-FmT5GbMU629/ty64741v7TdO8jm5xW09okr2VNExkLuRk5ngjKIDdn/woTB8lDtcgCMRS8lUNubImen0MkdF6g==",
"dev": true,
"requires": {
"@types/mdast": "^3.0.3",
"@types/unist": "^2.0.3"
}
},
"tslib": { "tslib": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.1.0.tgz",
@ -12185,9 +12204,9 @@
} }
}, },
"zustand": { "zustand": {
"version": "3.3.1", "version": "3.5.1",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-3.3.1.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.5.1.tgz",
"integrity": "sha512-o0rgrBsi29nCkPHdhtkAHisCIlmRUoXOV+1AmDMeCgkGG0i5edFSpGU0KiZYBvFmBYycnck4Z07JsLYDjSET9g==" "integrity": "sha512-7J56Ve814z4zap71iaKFD+t65LFI//jEq/Vf55BTSVqJZCm+w9rov8OMBg+YSwIPQk54bfoIWHTrOWuAbpEDMw=="
} }
} }
} }

View File

@ -22,7 +22,7 @@
"css-loader": "^3.6.0", "css-loader": "^3.6.0",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"formik": "^2.1.5", "formik": "^2.1.5",
"immer": "^8.0.1", "immer": "^9.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"moment": "^2.29.1", "moment": "^2.29.1",
"mousetrap": "^1.6.5", "mousetrap": "^1.6.5",
@ -56,7 +56,7 @@
"workbox-recipes": "^6.0.2", "workbox-recipes": "^6.0.2",
"workbox-routing": "^6.0.2", "workbox-routing": "^6.0.2",
"yup": "^0.29.3", "yup": "^0.29.3",
"zustand": "^3.3.1" "zustand": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.12.10", "@babel/core": "^7.12.10",
@ -91,6 +91,7 @@
"react-hot-loader": "^4.13.0", "react-hot-loader": "^4.13.0",
"sass": "^1.32.5", "sass": "^1.32.5",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"ts-mdast": "^1.0.0",
"typescript": "^4.2.4", "typescript": "^4.2.4",
"webpack": "^4.46.0", "webpack": "^4.46.0",
"webpack-cli": "^3.3.12", "webpack-cli": "^3.3.12",

View File

@ -1,6 +1,9 @@
import { Patp } from '@urbit/api'; import { Patp } from '@urbit/api';
import { ContactEditField } from '@urbit/api/contacts'; import { ContactEditField } from '@urbit/api/contacts';
import _ from 'lodash'; import _ from 'lodash';
import {edit} from '../reducers/contact-update';
import {doOptimistically} from '../state/base';
import useContactState from '../state/contact';
import { StoreState } from '../store/type'; import { StoreState } from '../store/type';
import BaseApi from './base'; import BaseApi from './base';
@ -26,13 +29,14 @@ export default class ContactsApi extends BaseApi<StoreState> {
{add-group: {ship, name}} {add-group: {ship, name}}
{remove-group: {ship, name}} {remove-group: {ship, name}}
*/ */
return this.storeAction({ const action = {
edit: { edit: {
ship, ship,
'edit-field': editField, 'edit-field': editField,
timestamp: Date.now() timestamp: Date.now()
} }
}); }
doOptimistically(useContactState, action, this.storeAction.bind(this), [edit])
} }
allowShips(ships: Patp[]) { allowShips(ships: Patp[]) {

View File

@ -1,7 +1,10 @@
import { Association, GraphNotifDescription, IndexedNotification, NotifIndex } from '@urbit/api'; import { Association, GraphNotifDescription, IndexedNotification, NotifIndex } from '@urbit/api';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import { BigInteger } from 'big-integer'; import { BigInteger } from 'big-integer';
import { getParentIndex } from '../lib/notification'; import { getParentIndex } from '../lib/notification';
import { dateToDa, decToUd } from '../lib/util'; import { dateToDa, decToUd } from '../lib/util';
import {reduce} from '../reducers/hark-update';
import {doOptimistically, optReduceState} from '../state/base';
import useHarkState from '../state/hark'; import useHarkState from '../state/hark';
import { StoreState } from '../store/type'; import { StoreState } from '../store/type';
import BaseApi from './base'; import BaseApi from './base';
@ -51,8 +54,15 @@ export class HarkApi extends BaseApi<StoreState> {
}); });
} }
archive(time: BigInteger, index: NotifIndex) { async archive(intTime: BigInteger, index: NotifIndex) {
return this.actOnNotification('archive', time, index); const time = decToUd(intTime.toString());
const action = {
archive: {
time,
index
}
};
await doOptimistically(useHarkState, action, this.harkAction.bind(this), [reduce])
} }
read(time: BigInteger, index: NotifIndex) { read(time: BigInteger, index: NotifIndex) {
@ -81,15 +91,15 @@ export class HarkApi extends BaseApi<StoreState> {
} }
markCountAsRead(association: Association, parent: string, description: GraphNotifDescription) { markCountAsRead(association: Association, parent: string, description: GraphNotifDescription) {
return this.harkAction( const action = { 'read-count': {
{ 'read-count': {
graph: { graph: {
graph: association.resource, graph: association.resource,
group: association.group, group: association.group,
description, description,
index: parent index: parent
} } } }
}); };
doOptimistically(useHarkState, action, this.harkAction.bind(this), [reduce]);
} }
markEachAsRead(association: Association, parent: string, child: string, description: GraphNotifDescription, mod: string) { markEachAsRead(association: Association, parent: string, child: string, description: GraphNotifDescription, mod: string) {

View File

@ -6,13 +6,15 @@ interface IFormGroupContext {
onDirty: (id: string, touched: boolean) => void; onDirty: (id: string, touched: boolean) => void;
onErrors: (id: string, errors: boolean) => void; onErrors: (id: string, errors: boolean) => void;
submitAll: () => Promise<any>; submitAll: () => Promise<any>;
addReset: (id: string, r: any) => any;
} }
const fallback: IFormGroupContext = { const fallback: IFormGroupContext = {
addSubmit: () => {}, addSubmit: () => {},
onDirty: () => {}, onDirty: () => {},
onErrors: () => {}, onErrors: () => {},
submitAll: () => Promise.resolve() submitAll: () => Promise.resolve(),
addReset: () => {}
}; };
export const FormGroupContext = React.createContext(fallback); export const FormGroupContext = React.createContext(fallback);

View File

@ -1,7 +1,8 @@
import { IndexedNotification, NotificationGraphConfig, Unreads } from '@urbit/api'; import { GraphNotifIndex, GroupNotifIndex, IndexedNotification, NotificationGraphConfig, Post, Unreads } from '@urbit/api';
import bigInt, { BigInteger } from 'big-integer'; import bigInt, { BigInteger } from 'big-integer';
import _ from 'lodash'; import _ from 'lodash';
import f from 'lodash/fp'; import f from 'lodash/fp';
import { pluralize } from './util';
export function getLastSeen( export function getLastSeen(
unreads: Unreads, unreads: Unreads,
@ -58,3 +59,56 @@ export function getNotificationKey(time: BigInteger, notification: IndexedNotifi
return `${base}-unknown`; return `${base}-unknown`;
} }
export function notificationReferent(not: IndexedNotification) {
if('graph' in not.index) {
return not.index.graph.graph;
} else {
return not.index.group.group;
}
}
export function describeNotification(notification: IndexedNotification) {
function group(idx: GroupNotifIndex) {
switch (idx.description) {
case 'add-members':
return 'joined';
case 'remove-members':
return 'left';
default:
return idx.description;
}
}
function graph(idx: GraphNotifIndex, plural: boolean, singleAuthor: boolean) {
const isDm = idx.graph.startsWith('dm--');
switch (idx.description) {
case 'post':
return singleAuthor ? 'replied to you' : 'Your post received replies';
case 'link':
return `New link${plural ? 's' : ''} in`;
case 'comment':
return `New comment${plural ? 's' : ''} on`;
case 'note':
return `New Note${plural ? 's' : ''} in`;
// @ts-ignore
case 'edit-note':
return `updated ${pluralize('note', plural)} in`;
case 'mention':
return singleAuthor ? 'mentioned you in' : 'You were mentioned in';
case 'message':
if (isDm) {
return 'messaged you';
}
return `New message${plural ? 's' : ''} in`;
default:
return idx.description;
}
}
if('group' in notification.index) {
return group(notification.index.group);
} else if('graph' in notification.index) {
// @ts-ignore needs better type guard
const contents = notification.notification?.contents?.graph ?? [] as Post[];
return graph(notification.index.graph, contents.length > 1, _.uniq(_.map(contents, 'author')).length === 1)
}
}

View File

@ -76,7 +76,7 @@ export function editPost(rev: number, noteId: BigInteger, title: string, body: s
return nodes; return nodes;
} }
export function getLatestRevision(node: GraphNode): [number, string, string, Post] { export function getLatestRevision(node: GraphNode): [number, string, any, Post] {
const empty = [1, '', '', buntPost()] as [number, string, string, Post]; const empty = [1, '', '', buntPost()] as [number, string, string, Post];
const revs = node.children?.get(bigInt(1)); const revs = node.children?.get(bigInt(1));
if(!revs) { if(!revs) {

View File

@ -0,0 +1,70 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import _ from 'lodash';
import { getChord } from '~/logic/lib/util';
type Handler = (e: KeyboardEvent) => void;
const fallback: ShortcutContextProps = {
add: () => {},
remove: () => {},
};
export const ShortcutContext = createContext(fallback);
export interface ShortcutContextProps {
add: (cb: (e: KeyboardEvent) => void, key: string) => void;
remove: (cb: (e: KeyboardEvent) => void, key: string) => void;
}
export function ShortcutContextProvider({ children }) {
const [shortcuts, setShortcuts] = useState({} as Record<string, Handler>);
const handlerRef = useRef<Handler>(() => {});
const add = useCallback((cb: Handler, key: string) => {
setShortcuts((s) => ({ ...s, [key]: cb }));
}, []);
const remove = useCallback((cb: Handler, key: string) => {
setShortcuts((s) => (key in s ? _.omit(s, key) : s));
}, []);
useEffect(() => {
function onKeypress(e: KeyboardEvent) {
handlerRef.current(e);
}
document.addEventListener('keypress', onKeypress);
return () => {
document.removeEventListener('keypress', onKeypress);
};
}, []);
useEffect(() => {
handlerRef.current = function (e: KeyboardEvent) {
const chord = getChord(e);
shortcuts?.[chord]?.(e);
};
}, [shortcuts]);
const value = useMemo(() => ({ add, remove }), [add, remove])
return (
<ShortcutContext.Provider value={value}>
{children}
</ShortcutContext.Provider>
);
}
export function useShortcut(key: string, cb: Handler) {
const { add, remove } = useContext(ShortcutContext);
useEffect(() => {
add(cb, key);
return () => {
remove(cb, key);
};
}, [key, cb]);
}

View File

@ -1,7 +1,7 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { writeText } from './util'; import { writeText } from './util';
export function useCopy(copied: string, display?: string) { export function useCopy(copied: string, display?: string, replaceText?: string) {
const [didCopy, setDidCopy] = useState(false); const [didCopy, setDidCopy] = useState(false);
const doCopy = useCallback(() => { const doCopy = useCallback(() => {
writeText(copied); writeText(copied);
@ -11,7 +11,7 @@ export function useCopy(copied: string, display?: string) {
}, 2000); }, 2000);
}, [copied]); }, [copied]);
const copyDisplay = useMemo(() => (didCopy ? 'Copied' : display), [ const copyDisplay = useMemo(() => (didCopy ? (replaceText ?? 'Copied') : display), [
didCopy, didCopy,
display display
]); ]);

View File

@ -17,7 +17,7 @@ interface SetStateFunc<T> {
} }
// See microsoft/typescript#37663 for filed bug // See microsoft/typescript#37663 for filed bug
type SetState<T> = T extends any ? SetStateFunc<T> : never; type SetState<T> = T extends any ? SetStateFunc<T> : never;
export function useLocalStorageState<T>(key: string, initial: T) { export function useLocalStorageState<T>(key: string, initial: T): any {
const [state, _setState] = useState(() => retrieve(key, initial)); const [state, _setState] = useState(() => retrieve(key, initial));
useEffect(() => { useEffect(() => {

View File

@ -2,13 +2,16 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import _ from 'lodash'; import _ from 'lodash';
import { patp2dec } from 'urbit-ob'; import { patp2dec } from 'urbit-ob';
import f, { compose, memoize } from 'lodash/fp'; import f, { compose, memoize } from 'lodash/fp';
import bigInt, { BigInteger } from 'big-integer';
import { Association, Contact, Patp } from '@urbit/api'; import { Association, Contact, Patp } from '@urbit/api';
import produce, { enableMapSet } from 'immer'; import produce, { enableMapSet } from 'immer';
import useSettingsState from '../state/settings'; import useSettingsState from '../state/settings';
/* eslint-disable max-lines */ /* eslint-disable max-lines */
import anyAscii from 'any-ascii'; import anyAscii from 'any-ascii';
import { IconRef } from '~/types'; import { sigil as sigiljs, stringRenderer } from '@tlon/sigil-js';
import bigInt, { BigInteger } from 'big-integer';
import { foregroundFromBackground } from '~/logic/lib/sigil';
import { IconRef, Workspace } from '~/types';
import useContactState from '../state/contact';
enableMapSet(); enableMapSet();
@ -47,6 +50,42 @@ export function parentPath(path: string) {
return _.dropRight(path.split('/'), 1).join('/'); return _.dropRight(path.split('/'), 1).join('/');
} }
/**
* undefined -> initial
* null -> disabled feed
* string -> enabled feed
*/
export function getFeedPath(association: Association): string | null | undefined {
const { metadata } = association;
if(metadata.config && 'group' in metadata?.config && metadata.config?.group) {
if ('resource' in metadata.config.group) {
return metadata.config.group.resource;
}
return null;
}
return undefined;
}
export const getChord = (e: KeyboardEvent) => {
let chord = [e.key];
if(e.metaKey) {
chord.unshift('meta');
}
if(e.ctrlKey) {
chord.unshift('ctrl');
}
return chord.join('+');
}
export function getResourcePath(workspace: Workspace, path: string, joined: boolean, mod: string) {
const base = workspace.type === 'group'
? `/~landscape${workspace.group}`
: workspace.type === 'home'
? `/~landscape/home`
: `/~landscape/messages`;
return `${base}/${joined ? 'resource' : 'join'}/${mod}${path}`
}
const DA_UNIX_EPOCH = bigInt('170141184475152167957503069145530368000'); // `@ud` ~1970.1.1 const DA_UNIX_EPOCH = bigInt('170141184475152167957503069145530368000'); // `@ud` ~1970.1.1
const DA_SECOND = bigInt('18446744073709551616'); // `@ud` ~s1 const DA_SECOND = bigInt('18446744073709551616'); // `@ud` ~s1
export function daToUnix(da: BigInteger) { export function daToUnix(da: BigInteger) {
@ -103,6 +142,13 @@ export function clamp(x: number, min: number, max: number) {
return Math.max(min, Math.min(max, x)); return Math.max(min, Math.min(max, x));
} }
/**
* Euclidean modulo
*/
export function modulo(x: number, mod: number) {
return x < 0 ? (x % mod + mod) % mod : x % mod;
}
// color is a #000000 color // color is a #000000 color
export function adjustHex(color: string, amount: number): string { export function adjustHex(color: string, amount: number): string {
return f.flow( return f.flow(
@ -249,14 +295,20 @@ export function cite(ship: string): string {
return `~${patp}`; return `~${patp}`;
} }
export function stripNonWord(string: string): string {
return string.replace(/[^\p{L}\p{N}\p{Z}]/gu, '');
}
export function alphabeticalOrder(a: string, b: string) { export function alphabeticalOrder(a: string, b: string) {
return a.toLowerCase().localeCompare(b.toLowerCase()); return stripNonWord(a).toLowerCase().trim().localeCompare(stripNonWord(b).toLowerCase().trim());
} }
export function lengthOrder(a: string, b: string) { export function lengthOrder(a: string, b: string) {
return b.length - a.length; return b.length - a.length;
} }
export const keys = <T extends {}>(o: T) => Object.keys(o) as (keyof T)[];
// TODO: deprecated // TODO: deprecated
export function alphabetiseAssociations(associations: any) { export function alphabetiseAssociations(associations: any) {
const result = {}; const result = {};
@ -431,6 +483,7 @@ export const useHovering = (): useHoveringInterface => {
export function withHovering<T>(Component: React.ComponentType<T>) { export function withHovering<T>(Component: React.ComponentType<T>) {
return React.forwardRef((props, ref) => { return React.forwardRef((props, ref) => {
const { hovering, bind } = useHovering(); const { hovering, bind } = useHovering();
// @ts-ignore needs type signature on return?
return <Component ref={ref} hovering={hovering} bind={bind} {...props} /> return <Component ref={ref} hovering={hovering} bind={bind} {...props} />
}) })
} }
@ -447,3 +500,22 @@ export function getItemTitle(association: Association): string {
return association.metadata.title ?? association.resource ?? ''; return association.metadata.title ?? association.resource ?? '';
} }
export const svgDataURL = (svg) => 'data:image/svg+xml;base64,' + btoa(svg);
export const svgBlobURL = (svg) => URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
export const favicon = () => {
let background = '#ffffff';
const contacts = useContactState.getState().contacts;
if (contacts.hasOwnProperty(`~${window.ship}`)) {
background = `#${uxToHex(contacts[`~${window.ship}`].color)}`;
}
const foreground = foregroundFromBackground(background);
const svg = sigiljs({
patp: window.ship,
renderer: stringRenderer,
size: 16,
colors: [background, foreground]
});
return svg;
}

View File

@ -1,4 +1,4 @@
import { ContactUpdate } from '@urbit/api'; import { ContactUpdate, deSig } from '@urbit/api';
import _ from 'lodash'; import _ from 'lodash';
import { reduceState } from '../state/base'; import { reduceState } from '../state/base';
import useContactState, { ContactState } from '../state/contact'; import useContactState, { ContactState } from '../state/contact';
@ -52,9 +52,9 @@ const remove = (json: ContactUpdate, state: ContactState): ContactState => {
return state; return state;
}; };
const edit = (json: ContactUpdate, state: ContactState): ContactState => { export const edit = (json: ContactUpdate, state: ContactState): ContactState => {
const data = _.get(json, 'edit', false); const data = _.get(json, 'edit', false);
const ship = `~${data.ship}`; const ship = `~${deSig(data.ship)}`;
if ( if (
data && data &&
(ship in state.contacts) (ship in state.contacts)

View File

@ -102,6 +102,7 @@ const addGraph = (json, state: GraphState): GraphState => {
const data = _.get(json, 'add-graph', false); const data = _.get(json, 'add-graph', false);
if (data) { if (data) {
if (!('graphs' in state)) { if (!('graphs' in state)) {
// @ts-ignore investigate zustand types
state.graphs = {}; state.graphs = {};
} }
@ -122,6 +123,7 @@ const removeGraph = (json, state: GraphState): GraphState => {
const data = _.get(json, 'remove-graph', false); const data = _.get(json, 'remove-graph', false);
if (data) { if (data) {
if (!('graphs' in state)) { if (!('graphs' in state)) {
// @ts-ignore investigate zustand types
state.graphs = {}; state.graphs = {};
} }
const resource = data.ship + '/' + data.name; const resource = data.ship + '/' + data.name;
@ -279,7 +281,7 @@ const removePosts = (json, state: GraphState): GraphState => {
} else { } else {
const child = graph.get(index[0]); const child = graph.get(index[0]);
if (child) { if (child) {
return graph.set(index[0], produce((draft) => { return graph.set(index[0], produce((draft: any) => {
draft.children = _remove(draft.children, index.slice(1)); draft.children = _remove(draft.children, index.slice(1));
})); }));
} }

View File

@ -8,8 +8,10 @@ import { BigInteger } from 'big-integer';
import _ from 'lodash'; import _ from 'lodash';
import { compose } from 'lodash/fp'; import { compose } from 'lodash/fp';
import { makePatDa } from '~/logic/lib/util'; import { makePatDa } from '~/logic/lib/util';
import { describeNotification } from '../lib/hark';
import { reduceState } from '../state/base'; import { reduceState } from '../state/base';
import useHarkState, { HarkState } from '../state/hark'; import useHarkState, { HarkState } from '../state/hark';
import useMetadataState from '../state/metadata';
export const HarkReducer = (json: any) => { export const HarkReducer = (json: any) => {
const data = _.get(json, 'harkUpdate', false); const data = _.get(json, 'harkUpdate', false);
@ -20,24 +22,32 @@ export const HarkReducer = (json: any) => {
const graphHookData = _.get(json, 'hark-graph-hook-update', false); const graphHookData = _.get(json, 'hark-graph-hook-update', false);
if (graphHookData) { if (graphHookData) {
reduceState<HarkState, any>(useHarkState, graphHookData, [ reduceState<HarkState, any>(useHarkState, graphHookData, [
// @ts-ignore investigate zustand types
graphInitial, graphInitial,
// @ts-ignore investigate zustand types
graphIgnore, graphIgnore,
// @ts-ignore investigate zustand types
graphListen, graphListen,
// @ts-ignore investigate zustand types
graphWatchSelf, graphWatchSelf,
// @ts-ignore investigate zustand types
graphMentions graphMentions
]); ]);
} }
const groupHookData = _.get(json, 'hark-group-hook-update', false); const groupHookData = _.get(json, 'hark-group-hook-update', false);
if (groupHookData) { if (groupHookData) {
reduceState<HarkState, any>(useHarkState, groupHookData, [ reduceState<HarkState, any>(useHarkState, groupHookData, [
// @ts-ignore investigate zustand types
groupInitial, groupInitial,
// @ts-ignore investigate zustand types
groupListen, groupListen,
// @ts-ignore investigate zustand types
groupIgnore groupIgnore
]); ]);
} }
}; };
function reduce(data, state) { export function reduce(data, state) {
const reducers = [ const reducers = [
calculateCount, calculateCount,
read, read,
@ -196,7 +206,7 @@ function readSince(json: any, state: HarkState): HarkState {
function unreadSince(json: any, state: HarkState): HarkState { function unreadSince(json: any, state: HarkState): HarkState {
const data = _.get(json, 'unread-count'); const data = _.get(json, 'unread-count');
if(data) { if (data) {
updateUnreadCount(state, data.index, u => u + 1); updateUnreadCount(state, data.index, u => u + 1);
} }
return state; return state;
@ -314,7 +324,7 @@ function removeNotificationFromUnread(state: HarkState, index: NotifIndex, time:
} }
} }
function updateNotificationStats(state: HarkState, index: NotifIndex, statField: 'unreads' | 'last', f: (x: number) => number) { function updateNotificationStats(state: HarkState, index: NotifIndex, statField: 'unreads' | 'last', f: (x: number) => number, notify = false) {
if('graph' in index) { if('graph' in index) {
const curr: any = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, statField], 0); const curr: any = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, statField], 0);
_.set(state.unreads.graph, [index.graph.graph, index.graph.index, statField], f(curr)); _.set(state.unreads.graph, [index.graph.graph, index.graph.index, statField], f(curr));
@ -330,6 +340,20 @@ function added(json: any, state: HarkState): HarkState {
const { index, notification } = data; const { index, notification } = data;
const [fresh, stale] = _.partition(state.unreadNotes, ({ index: idx }) => !notifIdxEqual(index, idx)); const [fresh, stale] = _.partition(state.unreadNotes, ({ index: idx }) => !notifIdxEqual(index, idx));
state.unreadNotes = [...fresh, { index, notification }]; state.unreadNotes = [...fresh, { index, notification }];
if ('Notification' in window && !useHarkState.getState().doNotDisturb) {
const description = describeNotification(data);
const meta = useMetadataState.getState();
const referent = 'graph' in data.index ? meta.associations.graph[data.index.graph.graph]?.metadata?.title ?? data.index.graph : meta.associations.groups[data.index.group.group]?.metadata?.title ?? data.index.group;
new Notification(`${description} ${referent}`, {
tag: 'landscape',
image: '/img/favicon.png',
icon: '/img/favicon.png',
badge: '/img/favicon.png',
renotify: true
});
}
} }
return state; return state;
} }
@ -368,7 +392,7 @@ function notifIdxEqual(a: NotifIndex, b: NotifIndex) {
return ( return (
a.graph.graph === b.graph.graph && a.graph.graph === b.graph.graph &&
a.graph.group === b.graph.group && a.graph.group === b.graph.group &&
a.graph.module === b.graph.module && a.graph.mark === b.graph.mark &&
a.graph.description === b.graph.description a.graph.description === b.graph.description
); );
} else if ('group' in a && 'group' in b) { } else if ('group' in a && 'group' in b) {

View File

@ -20,6 +20,7 @@ export default class LaunchReducer {
const weatherData: WeatherState | boolean | Record<string, never> = _.get(json, 'weather', false); const weatherData: WeatherState | boolean | Record<string, never> = _.get(json, 'weather', false);
if (weatherData) { if (weatherData) {
useLaunchState.getState().set((state) => { useLaunchState.getState().set((state) => {
// @ts-ignore investigate zustand types
state.weather = weatherData; state.weather = weatherData;
}); });
} }
@ -27,6 +28,7 @@ export default class LaunchReducer {
const locationData = _.get(json, 'location', false); const locationData = _.get(json, 'location', false);
if (locationData) { if (locationData) {
useLaunchState.getState().set((state) => { useLaunchState.getState().set((state) => {
// @ts-ignore investigate zustand types
state.userLocation = locationData; state.userLocation = locationData;
}); });
} }
@ -34,6 +36,7 @@ export default class LaunchReducer {
const baseHash = _.get(json, 'baseHash', false); const baseHash = _.get(json, 'baseHash', false);
if (baseHash) { if (baseHash) {
useLaunchState.getState().set((state) => { useLaunchState.getState().set((state) => {
// @ts-ignore investigate zustand types
state.baseHash = baseHash; state.baseHash = baseHash;
}); });
} }
@ -41,6 +44,7 @@ export default class LaunchReducer {
const runtimeLag = _.get(json, 'runtimeLag', null); const runtimeLag = _.get(json, 'runtimeLag', null);
if (runtimeLag !== null) { if (runtimeLag !== null) {
useLaunchState.getState().set(state => { useLaunchState.getState().set(state => {
// @ts-ignore investigate zustand types
state.runtimeLag = runtimeLag; state.runtimeLag = runtimeLag;
}); });
} }

View File

@ -1,24 +1,39 @@
import produce, { setAutoFreeze } from 'immer'; import produce, { applyPatches, Patch, produceWithPatches, setAutoFreeze, enablePatches } from 'immer';
import { compose } from 'lodash/fp'; import { compose } from 'lodash/fp';
import create, { State, UseStore } from 'zustand'; import _ from 'lodash';
import create, { UseStore } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
setAutoFreeze(false); setAutoFreeze(false);
enablePatches();
export const stateSetter = <StateType>( export const stateSetter = <T extends {}>(
fn: (state: StateType) => void, fn: (state: Readonly<T & BaseState<T>>) => void,
set set: (newState: T & BaseState<T>) => void
): void => { ): void => {
set(produce(fn)); set(produce(fn) as any);
}; };
export const optStateSetter = <T extends {}>(
fn: (state: T & BaseState<T>) => void,
set: (newState: T & BaseState<T>) => void,
get: () => T & BaseState<T>
): string => {
const old = get();
const id = _.uniqueId()
const [state, ,patches] = produceWithPatches(old, fn) as readonly [(T & BaseState<T>), any, Patch[]];
set({ ...state, patches: { ...state.patches, [id]: patches }});
return id;
};
export const reduceState = < export const reduceState = <
StateType extends BaseState<StateType>, S extends {},
UpdateType U
>( >(
state: UseStore<StateType>, state: UseStore<S & BaseState<S>>,
data: UpdateType, data: U,
reducers: ((data: UpdateType, state: StateType) => StateType)[] reducers: ((data: U, state: S & BaseState<S>) => S & BaseState<S>)[]
): void => { ): void => {
const reducer = compose(reducers.map(r => sta => r(data, sta))); const reducer = compose(reducers.map(r => sta => r(data, sta)));
state.getState().set((state) => { state.getState().set((state) => {
@ -26,6 +41,18 @@ export const reduceState = <
}); });
}; };
export const optReduceState = <S, U>(
state: UseStore<S & BaseState<S>>,
data: U,
reducers: ((data: U, state: S & BaseState<S>) => BaseState<S> & S)[]
): string => {
const reducer = compose(reducers.map(r => sta => r(data, sta)));
return state.getState().optSet((state) => {
reducer(state);
});
};
export let stateStorageKeys: string[] = []; export let stateStorageKeys: string[] = [];
export const stateStorageKey = (stateName: string) => { export const stateStorageKey = (stateName: string) => {
@ -40,19 +67,59 @@ export const stateStorageKey = (stateName: string) => {
}); });
}; };
export interface BaseState<StateType> extends State { export interface BaseState<StateType extends {}> {
set: (fn: (state: StateType) => void) => void; rollback: (id: string) => void;
patches: {
[id: string]: Patch[];
};
set: (fn: (state: BaseState<StateType>) => void) => void;
addPatch: (id: string, ...patch: Patch[]) => void;
removePatch: (id: string) => void;
optSet: (fn: (state: BaseState<StateType>) => void) => string;
} }
export const createState = <T extends BaseState<T>>( export const createState = <T extends {}>(
name: string, name: string,
properties: { [K in keyof Omit<T, 'set'>]: T[K] }, properties: T,
blacklist: string[] = [] blacklist: (keyof BaseState<T> | keyof T)[] = []
): UseStore<T> => create(persist((set, get) => ({ ): UseStore<T & BaseState<T>> => create<T & BaseState<T>>(persist<T & BaseState<T>>((set, get) => ({
// @ts-ignore investigate zustand types
set: fn => stateSetter(fn, set), set: fn => stateSetter(fn, set),
...properties as any optSet: fn => {
return optStateSetter(fn, set, get);
},
patches: {},
addPatch: (id: string, ...patch: Patch[]) => {
// @ts-ignore investigate immer types
set(({ patches }) => ({ patches: {...patches, [id]: patch }}));
},
removePatch: (id: string) => {
// @ts-ignore investigate immer types
set(({ patches }) => ({ patches: _.omit(patches, id)}));
},
rollback: (id: string) => {
set(state => {
const applying = state.patches[id]
return {...applyPatches(state, applying), patches: _.omit(state.patches, id) }
});
},
...properties
}), { }), {
blacklist, blacklist,
name: stateStorageKey(name), name: stateStorageKey(name),
version: process.env.LANDSCAPE_SHORTHASH as any version: process.env.LANDSCAPE_SHORTHASH as any
})); }));
export async function doOptimistically<A, S extends {}>(state: UseStore<S & BaseState<S>>, action: A, call: (a: A) => Promise<any>, reduce: ((a: A, fn: S & BaseState<S>) => S & BaseState<S>)[]) {
let num: string | undefined = undefined;
try {
num = optReduceState(state, action, reduce);
await call(action);
state.getState().removePatch(num)
} catch (e) {
console.error(e);
if(num) {
state.getState().rollback(num);
}
}
}

View File

@ -9,6 +9,7 @@ export interface ContactState extends BaseState<ContactState> {
// fetchIsAllowed: (entity, name, ship, personal) => Promise<boolean>; // fetchIsAllowed: (entity, name, ship, personal) => Promise<boolean>;
} }
// @ts-ignore investigate zustand types
const useContactState = createState<ContactState>('Contact', { const useContactState = createState<ContactState>('Contact', {
contacts: {}, contacts: {},
nackedContacts: new Set(), nackedContacts: new Set(),

View File

@ -27,7 +27,7 @@ export interface GraphState extends BaseState<GraphState> {
// getGraphSubset: (ship: string, resource: string, start: string, end: string) => Promise<void>; // getGraphSubset: (ship: string, resource: string, start: string, end: string) => Promise<void>;
// getNode: (ship: string, resource: string, index: string) => Promise<void>; // getNode: (ship: string, resource: string, index: string) => Promise<void>;
} }
// @ts-ignore investigate zustand types
const useGraphState = createState<GraphState>('Graph', { const useGraphState = createState<GraphState>('Graph', {
graphs: {}, graphs: {},
graphKeys: new Set(), graphKeys: new Set(),

View File

@ -9,6 +9,7 @@ export interface GroupState extends BaseState<GroupState> {
pendingJoin: JoinRequests; pendingJoin: JoinRequests;
} }
// @ts-ignore investigate zustand types
const useGroupState = createState<GroupState>('Group', { const useGroupState = createState<GroupState>('Group', {
groups: {}, groups: {},
pendingJoin: {} pendingJoin: {}

View File

@ -4,11 +4,11 @@ import BigIntOrderedMap from "@urbit/api/lib/BigIntOrderedMap";
import {useCallback} from "react"; import {useCallback} from "react";
// import { harkGraphHookReducer, harkGroupHookReducer, harkReducer } from "~/logic/subscription/hark"; // import { harkGraphHookReducer, harkGroupHookReducer, harkReducer } from "~/logic/subscription/hark";
import { BaseState, createState } from './base'; import { createState } from './base';
export const HARK_FETCH_MORE_COUNT = 3; export const HARK_FETCH_MORE_COUNT = 3;
export interface HarkState extends BaseState<HarkState> { export interface HarkState {
archivedNotifications: BigIntOrderedMap<Timebox>; archivedNotifications: BigIntOrderedMap<Timebox>;
doNotDisturb: boolean; doNotDisturb: boolean;
// getMore: () => Promise<boolean>; // getMore: () => Promise<boolean>;

View File

@ -5,6 +5,7 @@ export interface InviteState extends BaseState<InviteState> {
invites: Invites; invites: Invites;
} }
// @ts-ignore investigate zustand types
const useInviteState = createState<InviteState>('Invite', { const useInviteState = createState<InviteState>('Invite', {
invites: {} invites: {}
}); });

View File

@ -13,6 +13,7 @@ export interface LaunchState extends BaseState<LaunchState> {
runtimeLag: boolean; runtimeLag: boolean;
}; };
// @ts-ignore investigate zustand types
const useLaunchState = createState<LaunchState>('Launch', { const useLaunchState = createState<LaunchState>('Launch', {
firstTime: true, firstTime: true,
tileOrdering: [], tileOrdering: [],

View File

@ -82,6 +82,7 @@ const useLocalState = create<LocalStateZus>(persist((set, get) => ({
state.suspendedFocus.blur(); state.suspendedFocus.blur();
} }
})), })),
// @ts-ignore investigate zustand types
set: fn => set(produce(fn)) set: fn => set(produce(fn))
}), { }), {
blacklist: [ blacklist: [
@ -98,6 +99,7 @@ function withLocalState<P, S extends keyof LocalState, C extends React.Component
(object, key) => ({ ...object, [key]: state[key] }), {} (object, key) => ({ ...object, [key]: state[key] }), {}
) )
): useLocalState(); ): useLocalState();
// @ts-ignore call signature forwarding unclear
return <Component ref={ref} {...localState} {...props} />; return <Component ref={ref} {...localState} {...props} />;
}); });
} }

View File

@ -22,7 +22,7 @@ export function useGraphsForGroup(group: string) {
const graphs = useMetadataState(s => s.associations.graph); const graphs = useMetadataState(s => s.associations.graph);
return _.pickBy(graphs, (a: Association) => a.group === group); return _.pickBy(graphs, (a: Association) => a.group === group);
} }
// @ts-ignore investigate zustand types
const useMetadataState = createState<MetadataState>('Metadata', { const useMetadataState = createState<MetadataState>('Metadata', {
associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} } associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} }
// preview: async (group): Promise<MetadataUpdatePreview> => { // preview: async (group): Promise<MetadataUpdatePreview> => {

View File

@ -1,6 +1,17 @@
import f from 'lodash/fp'; import f from 'lodash/fp';
import { RemoteContentPolicy, LeapCategories, leapCategories } from "~/types/local-update";
import { useShortcut as usePlainShortcut } from '~/logic/lib/shortcutContext';
import { BaseState, createState } from '~/logic/state/base'; import { BaseState, createState } from '~/logic/state/base';
import { LeapCategories, leapCategories, RemoteContentPolicy } from '~/types/local-update'; import {useCallback} from 'react';
export interface ShortcutMapping {
cycleForward: string;
cycleBack: string;
navForward: string;
navBack: string;
hideSidebar: string;
}
export interface SettingsState extends BaseState<SettingsState> { export interface SettingsState extends BaseState<SettingsState> {
display: { display: {
@ -16,6 +27,7 @@ export interface SettingsState extends BaseState<SettingsState> {
hideGroups: boolean; hideGroups: boolean;
hideUtilities: boolean; hideUtilities: boolean;
}; };
keyboard: ShortcutMapping;
remoteContentPolicy: RemoteContentPolicy; remoteContentPolicy: RemoteContentPolicy;
leap: { leap: {
categories: LeapCategories[]; categories: LeapCategories[];
@ -33,6 +45,7 @@ export const selectCalmState = (s: SettingsState) => s.calm;
export const selectDisplayState = (s: SettingsState) => s.display; export const selectDisplayState = (s: SettingsState) => s.display;
// @ts-ignore investigate zustand types
const useSettingsState = createState<SettingsState>('Settings', { const useSettingsState = createState<SettingsState>('Settings', {
display: { display: {
backgroundType: 'none', backgroundType: 'none',
@ -59,7 +72,19 @@ const useSettingsState = createState<SettingsState>('Settings', {
tutorial: { tutorial: {
seen: true, seen: true,
joined: undefined joined: undefined
},
keyboard: {
cycleForward: 'ctrl+\'',
cycleBack: 'ctrl+;',
navForward: 'ctrl+[',
navBack: 'ctrl+[',
hideSidebar: 'ctrl+\\'
} }
}); });
export function useShortcut<T extends keyof ShortcutMapping>(name: T, cb: (e: KeyboardEvent) => void) {
const key = useSettingsState(useCallback(s => s.keyboard[name], [name]));
return usePlainShortcut(key, cb);
}
export default useSettingsState; export default useSettingsState;

View File

@ -19,6 +19,7 @@ export interface StorageState extends BaseState<StorageState> {
} }
} }
// @ts-ignore investigate zustand types
const useStorageState = createState<StorageState>('Storage', { const useStorageState = createState<StorageState>('Storage', {
gcp: {}, gcp: {},
s3: { s3: {

View File

@ -1,6 +1,5 @@
import dark from '@tlon/indigo-dark'; import dark from '@tlon/indigo-dark';
import light from '@tlon/indigo-light'; import light from '@tlon/indigo-light';
import { sigil as sigiljs, stringRenderer } from '@tlon/sigil-js';
import Mousetrap from 'mousetrap'; import Mousetrap from 'mousetrap';
import 'mousetrap-global-bind'; import 'mousetrap-global-bind';
import * as React from 'react'; import * as React from 'react';
@ -11,13 +10,14 @@ import { BrowserRouter as Router, withRouter } from 'react-router-dom';
import styled, { ThemeProvider } from 'styled-components'; import styled, { ThemeProvider } from 'styled-components';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import gcpManager from '~/logic/lib/gcpManager'; import gcpManager from '~/logic/lib/gcpManager';
import { foregroundFromBackground } from '~/logic/lib/sigil'; import { favicon, svgDataURL } from '~/logic/lib/util';
import { uxToHex } from '~/logic/lib/util';
import withState from '~/logic/lib/withState'; import withState from '~/logic/lib/withState';
import useContactState from '~/logic/state/contact'; import useContactState from '~/logic/state/contact';
import useGroupState from '~/logic/state/group'; import useGroupState from '~/logic/state/group';
import useLocalState from '~/logic/state/local'; import useLocalState from '~/logic/state/local';
import useSettingsState from '~/logic/state/settings'; import useSettingsState from '~/logic/state/settings';
import { ShortcutContextProvider } from '~/logic/lib/shortcutContext';
import GlobalStore from '~/logic/store/store'; import GlobalStore from '~/logic/store/store';
import GlobalSubscription from '~/logic/subscription/global'; import GlobalSubscription from '~/logic/subscription/global';
import ErrorBoundary from '~/views/components/ErrorBoundary'; import ErrorBoundary from '~/views/components/ErrorBoundary';
@ -86,7 +86,6 @@ class App extends React.Component {
this.updateTheme = this.updateTheme.bind(this); this.updateTheme = this.updateTheme.bind(this);
this.updateMobile = this.updateMobile.bind(this); this.updateMobile = this.updateMobile.bind(this);
this.faviconString = this.faviconString.bind(this);
} }
componentDidMount() { componentDidMount() {
@ -131,22 +130,6 @@ class App extends React.Component {
}); });
} }
faviconString() {
let background = '#ffffff';
if (this.props.contacts.hasOwnProperty(`~${window.ship}`)) {
background = `#${uxToHex(this.props.contacts[`~${window.ship}`].color)}`;
}
const foreground = foregroundFromBackground(background);
const svg = sigiljs({
patp: window.ship,
renderer: stringRenderer,
size: 16,
colors: [background, foreground]
});
const dataurl = 'data:image/svg+xml;base64,' + btoa(svg);
return dataurl;
}
getTheme() { getTheme() {
const { props } = this; const { props } = this;
return ((props.dark && props?.display?.theme == 'auto') || return ((props.dark && props?.display?.theme == 'auto') ||
@ -161,9 +144,10 @@ class App extends React.Component {
const ourContact = this.props.contacts[`~${this.ship}`] || null; const ourContact = this.props.contacts[`~${this.ship}`] || null;
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<ShortcutContextProvider>
<Helmet> <Helmet>
{window.ship.length < 14 {window.ship.length < 14
? <link rel="icon" type="image/svg+xml" href={this.faviconString()} /> ? <link rel="icon" type="image/svg+xml" href={svgDataURL(favicon())} />
: null} : null}
</Helmet> </Helmet>
<Root> <Root>
@ -198,6 +182,7 @@ class App extends React.Component {
</Router> </Router>
</Root> </Root>
<div id="portal-root" /> <div id="portal-root" />
</ShortcutContextProvider>
</ThemeProvider> </ThemeProvider>
); );
} }

View File

@ -128,10 +128,27 @@ export default class ChatEditor extends Component<ChatEditorProps, ChatEditorSta
}; };
this.editor = null; this.editor = null;
this.onKeyPress = this.onKeyPress.bind(this);
}
componentDidMount() {
document.addEventListener('keydown', this.onKeyPress);
} }
componentWillUnmount(): void { componentWillUnmount(): void {
this.props.onUnmount(this.state.message); this.props.onUnmount(this.state.message);
document.removeEventListener('keydown', this.onKeyPress);
}
onKeyPress(e) {
const focusedTag = document.activeElement?.nodeName?.toLowerCase();
const shouldCapture = !(focusedTag === 'textarea' || focusedTag === 'input' || e.metaKey || e.ctrlKey);
if(/^[a-z]|[A-Z]$/.test(e.key) && shouldCapture) {
this.editor.focus();
}
if(e.key === 'Escape') {
this.editor.getInputField().blur();
}
} }
componentDidUpdate(prevProps: ChatEditorProps): void { componentDidUpdate(prevProps: ChatEditorProps): void {
@ -140,9 +157,9 @@ export default class ChatEditor extends Component<ChatEditorProps, ChatEditorSta
if (prevProps.message !== props.message && this.editor) { if (prevProps.message !== props.message && this.editor) {
this.editor.setValue(props.message); this.editor.setValue(props.message);
this.editor.setOption('mode', MARKDOWN_CONFIG); this.editor.setOption('mode', MARKDOWN_CONFIG);
this.editor?.focus(); //this.editor?.focus();
this.editor.execCommand('goDocEnd'); //this.editor.execCommand('goDocEnd');
this.editor?.focus(); //this.editor?.focus();
return; return;
} }
@ -281,7 +298,6 @@ return;
onChange={(e, d, v) => this.messageChange(e, d, v)} onChange={(e, d, v) => this.messageChange(e, d, v)}
editorDidMount={(editor) => { editorDidMount={(editor) => {
this.editor = editor; this.editor = editor;
editor.focus();
}} }}
{...props} {...props}
/> />

View File

@ -226,7 +226,9 @@ export class ChatInput extends Component<ChatInputProps, ChatInputState> {
} }
} }
// @ts-ignore withLocalState prop passing weirdness
export default withLocalState<Omit<ChatInputProps, keyof IuseStorage>, 'hideAvatars', ChatInput>( export default withLocalState<Omit<ChatInputProps, keyof IuseStorage>, 'hideAvatars', ChatInput>(
// @ts-ignore withLocalState prop passing weirdness
withStorage<ChatInputProps, ChatInput>(ChatInput, { accept: 'image/*' }), withStorage<ChatInputProps, ChatInput>(ChatInput, { accept: 'image/*' }),
['hideAvatars'] ['hideAvatars']
); );

View File

@ -251,8 +251,7 @@ function ChatMessage(props: ChatMessageProps) {
let onDelete = props?.onDelete ?? (() => {}); let onDelete = props?.onDelete ?? (() => {});
const transcluded = props?.transcluded ?? 0; const transcluded = props?.transcluded ?? 0;
const renderSigil = props.renderSigil ?? (Boolean(nextMsg && msg.author !== nextMsg.author) || const renderSigil = props.renderSigil ?? (Boolean(nextMsg && msg.author !== nextMsg.author) ||
!nextMsg || !nextMsg
msg.number === 1
); );
const ourMention = msg?.contents?.some((e: MentionContent) => { const ourMention = msg?.contents?.some((e: MentionContent) => {

View File

@ -34,7 +34,7 @@ interface ChatPaneProps {
* Get contents of reply message * Get contents of reply message
*/ */
onReply: (msg: Post) => string; onReply: (msg: Post) => string;
onDelete: (msg: Post) => void; onDelete?: (msg: Post) => void;
/** /**
* Fetch more messages * Fetch more messages
* *
@ -136,6 +136,7 @@ export function ChatPane(props: ChatPaneProps): ReactElement {
} }
return ( return (
// @ts-ignore
<Col {...bind} height="100%" overflow="hidden" position="relative"> <Col {...bind} height="100%" overflow="hidden" position="relative">
<ShareProfile <ShareProfile
our={ourContact} our={ourContact}

View File

@ -239,6 +239,7 @@ class ChatWindow extends Component<
}; };
return ( return (
// @ts-ignore
<ChatMessage <ChatMessage
key={index.toString()} key={index.toString()}
ref={ref} ref={ref}
@ -281,6 +282,7 @@ class ChatWindow extends Component<
origin='bottom' origin='bottom'
style={virtScrollerStyle} style={virtScrollerStyle}
onBottomLoaded={this.onBottomLoaded} onBottomLoaded={this.onBottomLoaded}
// @ts-ignore paging @liam-fitzgerald on virtualscroller props
onScroll={this.onScroll} onScroll={this.onScroll}
data={graph} data={graph}
size={graph.size} size={graph.size}

View File

@ -56,15 +56,14 @@ export default function Groups(props: GroupsProps & Parameters<typeof Box>[0]) {
const path = group?.group; const path = group?.group;
const unreadCount = graphUnreads(path); const unreadCount = graphUnreads(path);
const notCount = graphNotifications(path); const notCount = graphNotifications(path);
return ( return (
<Group <Group
key={group.metadata.title}
updates={notCount} updates={notCount}
first={index === 0} first={index === 0}
unreads={unreadCount} unreads={unreadCount}
path={group?.group} path={group?.group}
title={group.metadata.title} title={group.metadata.title}
picture={group.metadata.picture}
/> />
); );
})} })}
@ -96,7 +95,7 @@ function Group(props: GroupProps) {
.diff(moment())) .diff(moment()))
.as('days'))) || 0; .as('days'))) || 0;
return ( return (
<Tile ref={anchorRef} position="relative" bg={isTutorialGroup ? 'lightBlue' : undefined} to={`/~landscape${path}`} gridColumnStart={first ? '1' : null}> <Tile ref={anchorRef} position="relative" bg={isTutorialGroup ? 'lightBlue' : undefined} to={`/~landscape${path}`} gridColumnStart={first ? 1 : null}>
<Col height="100%" justifyContent="space-between"> <Col height="100%" justifyContent="space-between">
<Text>{title}</Text> <Text>{title}</Text>
{!hideUnreads && (<Col> {!hideUnreads && (<Col>

View File

@ -35,6 +35,7 @@ const Tiles = (props: TileProps): ReactElement => {
return ( return (
<WeatherTile <WeatherTile
key={key} key={key}
// @ts-ignore withState not passing props
api={props.api} api={props.api}
/> />
); );

View File

@ -135,7 +135,7 @@ class WeatherTile extends React.Component<WeatherTileProps, WeatherTileState> {
{locationName ? ` Current location is near ${locationName}.` : ''} {locationName ? ` Current location is near ${locationName}.` : ''}
</Text> </Text>
{error} {error}
<Box mt='auto' display='flex' marginBlockEnd={0}> <Box mt='auto' display='flex' style={{ marginBlockEnd: '0' }}>
<BaseInput <BaseInput
id="location" id="location"
size={10} size={10}

View File

@ -1,4 +1,5 @@
import { Box, Center, Col, LoadingSpinner, Text } from '@tlon/indigo-react'; import { Box, Center, Col, LoadingSpinner, Text } from '@tlon/indigo-react';
import { Group } from '@urbit/api';
import { Association } from '@urbit/api/metadata'; import { Association } from '@urbit/api/metadata';
import bigInt from 'big-integer'; import bigInt from 'big-integer';
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
@ -35,7 +36,7 @@ export function LinkResource(props: LinkResourceProps) {
const [, , ship, name] = rid.split('/'); const [, , ship, name] = rid.split('/');
const resourcePath = `${ship.slice(1)}/${name}`; const resourcePath = `${ship.slice(1)}/${name}`;
const resource = associations.graph[rid] const resource: any = associations.graph[rid]
? associations.graph[rid] ? associations.graph[rid]
: { metadata: {} }; : { metadata: {} };
const groups = useGroupState(state => state.groups); const groups = useGroupState(state => state.groups);
@ -62,13 +63,14 @@ export function LinkResource(props: LinkResourceProps) {
path={relativePath('')} path={relativePath('')}
render={(props) => { render={(props) => {
return ( return (
// @ts-ignore
<LinkWindow <LinkWindow
key={rid} key={rid}
association={resource} association={resource}
resource={resourcePath} resource={resourcePath}
graph={graph} graph={graph}
baseUrl={resourceUrl} baseUrl={resourceUrl}
group={group} group={group as Group}
path={resource.group} path={resource.group}
pendingSize={Object.keys(graphTimesentMap[resourcePath] || {}).length} pendingSize={Object.keys(graphTimesentMap[resourcePath] || {}).length}
api={api} api={api}
@ -110,7 +112,7 @@ export function LinkResource(props: LinkResourceProps) {
node={node} node={node}
baseUrl={resourceUrl} baseUrl={resourceUrl}
association={association} association={association}
group={group} group={group as Group}
path={resource?.group} path={resource?.group}
api={api} api={api}
mt={3} mt={3}
@ -125,8 +127,8 @@ export function LinkResource(props: LinkResourceProps) {
api={api} api={api}
editCommentId={editCommentId} editCommentId={editCommentId}
history={props.history} history={props.history}
baseUrl={`${resourceUrl}/${props.match.params.index}`} baseUrl={`${resourceUrl}/index/${props.match.params.index}`}
group={group} group={group as Group}
px={3} px={3}
/> />
</Col> </Col>

View File

@ -21,6 +21,7 @@ interface LinkWindowProps {
path: string; path: string;
api: GlobalApi; api: GlobalApi;
pendingSize: number; pendingSize: number;
mb?: number;
} }
const style = { const style = {
@ -48,6 +49,7 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
const { props } = this; const { props } = this;
const { association, graph, api } = props; const { association, graph, api } = props;
const [, , ship, name] = association.resource.split('/'); const [, , ship, name] = association.resource.split('/');
// @ts-ignore Uint8Array vs. BigInt mismatch?
const node = graph.get(index); const node = graph.get(index);
const first = graph.peekLargest()?.[0]; const first = graph.peekLargest()?.[0];
const post = node?.post; const post = node?.post;
@ -58,6 +60,7 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
...props, ...props,
node node
}; };
{/* @ts-ignore calling @liam-fitzgerald on Uint8Array props */}
if (this.canWrite() && index.eq(first ?? bigInt.zero)) { if (this.canWrite() && index.eq(first ?? bigInt.zero)) {
return ( return (
<React.Fragment key={index.toString()}> <React.Fragment key={index.toString()}>
@ -125,6 +128,7 @@ class LinkWindow extends Component<LinkWindowProps, {}> {
return ( return (
<Col width="100%" height="100%" position="relative"> <Col width="100%" height="100%" position="relative">
{/* @ts-ignore calling @liam-fitzgerald on virtualscroller */}
<VirtualScroller <VirtualScroller
origin="top" origin="top"
offset={0} offset={0}

View File

@ -20,6 +20,8 @@ interface LinkItemProps {
group: Group; group: Group;
path: string; path: string;
baseUrl: string; baseUrl: string;
mt?: number;
measure?: any;
} }
export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<HTMLDivElement>): ReactElement => { export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<HTMLDivElement>): ReactElement => {
const { const {
@ -49,6 +51,7 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
setTimeout(() => { setTimeout(() => {
console.log(remoteRef.current); console.log(remoteRef.current);
if(document.activeElement instanceof HTMLIFrameElement if(document.activeElement instanceof HTMLIFrameElement
// @ts-ignore forwardref prop passing
&& remoteRef?.current?.containerRef?.contains(document.activeElement)) { && remoteRef?.current?.containerRef?.contains(document.activeElement)) {
markRead(); markRead();
} }
@ -100,6 +103,7 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
const appPath = `/ship/~${resource}`; const appPath = `/ship/~${resource}`;
const unreads = useHarkState(state => state.unreads); const unreads = useHarkState(state => state.unreads);
const commColor = (unreads.graph?.[appPath]?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray'; const commColor = (unreads.graph?.[appPath]?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray';
// @ts-ignore hark will have to choose between sets and numbers
const isUnread = unreads.graph?.[appPath]?.['/']?.unreads?.has(node.post.index); const isUnread = unreads.graph?.[appPath]?.['/']?.unreads?.has(node.post.index);
return ( return (
@ -135,8 +139,10 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
<> <>
<RemoteContent <RemoteContent
ref={(r) => { ref={(r) => {
// @ts-ignore RemoteContent weirdness
remoteRef.current = r; remoteRef.current = r;
}} }}
// @ts-ignore RemoteContent weirdness
renderUrl={false} renderUrl={false}
url={href} url={href}
text={contents[0].text} text={contents[0].text}

View File

@ -12,6 +12,7 @@ interface LinkSubmitProps {
api: GlobalApi; api: GlobalApi;
name: string; name: string;
ship: string; ship: string;
parentIndex?: any;
} }
const LinkSubmit = (props: LinkSubmitProps) => { const LinkSubmit = (props: LinkSubmitProps) => {
@ -157,6 +158,7 @@ const LinkSubmit = (props: LinkSubmitProps) => {
return ( return (
<> <>
{/* @ts-ignore archaic event type mismatch */}
<Box <Box
flexShrink={0} flexShrink={0}
position='relative' position='relative'
@ -194,6 +196,7 @@ const LinkSubmit = (props: LinkSubmitProps) => {
onBlur={() => [setUrlFocused(false), setSubmitFocused(false)]} onBlur={() => [setUrlFocused(false), setSubmitFocused(false)]}
onFocus={() => [setUrlFocused(true), setSubmitFocused(true)]} onFocus={() => [setUrlFocused(true), setSubmitFocused(true)]}
spellCheck="false" spellCheck="false"
// @ts-ignore archaic event type mismatch error
onPaste={onPaste} onPaste={onPaste}
onKeyPress={onKeyPress} onKeyPress={onKeyPress}
value={linkValue} value={linkValue}

View File

@ -292,7 +292,7 @@ export function GraphNotification(props: {
const first = contents[0]; const first = contents[0];
history.push( history.push(
getNodeUrl( getNodeUrl(
index.module, index.mark,
groups[association?.group]?.hidden, groups[association?.group]?.hidden,
association?.group, association?.group,
association?.resource, association?.resource,
@ -328,7 +328,6 @@ export function GraphNotification(props: {
hideAuthors={hideAuthors} hideAuthors={hideAuthors}
posts={contents.slice(0, 4)} posts={contents.slice(0, 4)}
mod={index.mark} mod={index.mark}
description={index.description}
index={contents?.[0].index} index={contents?.[0].index}
association={association} association={association}
hidden={groups[association?.group]?.hidden} hidden={groups[association?.group]?.hidden}

View File

@ -49,7 +49,7 @@ function TranscludedLinkNode(props: {
<Author <Author
pt='12px' pt='12px'
pl='12px' pl='12px'
size='24' size={24}
sigilPadding='6' sigilPadding='6'
showImage showImage
ship={node.post.author} ship={node.post.author}
@ -121,7 +121,7 @@ function TranscludedComment(props: {
<Author <Author
pt='12px' pt='12px'
pl='12px' pl='12px'
size='24' size={24}
sigilPadding='6' sigilPadding='6'
showImage showImage
ship={comment.post.author} ship={comment.post.author}
@ -175,7 +175,7 @@ function TranscludedPublishNode(props: {
<Author <Author
pl='12px' pl='12px'
pt='12px' pt='12px'
size='24' size={24}
sigilPadding='6' sigilPadding='6'
showImage showImage
ship={post.post.author} ship={post.post.author}
@ -235,7 +235,7 @@ export function TranscludedPost(props: {
<Author <Author
pt='12px' pt='12px'
pl='12px' pl='12px'
size='24' size={24}
sigilPadding='6' sigilPadding='6'
showImage showImage
ship={post.author} ship={post.author}
@ -273,7 +273,7 @@ export function TranscludedNode(props: {
if ( if (
typeof node?.post === "string" && typeof node?.post === "string" &&
assoc.metadata.config.graph === "chat" (assoc.metadata.config as GraphConfig).graph === "chat"
) { ) {
return ( return (
<Box <Box
@ -296,6 +296,7 @@ export function TranscludedNode(props: {
renderSigil renderSigil
transcluded={transcluded + 1} transcluded={transcluded + 1}
className="items-top cf hide-child" className="items-top cf hide-child"
// @ts-ignore isn't forwarding props to memo
association={assoc} association={assoc}
msg={node.post} msg={node.post}
fontSize={0} fontSize={0}

View File

@ -1,20 +1,12 @@
import { import { BaseAnchor, Box, Center, Col, Icon, Row, Text } from "@tlon/indigo-react";
BaseAnchor, Box, import { Association, GraphNode, resourceFromPath, GraphConfig } from '@urbit/api';
Center, Col, Icon, Row, Text
} from "@tlon/indigo-react";
import { Association, GraphNode, resourceFromPath } from '@urbit/api';
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useHistory, useLocation } from 'react-router-dom'; import { useHistory, useLocation } from 'react-router-dom';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { import {
getPermalinkForGraph, GraphPermalink as IGraphPermalink, parsePermalink getPermalinkForGraph, GraphPermalink as IGraphPermalink, parsePermalink
} from '~/logic/lib/permalinks'; } from '~/logic/lib/permalinks';
import { getModuleIcon } from "~/logic/lib/util"; import { getModuleIcon, GraphModule } from "~/logic/lib/util";
import { useVirtualResizeProp } from "~/logic/lib/virtualContext"; import { useVirtualResizeProp } from "~/logic/lib/virtualContext";
import useGraphState from "~/logic/state/graph"; import useGraphState from "~/logic/state/graph";
import useMetadataState from "~/logic/state/metadata"; import useMetadataState from "~/logic/state/metadata";
@ -129,7 +121,7 @@ function GraphPermalink(
<PermalinkDetails <PermalinkDetails
known known
showTransclusion={showTransclusion} showTransclusion={showTransclusion}
icon={getModuleIcon(association.metadata.config.graph)} icon={getModuleIcon((association.metadata.config as GraphConfig).graph as GraphModule)}
title={association.metadata.title} title={association.metadata.title}
permalink={permalink} permalink={permalink}
/> />
@ -197,6 +189,7 @@ export function PermalinkEmbed(props: {
transcluded: number; transcluded: number;
showOurContact?: boolean; showOurContact?: boolean;
full?: boolean; full?: boolean;
pending?: any;
}) { }) {
const permalink = parsePermalink(props.link); const permalink = parsePermalink(props.link);

View File

@ -90,11 +90,12 @@ export function EditProfile(props: any): ReactElement {
const onSubmit = async (values: any, actions: any) => { const onSubmit = async (values: any, actions: any) => {
try { try {
await Object.keys(values).reduce((acc, key) => { Object.keys(values).forEach((key) => {
const newValue = key !== 'color' ? values[key] : uxToHex(values[key]); const newValue = key !== 'color' ? values[key] : uxToHex(values[key]);
if (newValue !== contact[key]) { if (newValue !== contact[key]) {
if (key === 'isPublic') { if (key === 'isPublic') {
return acc.then(() => api.contacts.setPublic(newValue)); api.contacts.setPublic(newValue)
return;
} else if (key === 'groups') { } else if (key === 'groups') {
const toRemove: string[] = _.difference( const toRemove: string[] = _.difference(
contact?.groups || [], contact?.groups || [],
@ -104,24 +105,18 @@ export function EditProfile(props: any): ReactElement {
newValue, newValue,
contact?.groups || [] contact?.groups || []
); );
const promises: Promise<any>[] = []; toRemove.forEach(e =>
promises.concat(
toRemove.map(e =>
api.contacts.edit(ship, { 'remove-group': resourceFromPath(e) }) api.contacts.edit(ship, { 'remove-group': resourceFromPath(e) })
) )
); toAdd.forEach(e =>
promises.concat(
toAdd.map(e =>
api.contacts.edit(ship, { 'add-group': resourceFromPath(e) }) api.contacts.edit(ship, { 'add-group': resourceFromPath(e) })
) )
);
return acc.then(() => Promise.all(promises));
} else if (key !== 'last-updated' && key !== 'isPublic') { } else if (key !== 'last-updated' && key !== 'isPublic') {
return acc.then(() => api.contacts.edit(ship, { [key]: newValue })); api.contacts.edit(ship, { [key]: newValue });
return;
} }
} }
return acc; });
}, Promise.resolve());
// actions.setStatus({ success: null }); // actions.setStatus({ success: null });
history.push(`/~profile/${ship}`); history.push(`/~profile/${ship}`);
} catch (e) { } catch (e) {

View File

@ -39,7 +39,7 @@ export function SetStatus(props: any) {
ref={inputRef} ref={inputRef}
onChange={onStatusChange} onChange={onStatusChange}
value={_status} value={_status}
autocomplete='off' autoComplete='off'
width='75%' width='75%'
mr={2} mr={2}
onKeyPress={(evt) => { onKeyPress={(evt) => {

View File

@ -10,10 +10,13 @@ type PublishResourceProps = StoreState & {
association: Association; association: Association;
api: GlobalApi; api: GlobalApi;
baseUrl: string; baseUrl: string;
history?: any;
match?: any;
location?: any;
}; };
export function PublishResource(props: PublishResourceProps) { export function PublishResource(props: PublishResourceProps) {
const { association, api, baseUrl, notebooks } = props; const { association, api, baseUrl } = props;
const rid = association.resource; const rid = association.resource;
const [, , ship, book] = rid.split('/'); const [, , ship, book] = rid.split('/');
const location = useLocation(); const location = useLocation();

View File

@ -69,6 +69,7 @@ export function NotePreview(props: NotePreviewProps) {
const [rev, title, body, content] = getLatestRevision(node); const [rev, title, body, content] = getLatestRevision(node);
const appPath = `/ship/${props.host}/${props.book}`; const appPath = `/ship/${props.host}/${props.book}`;
const unreads = useHarkState(state => state.unreads); const unreads = useHarkState(state => state.unreads);
// @ts-ignore hark will have to choose between sets and numbers
const isUnread = unreads.graph?.[appPath]?.['/']?.unreads?.has(`/${noteId}/1/1`); const isUnread = unreads.graph?.[appPath]?.['/']?.unreads?.has(`/${noteId}/1/1`);
const snippet = getSnippet(body); const snippet = getSnippet(body);

View File

@ -1,5 +1,5 @@
import { Box, Col, Row, Text } from '@tlon/indigo-react'; import { Box, Col, Row, Text } from '@tlon/indigo-react';
import { Association, Graph, Unreads } from '@urbit/api'; import { Association, Graph } from '@urbit/api';
import React, { ReactElement } from 'react'; import React, { ReactElement } from 'react';
import { RouteComponentProps } from 'react-router-dom'; import { RouteComponentProps } from 'react-router-dom';
import { useShowNickname } from '~/logic/lib/util'; import { useShowNickname } from '~/logic/lib/util';
@ -14,7 +14,6 @@ interface NotebookProps {
association: Association; association: Association;
baseUrl: string; baseUrl: string;
rootUrl: string; rootUrl: string;
unreads: Unreads;
} }
export function Notebook(props: NotebookProps & RouteComponentProps): ReactElement | null { export function Notebook(props: NotebookProps & RouteComponentProps): ReactElement | null {

View File

@ -15,7 +15,6 @@ interface NotebookPostsProps {
} }
export function NotebookPosts(props: NotebookPostsProps) { export function NotebookPosts(props: NotebookPostsProps) {
const contacts = useContactState(state => state.contacts);
return ( return (
<Col> <Col>
{Array.from(props.graph || []).map( {Array.from(props.graph || []).map(
@ -25,7 +24,6 @@ export function NotebookPosts(props: NotebookPostsProps) {
key={date.toString()} key={date.toString()}
host={props.host} host={props.host}
book={props.book} book={props.book}
contact={contacts[`~${node.post.author}`]}
node={node} node={node}
baseUrl={props.baseUrl} baseUrl={props.baseUrl}
group={props.group} group={props.group}

View File

@ -3,6 +3,7 @@ import {
ManagedRadioButtonField as Radio, Row, Text ManagedRadioButtonField as Radio, Row, Text
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
import {useField} from 'formik';
import React, { ReactElement } from 'react'; import React, { ReactElement } from 'react';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { ColorInput } from '~/views/components/ColorInput'; import { ColorInput } from '~/views/components/ColorInput';
@ -10,11 +11,7 @@ import { ImageInput } from '~/views/components/ImageInput';
export type BgType = 'none' | 'url' | 'color'; export type BgType = 'none' | 'url' | 'color';
export function BackgroundPicker({ export function BackgroundPicker({ api }: {
bgType,
bgUrl,
api
}: {
bgType: BgType; bgType: BgType;
bgUrl?: string; bgUrl?: string;
api: GlobalApi; api: GlobalApi;
@ -40,7 +37,6 @@ export function BackgroundPicker({
id="bgUrl" id="bgUrl"
placeholder="Drop or upload a file, or paste a link here" placeholder="Drop or upload a file, or paste a link here"
name="bgUrl" name="bgUrl"
url={bgUrl || ''}
/> />
</Col> </Col>
</Row> </Row>

View File

@ -6,9 +6,11 @@ import {
import { Form, Formik, FormikHelpers } from 'formik'; import { Form, Formik, FormikHelpers } from 'formik';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import useSettingsState, { selectSettingsState } from '~/logic/state/settings'; import useSettingsState, { selectSettingsState, SettingsState } from '~/logic/state/settings';
import { AsyncButton } from '~/views/components/AsyncButton'; import { AsyncButton } from '~/views/components/AsyncButton';
import { BackButton } from './BackButton'; import { BackButton } from './BackButton';
import _ from 'lodash';
import {FormikOnBlur} from '~/views/components/FormikOnBlur';
interface FormSchema { interface FormSchema {
hideAvatars: boolean; hideAvatars: boolean;
@ -22,57 +24,39 @@ interface FormSchema {
videoShown: boolean; videoShown: boolean;
} }
const settingsSel = selectSettingsState(['calm', 'remoteContentPolicy']); const settingsSel = (s: SettingsState): FormSchema => ({
hideAvatars: s.calm.hideAvatars,
hideNicknames: s.calm.hideAvatars,
hideUnreads: s.calm.hideUnreads,
hideGroups: s.calm.hideGroups,
hideUtilities: s.calm.hideUtilities,
imageShown: !s.remoteContentPolicy.imageShown,
videoShown: !s.remoteContentPolicy.videoShown,
oembedShown: !s.remoteContentPolicy.oembedShown,
audioShown: !s.remoteContentPolicy.audioShown
});
export function CalmPrefs(props: { export function CalmPrefs(props: {
api: GlobalApi; api: GlobalApi;
}) { }) {
const { api } = props; const { api } = props;
const { const initialValues = useSettingsState(settingsSel);
calm: {
hideAvatars,
hideNicknames,
hideUnreads,
hideGroups,
hideUtilities
},
remoteContentPolicy: {
imageShown,
videoShown,
oembedShown,
audioShown
}
} = useSettingsState(settingsSel);
const initialValues: FormSchema = {
hideAvatars,
hideNicknames,
hideUnreads,
hideGroups,
hideUtilities,
imageShown: !imageShown,
videoShown: !videoShown,
oembedShown: !oembedShown,
audioShown: !audioShown
};
const onSubmit = useCallback(async (v: FormSchema, actions: FormikHelpers<FormSchema>) => { const onSubmit = useCallback(async (v: FormSchema, actions: FormikHelpers<FormSchema>) => {
await Promise.all([ let promises: Promise<any>[] = [];
api.settings.putEntry('calm', 'hideAvatars', v.hideAvatars), _.forEach(v, (bool, key) => {
api.settings.putEntry('calm', 'hideNicknames', v.hideNicknames), const bucket = ['imageShown', 'videoShown', 'audioShown', 'oembedShown'].includes(key) ? 'remoteContentPolicy' : 'calm';
api.settings.putEntry('calm', 'hideUnreads', v.hideUnreads), if(initialValues[key] !== bool) {
api.settings.putEntry('calm', 'hideGroups', v.hideGroups), promises.push(api.settings.putEntry(bucket, key, bool));
api.settings.putEntry('calm', 'hideUtilities', v.hideUtilities), }
api.settings.putEntry('remoteContentPolicy', 'imageShown', !v.imageShown), })
api.settings.putEntry('remoteContentPolicy', 'videoShown', !v.videoShown), await Promise.all(promises);
api.settings.putEntry('remoteContentPolicy', 'audioShown', !v.audioShown),
api.settings.putEntry('remoteContentPolicy', 'oembedShown', !v.oembedShown)
]);
actions.setStatus({ success: null }); actions.setStatus({ success: null });
}, [api]); }, [api]);
return ( return (
<Formik initialValues={initialValues} onSubmit={onSubmit}> <FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
<Form> <Form>
<BackButton /> <BackButton />
<Col borderBottom={1} borderBottomColor="washedGray" p={5} pt={4} gapY={5}> <Col borderBottom={1} borderBottomColor="washedGray" p={5} pt={4} gapY={5}>
@ -132,12 +116,8 @@ export function CalmPrefs(props: {
id="oembedShown" id="oembedShown"
caption="Embedded content may contain scripts that can track you" caption="Embedded content may contain scripts that can track you"
/> />
<AsyncButton primary width="fit-content" type="submit">
Save
</AsyncButton>
</Col> </Col>
</Form> </Form>
</Formik> </FormikOnBlur>
); );
} }

View File

@ -16,7 +16,7 @@ import { BackButton } from './BackButton';
interface StoreDebuggerProps { interface StoreDebuggerProps {
name: string; name: string;
useStore: UseStore<BaseState<any>>; useStore: UseStore<BaseState<any> & any>;
} }
const objectToString = (obj: any): string => JSON.stringify(obj, null, ' '); const objectToString = (obj: any): string => JSON.stringify(obj, null, ' ');
@ -57,7 +57,9 @@ const StoreDebugger = (props: StoreDebuggerProps) => {
placeholder="Drill Down" placeholder="Drill Down"
width="100%" width="100%"
onKeyUp={(event) => { onKeyUp={(event) => {
// @ts-ignore clearly value is in eventtarget
if (event.target.value) { if (event.target.value) {
// @ts-ignore clearly value is in eventtarget
tryFilter(event.target.value); tryFilter(event.target.value);
} else { } else {
setFilter(''); setFilter('');

View File

@ -11,6 +11,7 @@ import GlobalApi from '~/logic/api/global';
import { uxToHex } from '~/logic/lib/util'; import { uxToHex } from '~/logic/lib/util';
import useSettingsState, { selectSettingsState } from '~/logic/state/settings'; import useSettingsState, { selectSettingsState } from '~/logic/state/settings';
import { AsyncButton } from '~/views/components/AsyncButton'; import { AsyncButton } from '~/views/components/AsyncButton';
import {FormikOnBlur} from '~/views/components/FormikOnBlur';
import { BackButton } from './BackButton'; import { BackButton } from './BackButton';
import { BackgroundPicker, BgType } from './BackgroundPicker'; import { BackgroundPicker, BgType } from './BackgroundPicker';
@ -58,7 +59,7 @@ export default function DisplayForm(props: DisplayFormProps) {
const bgType = backgroundType || 'none'; const bgType = backgroundType || 'none';
return ( return (
<Formik <FormikOnBlur
validationSchema={formSchema} validationSchema={formSchema}
initialValues={ initialValues={
{ {
@ -86,7 +87,6 @@ export default function DisplayForm(props: DisplayFormProps) {
actions.setStatus({ success: null }); actions.setStatus({ success: null });
}} }}
> >
{props => (
<Form> <Form>
<BackButton /> <BackButton />
<Col p={5} pt={4} gapY={5}> <Col p={5} pt={4} gapY={5}>
@ -99,9 +99,8 @@ export default function DisplayForm(props: DisplayFormProps) {
</Text> </Text>
</Col> </Col>
<BackgroundPicker <BackgroundPicker
bgType={props.values.bgType}
bgUrl={props.values.bgUrl}
api={api} api={api}
bgType={bgType}
/> />
<Label>Theme</Label> <Label>Theme</Label>
<Radio name="theme" id="light" label="Light" /> <Radio name="theme" id="light" label="Light" />
@ -112,7 +111,6 @@ export default function DisplayForm(props: DisplayFormProps) {
</AsyncButton> </AsyncButton>
</Col> </Col>
</Form> </Form>
)} </FormikOnBlur>
</Formik>
); );
} }

View File

@ -3,7 +3,8 @@ import {
Center, Col, Icon, Center, Col, Icon,
StatelessToggleSwitchField, Text ToggleSwitch, Text,
StatelessToggleSwitchField
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
import { Association, GraphConfig, resourceFromPath } from '@urbit/api'; import { Association, GraphConfig, resourceFromPath } from '@urbit/api';
import { useField } from 'formik'; import { useField } from 'formik';
@ -100,7 +101,7 @@ function Channel(props: { association: Association }) {
return isWatching(config, association.resource); return isWatching(config, association.resource);
}); });
const [{ value }, meta, { setValue }] = useField( const [{ value }, meta, { setValue, setTouched }] = useField(
`graph["${association.resource}"]` `graph["${association.resource}"]`
); );
@ -108,9 +109,11 @@ function Channel(props: { association: Association }) {
setValue(watching); setValue(watching);
}, [watching]); }, [watching]);
const onChange = () => { const onClick = () => {
setValue(!value); setValue(!value);
}; setTouched(true);
}
const icon = getModuleIcon((metadata.config as GraphConfig)?.graph as GraphModule); const icon = getModuleIcon((metadata.config as GraphConfig)?.graph as GraphModule);
@ -123,7 +126,7 @@ function Channel(props: { association: Association }) {
<Text> {metadata.title}</Text> <Text> {metadata.title}</Text>
</Box> </Box>
<Box gridColumn={4}> <Box gridColumn={4}>
<StatelessToggleSwitchField selected={value} onChange={onChange} /> <StatelessToggleSwitchField selected={value} onClick={onClick} />
</Box> </Box>
</> </>
); );

View File

@ -50,7 +50,6 @@ export function LeapSettings(props: { api: GlobalApi; }) {
const { leap, set: setSettingsState } = useSettingsState(settingsSel); const { leap, set: setSettingsState } = useSettingsState(settingsSel);
const categories = leap.categories as LeapCategories[]; const categories = leap.categories as LeapCategories[];
const missing = _.difference(leapCategories, categories); const missing = _.difference(leapCategories, categories);
console.log(categories);
const initialValues = { const initialValues = {
categories: [ categories: [

View File

@ -1,15 +1,19 @@
import { import {
Button,
Col, Col,
ManagedToggleSwitchField as Toggle, Text ManagedToggleSwitchField as Toggle, Text
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
import { Form, Formik, FormikHelpers } from 'formik'; import { Form, FormikHelpers } from 'formik';
import _ from 'lodash'; import _ from 'lodash';
import React, { useCallback } from 'react'; import React, { useCallback, useState } from 'react';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { isWatching } from '~/logic/lib/hark'; import { isWatching } from '~/logic/lib/hark';
import useHarkState from '~/logic/state/hark'; import useHarkState from '~/logic/state/hark';
import { AsyncButton } from '~/views/components/AsyncButton'; import { FormikOnBlur } from '~/views/components/FormikOnBlur';
import { BackButton } from './BackButton'; import { BackButton } from './BackButton';
import { GroupChannelPicker } from './GroupChannelPicker'; import { GroupChannelPicker } from './GroupChannelPicker';
@ -69,6 +73,8 @@ export function NotificationPreferences(props: {
} }
}, [api, graphConfig, dnd]); }, [api, graphConfig, dnd]);
const [notificationsAllowed, setNotificationsAllowed] = useState('Notification' in window && Notification.permission !== 'default');
return ( return (
<> <>
<BackButton /> <BackButton />
@ -82,9 +88,17 @@ export function NotificationPreferences(props: {
messaging messaging
</Text> </Text>
</Col> </Col>
<Formik initialValues={initialValues} onSubmit={onSubmit}> <FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
<Form> <Form>
<Col gapY={4}> <Col gapY="4">
{notificationsAllowed || !('Notification' in window)
? null
: <Button alignSelf='flex-start' onClick={() => {
Notification.requestPermission().then(() => {
setNotificationsAllowed(Notification.permission !== 'default');
});
}}>Allow Browser Notifications</Button>
}
<Toggle <Toggle
label="Do not disturb" label="Do not disturb"
id="dnd" id="dnd"
@ -109,12 +123,9 @@ export function NotificationPreferences(props: {
</Text> </Text>
<GroupChannelPicker /> <GroupChannelPicker />
</Col> </Col>
<AsyncButton primary width="fit-content">
Save
</AsyncButton>
</Col> </Col>
</Form> </Form>
</Formik> </FormikOnBlur>
</Col> </Col>
</> </>
); );

View File

@ -0,0 +1,117 @@
import React, { useCallback, useEffect, useState } from 'react';
import _ from 'lodash';
import { Box, Col, Text } from '@tlon/indigo-react';
import { Formik, Form, useField } from 'formik';
import GlobalApi from '~/logic/api/global';
import { getChord } from '~/logic/lib/util';
import useSettingsState, {
selectSettingsState,
ShortcutMapping,
} from '~/logic/state/settings';
import { AsyncButton } from '~/views/components/AsyncButton';
import { BackButton } from './BackButton';
interface ShortcutSettingsProps {
api: GlobalApi;
}
const settingsSel = selectSettingsState(['keyboard']);
export function ChordInput(props: { id: string; label: string }) {
const { id, label } = props;
const [capturing, setCapturing] = useState(false);
const [{ value }, , { setValue }] = useField(id);
const onCapture = useCallback(() => {
setCapturing(true);
}, []);
useEffect(() => {
if (!capturing) {
return;
}
function onKeydown(e: KeyboardEvent) {
if (['Control', 'Shift', 'Meta'].includes(e.key)) {
return;
}
const chord = getChord(e);
setValue(chord);
e.stopImmediatePropagation();
e.preventDefault();
setCapturing(false);
}
document.addEventListener('keydown', onKeydown);
return () => {
document.removeEventListener('keydown', onKeydown);
};
}, [capturing]);
return (
<>
<Box p="1">
<Text>{label}</Text>
</Box>
<Box
border="1"
borderColor="lightGray"
borderRadius="2"
onClick={onCapture}
p="1"
>
<Text>{capturing ? 'Press' : value}</Text>
</Box>
</>
);
}
export default function ShortcutSettings(props: ShortcutSettingsProps) {
const { api } = props;
const { keyboard } = useSettingsState(settingsSel);
return (
<Formik
initialValues={keyboard}
onSubmit={async (values: ShortcutMapping, actions) => {
const promises = _.map(values, (value, key) => {
return keyboard[key] !== value
? api.settings.putEntry('keyboard', key, value)
: Promise.resolve();
});
await Promise.all(promises);
actions.setStatus({ success: null });
}}
>
<Form>
<BackButton />
<Col p="5" pt="4" gapY="5">
<Col gapY="1" mt="0">
<Text color="black" fontSize={2} fontWeight="medium">
Shortcuts
</Text>
<Text gray>Customize keyboard shortcuts for landscape</Text>
</Col>
<Box
display="grid"
gridTemplateColumns="1fr 100px"
gridGap={3}
maxWidth="500px"
>
<ChordInput id="navForward" label="Go forward in history" />
<ChordInput id="navBack" label="Go backward in history" />
<ChordInput
id="cycleForward"
label="Cycle forward through channel list"
/>
<ChordInput
id="cycleBack"
label="Cycle backward through channel list"
/>
<ChordInput id="hideSidebar" label="Show/hide group sidebar" />
</Box>
<AsyncButton primary width="fit-content">Save Changes</AsyncButton>
</Col>
</Form>
</Formik>
);
}

View File

@ -13,6 +13,7 @@ import { NotificationPreferences } from './components/lib/NotificationPref';
import S3Form from './components/lib/S3Form'; import S3Form from './components/lib/S3Form';
import SecuritySettings from './components/lib/Security'; import SecuritySettings from './components/lib/Security';
import {DmSettings} from './components/lib/DmSettings'; import {DmSettings} from './components/lib/DmSettings';
import ShortcutSettings from './components/lib/ShortcutSettings';
export const Skeleton = (props: { children: ReactNode }) => ( export const Skeleton = (props: { children: ReactNode }) => (
<Box height='100%' width='100%' px={[0, 3]} pb={[0, 3]} borderRadius={1}> <Box height='100%' width='100%' px={[0, 3]} pb={[0, 3]} borderRadius={1}>
@ -115,6 +116,7 @@ return;
<SidebarItem icon='LeapArrow' text='Leap' hash='leap' /> <SidebarItem icon='LeapArrow' text='Leap' hash='leap' />
<SidebarItem icon='Messages' text='Direct Messages' hash='dm' /> <SidebarItem icon='Messages' text='Direct Messages' hash='dm' />
<SidebarItem icon='Node' text='CalmEngine' hash='calm' /> <SidebarItem icon='Node' text='CalmEngine' hash='calm' />
<SidebarItem icon='Keyboard' text='Shortcuts' hash='shortcuts' />
<SidebarItem <SidebarItem
icon='Locked' icon='Locked'
text='Devices + Security' text='Devices + Security'
@ -132,6 +134,7 @@ return;
)} )}
{hash === 'display' && <DisplayForm api={props.api} />} {hash === 'display' && <DisplayForm api={props.api} />}
{hash === 'dm' && <DmSettings api={props.api} />} {hash === 'dm' && <DmSettings api={props.api} />}
{hash === 'shortcuts' && <ShortcutSettings api={props.api} />}
{hash === 's3' && <S3Form api={props.api} />} {hash === 's3' && <S3Form api={props.api} />}
{hash === 'leap' && <LeapSettings api={props.api} />} {hash === 'leap' && <LeapSettings api={props.api} />}
{hash === 'calm' && <CalmPrefs api={props.api} />} {hash === 'calm' && <CalmPrefs api={props.api} />}

View File

@ -78,6 +78,7 @@ class TermApp extends Component {
border={['0','1']} border={['0','1']}
cursor='text' cursor='text'
> >
{/* @ts-ignore declare props in later pass */}
<History log={this.state.lines.slice(0, -1)} /> <History log={this.state.lines.slice(0, -1)} />
<Input <Input
ship={this.props.ship} ship={this.props.ship}

View File

@ -21,7 +21,9 @@ export class History extends Component {
<Box <Box
mt='auto' mt='auto'
> >
{/* @ts-ignore declare props in later pass */}
{this.props.log.map((line, i) => { {this.props.log.map((line, i) => {
// @ts-ignore react memo not passing props
return <Line key={i} line={line} />; return <Line key={i} line={line} />;
})} })}
</Box> </Box>

View File

@ -15,10 +15,11 @@ export class Input extends Component {
componentDidUpdate() { componentDidUpdate() {
if ( if (
!document.activeElement == document.body document.activeElement == this.inputRef.current
|| document.activeElement == this.inputRef.current
) { ) {
// @ts-ignore ref type issues
this.inputRef.current.focus(); this.inputRef.current.focus();
// @ts-ignore ref type issues
this.inputRef.current.setSelectionRange(this.props.cursor, this.props.cursor); this.inputRef.current.setSelectionRange(this.props.cursor, this.props.cursor);
} }
} }
@ -26,7 +27,7 @@ export class Input extends Component {
keyPress(e) { keyPress(e) {
const key = e.key; const key = e.key;
// let paste and leap events pass // let paste and leap events pass
if ((e.getModifierState('Control') || event.getModifierState('Meta')) if ((e.getModifierState('Control') || e.getModifierState('Meta'))
&& (e.key === 'v' || e.key === '/')) { && (e.key === 'v' || e.key === '/')) {
return; return;
} }
@ -115,6 +116,7 @@ belt = { met: 'bac' };
onKeyDown={this.keyPress} onKeyDown={this.keyPress}
onClick={this.click} onClick={this.click}
onPaste={this.paste} onPaste={this.paste}
// @ts-ignore indigo-react doesn't let us pass refs
ref={this.inputRef} ref={this.inputRef}
defaultValue="connecting..." defaultValue="connecting..."
value={prompt} value={prompt}

View File

@ -1,6 +1,6 @@
import { Text } from '@tlon/indigo-react'; import { Text } from '@tlon/indigo-react';
import React from 'react'; import React from 'react';
// @ts-ignore line isn't in props?
export default React.memo(({ line }) => { export default React.memo(({ line }) => {
// line body to jsx // line body to jsx
// NOTE lines are lists of characters that might span multiple codepoints // NOTE lines are lists of characters that might span multiple codepoints

View File

@ -46,7 +46,7 @@ export function CommentItem(props: CommentItemProps) {
const children = Array.from(revs.children); const children = Array.from(revs.children);
const indices = []; const indices = [];
for (const child in children) { for (const child in children) {
const node = children[child]; const node = children[child] as any;
if (!node?.post || typeof node.post !== 'string') { if (!node?.post || typeof node.post !== 'string') {
indices.push(node.post?.index); indices.push(node.post?.index);
} }

View File

@ -129,6 +129,7 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>): ReactElement {
return ( return (
<Box {...rest} position="relative" zIndex={9}> <Box {...rest} position="relative" zIndex={9}>
{ /* @ts-ignore investigate onblur on styled-system component later */}
<Input <Input
ref={textarea} ref={textarea}
onChange={changeCallback} onChange={changeCallback}

View File

@ -1,28 +1,32 @@
import { FormikConfig, FormikProvider, FormikValues, useFormik } from 'formik'; import { FormikConfig, FormikProvider, FormikValues, useFormik } from 'formik';
import React, { useEffect, useImperativeHandle } from 'react'; import React, { useEffect, useImperativeHandle, useState } from 'react';
export function FormikOnBlur< export function FormikOnBlur<
Values extends FormikValues = FormikValues, Values extends FormikValues = FormikValues,
ExtraProps = {} ExtraProps = {}
>(props: FormikConfig<Values> & ExtraProps) { >(props: FormikConfig<Values> & ExtraProps) {
const formikBag = useFormik<Values>({ ...props, validateOnBlur: true }); const formikBag = useFormik<Values>({ ...props, validateOnBlur: true });
const [submitting, setSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
if ( if (
Object.keys(formikBag.errors || {}).length === 0 && Object.keys(formikBag.errors || {}).length === 0 &&
Object.keys(formikBag.touched || {}).length !== 0 && formikBag.dirty &&
!formikBag.isSubmitting !formikBag.isSubmitting &&
!submitting
) { ) {
setSubmitting(true);
const { values } = formikBag; const { values } = formikBag;
formikBag.submitForm().then(() => { formikBag.submitForm().then(() => {
formikBag.resetForm({ values, touched: {} }); formikBag.resetForm({ values })
setSubmitting(false);
}); });
} }
}, [ }, [
formikBag.errors, formikBag.errors,
formikBag.touched, formikBag.dirty,
formikBag.submitForm, submitting,
formikBag.values formikBag.isSubmitting
]); ]);
const { children, innerRef } = props; const { children, innerRef } = props;

View File

@ -77,16 +77,6 @@ export function GroupLink(
<Box pt='1' ml='2' display='flex' alignItems='center'> <Box pt='1' ml='2' display='flex' alignItems='center'>
{preview ? {preview ?
<> <>
<Box pr='2' display='flex' alignItems='center'>
<Icon
icon={preview.metadata.hidden ? 'Locked' : 'Public'}
color='gray'
mr='1'
/>
<Text fontSize='0' color='gray'>
{preview.metadata.hidden ? 'Private' : 'Public'}
</Text>
</Box>
<Box display='flex' alignItems='center'> <Box display='flex' alignItems='center'>
<Icon icon='Users' color='gray' mr='1' /> <Icon icon='Users' color='gray' mr='1' />
<Text fontSize='0'color='gray' > <Text fontSize='0'color='gray' >

View File

@ -6,6 +6,7 @@ import {
ErrorLabel, Icon, Label, ErrorLabel, Icon, Label,
Row, Text Row, Text
} from '@tlon/indigo-react'; } from '@tlon/indigo-react';
import { OpenPolicy } from '@urbit/api';
import { Association } from '@urbit/api/metadata'; import { Association } from '@urbit/api/metadata';
import { FieldArray, useFormikContext } from 'formik'; import { FieldArray, useFormikContext } from 'formik';
import _ from 'lodash'; import _ from 'lodash';
@ -100,7 +101,7 @@ export function GroupSearch<I extends string, V extends FormValues<I>>(props: Gr
return Object.values( return Object.values(
Object.keys(associations.groups) Object.keys(associations.groups)
.filter( .filter(
e => groupState?.[e]?.policy?.open e => (groupState?.[e]?.policy as OpenPolicy)?.open
) )
.reduce((obj, key) => { .reduce((obj, key) => {
obj[key] = associations.groups[key]; obj[key] = associations.groups[key];

View File

@ -13,7 +13,7 @@ import useStorage from '~/logic/lib/useStorage';
type ImageInputProps = Parameters<typeof Box>[0] & { type ImageInputProps = Parameters<typeof Box>[0] & {
id: string; id: string;
label: string; label?: string;
placeholder?: string; placeholder?: string;
}; };

View File

@ -10,7 +10,7 @@ import {
Metadata, MetadataUpdatePreview, Metadata, MetadataUpdatePreview,
resourceFromPath resourceFromPath
} from '@urbit/api'; } from '@urbit/api';
import { GraphConfig } from '@urbit/api/dist'; import { GraphConfig } from '@urbit/api';
import _ from 'lodash'; import _ from 'lodash';
import React, { ReactElement, ReactNode, useCallback } from 'react'; import React, { ReactElement, ReactNode, useCallback } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
@ -217,6 +217,7 @@ function InviteActions(props: {
const hideJoin = useCallback(async (e) => { const hideJoin = useCallback(async (e) => {
if(status?.progress === 'done') { if(status?.progress === 'done') {
set(s => { set(s => {
// @ts-ignore investigate zustand types
delete s.pendingJoin[resource] delete s.pendingJoin[resource]
}); });
e.stopPropagation(); e.stopPropagation();
@ -245,14 +246,14 @@ function InviteActions(props: {
color="blue" color="blue"
height={4} height={4}
backgroundColor="white" backgroundColor="white"
onClick={inviteAccept} onClick={inviteAccept as any}
> >
Accept Accept
</StatelessAsyncButton> </StatelessAsyncButton>
<StatelessAsyncButton <StatelessAsyncButton
height={4} height={4}
backgroundColor="white" backgroundColor="white"
onClick={inviteDecline} onClick={inviteDecline as any}
> >
Decline Decline
</StatelessAsyncButton> </StatelessAsyncButton>

View File

@ -218,7 +218,7 @@ const ProfileOverlay = (props: ProfileOverlayProps) => {
textOverflow='ellipsis' textOverflow='ellipsis'
overflow='hidden' overflow='hidden'
whiteSpace='pre' whiteSpace='pre'
marginBottom={0} mb={0}
disableRemoteContent disableRemoteContent
gray gray
title={contact?.status ? contact.status : ''} title={contact?.status ? contact.status : ''}

View File

@ -41,7 +41,7 @@ export const ProfileStatus = (props) => {
<Input <Input
onChange={onStatusChange} onChange={onStatusChange}
value={_status} value={_status}
autocomplete='off' autoComplete='off'
width='100%' width='100%'
placeholder='Set Status' placeholder='Set Status'
onKeyPress={(evt) => { onKeyPress={(evt) => {

View File

@ -8,15 +8,15 @@ const ReconnectButton = ({ connection, subscription }) => {
if (connectedStatus === 'disconnected') { if (connectedStatus === 'disconnected') {
return ( return (
<Button onClick={reconnect} borderColor='red' px={2}> <Button onClick={reconnect} borderColor='red' px={2}>
<Text display={['none', 'inline']} textAlign='middle' color='red'>Reconnect</Text> <Text display={['none', 'inline']} textAlign='center' color='red'>Reconnect</Text>
<Text color='red'> </Text> <Text color='red'> </Text>
</Button> </Button>
); );
} else if (connectedStatus === 'reconnecting') { } else if (connectedStatus === 'reconnecting') {
return ( return (
<Button borderColor='yellow' px={2} onClick={() => {}} cursor='default'> <Button borderColor='yellow' px={2} onClick={() => {}} cursor='default'>
<LoadingSpinner pr={['0','2']} foreground='scales.yellow60' background='scales.yellow30' /> <LoadingSpinner foreground='scales.yellow60' background='scales.yellow30' />
<Text display={['none', 'inline']} textAlign='middle' color='yellow'>Reconnecting</Text> <Text display={['none', 'inline']} pl={['0','2']} textAlign='center' color='yellow'>Reconnecting</Text>
</Button> </Button>
); );
} else { } else {

View File

@ -25,7 +25,7 @@ const DISABLED_BLOCK_TOKENS = [
const DISABLED_INLINE_TOKENS = []; const DISABLED_INLINE_TOKENS = [];
type RichTextProps = ReactMarkdownProps & { type RichTextProps = ReactMarkdownProps & {
api: GlobalApi; api?: GlobalApi;
disableRemoteContent?: boolean; disableRemoteContent?: boolean;
contact?: Contact; contact?: Contact;
group?: Group; group?: Group;
@ -35,7 +35,21 @@ type RichTextProps = ReactMarkdownProps & {
color?: string; color?: string;
children?: any; children?: any;
width?: string; width?: string;
} & PropFunc<typeof Box>; display?: string[] | string;
mono?: boolean;
mb?: number;
minWidth?: number | string;
maxWidth?: number | string;
flexShrink?: number;
textOverflow?: string;
overflow?: string;
whiteSpace?: string;
gray?: boolean;
title?: string;
py?: number;
overflowX?: any;
verticalAlign?: any;
};
const RichText = React.memo(({ disableRemoteContent = false, api, ...props }: RichTextProps) => ( const RichText = React.memo(({ disableRemoteContent = false, api, ...props }: RichTextProps) => (
<ReactMarkdown <ReactMarkdown
@ -49,6 +63,7 @@ const RichText = React.memo(({ disableRemoteContent = false, api, ...props }: Ri
oembedShown: false oembedShown: false
} : null; } : null;
if (!disableRemoteContent) { if (!disableRemoteContent) {
// @ts-ignore RemoteContent weirdness
return <RemoteContent className="mw-100" url={linkProps.href} />; return <RemoteContent className="mw-100" url={linkProps.href} />;
} }
@ -61,15 +76,15 @@ const RichText = React.memo(({ disableRemoteContent = false, api, ...props }: Ri
remoteContentPolicy={remoteContentPolicy} remoteContentPolicy={remoteContentPolicy}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
}} }}
{...linkProps} {...linkProps}
>{linkProps.children}</Anchor> >{linkProps.children}</Anchor>
); );
}, },
linkReference: (linkProps) => { linkReference: (linkProps): any => {
const linkText = String(linkProps.children[0].props.children); const linkText = String(linkProps.children[0].props.children);
if (isValidPatp(linkText)) { if (isValidPatp(linkText)) {
return <Mention contact={props.contact || {}} group={props.group} ship={deSig(linkText)} api={api} />; return <Mention ship={deSig(linkText)} api={api} />;
} else if(linkText.startsWith('web+urbitgraph://')) { } else if(linkText.startsWith('web+urbitgraph://')) {
return ( return (
<PermalinkEmbed <PermalinkEmbed

View File

@ -10,7 +10,6 @@ const Spinner = ({
<LoadingSpinner <LoadingSpinner
foreground='black' foreground='black'
background='gray' background='gray'
style={{ flexShrink: 0 }}
/> />
<Text display='inline-block' ml={2} verticalAlign='middle' flexShrink={0}>{text}</Text> <Text display='inline-block' ml={2} verticalAlign='middle' flexShrink={0}>{text}</Text>
</Text> </Text>

View File

@ -23,7 +23,7 @@ export function StatelessAsyncAction({
return ( return (
<Action <Action
height="18px" height="16px"
hideDisabled={!disabled} hideDisabled={!disabled}
disabled={disabled || state === 'loading'} disabled={disabled || state === 'loading'}
onClick={handleClick} {...rest} onClick={handleClick} {...rest}

View File

@ -1,12 +1,12 @@
import { import {
BaseImage, Box, BaseImage,
Box,
Button, Col, Button,
Col,
Icon, Row, Icon,
Row,
Text Text,
} from '@tlon/indigo-react'; } from "@tlon/indigo-react";
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { Sigil } from '~/logic/lib/sigil'; import { Sigil } from '~/logic/lib/sigil';
@ -73,7 +73,7 @@ const StatusBar = (props) => {
px={3} px={3}
pb={3} pb={3}
> >
<Row collapse> <Row>
<Button <Button
width='32px' width='32px'
borderColor='lightGray' borderColor='lightGray'
@ -108,14 +108,15 @@ const StatusBar = (props) => {
subscription={props.subscription} subscription={props.subscription}
/> />
</Row> </Row>
<Row justifyContent='flex-end' collapse> <Row justifyContent='flex-end'>
<StatusBarItem <StatusBarItem
width='32px'
mr={2} mr={2}
backgroundColor='yellow' backgroundColor='yellow'
display={ display={
process.env.LANDSCAPE_STREAM === 'development' ? 'flex' : 'none' process.env.LANDSCAPE_STREAM === 'development' ? 'flex' : 'none'
} }
justifyContent='flex-end' justifyContent='center'
flexShrink={0} flexShrink={0}
onClick={() => onClick={() =>
window.open( window.open(
@ -190,6 +191,7 @@ const StatusBar = (props) => {
px={xPadding} px={xPadding}
width='32px' width='32px'
flexShrink={0} flexShrink={0}
border={0}
backgroundColor={bgColor} backgroundColor={bgColor}
> >
{profileImage} {profileImage}

View File

@ -100,7 +100,9 @@ export function Omnibox(props: OmniboxProps): ReactElement {
} }
Mousetrap.bind('escape', props.toggle); Mousetrap.bind('escape', props.toggle);
const touchstart = new Event('touchstart'); const touchstart = new Event('touchstart');
// @ts-ignore
inputRef?.current?.input?.dispatchEvent(touchstart); inputRef?.current?.input?.dispatchEvent(touchstart);
// @ts-ignore
inputRef?.current?.input?.focus(); inputRef?.current?.input?.focus();
return () => { return () => {
Mousetrap.unbind('escape'); Mousetrap.unbind('escape');
@ -173,6 +175,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
const totalLength = flattenedResults.length; const totalLength = flattenedResults.length;
if (selected.length) { if (selected.length) {
const currentIndex = flattenedResults.indexOf( const currentIndex = flattenedResults.indexOf(
// @ts-ignore unclear how to give this spread a return signature
...flattenedResults.filter((e) => { ...flattenedResults.filter((e) => {
return e.link === selected[1]; return e.link === selected[1];
}) })
@ -194,6 +197,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
const flattenedResults = Array.from(results.values()).flat(); const flattenedResults = Array.from(results.values()).flat();
if (selected.length) { if (selected.length) {
const currentIndex = flattenedResults.indexOf( const currentIndex = flattenedResults.indexOf(
// @ts-ignore unclear how to give this spread a return signature
...flattenedResults.filter((e) => { ...flattenedResults.filter((e) => {
return e.link === selected[1]; return e.link === selected[1];
}) })
@ -325,6 +329,7 @@ export function Omnibox(props: OmniboxProps): ReactElement {
{categoryResults.sort(sortResults).map((result, i2) => ( {categoryResults.sort(sortResults).map((result, i2) => (
<OmniboxResult <OmniboxResult
key={i2} key={i2}
// @ts-ignore withHovering doesn't pass props
icon={result.app} icon={result.app}
text={result.title} text={result.title}
subtext={result.host} subtext={result.host}
@ -365,8 +370,10 @@ export function Omnibox(props: OmniboxProps): ReactElement {
omniboxRef.current = el; omniboxRef.current = el;
}} }}
> >
{ /* @ts-ignore investigate zustand types */ }
<OmniboxInput <OmniboxInput
ref={(el) => { ref={(el) => {
// @ts-ignore investigate refs
inputRef.current = el; inputRef.current = el;
}} }}
control={e => control(e)} control={e => control(e)}
@ -380,5 +387,5 @@ export function Omnibox(props: OmniboxProps): ReactElement {
</Portal> </Portal>
); );
} }
// @ts-ignore investigate zustand types
export default withLocalState(Omnibox, ['toggleOmnibox', 'omniboxShown']); export default withLocalState(Omnibox, ['toggleOmnibox', 'omniboxShown']);

View File

@ -4,6 +4,7 @@ import React, { Component, ReactElement } from 'react';
import defaultApps from '~/logic/lib/default-apps'; import defaultApps from '~/logic/lib/default-apps';
import Sigil from '~/logic/lib/sigil'; import Sigil from '~/logic/lib/sigil';
import { cite, uxToHex } from '~/logic/lib/util'; import { cite, uxToHex } from '~/logic/lib/util';
import { IconRef } from '~/types/util';
import withState from '~/logic/lib/withState'; import withState from '~/logic/lib/withState';
import useContactState from '~/logic/state/contact'; import useContactState from '~/logic/state/contact';
import useHarkState from '~/logic/state/hark'; import useHarkState from '~/logic/state/hark';
@ -18,6 +19,7 @@ interface OmniboxResultProps {
link: string; link: string;
navigate: () => void; navigate: () => void;
notificationsCount: number; notificationsCount: number;
runtimeLag: any;
selected: string; selected: string;
setSelection: () => void; setSelection: () => void;
subtext: string; subtext: string;
@ -50,6 +52,7 @@ export class OmniboxResult extends Component<OmniboxResultProps, OmniboxResultSt
props.selected === props.link props.selected === props.link
&& this.result.current && this.result.current
) { ) {
// @ts-ignore ref is forwarded as never, investigate later
this.result.current.scrollIntoView({ block: 'nearest' }); this.result.current.scrollIntoView({ block: 'nearest' });
} }
} }
@ -63,7 +66,7 @@ export class OmniboxResult extends Component<OmniboxResultProps, OmniboxResultSt
notificationsCount: number, notificationsCount: number,
text: string, text: string,
color: string color: string
): (typeof Icon) { ): (any) {
const iconFill = const iconFill =
(this.state.hovered || selected === link) ? 'white' : 'black'; (this.state.hovered || selected === link) ? 'white' : 'black';
const bulletFill = const bulletFill =
@ -81,7 +84,7 @@ export class OmniboxResult extends Component<OmniboxResultProps, OmniboxResultSt
icon.toLowerCase() === 'terminal' icon.toLowerCase() === 'terminal'
) { ) {
if (icon === 'Link') { if (icon === 'Link') {
link = 'Collection'; icon = 'Collection';
} else if (icon === 'Terminal') { } else if (icon === 'Terminal') {
icon = 'Dojo'; icon = 'Dojo';
} }
@ -89,7 +92,7 @@ export class OmniboxResult extends Component<OmniboxResultProps, OmniboxResultSt
<Icon <Icon
display='inline-block' display='inline-block'
verticalAlign='middle' verticalAlign='middle'
icon={icon} icon={icon as IconRef}
mr={2} mr={2}
size='18px' size='18px'
color={iconFill} color={iconFill}
@ -254,6 +257,7 @@ export class OmniboxResult extends Component<OmniboxResultProps, OmniboxResultSt
onClick={navigate} onClick={navigate}
width='100%' width='100%'
justifyContent='space-between' justifyContent='space-between'
// @ts-ignore indigo-react doesn't allow us to pass refs
ref={this.result} ref={this.result}
> >
<Box <Box

View File

@ -1,18 +0,0 @@
import deep_diff from 'deep-diff';
import React, { Component, useEffect, useRef } from 'react';
const withPropsChecker = (WrappedComponent: Component) => {
return (props: any) => {
const prevProps = useRef(props);
useEffect(() => {
const diff = deep_diff.diff(prevProps.current, props);
if (diff) {
console.log(diff);
}
prevProps.current = props;
});
return <WrappedComponent {...props} />;
};
};
export default withPropsChecker;

View File

@ -1,6 +1,6 @@
import { Box } from '@tlon/indigo-react'; import { Box } from '@tlon/indigo-react';
import React, { useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { Route, Switch } from 'react-router-dom'; import { Route, Switch, useHistory } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState'; import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import LaunchApp from '~/views/apps/launch/App'; import LaunchApp from '~/views/apps/launch/App';
@ -10,6 +10,8 @@ import Profile from '~/views/apps/profile/profile';
import Settings from '~/views/apps/settings/settings'; import Settings from '~/views/apps/settings/settings';
import TermApp from '~/views/apps/term/app'; import TermApp from '~/views/apps/term/app';
import ErrorComponent from '~/views/components/Error'; import ErrorComponent from '~/views/components/Error';
import { useShortcut } from '~/logic/state/settings';
import Landscape from '~/views/landscape/index'; import Landscape from '~/views/landscape/index';
import GraphApp from '../../apps/graph/App'; import GraphApp from '../../apps/graph/App';
@ -21,6 +23,21 @@ export const Container = styled(Box)`
`; `;
export const Content = (props) => { export const Content = (props) => {
const history = useHistory();
useShortcut('navForward', useCallback((e) => {
e.preventDefault();
e.stopImmediatePropagation();
history.goForward();
}, [history.goForward]));
useShortcut('navBack', useCallback((e) => {
e.preventDefault();
e.stopImmediatePropagation();
history.goBack();
}, [history.goBack]));
const [hasProtocol, setHasProtocol] = useLocalStorageState( const [hasProtocol, setHasProtocol] = useLocalStorageState(
'registeredProtocol', false 'registeredProtocol', false
); );

View File

@ -9,7 +9,7 @@ import { Content, ReferenceContent } from '@urbit/api';
import _ from 'lodash'; import _ from 'lodash';
import { import {
BlockContent, Content as AstContent, Parent, Root BlockContent, Content as AstContent, Parent, Root
} from 'mdast'; } from 'ts-mdast';
import React from 'react'; import React from 'react';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { referenceToPermalink } from '~/logic/lib/permalinks'; import { referenceToPermalink } from '~/logic/lib/permalinks';
@ -350,12 +350,18 @@ const renderers = {
'graph-mention': ({ ship }) => <Mention api={{} as any} ship={ship} />, 'graph-mention': ({ ship }) => <Mention api={{} as any} ship={ship} />,
image: ({ url }) => ( image: ({ url }) => (
<Box mt="1" mb="2" flexShrink={0}> <Box mt="1" mb="2" flexShrink={0}>
<RemoteContent key={url} url={url} /> <RemoteContent
// @ts-ignore RemoteContent weirdness
key={url} url={url}
/>
</Box> </Box>
), ),
'graph-url': ({ url }) => ( 'graph-url': ({ url }) => (
<Box mt={1} mb={2} flexShrink={0}> <Box mt={1} mb={2} flexShrink={0}>
<RemoteContent key={url} url={url} /> <RemoteContent
// @ts-ignore RemoteContent weirdness
key={url} url={url}
/>
</Box> </Box>
), ),
'graph-reference': ({ api, reference, transcluded }) => { 'graph-reference': ({ api, reference, transcluded }) => {

View File

@ -1,11 +1,11 @@
import { Col, Row, Text } from '@tlon/indigo-react'; import { Col, Row, Text, Icon } from '@tlon/indigo-react';
import { Metadata } from '@urbit/api'; import { Metadata } from '@urbit/api';
import React, { ReactElement, ReactNode, useRef } from 'react'; import React, { ReactElement, ReactNode, useRef } from 'react';
import { TUTORIAL_GROUP, TUTORIAL_HOST } from '~/logic/lib/tutorialModal'; import { TUTORIAL_GROUP, TUTORIAL_HOST } from '~/logic/lib/tutorialModal';
import { PropFunc } from '~/types'; import { PropFunc, IconRef } from '~/types';
import { useTutorialModal } from '~/views/components/useTutorialModal'; import { useTutorialModal } from '~/views/components/useTutorialModal';
import { MetadataIcon } from './MetadataIcon'; import { MetadataIcon } from './MetadataIcon';
import { useCopy } from '~/logic/lib/useCopy';
interface GroupSummaryProps { interface GroupSummaryProps {
metadata: Metadata; metadata: Metadata;
memberCount: number; memberCount: number;
@ -13,6 +13,8 @@ interface GroupSummaryProps {
resource?: string; resource?: string;
children?: ReactNode; children?: ReactNode;
gray?: boolean; gray?: boolean;
AllowCopy?: boolean;
locked?: boolean;
} }
export function GroupSummary(props: GroupSummaryProps & PropFunc<typeof Col>): ReactElement { export function GroupSummary(props: GroupSummaryProps & PropFunc<typeof Col>): ReactElement {
@ -23,6 +25,7 @@ export function GroupSummary(props: GroupSummaryProps & PropFunc<typeof Col>): R
resource === `/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`, resource === `/ship/${TUTORIAL_HOST}/${TUTORIAL_GROUP}`,
anchorRef anchorRef
); );
const { doCopy, copyDisplay } = useCopy(`web+urbitgraph://group${resource?.slice(5)}`, "Copy", "Checkmark");
return ( return (
<Col {...rest} ref={anchorRef} gapY={4} maxWidth={['100%', '288px']}> <Col {...rest} ref={anchorRef} gapY={4} maxWidth={['100%', '288px']}>
<Row gapX={2} width="100%"> <Row gapX={2} width="100%">
@ -33,13 +36,24 @@ export function GroupSummary(props: GroupSummaryProps & PropFunc<typeof Col>): R
flexShrink={0} flexShrink={0}
/> />
<Col justifyContent="space-between" flexGrow={1} overflow="hidden"> <Col justifyContent="space-between" flexGrow={1} overflow="hidden">
<Row justifyContent="space-between">
<Text <Text
fontSize={1} fontSize={1}
textOverflow="ellipsis" textOverflow="ellipsis"
whiteSpace="nowrap" whiteSpace="nowrap"
overflow="hidden" overflow="hidden"
>{metadata.title}</Text> >{metadata.title}
<Row gapX={4} > </Text>
{props?.AllowCopy &&
<Icon
color="gray"
icon={props?.locked ? "Locked" : copyDisplay as IconRef}
onClick={!props?.locked ? doCopy : null}
cursor={props?.locked ? "default" : "pointer"}
/>
}
</Row>
<Row gapX={4} justifyContent="space-between">
<Text fontSize={1} gray> <Text fontSize={1} gray>
{memberCount} participants {memberCount} participants
</Text> </Text>

View File

@ -23,9 +23,11 @@ export function EmptyGroupHome(props) {
{ groupAssociation?.group ? ( { groupAssociation?.group ? (
<GroupSummary <GroupSummary
memberCount={groups[groupAssociation.group].members.size} memberCount={groups[groupAssociation.group].members.size}
locked={Boolean('invite' in groups[groupAssociation.group].policy)}
channelCount={channelCount} channelCount={channelCount}
metadata={groupAssociation.metadata} metadata={groupAssociation.metadata}
resource={groupAssociation.group} resource={groupAssociation.group}
AllowCopy
/> />
) : ( ) : (
<Box p={4}> <Box p={4}>

View File

@ -8,6 +8,7 @@ import { AddFeedBanner } from './AddFeedBanner';
import { EmptyGroupHome } from './EmptyGroupHome'; import { EmptyGroupHome } from './EmptyGroupHome';
import { EnableGroupFeed } from './EnableGroupFeed'; import { EnableGroupFeed } from './EnableGroupFeed';
import { GroupFeed } from './GroupFeed'; import { GroupFeed } from './GroupFeed';
import {getFeedPath} from '~/logic/lib/util';
function GroupHome(props) { function GroupHome(props) {
const { const {
@ -21,23 +22,15 @@ function GroupHome(props) {
const associations = useMetadataState(state => state.associations); const associations = useMetadataState(state => state.associations);
const groups = useGroupState(state => state.groups); const groups = useGroupState(state => state.groups);
const metadata = associations?.groups[groupPath]?.metadata; const association = associations?.groups[groupPath];
const askFeedBanner = const feedPath = getFeedPath(association);
ship === `~${window.ship}` &&
metadata &&
metadata.config &&
'group' in metadata.config &&
metadata.config.group === null;
const isFeedEnabled = const askFeedBanner = feedPath === undefined;
metadata &&
metadata.config &&
(metadata.config as GroupConfig).group &&
'resource' in (metadata.config as GroupConfig).group;
const graphPath = (metadata.config as GroupConfig)?.group?.resource; const isFeedEnabled = !!feedPath;
const graphMetadata = associations?.graph[graphPath]?.metadata;
const graphMetadata = associations?.graph[feedPath]?.metadata;
return ( return (
<Box width="100%" height="100%" overflow="hidden"> <Box width="100%" height="100%" overflow="hidden">
@ -62,7 +55,7 @@ function GroupHome(props) {
) : null } ) : null }
<Route path={`${baseUrl}/feed`}> <Route path={`${baseUrl}/feed`}>
<GroupFeed <GroupFeed
graphPath={graphPath} graphPath={feedPath}
groupPath={groupPath} groupPath={groupPath}
vip={graphMetadata?.vip || ''} vip={graphMetadata?.vip || ''}
api={api} api={api}

View File

@ -1,5 +1,6 @@
import { Box, Col } from '@tlon/indigo-react'; import { Box, Col } from '@tlon/indigo-react';
import { Association, Graph, GraphNode, Group } from '@urbit/api'; import { Association, Graph, GraphNode, Group } from '@urbit/api';
import { History } from 'history';
import bigInt from 'big-integer'; import bigInt from 'big-integer';
import React from 'react'; import React from 'react';
import { withRouter } from 'react-router'; import { withRouter } from 'react-router';
@ -26,7 +27,7 @@ interface PostFeedProps {
pendingSize: number; pendingSize: number;
} }
class PostFeed extends React.Component<PostFeedProps, PostFeedState> { class PostFeed extends React.Component<PostFeedProps, any> {
isFetching: boolean; isFetching: boolean;
constructor(props) { constructor(props) {
super(props); super(props);
@ -36,7 +37,7 @@ class PostFeed extends React.Component<PostFeedProps, PostFeedState> {
this.fetchPosts = this.fetchPosts.bind(this); this.fetchPosts = this.fetchPosts.bind(this);
this.doNotFetch = this.doNotFetch.bind(this); this.doNotFetch = this.doNotFetch.bind(this);
} }
// @ts-ignore needs @liam-fitzgerald peek at props for virtualscroller
renderItem = React.forwardRef(({ index, scrollWindow }, ref) => { renderItem = React.forwardRef(({ index, scrollWindow }, ref) => {
const { const {
graph, graph,
@ -69,6 +70,7 @@ class PostFeed extends React.Component<PostFeedProps, PostFeedState> {
<React.Fragment key={index.toString()}> <React.Fragment key={index.toString()}>
<Col <Col
key={index.toString()} key={index.toString()}
// @ts-ignore indigo-react doesn't allow us to pass refs
ref={ref} ref={ref}
mb={3} mb={3}
width="100%" width="100%"
@ -76,6 +78,7 @@ class PostFeed extends React.Component<PostFeedProps, PostFeedState> {
> >
<PostItem <PostItem
key={parentNode.post.index} key={parentNode.post.index}
// @ts-ignore withHovering prop pass is broken?
parentPost={grandparentNode?.post} parentPost={grandparentNode?.post}
node={parentNode} node={parentNode}
parentNode={grandparentNode} parentNode={grandparentNode}
@ -92,6 +95,7 @@ class PostFeed extends React.Component<PostFeedProps, PostFeedState> {
/> />
</Col> </Col>
<PostItem <PostItem
// @ts-ignore withHovering prop pass is broken?
node={node} node={node}
graphPath={graphPath} graphPath={graphPath}
association={association} association={association}
@ -110,8 +114,10 @@ class PostFeed extends React.Component<PostFeedProps, PostFeedState> {
} }
return ( return (
// @ts-ignore indigo-react doesn't allow us to pass refs
<Box key={index.toString()} ref={ref}> <Box key={index.toString()} ref={ref}>
<PostItem <PostItem
// @ts-ignore withHovering prop pass is broken?
node={node} node={node}
graphPath={graphPath} graphPath={graphPath}
association={association} association={association}
@ -179,6 +185,7 @@ class PostFeed extends React.Component<PostFeedProps, PostFeedState> {
data={graph} data={graph}
averageHeight={106} averageHeight={106}
size={graph.size} size={graph.size}
totalSize={graph.size}
style={virtualScrollerStyle} style={virtualScrollerStyle}
pendingSize={pendingSize} pendingSize={pendingSize}
renderer={this.renderItem} renderer={this.renderItem}

View File

@ -120,7 +120,7 @@ const PostInput = (props: PostInputProps): ReactElement | null => {
fontSize={1} fontSize={1}
minHeight="62px" minHeight="62px"
fontFamily={code ? 'mono' : 'sans'} fontFamily={code ? 'mono' : 'sans'}
lineNumber={3} rows={3}
style={{ style={{
resize: 'vertical' resize: 'vertical'
}} }}

View File

@ -15,6 +15,7 @@ interface PostHeaderProps {
association: Association; association: Association;
isReply: boolean; isReply: boolean;
showTimestamp: boolean; showTimestamp: boolean;
graphPath: any;
} }
const PostHeader = (props: PostHeaderProps): ReactElement => { const PostHeader = (props: PostHeaderProps): ReactElement => {

View File

@ -1,4 +1,5 @@
import { Box, Col, Text } from '@tlon/indigo-react'; import { Box, Col, Text } from '@tlon/indigo-react';
import { GraphNode } from '@urbit/api';
import bigInt from 'big-integer'; import bigInt from 'big-integer';
import React from 'react'; import React from 'react';
import { resourceFromPath } from '~/logic/lib/group'; import { resourceFromPath } from '~/logic/lib/group';
@ -37,7 +38,7 @@ export default function PostReplies(props) {
return bigInt(ind); return bigInt(ind);
}); });
let node; let node: GraphNode;
let parentNode; let parentNode;
nodeIndex.forEach((i, idx) => { nodeIndex.forEach((i, idx) => {
if (!graph) { if (!graph) {
@ -69,6 +70,7 @@ export default function PostReplies(props) {
<Box mt={3} width="100%" alignItems="center"> <Box mt={3} width="100%" alignItems="center">
<PostItem <PostItem
key={node.post.index} key={node.post.index}
// @ts-ignore withHovering prop pass is broken?
node={node} node={node}
graphPath={graphPath} graphPath={graphPath}
association={association} association={association}

View File

@ -1,5 +1,6 @@
import { Box, Col, Text } from '@tlon/indigo-react'; import { Box, Col, Text } from '@tlon/indigo-react';
import { Association, Graph, Group } from '@urbit/api'; import { Association, Graph, Group } from '@urbit/api';
import { History } from 'history';
import React, { ReactElement } from 'react'; import React, { ReactElement } from 'react';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import { Loading } from '~/views/components/Loading'; import { Loading } from '~/views/components/Loading';
@ -15,6 +16,7 @@ interface PostTimelineProps {
group: Group; group: Group;
pendingSize: number; pendingSize: number;
vip: string; vip: string;
history?: History;
} }
const PostTimeline = (props: PostTimelineProps): ReactElement => { const PostTimeline = (props: PostTimelineProps): ReactElement => {

View File

@ -31,7 +31,7 @@ interface NewGroupProps {
api: GlobalApi; api: GlobalApi;
} }
export function NewGroup(props: NewGroupProps & RouteComponentProps): ReactElement { export function NewGroup(props: NewGroupProps): ReactElement {
const { api } = props; const { api } = props;
const history = useHistory(); const history = useHistory();
const initialValues: FormSchema = { const initialValues: FormSchema = {

View File

@ -1,14 +1,16 @@
import React, { ReactElement } from 'react'; import React, { ReactElement, useCallback } from 'react';
import { AppAssociations, Associations, Graph, UnreadStats } from '@urbit/api'; import { AppAssociations, Associations, Graph, UnreadStats } from '@urbit/api';
import { patp, patp2dec } from 'urbit-ob'; import { patp, patp2dec } from 'urbit-ob';
import { SidebarAssociationItem, SidebarDmItem } from './SidebarItem'; import { SidebarAssociationItem, SidebarDmItem } from './SidebarItem';
import useMetadataState from '~/logic/state/metadata'; import useGraphState, {useInbox} from '~/logic/state/graph';
import {useInbox} from '~/logic/state/graph';
import useHarkState from '~/logic/state/hark'; import useHarkState from '~/logic/state/hark';
import { alphabeticalOrder } from '~/logic/lib/util'; import { alphabeticalOrder, getResourcePath, modulo } from '~/logic/lib/util';
import { Workspace } from '~/types/workspace';
import { SidebarAppConfigs, SidebarListConfig, SidebarSort } from './types'; import { SidebarAppConfigs, SidebarListConfig, SidebarSort } from './types';
import { Workspace } from '~/types/workspace';
import useMetadataState from '~/logic/state/metadata';
import {useHistory} from 'react-router';
import { useShortcut } from '~/logic/state/settings';
function sidebarSort( function sidebarSort(
associations: AppAssociations, associations: AppAssociations,
@ -46,7 +48,6 @@ function sidebarSort(
}; };
} }
function getItems(associations: Associations, workspace: Workspace, inbox: Graph) { function getItems(associations: Associations, workspace: Workspace, inbox: Graph) {
const filtered = Object.keys(associations.graph).filter((a) => { const filtered = Object.keys(associations.graph).filter((a) => {
const assoc = associations.graph[a]; const assoc = associations.graph[a];
@ -95,11 +96,39 @@ export function SidebarList(props: {
const associations = useMetadataState(state => state.associations); const associations = useMetadataState(state => state.associations);
const inbox = useInbox(); const inbox = useInbox();
const unreads = useHarkState(s => s.unreads.graph?.[`/ship/~${window.ship}/dm-inbox`]); const unreads = useHarkState(s => s.unreads.graph?.[`/ship/~${window.ship}/dm-inbox`]);
const graphKeys = useGraphState(s => s.graphKeys);
const ordered = getItems(associations, workspace, inbox) const ordered = getItems(associations, workspace, inbox)
.sort(sidebarSort(associations.graph, props.apps, unreads)[config.sortBy]); .sort(sidebarSort(associations.graph, props.apps, unreads)[config.sortBy]);
const history = useHistory();
const cycleChannels = useCallback((backward: boolean) => {
const idx = ordered.findIndex(s => s === selected);
const offset = backward ? -1 : 1
const newIdx = modulo(idx+offset, ordered.length - 1);
const { metadata, resource } = associations[ordered[newIdx]];
const joined = graphKeys.has(resource.slice(7));
let path = '/~landscape/home';
if ('graph' in metadata.config) {
path = getResourcePath(workspace, resource, joined, metadata.config.graph);
}
history.push(path)
}, [selected, history.push]);
useShortcut('cycleForward', useCallback((e: KeyboardEvent) => {
cycleChannels(false);
e.preventDefault();
}, [cycleChannels]));
useShortcut('cycleBack', useCallback((e: KeyboardEvent) => {
cycleChannels(true);
e.preventDefault();
}, [cycleChannels]))
return ( return (
<> <>
{ordered.map((pathOrShip) => { {ordered.map((pathOrShip) => {

View File

@ -21,6 +21,7 @@ import { Dropdown } from '~/views/components/Dropdown';
import { FormikOnBlur } from '~/views/components/FormikOnBlur'; import { FormikOnBlur } from '~/views/components/FormikOnBlur';
import { NewChannel } from '~/views/landscape/components/NewChannel'; import { NewChannel } from '~/views/landscape/components/NewChannel';
import { SidebarListConfig } from './types'; import { SidebarListConfig } from './types';
import {getFeedPath} from '~/logic/lib/util';
export function SidebarListHeader(props: { export function SidebarListHeader(props: {
api: GlobalApi; api: GlobalApi;
@ -52,10 +53,7 @@ export function SidebarListHeader(props: {
const noun = (props.workspace?.type === 'messages') ? 'Messages' : 'Channels'; const noun = (props.workspace?.type === 'messages') ? 'Messages' : 'Channels';
let feedPath: string = null; let feedPath = groupPath ? getFeedPath(associations.groups[groupPath]) : undefined;
if (metadata?.config && 'group' in metadata?.config && metadata.config?.group && 'resource' in metadata.config.group) {
feedPath = metadata.config.group.resource;
}
const unreadCount = useHarkState( const unreadCount = useHarkState(
s => s.unreads?.graph?.[feedPath ?? '']?.['/']?.unreads as number ?? 0 s => s.unreads?.graph?.[feedPath ?? '']?.['/']?.unreads as number ?? 0

View File

@ -1,13 +1,14 @@
import React, { Children, ReactElement, ReactNode, useCallback, useMemo, useState } from 'react';
import { Sidebar } from './Sidebar/Sidebar';
import { AppName } from '@urbit/api'; import { AppName } from '@urbit/api';
import React, { ReactElement, ReactNode, useMemo } from 'react';
import GlobalApi from '~/logic/api/global'; import GlobalApi from '~/logic/api/global';
import useGraphState from '~/logic/state/graph'; import useGraphState from '~/logic/state/graph';
import useHarkState from '~/logic/state/hark'; import useHarkState from '~/logic/state/hark';
import { Workspace } from '~/types/workspace'; import { Workspace } from '~/types/workspace';
import { Body } from '~/views/components/Body'; import { Body } from '~/views/components/Body';
import ErrorBoundary from '~/views/components/ErrorBoundary'; import ErrorBoundary from '~/views/components/ErrorBoundary';
import { useShortcut } from '~/logic/state/settings';
import { useGraphModule } from './Sidebar/Apps'; import { useGraphModule } from './Sidebar/Apps';
import { Sidebar } from './Sidebar/Sidebar';
interface SkeletonProps { interface SkeletonProps {
children: ReactNode; children: ReactNode;
@ -21,6 +22,10 @@ interface SkeletonProps {
} }
export function Skeleton(props: SkeletonProps): ReactElement { export function Skeleton(props: SkeletonProps): ReactElement {
const [sidebar, setSidebar] = useState(true)
useShortcut('hideSidebar', useCallback(() => {
setSidebar(s => !s);
}, []));
const graphs = useGraphState(state => state.graphs); const graphs = useGraphState(state => state.graphs);
const graphKeys = useGraphState(state => state.graphKeys); const graphKeys = useGraphState(state => state.graphKeys);
const unreads = useHarkState(state => state.unreads); const unreads = useHarkState(state => state.unreads);
@ -32,7 +37,7 @@ export function Skeleton(props: SkeletonProps): ReactElement {
[graphConfig] [graphConfig]
); );
return ( return !sidebar ? (<Body> {props.children} </Body>) : (
<Body <Body
display="grid" display="grid"
gridTemplateColumns={ gridTemplateColumns={

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
if (process.env.NODE_ENV === 'development') { if (false && process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render'); const whyDidYouRender = require('@welldone-software/why-did-you-render');
whyDidYouRender(React, { whyDidYouRender(React, {
trackAllPureComponents: true trackAllPureComponents: true

View File

@ -20,7 +20,7 @@ export interface GraphNotifIndex {
graph: string; graph: string;
group: string; group: string;
description: GraphNotifDescription; description: GraphNotifDescription;
module: string; mark: string;
index: string; index: string;
} }

View File

@ -39,7 +39,7 @@ export default class BigIntOrderedMap<V> implements Iterable<[BigInteger, V]> {
items.forEach(([key, value]) => { items.forEach(([key, value]) => {
draft.root[key.toString()] = castDraft(value); draft.root[key.toString()] = castDraft(value);
}); });
draft.generateCachedIter(); draft.cachedIter = null;
}, },
(patches) => { (patches) => {
//console.log(`gassed with ${JSON.stringify(patches, null, 2)}`); //console.log(`gassed with ${JSON.stringify(patches, null, 2)}`);

View File

@ -1,4 +1,3 @@
import { Resource } from "..";
import { AppName, Path, Patp } from "../lib"; import { AppName, Path, Patp } from "../lib";
export type MetadataUpdate = export type MetadataUpdate =