From f4ddda2ce54b858085724498f5cf64221865dad9 Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Tue, 8 Jun 2021 12:41:15 +1000 Subject: [PATCH 01/66] @urbit/api: update types --- pkg/npm/api/graph/lib.ts | 8 ++++---- pkg/npm/api/graph/types.ts | 4 ++-- pkg/npm/api/hark/types.ts | 2 +- pkg/npm/api/metadata/types.ts | 12 ++++++------ 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/pkg/npm/api/graph/lib.ts b/pkg/npm/api/graph/lib.ts index 97e04fa86..048650774 100644 --- a/pkg/npm/api/graph/lib.ts +++ b/pkg/npm/api/graph/lib.ts @@ -5,14 +5,14 @@ import { deSig, unixToDa } from '../lib'; import { Enc, Path, Patp, PatpNoSig, Poke, Thread } from '../lib/types'; import { Content, Graph, GraphChildrenPoke, GraphNode, GraphNodePoke, Post } from './types'; -export const GRAPH_UPDATE_VERSION: number = 1; +export const GRAPH_UPDATE_VERSION: number = 2; export const createBlankNodeWithChildPost = ( ship: PatpNoSig, parentIndex: string = '', childIndex: string = '', contents: Content[] -): any => { // TODO should be GraphNode +): GraphNodePoke => { const date = unixToDa(Date.now()).toString(); const nodeIndex = parentIndex + '/' + date; @@ -256,9 +256,9 @@ export const addPost = ( export const addNode = ( ship: Patp, name: string, - node: GraphNode + node: GraphNodePoke ): Thread => { - let nodes: Record = {}; + let nodes: Record = {}; nodes[node.post.index] = node; return addNodes(ship, name, nodes); diff --git a/pkg/npm/api/graph/types.ts b/pkg/npm/api/graph/types.ts index 8afd0c0a8..22779dc31 100644 --- a/pkg/npm/api/graph/types.ts +++ b/pkg/npm/api/graph/types.ts @@ -63,12 +63,12 @@ export interface GraphChildrenPoke { export interface GraphNode { children: Graph | null; - post: Post; + post: Post | string; } export interface FlatGraphNode { children: null; - post: Post; + post: Post | string; } export type Graph = BigIntOrderedMap; diff --git a/pkg/npm/api/hark/types.ts b/pkg/npm/api/hark/types.ts index 76e8b7380..057e506a9 100644 --- a/pkg/npm/api/hark/types.ts +++ b/pkg/npm/api/hark/types.ts @@ -12,7 +12,7 @@ export interface UnreadStats { } interface NotifRef { - time: BigInteger; + time: BigInteger | undefined; index: NotifIndex; } diff --git a/pkg/npm/api/metadata/types.ts b/pkg/npm/api/metadata/types.ts index 9d1c7872a..1aa620943 100644 --- a/pkg/npm/api/metadata/types.ts +++ b/pkg/npm/api/metadata/types.ts @@ -44,13 +44,13 @@ export interface MetadataUpdatePreview { export type Associations = Record; -export type AppAssociations = { - [p in Path]: Association; +export type AppAssociations = { + [p in Path]: Association; } -export type Association = MdResource & { +export type Association = MdResource & { group: Path; - metadata: Metadata; + metadata: Metadata; }; export interface AssociationPoke { @@ -59,13 +59,13 @@ export interface AssociationPoke { metadata: Metadata; } -export interface Metadata { +export interface Metadata { color: string; creator: Patp; 'date-created': string; description: string; title: string; - config: MetadataConfig; + config: C; hidden: boolean; picture: string; preview: boolean; From 2cba9b1a0b42d55075f9448fabdeca5e01b55c92 Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Tue, 8 Jun 2021 14:25:43 +1000 Subject: [PATCH 02/66] @urbit/http-api: fix package configuration --- pkg/npm/http-api/Urbit.ts | 521 ----------------------------- pkg/npm/http-api/index.ts | 3 - pkg/npm/http-api/package-lock.json | Bin 245107 -> 235481 bytes pkg/npm/http-api/src/Urbit.ts | 296 ++++++++++++---- pkg/npm/http-api/src/types.ts | 18 +- pkg/npm/http-api/tsconfig.json | 4 +- pkg/npm/http-api/types.ts | 72 ---- pkg/npm/http-api/utils.ts | 82 ----- 8 files changed, 239 insertions(+), 757 deletions(-) delete mode 100644 pkg/npm/http-api/Urbit.ts delete mode 100644 pkg/npm/http-api/index.ts delete mode 100644 pkg/npm/http-api/types.ts delete mode 100644 pkg/npm/http-api/utils.ts diff --git a/pkg/npm/http-api/Urbit.ts b/pkg/npm/http-api/Urbit.ts deleted file mode 100644 index 4816469a5..000000000 --- a/pkg/npm/http-api/Urbit.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { isBrowser, isNode } from 'browser-or-node'; -import { Action, Scry, Thread } from '@urbit/api'; -import { fetchEventSource, EventSourceMessage, EventStreamContentType } from '@microsoft/fetch-event-source'; - -import { AuthenticationInterface, SubscriptionInterface, CustomEventHandler, PokeInterface, SubscriptionRequestInterface, headers, UrbitInterface, SSEOptions, PokeHandlers, Message } from './types'; -import { uncamelize, hexString } from './utils'; - -/** - * A class for interacting with an urbit ship, given its URL and code - */ -export class Urbit implements UrbitInterface { - /** - * UID will be used for the channel: The current unix time plus a random hex string - */ - uid: string = `${Math.floor(Date.now() / 1000)}-${hexString(6)}`; - - /** - * Last Event ID is an auto-updated index of which events have been sent over this channel - */ - lastEventId: number = 0; - - lastAcknowledgedEventId: number = 0; - - /** - * SSE Client is null for now; we don't want to start polling until it the channel exists - */ - sseClientInitialized: boolean = false; - - /** - * Cookie gets set when we log in. - */ - cookie?: string | undefined; - - /** - * A registry of requestId to successFunc/failureFunc - * - * These functions are registered during a +poke and are executed - * in the onServerEvent()/onServerError() callbacks. Only one of - * the functions will be called, and the outstanding poke will be - * removed after calling the success or failure function. - */ - - outstandingPokes: Map = new Map(); - - /** - * A registry of requestId to subscription functions. - * - * These functions are registered during a +subscribe and are - * executed in the onServerEvent()/onServerError() callbacks. The - * event function will be called whenever a new piece of data on this - * subscription is available, which may be 0, 1, or many times. The - * disconnect function may be called exactly once. - */ - - outstandingSubscriptions: Map = new Map(); - - /** - * Ship can be set, in which case we can do some magic stuff like send chats - */ - ship?: string | null; - - /** - * If verbose, logs output eagerly. - */ - verbose?: boolean; - - onError?: (error: any) => void = null; - - /** This is basic interpolation to get the channel URL of an instantiated Urbit connection. */ - get channelUrl(): string { - return `${this.url}/~/channel/${this.uid}`; - } - - get fetchOptions(): any { - const headers: headers = { - 'Content-Type': 'application/json', - }; - if (!isBrowser) { - headers.Cookie = this.cookie; - } - return { - credentials: 'include', - accept: '*', - headers - }; - } - - /** - * Constructs a new Urbit connection. - * - * @param url The URL (with protocol and port) of the ship to be accessed - * @param code The access code for the ship at that address - */ - constructor( - public url: string, - public code: string - ) { - if (isBrowser) { - window.addEventListener('beforeunload', this.delete); - } - return this; - } - - /** - * All-in-one hook-me-up. - * - * Given a ship, url, and code, this returns an airlock connection - * that is ready to go. It `|hi`s itself to create the channel, - * then opens the channel via EventSource. - * - * @param AuthenticationInterface - */ - static async authenticate({ ship, url, code, verbose = false }: AuthenticationInterface) { - const airlock = new Urbit(`http://${url}`, code); - airlock.verbose = verbose; - airlock.ship = ship; - await airlock.connect(); - await airlock.poke({ app: 'hood', mark: 'helm-hi', json: 'opening airlock' }); - await airlock.eventSource(); - return airlock; - } - - /** - * Connects to the Urbit ship. Nothing can be done until this is called. - * That's why we roll it into this.authenticate - */ - async connect(): Promise { - if (this.verbose) { - console.log(`password=${this.code} `, isBrowser ? "Connecting in browser context at " + `${this.url}/~/login` : "Connecting from node context"); - } - return fetch(`${this.url}/~/login`, { - method: 'post', - body: `password=${this.code}`, - credentials: 'include', - }).then(response => { - if (this.verbose) { - console.log('Received authentication response', response); - } - const cookie = response.headers.get('set-cookie'); - if (!this.ship) { - this.ship = new RegExp(/urbauth-~([\w-]+)/).exec(cookie)[1]; - } - if (!isBrowser) { - this.cookie = cookie; - } - }); - } - - - /** - * Initializes the SSE pipe for the appropriate channel. - */ - eventSource(): void { - if (!this.sseClientInitialized) { - const sseOptions: SSEOptions = { - headers: {} - }; - if (isBrowser) { - sseOptions.withCredentials = true; - } else if (isNode) { - sseOptions.headers.Cookie = this.cookie; - } - if (this.lastEventId === 0) { - // Can't receive events until the channel is open - this.poke({ app: 'hood', mark: 'helm-hi', json: 'Opening API channel' }); - } - fetchEventSource(this.channelUrl, { - ...this.fetchOptions, - openWhenHidden: true, - onopen: async (response) => { - if (this.verbose) { - console.log('Opened eventsource', response); - } - if (response.ok && response.headers.get('content-type') === EventStreamContentType) { - return; // everything's good - } else if (response.status >= 400 && response.status < 500 && response.status !== 429) { - if (this.onError) { - this.onError(response.statusText); - } else { - throw new Error(); - } - } else { - if (this.onError) { - this.onError(response.statusText); - } else { - throw new Error(); - } - } - }, - onmessage: (event: EventSourceMessage) => { - if (this.verbose) { - console.log('Received SSE: ', event); - } - if (!event.id) return; - this.ack(Number(event.id)); - if (event.data && JSON.parse(event.data)) { - - const data: any = JSON.parse(event.data); - - if (data.response === 'diff') { - this.clearQueue(); - } - - if (data.response === 'poke' && this.outstandingPokes.has(data.id)) { - const funcs = this.outstandingPokes.get(data.id); - if (data.hasOwnProperty('ok')) { - funcs.onSuccess(); - } else if (data.hasOwnProperty('err')) { - funcs.onError(data.err); - } else { - console.error('Invalid poke response', data); - } - this.outstandingPokes.delete(data.id); - } else if (data.response === 'subscribe' || - (data.response === 'poke' && this.outstandingSubscriptions.has(data.id))) { - const funcs = this.outstandingSubscriptions.get(data.id); - if (data.hasOwnProperty('err')) { - funcs.err(data.err); - this.outstandingSubscriptions.delete(data.id); - } - } else if (data.response === 'diff' && this.outstandingSubscriptions.has(data.id)) { - const funcs = this.outstandingSubscriptions.get(data.id); - funcs.event(data.json); - } else if (data.response === 'quit' && this.outstandingSubscriptions.has(data.id)) { - const funcs = this.outstandingSubscriptions.get(data.id); - funcs.quit(data); - this.outstandingSubscriptions.delete(data.id); - } else { - console.log('Unrecognized response', data); - } - } - }, - onerror: (error) => { - if (this.onError) { - this.onError(error); - } else { - throw error; - } - } - }); - this.sseClientInitialized = true; - } - return; - } - - /** - * Autoincrements the next event ID for the appropriate channel. - */ - getEventId(): number { - this.lastEventId = Number(this.lastEventId) + 1; - return this.lastEventId; - } - - /** - * Acknowledges an event. - * - * @param eventId The event to acknowledge. - */ - async ack(eventId: number): Promise { - const message: Message = { - action: 'ack', - 'event-id': eventId - }; - await this.sendJSONtoChannel(message); - return eventId; - } - - /** - * This is a wrapper method that can be used to send any action with data. - * - * Every message sent has some common parameters, like method, headers, and data - * structure, so this method exists to prevent duplication. - * - * @param action The action to send - * @param data The data to send with the action - * - * @returns void | number If successful, returns the number of the message that was sent - */ - // async sendMessage(action: Action, data?: object): Promise { - // const id = this.getEventId(); - // if (this.verbose) { - // console.log(`Sending message ${id}:`, action, data,); - // } - // const message: Message = { id, action, ...data }; - // await this.sendJSONtoChannel(message); - // return id; - // } - - outstandingJSON: Message[] = []; - - debounceTimer: any = null; - debounceInterval = 500; - calm = true; - - sendJSONtoChannel(json: Message): Promise { - this.outstandingJSON.push(json); - return this.processQueue(); - } - - processQueue(): Promise { - return new Promise(async (resolve, reject) => { - const process = async () => { - if (this.calm) { - if (this.outstandingJSON.length === 0) resolve(true); - this.calm = false; // We are now occupied - const json = this.outstandingJSON; - const body = JSON.stringify(json); - this.outstandingJSON = []; - if (body === '[]') { - this.calm = true; - return resolve(false); - } - try { - await fetch(this.channelUrl, { - ...this.fetchOptions, - method: 'PUT', - body - }); - } catch (error) { - json.forEach(failed => this.outstandingJSON.push(failed)); - if (this.onError) { - this.onError(error); - } else { - throw error; - } - } - this.calm = true; - if (!this.sseClientInitialized) { - this.eventSource(); // We can open the channel for subscriptions once we've sent data over it - } - resolve(true); - } else { - clearTimeout(this.debounceTimer); - this.debounceTimer = setTimeout(process, this.debounceInterval); - resolve(false); - } - } - - this.debounceTimer = setTimeout(process, this.debounceInterval); - - - }); - } - - - - // resetDebounceTimer() { - // if (this.debounceTimer) { - // clearTimeout(this.debounceTimer); - // this.debounceTimer = null; - // } - // this.calm = false; - // this.debounceTimer = setTimeout(() => { - // this.calm = true; - // }, this.debounceInterval); - // } - - clearQueue() { - clearTimeout(this.debounceTimer); - this.debounceTimer = null; - } - - /** - * Pokes a ship with data. - * - * @param app The app to poke - * @param mark The mark of the data being sent - * @param json The data to send - */ - poke(params: PokeInterface): Promise { - const { - app, - mark, - json, - ship, - onSuccess, - onError - } = { - onSuccess: () => { }, - onError: () => { }, - ship: this.ship, - ...params - }; - return new Promise((resolve, reject) => { - const message: Message = { - id: this.getEventId(), - action: 'poke', - ship, - app, - mark, - json - }; - this.outstandingPokes.set(message.id, { - onSuccess: () => { - onSuccess(); - resolve(message.id); - }, - onError: (event) => { - onError(event); - reject(event.err); - } - }); - this.sendJSONtoChannel(message).then(() => { - resolve(message.id); - }); - }); - } - - /** - * Subscribes to a path on an app on a ship. - * - * @param app The app to subsribe to - * @param path The path to which to subscribe - * @param handlers Handlers to deal with various events of the subscription - */ - async subscribe(params: SubscriptionRequestInterface): Promise { - const { - app, - path, - ship, - err, - event, - quit - } = { - err: () => { }, - event: () => { }, - quit: () => { }, - ship: this.ship, - ...params - }; - - const message: Message = { - id: this.getEventId(), - action: 'subscribe', - ship, - app, - path - }; - - this.outstandingSubscriptions.set(message.id, { - app, path, err, event, quit - }); - - await this.sendJSONtoChannel(message); - - return message.id; - } - - /** - * Unsubscribes to a given subscription. - * - * @param subscription - */ - async unsubscribe(subscription: number) { - return this.sendJSONtoChannel({ - id: this.getEventId(), - action: 'unsubscribe', - subscription - }).then(() => { - this.outstandingSubscriptions.delete(subscription); - }); - } - - /** - * Deletes the connection to a channel. - */ - delete() { - if (isBrowser) { - navigator.sendBeacon(this.channelUrl, JSON.stringify([{ - action: 'delete' - }])); - } else { - // TODO - // this.sendMessage('delete'); - } - } - - /** - * - * @param app The app into which to scry - * @param path The path at which to scry - */ - async scry(params: Scry): Promise { - const { app, path } = params; - const response = await fetch(`${this.url}/~/scry/${app}${path}.json`, this.fetchOptions); - return await response.json(); - } - - /** - * - * @param inputMark The mark of the data being sent - * @param outputMark The mark of the data being returned - * @param threadName The thread to run - * @param body The data to send to the thread - */ - async thread(params: Thread): Promise { - const { inputMark, outputMark, threadName, body } = params; - const res = await fetch(`${this.url}/spider/${inputMark}/${threadName}/${outputMark}.json`, { - ...this.fetchOptions, - method: 'POST', - body: JSON.stringify(body) - }); - - return res.json(); - } - - /** - * Utility function to connect to a ship that has its *.arvo.network domain configured. - * - * @param name Name of the ship e.g. zod - * @param code Code to log in - */ - static async onArvoNetwork(ship: string, code: string): Promise { - const url = `https://${ship}.arvo.network`; - return await Urbit.authenticate({ ship, url, code }); - } -} - - - -export default Urbit; diff --git a/pkg/npm/http-api/index.ts b/pkg/npm/http-api/index.ts deleted file mode 100644 index 8e242ef6b..000000000 --- a/pkg/npm/http-api/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './types'; -import Urbit from './Urbit'; -export { Urbit as default }; \ No newline at end of file diff --git a/pkg/npm/http-api/package-lock.json b/pkg/npm/http-api/package-lock.json index 85f2af07f6c23ea47a3588f73432895e7750a76d..6a7b6bc1697a5e0e5db9d7bf4f256b975d8a60d2 100644 GIT binary patch delta 397 zcmV;80doHH`VQHz4v;YcF|jggQj>Oh5t9wl3Q}EbY?+wa&}B+Q&=-&QZ-U?LQ6qnS#3FDM{_ViD^Wc?B9qZg6tkRC5F(f0DFQZ= z@OlRcVQ^_LEiYkkX_wI|11MH+W^6=fbyILcHg!2xZES5~STZwOD`8V=O+!X%H&J43 zQAI~KS!!rnc6E9(Z)-O(F)v0nD?&6*c1KH3Y%oq{WJfY#Z!&jFH*G6=G(mYtG;msK zQI~&|3o(w_y1J?o5YaMgq5CMgtOex}J|| delta 1870 zcmYjSX-t$?6wP}N1ZQI$5Ev8~2SlaAunD81fWs!rplm|H4vT^;vJ8kYYJzA@nra2~ zRc{*?sQuB_Hdd;UG*YC37FtEctt}d9e-&*ljg1CS>7DNzlTOGakMDl>opbKFXMXuw z@oY-b9u9q<(pg=G(#&(6SxT*JOGQ*b>Fl*Sqk^OivOGbF5@hUSeZ)eoAdhcJkU~JIizOni9;pn=%aBO`GyE zlD0-gBsE5a=2v7G@(g7et4nj1TPx%1Dr+*VNeO8wTT-*a>l5-!lIoQHv{KkVh5Vw~&gBhRj z3H#|Xp16gnxW_}s>7s>6Y4cJjwh<3lm5q-!;G?k=<_!7*CUa_z)JO5~D}nHDEC~XI zhKOoGb{y(*Vn!uOHZUXLII^DjikJKugOVIyMPzWq1M##TBa$P>iG$G1on#?cs<4&7 zXsiII^JNtgD8Wb-5k+`f$lSqN{pKa4B!x@GSbDaR7~$*zgkzsHJUKv$AwHg};oz@m zhTP4le9M#(+K-}ILdC74STTN+YN6M{!TF7L4gI zv=3+E)=a$NvuUE>vFCL#@e=j8K8Qj;o5ms7@%g+IX?w9!Td5t&28fhV4V-l+Ua&<* zec_XP#EV-_y9oIJul%VJwq~K=Q5qV^XNuHNqoLxC@B}oJJw-sX0Hxb~7psn?Qc;NC zQ>nNcPPH|g%OP|aCB0UFbOgO>1`O_|yY8S!>j23oxPd zky@QZgCBUShz5!7&n!aVRvS8hTM?ZH`|U^RFs5bUhbYRpk@li>7 z6Nj+mhXMMw`0HS{u_Z7eK@Yz(Ow5oh#XPXvsT%WA+@R1KL`n?Meh=3#T8`oOawUG{ z%b5hO-={kGK+YD6OEDy8j@Y|&jA&r2g(-x{+m(ba2;74ph2>1$gRQ@fXZif%J~uvg oXx6!pYy}DLzUb+G(L?^2+kNdenKtvJ6Pow#XlVBx4Rdb#4`V*0asU7T diff --git a/pkg/npm/http-api/src/Urbit.ts b/pkg/npm/http-api/src/Urbit.ts index 4f0136168..18a1141f3 100644 --- a/pkg/npm/http-api/src/Urbit.ts +++ b/pkg/npm/http-api/src/Urbit.ts @@ -1,8 +1,8 @@ import { isBrowser, isNode } from 'browser-or-node'; import { Action, Scry, Thread } from '@urbit/api'; -import { fetchEventSource, EventSourceMessage } from '@microsoft/fetch-event-source'; +import { fetchEventSource, EventSourceMessage, EventStreamContentType } from '@microsoft/fetch-event-source'; -import { AuthenticationInterface, SubscriptionInterface, CustomEventHandler, PokeInterface, SubscriptionRequestInterface, headers, UrbitInterface, SSEOptions, PokeHandlers } from './types'; +import { AuthenticationInterface, SubscriptionInterface, CustomEventHandler, PokeInterface, SubscriptionRequestInterface, headers, UrbitInterface, SSEOptions, PokeHandlers, Message } from './types'; import { uncamelize, hexString } from './utils'; /** @@ -52,7 +52,7 @@ export class Urbit implements UrbitInterface { * disconnect function may be called exactly once. */ - outstandingSubscriptions: Map = new Map(); + outstandingSubscriptions: Map = new Map(); /** * Ship can be set, in which case we can do some magic stuff like send chats @@ -64,6 +64,8 @@ export class Urbit implements UrbitInterface { */ verbose?: boolean; + onError?: (error: any) => void = null; + /** This is basic interpolation to get the channel URL of an instantiated Urbit connection. */ get channelUrl(): string { return `${this.url}/~/channel/${this.uid}`; @@ -78,6 +80,7 @@ export class Urbit implements UrbitInterface { } return { credentials: 'include', + accept: '*', headers }; } @@ -90,8 +93,11 @@ export class Urbit implements UrbitInterface { */ constructor( public url: string, - public code: string + public code?: string ) { + if (isBrowser) { + window.addEventListener('beforeunload', this.delete); + } return this; } @@ -144,7 +150,8 @@ export class Urbit implements UrbitInterface { /** * Initializes the SSE pipe for the appropriate channel. */ - eventSource(): void{ + eventSource(): Promise { + return new Promise((resolve, reject) => { if (!this.sseClientInitialized) { const sseOptions: SSEOptions = { headers: {} @@ -154,15 +161,50 @@ export class Urbit implements UrbitInterface { } else if (isNode) { sseOptions.headers.Cookie = this.cookie; } + if (this.lastEventId === 0) { + // Can't receive events until the channel is open + this.poke({ app: 'hood', mark: 'helm-hi', json: 'Opening API channel' }); + } fetchEventSource(this.channelUrl, { - // withCredentials: true, + ...this.fetchOptions, + openWhenHidden: true, + onopen: async (response) => { + if (this.verbose) { + console.log('Opened eventsource', response); + } + if (response.ok) { + resolve(); + return; // everything's good + } else if (response.status >= 400 && response.status < 500 && response.status !== 429) { + reject(); + if (this.onError) { + this.onError(response.statusText); + } else { + throw new Error(); + } + } else { + reject(); + if (this.onError) { + this.onError(response.statusText); + } else { + throw new Error(); + } + } + }, onmessage: (event: EventSourceMessage) => { if (this.verbose) { console.log('Received SSE: ', event); } + if (!event.id) return; this.ack(Number(event.id)); if (event.data && JSON.parse(event.data)) { + const data: any = JSON.parse(event.data); + + if (data.response === 'diff') { + this.clearQueue(); + } + if (data.response === 'poke' && this.outstandingPokes.has(data.id)) { const funcs = this.outstandingPokes.get(data.id); if (data.hasOwnProperty('ok')) { @@ -193,12 +235,17 @@ export class Urbit implements UrbitInterface { } }, onerror: (error) => { - console.error('pipe error', error); + if (this.onError) { + this.onError(error); + } else { + throw error; + } } }); this.sseClientInitialized = true; } - return; + resolve(); + }); } /** @@ -214,8 +261,13 @@ export class Urbit implements UrbitInterface { * * @param eventId The event to acknowledge. */ - ack(eventId: number): Promise { - return this.sendMessage('ack', { 'event-id': eventId }); + async ack(eventId: number): Promise { + const message: Message = { + action: 'ack', + 'event-id': eventId + }; + await this.sendJSONtoChannel(message); + return eventId; } /** @@ -229,31 +281,88 @@ export class Urbit implements UrbitInterface { * * @returns void | number If successful, returns the number of the message that was sent */ - async sendMessage(action: Action, data?: object): Promise { - - const id = this.getEventId(); - if (this.verbose) { - console.log(`Sending message ${id}:`, action, data,); - } - let response: Response | undefined; - try { - response = await fetch(this.channelUrl, { - ...this.fetchOptions, - method: 'put', - body: JSON.stringify([{ - id, - action, - ...data, - }]), - }); - } catch (error) { - console.error('message error', error); - response = undefined; - } - if (this.verbose) { - console.log(`Received from message ${id}: `, response); - } - return id; + // async sendMessage(action: Action, data?: object): Promise { + // const id = this.getEventId(); + // if (this.verbose) { + // console.log(`Sending message ${id}:`, action, data,); + // } + // const message: Message = { id, action, ...data }; + // await this.sendJSONtoChannel(message); + // return id; + // } + + outstandingJSON: Message[] = []; + + debounceTimer: any = null; + debounceInterval = 500; + calm = true; + + sendJSONtoChannel(json: Message): Promise { + this.outstandingJSON.push(json); + return this.processQueue(); + } + + processQueue(): Promise { + return new Promise(async (resolve, reject) => { + const process = async () => { + if (this.calm) { + if (this.outstandingJSON.length === 0) resolve(true); + this.calm = false; // We are now occupied + const json = this.outstandingJSON; + const body = JSON.stringify(json); + this.outstandingJSON = []; + if (body === '[]') { + this.calm = true; + return resolve(false); + } + try { + await fetch(this.channelUrl, { + ...this.fetchOptions, + method: 'PUT', + body + }); + } catch (error) { + json.forEach(failed => this.outstandingJSON.push(failed)); + if (this.onError) { + this.onError(error); + } else { + throw error; + } + } + this.calm = true; + if (!this.sseClientInitialized) { + this.eventSource(); // We can open the channel for subscriptions once we've sent data over it + } + resolve(true); + } else { + clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(process, this.debounceInterval); + resolve(false); + } + } + + this.debounceTimer = setTimeout(process, this.debounceInterval); + + + }); + } + + + + // resetDebounceTimer() { + // if (this.debounceTimer) { + // clearTimeout(this.debounceTimer); + // this.debounceTimer = null; + // } + // this.calm = false; + // this.debounceTimer = setTimeout(() => { + // this.calm = true; + // }, this.debounceInterval); + // } + + clearQueue() { + clearTimeout(this.debounceTimer); + this.debounceTimer = null; } /** @@ -263,29 +372,42 @@ export class Urbit implements UrbitInterface { * @param mark The mark of the data being sent * @param json The data to send */ - poke(params: PokeInterface): Promise { - const { app, mark, json, onSuccess, onError } = { onSuccess: () => {}, onError: () => {}, ...params }; + poke(params: PokeInterface): Promise { + const { + app, + mark, + json, + ship, + onSuccess, + onError + } = { + onSuccess: () => { }, + onError: () => { }, + ship: this.ship, + ...params + }; return new Promise((resolve, reject) => { - this - .sendMessage('poke', { ship: this.ship, app, mark, json }) - .then(pokeId => { - if (!pokeId) { - return reject('Poke failed'); - } - if (!this.sseClientInitialized) resolve(pokeId); // A poke may occur before a listener has been opened - this.outstandingPokes.set(pokeId, { - onSuccess: () => { - onSuccess(); - resolve(pokeId); - }, - onError: (event) => { - onError(event); - reject(event.err); - } - }); - }).catch(error => { - console.error(error); - }); + const message: Message = { + id: this.getEventId(), + action: 'poke', + ship, + app, + mark, + json + }; + this.outstandingPokes.set(message.id, { + onSuccess: () => { + onSuccess(); + resolve(message.id); + }, + onError: (event) => { + onError(event); + reject(event.err); + } + }); + this.sendJSONtoChannel(message).then(() => { + resolve(message.id); + }); }); } @@ -296,18 +418,37 @@ export class Urbit implements UrbitInterface { * @param path The path to which to subscribe * @param handlers Handlers to deal with various events of the subscription */ - async subscribe(params: SubscriptionRequestInterface): Promise { - const { app, path, err, event, quit } = { err: () => {}, event: () => {}, quit: () => {}, ...params }; + async subscribe(params: SubscriptionRequestInterface): Promise { + const { + app, + path, + ship, + err, + event, + quit + } = { + err: () => { }, + event: () => { }, + quit: () => { }, + ship: this.ship, + ...params + }; - const subscriptionId = await this.sendMessage('subscribe', { ship: this.ship, app, path }); + const message: Message = { + id: this.getEventId(), + action: 'subscribe', + ship, + app, + path + }; - if (!subscriptionId) return; - - this.outstandingSubscriptions.set(subscriptionId, { - err, event, quit + this.outstandingSubscriptions.set(message.id, { + app, path, err, event, quit }); - return subscriptionId; + await this.sendJSONtoChannel(message); + + return message.id; } /** @@ -315,15 +456,28 @@ export class Urbit implements UrbitInterface { * * @param subscription */ - unsubscribe(subscription: string): Promise { - return this.sendMessage('unsubscribe', { subscription }); + async unsubscribe(subscription: number) { + return this.sendJSONtoChannel({ + id: this.getEventId(), + action: 'unsubscribe', + subscription + }).then(() => { + this.outstandingSubscriptions.delete(subscription); + }); } /** * Deletes the connection to a channel. */ - delete(): Promise { - return this.sendMessage('delete'); + delete() { + if (isBrowser) { + navigator.sendBeacon(this.channelUrl, JSON.stringify([{ + action: 'delete' + }])); + } else { + // TODO + // this.sendMessage('delete'); + } } /** @@ -333,7 +487,7 @@ export class Urbit implements UrbitInterface { */ async scry(params: Scry): Promise { const { app, path } = params; - const response = await fetch(`/~/scry/${app}${path}.json`, this.fetchOptions); + const response = await fetch(`${this.url}/~/scry/${app}${path}.json`, this.fetchOptions); return await response.json(); } @@ -346,7 +500,7 @@ export class Urbit implements UrbitInterface { */ async thread(params: Thread): Promise { const { inputMark, outputMark, threadName, body } = params; - const res = await fetch(`/spider/${inputMark}/${threadName}/${outputMark}.json`, { + const res = await fetch(`${this.url}/spider/${inputMark}/${threadName}/${outputMark}.json`, { ...this.fetchOptions, method: 'POST', body: JSON.stringify(body) diff --git a/pkg/npm/http-api/src/types.ts b/pkg/npm/http-api/src/types.ts index 04cc62da5..d544cae3c 100644 --- a/pkg/npm/http-api/src/types.ts +++ b/pkg/npm/http-api/src/types.ts @@ -37,19 +37,20 @@ export interface UrbitInterface { sseClientInitialized: boolean; cookie?: string | undefined; outstandingPokes: Map; - outstandingSubscriptions: Map; + outstandingSubscriptions: Map; verbose?: boolean; ship?: string | null; + onError?: (error: any) => void; connect(): void; connect(): Promise; eventSource(): void; getEventId(): number; ack(eventId: number): Promise; - sendMessage(action: Action, data?: object): Promise; - poke(params: PokeInterface): Promise; - subscribe(params: SubscriptionRequestInterface): Promise; - unsubscribe(subscription: string): Promise; - delete(): Promise; + // sendMessage(action: Action, data?: object): Promise; + poke(params: PokeInterface): Promise; + subscribe(params: SubscriptionRequestInterface): Promise; + unsubscribe(subscription: number): Promise; + delete(): void; scry(params: Scry): Promise; thread(params: Thread): Promise; } @@ -64,3 +65,8 @@ export interface SSEOptions { }; withCredentials?: boolean; } + +export interface Message extends Record { + action: Action; + id?: number; +} \ No newline at end of file diff --git a/pkg/npm/http-api/tsconfig.json b/pkg/npm/http-api/tsconfig.json index 7e7396492..69acc414e 100644 --- a/pkg/npm/http-api/tsconfig.json +++ b/pkg/npm/http-api/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["*.ts"], + "include": ["src/**/*"], "exclude": ["node_modules", "dist", "@types"], "compilerOptions": { "outDir": "./dist", @@ -20,4 +20,4 @@ "*" : ["./node_modules/@types/*", "*"] } } -} \ No newline at end of file +} diff --git a/pkg/npm/http-api/types.ts b/pkg/npm/http-api/types.ts deleted file mode 100644 index d544cae3c..000000000 --- a/pkg/npm/http-api/types.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Action, Poke, Scry, Thread } from '@urbit/api'; - -export interface PokeHandlers { - onSuccess?: () => void; - onError?: (e: any) => void; -} - -export type PokeInterface = PokeHandlers & Poke; - -export interface AuthenticationInterface { - ship: string; - url: string; - code: string; - verbose?: boolean; -} - -export interface SubscriptionInterface { - err?(error: any): void; - event?(data: any): void; - quit?(data: any): void; -} - -export type SubscriptionRequestInterface = SubscriptionInterface & { - app: string; - path: string; -} - -export interface headers { - 'Content-Type': string; - Cookie?: string; -} - -export interface UrbitInterface { - uid: string; - lastEventId: number; - lastAcknowledgedEventId: number; - sseClientInitialized: boolean; - cookie?: string | undefined; - outstandingPokes: Map; - outstandingSubscriptions: Map; - verbose?: boolean; - ship?: string | null; - onError?: (error: any) => void; - connect(): void; - connect(): Promise; - eventSource(): void; - getEventId(): number; - ack(eventId: number): Promise; - // sendMessage(action: Action, data?: object): Promise; - poke(params: PokeInterface): Promise; - subscribe(params: SubscriptionRequestInterface): Promise; - unsubscribe(subscription: number): Promise; - delete(): void; - scry(params: Scry): Promise; - thread(params: Thread): Promise; -} - -export interface CustomEventHandler { - (data: any, response: string): void; -} - -export interface SSEOptions { - headers?: { - Cookie?: string - }; - withCredentials?: boolean; -} - -export interface Message extends Record { - action: Action; - id?: number; -} \ No newline at end of file diff --git a/pkg/npm/http-api/utils.ts b/pkg/npm/http-api/utils.ts deleted file mode 100644 index 94c0998cb..000000000 --- a/pkg/npm/http-api/utils.ts +++ /dev/null @@ -1,82 +0,0 @@ -import * as http from 'http'; - -interface HttpResponse { - req: http.ClientRequest; - res: http.IncomingMessage; - data: string; -} - -export function request( - url: string, - options: http.ClientRequestArgs, - body?: string -): Promise { - return new Promise((resolve, reject) => { - const req = http.request(url, options, res => { - let data = ""; - res.on("data", chunk => { - data += chunk; - }); - res.on("end", () => { - resolve({ req, res, data }); - }); - res.on("error", e => { - reject(e); - }); - }); - if (body) { - req.write(body); - } - req.end(); - }); -} - -export function camelize(str: string) { - return str - .replace(/\s(.)/g, function($1: string) { return $1.toUpperCase(); }) - .replace(/\s/g, '') - .replace(/^(.)/, function($1: string) { return $1.toLowerCase(); }); -} - -export function uncamelize(str: string, separator = '-') { - // Replace all capital letters by separator followed by lowercase one - var str = str.replace(/[A-Z]/g, function (letter: string) { - return separator + letter.toLowerCase(); - }); - return str.replace(new RegExp('^' + separator), ''); -} - -/** - * Returns a hex string of given length. - * - * Poached from StackOverflow. - * - * @param len Length of hex string to return. - */ -export function hexString(len: number): string { - const maxlen = 8; - const min = Math.pow(16, Math.min(len, maxlen) - 1); - const max = Math.pow(16, Math.min(len, maxlen)) - 1; - const n = Math.floor(Math.random() * (max - min + 1)) + min; - let r = n.toString(16); - while (r.length < len) { - r = r + hexString(len - maxlen); - } - return r; -} - -/** - * Generates a random UID. - * - * Copied from https://github.com/urbit/urbit/blob/137e4428f617c13f28ed31e520eff98d251ed3e9/pkg/interface/src/lib/util.js#L3 - */ -export function uid(): string { - let str = '0v'; - str += Math.ceil(Math.random() * 8) + '.'; - for (let i = 0; i < 5; i++) { - let _str = Math.ceil(Math.random() * 10000000).toString(32); - _str = ('00000' + _str).substr(-5, 5); - str += _str + '.'; - } - return str.slice(0, -1); -} \ No newline at end of file From eadaa25e9a3a843279c1886b667479fb3952be25 Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Tue, 8 Jun 2021 15:06:59 +1000 Subject: [PATCH 03/66] interface: introduce api singleton --- pkg/interface/src/logic/api/index.ts | 25 ++++++++++++++++++ pkg/interface/src/logic/state/base.ts | 38 ++++++++++++++++++++++++--- pkg/interface/src/views/App.js | 5 +++- 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 pkg/interface/src/logic/api/index.ts diff --git a/pkg/interface/src/logic/api/index.ts b/pkg/interface/src/logic/api/index.ts new file mode 100644 index 000000000..a8f0383d6 --- /dev/null +++ b/pkg/interface/src/logic/api/index.ts @@ -0,0 +1,25 @@ +import Urbit from '@urbit/http-api'; +import useHarkState from '~/logic/state/hark'; + +const api = new Urbit('', ''); +api.ship = window.ship; +api.verbose = true; +console.log(api); + +// @ts-ignore TODO window typings +window.api = api; + +export const bootstrapApi = async () => { + console.log('a'); + await api.poke({ app: 'hood', mark: 'helm-hi', json: 'opening airlock' }); + + console.log('b'); + await api.eventSource(); + console.log('c'); + [useHarkState].forEach((state) => { + state.getState().initialize(api); + console.log('initialized'); + }); +}; + +export default api; diff --git a/pkg/interface/src/logic/state/base.ts b/pkg/interface/src/logic/state/base.ts index 543cb7e40..472023854 100644 --- a/pkg/interface/src/logic/state/base.ts +++ b/pkg/interface/src/logic/state/base.ts @@ -1,8 +1,9 @@ import { applyPatches, Patch, produceWithPatches, setAutoFreeze, enablePatches } from 'immer'; import { compose } from 'lodash/fp'; import _ from 'lodash'; -import create, { UseStore } from 'zustand'; +import create, { GetState, SetState, UseStore } from 'zustand'; import { persist } from 'zustand/middleware'; +import Urbit, { SubscriptionRequestInterface } from '@urbit/http-api'; setAutoFreeze(false); enablePatches(); @@ -44,6 +45,18 @@ export const reduceState = < }); }; +export const reduceStateN = < + S extends {}, + U +>( + state: S & BaseState, + data: U, + reducers: ((data: U, state: S & BaseState) => S & BaseState)[] +): void => { + const reducer = compose(reducers.map(r => sta => r(data, sta))); + state.set(reducer); +}; + export const optReduceState = ( state: UseStore>, data: U, @@ -78,13 +91,30 @@ export interface BaseState { addPatch: (id: string, ...patch: Patch[]) => void; removePatch: (id: string) => void; optSet: (fn: (state: BaseState) => void) => string; + initialize: (api: Urbit) => void; +} + +export function createSubscription(app: string, path: string, e: (data: any) => void): SubscriptionRequestInterface { + const request = { + app, + path, + event: e, + err: () => {}, + quit: () => {} + }; + // TODO: err, quit handling (resubscribe?) + return request; } export const createState = ( name: string, - properties: T, - blacklist: (keyof BaseState | keyof T)[] = [] + properties: T | ((set: SetState>, get: GetState>) => T), + blacklist: (keyof BaseState | keyof T)[] = [], + subscriptions: ((set: SetState>, get: GetState>) => SubscriptionRequestInterface)[] = [] ): UseStore> => create>(persist>((set, get) => ({ + initialize: (api: Urbit) => { + subscriptions.forEach(sub => api.subscribe(sub(set, get))); + }, // @ts-ignore investigate zustand types set: fn => stateSetter(fn, set, get), optSet: (fn) => { @@ -105,7 +135,7 @@ export const createState = ( return { ...applyPatches(state, applying), patches: _.omit(state.patches, id) }; }); }, - ...properties + ...(typeof properties === 'function' ? (properties as any)(set, get) : properties) }), { blacklist, name: stateStorageKey(name), diff --git a/pkg/interface/src/views/App.js b/pkg/interface/src/views/App.js index 7c0ab1cc8..1a8b73917 100644 --- a/pkg/interface/src/views/App.js +++ b/pkg/interface/src/views/App.js @@ -29,6 +29,7 @@ import './css/fonts.css'; import './css/indigo-static.css'; import { Content } from './landscape/components/Content'; import './landscape/css/custom.css'; +import { bootstrapApi } from '~/logic/api'; const Root = withState(styled.div` font-family: ${p => p.theme.fonts.sans}; @@ -78,6 +79,7 @@ class App extends React.Component { this.store.setStateHandler(this.setState.bind(this)); this.state = this.store.state; + // eslint-disable-next-line this.appChannel = new window.channel(); this.api = new GlobalApi(this.ship, this.appChannel, this.store); gcpManager.configure(this.api); @@ -89,6 +91,7 @@ class App extends React.Component { } componentDidMount() { + bootstrapApi(); this.subscription.start(); this.api.graph.getShallowChildren(`~${window.ship}`, 'dm-inbox'); const theme = this.getTheme(); @@ -103,7 +106,7 @@ class App extends React.Component { this.updateTheme(this.themeWatcher); }, 500); this.api.local.getBaseHash(); - this.api.local.getRuntimeLag(); //TODO consider polling periodically + this.api.local.getRuntimeLag(); // TODO consider polling periodically this.api.settings.getAll(); gcpManager.start(); Mousetrap.bindGlobal(['command+/', 'ctrl+/'], (e) => { From e22d38fbeff10d18b68baf9908d184527105970b Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Tue, 8 Jun 2021 15:09:26 +1000 Subject: [PATCH 04/66] hark: upgrade to hooks api --- .../src/logic/reducers/hark-update.ts | 50 ++----- pkg/interface/src/logic/state/hark.ts | 141 +++++++++++------- pkg/interface/src/logic/store/store.ts | 6 +- .../src/logic/subscription/global.ts | 1 - 4 files changed, 101 insertions(+), 97 deletions(-) diff --git a/pkg/interface/src/logic/reducers/hark-update.ts b/pkg/interface/src/logic/reducers/hark-update.ts index beddf4aee..fd0f76929 100644 --- a/pkg/interface/src/logic/reducers/hark-update.ts +++ b/pkg/interface/src/logic/reducers/hark-update.ts @@ -8,8 +8,6 @@ import _ from 'lodash'; import { compose } from 'lodash/fp'; import { makePatDa } from '~/logic/lib/util'; import { describeNotification, getReferent } from '../lib/hark'; -import { reduceState } from '../state/base'; -import useHarkState, { HarkState } from '../state/hark'; function calculateCount(json: any, state: HarkState) { state.notificationsCount = Object.keys(state.unreadNotes).length; @@ -263,7 +261,7 @@ function added(json: any, state: HarkState): HarkState { const [fresh] = _.partition(state.unreadNotes, ({ index: idx }) => !notifIdxEqual(index, idx)); state.unreadNotes = [...fresh, { index, notification }]; - if ('Notification' in window && !useHarkState.getState().doNotDisturb) { + if ('Notification' in window && !state.doNotDisturb) { const description = describeNotification(data); const referent = getReferent(data); new Notification(`${description} ${referent}`, { @@ -412,37 +410,17 @@ export function reduce(data, state) { return reducer(state); } -export const HarkReducer = (json: any) => { - const data = _.get(json, 'harkUpdate', false); - if (data) { - console.log(data); - reduceState(useHarkState, data, [reduce]); - } - const graphHookData = _.get(json, 'hark-graph-hook-update', false); - if (graphHookData) { - reduceState(useHarkState, graphHookData, [ - // @ts-ignore investigate zustand types - graphInitial, - // @ts-ignore investigate zustand types - graphIgnore, - // @ts-ignore investigate zustand types - graphListen, - // @ts-ignore investigate zustand types - graphWatchSelf, - // @ts-ignore investigate zustand types - graphMentions - ]); - } - const groupHookData = _.get(json, 'hark-group-hook-update', false); - if (groupHookData) { - reduceState(useHarkState, groupHookData, [ - // @ts-ignore investigate zustand types - groupInitial, - // @ts-ignore investigate zustand types - groupListen, - // @ts-ignore investigate zustand types - groupIgnore - ]); - } -}; +export const reduceGraph = [ + graphInitial, + graphIgnore, + graphListen, + graphWatchSelf, + graphMentions +]; + +export const reduceGroup = [ + groupInitial, + groupListen, + groupIgnore +]; diff --git a/pkg/interface/src/logic/state/hark.ts b/pkg/interface/src/logic/state/hark.ts index 864936161..cd2c8ae0c 100644 --- a/pkg/interface/src/logic/state/hark.ts +++ b/pkg/interface/src/logic/state/hark.ts @@ -1,18 +1,24 @@ -import { NotificationGraphConfig, Timebox, Unreads, dateToDa } from "@urbit/api"; +import { + NotificationGraphConfig, + Timebox, + Unreads +} from '@urbit/api'; import { patp2dec } from 'urbit-ob'; -import BigIntOrderedMap from "@urbit/api/lib/BigIntOrderedMap"; -import {useCallback} from "react"; +import _ from 'lodash'; +import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap'; +import api from '~/logic/api'; +import { useCallback } from 'react'; -// import { harkGraphHookReducer, harkGroupHookReducer, harkReducer } from "~/logic/subscription/hark"; -import { createState } from './base'; +import { createState, createSubscription, reduceState, reduceStateN } from './base'; +import { reduce, reduceGraph, reduceGroup } from '../reducers/hark-update'; export const HARK_FETCH_MORE_COUNT = 3; export interface HarkState { archivedNotifications: BigIntOrderedMap; doNotDisturb: boolean; - // getMore: () => Promise; - // getSubset: (offset: number, count: number, isArchive: boolean) => Promise; + getMore: () => Promise; + getSubset: (offset: number, count: number, isArchive: boolean) => Promise; // getTimeSubset: (start?: Date, end?: Date) => Promise; notifications: BigIntOrderedMap; unreadNotes: Timebox; @@ -22,57 +28,80 @@ export interface HarkState { unreads: Unreads; } -const useHarkState = createState('Hark', { - archivedNotifications: new BigIntOrderedMap(), - doNotDisturb: false, - unreadNotes: [], - // getMore: async (): Promise => { - // const state = get(); - // const offset = state.notifications.size || 0; - // await state.getSubset(offset, HARK_FETCH_MORE_COUNT, false); - // // TODO make sure that state has mutated at this point. - // return offset === (state.notifications.size || 0); - // }, - // getSubset: async (offset, count, isArchive): Promise => { - // const api = useApi(); - // const where = isArchive ? 'archive' : 'inbox'; - // const result = await api.scry({ - // app: 'hark-store', - // path: `/recent/${where}/${offset}/${count}` - // }); - // harkReducer(result); - // return; - // }, - // getTimeSubset: async (start, end): Promise => { - // const api = useApi(); - // const s = start ? dateToDa(start) : '-'; - // const e = end ? dateToDa(end) : '-'; - // const result = await api.scry({ - // app: 'hark-hook', - // path: `/recent/${s}/${e}` - // }); - // harkGroupHookReducer(result); - // harkGraphHookReducer(result); - // return; - // }, - notifications: new BigIntOrderedMap(), - notificationsCount: 0, - notificationsGraphConfig: { - watchOnSelf: false, - mentions: false, - watching: [] - }, - notificationsGroupConfig: [], - unreads: { - graph: {}, - group: {} - } -}, ['unreadNotes', 'notifications', 'archivedNotifications', 'unreads', 'notificationsCount']); +const useHarkState = createState( + 'Hark', + (set, get) => ({ + archivedNotifications: new BigIntOrderedMap(), + doNotDisturb: false, + unreadNotes: [], + getMore: async (): Promise => { + const state = get(); + const offset = state.notifications.size || 0; + await state.getSubset(offset, HARK_FETCH_MORE_COUNT, false); + const newState = get(); + return offset === (newState?.notifications?.size || 0); + }, + getSubset: async (offset, count, isArchive): Promise => { + const where = isArchive ? 'archive' : 'inbox'; + const { harkUpdate } = await api.scry({ + app: 'hark-store', + path: `/recent/${where}/${offset}/${count}` + }); + reduceState(useHarkState, harkUpdate, [reduce]); + }, + notifications: new BigIntOrderedMap(), + notificationsCount: 0, + notificationsGraphConfig: { + watchOnSelf: false, + mentions: false, + watching: [] + }, + notificationsGroupConfig: [], + unreads: { + graph: {}, + group: {} + } + }), + [ + 'unreadNotes', + 'notifications', + 'archivedNotifications', + 'unreads', + 'notificationsCount' + ], + [ + (set, get) => createSubscription('hark-store', '/updates', (j) => { + const d = _.get(j, 'harkUpdate', false); + if (d) { + reduceStateN(get(), d, [reduce]); + } + }), + (set, get) => createSubscription('hark-graph-hook', '/updates', (j) => { + const graphHookData = _.get(j, 'hark-graph-hook-update', false); + if (graphHookData) { + reduceStateN(get(), graphHookData, reduceGraph); + } + }), + (set, get) => createSubscription('hark-group-hook', '/updates', (j) => { + const data = _.get(j, 'hark-group-hook-update', false); + if (data) { + reduceStateN(get(), data, reduceGroup); + } + }) + ] +); export function useHarkDm(ship: string) { - return useHarkState(useCallback(s => { - return s.unreads.graph[`/ship/~${window.ship}/dm-inbox`]?.[`/${patp2dec(ship)}`]; - }, [ship])); + return useHarkState( + useCallback( + (s) => { + return s.unreads.graph[`/ship/~${window.ship}/dm-inbox`]?.[ + `/${patp2dec(ship)}` + ]; + }, + [ship] + ) + ); } export default useHarkState; diff --git a/pkg/interface/src/logic/store/store.ts b/pkg/interface/src/logic/store/store.ts index cfc943d7f..5dd2e6b1a 100644 --- a/pkg/interface/src/logic/store/store.ts +++ b/pkg/interface/src/logic/store/store.ts @@ -1,5 +1,5 @@ import _ from 'lodash'; -import { unstable_batchedUpdates } from 'react-dom'; +import { unstable_batchedUpdates as batchedUpdates } from 'react-dom'; import { Cage } from '~/types/cage'; import ConnectionReducer from '../reducers/connection'; import { ContactReducer } from '../reducers/contact-update'; @@ -7,7 +7,6 @@ import GcpReducer from '../reducers/gcp-reducer'; import { GraphReducer } from '../reducers/graph-update'; import GroupReducer from '../reducers/group-update'; import { GroupViewReducer } from '../reducers/group-view'; -import { HarkReducer } from '../reducers/hark-update'; import InviteReducer from '../reducers/invite-update'; import LaunchReducer from '../reducers/launch-update'; import MetadataReducer from '../reducers/metadata-update'; @@ -45,7 +44,7 @@ export default class GlobalStore extends BaseStore { } reduce(data: Cage, state: StoreState) { - unstable_batchedUpdates(() => { + batchedUpdates(() => { // debug shim const tag = Object.keys(data)[0]; const oldActions = this.pastActions[tag] || []; @@ -58,7 +57,6 @@ export default class GlobalStore extends BaseStore { this.launchReducer.reduce(data); this.connReducer.reduce(data, this.state); GraphReducer(data); - HarkReducer(data); ContactReducer(data); this.settingsReducer.reduce(data); this.gcpReducer.reduce(data); diff --git a/pkg/interface/src/logic/subscription/global.ts b/pkg/interface/src/logic/subscription/global.ts index 6e928b6f2..a95c4ab60 100644 --- a/pkg/interface/src/logic/subscription/global.ts +++ b/pkg/interface/src/logic/subscription/global.ts @@ -17,7 +17,6 @@ export default class GlobalSubscription extends BaseSubscription { this.subscribe('/all', 'contact-store'); this.subscribe('/all', 's3-store'); this.subscribe('/keys', 'graph-store'); - this.subscribe('/updates', 'hark-store'); this.subscribe('/updates', 'hark-graph-hook'); this.subscribe('/updates', 'hark-group-hook'); this.subscribe('/all', 'settings-store'); From d81d74f38fa6b83e20c87a3a474ddf6cbd94e810 Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Tue, 8 Jun 2021 15:48:03 +1000 Subject: [PATCH 05/66] @urbit/api: update hark pokes --- pkg/npm/api/hark/lib.ts | 44 +++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 24 deletions(-) diff --git a/pkg/npm/api/hark/lib.ts b/pkg/npm/api/hark/lib.ts index 355a778c2..fcdf28ea8 100644 --- a/pkg/npm/api/hark/lib.ts +++ b/pkg/npm/api/hark/lib.ts @@ -78,14 +78,18 @@ export const setDoNotDisturb = ( }); export const archive = ( - time: BigInteger, - index: NotifIndex -): Poke => actOnNotification('archive', time, index); + index: NotifIndex, + time?: BigInteger, +): Poke => harkAction({ + 'archive': { + time: time ? decToUd(time.toString()) : null, + index + } +}); -export const read = ( - time: BigInteger, +export const readNote = ( index: NotifIndex -): Poke => actOnNotification('read-note', time, index); +): Poke => harkAction({ 'read-note': index }); export const readIndex = ( index: NotifIndex @@ -99,38 +103,30 @@ export const unread = ( ): Poke => actOnNotification('unread-note', time, index); export const markCountAsRead = ( - association: Association, - parent: string, - description: GraphNotifDescription + graph: string, + index = '/' ): Poke => harkAction({ 'read-count': { graph: { - graph: association.resource, - group: association.group, - description: description, - index: parent + graph, + index } } }); export const markEachAsRead = ( - association: Association, - parent: string, - child: string, - description: GraphNotifDescription, - module: string + graph: string, + index: string, + target: string ): Poke => harkAction({ 'read-each': { index: { graph: { - graph: association.resource, - group: association.group, - description: description, - module: module, - index: parent + graph, + index } }, - target: child + target } }); From 282df969910894def11c69f0525f9e3cd78ffd44 Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Tue, 8 Jun 2021 15:55:18 +1000 Subject: [PATCH 06/66] interface: strip api.hark --- .../src/logic/reducers/hark-update.ts | 4 ++ .../src/views/apps/chat/ChatResource.tsx | 7 +-- .../src/views/apps/chat/DmResource.tsx | 8 ++- .../views/apps/links/components/LinkItem.tsx | 8 +-- .../src/views/apps/notifications/inbox.tsx | 33 +++++------- .../views/apps/notifications/notification.tsx | 50 +++---------------- .../apps/notifications/notifications.tsx | 41 +++------------ .../views/apps/publish/components/Note.tsx | 12 ++--- .../components/lib/NotificationPref.tsx | 17 +++---- .../src/views/components/Comments.tsx | 13 ++--- .../ChannelPopoverRoutes/Notifications.tsx | 11 ++-- .../components/GroupSettings/Personal.tsx | 6 ++- .../landscape/components/Home/GroupFeed.tsx | 4 +- .../components/Home/GroupFlatFeed.tsx | 4 +- pkg/npm/api/graph/types.ts | 4 +- 15 files changed, 77 insertions(+), 145 deletions(-) diff --git a/pkg/interface/src/logic/reducers/hark-update.ts b/pkg/interface/src/logic/reducers/hark-update.ts index fd0f76929..b62519712 100644 --- a/pkg/interface/src/logic/reducers/hark-update.ts +++ b/pkg/interface/src/logic/reducers/hark-update.ts @@ -8,6 +8,10 @@ import _ from 'lodash'; import { compose } from 'lodash/fp'; import { makePatDa } from '~/logic/lib/util'; import { describeNotification, getReferent } from '../lib/hark'; +import { BaseState } from '../state/base'; +import { HarkState as State } from '../state/hark'; + +type HarkState = State & BaseState; function calculateCount(json: any, state: HarkState) { state.notificationsCount = Object.keys(state.unreadNotes).length; diff --git a/pkg/interface/src/views/apps/chat/ChatResource.tsx b/pkg/interface/src/views/apps/chat/ChatResource.tsx index 03949d0da..131bc7d83 100644 --- a/pkg/interface/src/views/apps/chat/ChatResource.tsx +++ b/pkg/interface/src/views/apps/chat/ChatResource.tsx @@ -1,4 +1,4 @@ -import { Content, createPost, Post } from '@urbit/api'; +import { Content, createPost, markCountAsRead, Post } from '@urbit/api'; import { Association } from '@urbit/api/metadata'; import { BigInteger } from 'big-integer'; import React, { @@ -16,6 +16,7 @@ import useHarkState from '~/logic/state/hark'; import { StoreState } from '~/logic/store/type'; import { Loading } from '~/views/components/Loading'; import { ChatPane } from './components/ChatPane'; +import airlock from '~/logic/api'; const getCurrGraphSize = (ship: string, name: string) => { const { graphs } = useGraphState.getState(); @@ -126,8 +127,8 @@ const ChatResource = (props: ChatResourceProps): ReactElement => { }, [resource]); const dismissUnread = useCallback(() => { - api.hark.markCountAsRead(association, '/', 'message'); - }, [association]); + airlock.poke(markCountAsRead(association.resource)); + }, [association.resource]); const getPermalink = useCallback( (index: BigInteger) => diff --git a/pkg/interface/src/views/apps/chat/DmResource.tsx b/pkg/interface/src/views/apps/chat/DmResource.tsx index e3cbbe25c..7c30db311 100644 --- a/pkg/interface/src/views/apps/chat/DmResource.tsx +++ b/pkg/interface/src/views/apps/chat/DmResource.tsx @@ -1,4 +1,4 @@ -import { cite, Content, Post } from '@urbit/api'; +import { cite, Content, markCountAsRead, Post } from '@urbit/api'; import React, { useCallback, useEffect } from 'react'; import _ from 'lodash'; import bigInt from 'big-integer'; @@ -12,6 +12,7 @@ import { useHarkDm } from '~/logic/state/hark'; import useSettingsState, { selectCalmState } from '~/logic/state/settings'; import { ChatPane } from './components/ChatPane'; import { patpToUd } from '~/logic/lib/util'; +import airlock from '~/logic/api'; interface DmResourceProps { ship: string; @@ -102,10 +103,7 @@ export function DmResource(props: DmResourceProps) { ); const dismissUnread = useCallback(() => { - api.hark.dismissReadCount( - `/ship/~${window.ship}/dm-inbox`, - `/${patp2dec(ship)}` - ); + airlock.poke(markCountAsRead(`/ship/~${window.ship}/dm-inbox`, `/${patp2dec(ship)}`)); }, [ship]); const onSubmit = useCallback( diff --git a/pkg/interface/src/views/apps/links/components/LinkItem.tsx b/pkg/interface/src/views/apps/links/components/LinkItem.tsx index fecd771e2..ac1a84b38 100644 --- a/pkg/interface/src/views/apps/links/components/LinkItem.tsx +++ b/pkg/interface/src/views/apps/links/components/LinkItem.tsx @@ -1,5 +1,5 @@ import { Action, Anchor, Box, Col, Icon, Row, Rule, Text } from '@tlon/indigo-react'; -import { Association, GraphNode, Group, TextContent, UrlContent } from '@urbit/api'; +import { Association, GraphNode, Group, markEachAsRead, TextContent, UrlContent } from '@urbit/api'; import React, { ReactElement, RefObject, useCallback, useEffect, useRef } from 'react'; import { Link, Redirect } from 'react-router-dom'; import GlobalApi from '~/logic/api/global'; @@ -11,6 +11,7 @@ import Author from '~/views/components/Author'; import { Dropdown } from '~/views/components/Dropdown'; import RemoteContent from '~/views/components/RemoteContent'; import { PermalinkEmbed } from '../../permalinks/embed'; +import airlock from '~/logic/api'; interface LinkItemProps { node: GraphNode; @@ -30,7 +31,6 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject { - api.hark.markEachAsRead(props.association, '/', `/${index}`, 'link', 'link'); - }, [association, index]); + airlock.poke(markEachAsRead(resource, '/', `/${index}`)); + }, [resource, index]); useEffect(() => { function onBlur() { diff --git a/pkg/interface/src/views/apps/notifications/inbox.tsx b/pkg/interface/src/views/apps/notifications/inbox.tsx index 3564434e2..8b63ff84d 100644 --- a/pkg/interface/src/views/apps/notifications/inbox.tsx +++ b/pkg/interface/src/views/apps/notifications/inbox.tsx @@ -4,6 +4,8 @@ import { JoinRequests, Notifications, + seen, + Timebox, unixToDa } from '@urbit/api'; @@ -11,15 +13,16 @@ import { BigInteger } from 'big-integer'; import _ from 'lodash'; import f from 'lodash/fp'; import moment from 'moment'; -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import GlobalApi from '~/logic/api/global'; import { getNotificationKey } from '~/logic/lib/hark'; import { useLazyScroll } from '~/logic/lib/useLazyScroll'; import useLaunchState from '~/logic/state/launch'; -import { daToUnix, MOMENT_CALENDAR_DATE } from '~/logic/lib/util'; +import { daToUnix } from '~/logic/lib/util'; import useHarkState from '~/logic/state/hark'; import { Invites } from './invites'; import { Notification } from './notification'; +import airlock from '~/logic/api'; type DatedTimebox = [BigInteger, Timebox]; @@ -48,13 +51,13 @@ export default function Inbox(props: { }) { const { api } = props; useEffect(() => { - let seen = false; + let hasSeen = false; setTimeout(() => { - seen = true; + hasSeen = true; }, 3000); return () => { - if (seen) { - api.hark.seen(); + if (hasSeen) { + airlock.poke(seen()); } }; }, []); @@ -65,6 +68,8 @@ export default function Inbox(props: { s => Object.keys(s.unreads.graph).length > 0 ); + const getMore = useHarkState(s => s.getMore); + const notificationState = useHarkState(state => state.notifications); const unreadNotes = useHarkState(s => s.unreadNotes); const archivedNotifications = useHarkState(state => state.archivedNotifications); @@ -72,16 +77,6 @@ export default function Inbox(props: { const notifications = Array.from(props.showArchive ? archivedNotifications : notificationState) || []; - const calendar = { - ...MOMENT_CALENDAR_DATE, sameDay: function (now) { - if (this.subtract(6, 'hours').isBefore(now)) { - return '[Earlier Today]'; - } else { - return MOMENT_CALENDAR_DATE.sameDay; - } - } - }; - const notificationsByDay = f.flow( f.map(([date, nots]) => [ date, @@ -105,16 +100,12 @@ export default function Inbox(props: { const scrollRef = useRef(null); - const loadMore = useCallback(async () => { - return api.hark.getMore(); - }, [api]); - const { isDone, isLoading } = useLazyScroll( scrollRef, ready, 0.2, _.flatten(notifications).length, - loadMore + getMore ); const date = unixToDa(Date.now()); diff --git a/pkg/interface/src/views/apps/notifications/notification.tsx b/pkg/interface/src/views/apps/notifications/notification.tsx index 687ea12b9..129ee3c51 100644 --- a/pkg/interface/src/views/apps/notifications/notification.tsx +++ b/pkg/interface/src/views/apps/notifications/notification.tsx @@ -1,26 +1,22 @@ import { Box, Button, Icon, Row } from '@tlon/indigo-react'; import { + archive, GraphNotificationContents, - GroupNotificationContents, - - GroupNotificationsConfig, IndexedNotification, - - NotificationGraphConfig + IndexedNotification, + readNote } from '@urbit/api'; import { BigInteger } from 'big-integer'; -import _ from 'lodash'; import React, { ReactNode, useCallback } from 'react'; import GlobalApi from '~/logic/api/global'; import { getNotificationKey } from '~/logic/lib/hark'; -import { getParentIndex } from '~/logic/lib/notification'; import { useHovering } from '~/logic/lib/util'; -import useHarkState from '~/logic/state/hark'; import useLocalState from '~/logic/state/local'; import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction'; import { SwipeMenu } from '~/views/components/SwipeMenu'; import { GraphNotification } from './graph'; import { GroupNotification } from './group'; +import airlock from '~/logic/api'; export interface NotificationProps { notification: IndexedNotification; @@ -29,32 +25,6 @@ export interface NotificationProps { unread: boolean; } -function getMuted( - idxNotif: IndexedNotification, - groups: GroupNotificationsConfig, - graphs: NotificationGraphConfig -) { - const { index, notification } = idxNotif; - if ('graph' in idxNotif.index) { - const { graph } = idxNotif.index.graph; - if (!('graph' in notification.contents)) { - throw new Error(); - } - const parent = getParentIndex(idxNotif.index.graph, notification.contents.graph); - - return ( - _.findIndex( - graphs?.watching || [], - g => g.graph === graph && g.index === parent - ) === -1 - ); - } - if ('group' in index) { - return _.findIndex(groups || [], g => g === index.group.group) === -1; - } - return false; -} - export function NotificationWrapper(props: { api: GlobalApi; time?: BigInteger; @@ -62,7 +32,7 @@ export function NotificationWrapper(props: { notification?: IndexedNotification; children: ReactNode; }) { - const { api, time, notification, children, read = false } = props; + const { time, notification, children, read = false } = props; const isMobile = useLocalState(s => s.mobile); @@ -71,20 +41,14 @@ export function NotificationWrapper(props: { if (!notification) { return; } - return api.hark.archive(time, notification.index); + await airlock.poke(archive(notification.index, time)); }, [time, notification]); - const groupConfig = useHarkState(state => state.notificationsGroupConfig); - const graphConfig = useHarkState(state => state.notificationsGraphConfig); - - const isMuted = - time && notification && getMuted(notification, groupConfig, graphConfig); - const onClick = (e: any) => { if (!notification || read) { return; } - return api.hark.read(time, notification.index); + return airlock.poke(readNote(notification.index)); }; const { hovering, bind } = useHovering(); diff --git a/pkg/interface/src/views/apps/notifications/notifications.tsx b/pkg/interface/src/views/apps/notifications/notifications.tsx index bfef8998e..26d9fe1e4 100644 --- a/pkg/interface/src/views/apps/notifications/notifications.tsx +++ b/pkg/interface/src/views/apps/notifications/notifications.tsx @@ -1,55 +1,26 @@ import { Box, Col, Icon, Row, Text } from '@tlon/indigo-react'; -import React, { ReactElement, useCallback, useRef, useState } from 'react'; +import React, { ReactElement, useCallback, useRef } from 'react'; import Helmet from 'react-helmet'; import { Link, Route, Switch } from 'react-router-dom'; import useGroupState from '~/logic/state/group'; import useHarkState from '~/logic/state/hark'; -import useMetadataState from '~/logic/state/metadata'; -import { PropFunc } from '~/types/util'; import { Body } from '~/views/components/Body'; import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction'; import { useTutorialModal } from '~/views/components/useTutorialModal'; import Inbox from './inbox'; +import airlock from '~/logic/api'; +import { readAll } from '@urbit/api'; const baseUrl = '/~notifications'; -const HeaderLink = React.forwardRef(( - props: PropFunc & { view?: string; current: string }, - ref -): ReactElement => { - const { current, view, ...textProps } = props; - const to = view ? `${baseUrl}/${view}` : baseUrl; - const active = view ? current === view : !current; - - return ( - - - - ); -}); - -interface NotificationFilter { - groups: string[]; -} - export default function NotificationsScreen(props: any): ReactElement { const relativePath = (p: string) => baseUrl + p; - const [filter, setFilter] = useState({ groups: [] }); - const associations = useMetadataState(state => state.associations); const pendingJoin = useGroupState(s => s.pendingJoin); - const onSubmit = async ({ groups } : NotificationFilter) => { - setFilter({ groups }); - }; const onReadAll = useCallback(async () => { - await props.api.hark.readAll(); + await airlock.poke(readAll()); }, []); - const groupFilterDesc = - filter.groups.length === 0 - ? 'All' - : filter.groups - .map(g => associations.groups?.[g]?.metadata?.title) - .join(', '); + const anchorRef = useRef(null); useTutorialModal('notifications', true, anchorRef); const notificationsCount = useHarkState(state => state.notificationsCount); @@ -101,7 +72,7 @@ export default function NotificationsScreen(props: any): ReactElement { {!view && } diff --git a/pkg/interface/src/views/apps/publish/components/Note.tsx b/pkg/interface/src/views/apps/publish/components/Note.tsx index f609c63af..e00f28407 100644 --- a/pkg/interface/src/views/apps/publish/components/Note.tsx +++ b/pkg/interface/src/views/apps/publish/components/Note.tsx @@ -1,5 +1,5 @@ -import { Action, Anchor, Box, Col, Row, Text } from '@tlon/indigo-react'; -import { Association, Graph, GraphNode, Group } from '@urbit/api'; +import { Action, Box, Col, Row, Text } from '@tlon/indigo-react'; +import { Association, Graph, GraphNode, Group, markEachAsRead } from '@urbit/api'; import bigInt from 'big-integer'; import React, { useEffect, useState } from 'react'; import { Link, RouteComponentProps } from 'react-router-dom'; @@ -8,13 +8,12 @@ import { roleForShip } from '~/logic/lib/group'; import { getPermalinkForGraph } from '~/logic/lib/permalinks'; import { getComments, getLatestRevision } from '~/logic/lib/publish'; import { useCopy } from '~/logic/lib/useCopy'; -import { useQuery } from '~/logic/lib/useQuery'; import Author from '~/views/components/Author'; import { Comments } from '~/views/components/Comments'; import { Spinner } from '~/views/components/Spinner'; import { GraphContent } from '~/views/landscape/components/Graph/GraphContent'; import { NoteNavigation } from './NoteNavigation'; -import { Redirect } from 'react-router-dom'; +import airlock from '~/logic/api'; interface NoteProps { ship: string; @@ -56,14 +55,13 @@ export function Note(props: NoteProps & RouteComponentProps) { ); } - const { query } = useQuery(); const comments = getComments(note); - const [revNum, title, body, post] = getLatestRevision(note); + const [, title, , post] = getLatestRevision(note); const index = note.post.index.split('/'); const noteId = bigInt(index[1]); useEffect(() => { - api.hark.markEachAsRead(props.association, '/',`/${index[1]}/1/1`, 'note', 'publish'); + airlock.poke(markEachAsRead(props.association.resource, '/',`/${index[1]}/1/1`)); }, [props.association, props.note]); const adminLinks: JSX.Element[] = []; diff --git a/pkg/interface/src/views/apps/settings/components/lib/NotificationPref.tsx b/pkg/interface/src/views/apps/settings/components/lib/NotificationPref.tsx index 5bda6924b..a14ad31e7 100644 --- a/pkg/interface/src/views/apps/settings/components/lib/NotificationPref.tsx +++ b/pkg/interface/src/views/apps/settings/components/lib/NotificationPref.tsx @@ -1,10 +1,6 @@ import { Button, Col, - - - - ManagedToggleSwitchField as Toggle, Text } from '@tlon/indigo-react'; import { Form, FormikHelpers } from 'formik'; @@ -16,6 +12,9 @@ import useHarkState from '~/logic/state/hark'; import { FormikOnBlur } from '~/views/components/FormikOnBlur'; import { BackButton } from './BackButton'; import { GroupChannelPicker } from './GroupChannelPicker'; +import airlock from '~/logic/api'; +import { ignoreGraph, ignoreGroup, listenGraph, listenGroup, setDoNotDisturb, setMentions } from '@urbit/api'; +import { setWatchOnSelf } from '@urbit/api'; interface FormSchema { mentions: boolean; @@ -46,22 +45,22 @@ export function NotificationPreferences(props: { try { const promises: Promise[] = []; if (values.mentions !== graphConfig.mentions) { - promises.push(api.hark.setMentions(values.mentions)); + promises.push(airlock.poke(setMentions(values.mentions))); } if (values.watchOnSelf !== graphConfig.watchOnSelf) { - promises.push(api.hark.setWatchOnSelf(values.watchOnSelf)); + promises.push(airlock.poke(setWatchOnSelf(values.watchOnSelf))); } if (values.dnd !== dnd && !_.isUndefined(values.dnd)) { - promises.push(api.hark.setDoNotDisturb(values.dnd)); + promises.push(airlock.poke(setDoNotDisturb(values.dnd))); } _.forEach(values.graph, (listen: boolean, graph: string) => { if(listen !== isWatching(graphConfig, graph)) { - promises.push(api.hark[listen ? 'listenGraph' : 'ignoreGraph'](graph, '/')); + promises.push(airlock.poke((listen ? listenGraph : ignoreGraph)(graph, '/'))); } }); _.forEach(values.groups, (listen: boolean, group: string) => { if(listen !== groupConfig.includes(group)) { - promises.push(api.hark[listen ? 'listenGroup' : 'ignoreGroup'](group)); + promises.push(airlock.poke((listen ? listenGroup : ignoreGroup)(group))); } }); diff --git a/pkg/interface/src/views/components/Comments.tsx b/pkg/interface/src/views/components/Comments.tsx index c9e0a6060..62b445d3e 100644 --- a/pkg/interface/src/views/components/Comments.tsx +++ b/pkg/interface/src/views/components/Comments.tsx @@ -1,5 +1,5 @@ import { Col } from '@tlon/indigo-react'; -import { Association, GraphNode, Group } from '@urbit/api'; +import { Association, GraphNode, Group, markCountAsRead } from '@urbit/api'; import bigInt from 'big-integer'; import { FormikHelpers } from 'formik'; import React, { useEffect, useMemo } from 'react'; @@ -15,6 +15,7 @@ import useHarkState from '~/logic/state/hark'; import { PropFunc } from '~/types/util'; import CommentInput from './CommentInput'; import { CommentItem } from './CommentItem'; +import airlock from '~/logic/api'; interface CommentsProps { comments: GraphNode; @@ -32,10 +33,10 @@ export function Comments(props: CommentsProps & PropFunc) { comments, ship, name, - api, history, baseUrl, group, + api, ...rest } = props; @@ -76,7 +77,7 @@ export function Comments(props: CommentsProps & PropFunc) { ) => { try { const commentNode = comments.children.get(bigInt(editCommentId))!; - const [idx, _] = getLatestCommentRevision(commentNode); + const [idx] = getLatestCommentRevision(commentNode); const content = tokenizeMessage(comment); const post = createPost( @@ -95,7 +96,7 @@ export function Comments(props: CommentsProps & PropFunc) { let commentContent = null; if (editCommentId) { const commentNode = comments.children.get(bigInt(editCommentId)); - const [_, post] = getLatestCommentRevision(commentNode); + const [,post] = getLatestCommentRevision(commentNode); commentContent = post.contents.reduce((val, curr) => { if ('text' in curr) { val = val + curr.text; @@ -118,9 +119,9 @@ export function Comments(props: CommentsProps & PropFunc) { useEffect(() => { return () => { - api.hark.markCountAsRead(association, parentIndex, 'comment'); + airlock.poke(markCountAsRead(association.resource)); }; - }, [comments.post.index]); + }, [comments.post?.index]); const unreads = useHarkState(state => state.unreads); const readCount = children.length - getUnreadCount(unreads, association.resource, parentIndex); diff --git a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Notifications.tsx b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Notifications.tsx index fd6256c79..d138636cc 100644 --- a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Notifications.tsx +++ b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Notifications.tsx @@ -1,17 +1,16 @@ import { BaseLabel, Col, Label, Text } from '@tlon/indigo-react'; -import { Association } from '@urbit/api'; +import { Association, ignoreGraph, listenGraph } from '@urbit/api'; import React, { useRef } from 'react'; -import GlobalApi from '~/logic/api/global'; import useHarkState from '~/logic/state/hark'; import { StatelessAsyncToggle } from '~/views/components/StatelessAsyncToggle'; +import airlock from '~/logic/api'; interface ChannelNotificationsProps { - api: GlobalApi; association: Association; } export function ChannelNotifications(props: ChannelNotificationsProps) { - const { api, association } = props; + const { association } = props; const rid = association.resource; const notificationsGraphConfig = useHarkState(state => state.notificationsGraphConfig); @@ -21,8 +20,8 @@ export function ChannelNotifications(props: ChannelNotificationsProps) { ) === -1; const onChangeMute = async () => { - const func = isMuted ? 'listenGraph' : 'ignoreGraph'; - await api.hark[func](rid, '/'); + const func = isMuted ? listenGraph : ignoreGraph; + await airlock.poke(func(rid, '/')); }; const anchorRef = useRef(null); diff --git a/pkg/interface/src/views/landscape/components/GroupSettings/Personal.tsx b/pkg/interface/src/views/landscape/components/GroupSettings/Personal.tsx index 46c97d566..2460b62d7 100644 --- a/pkg/interface/src/views/landscape/components/GroupSettings/Personal.tsx +++ b/pkg/interface/src/views/landscape/components/GroupSettings/Personal.tsx @@ -4,11 +4,13 @@ import { Text } from '@tlon/indigo-react'; +import { ignoreGroup, listenGroup } from '@urbit/api'; import { Association } from '@urbit/api/metadata'; import React from 'react'; import GlobalApi from '~/logic/api/global'; import useHarkState from '~/logic/state/hark'; import { StatelessAsyncToggle } from '~/views/components/StatelessAsyncToggle'; +import airlock from '~/logic/api'; export function GroupPersonalSettings(props: { api: GlobalApi; @@ -21,8 +23,8 @@ export function GroupPersonalSettings(props: { const watching = notificationsGroupConfig.findIndex(g => g === groupPath) !== -1; const onClick = async () => { - const func = !watching ? 'listenGroup' : 'ignoreGroup'; - await props.api.hark[func](groupPath); + const func = !watching ? listenGroup : ignoreGroup; + await airlock.poke(func(groupPath)); }; return ( diff --git a/pkg/interface/src/views/landscape/components/Home/GroupFeed.tsx b/pkg/interface/src/views/landscape/components/Home/GroupFeed.tsx index 314d7fdb0..741f0024e 100644 --- a/pkg/interface/src/views/landscape/components/Home/GroupFeed.tsx +++ b/pkg/interface/src/views/landscape/components/Home/GroupFeed.tsx @@ -1,4 +1,5 @@ import { Col } from '@tlon/indigo-react'; +import { markCountAsRead } from '@urbit/api'; import React, { useEffect } from 'react'; @@ -11,6 +12,7 @@ import { Loading } from '~/views/components/Loading'; import { GroupFeedHeader } from './GroupFeedHeader'; import PostReplies from './Post/PostReplies'; import PostTimeline from './Post/PostTimeline'; +import airlock from '~/logic/api'; function GroupFeed(props) { const { @@ -50,7 +52,7 @@ function GroupFeed(props) { return; } api.graph.getNewest(graphResource.ship, graphResource.name, 100); - api.hark.markCountAsRead(association, '/', 'post'); + airlock.poke(markCountAsRead(graphPath)); }, [graphPath]); if (!graphPath) { diff --git a/pkg/interface/src/views/landscape/components/Home/GroupFlatFeed.tsx b/pkg/interface/src/views/landscape/components/Home/GroupFlatFeed.tsx index b57215738..bb1562b9d 100644 --- a/pkg/interface/src/views/landscape/components/Home/GroupFlatFeed.tsx +++ b/pkg/interface/src/views/landscape/components/Home/GroupFlatFeed.tsx @@ -12,6 +12,8 @@ import { GroupFeedHeader } from './GroupFeedHeader'; import PostThread from './Post/PostThread'; import PostFlatTimeline from './Post/PostFlatTimeline'; import PostReplies from './Post/PostReplies'; +import airlock from '~/logic/api'; +import { markCountAsRead } from '@urbit/api'; function GroupFlatFeed(props) { const { @@ -42,7 +44,7 @@ function GroupFlatFeed(props) { return; } api.graph.getDeepOlderThan(graphRid.ship, graphRid.name, null, 100); - api.hark.markCountAsRead(association, '/', 'post'); + airlock.poke(markCountAsRead(graphPath)); }, [graphPath]); if (!graphPath) { diff --git a/pkg/npm/api/graph/types.ts b/pkg/npm/api/graph/types.ts index 22779dc31..6a8972bc4 100644 --- a/pkg/npm/api/graph/types.ts +++ b/pkg/npm/api/graph/types.ts @@ -63,12 +63,12 @@ export interface GraphChildrenPoke { export interface GraphNode { children: Graph | null; - post: Post | string; + post: Post; } export interface FlatGraphNode { children: null; - post: Post | string; + post: Post; } export type Graph = BigIntOrderedMap; From 9c0ed504a6bcf7f4a148a618113c86e433c78ccd Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Tue, 8 Jun 2021 16:55:01 +1000 Subject: [PATCH 07/66] interface: add http-api to deps --- pkg/interface/package-lock.json | Bin 1119170 -> 1361323 bytes pkg/interface/package.json | 1 + 2 files changed, 1 insertion(+) diff --git a/pkg/interface/package-lock.json b/pkg/interface/package-lock.json index 0d756ff692258d8e9ffee395eda6a60ec840e063..7f841b93d14f4e4a75b46b134ea5b6ea4dd84fcb 100644 GIT binary patch delta 44612 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 Date: Wed, 9 Jun 2021 10:48:28 +1000 Subject: [PATCH 08/66] @urbit/api: update metadata pokes --- pkg/npm/api/metadata/lib.ts | 2 +- pkg/npm/api/metadata/types.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/npm/api/metadata/lib.ts b/pkg/npm/api/metadata/lib.ts index db2e06159..8f2120e98 100644 --- a/pkg/npm/api/metadata/lib.ts +++ b/pkg/npm/api/metadata/lib.ts @@ -77,4 +77,4 @@ export const update = ( }); } -export { update as metadataUpdate }; \ No newline at end of file +export { update as metadataUpdate }; diff --git a/pkg/npm/api/metadata/types.ts b/pkg/npm/api/metadata/types.ts index 1aa620943..a51b6cf02 100644 --- a/pkg/npm/api/metadata/types.ts +++ b/pkg/npm/api/metadata/types.ts @@ -42,7 +42,10 @@ export interface MetadataUpdatePreview { metadata: Metadata; } -export type Associations = Record; +export type Associations = { + groups: AppAssociations + graph: AppAssociations; +} export type AppAssociations = { [p in Path]: Association; From 81937a4de67b7444b05aed159df510b4f59eb74c Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Wed, 9 Jun 2021 10:50:19 +1000 Subject: [PATCH 09/66] @urbit/http-api: add subscribeOnce --- pkg/npm/http-api/src/Urbit.ts | 42 ++++++++++++++++++++++++++++++++++- pkg/npm/http-api/src/types.ts | 6 +++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/pkg/npm/http-api/src/Urbit.ts b/pkg/npm/http-api/src/Urbit.ts index 18a1141f3..16da2d2e2 100644 --- a/pkg/npm/http-api/src/Urbit.ts +++ b/pkg/npm/http-api/src/Urbit.ts @@ -219,7 +219,7 @@ export class Urbit implements UrbitInterface { (data.response === 'poke' && this.outstandingSubscriptions.has(data.id))) { const funcs = this.outstandingSubscriptions.get(data.id); if (data.hasOwnProperty('err')) { - funcs.err(data.err); + funcs.err(data.err, data.id); this.outstandingSubscriptions.delete(data.id); } } else if (data.response === 'diff' && this.outstandingSubscriptions.has(data.id)) { @@ -347,6 +347,46 @@ export class Urbit implements UrbitInterface { }); } + /** + * Creates a subscription, waits for a fact and then unsubscribes + * + * @param app Name of gall agent to subscribe to + * @param path Path to subscribe to + * @param timeout Optional timeout before ending subscription + * + * @returns The first fact on the subcription + */ + async subscribeOnce(app: string, path: string, timeout?: number) { + return new Promise(async (resolve, reject) => { + let done = false; + let id: number | null = null; + const quit = () => { + if(!done) { + reject('quit'); + } + }; + const event = (e: T) => { + if(!done) { + resolve(e); + this.unsubscribe(id); + } + } + const request = { app, path, event, err: reject, quit }; + + id = await this.subscribe(request); + + if(timeout) { + setTimeout(() => { + if(!done) { + done = true; + reject('timeout'); + this.unsubscribe(id); + } + }, timeout); + } + }); + } + // resetDebounceTimer() { diff --git a/pkg/npm/http-api/src/types.ts b/pkg/npm/http-api/src/types.ts index d544cae3c..01202fd1f 100644 --- a/pkg/npm/http-api/src/types.ts +++ b/pkg/npm/http-api/src/types.ts @@ -15,11 +15,13 @@ export interface AuthenticationInterface { } export interface SubscriptionInterface { - err?(error: any): void; + err?(error: any, id: string): void; event?(data: any): void; quit?(data: any): void; } +export type OnceSubscriptionErr = 'quit' | 'nack' | 'timeout'; + export type SubscriptionRequestInterface = SubscriptionInterface & { app: string; path: string; @@ -69,4 +71,4 @@ export interface SSEOptions { export interface Message extends Record { action: Action; id?: number; -} \ No newline at end of file +} From 56b3273c123ce13e779aaf6b22f8c373a6e33166 Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Wed, 9 Jun 2021 10:54:05 +1000 Subject: [PATCH 10/66] interface: move metadata subs to airlock --- pkg/interface/src/logic/api/index.ts | 7 +- .../src/logic/reducers/metadata-update.ts | 42 ++--- pkg/interface/src/logic/state/base.ts | 4 +- pkg/interface/src/logic/state/metadata.ts | 147 ++++++++++++------ 4 files changed, 122 insertions(+), 78 deletions(-) diff --git a/pkg/interface/src/logic/api/index.ts b/pkg/interface/src/logic/api/index.ts index a8f0383d6..2b9d0c49a 100644 --- a/pkg/interface/src/logic/api/index.ts +++ b/pkg/interface/src/logic/api/index.ts @@ -1,5 +1,6 @@ import Urbit from '@urbit/http-api'; import useHarkState from '~/logic/state/hark'; +import useMetadataState from '~/logic/state/metadata'; const api = new Urbit('', ''); api.ship = window.ship; @@ -10,15 +11,11 @@ console.log(api); window.api = api; export const bootstrapApi = async () => { - console.log('a'); await api.poke({ app: 'hood', mark: 'helm-hi', json: 'opening airlock' }); - console.log('b'); await api.eventSource(); - console.log('c'); - [useHarkState].forEach((state) => { + [useHarkState, useMetadataState].forEach((state) => { state.getState().initialize(api); - console.log('initialized'); }); }; diff --git a/pkg/interface/src/logic/reducers/metadata-update.ts b/pkg/interface/src/logic/reducers/metadata-update.ts index 9049a19f3..5cf6671af 100644 --- a/pkg/interface/src/logic/reducers/metadata-update.ts +++ b/pkg/interface/src/logic/reducers/metadata-update.ts @@ -1,32 +1,17 @@ import { MetadataUpdate } from '@urbit/api/metadata'; import _ from 'lodash'; import { Cage } from '~/types/cage'; -import { reduceState } from '../state/base'; -import useMetadataState, { MetadataState } from '../state/metadata'; +import { BaseState } from '../state/base'; +import { MetadataState as State } from '../state/metadata'; + +type MetadataState = State & BaseState; export default class MetadataReducer { reduce(json: Cage) { - const data = json['metadata-update']; - if (data) { - reduceState(useMetadataState, data, [ - associations, - add, - update, - remove, - groupInitial - ]); - } + return; } } -const groupInitial = (json: MetadataUpdate, state: MetadataState): MetadataState => { - const data = _.get(json, 'initial-group', false); - if(data) { - associations(data, state); - } - return state; -}; - const associations = (json: MetadataUpdate, state: MetadataState): MetadataState => { const data = _.get(json, 'associations', false); if (data) { @@ -69,6 +54,14 @@ const add = (json: MetadataUpdate, state: MetadataState): MetadataState => { return state; }; +const groupInitial = (json: MetadataUpdate, state: MetadataState): MetadataState => { + const data = _.get(json, 'initial-group', false); + if(data) { + associations(data, state); + } + return state; +}; + const update = (json: MetadataUpdate, state: MetadataState): MetadataState => { const data = _.get(json, 'update-metadata', false); if (data) { @@ -103,3 +96,12 @@ const remove = (json: MetadataUpdate, state: MetadataState): MetadataState => { } return state; }; + +export const reduce = [ + associations, + add, + update, + remove, + groupInitial +]; + diff --git a/pkg/interface/src/logic/state/base.ts b/pkg/interface/src/logic/state/base.ts index 472023854..558f65f4b 100644 --- a/pkg/interface/src/logic/state/base.ts +++ b/pkg/interface/src/logic/state/base.ts @@ -87,10 +87,10 @@ export interface BaseState { patches: { [id: string]: Patch[]; }; - set: (fn: (state: BaseState) => void) => void; + set: (fn: (state: StateType & BaseState) => void) => void; addPatch: (id: string, ...patch: Patch[]) => void; removePatch: (id: string) => void; - optSet: (fn: (state: BaseState) => void) => string; + optSet: (fn: (state: StateType & BaseState) => void) => string; initialize: (api: Urbit) => void; } diff --git a/pkg/interface/src/logic/state/metadata.ts b/pkg/interface/src/logic/state/metadata.ts index 4983d6d58..2d43a8479 100644 --- a/pkg/interface/src/logic/state/metadata.ts +++ b/pkg/interface/src/logic/state/metadata.ts @@ -1,70 +1,115 @@ -import { Association, Associations } from '@urbit/api'; +import { Association, Associations, MetadataUpdatePreview } from '@urbit/api'; import _ from 'lodash'; -import { useCallback } from 'react'; -import { BaseState, createState } from './base'; +import { useCallback, useEffect, useState } from 'react'; +import { + createState, + createSubscription, + reduceStateN +} from './base'; +import airlock from '~/logic/api'; +import { reduce } from '../reducers/metadata-update'; export const METADATA_MAX_PREVIEW_WAIT = 150000; -export interface MetadataState extends BaseState { +export interface MetadataState { associations: Associations; - // preview: (group: string) => Promise; + getPreview: (group: string) => Promise; + previews: { + [group: string]: MetadataUpdatePreview + } } +// @ts-ignore investigate zustand types +const useMetadataState = createState( + 'Metadata', + (set, get) => ({ + associations: { + groups: {}, + graph: {} + }, + previews: {}, + getPreview: async (group: string): Promise => { + const state = get(); + if(group in state.previews) { + return state.previews[group]; + } + try { + const preview = await airlock.subscribeOnce('metadata-pull-hook', `/preview${group}`); + if('metadata-hook-update' in preview) { + const newState = get(); + newState.set((s) => { + s.previews[group] = preview['metadata-hook-update']; + }); + return preview['metadata-hook-update']; + } else { + throw 'no-permissions'; + } + } catch (e) { + if(e === 'timeout') { + throw 'offline'; + } + throw e; + } + } + }), + [], + [ + (set, get) => + createSubscription('metadata-store', '/all', (j) => { + const d = _.get(j, 'metadata-update', false); + if (d) { + reduceStateN(get(), d, reduce); + } + }) + ] +); + export function useAssocForGraph(graph: string) { - return useMetadataState(useCallback(s => s.associations.graph[graph] as Association | undefined, [graph])); + return useMetadataState( + useCallback(s => s.associations.graph[graph] as Association | undefined, [ + graph + ]) + ); } export function useAssocForGroup(group: string) { - return useMetadataState(useCallback(s => s.associations.groups[group] as Association | undefined, [group])); + return useMetadataState( + useCallback( + s => s.associations.groups[group] as Association | undefined, + [group] + ) + ); +} + +export function usePreview(group: string) { + const [error, setError] = useState(null); + const [previews, getPreview] = useMetadataState(s => [s.previews, s.getPreview]); + useEffect(() => { + let mounted = true; + (async () => { + try { + await getPreview(group); + } catch (e) { + if(mounted) { + setError(e); + } + } + })(); + + return () => { + mounted = false; + }; + }); + + const preview = previews[group]; + + return { error, preview }; } export function useGraphsForGroup(group: string) { const graphs = useMetadataState(s => s.associations.graph); return _.pickBy(graphs, (a: Association) => a.group === group); } -// @ts-ignore investigate zustand types -const useMetadataState = createState('Metadata', { - associations: { groups: {}, graph: {}, contacts: {}, chat: {}, link: {}, publish: {} } - // preview: async (group): Promise => { - // return new Promise((resolve, reject) => { - // const api = useApi(); - // let done = false; - - // setTimeout(() => { - // if (done) { - // return; - // } - // done = true; - // reject(new Error('offline')); - // }, METADATA_MAX_PREVIEW_WAIT); - - // api.subscribe({ - // app: 'metadata-pull-hook', - // path: `/preview${group}`, - // // TODO type this message? - // event: (message) => { - // if ('metadata-hook-update' in message) { - // done = true; - // const update = message['metadata-hook-update'].preview as MetadataUpdatePreview; - // resolve(update); - // } else { - // done = true; - // reject(new Error('no-permissions')); - // } - // // TODO how to delete this subscription? Perhaps return the susbcription ID as the second parameter of all the handlers - // }, - // err: (error) => { - // console.error(error); - // reject(error); - // }, - // quit: () => { - // if (!done) { - // reject(new Error('offline')); - // } - // } - // }); - // }); - // }, -}); export default useMetadataState; From c80751e8b80811b6d02b0f49d7bfbd94073a6d8d Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Wed, 9 Jun 2021 10:59:09 +1000 Subject: [PATCH 11/66] interface: move metadata pokes to airlock --- .../src/views/components/GroupLink.tsx | 29 ++++--------------- .../src/views/components/Invite/index.tsx | 21 +++----------- .../ChannelPermissions.tsx | 7 +++-- .../ChannelPopoverRoutes/Details.tsx | 14 ++++----- .../components/ChannelPopoverRoutes/index.tsx | 6 ++-- .../components/GroupSettings/Admin.tsx | 11 +++---- .../components/GroupSettings/Channels.tsx | 15 +++++----- .../components/GroupSettings/GroupFeed.tsx | 12 +++----- .../views/landscape/components/JoinGroup.tsx | 19 +++++------- 9 files changed, 48 insertions(+), 86 deletions(-) diff --git a/pkg/interface/src/views/components/GroupLink.tsx b/pkg/interface/src/views/components/GroupLink.tsx index 290073385..27c427199 100644 --- a/pkg/interface/src/views/components/GroupLink.tsx +++ b/pkg/interface/src/views/components/GroupLink.tsx @@ -1,11 +1,9 @@ import { Box, Col, Icon, Row, Text } from '@tlon/indigo-react'; -import { MetadataUpdatePreview } from '@urbit/api'; -import React, { ReactElement, useEffect, useLayoutEffect, useState } from 'react'; +import React, { ReactElement } from 'react'; import { useHistory } from 'react-router-dom'; import GlobalApi from '~/logic/api/global'; import { useModal } from '~/logic/lib/useModal'; -import { useVirtual } from '~/logic/lib/virtualContext'; -import useMetadataState from '~/logic/state/metadata'; +import useMetadataState, { usePreview } from '~/logic/state/metadata'; import { PropFunc } from '~/types'; import { JoinGroup } from '../landscape/components/JoinGroup'; import { MetadataIcon } from '../landscape/components/MetadataIcon'; @@ -19,32 +17,15 @@ export function GroupLink( ): ReactElement { const { resource, api, ...rest } = props; const name = resource.slice(6); - const [preview, setPreview] = useState(null); const associations = useMetadataState(state => state.associations); - const { save, restore } = useVirtual(); const history = useHistory(); const joined = resource in associations.groups; const { modal, showModal } = useModal({ modal: - }); + }); - useEffect(() => { - (async () => { - const prev = await api.metadata.preview(resource); - save(); - setPreview(prev); - })(); - - return () => { - save(); - setPreview(null); - }; - }, [resource]); - - useLayoutEffect(() => { - restore(); - }, [preview]); + const { preview } = usePreview(resource); return ( - + {preview ? preview.metadata.title : name} diff --git a/pkg/interface/src/views/components/Invite/index.tsx b/pkg/interface/src/views/components/Invite/index.tsx index 3abd042a6..b91f699e1 100644 --- a/pkg/interface/src/views/components/Invite/index.tsx +++ b/pkg/interface/src/views/components/Invite/index.tsx @@ -1,9 +1,8 @@ -import { - JoinRequest, MetadataUpdatePreview -} from '@urbit/api'; +import { JoinRequest } from '@urbit/api'; import { Invite } from '@urbit/api/invite'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import GlobalApi from '~/logic/api/global'; +import { usePreview } from '~/logic/state/metadata'; import { GroupInvite } from './Group'; interface InviteItemProps { @@ -16,21 +15,9 @@ interface InviteItemProps { } export function InviteItem(props: InviteItemProps) { - const [preview, setPreview] = useState(null); const { pendingJoin, invite, resource, uid, app, api } = props; - useEffect(() => { - if (!app || app === 'groups') { - (async () => { - setPreview(await api.metadata.preview(resource)); - })(); - return () => { - setPreview(null); - }; - } else { - return () => {}; - } - }, [invite]); + const { preview } = usePreview(resource); if (pendingJoin?.hidden) { return null; diff --git a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx index 75825181d..8b0cce008 100644 --- a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx +++ b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx @@ -5,7 +5,7 @@ import { Text } from '@tlon/indigo-react'; -import { Association, Group, PermVariation } from '@urbit/api'; +import { Association, Group, metadataUpdate, PermVariation } from '@urbit/api'; import { Form, Formik } from 'formik'; import _ from 'lodash'; import React from 'react'; @@ -15,6 +15,7 @@ import { resourceFromPath } from '~/logic/lib/group'; import { FormGroupChild } from '~/views/components/FormGroup'; import { shipSearchSchemaInGroup } from '~/views/components/ShipSearch'; import { ChannelWritePerms } from '../ChannelWritePerms'; +import airlock from '~/logic/api'; function PermissionsSummary(props: { writersSize: number; @@ -108,9 +109,9 @@ export function GraphPermissions(props: GraphPermissionsProps) { }; const allWriters = Array.from(writers).map(w => `~${w}`); if (values.readerComments !== readerComments) { - await api.metadata.update(association, { + await airlock.poke(metadataUpdate(association, { vip: values.readerComments ? 'reader-comments' : '' - }); + })); } if (values.writePerms === 'everyone') { diff --git a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Details.tsx b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Details.tsx index ce3a9312e..210f81f4b 100644 --- a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Details.tsx +++ b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/Details.tsx @@ -1,10 +1,9 @@ import { - Col, - Label, ManagedTextInputField as Input, - - Text + Col, + Label, ManagedTextInputField as Input, + Text } from '@tlon/indigo-react'; -import { Association } from '@urbit/api'; +import { Association, metadataUpdate } from '@urbit/api'; import { Form, Formik } from 'formik'; import React from 'react'; import GlobalApi from '~/logic/api/global'; @@ -12,6 +11,7 @@ import { uxToHex } from '~/logic/lib/util'; import { ColorInput } from '~/views/components/ColorInput'; import { FormError } from '~/views/components/FormError'; import { FormGroupChild } from '~/views/components/FormGroup'; +import airlock from '~/logic/api'; interface FormSchema { title: string; @@ -25,7 +25,7 @@ interface ChannelDetailsProps { } export function ChannelDetails(props: ChannelDetailsProps) { - const { association, api } = props; + const { association } = props; const { metadata } = association; const initialValues: FormSchema = { title: metadata?.title || '', @@ -36,7 +36,7 @@ export function ChannelDetails(props: ChannelDetailsProps) { const onSubmit = async (values: FormSchema, actions) => { const { title, description } = values; const color = uxToHex(values.color); - await api.metadata.update(association, { title, color, description }); + await airlock.poke(metadataUpdate(association, { title, color, description })); actions.setStatus({ success: null }); }; diff --git a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/index.tsx b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/index.tsx index a97ef76b4..4a6bda2b6 100644 --- a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/index.tsx +++ b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/index.tsx @@ -2,7 +2,8 @@ import { Box, Col, Row, Text } from '@tlon/indigo-react'; import { Association, - Group + Group, + metadataRemove } from '@urbit/api'; import React, { useCallback, useRef } from 'react'; import { Link, useHistory } from 'react-router-dom'; @@ -16,6 +17,7 @@ import { GraphPermissions } from './ChannelPermissions'; import { ChannelDetails } from './Details'; import { ChannelNotifications } from './Notifications'; import { ChannelPopoverRoutesSidebar } from './Sidebar'; +import airlock from '~/logic/api'; interface ChannelPopoverRoutesProps { baseUrl: string; @@ -41,7 +43,7 @@ export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) { history.push(props.rootUrl); }; const handleRemove = async () => { - await api.metadata.remove('graph', association.resource, association.group); + await airlock.poke(metadataRemove('graph', association.resource, association.group)); history.push(props.rootUrl); }; const handleArchive = async () => { diff --git a/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx b/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx index 861a96516..3fa8cece7 100644 --- a/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx +++ b/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx @@ -1,17 +1,14 @@ import { Box, - Col, ManagedTextInputField as Input, ManagedToggleSwitchField as Checkbox, - Text } from '@tlon/indigo-react'; -import { Enc } from '@urbit/api'; +import { Enc, metadataUpdate } from '@urbit/api'; import { Group, GroupPolicy } from '@urbit/api/groups'; import { Association } from '@urbit/api/metadata'; import { Form, Formik, FormikHelpers } from 'formik'; import React from 'react'; -import { useHistory } from 'react-router-dom'; import * as Yup from 'yup'; import GlobalApi from '~/logic/api/global'; import { resourceFromPath, roleForShip } from '~/logic/lib/group'; @@ -20,6 +17,7 @@ import { AsyncButton } from '~/views/components/AsyncButton'; import { ColorInput } from '~/views/components/ColorInput'; import { FormError } from '~/views/components/FormError'; import { ImageInput } from '~/views/components/ImageInput'; +import airlock from '~/logic/api'; interface FormSchema { title: string; @@ -47,7 +45,6 @@ interface GroupAdminSettingsProps { export function GroupAdminSettings(props: GroupAdminSettingsProps) { const { group, association } = props; const { metadata } = association; - const history = useHistory(); const currentPrivate = 'invite' in props.group.policy; const initialValues: FormSchema = { title: metadata?.title, @@ -66,13 +63,13 @@ export function GroupAdminSettings(props: GroupAdminSettingsProps) { const { title, description, picture, color, isPrivate, adminMetadata } = values; const uxColor = uxToHex(color); const vip = adminMetadata ? '' : 'member-metadata'; - await props.api.metadata.update(props.association, { + await airlock.poke(metadataUpdate(props.association, { title, description, picture, color: uxColor, vip - }); + })); if (isPrivate !== currentPrivate) { const resource = resourceFromPath(props.association.group); const newPolicy: Enc = isPrivate diff --git a/pkg/interface/src/views/landscape/components/GroupSettings/Channels.tsx b/pkg/interface/src/views/landscape/components/GroupSettings/Channels.tsx index 6beb6cfa6..dc32208ca 100644 --- a/pkg/interface/src/views/landscape/components/GroupSettings/Channels.tsx +++ b/pkg/interface/src/views/landscape/components/GroupSettings/Channels.tsx @@ -1,5 +1,5 @@ import { Col, Icon, Row, Text } from '@tlon/indigo-react'; -import { Association, Group } from '@urbit/api'; +import { Association, Group, metadataRemove, metadataUpdate } from '@urbit/api'; import React, { useCallback } from 'react'; import GlobalApi from '~/logic/api/global'; import { resourceFromPath, roleForShip } from '~/logic/lib/group'; @@ -7,6 +7,7 @@ import { getModuleIcon, GraphModule } from '~/logic/lib/util'; import useMetadataState from '~/logic/state/metadata'; import { Dropdown } from '~/views/components/Dropdown'; import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction'; +import airlock from '~/logic/api'; interface GroupChannelSettingsProps { group: Group; @@ -15,7 +16,7 @@ interface GroupChannelSettingsProps { } export function GroupChannelSettings(props: GroupChannelSettingsProps) { - const { api, association, group } = props; + const { association, group } = props; const associations = useMetadataState(state => state.associations); const channels = Object.values(associations.graph).filter( ({ group }) => association.group === group @@ -23,16 +24,16 @@ export function GroupChannelSettings(props: GroupChannelSettingsProps) { const onChange = useCallback( async (resource: string, preview: boolean) => { - return api.metadata.update(associations.graph[resource], { preview }); + return airlock.poke(metadataUpdate(associations.graph[resource], { preview })); }, - [associations, api] + [associations.graph] ); const onRemove = useCallback( async (resource: string) => { - return api.metadata.remove('graph', resource, association.group); + return airlock.poke(metadataRemove('graph', resource, association.group)); }, - [api, association] + [association] ); const disabled = @@ -53,7 +54,7 @@ export function GroupChannelSettings(props: GroupChannelSettingsProps) { 'graph' in metadata?.config ? metadata?.config?.graph as GraphModule : 'post')} - /> + /> {metadata.title} {metadata.preview && Pinned} diff --git a/pkg/interface/src/views/landscape/components/GroupSettings/GroupFeed.tsx b/pkg/interface/src/views/landscape/components/GroupSettings/GroupFeed.tsx index a89bc708a..62ea84f6c 100644 --- a/pkg/interface/src/views/landscape/components/GroupSettings/GroupFeed.tsx +++ b/pkg/interface/src/views/landscape/components/GroupSettings/GroupFeed.tsx @@ -1,14 +1,13 @@ import { BaseLabel, Col, Label, Text } from '@tlon/indigo-react'; -import { Association, Group, PermVariation, resourceFromPath } from '@urbit/api'; +import { Association, Group, metadataUpdate, PermVariation, resourceFromPath } from '@urbit/api'; import { Form, Formik, FormikHelpers } from 'formik'; import React from 'react'; import GlobalApi from '~/logic/api/global'; import useMetadataState from '~/logic/state/metadata'; import { FormSubmit } from '~/views/components/FormSubmit'; import { StatelessAsyncToggle } from '~/views/components/StatelessAsyncToggle'; -import { - GroupFeedPermsInput -} from '../Home/Post/GroupFeedPerms'; +import { GroupFeedPermsInput } from '../Home/Post/GroupFeedPerms'; +import airlock from '~/logic/api'; interface FormSchema { permissions: PermVariation; @@ -33,9 +32,6 @@ export function GroupFeedSettings(props: { const feedAssoc = useMetadataState(s => s.associations.graph[feedResource]); const isEnabled = Boolean(feedResource); - const associations = useMetadataState(state => state.associations); - const feedMetadata = associations?.graph[feedResource]; - const vip = feedAssoc?.metadata?.vip || ' '; const toggleFeed = async (actions: any) => { if (isEnabled) { @@ -52,7 +48,7 @@ export function GroupFeedSettings(props: { values: FormSchema, actions: FormikHelpers ) => { - await api.metadata.update(feedAssoc, { vip: values.permissions.trim() as PermVariation }); + await airlock.poke(metadataUpdate(feedAssoc, { vip: values.permissions.trim() as PermVariation })); actions.setStatus({ success: null }); }; diff --git a/pkg/interface/src/views/landscape/components/JoinGroup.tsx b/pkg/interface/src/views/landscape/components/JoinGroup.tsx index 1b3d91db7..b84cda932 100644 --- a/pkg/interface/src/views/landscape/components/JoinGroup.tsx +++ b/pkg/interface/src/views/landscape/components/JoinGroup.tsx @@ -1,10 +1,7 @@ import { Box, Col, - Icon, - ManagedTextInputField as Input, Row, - Text } from '@tlon/indigo-react'; import { MetadataUpdatePreview } from '@urbit/api'; @@ -61,7 +58,7 @@ function Autojoin(props: { autojoin: string | null }) { export function JoinGroup(props: JoinGroupProps): ReactElement { const { api, autojoin } = props; - const associations = useMetadataState(state => state.associations); + const { associations, getPreview } = useMetadataState(); const groups = useGroupState(state => state.groups); const history = useHistory(); const initialValues: FormSchema = { @@ -120,19 +117,19 @@ export function JoinGroup(props: JoinGroupProps): ReactElement { } // skip if it's unmanaged try { - const prev = await api.metadata.preview(path); + const prev = await getPreview(path); actions.setStatus({ success: null }); setPreview(prev); } catch (e) { - if (!(e instanceof Error)) { - actions.setStatus({ error: 'Unknown error' }); - } else if (e.message === 'no-permissions') { + if (e === 'no-permissions') { actions.setStatus({ error: 'Unable to join group, you do not have the correct permissions' }); - } else if (e.message === 'offline') { + } else if (e === 'offline') { setPreview(path); + } else { + actions.setStatus({ error: 'Unknown error' }); } } }, @@ -179,8 +176,8 @@ export function JoinGroup(props: JoinGroupProps): ReactElement { Channels - {Object.values(preview.channels).map(({ metadata }: any) => ( - + {Object.values(preview.channels).map(({ metadata }: any, i) => ( + Date: Wed, 9 Jun 2021 11:41:53 +1000 Subject: [PATCH 12/66] interface: move group subs to airlock --- .../src/logic/reducers/group-update.ts | 70 +++++++++---------- .../src/logic/reducers/group-view.ts | 24 +++---- pkg/interface/src/logic/state/group.ts | 47 ++++++++++--- pkg/interface/src/logic/store/store.ts | 2 - 4 files changed, 83 insertions(+), 60 deletions(-) diff --git a/pkg/interface/src/logic/reducers/group-update.ts b/pkg/interface/src/logic/reducers/group-update.ts index eb4d242ae..a008b64c6 100644 --- a/pkg/interface/src/logic/reducers/group-update.ts +++ b/pkg/interface/src/logic/reducers/group-update.ts @@ -9,8 +9,10 @@ import { import _ from 'lodash'; import { Cage } from '~/types/cage'; import { resourceAsPath } from '../lib/util'; -import { reduceState } from '../state/base'; -import useGroupState, { GroupState } from '../state/group'; +import { BaseState } from '../state/base'; +import { GroupState as State } from '../state/group'; + +type GroupState = BaseState & State; function decodeGroup(group: Enc): Group { const members = new Set(group.members); @@ -54,21 +56,7 @@ function decodeTags(tags: Enc): Tags { export default class GroupReducer { reduce(json: Cage) { - const data = json.groupUpdate; - if (data) { - reduceState(useGroupState, data, [ - initial, - addMembers, - addTag, - removeMembers, - initialGroup, - removeTag, - addGroup, - removeGroup, - changePolicy, - expose - ]); - } + return; } } const initial = (json: GroupUpdate, state: GroupState): GroupState => { @@ -175,24 +163,6 @@ const removeTag = (json: GroupUpdate, state: GroupState): GroupState => { return state; }; -const changePolicy = (json: GroupUpdate, state: GroupState): GroupState => { - if ('changePolicy' in json && state) { - const { resource, diff } = json.changePolicy; - const resourcePath = resourceAsPath(resource); - const policy = state.groups[resourcePath].policy; - if ('open' in policy && 'open' in diff) { - openChangePolicy(diff.open, policy); - } else if ('invite' in policy && 'invite' in diff) { - inviteChangePolicy(diff.invite, policy); - } else if ('replace' in diff) { - state.groups[resourcePath].policy = diff.replace; - } else { - console.log('bad policy diff'); - } - } - return state; -}; - const expose = (json: GroupUpdate, state: GroupState): GroupState => { if( 'expose' in json && state) { const { resource } = json.expose; @@ -243,3 +213,33 @@ const openChangePolicy = (diff: OpenPolicyDiff, policy: OpenPolicy) => { console.log('bad policy change'); } }; + +const changePolicy = (json: GroupUpdate, state: GroupState): GroupState => { + if ('changePolicy' in json && state) { + const { resource, diff } = json.changePolicy; + const resourcePath = resourceAsPath(resource); + const policy = state.groups[resourcePath].policy; + if ('open' in policy && 'open' in diff) { + openChangePolicy(diff.open, policy); + } else if ('invite' in policy && 'invite' in diff) { + inviteChangePolicy(diff.invite, policy); + } else if ('replace' in diff) { + state.groups[resourcePath].policy = diff.replace; + } else { + console.log('bad policy diff'); + } + } + return state; +}; +export const reduce = [ + initial, + addMembers, + addTag, + removeMembers, + initialGroup, + removeTag, + addGroup, + removeGroup, + changePolicy, + expose +]; diff --git a/pkg/interface/src/logic/reducers/group-view.ts b/pkg/interface/src/logic/reducers/group-view.ts index fae75a0a6..2f694efa7 100644 --- a/pkg/interface/src/logic/reducers/group-view.ts +++ b/pkg/interface/src/logic/reducers/group-view.ts @@ -1,6 +1,7 @@ -import { GroupUpdate } from '@urbit/api/groups'; -import { reduceState } from '../state/base'; -import useGroupState, { GroupState } from '../state/group'; +import { BaseState } from '../state/base'; +import { GroupState as State } from '../state/group'; + +type GroupState = State & BaseState; const initial = (json: any, state: GroupState): GroupState => { const data = json.initial; @@ -41,14 +42,9 @@ const hide = (json: any, state: GroupState) => { return state; }; -export const GroupViewReducer = (json: any) => { - const data = json['group-view-update']; - if (data) { - reduceState(useGroupState, data, [ - progress, - hide, - started, - initial - ]); - } -}; +export const reduce = [ + progress, + hide, + started, + initial +]; diff --git a/pkg/interface/src/logic/state/group.ts b/pkg/interface/src/logic/state/group.ts index 5f7324124..c82f3ad42 100644 --- a/pkg/interface/src/logic/state/group.ts +++ b/pkg/interface/src/logic/state/group.ts @@ -1,27 +1,56 @@ import { Association, Group, JoinRequests } from '@urbit/api'; import { useCallback } from 'react'; -import { BaseState, createState } from './base'; +import { reduce } from '../reducers/group-update'; +import _ from 'lodash'; +import { reduce as reduceView } from '../reducers/group-view'; +import { + createState, + createSubscription, + reduceStateN +} from './base'; -export interface GroupState extends BaseState { +export interface GroupState { groups: { [group: string]: Group; - } + }; pendingJoin: JoinRequests; } // @ts-ignore investigate zustand types -const useGroupState = createState('Group', { - groups: {}, - pendingJoin: {} -}, ['groups']); +const useGroupState = createState( + 'Group', + { + groups: {}, + pendingJoin: {} + }, + ['groups'], + [ + (set, get) => + createSubscription('group-store', '/groups', (e) => { + if ('groupUpdate' in e) { + reduceStateN(get(), e.groupUpdate, reduce); + } + }), + (set, get) => createSubscription('group-view', '/groups', (e) => { + const data = _.get(e, 'group-view-update', false); + if (data) { + reduceStateN(get(), data, reduceView); + } + }) + ] +); export function useGroup(group: string) { - return useGroupState(useCallback(s => s.groups[group] as Group | undefined, [group])); + return useGroupState( + useCallback(s => s.groups[group] as Group | undefined, [group]) + ); } export function useGroupForAssoc(association: Association) { return useGroupState( - useCallback(s => s.groups[association.group] as Group | undefined, [association]) + useCallback(s => s.groups[association.group] as Group | undefined, [ + association + ]) ); } diff --git a/pkg/interface/src/logic/store/store.ts b/pkg/interface/src/logic/store/store.ts index 5dd2e6b1a..1dd2c021d 100644 --- a/pkg/interface/src/logic/store/store.ts +++ b/pkg/interface/src/logic/store/store.ts @@ -6,7 +6,6 @@ import { ContactReducer } from '../reducers/contact-update'; import GcpReducer from '../reducers/gcp-reducer'; import { GraphReducer } from '../reducers/graph-update'; import GroupReducer from '../reducers/group-update'; -import { GroupViewReducer } from '../reducers/group-view'; import InviteReducer from '../reducers/invite-update'; import LaunchReducer from '../reducers/launch-update'; import MetadataReducer from '../reducers/metadata-update'; @@ -53,7 +52,6 @@ export default class GlobalStore extends BaseStore { this.metadataReducer.reduce(data); this.s3Reducer.reduce(data); this.groupReducer.reduce(data); - GroupViewReducer(data); this.launchReducer.reduce(data); this.connReducer.reduce(data, this.state); GraphReducer(data); From 73f43a282925ec8ee625817840d4d37b29f94638 Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Wed, 9 Jun 2021 11:42:27 +1000 Subject: [PATCH 13/66] @urbit/api: update group pokes --- pkg/npm/api/groups/lib.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/npm/api/groups/lib.ts b/pkg/npm/api/groups/lib.ts index 99a8514e0..97b0de9d6 100644 --- a/pkg/npm/api/groups/lib.ts +++ b/pkg/npm/api/groups/lib.ts @@ -89,8 +89,8 @@ export const removeGroup = ( export const changePolicy = ( resource: Resource, - diff: GroupPolicyDiff -): Poke => proxyAction({ + diff: Enc +): Poke> => proxyAction({ changePolicy: { resource, diff @@ -148,7 +148,7 @@ export const invite = ( } }); -export const hide = ( +export const hideGroup = ( resource: string ): Poke => viewAction({ hide: resource From 2f70a433bd70c4ce014a6a1dbcfbbd594111eabb Mon Sep 17 00:00:00 2001 From: Liam Fitzgerald Date: Wed, 9 Jun 2021 11:46:42 +1000 Subject: [PATCH 14/66] interface: move group pokes to airlock --- pkg/interface/src/views/apps/launch/App.tsx | 4 +- .../src/views/apps/notifications/joining.tsx | 17 +++--- .../views/apps/publish/components/Writers.tsx | 9 +-- .../src/views/components/Invite/Group.tsx | 14 +++-- .../ChannelPermissions.tsx | 14 ++--- .../landscape/components/DeleteGroup.tsx | 8 ++- .../components/GroupSettings/Admin.tsx | 4 +- .../components/GroupSettings/Channels.tsx | 4 +- .../landscape/components/InvitePopover.tsx | 8 ++- .../views/landscape/components/JoinGroup.tsx | 5 +- .../landscape/components/MessageInvite.tsx | 8 ++- .../views/landscape/components/NewChannel.tsx | 6 +- .../views/landscape/components/NewGroup.tsx | 10 ++-- .../landscape/components/Participants.tsx | 55 +++++++++---------- .../landscape/components/TutorialModal.tsx | 15 ++--- 15 files changed, 98 insertions(+), 83 deletions(-) diff --git a/pkg/interface/src/views/apps/launch/App.tsx b/pkg/interface/src/views/apps/launch/App.tsx index f84d4592d..25ad3df5c 100644 --- a/pkg/interface/src/views/apps/launch/App.tsx +++ b/pkg/interface/src/views/apps/launch/App.tsx @@ -32,6 +32,8 @@ import ModalButton from './components/ModalButton'; import Tiles from './components/tiles'; import Tile from './components/tiles/tile'; import './css/custom.css'; +import airlock from '~/logic/api'; +import { join } from '@urbit/api/groups'; const ScrollbarLessBox = styled(Box)` scrollbar-width: none !important; @@ -107,7 +109,7 @@ export const LaunchApp = (props: LaunchAppProps): ReactElement | null => { const onContinue = async (e) => { e.stopPropagation(); if (!hasTutorialGroup({ associations })) { - await props.api.groups.join(TUTORIAL_HOST, TUTORIAL_GROUP); + await airlock.poke(join(TUTORIAL_HOST, TUTORIAL_GROUP)); await props.api.settings.putEntry('tutorial', 'joined', Date.now()); await waiter(hasTutorialGroup); await Promise.all( diff --git a/pkg/interface/src/views/apps/notifications/joining.tsx b/pkg/interface/src/views/apps/notifications/joining.tsx index 49cddc95e..fc85ac957 100644 --- a/pkg/interface/src/views/apps/notifications/joining.tsx +++ b/pkg/interface/src/views/apps/notifications/joining.tsx @@ -1,12 +1,9 @@ import { Box, Row, SegmentedProgressBar, Text } from '@tlon/indigo-react'; -import { - joinError, joinProgress, - - JoinRequest -} from '@urbit/api'; +import { joinError, joinProgress, JoinRequest, hideGroup } from '@urbit/api'; import React, { useCallback } from 'react'; import GlobalApi from '~/logic/api/global'; import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction'; +import airlock from '~/logic/api'; interface JoiningStatusProps { status: JoinRequest; @@ -23,13 +20,17 @@ const description: string[] = [ ]; export function JoiningStatus(props: JoiningStatusProps) { - const { status, resource, api } = props; + const { status, resource } = props; const current = joinProgress.indexOf(status.progress); const desc = description?.[current] || ''; const isError = joinError.indexOf(status.progress as any) !== -1; - const onHide = useCallback(() => api.groups.hide(resource) - , [resource, api]); + const onHide = useCallback( + async () => { + await airlock.poke(hideGroup(resource)); +}, + [resource] + ); return ( { - const { association, groups, api } = props; + const { association, groups } = props; const resource = resourceFromPath(association?.group); const onSubmit = async (values, actions) => { try { const ships = values.ships.map(e => `~${e}`); - await api.groups.addTag( + await airlock.poke(addTag( resource, { app: 'graph', resource: association.resource, tag: 'writers' }, ships - ); + )); actions.resetForm(); actions.setStatus({ success: null }); } catch (e) { diff --git a/pkg/interface/src/views/components/Invite/Group.tsx b/pkg/interface/src/views/components/Invite/Group.tsx index 642774bf5..820d6dc21 100644 --- a/pkg/interface/src/views/components/Invite/Group.tsx +++ b/pkg/interface/src/views/components/Invite/Group.tsx @@ -5,7 +5,8 @@ import { LoadingSpinner, Row, Text } from '@tlon/indigo-react'; import { - Invite, joinProgress, + hideGroup, + Invite, join, joinProgress, JoinRequest, Metadata, MetadataUpdatePreview, resourceFromPath @@ -27,6 +28,7 @@ import { Header } from '~/views/apps/notifications/header'; import { NotificationWrapper } from '~/views/apps/notifications/notification'; import { MetadataIcon } from '~/views/landscape/components/MetadataIcon'; import { StatelessAsyncButton } from '../StatelessAsyncButton'; +import airlock from '~/logic/api'; interface GroupInviteProps { preview?: MetadataUpdatePreview; @@ -164,7 +166,7 @@ export function useInviteAccept( return false; } - await api.groups.join(ship, name); + await airlock.poke(join(ship, name)); await api.invite.accept(app, uid); await waiter((p) => { return ( @@ -216,15 +218,15 @@ function InviteActions(props: { const hideJoin = useCallback(async (e) => { if(status?.progress === 'done') { - set(s => { + set((s) => { // @ts-ignore investigate zustand types - delete s.pendingJoin[resource] + delete s.pendingJoin[resource]; }); e.stopPropagation(); return; } - await api.groups.hide(resource); - }, [api, resource, status]); + await airlock.poke(hideGroup(resource)); + }, [resource, status]); if (status) { return ( diff --git a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx index 8b0cce008..da51e7147 100644 --- a/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx +++ b/pkg/interface/src/views/landscape/components/ChannelPopoverRoutes/ChannelPermissions.tsx @@ -5,7 +5,7 @@ import { Text } from '@tlon/indigo-react'; -import { Association, Group, metadataUpdate, PermVariation } from '@urbit/api'; +import { addTag, Association, Group, metadataUpdate, PermVariation, removeTag } from '@urbit/api'; import { Form, Formik } from 'formik'; import _ from 'lodash'; import React from 'react'; @@ -72,7 +72,7 @@ const formSchema = (members: string[]) => { }; export function GraphPermissions(props: GraphPermissionsProps) { - const { api, group, association } = props; + const { group, association } = props; const writers = _.get( group?.tags, @@ -119,7 +119,7 @@ export function GraphPermissions(props: GraphPermissionsProps) { actions.setStatus({ success: null }); return; } - await api.groups.removeTag(resource, tag, allWriters); + await airlock.poke(removeTag(tag, resource, allWriters)); } else if (values.writePerms === 'self') { if (writePerms === 'self') { actions.setStatus({ success: null }); @@ -127,8 +127,8 @@ export function GraphPermissions(props: GraphPermissionsProps) { } const promises: Promise[] = []; allWriters.length > 0 && - promises.push(api.groups.removeTag(resource, tag, allWriters)); - promises.push(api.groups.addTag(resource, tag, [`~${hostShip}`])); + promises.push(airlock.poke(removeTag(tag, resource, allWriters))); + promises.push(airlock.poke(addTag(resource, tag, [`~${hostShip}`]))); await Promise.all(promises); actions.setStatus({ success: null }); } else if (values.writePerms === 'subset') { @@ -141,9 +141,9 @@ export function GraphPermissions(props: GraphPermissionsProps) { const promises: Promise[] = []; toRemove.length > 0 && - promises.push(api.groups.removeTag(resource, tag, toRemove)); + promises.push(airlock.poke(removeTag(tag, resource, toRemove))); toAdd.length > 0 && - promises.push(api.groups.addTag(resource, tag, toAdd)); + promises.push(airlock.poke(addTag(resource, tag, toAdd))); await Promise.all(promises); actions.setStatus({ success: null }); diff --git a/pkg/interface/src/views/landscape/components/DeleteGroup.tsx b/pkg/interface/src/views/landscape/components/DeleteGroup.tsx index 9b24c7369..4a01adb7e 100644 --- a/pkg/interface/src/views/landscape/components/DeleteGroup.tsx +++ b/pkg/interface/src/views/landscape/components/DeleteGroup.tsx @@ -1,11 +1,13 @@ import { Button, Col, Icon, Label, Row, Text } from '@tlon/indigo-react'; -import { Association } from '@urbit/api'; +import { Association, leaveGroup } from '@urbit/api'; +import { deleteGroup } from '@urbit/api/dist'; import React from 'react'; import { useHistory } from 'react-router-dom'; import GlobalApi from '~/logic/api/global'; import { resourceFromPath } from '~/logic/lib/group'; import { useModal } from '~/logic/lib/useModal'; import { StatelessAsyncButton } from '~/views/components/StatelessAsyncButton'; +import airlock from '~/logic/api'; export function DeleteGroup(props: { owner: boolean; @@ -22,9 +24,9 @@ export function DeleteGroup(props: { return; } if(props.owner) { - props.api.groups.deleteGroup(ship, name); + airlock.thread(deleteGroup(ship, name)); } else { - props.api.groups.leaveGroup(ship, name); + airlock.thread(leaveGroup(ship, name)); } history.push('/'); }; diff --git a/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx b/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx index 3fa8cece7..64843f7df 100644 --- a/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx +++ b/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx @@ -4,7 +4,7 @@ import { ManagedToggleSwitchField as Checkbox, Text } from '@tlon/indigo-react'; -import { Enc, metadataUpdate } from '@urbit/api'; +import { changePolicy, Enc, metadataUpdate } from '@urbit/api'; import { Group, GroupPolicy } from '@urbit/api/groups'; import { Association } from '@urbit/api/metadata'; import { Form, Formik, FormikHelpers } from 'formik'; @@ -76,7 +76,7 @@ export function GroupAdminSettings(props: GroupAdminSettingsProps) { ? { invite: { pending: [] } } : { open: { banRanks: [], banned: [] } }; const diff = { replace: newPolicy }; - await props.api.groups.changePolicy(resource, diff); + await airlock.poke(changePolicy(resource, diff)); } actions.setStatus({ success: null }); diff --git a/pkg/interface/src/views/landscape/components/GroupSettings/Channels.tsx b/pkg/interface/src/views/landscape/components/GroupSettings/Channels.tsx index dc32208ca..037f70880 100644 --- a/pkg/interface/src/views/landscape/components/GroupSettings/Channels.tsx +++ b/pkg/interface/src/views/landscape/components/GroupSettings/Channels.tsx @@ -24,14 +24,14 @@ export function GroupChannelSettings(props: GroupChannelSettingsProps) { const onChange = useCallback( async (resource: string, preview: boolean) => { - return airlock.poke(metadataUpdate(associations.graph[resource], { preview })); + await airlock.poke(metadataUpdate(associations.graph[resource], { preview })); }, [associations.graph] ); const onRemove = useCallback( async (resource: string) => { - return airlock.poke(metadataRemove('graph', resource, association.group)); + await airlock.poke(metadataRemove('graph', resource, association.group)); }, [association] ); diff --git a/pkg/interface/src/views/landscape/components/InvitePopover.tsx b/pkg/interface/src/views/landscape/components/InvitePopover.tsx index f1a6f3b26..719e26ad5 100644 --- a/pkg/interface/src/views/landscape/components/InvitePopover.tsx +++ b/pkg/interface/src/views/landscape/components/InvitePopover.tsx @@ -5,6 +5,7 @@ import { Row, Text } from '@tlon/indigo-react'; +import { invite } from '@urbit/api/groups'; import { Association } from '@urbit/api/metadata'; import { Form, Formik } from 'formik'; import _ from 'lodash'; @@ -19,6 +20,7 @@ import { Workspace } from '~/types/workspace'; import { AsyncButton } from '~/views/components/AsyncButton'; import { FormError } from '~/views/components/FormError'; import { ShipSearch } from '~/views/components/ShipSearch'; +import airlock from '~/logic/api'; interface InvitePopoverProps { baseUrl: string; @@ -39,7 +41,7 @@ const formSchema = Yup.object({ }); export function InvitePopover(props: InvitePopoverProps) { - const { baseUrl, api, association } = props; + const { baseUrl, association } = props; const relativePath = (p: string) => baseUrl + p; const { title } = association?.metadata || { title: '' }; @@ -55,11 +57,11 @@ export function InvitePopover(props: InvitePopoverProps) { // TODO: how to invite via email? try { const { ship, name } = resourceFromPath(association.group); - await api.groups.invite( + await airlock.thread(invite( ship, name, _.compact(ships).map(s => `~${deSig(s)}`), description - ); + )); actions.setStatus({ success: null }); onOutsideClick(); diff --git a/pkg/interface/src/views/landscape/components/JoinGroup.tsx b/pkg/interface/src/views/landscape/components/JoinGroup.tsx index b84cda932..c6c662476 100644 --- a/pkg/interface/src/views/landscape/components/JoinGroup.tsx +++ b/pkg/interface/src/views/landscape/components/JoinGroup.tsx @@ -4,7 +4,7 @@ import { ManagedTextInputField as Input, Row, Text } from '@tlon/indigo-react'; -import { MetadataUpdatePreview } from '@urbit/api'; +import { join, MetadataUpdatePreview } from '@urbit/api'; import { Form, Formik, FormikHelpers, useFormikContext } from 'formik'; import _ from 'lodash'; import React, { ReactElement, useCallback, useEffect, useState } from 'react'; @@ -22,6 +22,7 @@ import { AsyncButton } from '~/views/components/AsyncButton'; import { FormError } from '~/views/components/FormError'; import { StatelessAsyncButton } from '~/views/components/StatelessAsyncButton'; import { GroupSummary } from './GroupSummary'; +import airlock from '~/logic/api'; const formSchema = Yup.object({ group: Yup.string() @@ -80,7 +81,7 @@ export function JoinGroup(props: JoinGroupProps): ReactElement { if (group in groups) { return history.push(`/~landscape${group}`); } - await api.groups.join(ship, name); + await airlock.poke(join(ship, name)); try { await waiter((p) => { return group in p.groups && diff --git a/pkg/interface/src/views/landscape/components/MessageInvite.tsx b/pkg/interface/src/views/landscape/components/MessageInvite.tsx index b3a4b1f82..51f100e7c 100644 --- a/pkg/interface/src/views/landscape/components/MessageInvite.tsx +++ b/pkg/interface/src/views/landscape/components/MessageInvite.tsx @@ -1,4 +1,5 @@ import { Box, Col, Text } from '@tlon/indigo-react'; +import { invite } from '@urbit/api/groups'; import { Form, Formik } from 'formik'; import _ from 'lodash'; import React from 'react'; @@ -7,6 +8,7 @@ import { resourceFromPath } from '~/logic/lib/group'; import { deSig } from '~/logic/lib/util'; import { AsyncButton } from '~/views/components/AsyncButton'; import { ShipSearch } from '~/views/components/ShipSearch'; +import airlock from '~/logic/api'; interface FormSchema { ships: string[]; @@ -17,17 +19,17 @@ const formSchema = Yup.object({ }); export const MessageInvite = (props) => { - const { association, api } = props; + const { association } = props; const initialValues: FormSchema = { ships: [] }; const onSubmit = async ({ ships }: FormSchema, actions) => { try { const { ship, name } = resourceFromPath(association.group); - await api.groups.invite( + await airlock.thread(invite( ship, name, _.compact(ships).map(s => `~${deSig(s)}`), `Inviting you to a DM with ~${ship}` - ); + )); actions.setStatus({ success: null }); } catch (e) { console.error(e); diff --git a/pkg/interface/src/views/landscape/components/NewChannel.tsx b/pkg/interface/src/views/landscape/components/NewChannel.tsx index 6f9470a9e..c1e01a93a 100644 --- a/pkg/interface/src/views/landscape/components/NewChannel.tsx +++ b/pkg/interface/src/views/landscape/components/NewChannel.tsx @@ -4,6 +4,7 @@ import { ManagedTextInputField as Input, Text } from '@tlon/indigo-react'; +import { addTag } from '@urbit/api/dist'; import { Form, Formik } from 'formik'; import _ from 'lodash'; import React, { ReactElement } from 'react'; @@ -21,6 +22,7 @@ import { FormError } from '~/views/components/FormError'; import { IconRadio } from '~/views/components/IconRadio'; import { ShipSearch, shipSearchSchema, shipSearchSchemaInGroup } from '~/views/components/ShipSearch'; import { ChannelWriteFieldSchema, ChannelWritePerms } from './ChannelWritePerms'; +import airlock from '~/logic/api'; type FormSchema = { name: string; @@ -98,10 +100,10 @@ export function NewChannel(props: NewChannelProps): ReactElement { writers = _.compact(writers).map(s => `~${s}`); const us = `~${window.ship}`; if (values.writePerms === 'self') { - await api.groups.addTag(resource, tag, [us]); + await airlock.poke(addTag(resource, tag, [us])); } else if (values.writePerms === 'subset') { writers.push(us); - await api.groups.addTag(resource, tag, writers); + await airlock.poke(addTag(resource, tag, writers)); } } else { await api.graph.createUnmanagedGraph( diff --git a/pkg/interface/src/views/landscape/components/NewGroup.tsx b/pkg/interface/src/views/landscape/components/NewGroup.tsx index 2fe724936..04b1e66b3 100644 --- a/pkg/interface/src/views/landscape/components/NewGroup.tsx +++ b/pkg/interface/src/views/landscape/components/NewGroup.tsx @@ -3,10 +3,10 @@ import { ManagedCheckboxField as Checkbox, ManagedTextInputField as Input, Text } from '@tlon/indigo-react'; -import { Enc, GroupPolicy } from '@urbit/api'; +import { createGroup, Enc, GroupPolicy } from '@urbit/api'; import { Form, Formik, FormikHelpers } from 'formik'; import React, { ReactElement, useCallback } from 'react'; -import { RouteComponentProps, useHistory } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import * as Yup from 'yup'; import GlobalApi from '~/logic/api/global'; import { useWaitForProps } from '~/logic/lib/useWaitForProps'; @@ -14,6 +14,7 @@ import { stringToSymbol } from '~/logic/lib/util'; import useGroupState from '~/logic/state/group'; import useMetadataState from '~/logic/state/metadata'; import { AsyncButton } from '~/views/components/AsyncButton'; +import airlock from '~/logic/api'; const formSchema = Yup.object({ title: Yup.string().required('Group must have a name'), @@ -32,7 +33,6 @@ interface NewGroupProps { } export function NewGroup(props: NewGroupProps): ReactElement { - const { api } = props; const history = useHistory(); const initialValues: FormSchema = { title: '', @@ -61,7 +61,7 @@ export function NewGroup(props: NewGroupProps): ReactElement { banned: [] } }; - await api.groups.create(name, policy, title, description); + await airlock.thread(createGroup(name, policy, title, description)); const path = `/ship/~${window.ship}/${name}`; await waiter((p) => { return path in p.groups && path in p.associations.groups; @@ -74,7 +74,7 @@ export function NewGroup(props: NewGroupProps): ReactElement { actions.setStatus({ error: e.message }); } }, - [api, waiter, history] + [waiter, history] ); return ( diff --git a/pkg/interface/src/views/landscape/components/Participants.tsx b/pkg/interface/src/views/landscape/components/Participants.tsx index b85271122..e269d250d 100644 --- a/pkg/interface/src/views/landscape/components/Participants.tsx +++ b/pkg/interface/src/views/landscape/components/Participants.tsx @@ -7,7 +7,7 @@ import { StatelessTextInput as Input, Text } from '@tlon/indigo-react'; import { Contact, Contacts } from '@urbit/api/contacts'; -import { Group, RoleTags } from '@urbit/api/groups'; +import { addTag, removeMembers, changePolicy, Group, removeTag, RoleTags } from '@urbit/api/groups'; import { Association } from '@urbit/api/metadata'; import _ from 'lodash'; import f from 'lodash/fp'; @@ -26,6 +26,7 @@ import useContactState from '~/logic/state/contact'; import useSettingsState, { selectCalmState } from '~/logic/state/settings'; import { Dropdown } from '~/views/components/Dropdown'; import { StatelessAsyncAction } from '~/views/components/StatelessAsyncAction'; +import airlock from '~/logic/api'; const TruncText = styled(Text)` white-space: nowrap; @@ -47,6 +48,19 @@ const searchParticipant = (search: string) => (p: Participant) => { return p.patp.includes(s) || p.nickname.toLowerCase().includes(s); }; +const emptyContact = (patp: string, pending: boolean): Participant => ({ + nickname: '', + bio: '', + status: '', + color: '0x0', + avatar: null, + cover: null, + groups: [], + patp, + 'last-updated': 0, + pending +}); + function getParticipants(cs: Contacts, group: Group) { const contacts: Participant[] = _.flow( f.omitBy((_c, patp) => !group.members.has(patp.slice(1))), @@ -77,19 +91,6 @@ function getParticipants(cs: Contacts, group: Group) { ] as const; } -const emptyContact = (patp: string, pending: boolean): Participant => ({ - nickname: '', - bio: '', - status: '', - color: '0x0', - avatar: null, - cover: null, - groups: [], - patp, - 'last-updated': 0, - pending -}); - const Tab = ({ selected, id, label, setSelected }) => ( { const resource = resourceFromPath(association.group); - await api.groups.addTag(resource, { tag: 'admin' }, [`~${contact.patp}`]); - }, [api, association]); + await airlock.poke(addTag(resource, { tag: 'admin' }, [`~${contact.patp}`])); + }, [contact.patp, association]); const onDemote = useCallback(async () => { const resource = resourceFromPath(association.group); - await api.groups.removeTag(resource, { tag: 'admin' }, [ - `~${contact.patp}` - ]); - }, [api, association]); + await airlock.poke(removeTag({ tag: 'admin' }, resource, [`~${contact.patp}`])); + }, [association, contact.patp]); const onBan = useCallback(async () => { const resource = resourceFromPath(association.group); - await api.groups.changePolicy(resource, { + await airlock.poke(changePolicy(resource, { open: { banShips: [`~${contact.patp}`] } - }); - }, [api, association]); + })); + }, [association, contact.patp]); const onKick = useCallback(async () => { const resource = resourceFromPath(association.group); if(contact.pending) { - await api.groups.changePolicy( + await airlock.poke(changePolicy( resource, { invite: { removeInvites: [`~${contact.patp}`] } } - ); + )); } else { - await api.groups.remove(resource, [`~${contact.patp}`]); + await airlock.poke(removeMembers(resource, [`~${contact.patp}`])); } - }, [api, contact, association]); + }, [contact, association]); const avatar = contact?.avatar && !hideAvatars ? ( diff --git a/pkg/interface/src/views/landscape/components/TutorialModal.tsx b/pkg/interface/src/views/landscape/components/TutorialModal.tsx index 3d389f811..e0122baa2 100644 --- a/pkg/interface/src/views/landscape/components/TutorialModal.tsx +++ b/pkg/interface/src/views/landscape/components/TutorialModal.tsx @@ -1,4 +1,5 @@ import { Box, Button, Col, Icon, Row, Text } from '@tlon/indigo-react'; +import { leaveGroup } from '@urbit/api'; import _ from 'lodash'; import React, { useCallback, useEffect, useState } from 'react'; import { useHistory } from 'react-router-dom'; @@ -15,6 +16,7 @@ import { ModalOverlay } from '~/views/components/ModalOverlay'; import { Portal } from '~/views/components/Portal'; import { StatelessAsyncButton } from '~/views/components/StatelessAsyncButton'; import { Triangle } from '~/views/components/Triangle'; +import airlock from '~/logic/api'; const localSelector = selectLocalState([ 'tutorialProgress', @@ -31,8 +33,7 @@ export function TutorialModal(props: { api: GlobalApi }) { tutorialRef, nextTutStep, prevTutStep, - hideTutorial, - set: setLocalState + hideTutorial } = useLocalState(localSelector); const { title, @@ -105,10 +106,10 @@ export function TutorialModal(props: { api: GlobalApi }) { setPaused(true); }, []); - const leaveGroup = useCallback(async () => { - await props.api.groups.leaveGroup(TUTORIAL_HOST, TUTORIAL_GROUP); + const doLeaveGroup = useCallback(async () => { + await airlock.thread(leaveGroup(TUTORIAL_HOST, TUTORIAL_GROUP)); await dismiss(); - }, [props.api, dismiss]); + }, [dismiss]); const progressIdx = progress.findIndex(p => p === tutorialProgress); @@ -149,7 +150,7 @@ export function TutorialModal(props: { api: GlobalApi }) { - + Leave Group @@ -173,7 +174,7 @@ export function TutorialModal(props: { api: GlobalApi }) { - You can always restart the tutorial by typing "tutorial" in Leap. + You can always restart the tutorial by typing "tutorial" in Leap. ); - } else if (connectedStatus === 'reconnecting') { + } else if (subscription === 'reconnecting') { return (