on master help sign in
48 files changed+1915−879
since splitsnapshots
unifiedsplit
.gitignore+2 README.md+105−25 RELEASING.md+166 desk/app/sphinx.hoon+113−20 desk/desk.docket-0+11−10 desk/gen/publish-all.hoon+13−13 desk/lib/gossip.hoon+283−58 desk/lib/pals.hoon+8−2 desk/lib/sphinx-json.hoon+6−4 desk/lib/sphinx.hoon+30−18 desk/sys.kelvin+1−2 flake.lock+63 flake.nix+98 ui/.nvmrc+1 ui/index.html+1−1 ui/package-lock.json+83−361 ui/package.json+4−2 ui/src/assets/android-chrome-512x512.png → ui/public/android-chrome-512x512.png ui/src/assets/manifest.json → ui/public/manifest.json+1−1 ui/src/api.ts+6 ui/src/app.tsx+1−2 ui/src/components/Listing.tsx+74 ui/src/components/ListingImage.tsx+3−3 ui/src/components/Listings.tsx+4−39 ui/src/components/Meta.tsx+4−2 ui/src/components/PostOptions.tsx+3−3 ui/src/components/TagField.tsx+67−54 ui/src/env.d.ts+4 ui/src/keys.ts+14 ui/src/logic/utils.ts+11 ui/src/manage-listings/AllListings.tsx+6−4 ui/src/manage-listings/Apps.tsx+3−2 ui/src/manage-listings/Groups.tsx+4−3 ui/src/manage-listings/MyListings.tsx+4−4 ui/src/manage-listings/Notebooks.tsx+13−11 ui/src/manage-listings/Post.tsx+28−13 ui/src/manage-listings/Posts.tsx+6−5 ui/src/pages/Search.tsx+8−7 ui/src/pages/Tag.tsx+4−5 ui/src/state/apps.ts+19−15 ui/src/state/groups.ts+55−20 ui/src/state/pals.ts+6−5 ui/src/state/posts.ts+73−157 ui/src/state/search.ts+3−3 ui/src/state/tags.ts+3−2 ui/src/types/index.ts+304 ui/src/utils.ts+189−2 ui/vite.config.ts+7−1
.gitignorenew file+2
@@ -0,0 +1,2 @@
1+/result
2+/result-*
README.md+105−25
@@ -1,45 +1,125 @@
11# sphinx
22
3−## Desk
3+A search engine powered by you and your pals for finding answers to your
4+riddles.
45
5−The desk currently has the minimum amount of files necessary to distribute an application and should be distributable immediately. Any further Hoon development should happen here.
6+sphinx keeps a directory of listings: apps, groups, notebook posts and links.
7+The listings you publish go to your mutual pals and on to their mutual pals,
8+theirs come to you, and you search everything you've received.
69
7−TODO: Add further documentation on beginning Hoon development
10+## Install
811
9−## UI
12+sphinx runs on the kernels listed in `desk/sys.kelvin`, currently
13+`[%zuse 408]`. Check your ship first: in the dojo, `+vats %base` must show
14+`/sys/kelvin: [%zuse 408]`. If it shows an older kernel, let your ship finish
15+its update first. If the update is blocked, Landscape lists the apps that
16+block it, and `|bump` suspends them.
1017
11−sphinx is built primarily using [React], [Typescript], and [Tailwind CSS]. [Vite] ensures that all code and assets are loaded appropriately, bundles the application for distribution and provides a functional dev environment.
18+Then, in your ship's dojo:
1219
13−### Getting Started
20+```
21+|install ~donmec-sallud %sphinx
22+```
23+
24+For a new install, you can also search for ~donmec-sallud in Landscape and
25+install sphinx from there.
26+
27+sphinx shares listings through %pals. Install it with
28+`|install ~paldev %pals` and add your friends as pals. Without %pals you only
29+see your own listings.
30+
31+### Moving from ~nocsyx-lassul
32+
33+sphinx used to be distributed from ~nocsyx-lassul. Its last release there
34+only supports `[%zuse 412]`, so it can't run on a current ship. On a
35+`[%zuse 412]` ship it blocks the kernel update until you `|bump`. After the
36+kernel check above, switch it to ~donmec-sallud in the dojo:
37+
38+```
39+|install ~donmec-sallud %sphinx
40+```
41+
42+Your listings are kept. Use the dojo for this: Landscape's Get App can't
43+switch an existing install. It asks ~nocsyx-lassul again, and docket refuses
44+because sphinx is already installed.
45+
46+If you ran `|install` before your ship reached `[%zuse 408]`, sphinx stays
47+suspended after the update, and another `|install` only prints
48+`already syncing`. Run `|uninstall %sphinx`, then
49+`|install ~donmec-sallud %sphinx` again. Your listings are kept.
50+
51+## Development
52+
53+The repo has two parts:
54+
55+- `desk/`: the Hoon agent, its marks and libraries, and the docket file.
56+- `ui/`: the web interface, built with [React], [TypeScript],
57+ [Tailwind CSS] and [Vite].
1458
15−To get started using sphinx first you need to run `npm install` inside the `ui` directory.
59+### Build
1660
17−To develop you'll need a running ship to point to. To do so you first need to add a `.env.local` file to the `ui` directory. This file will not be committed. Adding `VITE_SHIP_URL={URL}` where **{URL}** is the URL of the ship you would like to point to, will allow you to run `npm run dev`. This will proxy all requests to the ship except for those powering the interface, allowing you to see live data.
61+With Nix:
1862
19−Regardless of what you run to develop, Vite will hot-reload code changes as you work so you don't have to constantly refresh.
63+```sh
64+nix build .#desk -o result-desk # the desk; Nix makes its files read-only
65+nix build .#glob -o result-glob # the UI, built for /apps/sphinx/
66+```
67+
68+To put the desk on a test ship, copy it writable and with `--delete`, as in
69+RELEASING.md step 2. vere aborts at `|unmount` if the mount is read-only.
70+
71+```sh
72+chmod -R u+w result-desk-rw 2>/dev/null; rm -rf result-desk-rw &&
73+cp -RL result-desk result-desk-rw && chmod -R u+w result-desk-rw &&
74+rsync -rpc --delete result-desk-rw/ <pier>/sphinx/
75+```
76+
77+The desk output adds what `desk/` needs from urbit's `base-dev` and
78+landscape's `desk-dev`. Both are pinned in `flake.nix` for the kelvin in
79+`desk/sys.kelvin`. When you port sphinx to a new kernel, update those two
80+inputs together with `desk/sys.kelvin`.
81+
82+Without Nix, run `npm install` and `npm run build` in `ui/` to build the UI
83+into `ui/dist/`. Use the Node version in `ui/.nvmrc`.
84+
85+To publish a release from ~donmec-sallud, follow [RELEASING.md](RELEASING.md).
2086
21−### Deploying
87+### Working on the UI
2288
23−To deploy, run `npm run build` in the `ui` directory which will bundle all the code and assets into the `dist/` folder. This can then be made into a glob by doing the following:
89+You need a running ship with sphinx installed. In `ui/`:
90+
91+1. Run `npm install`.
92+2. Create `ui/.env.local` with `VITE_SHIP_URL=<URL>`, where `<URL>` is your
93+ ship's web address, for example `http://localhost:8080`. Git ignores this
94+ file.
95+3. Run `npm run dev` and open the address it prints.
96+
97+The dev server sends every request except those for the interface itself to
98+the ship, so you see live data. Vite reloads the page as you change the code.
99+
100+### The tile image
101+
102+The tile is inlined in `desk/desk.docket-0` as a `data:` URI, so Landscape
103+doesn't load it from another server.
104+
105+To get the SVG out:
106+
107+```sh
108+sed -n "s|^ image+'data:image/svg+xml;base64,\(.*\)'\$|\1|p" desk/desk.docket-0 | base64 -d > tile.svg
109+```
24110
25−1. Create or launch an urbit using the -F flag
26−2. On that urbit, if you don't already have a desk to run from, run `|merge %work our %base` to create a new desk and mount it with `|mount %work`.
27−3. Now the `%work` desk is accessible through the host OS's filesystem as a directory of that urbit's pier ie `~/zod/work`.
28−4. From the `ui` directory you can run `rsync -avL --delete dist/ ~/zod/work/sphinx` where `~/zod` is your fake urbit's pier.
29−5. Once completed you can then run `|commit %work` on your urbit and you should see your files logged back out from the dojo.
30−6. Now run `=dir /=garden` to switch to the garden desk directory
31−7. You can now run `-make-glob %work /sphinx` which will take the folder where you just added files and create a glob which can be thought of as a sort of bundle. It will be output to `~/zod/.urb/put`.
32−8. If you navigate to `~/zod/.urb/put` you should see a file that looks like this `glob-0v5.fdf99.nph65.qecq3.ncpjn.q13mb.glob`. The characters between `glob-` and `.glob` are a hash of the glob's contents.
33−9. Now that we have the glob it can be uploaded to any publicly available HTTP endpoint that can serve files. This allows the glob to distributed over HTTP.
34−10. Once you've uploaded the glob, you should then update the corresponding entry in the docket file at `desk/desk.docket-0`. Both the full URL and the hash should be updated to match the glob we just created, on the line that looks like this:
111+To put a new one in, print a new `image+` line and replace the old line in
112+`desk/desk.docket-0` with it:
35113
36−```hoon
37− glob-http+['https://bootstrap.urbit.org/glob-0v5.fdf99.nph65.qecq3.ncpjn.q13mb.glob' 0v5.fdf99.nph65.qecq3.ncpjn.q13mb]
114+```sh
115+printf " image+'data:image/svg+xml;base64,%s'\n" "$(base64 < tile.svg | tr -d '\n')"
38116```
39117
40−11. This can now be safely committed and deployed.
118+Keep the rest of the file as it is. Docket rewrites the file when a glob is
119+uploaded, and the hash copy-back in RELEASING.md should only change the hash.
120+A new tile doesn't need a new glob.
41121
42−[react]: https://reactjs.org/
122+[react]: https://react.dev/
43123[typescript]: https://www.typescriptlang.org/
44124[tailwind css]: https://tailwindcss.com/
45125[vite]: https://vitejs.dev/
RELEASING.mdnew file+166
@@ -0,0 +1,166 @@
1+# Releasing sphinx
2+
3+sphinx is published from ~donmec-sallud. Ships install it with
4+`|install ~donmec-sallud %sphinx` and then follow it: the desk syncs from
5+~donmec-sallud's `%sphinx` desk, and docket fetches the UI (the glob) over ames
6+from ~donmec-sallud.
7+
8+A release has three parts:
9+
10+1. **Desk**: copy `nix build .#desk` into ~donmec-sallud's mounted `%sphinx`
11+ desk and commit it.
12+2. **Glob**: if the UI changed, upload `nix build .#glob` at `/docket/upload`.
13+ Docket hashes it and writes the hash into the desk's `desk.docket-0`.
14+3. **Hash copy-back**: copy that `desk.docket-0` back into the repo and commit
15+ it.
16+
17+Never skip the copy-back. On every commit of the desk, docket compares the hash
18+in `desk.docket-0` with the glob it serves, and when they differ it drops the
19+glob and waits for a new upload. So a stale hash in the repo takes the UI down
20+at the next release, on ~donmec-sallud and on every ship that installed
21+sphinx, until the glob is uploaded again.
22+
23+## Before you start
24+
25+- [ ] ~donmec-sallud runs a kernel listed in `desk/sys.kelvin`. In its dojo,
26+ `+vats %base` prints it on the `/sys/kelvin:` line. If it isn't listed,
27+ port the desk to that kernel first.
28+- [ ] Optional: bump `version` in `desk/desk.docket-0`. Landscape and the
29+ sphinx footer show it.
30+- [ ] Everything is committed and pushed. The release is whatever the repo
31+ builds.
32+
33+The commands below use these shell variables:
34+
35+```sh
36+server=user@host # ssh login of the user that runs ~donmec-sallud's vere and owns the pier
37+pier=/path/to/donmec-sallud # the pier directory on that machine
38+```
39+
40+## 1. Build
41+
42+From the repo root:
43+
44+```sh
45+nix build .#desk -o result-desk
46+nix build .#glob -o result-glob
47+```
48+
49+## 2. Desk
50+
51+First release only, in ~donmec-sallud's dojo:
52+
53+```
54+|new-desk %sphinx
55+|mount %sphinx
56+```
57+
58+Leave the desk mounted. The copy-back in step 4 reads the mounted file.
59+
60+Every release, copy the desk into the mount from a writable copy of the
61+build:
62+
63+```sh
64+chmod -R u+w result-desk-rw 2>/dev/null; rm -rf result-desk-rw &&
65+cp -RL result-desk result-desk-rw && chmod -R u+w result-desk-rw &&
66+rsync -rpc --delete result-desk-rw/ "$server:$pier/sphinx/"
67+ssh "$server" find "$pier/sphinx" '!' -perm -u+w # must print nothing
68+```
69+
70+- The `&&`s stop the copy when an earlier step fails, so a leftover
71+ `result-desk-rw` from an interrupted run is never sent.
72+- Send a writable copy. Nix store files and directories are read-only, and
73+ `-p` copies their modes into the mount. Don't rely on `rsync --chmod=u+w`:
74+ macOS's `rsync` (openrsync) accepts it and ignores it. In a read-only
75+ mount, vere aborts when it has to delete a file, for example at
76+ `|unmount %sphinx` in step 3, and clay's writes to read-only files fail.
77+- `-c` compares contents. Don't use `rsync -a`: Nix store files all have
78+ mtime 1, so with `-a` rsync can skip an edited file of the same size.
79+
80+Then in the dojo:
81+
82+```
83+|commit %sphinx
84+```
85+
86+The dojo lists the changed files, for example
87+`: /~donmec-sallud/sphinx/4/desk/docket-0`. A commit that changes nothing
88+prints nothing. A mark or build error rejects the whole commit, so fix the
89+desk and repeat this step.
90+
91+First release only:
92+
93+```
94+|install our %sphinx
95+|public %sphinx
96+:treaty|publish %sphinx
97+```
98+
99+- `|install our %sphinx` starts the app on ~donmec-sallud. Docket then prints
100+ `docket: awaiting manual glob for %sphinx desk` until step 3.
101+- `|public %sphinx` lets other ships read the desk. Without it, clay doesn't
102+ answer their requests, so `|install ~donmec-sallud %sphinx` waits without
103+ an error. A waiting install continues by itself once the desk is public.
104+- `:treaty|publish %sphinx` lists sphinx under ~donmec-sallud in Landscape's
105+ app search. It also makes the desk public.
106+
107+## 3. Glob
108+
109+Upload on the first release and whenever `ui/` changed since the last one. If
110+you're unsure, upload anyway: the same files give the same hash.
111+
112+1. Log in to ~donmec-sallud's web interface in Chrome or Firefox. Safari can't
113+ upload folders. `+code` in the dojo prints the password.
114+2. Open `/docket/upload` on that host, pick the desk `sphinx`, choose the
115+ `result-glob` folder and press **glob!**. If the folder picker won't follow
116+ the `result-glob` symlink, press ⇧⌘G and paste the path that
117+ `readlink result-glob` prints.
118+3. The page answers `successfully globbed`.
119+4. Refresh the mount in the dojo:
120+
121+ ```
122+ |unmount %sphinx
123+ |mount %sphinx
124+ ```
125+
126+ Docket wrote the new hash into clay. vere rewrites a mounted file only
127+ while it still holds exactly what clay last wrote there. If the
128+ `desk.docket-0` you committed differs in any byte from what docket prints
129+ (another layout, or a final newline an editor added), the mounted file
130+ keeps the old hash, and any later `|commit %sphinx`, even one with no
131+ edits, puts the old hash back and takes the UI down. The remount rewrites
132+ the mount from clay. It's harmless when the file is already current, so
133+ always do it.
134+
135+## 4. Hash copy-back
136+
137+After every upload and remount:
138+
139+```sh
140+scp "$server:$pier/sphinx/desk.docket-0" desk/desk.docket-0
141+```
142+
143+Only the hash on the `glob-ames` line should change. Commit
144+`desk/desk.docket-0` and push it.
145+
146+## 5. Check
147+
148+- [ ] `/apps/sphinx/` on ~donmec-sallud loads.
149+- [ ] The dojo has not printed `docket: awaiting manual glob for %sphinx desk`
150+ since the last commit or upload.
151+- [ ] `+vats %sphinx` shows `app status: running`.
152+- [ ] First release only: another ship can `|install ~donmec-sallud %sphinx`.
153+ Its log shows `kiln: merge into %sphinx succeeded` and
154+ `docket: fetching %ames glob for %sphinx desk`.
155+- [ ] A ship that installed sphinx shows the release at `/apps/sphinx/`.
156+ After a glob upload, it answers 404 until it has downloaded the new
157+ glob from ~donmec-sallud.
158+
159+## If the UI is down
160+
161+`docket: awaiting manual glob for %sphinx desk` after a commit means the hash
162+in the desk's `desk.docket-0` doesn't match the glob docket serves, usually
163+because a copy-back or a remount was skipped. Build the glob from the released
164+commit, upload it and remount (step 3), then do the copy-back (step 4). Other
165+ships pick up the corrected `desk.docket-0` and fetch the glob again on their
166+own.
desk/app/sphinx.hoon+113−20
@@ -15,13 +15,84 @@
1515 state-1:s
1616 state:s
1717 ==
18+ :: +guard-verb: refuse /verb watches from other ships
19+ ::
20+ :: lib/verb (here and inside lib/gossip) answers /verb/events and
21+ :: /verb/events-plus for anyone, and those carry every poke, card
22+ :: and wire of the agent, the mutuals in gossip's wires included
23+ ::
24+ ++ guard-verb
25+ |= inner=agent:gall
26+ ^- agent:gall
27+ |_ =bowl:gall
28+ +* this .
29+ ag ~(. inner bowl)
30+ ++ on-init
31+ ^- (quip card _this)
32+ =^ cards inner on-init:ag
33+ [cards this]
34+ ::
35+ ++ on-save on-save:ag
36+ ++ on-load
37+ |= old=vase
38+ ^- (quip card _this)
39+ =^ cards inner (on-load:ag old)
40+ :: and end the /verb watches other ships made before this guard
41+ ::
42+ :_ this
43+ %+ weld cards
44+ %+ murn ~(val by sup.bowl)
45+ |= [=ship =path]
46+ ^- (unit card)
47+ ?. &(?=([%verb *] path) !=(our.bowl ship)) ~
48+ `[%give %kick ~[path] `ship]
49+ ::
50+ ++ on-poke
51+ |= [=mark =vase]
52+ ^- (quip card _this)
53+ =^ cards inner (on-poke:ag mark vase)
54+ [cards this]
55+ ::
56+ ++ on-watch
57+ |= =path
58+ ^- (quip card _this)
59+ ?< &(?=([%verb *] path) !=(our.bowl src.bowl))
60+ =^ cards inner (on-watch:ag path)
61+ [cards this]
62+ ::
63+ ++ on-leave
64+ |= =path
65+ ^- (quip card _this)
66+ =^ cards inner (on-leave:ag path)
67+ [cards this]
68+ ::
69+ ++ on-peek on-peek:ag
70+ ++ on-agent
71+ |= [=wire =sign:agent:gall]
72+ ^- (quip card _this)
73+ =^ cards inner (on-agent:ag wire sign)
74+ [cards this]
75+ ::
76+ ++ on-arvo
77+ |= [=wire =sign-arvo]
78+ ^- (quip card _this)
79+ =^ cards inner (on-arvo:ag wire sign-arvo)
80+ [cards this]
81+ ::
82+ ++ on-fail
83+ |= [=term =tang]
84+ ^- (quip card _this)
85+ =^ cards inner (on-fail:ag term tang)
86+ [cards this]
87+ --
1888 --
1989=| state:s
2090=* state -
2191=<
22− %+ verb &
92+ %- guard-verb
93+ %+ verb |
2394 %- %+ agent:gossip
24− [2 %mutuals %mutuals]
95+ [2 %mutuals %mutuals |]
2596 %- malt
2697 ^- (list [mark $-(* vase)])
2798 :~ [%directory-listing |=(n=* !>((grab-listing n)))]
@@ -61,7 +132,7 @@
61132 |= =directory:s
62133 =/ purged (purge-invalid directory)
63134 :* %2
64− (reindex directory)
135+ (reindex purged)
65136 purged
66137 (purge-foreign purged)
67138 ==
@@ -75,10 +146,15 @@
75146 |= =directory:s
76147 ^- directory:s
77148 %- malt
78− %+ skim
79− ~(tap by directory)
149+ %+ skim
150+ ~(tap by directory)
80151 |= [=hash:s =listing:s]
81− (listing-valid listing)
152+ :: the old %directory handler kept peers' entries under any key
153+ ::
154+ ?& =(hash hash.listing)
155+ =(hash (digest post.listing))
156+ (listing-valid listing)
157+ ==
82158 ++ purge-foreign
83159 |= =directory:s
84160 ^- directory:s
@@ -142,13 +218,16 @@
142218 cor
143219 ::
144220 %remove
221+ ?> from-self
145222 =+ !<(=hash:s vase)
146223 di-abet:di-remove:(di-abed:di-core hash)
147224 ==
148225 ++ publish
149226 |= [=declare:s core=_cor]
150227 ^+ cor
151− ::
228+ :: .core accumulates a %declarations batch: a skipped listing must
229+ :: return it, not the pre-batch .cor, or the batch so far is lost
230+ ::
152231 ?> from-self
153232 =/ listing
154233 :* post=q.declare
@@ -157,10 +236,10 @@
157236 source=our.bowl
158237 time=now.bowl
159238 ==
160− ?: (~(has by directory) hash.listing)
239+ ?: (~(has by directory.core) hash.listing)
161240 ~& 'Listing already exists.'
162− cor
163− ?. (listing-valid listing) cor
241+ core
242+ ?. (listing-valid listing) core
164243 di-abet:(di-publish:(di-abed:di-core:core hash.listing) listing)
165244 --
166245::
@@ -194,10 +273,21 @@
194273 ::
195274 %directory
196275 =+ !<(d=directory:s vase)
197− =. directory (~(uni by directory) d)
276+ :: take only listings we don't have yet that pass the checks a
277+ :: %directory-listing gets: a peer's map must not replace our
278+ :: listings, and each listing may be indexed only once
279+ ::
280+ =/ new=(list [=hash:s =listing:s])
281+ %+ skim ~(tap by d)
282+ |= [=hash:s l=listing:s]
283+ ?& =(hash hash.l)
284+ =(hash (digest post.l))
285+ !(~(has by directory) hash)
286+ (listing-valid l)
287+ ==
288+ =. directory (~(gas by directory) new)
198289 =. index
199− %+ roll
200− ~(tap by d)
290+ %+ roll new
201291 |= [[=hash:s l=listing:s] i=_index]
202292 (~(catalog delver i) hash l)
203293 cor
@@ -239,10 +329,13 @@
239329 =/ limit (slav %ud i.t.t.t.t.path)
240330 =/ tag (slav %t i.t.t.t.t.t.path)
241331 %^ page start limit
242− %+ skim
243− ~(val by directory)
244− |= =listing:s
245− !=(~ (find ~[tag] tags.post.listing))
332+ %+ sort
333+ %+ skim
334+ ~(val by directory)
335+ |= =listing:s
336+ !=(~ (find ~[tag] tags.post.listing))
337+ |= [a=listing:s b=listing:s]
338+ (lth time.b time.a)
246339 ::
247340 [%x %lookup @ @ @ $@(~ [@ ~])]
248341 =- ``search+!>(-)
@@ -287,8 +380,8 @@
287380 ==
288381++ get-listings
289382 |= l=(list hash)
290− %+ turn l
291− |=(=hash:s (~(got by directory) hash))
383+ %+ murn l
384+ |=(=hash:s (~(get by directory) hash))
292385++ from-self =(our src):bowl
293386++ di-core
294387 |_ [=listing:s =hash:s gone=_|]
@@ -303,7 +396,7 @@
303396 di-core(hash h, listing (~(gut by directory) h *listing:s))
304397 ++ di-remove
305398 =. gone &
306− =/ keys (~(got by trail.index) hash)
399+ =/ keys (~(gut by trail.index) hash ~)
307400 =. trail.index (~(del by trail.index) hash)
308401 =. lookup.index
309402 ^- lookup:s
desk/desk.docket-0+11−10
@@ -1,10 +1,11 @@
1−:~ title+'sphinx'
2− info+'A search engine powered by you and your pals for finding answers to your riddles'
3− color+0xb2.5068
4− image+'https://nyc3.digitaloceanspaces.com/hmillerdev/nocsyx-lassul/2022.7.16..04.35.22-sphinx-web-shifted.svg'
5− glob-http+['https://nyc3.digitaloceanspaces.com/hmillerdev/sphinx/glob-0v7.folii.k685s.mjn1b.46up6.but26.glob' 0v7.folii.k685s.mjn1b.46up6.but26]
6− base+'sphinx'
7− version+[0 5 0]
8− website+'https://github.com'
9− license+'MIT'
10−==
1+:~
2+ title+'sphinx'
3+ info+'A search engine powered by you and your pals for finding answers to your riddles'
4+ color+0xb2.5068
5+ version+[0 7 0]
6+ website+'https://github.com/iko-soy/sphinx'
7+ license+'MIT'
8+ image+'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0zMCAxNSA2MDAgNjAwIiBmaWxsPSJub25lIj48c3R5bGU+LnN0MXtmaWxsOiNlN2FiNzl9PC9zdHlsZT48cGF0aCBjbGFzcz0ic3QxIiBkPSJNMjk0LjY5IDU2Mi45NmMtMy4zNCAwLTYuNjgtLjA5LTEwLjAyLjAyLTcuNTkuMjUtMTUuMS0uMi0yMi41NS0xLjg1LTkuNTQtMi4xMi0xNy44NS02LjU3LTI0LjYyLTEzLjUzLTQuMTUtNC4yNy03Ljk3LTguOTQtMTEuNC0xMy44MS0xMC40Mi0xNC44Mi0yMC40OS0yOS44OS0zMC44Ni00NC43NS0xMS44OC0xNy4wMi0yNC4wNC0zMy44NS0zNS44OC01MC45LTguNDMtMTIuMTQtMTYuNTYtMjQuNDgtMjQuNzItMzYuOC0yLjAxLTMuMDQtNC41Ny01LjE2LTcuNzYtNi44OC0yMS4zMy0xMS41MS0zOC44NC0yNy4xOC01MS40Ni00OC4wMy0zLjY0LTYuMDItNy4yNy0xMi4wNi0xMC41LTE4LjMtNC41Ni04LjgxLTYuOTgtMTguMzktOS4wNC0yOC4wNC00LjY3LTIxLjg2LTIuNDktNDMuMTIgNi42OC02My40NSA1Ljc4LTEyLjgyIDExLjkzLTI1LjUgMTguNjYtMzcuODQgMTQuNjctMjYuOTIgMzEuMTktNTIuNjcgNTAuMzctNzYuNjUgOC40OC0xMC42IDE3LjUzLTIwLjY1IDI4LjA5LTI5LjI2IDIwLjItMTYuNDcgNDIuOTctMjcuOTQgNjcuOTktMzUuMDUgOC40Ny0yLjQxIDE3LjA1LTQuMTggMjUuODEtNS4yNiA2Ljk3LS44NiAxMy44Mi0yLjYxIDIwLjc4LTMuNiA4LjE4LTEuMTUgMTYuNC0yLjA0IDI0LjYzLTIuNzggOS40NS0uODUgMTguOTMtMS41OCAyOC40MS0yLjA0IDE0Ljk5LS43MyAyOS44NS43MiA0NC42NCAzLjI1IDIzLjYgNC4wMyA0Ni44NSA5LjU2IDY5Ljk4IDE1LjY4IDMuMDUuODEgNi4wMSAxLjk4IDkuMDUgMi44NSAzLjE5LjkxIDQuNDYgMy4wNCA0LjU3IDYuMjMuMjggOC43OS43NCAxNy41Ny45OSAyNi4zNi4xNSA1LjQ1LjA4IDEwLjkxLjAzIDE2LjM2LS4wNCAzLjU3LjY1IDYuOTcgMS43OCAxMC4zNCAzLjQ3IDEwLjM0IDYuOTMgMjAuNjggMTAuMyAzMS4wNCAzLjEgOS41MyA2LjA1IDE5LjExIDkuMTcgMjguNjMgMS4yIDMuNjcgMi40OCA3LjM1IDQuMTQgMTAuODIgOC4zMiAxNy40NyAxNi43MiAzNC45IDI1LjE5IDUyLjI5IDUuNzggMTEuODYgMTEuNzIgMjMuNjMgMTcuNiAzNS40NS42MyAxLjI2IDEuMTkgMi41NSAxLjg5IDMuNzcgNC43IDguMjggMy44NCAxNi41OS0uMDkgMjQuNzctMi41OSA1LjM5LTUuNTIgMTAuNjEtOC4zOSAxNS44Ni01LjQgOS44OC0xMC43NiAxOS43OC0xNi4zMiAyOS41Ny0zLjMgNS44MS00LjY2IDEyLjAxLTUuMTMgMTguNjEtMS4zIDE4LjQxLTMuMDcgMzYuNzgtNS45MSA1NS4wNC0xLjc5IDExLjQ2LTIuOTEgMjMuMDItNC40NyAzNC41Mi0xLjU0IDExLjMyLTMuMiAyMi42My00LjkxIDMzLjkyLTIgMTMuMjEtNC4xNyAyNi40LTYuMTEgMzkuNjItLjM4IDIuNi0xLjEzIDQuNzQtMy4zIDYuMjgtLjQzLjMtLjguNjgtMS4yMSAxLjAyLTcuOTcgNi43Ny03Ljk3IDYuODEtMTguNSA3LjAxLTYuNjguMTItMTMuMzcuMTItMjAuMDUuMTQtMS4yMyAwLTIuNDYtLjEtMy42OS0uMTktMi4zMS0uMTYtMy44Ni0xLjE3LTQuNzEtMy41LTIuMjctNi4yOC00LjczLTEyLjQ4LTcuMDgtMTguNzMtMS4xMS0yLjk2LTIuMi01LjkzLTMuMjEtOC45Mi0uNTMtMS41OS0xLjY3LTIuMzUtMy4xNy0yLjI3LTMuNS4xOS03LjAzLjM4LTEwLjQ2IDEuMDEtMS4xLjItMi4yNCAxLjY0LTIuODMgMi43OS0xLjMxIDIuNTMtMy4yNiAzLjM4LTUuOTkgMy40Mi0xNS42NS4yNC0zMS4zLjU2LTQ2Ljk1LjkxLTIuMzMuMDUtNC4xMy0uNTctNS4yNC0yLjcyLS42My0xLjIxLTEuNjYtMS42Ny0zLjA1LTEuNjMtNS40NS4xNC0xMC45MS4xNy0xNi4zNi4zMy0yLjU5LjA4LTMuMjYuOTktMi4yMyAzLjMzIDMuMzQgNy41NiA2LjgzIDE1LjA1IDEwLjI4IDIyLjU2Ljk1IDIuMDggMi4wNiA0LjA5IDIuOSA2LjIxIDEuMTUgMi45My41MSA0LjQxLTIuNjIgNC45Mi01LjIuODUtMTAuNDUgMS41NC0xNS43IDEuODEtNS43OS4zMS0xMS42LjA3LTE3LjQxLjA3LS4wMy4wMS0uMDMtLjAxLS4wMy0uMDN6bTc3Ljk0LTQwMS40N2MtNC45NS4xNC0xMC45My4xOC0xNi45LjUtNy45LjQyLTE1Ljc5IDEuMDgtMjMuNTEgMy0xNS40NSAzLjg1LTMwLjA4IDkuNzktNDQuMDIgMTcuNDUtMi40NSAxLjM1LTQuODIgMi45Ni02LjkgNC44Mi0xLjc5IDEuNi0zLjU5IDMuNTItNC41NyA1LjY2LTMuMDYgNi43MS02LjA4IDEzLjQ4LTguNDkgMjAuNDQtMy41MiAxMC4xOC00LjIgMjAuNzMtMS41NyAzMS4yMSA0LjUxIDE4LjAxIDEyLjUxIDM0LjMzIDI0Ljg5IDQ4LjMyIDguMzMgOS40MiAxOC4wMyAxNy4yOSAyOC41NCAyNC4xMSAzLjUxIDIuMjggNy4zNiA0LjE3IDExLjI4IDUuNjggNS40MSAyLjA4IDExLjAzIDMuNiAxNi41MyA1LjQ0IDIuMjQuNzUgNC4zNS43NSA2LjY4LjE4IDExLjQ0LTIuODIgMjIuOS01LjU0IDM0LjM5LTguMTQgNy4yLTEuNjMgMTQuNS0yLjgzIDIxLjY4LTQuNTMgNS42NS0xLjM0IDkuNi41MyAxMi40NCA1LjM3LjYyIDEuMDYgMS4yOCAyLjExIDIgMy4xMSAxLjEzIDEuNTcgMi4yNCAxLjYgMy4yMS0uMTUgNC44Ny04Ljc2IDkuODEtMTcuNDkgMTMuMDktMjcuMDMgNC45My0xNC4zMSA0LjgtMjkuMDYgMy42Ny00My44NS0uNzgtMTAuMTYtMS45Ny0yMC4yOS0yLjk0LTMwLjQ0LS4zMi0zLjM2LTEuMDMtNC4wOC00LjM0LTMuNDQtNS43OSAxLjExLTExLjU0Ljg3LTE3LjMyLS4wMS0yLjYxLS40LTUuMjEtLjc5LTcuODMtMS4xMi0xLjYyLS4yLTIuNDcuNzktMS43NiAyLjIgMS4yNiAyLjUgMi42NCA0Ljk3IDQuMTUgNy4zMyAzLjEzIDQuODkgNi4zNCA5LjczIDkuNjEgMTQuNTIgMi4zOSAzLjUxIDIuODkgNy4yMSAxLjA3IDExLTIuNDEgNS4wMi00LjI2IDEwLjUyLTkuNzIgMTMuMzYtMS40NC43NS0xLjk4IDIuMTItMS42IDMuNTcuMzkgMS40OCAxLjEgMi45IDEuODUgNC4yNSAxLjExIDEuOTggMi40OSAzLjgxIDMuNTUgNS44MS45MyAxLjc2IDEuMTggMy44LjMxIDUuNjItMS4xNyAyLjQ3LTEuNDYgNC45Mi0xLjI2IDcuNjQuMjUgMy40Ni0xLjUyIDUuNzktNC45MSA2LjUtMi43Mi41Ny01LjU4Ljc1LTguMzcuNjUtMTAuMDItLjM1LTIwLjAzLS42NC0zMC4wNC4zMi0xLjc0LjE3LTMuNTIuMTktNS4yNy4xLTEuOTktLjExLTMuMTctMS4wMi0zLjAyLTIuMjMuMjMtMS44OCAxLjc5LTEuOTQgMy4xOS0yLjAzIDUuMjYtLjM1IDEwLjUzLS44NSAxNS44LS44OSA2LjUtLjA1IDEyLjk5LjM2IDE5LjQ5LjQzIDIuMjUuMDIgNC41NC0uMjMgNi43Ni0uNjMgMS4yOC0uMjMgMi4xMy0xLjMgMi4wNC0yLjcxLS4xLTEuNTMtLjg0LTIuNzQtMi40NS0zLjAxLTEuMi0uMjEtMi40NS0uMjEtMy42OC0uMjQtNC40LS4wOS04LjgtLjAzLTEzLjE5LS4yNS02LjQ0LS4zMS0xMi41OS44NS0xOC41OCAzLjEzLTIuNDYuOTMtNC45NiAxLjg4LTcuNTMgMi4zNi0xLjgzLjM0LTMuOTEuMjctNS42Ny0uMjgtMy4wOC0uOTYtMy45MS00LjQxLTEuNzMtNi44IDEuMDMtMS4xMyAyLjQ2LTEuOTggMy44NS0yLjY5IDQuMjMtMi4xNSA4LjU2LTQuMTIgMTIuOC02LjI1IDcuOTgtNC4wMSAxNi40Mi02LjE5IDI1LjM0LTYuNTMgMS40LS4wNSAyLjgyLS4xNCA0LjE4LS40Ni41Mi0uMTIgMS4zMS0xLjA0IDEuMjItMS40LS4xOS0uNzEtLjc4LTEuNTItMS40My0xLjg0LS43NC0uMzYtMS43Mi0uMzItMi41OS0uMjktNC4zOS4xOS04Ljc3LjUtMTMuMTcuNjMtMy4xNS4wOS02LjMxLjEzLTkuNDUtLjExLTIuMDItLjE2LTMuMDMtMS4yOS0yLjgxLTIuNDguMzItMS43NCAxLjczLTIuMDMgMy4xNy0yLjIzLjM1LS4wNS43LS4wNCAxLjA1LS4wNiA1LjgtLjE3IDExLjYxLS4yMyAxNy40LS41NiAzLjY3LS4yMSA3LjM0LS43MiAxMC45Ny0xLjMyIDIuMDgtLjM1IDQuMTItMS4xMyA1LjI0LTMuMTcuMjEtLjM5LjItLjk4LjEtMS40My0uMDQtLjE4LS42LS4zNi0uOTEtLjM1LTEwLjcxLjUyLTIxLjQyIDEuMDgtMzIuMTMgMS42LTIuNTguMTMtNC44MiAxLjA5LTYuNjcgMi44MS0xLjc5IDEuNjctMy40MSAzLjUzLTUuMDggNS4zNC0uNzEuNzctMS4yMyAxLjc1LTIuMDMgMi4zOS0uNDMuMzUtMS4zOS40My0xLjkxLjE5cy0xLS45OS0xLjExLTEuNTljLS4xMS0uNjMuMDYtMS40Ni40MS0yIDQuMTMtNi40MSA4Ljk0LTExLjc0IDE3LjQxLTExLjk2IDguMDktLjIxIDE2LjE3LS42NSAyNC4yNC0xLjA4IDIuODEtLjE1IDUuNjEtLjUxIDguMzktLjkgMy41Ni0uNSA0Ljc4LTIuNzUgMy4yMy02LS42OC0xLjQyLTEuNjQtMi43LTIuNTEtNC4wMi0zLjU5LTUuNDMtNy4zMS0xMC43Ny0xMC43Ni0xNi4yOS0yLjkxLTQuNjUtNS40LTkuNjMtNS40LTE1LjI0IDAtNi44NS41MS0xMy42OS44MS0yMC41NC4wMi0uMzUuMTYtLjY4LjIyLTEuMDMgMS4wNC02LjM0IDQuNzMtMTAuNDMgMTAuNzItMTIuNTUgOC40NC0yLjk4IDE2LjgzLTMuNTMgMjUuMTEuNTcuNjMuMzEgMS4yNy42MiAxLjkyLjg4IDIuMTYuODUgMi43OC41OCAzLjMzLTEuNzQgMS41NS02LjUxLjktNi4xNi00LjM1LTcuOC05Ljc4LTMuMDYtMTkuODItNC42NC0yOS45OS01LjcxLTEyLjI1LTEuMjYtMjQuNDktMi4zMy0zNy44Ni0xLjA5em0tMTU3LjA4IDYuMjljLTEuMzQtMTAuMS0yLjUtMjAuMjMtNC4wNi0zMC4zLTIuNDctMTUuOTctNS4yMS0zMS45MS03Ljg0LTQ3Ljg1LS4wOS0uNTItLjE0LTEuMDUtLjI3LTEuNTUtLjI3LTEuMDEtMS4yMi0xLjIzLTItLjQ1LS40OS41LS45NCAxLjA0LTEuMzggMS41OS0xNy4wMyAyMS4yOC0zMy45MSA0Mi42Ny00OS45OSA2NC42OC04LjcyIDExLjkzLTE3LjAyIDI0LjE0LTI0LjUyIDM2Ljg4LTYuODkgMTEuNy0xMi42OCAyMy45MS0xNi43NiAzNi44OS0yLjcxIDguNjMtNC4wMSAxNy40MS0zLjExIDI2LjUgMS4wNSAxMC42NiAxLjc2IDIxLjM2IDIuNjggMzIuMDQuNzEgOC4yMyAxLjM5IDE2LjQ2IDIuMzIgMjQuNjYuNzYgNi43MyAxLjI0IDYuOTYgNy45MyA2LjQ2IDE0LjAyLTEuMDUgMjguMDQtMi4wMyA0Mi4wNS0zLjEzIDUuMjUtLjQxIDEwLjQ4LTEuMTYgMTUuNzMtMS42MyA5LjI3LS44MyAxOC40Mi0yLjg1IDI3Ljc5LTIuNjMgMS4yMi4wMyAyLjQ2LS4xNyAzLjY3LS4zOSAyLjE3LS40IDIuNTQtMS4xMyAxLjgyLTMuMzUtLjE2LS41LS4zOC0uOTgtLjU5LTEuNDctMy45Ny05LjYtNy4xOC0xOS40NC05Ljg5LTI5LjQ3LS41My0xLjk1LTEuNzctMy43OC0yLjk1LTUuNDctMi43Mi0zLjg4LTUuODktNy40Ny04LjM5LTExLjQ5LTguODktMTQuMy0xMy43NS0yOS41NC0xMC41NS00Ni42NiAxLjkyLTEwLjI5IDYuMTgtMTkuMzUgMTIuOTYtMjcuMjggNC40LTUuMTUgOS44Ny04LjM3IDE2LjU3LTkuNDYgMS4yMS0uMiAyLjQxLS40OCAzLjYzLS42NyA1LjQ3LS44NSA1LjQ3LS44NCA1LjE1LTYuNDV6bTQ1LjAxIDY0LjFjLS40MS03LjQxIDEuMDYtMTQuNjQgMy42MS0yMS42MiAxLjkzLTUuMjcgNC40Mi0xMC4zNCA2LjU1LTE1LjU1IDMuMzEtOC4wOCA5LjMtMTMuNTggMTYuOTItMTcuMzcgNy40LTMuNjggMTQuODMtNy4zMyAyMi40OC0xMC40NCAxMC4xMi00LjExIDIwLjU1LTcuNTIgMzEuNDctOC41NiAxMS4zNy0xLjA5IDIyLjgtMS42IDM0LjIxLTIuMSAxMy4zOC0uNTkgMjYuNzIuMTkgNDAgMS45OCA3LjY3IDEuMDMgMTUuMzMgMi4wOSAyMi43NSA0LjQzIDIuMTguNjkgNC4zOSAxLjI5IDYuNjEgMS43OSAxLjU5LjM2IDIuMy0uMTggMi41NC0xLjgxLjE4LTEuMjEuMjEtMi40NS4yNS0zLjY4LjI4LTcuNTYuNS0xNS4xMi44My0yMi42OC4xMy0yLjk4LjU5LTUuOTYuNjctOC45NC4wOS0zLjE1LS43LTQuMDItMy44OS00LjUtMTEuNjUtMS43My0yMy4zMS0zLjQ1LTM0Ljk4LTUuMDctMi40Mi0uMzQtNC45MS0uMjktNy4zNy0uMjYtMS42Mi4wMi0yLjU5IDEuMDItMi43OCAyLjYzLS4xMiAxLjA0LS4xMSAyLjEtLjE1IDMuMTYtLjIzIDUuOC0uNCAxMS42LS42OSAxNy4zOS0uMjEgNC4wMS0xLjQ3IDUuMTctNS42NyA1LjE1LTIuNjMtLjAxLTUuMjYtLjQyLTcuOS0uNDUtNC4yMS0uMDUtOC40OS0uMzgtMTIuNjEuMjYtMy43NS41OC02LjEtLjYzLTcuOTktMy41My0xLjI1LTEuOTEtMi41NS0zLjgtMy42OC01Ljc5LTMuMDMtNS4zNi02LjE1LTEwLjY4LTguODktMTYuMTgtMS4zOS0yLjgtMy4xMi0zLjktNi4xOS0zLjU5LTguNC44NS0xNi44NSAxLjItMjUuMiAyLjM1LTI1LjExIDMuNDMtNDkuODYgOC44MS03NC40OSAxNC43LTguNzEgMi4wOC0xNy4yOCA0Ljc5LTI1Ljg3IDcuMzctMy40MiAxLjAzLTMuNzMgMS41My0zLjM2IDQuODkuODYgNy44NiAxLjkxIDE1LjcxIDIuODEgMjMuNTYuMzUgMy4wMiAxLjU0IDUuMDIgNC42NCA2LjI1IDYuODkgMi43MyAxMS40OSA4LjAyIDE0LjI3IDE0Ljc4IDEuNDcgMy41NiAyLjYzIDcuMjggMy41OSAxMS4wMiAyLjY4IDEwLjM4IDQuOTggMjAuODYgNy44IDMxLjIgMy44OCAxNC4yNSA3LjkgMjguNDYgMTIuMjIgNDIuNTggNC44MiAxNS43NCAxNC4xMiAyOC42NyAyNi41OSAzOS4yMyA3LjI0IDYuMTMgMTQuOTggMTEuNjcgMjIuNDcgMTcuNTEgNy41NCA1Ljg4IDE1LjcyIDEwLjcgMjQuNTkgMTQuMjIgNC41NCAxLjggNy42MyA0Ljg1IDkuOCA5LjA3IDIuNDkgNC44NSA1LjIxIDkuNTkgNy40OSAxNC41NCAxMS4zOSAyNC43NyAyMi42OCA0OS41OSAzNCA3NC4zOS41OCAxLjI4IDEuMTIgMi41OSAxLjc0IDMuODQgMS4wMSAyLjAyIDEuODEgMi4xIDMuNTIuNDMgNC41My00LjQzIDQuODEtNS43MSAyLjEtMTEuNTQtNi42LTE0LjE5LTEzLjIyLTI4LjM4LTE5LjktNDIuNTQtMy4zLTYuOTktNy4xNS0xMy43NS0xMC0yMC45Mi01LjAyLTEyLjYtMTAuNjYtMjQuODktMTYuNjMtMzcuMDYtMS4yLTIuNDUtMi44NC0zLjgyLTUuMzYtNC41NS0zLjM4LS45OC02LjY4LTIuMjEtMTAuMDQtMy4yMy02LjgtMi4wNy0xMy4wOC01LjE4LTE4LjgxLTkuMzUtNC4yNi0zLjExLTguNDctNi4yOS0xMi41OS05LjU5LTE5Ljc2LTE1Ljg0LTMyLjI5LTM2LjQtMzkuMzEtNjAuNTMtMS42MS01LjYxLTIuNDktMTEuMzMtMi4xNy0xNy4yOXptNzYuMjQgMTIwLjA0Yy0xLjgyLS45Mi0yLjg2LTEuNDQtMy44OS0xLjk3LTQuMzgtMi4yNC04Ljk5LTQuMTMtMTMuMDktNi44LTEyLjA4LTcuODktMjMuNzYtMTYuMzYtMzQuNTUtMjUuOTYtMTIuMzItMTAuOTUtMjEuNTYtMjQuMDUtMjYuNzEtMzkuODQtMS4wNC0zLjE3LTIuMTgtNi4zMi0zLjEyLTkuNTItNi40MS0yMS43Ni0xMS44Mi00My43OC0xNi45Ny02NS44Ni0xLjIxLTUuMi0zLjA1LTEwLjEtNi4xNi0xNC40NS0zLjA4LTQuMy03LjA2LTcuNzItMTIuMzQtOC4zNi0xMC4yMi0xLjI1LTE5LjczLjUtMjYuNjMgOS4xNC0xMS44MSAxNC44Mi0xNS4zMSAzMS40Ni05LjY5IDQ5LjU4IDUuMzggMTcuMzQgMTYuMjggMzAuOTggMzAuMjMgNDIuMyA0LjE3IDMuMzggOSA1LjUxIDE0LjM0IDYuMjQgMS41Mi4yMSAzLjE4LjE4IDQuNjctLjE4IDguMjItMS45OSAxMi40Mi0xMC4zMyA5LjA3LTE4LjEyLTEuMTgtMi43My0yLjg2LTUuMjQtNC4yMy03LjktLjU1LTEuMDctMS4wMi0yLjIzLTEuMjItMy40MS0uMDktLjUyLjQzLTEuNDEuOTMtMS43LjQ5LS4yOSAxLjQyLS4yNiAxLjk0LjAyLjc0LjQgMS40NiAxLjA1IDEuODcgMS43OCAyLjA3IDMuNjcgNC4yMyA3LjMxIDUuOTggMTEuMTMgMS4zNSAyLjkzIDEuMzEgNi4xMS40NSA5LjMtMS4yIDQuNDEtMy4zNSA4LjE3LTcuMTMgMTAuNzUtMi41MiAxLjcyLTIuOTIgMy43My0xLjc1IDYuMzQuMzYuOC41OSAxLjY1LjkgMi40OCA1LjggMTUuNTkgMTUuMjQgMjguODYgMjYuNDQgNDAuOTQgNC45NiA1LjM0IDExLjAxIDkuMjcgMTcuNjcgMTIuMjMgMy4zNyAxLjUgNi43OSAyLjkxIDEwLjA3IDQuNiA2LjMxIDMuMjQgMTMuMDYgNC4wNyAxOS45NyAzLjgxIDIuNTktLjEgNS4zMi0uNjEgNy42Ny0xLjY0IDQuNjctMi4wNCA5LjM4LTMuODEgMTUuMjgtNC45M3ptMTE4LjQzLTIxOS45NmMtLjMuMDctLjYuMTQtLjkxLjIxLS4xOSAxLjItLjUgMi40LS41NSAzLjYxLS4yNCA1LjI3LS4zOCAxMC41NC0uNiAxNS44MS0uMzUgOC4yNS0uMzYgMTYuNTMtMi40NSAyNC42LTIuMjIgOC41My0zLjQzIDE3LjIyLTMuNjkgMjYuMDEtLjQ3IDE2LjU0LjU3IDMyLjk5IDIuNzMgNDkuNDIgMS42MSAxMi4yMyAxLjU1IDI0LjU1LS45OSAzNi43My0uMTguODYtLjQ0IDEuNzEtLjUzIDIuNTgtLjIgMi4wNi40MSAzLjAzIDIuNDEgMy4zNiAyLjYuNDMgNS4yMi43OSA3Ljg0Ljk0IDguNzcuNTEgMTcuNTUuODQgMjYuMzIgMS4zOSA2LjY2LjQyIDEzLjMxIDEuMDUgMTkuOTYgMS42MiA1LjI1LjQ1IDEwLjUgMS4wMiAxNS43NSAxLjM5IDQuNTUuMzIgNS4zOS0uNzMgMy45My01LjE1LS44OC0yLjY1LTIuMTYtNS4xOS0zLjQyLTcuNjktNS4xMy0xMC4yMS0xMC40NC0yMC4zMy0xNS40NS0zMC42LTkuNDktMTkuNDMtMTguODUtMzguOTEtMjguMTctNTguNDItMS41OC0zLjMyLTIuODQtNi44MS0zLjk5LTEwLjMxLTUuNDUtMTYuNy0xMC44LTMzLjQ0LTE2LjIxLTUwLjE2LS41Ny0xLjgxLTEuMzEtMy41Ny0xLjk4LTUuMzR6bS0xNDUuMzkgMjY0LjRjLjIzLjgxLjQyIDIuMDMuOTIgMy4xMS45NiAyLjA3IDIuMSA0LjA2IDMuMTcgNi4wOCA1LjkxIDExLjIgMTIuMDEgMjIuMyAxNy42NyAzMy42MyA1Ljk3IDExLjk2IDExLjUgMjQuMTQgMTcuMjMgMzYuMjEgMS41MSAzLjE4IDMuMDIgNi4zNiA0LjY1IDkuNDcgMS4xMSAyLjExIDEuODcgMi4xMSAzLjU1LjM3IDQuOTktNS4xOSA5Ljk2LTEwLjQyIDE1LjAyLTE1LjU0IDMuMDktMy4xMyA2LjQzLTYgOS41LTkuMTUgNi4zNi02LjUzIDYuNC00LjMxIDIuNC0xMy4zNi03LjM5LTE2LjczLTE1LjA3LTMzLjM0LTIyLjY1LTQ5Ljk5LTQuNzQtMTAuNC05LjQ5LTIwLjgtMTQuMzEtMzEuMTctMS4xOC0yLjU1LTIuNjMtNC45OC0zLjk1LTcuNDYtLjc1LTEuNDEtMS44OS0xLjg2LTMuNS0xLjczLTQuNDMuMzQtOC42NyAxLjI5LTEyLjc1IDMuMDgtNC4yNiAxLjg3LTcuMzQgNC43My04LjcyIDkuMjgtMi41IDguMjQtNS4wNSAxNi40Ny03LjU3IDI0LjcxLS4yLjY2LS4zNiAxLjM1LS42NiAyLjQ2em0tMzEuNjIgMjYuNjdjMy4zNy4wMyA0LjEzLS42MSA0LjYyLTIuOTkuMTEtLjUyLjExLTEuMDUuMjEtMS41NyAyLjczLTE0Ljg3IDUuNDUtMjkuNzMgOC4xOS00NC42LjYxLTMuMjggMS4zNS02LjU0IDEuOTEtOS44NC41NC0zLjIyLjE5LTMuOS0yLjc1LTUuNDktMS43LS45Mi0zLjQ2LTEuNzQtNS4yNS0yLjQ2LTExLjktNC43Ni0yMS41LTEyLjQ1LTI5LjM4LTIyLjUxLTkuMDYtMTEuNTctMTYuODQtMjMuODUtMjEuNC0zNy45My0uODYtMi42Ny0yLjIzLTQuMDEtNS4xOS00LjIxLTcuMjQtLjUtMTMuNTMtMy41My0xOS4xOC04LS45Ni0uNzYtMS45My0xLjUyLTIuOTctMi4xNy0uMTQtLjA5LS45OS40OC0uOTYuNjMuMjkgMS4zNy42NiAyLjczIDEuMDggNC4wNiAzLjI5IDEwLjQyIDcuNzkgMjAuMzUgMTIuMTkgMzAuMzMgNS42NyAxMi44NyAxMS4yOCAyNS43NyAxNi43NyAzOC43MSA2LjA0IDE0LjI0IDExLjk0IDI4LjU1IDE3Ljg0IDQyLjg1IDIuMSA1LjA4IDUuMDMgOS42NyA4LjExIDE0LjE2IDIuNzMgMy45NyA2LjM4IDcuMDUgMTAuNzQgOS4xMyAyLjAzLjk4IDQuMjcgMS41MSA1LjQyIDEuOXptMTIyLjIxIDY0LjQ4YzUuNjIgMCAxMS4yNS0uMTMgMTYuODYuMDUgMy4wNy4xIDUuMy0uOTYgNy40MS0zLjE0IDUuNjEtNS44MSAxMS40LTExLjQ1IDE3LjEyLTE3LjE2IDYuMS02LjA4IDEyLjI4LTEyLjA3IDE4LjI2LTE4LjI3IDIuNDItMi41MSA0LjU0LTUuMzUgNi40OC04LjI1IDEuNC0yLjA5LjUtMy44NC0yLjAzLTQuMDYtMi43OC0uMjUtNS42Mi0uMjEtOC40MS4wMy01LjYuNDgtMTEuMiAxLTE2Ljc1IDEuODItMTAuOTQgMS42Mi0yMS44NSAzLjQ4LTMyLjc3IDUuMi0yLjM2LjM3LTQuMyAxLjMtNS44NiAzLjE3LTguOSAxMC42OS0xOSAyMC4yMi0yOSAyOS44NS0yLjc4IDIuNjgtNS41MSA1LjQzLTguMTEgOC4yOC0uOTggMS4wNy0yLjg5IDIuMjMtMS44MiAzLjg2LjQ1LjY4IDIuNTguNDUgMy45Mi4zMyAxMS41NC0xLjAzIDIzLjA4LTIuMSAzNC43LTEuNzF6bS0xMzEuMzEtNjIuMDRjLjMyIDEuMTguNCAxLjg2LjY4IDIuNDUgNS4yOCAxMS4zIDEwLjMyIDIyLjczIDE1Ljk0IDMzLjg2IDguOTYgMTcuNzMgMTguNDEgMzUuMiAyNi4yMSA1My40OS4yOC42NS42MSAxLjI2LjkzIDEuODkuNjkgMS4zOCAxLjgyIDIuMjEgMy4zNCAyLjIxIDUuNjIgMCAxMS4yNC0uMDUgMTYuODYtLjE4IDEuMjctLjAzIDEuNzYtMS4wMiAxLjI4LTIuMjktLjQzLTEuMTUtMS0yLjI1LTEuNTMtMy4zNS0zLjU4LTcuNDQtNy4xNC0xNC45LTEwLjc3LTIyLjMyLTcuNzQtMTUuNzktMTUuMTItMzEuNzMtMjEuNjItNDguMDctLjg0LTIuMTItMS42Mi00LjMyLTIuODEtNi4yNC0zLjQ5LTUuNjctOC05LjgtMTUuMzUtOS4wMy0zLjIxLjM0LTYuMy0uMTgtOS4zMy0xLjI1LTEuMTEtLjM5LTIuMjQtLjY5LTMuODMtMS4xN3ptLTE0MC4wNS04OC44NGM0Ljk3LS4zNyA4LjY1LS43OCAxMi4zMy0uOSAxOC44LS42MSAzNy41Ny0xLjcxIDU2LjMtMy40OSA2LjMtLjYgMTIuNTctMS40MiAxOC44NS0yLjE5IDIuNDYtLjMgMy0xLjIzIDIuMDItMy41OC0xLjIzLTIuOTEtMi42NS01Ljc1LTMuOTEtOC42NS0uODctMi4wMS0yLjMtMy00LjUtMi45NC01LjI3LjE1LTEwLjYxLS4yNC0xNS43OS40OC0yMy4xOCAzLjE5LTQ2LjQ5IDQuNzktNjkuODMgNi4wNS0xLjc1LjA5LTMuNTEuMjUtNS4yNS41MS0zLjM1LjUxLTMuNzYgMS4yOS0yLjEgNC4wMyAxLjE4IDEuOTUgMi42OSAzLjcyIDMuNzYgNS43MyAyLjA0IDMuODggNC44NyA1LjkzIDguMTIgNC45NXpNMzg0LjYzIDE0MC4zYzAgLjA4LjAxLjE2LjAxLjI0IDIuMS4xOCA0LjIuNDYgNi4zMS41IDIuOTkuMDUgMy43Ny0uNjYgMy45MS0zLjY4LjE4LTMuNjkuMy03LjM5LjItMTEuMDctLjE5LTcuMDItLjA3LTE0LjAyLTEuMDEtMjEuMDQtLjg2LTYuNDUtMy45NC0xMS4xOS04Ljg0LTE0Ljk2LTIuMjEtMS43LTQuNzItMy4wMi03LjE4LTQuMzctMy4yLTEuNzUtMy40Ni0yLjE0LTIuODItNS44My4xMi0uNjkuNDMtMS4zNy40My0yLjA2LS4wMi0zLjUuMzktNy4xLS4yOS0xMC40OC0xLjMzLTYuNTItNS45My0xMC42OC0xMS43Ny0xMy4yOC0zLjM0LTEuNDktNy4wMi0yLjIyLTEwLjU1LTMuMjctNy4yLTIuMTUtMTMuOTItLjUxLTIwLjM2IDIuNzktNi4wOSAzLjExLTcuNjQgNS45LTYuOTMgMTIuNy40NiA0LjQ0IDEuNjYgOC44IDUuMTQgMTEuODEgNC42OCA0LjA0IDkuOCA3LjM0IDE2LjIyIDguMjMgNC43OS42NiA1Ljk3IDIuMzYgNS4zOSA3LjIzLS43NCA2LjI3LjE5IDEyLjMxIDIuMjYgMTguMTUgMy4zNiA5LjUxIDguNzggMTcuOTggMTQuMDcgMjYuNDkgMS4zOSAyLjI0IDMuMjcgMi43OCA1Ljg0IDIuNDcgMy4yOS0uNDIgNi42NC0uNDEgOS45Ny0uNTd6bS0yMTguNSAyMzYuNTF2LS4xYzEwLjM1LS40MyAyMC43LS44NiAzMS4wNC0xLjI5LjUzLS4wMiAxLjA1LS4xMSAxLjU3LS4xNCAxMS4zOS0uNjQgMjIuNzgtMS4yNyAzNC4xNi0xLjkuODgtLjA1IDEuNzYuMDEgMi42My0uMDYgMS42Ny0uMTMgMi4xNy0uODMgMS41NS0yLjQ2LTEuMjMtMy4yOS0yLjU2LTYuNTQtMy44OS05Ljc5LTEuNzktNC40LTIuODUtNS4wNS03LjUyLTQuNzItOC4wNS41Ni0xNi4xIDEuMjgtMjQuMTYgMS42Mi0xNC4yMS42LTI4LjQzLjkyLTQyLjY0IDEuNDQtNC43My4xOC05LjQ2LjU4LTE0LjE4Ljk3LTEuOTIuMTYtMi40IDEuMjMtMS4zIDIuODggMi41MiAzLjggNS4xNyA3LjUzIDcuNzEgMTEuMzIgMS4wOSAxLjYzIDIuNTggMi4yNSA0LjQ3IDIuMjQgMy41NC0uMDMgNy4wNS0uMDEgMTAuNTYtLjAxem01Ni40IDY0LjZjMCAuMDQgMCAuMDkuMDEuMTMgMTMuMzItLjQ5IDI2LjY1LS45OCAzOS45Ny0xLjQ4IDEuNC0uMDUgMi44Mi0uMDggNC4yLS4yOCAxLjc5LS4yNiAyLjQ4LTEuMzMgMS43Ni0yLjk4LTEuNzYtNC4wMi0zLjY2LTcuOTctNS41My0xMS45My0uNzUtMS42LTIuMDktMi4yOC0zLjgyLTIuMzItMS4wNS0uMDItMi4xLS4xMS0zLjE2LS4xLTExLjkzLjA5LTIzLjg3LjA4LTM1LjguMzQtOC4wNi4xNy0xNi4xMi43Mi0yNC4xOCAxLjEtNC4wMy4xOS04LjA2LjM0LTEyLjA5LjYyLTEuOTUuMTQtMi42MSAxLjItMS43MSAyLjgzIDIuMzkgNC4yOSA0LjkgOC41MiA3LjM5IDEyLjc0Ljc2IDEuMyAxLjk0IDEuOTggMy40OSAxLjk2IDEuNC0uMDEgMi44MS4wNSA0LjIxLjAxIDguNDMtLjIgMTYuODUtLjQzIDI1LjI2LS42NHptLTQuMjktMTA2LjU3Yy0yLjM3LjItMy45Ni4yNS01LjUyLjQ3LTIyLjg0IDMuMTktNDUuODMgNC40Mi02OC44NiA1LjA5LTQuMzkuMTMtOC43Ny41LTEzLjE1Ljg3LTEuNjQuMTQtMi4yMyAxLjI2LTEuMzMgMi41NiAyLjYxIDMuNzUgNS4zMyA3LjQyIDguMDQgMTEuMS45NiAxLjMgMi4zIDEuMjQgMy44IDEuMTYgMTQuNzQtLjg2IDI5LjQ4LTEuNjggNDQuMjItMi40NCAxMC41My0uNTQgMjEuMDctLjk1IDMxLjYxLTEuNDcgMi44MS0uMTQgNS42MS0uNDUgOC40MS0uNzMgMy4xMy0uMzEgMy41Ni0uOTkgMi4zNS00LjEzLTEuMDctMi43OS0yLjQ1LTUuNDYtMy40Ny04LjI3LTEuMjEtMy4zNi0zLjM3LTQuODEtNi4xLTQuMjF6bS0xMi40NCA2Ni43MWgtMzEuODdjLTEuNCAwLTIuODEtLjA0LTQuMjEuMDYtMS4yNy4wOS0xLjgyIDEuMDktMS4xNyAyLjI1IDEuNTUgMi43NSAzLjIzIDUuNDMgNC44MiA4LjE2IDQuODEgOC4yNCA0LjgxIDguMjMgMTQuMzkgNy42OCA5LjgxLS41NyAxOS42Mi0xLjM1IDI5LjQ0LTEuNjMgMTIuMjctLjM2IDI0LjU1LTEuNDcgMzYuODQtLjQ2Ljg3LjA3IDEuNzYuMDQgMi42My0uMDEgMS43Ny0uMTEgMi4xOS0uODcgMS4zLTIuMzgtMi4xMy0zLjYzLTQuMzctNy4yLTYuMzgtMTAuODktMS4wMy0xLjktMi4zOS0yLjkzLTQuNTMtMy4wMy00LjU1LS4yMS05LjEtLjY4LTEzLjY0LS42Mi04Ljk0LjEyLTE3Ljg4LjU0LTI3LjYyLjg3em0tLjQ3LTQuNzd2LS40N2MxMi40Ny0uMTcgMjQuOTQtLjMzIDM3LjQxLS41LjE4IDAgLjM1IDAgLjUzLS4wMSAzLjI0LS4yIDMuODYtLjk5IDIuNzEtMy44OS0xLjI5LTMuMjYtMi44OS02LjQxLTQuMTUtOS42OS0xLjExLTIuOS0zLjE1LTQuMDctNi4xLTQtNS42Mi4xMy0xMS4yNS4xMS0xNi44Ni40LTE1LjYxLjgxLTMxLjIxIDEuNzgtNDYuODIgMi42LTQuNTYuMjQtOS4xNC4xNi0xMy43LjMyLTEuNjIuMDYtMi4yNSAxLjEtMS41IDIuNSAxLjU4IDIuOTQgMy40NiA1LjcyIDQuOTIgOC43MSAxLjUzIDMuMSAzLjc5IDQuMTIgNy4xOSA0LjA3IDEyLjEyLS4xNCAyNC4yNS0uMDQgMzYuMzctLjA0em02MC43NCAxMDguMzJjMCAuMDguMDEuMTYuMDEuMjMgNS45Ny0uMjUgMTEuOTMtLjU0IDE3LjktLjcyIDQuNzQtLjE0IDkuNDktLjEyIDE0LjIzLS4yMyAyLjY3LS4wNiAzLjA5LS43MiAxLjktMy4yNS0xLjc4LTMuODItMy44My03LjUyLTUuNTItMTEuMzgtMS4zMi0yLjk5LTMuMzktNC4zLTYuNi0zLjk5LTEuMDUuMS0yLjExLjAxLTMuMTYuMDYtNy45LjMzLTE1Ljc5Ljg3LTIzLjY5Ljk3LTEyLjk5LjE1LTI1Ljk4IDEuNDEtMzguOTguMzctLjM1LS4wMy0uNy0uMDItMS4wNSAwLTIuMjkuMTEtMi44NCAxLjAyLTEuNTcgMy4wMyAyLjUzIDQuMDEgNS4zNyA3LjgzIDcuNzYgMTEuOTMgMi4wNiAzLjUzIDQuODIgNC41MSA4Ljc2IDQuMjYgOS45OS0uNjEgMjAtLjg3IDMwLjAxLTEuMjh6bS00OC45Mi00MS43NWM3LjkyLS4zNSAxNS4yOS0uNyAyMi42NS0xIDExLjIyLS40NiAyMi40NS0uODkgMzMuNjctMS4zNSAxLjA0LS4wNCAyLjEtLjE1IDMuMTEtLjQxIDEuNC0uMzYgMS45OC0xLjQzIDEuMzctMi44NC0xLjYtMy43LTMuMzItNy4zNi01LjA0LTExLS42Ni0xLjQtMS44OS0yLjAzLTMuNDItMi4wMy0xLjIzIDAtMi40Ni4wMS0zLjY4LjA3LTUuMjYuMjgtMTAuNTEuNzItMTUuNzcuODUtMTcuMDIuNDItMzQuMDQuNzItNTEuMDYgMS4wNy0uNy4wMS0xLjQxIDAtMi4xLjA2LTIuMjUuMjEtMi42OCAxLjA0LTEuNDcgMy4xNCAyLjAyIDMuNDkgNC4yMiA2Ljg5IDYuMTcgMTAuNDEgMS4yNyAyLjI5IDMuMDggMy4xMiA1LjYxIDMuMDUgMy40OS0uMSA3LS4wMiA5Ljk2LS4wMnptMjIuMjcgMTkuMDV2LjE4YzE0Ljc0LS41MSAyOS40OS0xLjAyIDQ0LjIzLTEuNTMuODgtLjAzIDEuNzYtLjAzIDIuNjItLjE4IDIuMTItLjM2IDIuNzctMS40IDEuOTYtMy4zLS43Ni0xLjc3LTEuNjgtMy40OC0yLjU5LTUuMTgtMy44NS03LjI3LTMuMDgtNi44NS0xMS4yOC02LjUzLTE4LjQzLjctMzYuODUgMS40LTU1LjI4IDIuMDctMy4zMy4xMi02LjY4LjA2LTEwLjAxLjE4LTEuNjMuMDYtMi4yMSAxLjA1LTEuNCAyLjQ5IDEuNjMgMi45MSAzLjQ0IDUuNzIgNS4wOSA4LjYyIDEuMjUgMi4yIDMuMDQgMy4yMyA1LjU4IDMuMjEgNy4wMy0uMDYgMTQuMDYtLjAzIDIxLjA4LS4wM3ptMjUuNjMgMjcuOTZjLTMuNjkgMC0xMC41Ni0uMDctMTcuNDEuMDMtMy42OS4wNi03LjM4LjM2LTExLjA2LjY2LTEuNjkuMTQtMi4xNyAxLjEzLTEuMTYgMi41NCAyLjk4IDQuMTQgNi4wNiA4LjIgOS4xMyAxMi4yOCAxLjMyIDEuNzUgMy4yMiAxLjk3IDUuMjIgMS44NSA0LjkxLS4zIDkuODItLjcgMTQuNzQtLjg5IDEzLjE4LS41MiAyNi4zNi0uOTUgMzkuNTQtMS40MyAxLjIzLS4wNCAyLjQ3LS4wNiAzLjY3LS4yOSAxLjc2LS4zMyAyLjMtMS4yOSAxLjY0LTMuMS0uNzctMi4xNS0xLjk4LTQuMTYtMi41Ni02LjM1LTEuMzQtNS4wNy01LTYuMzYtOS41OS02LjM4LTEuMDYtLjAxLTIuMTEtLjAyLTMuMTcuMDItOC41OS4zLTE3LjIuNjItMjguOTkgMS4wNnptMjQwLjY2LTE5My41OGMuMDEtLjA1LjAxLS4xLjAyLS4xNCAzLjE3IDAgNi4zMy4wNCA5LjUtLjAzIDEuMDMtLjAyIDIuMzYuMDEgMy4wMi0uNTkgMi42Ny0yLjQyIDMuOTktNS43IDQuOTUtOS4wOS4xMi0uNDItLjU1LTEuMjktMS4wNy0xLjYtLjcyLS40NC0xLjYyLS42OC0yLjQ4LS43OS0zLjE0LS40LTYuMjgtLjgyLTkuNDQtMS4wNS0xOS42My0xLjQ2LTM5LjI3LTIuODgtNTguOS00LjMtLjg4LS4wNi0xLjc2LS4wNy0yLjY0LS4wOS0xLjU2LS4wMy0yLjc2LjYyLTMuNDIgMi4wMi0xLjUgMy4xOC0yLjk5IDYuMzctNC4zOCA5LjYtLjYzIDEuNDYtLjAzIDIuMzQgMS42NCAyLjQ0IDMuNTEuMjIgNy4wMy4yOCAxMC41NC40OCAxNy41NiAxLjAzIDM1LjExIDIuMDkgNTIuNjYgMy4xNHptLTkuNiAxNy43M2MwLS4wNC4wMS0uMDguMDEtLjEyIDEuNCAwIDIuODEuMDEgNC4yMSAwIDkuOTEtLjA2IDcuODEgMS4xNCAxMi44LTcuNjYgMi42LTQuNTggMi4xNi01LjE3LTMuMTctNS4zNS0xMi4xLS40Mi0yNC4xOS0uNzktMzYuMjgtMS4zOC02LjY1LS4zMi0xMy4yOC0xLjE0LTE5LjkyLTEuNTctNC41NS0uMy05LjExLS4zNC0xMy42Ny0uNDUtMS40My0uMDMtMi45MS4xNi0zLjcxIDEuNTMtMS44NiAzLjE3LTMuNjcgNi4zOC01LjM4IDkuNjMtLjQ3Ljg5LjAyIDEuNzcgMS4wNSAxLjk5IDEuMDIuMjIgMi4wOC4yNyAzLjEzLjMzIDcuNy40MiAxNS40MS44NCAyMy4xMiAxLjIzIDEyLjU5LjYxIDI1LjIgMS4yMSAzNy44MSAxLjgyem0tNjAuMDggMS43OGMwLS4wNiAwLS4xMi4wMS0uMTctLjcgMC0xLjQtLjAxLTIuMTEgMC0yLjcuMDUtMy4zNC43NS0yLjQ2IDMuMDggMS4zIDMuNDUgMi43OSA2LjgyIDQuMiAxMC4yMi44NCAyLjAyIDIuMzYgMi45MSA0LjU2IDIuODQgMi40Ni0uMDggNC45MS0uMDQgNy4zNy0uMDggMTQuNzQtLjI4IDI5LjQ4LS42IDQ0LjIxLS44NiA4Ljg2LS4xNiA3LjI2IDEuMzYgMTEuODgtNy4zMiAyLjE4LTQuMSAxLjgxLTQuNTktMy00Ljc2LTExLjA1LS4zOC0yMi4xLS42NC0zMy4xNC0xLjE0LTEwLjUxLS40Ny0yMS4wMS0xLjE5LTMxLjUyLTEuODF6TTIyOS42MyA3OS43MmMuMSAxLjI2LjEyIDEuNzkuMTkgMi4zMSAxLjM5IDEwLjQyIDIuNzIgMjAuODUgNC4yMSAzMS4yNS43IDQuODUgMS42MiA5LjY4IDIuNjIgMTQuNDguNTMgMi41NCAxLjQgMy4wOCAzLjY1IDIuNTUgNC4wOS0uOTcgOC4xNy0yLjAyIDEyLjIxLTMuMTcgMi4yOC0uNjUgMi41OC0xLjM3IDIuMTEtMy45Ni0uMTYtLjg2LS4zOS0xLjcxLS41Ni0yLjU3LTEuNzItOC43Ni0zLjUyLTE3LjUxLTUuMTEtMjYuMy0uOS01LTEuNDMtMTAuMDYtMi4xMy0xNS4wOS0uNjUtNC42OC0xLjc4LTUuNjUtNi4xNC00LjcyLTIuOTEuNjItNS43NCAxLjU3LTguNiAyLjQtMS43NS41Mi0yLjU4IDEuNzEtMi40NSAyLjgyem0tNi43OS0uOTVjLTQuNjggMS4xOS05LjEgMi4yNi0xMy40OSAzLjQ2LTEuMzUuMzctMS44NCAxLjU1LTEuNzEgMi45Mi4wOC44Ny4yNyAxLjczLjQxIDIuNiAxLjYyIDkuNTEgMy4yMyAxOS4wMiA0Ljg2IDI4LjUzIDEuMDEgNS44OCAxLjk5IDExLjc2IDMuMDYgMTcuNjMuNDkgMi42NyAxLjQyIDMuMjMgNC4wNSAyLjQ5IDMuMDQtLjg2IDYuMDYtMS43OCA5LjA0LTIuODIgMi43LS45NCAzLjA2LTEuNjMgMi43NC00LjI3LS4wNi0uNTItLjIyLTEuMDMtLjMxLTEuNTUtMi40OS0xNC4xNy00LjUyLTI4LjQxLTUuODYtNDIuNzQtLjEzLTEuMzktLjMyLTIuODMtLjgxLTQuMTItLjI5LS43OS0xLjIyLTEuMzUtMS45OC0yLjEzem0yMjYuNTggNDE4LjI2YzguNzkgMCAxNS40OC4wMSAyMi4xNy0uMDEgMS4wNSAwIDIuMi4xNiAzLjEzLS4yLjg3LS4zMyAyLjAyLTEuMTUgMi4xOC0xLjkzLjkxLTQuNDcgMS42My04Ljk4IDIuMjUtMTMuNTEuMjItMS42MS0uNzgtMi42NC0yLjQ1LTIuODMtLjctLjA4LTEuNC0uMTUtMi4xLS4xNC0xMS43OC4yOC0yMy41Ni41OC0zNS4zNC44Ni0xLjgyLjA0LTMuNDcuNDktNC43OSAxLjgzLTIuNTkgMi42My01LjMyIDUuMTUtNy43MyA3Ljk0LTEuMTggMS4zNy0yLjk0IDIuODUtMi4yIDUuMDUuODggMi42MiAzLjM2IDIuMjQgNS4zOCAyLjMzIDcuMTkuMzIgMTQuNC40NyAxOS41LjYxek0zMDAuMTcgMzYyLjNjLTEuNDYtLjA2LTEuNy4xLTIuMzkgMi4zNy0uMi42Ny0uMzYgMS4zNi0uNDkgMi4wNS0yLjQzIDEyLjk0LTQuODggMjUuODgtNy4yNSAzOC44My0uODIgNC40OS0xLjQxIDkuMDItMi4xNSAxMy41Mi0uMzggMi4zMi4xIDQuMDMgMi40NiA1LjE2IDIuMDQuOTggMy44NiAyLjQxIDUuOCAzLjYyIDEuNTYuOTcgMi4yOS43MiAyLjU4LTEuMDIuNTgtMy40Ni45NS02Ljk2IDEuNi0xMC40MSAyLjk3LTE1LjcyIDYuOTctMzEuMTggMTIuMTctNDYuMzEuNDYtMS4zMy45NC0yLjY1IDEuMjgtNC4wMS40LTEuNi0uMTEtMi40NC0xLjY1LTIuNjItMy42NS0uNDItNy4zMi0uNzMtMTEuOTYtMS4xOHpNMjYxLjE5IDY5Ljg3Yy0xLjU1LjQyLTQuNDYgMS4wNi03LjI2IDIuMDMtMi45OSAxLjAzLTMuMzcgMS42Ni0yLjg5IDQuNjUgMS44MyAxMS4yNiAzLjY4IDIyLjUxIDUuNjggMzMuNzQuNzQgNC4xNCAxLjgzIDguMjIgMi45MyAxMi4yOS41OCAyLjEzIDEuMjggMi41MSAzLjQ5IDIuMTMgMy4xMS0uNTMgNi4yMi0xLjE0IDkuMjgtMS44OSAyLjIyLS41NCAyLjU3LTEuMiAyLjIyLTMuMzktLjE5LTEuMjEtLjU1LTIuMzktLjgxLTMuNTktLjkxLTQuMjktMi04LjU1LTIuNjctMTIuODgtMS41LTkuNzEtMi43Ny0xOS40Ni00LjE1LTI5LjE5LS40NS0zLjIxLTEuMjYtMy45MS01LjgyLTMuOXptMTguNDgtMy4xOGMtLjQxLjA1LTIuMDEuMDgtMy41NC40My0zLjY3Ljg1LTQuNTEgMi4wNS00LjQxIDUuOTUuMDMgMS4wNS4wNiAyLjEyLjIyIDMuMTUgMS40NSA5LjU1IDIuODEgMTkuMTIgNC40NCAyOC42My43NyA0LjQ5IDIgOC45MSAzLjE3IDEzLjMyLjU5IDIuMjEgMS4zMiAyLjY2IDMuNDEgMi4zMyAyLjk1LS40NyA1Ljg5LTEuMDMgOC43OC0xLjc0IDIuMjMtLjU1IDIuOC0xLjY0IDIuNDItMy45MS0uMTItLjY5LS4zNy0xLjM2LS41NC0yLjA0LTIuNS0xMC4wNy00LjU1LTIwLjIyLTUuODUtMzAuNTEtLjUzLTQuMTgtMS4xMi04LjM2LTEuNzQtMTIuNTItLjQ4LTMuMTctLjc0LTMuMzMtNC4xNC0zLjIyLS4zNS4wMS0uNy4wNC0yLjIyLjEzem02LjQzIDQ2NC4xMWMwIC4yMi4wMS40NC4wMS42Ni01Ljk4IDAtMTEuOTYtLjExLTE3Ljk0LjAzLTUuMDkuMTItMTAuMTguNTEtMTUuMjcuODUtLjYxLjA0LTEuMTkuNTItMS43OC43OS4yOS42MS40NSAxLjMzLjg5IDEuOCAyLjAyIDIuMjEgNC4xOSA0LjI4IDYuMTUgNi41MiAyLjg5IDMuMzEgNi4zNiA0LjUyIDEwLjc4IDQuMTMgMTQuNzEtMS4yOCAyOS40NC0yLjI4IDQ0LjE2LTMuNDcgNC4wMy0uMzIgNC40LTEuMTIgMi44My01LjA4LS4zMi0uODItLjc4LTEuNTktMS4wMS0yLjQzLTEuMDQtMy43Mi0zLjU2LTQuNzItNy4yLTQuNTMtNy4yLjQtMTQuNDEuNTEtMjEuNjIuNzN6bTE4Mi4zLTE1OS40M2MwLS4wNi4wMS0uMTMuMDEtLjE5IDYuNDguMjkgMTIuOTcuNTkgMTkuNDUuODggMy4yNi4xNCAzLjU2LS4wOCA0LjMzLTMuMTcuNjctMi43MSAxLjA5LTUuNDIgMi41Ny03Ljk1IDIuNTUtNC4zNCAyLjA1LTUuMDUtMi45NC00Ljk3LTE1LjQ0LjI1LTMwLjg3LjU2LTQ2LjMxLjg1LS44OC4wMi0xLjc2LjA2LTIuNjMuMTctMi42Mi4zMy0zLjE1IDEuMDUtMi4zIDMuNC43OCAyLjE0IDEuNzkgNC4xOSAyLjYgNi4zMy45MyAyLjQ3IDIuNjggMy40MyA1LjI2IDMuNTUgNi42Ni4yOSAxMy4zMS43MiAxOS45NiAxLjF6TTI5Ni43OCA2NC42N2MtNS4yMS0uMi02LjMyLjkyLTUuOTIgNS45Ny4wMy4zNS4wNS43LjEgMS4wNSAxLjg0IDEzLjQgNC4wMyAyNi43MyA3LjM4IDM5Ljg1LjMgMS4xOS41MiAyLjQ3IDEuMTUgMy40OC40OS43OCAxLjYxIDEuNzggMi4zNSAxLjcgMy42NC0uMzkgNy4yNy0xLjAzIDEwLjg3LTEuNzYgMS40Mi0uMjkgMS44Mi0xLjU1IDEuNTctMi45MS0uMTYtLjg2LS40MS0xLjctLjY1LTIuNTUtMy43OS0xMy41Mi03LjU3LTI3LjA1LTExLjM4LTQwLjU2LTEuMTEtMy44Ny0xLjUtNC4xMS01LjQ3LTQuMjd6TTQ1MS40IDUwMS45MXYtLjIzYy0xLjA1IDAtMi4xMS4wMy0zLjE2IDAtNi4xNC0uMTgtMTIuMjctLjQtMTguNDEtLjU1LTItLjA1LTIuNjMuNjktMi4wNiAyLjU3Ljk2IDMuMTkgMi4xNyA2LjMgMy4xIDkuNS44IDIuNzUgMi40IDMuOTYgNS4zMyAzLjk5IDExLjU4LjExIDIzLjE2LjM2IDM0LjczLjU3IDEuNjIuMDMgMi43My0uNjEgMy0yLjI4LjU4LTMuNjQgMS4xNy03LjI3IDEuNjUtMTAuOTIuMTktMS40NC0uNjUtMi4zNi0yLjA4LTIuNTItMS4yMi0uMTQtMi40NS0uMTItMy42OC0uMTMtNi4xNC0uMDEtMTIuMjggMC0xOC40MiAwek0zMTQuOTcgNjMuOTdjLTEuNTMgMC0yLjQxLjAxLTMuMjkgMC00Ljg4LS4wNy01LjU3Ljc5LTQuNDQgNS41My4yNCAxLjAyLjQ5IDIuMDUuNzggMy4wNiAzLjIzIDExLjEyIDYuNDYgMjIuMjMgOS42OSAzMy4zNC41NCAxLjg1IDEuMDggMy43IDEuNjQgNS41NS40NyAxLjU2IDEuNTcgMi4zMyAzLjE1IDIuMjIgMS45Mi0uMTMgMy44My0uNDEgNS43NC0uNjEgMy4xOS0uMzMgMy43Ni0uNzEgMi45OC00LjUzLS40Mi0yLjA2LTEuMDUtNC4wNy0xLjU2LTYuMTItMi43Mi0xMC44OS01LjU0LTIxLjc1LTguMS0zMi42Ny0xLjM4LTUuOTYtMS4xOC02LjAxLTYuNTktNS43N3ptMTU4LjYgMzI3LjMzYzQuMzkgMCA4Ljc4LjA4IDEzLjE3LS4wMyAzLjAyLS4wOCAzLjcxLS43OSA0LjA3LTMuNjcuMjktMi4yNi41Mi00LjU0LjY5LTYuODEuMTktMi42My0uNTItMy40OS0zLjE5LTMuNjktNC43My0uMzQtOS40Ni0uNTgtMTQuMTktLjg1LTUuNDMtLjMxLTEwLjg3LS42Mi0xNi4zLS44OS0yLjYzLS4xMy01LjI2LS4yNi03LjktLjI1LTEuNzEgMC0yLjQ3Ljg4LTEuOTEgMi4yOCAxLjUgMy43NSAzLjA0IDcuNDggNC44NCAxMS4wOS40Ni45MiAxLjgxIDEuNjUgMi45IDEuOTUgMS44NC41MSAzLjguNzggNS43MS44MyA0LjAyLjEzIDguMDcuMDUgMTIuMTEuMDR6bS0yMC4yOSAxMzAuODV2LS4yNGMtNS4yNyAwLTEwLjU0LS4wNy0xNS44MS4wMy0yLjQ5LjA0LTMuMTQuOTgtMi40IDMuMzggMS4wMyAzLjM2IDIuMjggNi42NSAzLjM0IDkuOTkuNjggMi4xNSAyLjEyIDMuMDkgNC4zIDMuMTcgNC4zOS4xNiA4Ljc4LjUyIDEzLjE2LjUxIDMuODYtLjAyIDcuNzEtLjQ1IDExLjU3LS42NyAyLjA4LS4xMiAzLjItMS4xOCAzLjUtMy4yMi40Mi0yLjc4Ljk1LTUuNTQgMS4zMS04LjMyLjQxLTMuMTMtLjExLTMuODMtMy4xOS0zLjk5LTUuMjUtLjI5LTEwLjUxLS40NC0xNS43OC0uNjR6bS04My40OCAxLjEydi0uOTJjNS45NiAwIDExLjkyLjA0IDE3Ljg4LS4wMyAxLjM3LS4wMSAzLjI3LjEyIDQuMDItLjY3IDIuNjEtMi43NyA0Ljg1LTUuOSA3LjEzLTguOTcuMTMtLjE3LS41MS0xLjI2LS45OC0xLjQ0LS43OC0uMy0xLjcxLS4yNy0yLjU5LS4zLTcuMDEtLjIxLTE0LjAyLS4zOS0yMS4wMi0uNTgtMy41MS0uMS03LjAxLS4xNi0xMC41Mi0uMjktMi0uMDgtMy44Ni4yMS01LjM0IDEuNy0yLjk3IDIuOTgtNS45NCA1Ljk2LTguODQgOS0uMzkuNDItLjY5IDEuNDEtLjQ2IDEuODEuMjUuNDMgMS4xNy42NiAxLjc5LjY3IDYuMzEuMDQgMTIuNjIuMDIgMTguOTMuMDJ6bTY0Ljc0IDE3LjI4Yy0uNTQtMS45Mi0uODctMy4yOS0xLjMxLTQuNjMtNC4xMS0xMi41My04LjIzLTI1LjA2LTEyLjM3LTM3LjU4LS4zMy0xLS43NC0xLjk4LTEuMTktMi45My0uNTYtMS4xOC0xLjQzLTIuMDItMi44Mi0yLjA2LTEuNC0uMDQtMi44Mi0uMTMtNC4yMSAwLTEuOTYuMTktMi42NiAxLjI5LTIuMTggMy4zNC4zMiAxLjM3Ljc5IDIuNyAxLjI1IDQuMDMgNC43IDEzLjgyIDkuNDkgMjcuNjEgMTQuMDggNDEuNDcgMi4wOSA2LjMxIDIuOTggNC45OSA2LjcgMS4zOC45My0uOTEgMS41MS0yLjIgMi4wNS0zLjAyem0yNi43Ny02Ni44OWM0LjIyIDAgOS40OS4wNiAxNC43NS0uMDIgMy43Ny0uMDYgNC41Ni0uOCA1LjA2LTQuNTQuMzItMi40My41LTQuODkuNjUtNy4zNC4xNS0yLjUzLS40LTMuMzMtMi44Ni0zLjMzLTYuMTQgMC0xMi4yOC4xNy0xOC40Mi4zNS0yLjM3LjA3LTQuMzYgMS4wNi02LjA1IDIuNzktMy40NCAzLjUxLTcgNi45LTEwLjQ0IDEwLjQxLS41Mi41My0uNjUgMS40NC0uOTcgMi4xOC42Ni4xNCAxLjMzLjQzIDEuOTkuNCA1LjA4LS4yNSAxMC4xNi0uNTYgMTYuMjktLjl6bS00MS4zNS0zNzcuOGguMTVjMC0zLjUxLS4wNC03LjAzLjAxLTEwLjU0LjEyLTguMzQuODctNy42OC03Ljk4LTkuODctNS4wMi0xLjI0LTUuNjUtLjc1LTUuNDYgNC4zLjM0IDguOTUuODUgMTcuODkgMS4zIDI2LjgzLjMxIDYuMTMuOSA2LjcxIDcuMTMgNy4xMS4zNS4wMi43LjA1IDEuMDUuMDUgMy4wMS4wMyAzLjc2LS42NSAzLjc5LTMuNjUuMDMtNC43Ni4wMS05LjUuMDEtMTQuMjN6bS0zOS4yMSA0MDkuNTh2LjRjNC4zOCAwIDguNzYuMDMgMTMuMTQtLjAyIDEuMzktLjAxIDIuODEtLjEyIDQuMTctLjM5IDEuMTktLjI0IDEuODEtMS4xNyAxLjM2LTIuMzctLjg3LTIuMjktMS44Ny00LjUzLTIuODQtNi43OC0uOTQtMi4xNy0yLjU2LTMuMS00Ljk3LTMuMTEtOS4yOC0uMDEtMTguNTQtLjU4LTI3LjgxLjgyLTQuODMuNzMtNS40MS42NC0zLjkzIDUuODUuMTQuNTEuMzYuOTkuNDYgMS41MS41NyAyLjgzIDIuMjUgNC4xNSA1LjE4IDQuMTEgNS4wOS0uMDcgMTAuMTctLjAyIDE1LjI0LS4wMnptNDQuMTgtNDA5LjEyaC0uMjVjMCAzLjM0LS4wMSA2LjY4IDAgMTAuMDIuMDEgMS41OC0uMDMgMy4xNy4wOSA0Ljc0LjIyIDMgLjcxIDMuNjUgMy42MSA0LjMzIDEuNy40IDMuNDcuNTcgNS4yMS42OSAyLjYyLjE4IDMuMDktLjI2IDMuMjMtMi44Ni4wOS0xLjc1IDAtMy41Mi4wNy01LjI3LjI5LTcuMDIuNTctMTQuMDUuOTItMjEuMDYuMjMtNC42OS0uMTQtNS4yNS00LjktNi40MS0xLjUzLS4zNy0zLjA3LS43OC00LjYzLTEuMDEtMi42NC0uNC0zLjI4LjA1LTMuMzMgMi42MS0uMDkgNC43My0uMDIgOS40Ny0uMDIgMTQuMjJ6bTUwLjMzIDMxMy4zM3YuNjRjMy4zNCAwIDYuNjguMDkgMTAuMDItLjAzIDIuNDUtLjA5IDMuMS0uNjkgMy41Mi0zLjA2LjQzLTIuNDIuNzctNC44Ny44Ny03LjMyLjExLTIuNzEtLjY0LTMuNTItMy4zNC0zLjYtOS40OC0uMjktMTguOTctLjQ4LTI4LjQ2LS42OC0xLjE2LS4wMi0xLjU4Ljg0LTEuMTggMS43NyAxLjMxIDMuMDcgMi43OCA2LjA3IDQuMTIgOS4xMi45NyAyLjIxIDIuNjQgMy4xNyA0Ljk4IDMuMTYgMy4xNS0uMDEgNi4zMSAwIDkuNDcgMHptLTcyLjQ0LTMwMi4zN2MtLjMtOS4yNy0uNTgtMTguNTQtLjktMjcuODEtLjA0LTEuMjItLjI2LTIuNDQtLjQxLTMuNjUtLjIzLTEuOTQtMS4yMy0zLjItMy4xOS0zLjY4LTIuODktLjcxLTUuNzUtMS41Mi04LjY1LTIuMjEtMS43Ni0uNDItMi4yOS4wMy0yLjExIDEuODMuNDggNC44OC45NSA5Ljc2IDEuNjYgMTQuNjEuMTkgMS4yOC45OCAyLjY2IDEuODkgMy42MiA0Ljc1IDUuMDUgNy4xMyAxMS4xMyA4LjI4IDE3Ljg0LjI3IDEuNTgtLjM3IDMuNzkgMi4wNyA0LjI2IDEuMzEuMjQgMS43Ni0xLjEyIDEuMzYtNC44MXptMjQuMzYgODEuODN2LjI4Yy0xLjkyIDAtMy44NS0uMDktNS43Ny4wMi0zLjM4LjItNi40MSAxLjQ0LTkuMzQgMy4xNy0zLjcyIDIuMi00LjY3IDUuNDEtNC4xNSA5LjM3LjE4IDEuMzYgMSAxLjc0IDIuMjQgMS4zMy41LS4xNi45Ny0uNCAxLjQ2LS41OSA1LjgtMi4yNyAxMS43OS0zLjE4IDE4LjAxLTIuNSAzLjEzLjM0IDYuMjcuNjkgOS40MS44Ljg4LjAzIDIuMTUtLjUgMi42My0xLjIgMS4zMy0xLjk0LjY1LTUuNjctMS4xMS03LjIxLTIuMDMtMS43OC00LjQyLTIuODUtNy4xLTMuMTItMi4wOC0uMjEtNC4xOC0uMjQtNi4yOC0uMzV6bTQ4LjMgMjI2LjAxdi0uMjVjLTIuNjEtLjE3LTUuMjItLjQtNy44NC0uNDctMS42LS4wNS0yLjEzLjk0LTEuNDYgMi40My4wNy4xNi4xMy4zMy4yMy40NyAzLjQ0IDQuNDYgNi4yOCA5LjE1IDYuOCAxNC45Ny4xNCAxLjYgMS41IDIuMzUgMy4wNyAyLjIgMi4yNi0uMjEgNC41LS41NSA2Ljc1LS44NyAxLjM1LS4xOSAyLjI5LS45OCAyLjUxLTIuMzEuNzItNC4zIDEuNS04LjYgMS45OC0xMi45Mi4yNS0yLjIzLS42OC0zLjA4LTMuMTQtMy4yMS0yLjk2LS4xNi01LjkzLS4wNC04LjktLjA0ek0zMzAuMjQgODUuM2MuMzkgMS42Ny43NCAzLjM1IDEuMTggNSAxLjMxIDQuOSAyLjY1IDkuNzkgMy45OCAxNC42OSAxLjgyIDYuNzEuNzUgNy4yNiA5LjY3IDYuNzguNTItLjAzIDEuMDUtLjA5IDEuNTctLjE0IDEuNTItLjE1IDIuMDktMS4wMiAxLjk2LTIuNS0uNDMtNC44Ny0uODYtOS43NC0xLjIzLTE0LjYxLS4xMy0xLjc4LS44My0zLjAxLTIuNjYtMy4zOS01LjAzLTEuMDUtOS41NS0zLjI1LTE0LjQ3LTUuODN6bTk4LjYxIDEzMC4yNmMzLjE5LS41NyA2Ljg1LS45NyAxMC4zNS0xLjkzIDIuNjEtLjcyIDMuNDQtMi45NCAyLjcyLTUuNDItLjYtMi4wNi0yLjMzLTIuNjMtNC4wOS0yLjctNS41OS0uMjQtMTEuMTktLjQ5LTE2Ljc4LS4zNi0zLjcyLjA5LTcuMTUgMS42Mi0xMC4zNyAzLjQ3LS42LjM0LTEuMTkuNzUtMS42NiAxLjI2LS43Mi43OS0uNjIgMS43My4yOSAyLjIxIDEuMDYuNTYgMi4yNCAxLjAyIDMuNDIgMS4yMSA1LjE5Ljc4IDEwLjM5IDEuNDYgMTYuMTIgMi4yNnptLTI5LjUzIDY5LjMzYzMuNDUgMCA3LjEyLjAyIDEwLjgtLjAxIDEuMDQtLjAxIDIuMTItLjA0IDMuMTItLjMgMS42Ny0uNDQgMi4zOC0yLjI0IDEuNTctMy43OC0xLjI0LTIuMzgtMy4yOS0zLjYxLTUuOTItMy43NC0yLjgtLjEzLTUuNi0uMTYtOC40LS4xLTYuMDQuMTMtMTEuNzcgMS42LTE3LjE5IDQuMjQtMy45MyAxLjkyLTcuODIgMy45My0xMS42OSA1Ljk2LTEuNjYuODctMy4yMiAxLjkxLTQuODMgMi44NyAyLjEzIDEuMzEgMy44Mi0uNCA1LjctLjgzLjM0LS4wOC42Ny0uMjEuOTktLjM0IDguMi0zLjU0IDE2LjY4LTUuMjQgMjUuODUtMy45N3ptNTAuNDUtMTgzLjc5aC4yMlY4Ny40NmMwLTMuNDItMi4zMi00LjI4LTUuMzEtNC4zMy0uNTQtLjAxLTEuMzIuNTgtMS41OSAxLjEtLjM4Ljc0LS41MSAxLjY4LS41NCAyLjU0LS4yOCA4LjkxLS42IDE3LjgzLS43MiAyNi43NC0uMDUgMy4zNCAxLjY0IDQuNTkgNS4wMSA0LjM4IDEuNDItLjA5IDIuMzUtLjc4IDIuNjEtMi4xMi4yNC0xLjE5LjMtMi40My4zMS0zLjY1LjAzLTMuNjcuMDEtNy4zNC4wMS0xMS4wMnpNMjczLjgyIDU1MC4xN2MtLjA0LjI5LS4wNy41OC0uMTEuODcuOTguMyAxLjkzLjcxIDIuOTMuODggMi40Mi40MSA0Ljg1Ljg4IDcuMjkgMS4wMyA2LjMxLjM5IDEyLjY0LjcyIDE4Ljk2Ljg3IDUuMjcuMTMgMTAuNTUuMDggMTUuODItLjA4IDIuNS0uMDggMy43My0xLjA2IDMuNjQtMi4yNS0uMTUtMS44LTEuNzctMy45MS0zLjQyLTQuMjQtMS4wMi0uMjEtMi4xLS4yNC0zLjE1LS4yMi0zLjE2LjA2LTYuMzMuMDktOS40OC4zMi05LjI5LjY2LTE4LjU3IDEuNC0yNy44NSAyLjE1LTEuNTUuMTItMy4wOS40NC00LjYzLjY3em0xOTguNjUtOTYuNTh2LS4wN2MyLjQ2IDAgNC45MS4wNSA3LjM3LS4wMiAxLjU4LS4wNCAyLjczLS45NCAzLjAxLTIuNDcuNTgtMy4yOCAxLjA4LTYuNTcgMS40MS05Ljg4LjE3LTEuNjktLjc4LTIuNTgtMi41OS0yLjUzLTEuOTIuMDUtMy44My40NC01Ljc1LjU2LTIuMTMuMTQtMy40NCAxLjE5LTQuNTUgMy4wMi0xLjcyIDIuODQtMy43OSA1LjQ2LTUuNzIgOC4xNy0uNDEuNTctMS4wNyAxLjA1LTEuMjMgMS42Ny0uMTEuNDMuNDIgMS40Ny43IDEuNDggMi40NC4xMiA0LjkuMDcgNy4zNS4wN3ptLTI5LjEzLTI2Ny43MmMtLjczLS44MS0xLjM3LTEuODYtMi4zLTIuNDgtOC43My01LjgtMTcuODQtNS43NS0yNy4wMy0xLjMxLTEuNjIuNzgtMi43OSAyLjU0LTQuMDcgMy45My0uMjguMy0uMTYuOTctLjIyIDEuNDcuNDkuMDQgMS4wNS4yNyAxLjQ1LjExIDkuOTktNC4yNyAyMC4wNi00LjUyIDMwLjI0LS43MS4zMS4xMi43My4xNiAxLjAyLjA0LjI2LS4xMi40My0uNDkuOTEtMS4wNXptMTAuNDYgMzU3Ljg5Yy00LjItLjI2LTcuMzctLjQtMTAuNTItLjY4LTMuNDQtLjMtNS41OCAxLjctNy40NiA0LjEyLS4xOS4yNS4yMSAxLjMzLjYgMS41Ny41NC4zMyAxLjM1LjMyIDIuMDUuMzEgNi42OC0uMTUgMTMuMzYtLjM1IDIwLjA0LS40OSAyLjUtLjA1IDQuMjgtMS40NCA1LjkyLTMuMDcuMjgtLjI4LjMzLS45LjMtMS4zNS0uMDEtLjE2LS41OS0uMzktLjkxLS4zOS0zLjY4LS4wMy03LjM3LS4wMi0xMC4wMi0uMDJ6bS00Ny4wNy0yNy44OXYtLjA0Yy4zNSAwIC43LjAyIDEuMDUgMCAyLjkzLS4xOCAzLjUtLjkgMi42Ny0zLjU3LTEuNjYtNS4zNS0zLjM3LTEwLjY5LTUuMS0xNi4wMi0uMjYtLjgxLS42LTEuNzctMS4yMi0yLjIzLS41OC0uNDMtMS42My0uNDYtMi4zOC0uMjgtLjMxLjA4LS42NSAxLjI0LS41IDEuNzguMzYgMS4zNCAxLjAyIDIuNiAxLjUyIDMuOTEuODEgMi4xMiAxLjU5IDQuMjYgMi4zNyA2LjQgMS4wMiAyLjc2IDEuMzYgNS4zNS0uOSA3Ljg1LTEuMDkgMS4yMS0uMzggMi4xMiAxLjQ1IDIuMi4zNC4wMS42OSAwIDEuMDQgMHpNMzgzLjY5IDgxLjQxYy0uMjgtMy4yNC0uMzYtNS41My0uNzItNy43Ny0uMjQtMS41LS44OS0yLjkzLTEuMzYtNC4zOS0yLjE4IDEuMTUtLjcyIDMuMDgtLjk0IDQuNjMtLjMyIDIuMjUtLjQ1IDQuNTItLjU5IDYuNzktLjA4IDEuMzYgMS4xNiAyLjYyIDIuMjUgMi4xNy42MS0uMjUuOTMtMS4yMiAxLjM1LTEuODkuMDctLjEyLjAxLS4zNC4wMS40NnoiLz48cGF0aCBjbGFzcz0ic3QxIiBkPSJNMzE3LjM4IDIwNy41OGMuODcuNCAxLjQ2LjgyIDIuMS45NiA2LjM3IDEuMyAxMi43MiAyLjY2IDE5LjEyIDMuNzkgOC43MiAxLjU0IDE3LjE1LjIyIDI1LjMyLTMuMDQgMS4zMS0uNTIgMi41Ny0xLjE2IDMuODgtMS42NiAxLjk0LS43NCAzLjI0LS41MSAzLjY1LjY4LjYxIDEuNzQtLjY4IDIuNTQtMS44NiAzLjMyLS41OC4zOC0xLjIyLjctMS44Ni45OS04LjgxIDQuMDQtMTguMDQgNS45NC0yNy43MSA0Ljc5LTUuNC0uNjQtMTAuNzUtMS43My0xNi4wOC0yLjgtNC4xMy0uODMtOC4yMS0xLjkzLTEyLjI5LTIuOTktMS4xNy0uMy0yLjM1LS43NS0zLjM3LTEuMzktMS4yNi0uNzktMS40Ni0yLjY1LS4zNi0zLjY3IDEtLjk0IDIuMjItMS43IDMuNDYtMi4zMSA5LjUzLTQuNzMgMTkuNTUtNy44NSAzMC4xNS05LjAzIDcuMTYtLjggMTQuMy0yIDIxLjUxLS41NCAxLjM4LjI4IDIuNDctLjA5IDIuOTYtMS40OS41OC0xLjY5IDAtMy40LTEuMzMtNC4xNC0uMzEtLjE3LS42My0uMzItLjk2LS40Mi0xMS41LTMuMzktMjIuOTMtNC45OC0zNC42LS4zMS05LjY0IDMuODYtMTkuMjkgNy42NS0yOC4yMiAxMy4wMS0yLjA4IDEuMjUtNC4xMyAyLjc3LTUuNzEgNC41OS0zLjA1IDMuNTEtMy4xMyA3LjAzLjA4IDEwLjQxIDMuMzcgMy41NSA3LjE1IDYuNzYgMTAuOTkgOS44MiAzLjI5IDIuNjIgNi44NyA0LjkgMTAuNCA3LjIgNC42NiAzLjAzIDcuNjQgNy4xOSA4Ljg4IDEyLjU5Ljc1IDMuMjUgMS41NSA2LjUgMi4xIDkuNzkgMS4zIDcuNjYgMy40NiAxNS4wNiA2LjA3IDIyLjM1IDEuODQgNS4xMiAzLjQ2IDEwLjMzIDUuMDUgMTUuNTQuNCAxLjMxLjUxIDIuNzguMzYgNC4xNC0uMDcuNjQtLjk4IDEuNDEtMS42OCAxLjY5LS40NS4xNy0xLjQ0LS4yOS0xLjc4LS43Ni0uNi0uODItLjk1LTEuODUtMS4yOC0yLjgzLTIuMTgtNi41LTQuNDctMTIuOTYtNi40My0xOS41Mi0xLjg1LTYuMjItMy4zNi0xMi41NS00Ljk2LTE4Ljg0LS40Ny0xLjg3LS44NC0zLjc4LTEuMDktNS43LS45NS03LjI3LTQuNDItMTIuNjItMTAuODctMTYuNDctNC45NS0yLjk1LTkuMzYtNi44Mi0xMy45MS0xMC40MS0xLjc5LTEuNDEtMy4zOC0zLjA4LTQuOTMtNC43NS02LjQ5LTctNC4yNi0xMy44MiAxLjExLTE5LjAzIDEuNzQtMS42OSAzLjkxLTMgNi4wMS00LjI1IDkuNzEtNS43NiAxOS45OS0xMC4zMyAzMC42Ny0xMy45NCA4Ljg2LTMgMTcuODctMy42IDI2Ljk4LS45NyAyLjE5LjYzIDQuNDUgMS4wMyA2LjY3IDEuNTYgNS4xMiAxLjIzIDEwLjMzIDIuMTUgMTUuMDYgNC42NyAxLjY0Ljg3IDIuNTIuMDkgMi42MS0xLjg0LjExLTIuMzctLjk2LTQuMDctMi44NS01LjI5LTYuMTItMy45Ni0xMi42Mi03LjAxLTIwLTcuNTktOC44Ni0uNjktMTcuNTQuMDYtMjUuOTMgMy4zMS02LjcyIDIuNi0xMy41NiA0Ljg5LTIwLjI1IDcuNTctMy40MSAxLjM3LTYuNiAzLjI4LTkuODggNC45Ny0xLjQuNzMtMi43NCAxLjYtNC4xNyAyLjI2LTEuODMuODQtMy4xOS41OS0zLjY4LS41OS0uNjktMS42NS4yMy0yLjc5IDEuNTEtMy41MSA0LjYtMi41NyA5LjA4LTUuNDQgMTMuOTItNy40NSA3Ljk1LTMuMyAxNi4xLTYuMTIgMjQuMjMtOC45NiA4LjgxLTMuMDggMTcuOTYtMy40MiAyNy4wOS0yLjE4IDcuNDMgMS4wMSAxNC4xMiA0LjI4IDIwLjIxIDguNTkgNC44NSAzLjQzIDYuMDcgMTAuNDQgMyAxNS41NS0xLjAzIDEuNzItMi41NiAyLjMzLTQuNTEgMS40OS0xLjkzLS44My0zLjctMi4wNC01LjYxLTIuOS0yLjI1LTEuMDEtMy4xNi0uMzgtMy41IDIuMTMtLjk4IDcuMzktLjk3IDcuMzUtOC40OCA1LjkyLTIuMDYtLjM5LTQuMTktLjgxLTYuMjYtLjcyLTExLjYyLjUzLTIzLjE0IDEuNzEtMzQuMDkgNi4wMi0xLjU2LjU4LTMuMzMuOS00LjY2IDIuNTl6TTIzMS43MyAyMDUuMTRjLS42OSA3LjE1LTMuMjUgMTQuOTItMTAuMTggMTkuOTQtNC43MyAzLjQzLTUuNTQgNi45NC0zLjk1IDEyLjA3IDEuMzUgNC4zNCAyLjE2IDguODYgMy4xNyAxMy4zLjM0IDEuNSAxLjAzIDMuMTctLjc4IDQuMjItMS4yNi43My0yLjUyLS4zOS0zLjUxLTMtLjMxLS44Mi0uNjEtMS42Ni0uNzktMi41MS0xLjI2LTYuMTktMi40NS0xMi4zOS0zLjc2LTE4LjU3LS41LTIuMzYuMDgtNC4xIDIuMTctNS4zOSAxLjE5LS43NCAyLjI1LTEuNjggMy4zOC0yLjUxIDUuMDgtMy43NCA3Ljc4LTguOTggOS4xNS0xNC45NSAyLjY0LTExLjUzLTguMDEtMTkuMDItMTcuNTktMTUuNDItNC4zNyAxLjY0LTguMTUgNC4xLTExLjIgNy42Mi0zLjAxIDMuNDgtNC4yNSA3LjU5LTQuMjYgMTIuMTUgMCAxLjQxLS4yMiAyLjg5LTEuOTUgMy4zMi0xLjI2LjMxLTIuMzctMS4xMi0yLjQxLTMuNDEtLjA5LTQuODcgMS4xOC05LjQyIDQuMDQtMTMuMzggNC4zOS02LjA5IDEwLjMzLTEwLjAxIDE3LjY4LTExLjUyIDEwLjYyLTIuMjIgMjAuNzggNi4xIDIwLjc5IDE4LjA0ek0yMTcuMzkgMjcwLjEzYy4wMS0zLjM4IDIuOTUtNi4wNCA2LjY4LTYuMDQgMy43MSAwIDYuNiAyLjczIDYuNTIgNi4xNi0uMDggMy40NC0yLjk2IDYuMzctNi4zOCA2LjUtMy41NC4xNC02LjgzLTMuMDUtNi44Mi02LjYyem04LjYyLjM4Yy0uNy0uNzYtMS4xNS0xLjYxLTEuNTMtMS41OS0uNy4wNS0xLjM3LjYzLTIuMDUuOTguNC42NC43MiAxLjcxIDEuMjIgMS44MS41OC4xMiAxLjMzLS42MyAyLjM2LTEuMnpNMzkyLjA4IDEyMS4yOWMtLjM3IDMuNjQtLjY0IDYuOTctMS4wNSAxMC4yOC0uMzQgMi43Mi0xLjg4IDQuNDctNC42MyA1LjA1LS42OS4xNC0xLjM2LjM2LTIuMDUuNTEtNi43NCAxLjQ4LTguMjMuODktMTEuMDUtNS40OC0zLjY5LTguMzUtNy4yLTE2Ljc5LTEwLjYxLTI1LjI2LTEuMDQtMi41OS0xLjYxLTUuMzgtMi4yMi04LjEyLS4zLTEuMzUtLjM2LTIuOC0uMjQtNC4xOS41Mi02LjAzIDQuOTktOS4xIDEwLjc0LTcuMjkgNS4xOCAxLjYzIDkuMzcgNC43NSAxMi41OCA5LjE0IDMuOTEgNS4zNSA2LjI2IDExLjM3IDcuNTIgMTcuODIuNDkgMi41OS43IDUuMjIgMS4wMSA3LjU0em0tNC43NiAxLjQyYy0uMjEtOC41Mi0yLjI5LTE1LjctNi4zMS0yMi4yOS0yLjE0LTMuNTEtNS4wMy02LjMyLTguOC04LjA5LS45NS0uNDQtMS45NC0uODctMi45Ni0xLjA3LTIuNDYtLjQ5LTQuMTMuNzUtNC4zNiAzLjI4LS4xMSAxLjItLjA2IDIuNDkuMjUgMy42NS44MSAzLjA1IDEuNjcgNi4xIDIuNzYgOS4wNiAxLjYzIDQuNDUgMy40MiA4Ljg1IDUuMjQgMTMuMjIgMS40MSAzLjQgMi45NyA2Ljc1IDQuNDggMTAuMTEuOTcgMi4xNyAyLjggMi40MiA0Ljg2IDIuMTIgMi41NS0uMzggMy41Mi0xLjEgMy45Ni0zLjQ3LjQ1LTIuNC42Ni00Ljg2Ljg4LTYuNTJ6TTM1Mi4wMiA4NC4zNGMtMS44Mi0uMzktNC45MS0uOTktNy45Ny0xLjczLTEuMDEtLjI0LTEuOTctLjc2LTIuODgtMS4yNy05LjMyLTUuMjgtOS43MS0xNi45NS0uNzctMjIuODYgMi41NS0xLjY5IDUuMzktMi41OSA4LjQzLTIuNjUgNi4xOS0uMTEgMTEuNzggMS41OCAxNi41IDUuNzQgNS4xNCA0LjUzIDUuOTIgMTAuMyAyLjExIDE2LjAyLTMuMzYgNS4wNS04LjM5IDYuNi0xNS40MiA2Ljc1em0tMS42NS0yNC4xNWMtMS43Mi4yOC0zLjUyLjMyLTUuMTQuOS0zLjczIDEuMzMtNi4yMSAzLjk3LTYuNzUgOC0uNDMgMy4yNCAyIDcuMDUgNS44NyA4LjUyIDIuOSAxLjExIDYuMDggMS45NSA5LjE2IDIuMDQgNC4xMy4xMiA3Ljk5LTEuMiAxMC4zMi01LjEzIDIuMDEtMy4zOSAxLjczLTYuMzItMS4xNC05LjAyLTMuNDMtMy4yMi03LjM5LTUuMzMtMTIuMzItNS4zMXoiLz48L3N2Zz4='
9+ base+'sphinx'
10+ glob-ames+[~donmec-sallud 0v0]
11+==
desk/gen/publish-all.hoon+13−13
@@ -2,28 +2,28 @@
22/+ *sphinx
33:- %say
44|= $: [now=@da eny=@uv =beak]
5− [ ~]
5+ [~ ~]
66 ==
77:- %declarations
88^- (list declare:s)
9−:* :- %friends
10− ['networked subject' %group 'web+urbitgraph://group/~matwet/networked-subject' 'https://subject.network | networked subject' 'https://urbits3.ams3.digitaloceanspaces.com/sitful-hatred/2022.7.07..17.15.52-ns.png' ~['hosting' 'networking' 'urbit' 'ops']]
9+:~ :- %friends
10+ ['networked subject' %group 'web+urbitgraph://group/~matwet/networked-subject' 'https://subject.network | networked subject' ~['hosting' 'networking' 'urbit' 'ops'] 'https://urbits3.ams3.digitaloceanspaces.com/sitful-hatred/2022.7.07..17.15.52-ns.png' '']
1111 :- %friends
12− ['Hooniverse' %group 'web+urbitgraph://group/~hiddev-dannut/new-hooniverse' 'Community based Hoon learning for all levels. For discussion of Hoon specific to Uqbar, join ~hiddev-dannut/uhoon' 'https://i.imgur.com/ghThlz7.png' ~['hoon' 'dev' 'urbit' 'programming' 'education']]
12+ ['Hooniverse' %group 'web+urbitgraph://group/~hiddev-dannut/new-hooniverse' 'Community based Hoon learning for all levels. For discussion of Hoon specific to Uqbar, join ~hiddev-dannut/uhoon' ~['hoon' 'dev' 'urbit' 'programming' 'education'] 'https://i.imgur.com/ghThlz7.png' '']
1313 :- %friends
14− ['Hollow Mars Theory' %group 'web+urbitgraph://group/~rabsef-bicrym/hollow-mars-theory' 'Definitely NOT a Conspiracy Chat' '' ~['conspiracies' 'ufos' 'steel beams']]
14+ ['Hollow Mars Theory' %group 'web+urbitgraph://group/~rabsef-bicrym/hollow-mars-theory' 'Definitely NOT a Conspiracy Chat' ~['conspiracies' 'ufos' 'steel beams'] '' '']
1515 :- %friends
16− ['celestial systems' %group 'web+urbitgraph://group/~nocsyx-lassul/celestial-systems' 'A place for pilots who are building hosting providers' '' ~['hosting' 'ops' 'urbit']]
16+ ['celestial systems' %group 'web+urbitgraph://group/~nocsyx-lassul/celestial-systems' 'A place for pilots who are building hosting providers' ~['hosting' 'ops' 'urbit'] '' '']
1717 :- %friends
18− ['Structure' %group 'web+urbitgraph://group/~fabled-faster/structure' 'Urbit Structural Design and Engineering Group. Always Thinking About Mechanics.' 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2022.1.27..17.59.43-image.png' ~['design' 'ux' 'hci' 'urbit']]
18+ ['Structure' %group 'web+urbitgraph://group/~fabled-faster/structure' 'Urbit Structural Design and Engineering Group. Always Thinking About Mechanics.' ~['design' 'ux' 'hci' 'urbit'] 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2022.1.27..17.59.43-image.png' '']
1919 :- %friends
20− ['The Marketplace' %group 'web+urbitgraph://group/~tirrel/the-marketplace' 'Welcome to The Marketplace, featuring The Pit, Urbit\'s first open-outcry market!' 'https://snipboard.io/U0IYyi.jpg' ~['urbit' 'market' 'sell' 'buy']]
20+ ['The Marketplace' %group 'web+urbitgraph://group/~tirrel/the-marketplace' 'Welcome to The Marketplace, featuring The Pit, Urbit\'s first open-outcry market!' ~['urbit' 'market' 'sell' 'buy'] 'https://snipboard.io/U0IYyi.jpg' '']
2121 :- %friends
22− ['The Forge' %group 'web+urbitgraph://group/~middev/the-forge' 'pale fire computing' 'https://nyc3.digitaloceanspaces.com/archiv/littel-wolfur/2021.5.06..21.01.58-the%20forge.png' ~['dev' 'programming' 'hoon' 'urbit']]
22+ ['The Forge' %group 'web+urbitgraph://group/~middev/the-forge' 'pale fire computing' ~['dev' 'programming' 'hoon' 'urbit'] 'https://nyc3.digitaloceanspaces.com/archiv/littel-wolfur/2021.5.06..21.01.58-the%20forge.png' '']
2323 :- %friends
24− ['UFORIA' %group 'web+urbitgraph://group/~tiplec-lacnyx/ufora' 'UFO Research, Investigations, and Analysis' 'https://tiplec-lacnyx.nyc3.digitaloceanspaces.com/tiplec-lacnyx/2021.12.27..06.09.47-change1.jpg' ~['UFO' 'conspiracies' 'research']]
24+ ['UFORIA' %group 'web+urbitgraph://group/~tiplec-lacnyx/ufora' 'UFO Research, Investigations, and Analysis' ~['UFO' 'conspiracies' 'research'] 'https://tiplec-lacnyx.nyc3.digitaloceanspaces.com/tiplec-lacnyx/2021.12.27..06.09.47-change1.jpg' '']
2525 :- %friends
26− ['Urbit Community' %group 'web+urbitgraph://group/~bitbet-bolbel/urbit-community' 'World hub, help desk, meet and greet, etc.' 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2021.4.02..21.52.41-UC.png' ~['general' 'urbit' 'community' 'help']]
26+ ['Urbit Community' %group 'web+urbitgraph://group/~bitbet-bolbel/urbit-community' 'World hub, help desk, meet and greet, etc.' ~['general' 'urbit' 'community' 'help'] 'https://fabled-faster.nyc3.digitaloceanspaces.com/fabled-faster/2021.4.02..21.52.41-UC.png' '']
2727 :- %friends
28− ['Urbytes' %group 'web+urbitgraph://group/~nartes-fasrum/urbytes' 'We feed and water Mars.' '' ~['cooking' 'food' 'beverages' 'peanut butter eggs']]
29−==
28+ ['Urbytes' %group 'web+urbitgraph://group/~nartes-fasrum/urbytes' 'We feed and water Mars.' ~['cooking' 'food' 'beverages' 'peanut butter eggs'] '' '']
29+==
desk/lib/gossip.hoon+283−58
@@ -1,14 +1,10 @@
1−:: gossip: data sharing with pals
1+:: gossip: data sharing with pals (and pals of pals, etc)
2+::
3+:: v1.1.1: sneaky whisper
24::
35:: automates using /app/pals for peer & content discovery,
46:: letting the underlying agent focus on handling the data.
57::
6−:: at the time of writing, this library has not seen much real-world
7−:: use yet. it may be subject to behavioral or interface changes.
8−:: it tries its best to not generate obscene amounts of network load,
9−:: but has not yet proven itself in the real world.
10−:: for the time being, please tread carefully.
11−::
128:: usage
139::
1410:: to use this library, call its +agent arm with an initial
@@ -44,6 +40,10 @@
4440:: setting hops to 1 distributes the data to direct pals only.
4541:: setting hops to 0 prevents emission of any gossip data at all,
4642:: even during initial subscription setup.
43+:: setting pass to true causes 50% of locally-originating data to be
44+:: proxied through a random "tell" peer. note that the proxy will have
45+:: a 50% chance of proxying in turn. for low amounts of hops, data could
46+:: escape the local social graph entirely.
4747::
4848:: (we introduce the /~/etc path prefix convention to indicate paths
4949:: that are for library-specific use only.
@@ -75,6 +75,12 @@
7575:: we may want to use pokes exclusively, instead of watches/facts,
7676:: making it easier to exclude the src.bowl, include metadata, etc.
7777::
78+:: we currently only emit every rumor as a fact once at most. but we might
79+:: receive it again later, with a higher hop count. if we want to try for
80+:: maximum reach within the given hops, we should re-send if we receive a
81+:: known rumor with higher hop counter. but this may not be worth the added
82+:: complexity...
83+::
7884:: what if this was a userspace-infrastructure app instead of a wrapper?
7985:: how would ensuring installation of this app-dependency work?
8086:: it gains us... a higher chance of peers having this installed.
@@ -93,6 +99,7 @@
9399:: - if pals ever watch-nacks (it shouldn't), we try rewatching after ~m1.
94100::
95101:: - for facts produced on /~/gossip/source, we
102+:: - 50% chance to redirect into the "pass flow" (see below)
96103:: - wrap them as %gossip-rumor to send them out on /~/gossip/gossip
97104:: - for new pals matching the hear mode, we watch their /~/gossip/gossip
98105:: - for gone pals, we leave that watch
@@ -102,6 +109,16 @@
102109:: - re-emit them as facts on /~/gossip/gossip if there are hops left
103110:: - for nacks on those watches, we retry after ~m30
104111::
112+:: - other gossip instances may poke us with rumors directly
113+:: - the inner agent learns the rumor normally, as if it came from a fact
114+:: - based on randomness (50/50), we do one of two things:
115+:: - publish the rumor as a fact to our subscribers, like the above
116+:: - poke a randomly selected peer (in the "tell" set) with the rumor
117+:: - when poking, store the rumor until we receive a poke-ack
118+:: - upon receiving a positive poke-ack, delete the local datum
119+:: - upon receiving a negative poke-ack, do the 50/50 again
120+:: - if we do not receive a poke-ack within some time, do the 50/50 again
121+::
105122/- pals
106123/+ lp=pals, dbug, verb
107124::
@@ -112,6 +129,8 @@
112129 ==
113130+$ meta-0 hops=_0
114131::
132++$ hash @uv
133+::
115134+$ whos
116135 $? %anybody :: any ship discoverable through pals
117136 %targets :: any ship we've added as a pal
@@ -123,7 +142,11 @@
123142 :: (1 hop is pals only, 0 stops exposing data at all)
124143 hear=whos :: who to subscribe to
125144 tell=whos :: who to allow subscriptions from
145+ pass=? :: whether to (50/50) emit data through proxy
126146 ==
147+::
148+++ pass-timeout ~s30
149+::
127150++ invent
128151 |= =cage
129152 ^- card:agent:gall
@@ -146,23 +169,27 @@
146169 ^- $-(agent:gall agent:gall)
147170 |^ agent
148171 ::
149− +$ state-0
150− $: %0
151− manner=config :: latest config
152− memory=(set @uv) :: datums seen
153− future=(list rumor) :: rumors of unknown kinds
172+ +$ state-3
173+ $: %3
174+ manner=config :: latest config
175+ memory=(set hash) :: datums seen (by inner agent)
176+ shared=(set hash) :: datums shared (as fact)
177+ passed=(map hash [rumor @da]) :: pending relays & timeouts
178+ misses=(set ship) :: ships that won't relay
179+ future=(list rumor) :: rumors of unknown kinds
154180 ==
155181 ::
156182 +$ card card:agent:gall
157183 ::
158184 ++ helper
159− |_ [=bowl:gall state-0]
185+ |_ [=bowl:gall state-3]
160186 +* state +<+
161187 pals ~(. lp bowl)
162188 ++ en-cage
163189 |= =(cask *)
164190 ^- cage
165191 ?^ to=(~(get by grab) p.cask)
192+ ::TODO +soft or otherwise virtualize? don't want to risk crashes, right?
166193 [p.cask (u.to q.cask)]
167194 ~& [gossip+dap.bowl %no-mark p.cask]
168195 [%gossip-unknown !>(cask)]
@@ -177,6 +204,10 @@
177204 ~| [%en-rumor initial-hops=hops.manner]
178205 [%0 `meta-0`(dec hops.manner)]
179206 ::
207+ ++ en-hash
208+ |= rumor
209+ (sham data)
210+ ::
180211 ++ play-card :: en-rumor relevant facts, handle config changes
181212 |= =card
182213 ^- (quip ^card _state)
@@ -211,11 +242,68 @@
211242 ?> =(/~/gossip/source path)
212243 :: if hops is configured at 0, we don't broadcast at all.
213244 ::
214− =. memory (~(put in memory) (sham (de-cage cage.p.card)))
215− ?: =(0 hops.manner) [caz state]
216− =- [[- caz] state]
217245 =/ =rumor (en-rumor cage.p.card)
218− card(paths.p [/~/gossip/gossip]~, cage.p [%gossip-rumor !>(rumor)])
246+ =. memory (~(put in memory) (en-hash rumor))
247+ ?: =(0 hops.manner)
248+ [caz state]
249+ =^ cas state (emit-rumor rumor)
250+ [(weld cas caz) state]
251+ ::
252+ ++ emit-rumor :: gossip a rumor as-is
253+ |= =rumor
254+ ^- (quip card _state)
255+ =/ =hash (en-hash rumor)
256+ =* fact
257+ :- [%give %fact [/~/gossip/gossip]~ %gossip-rumor !>(rumor)]~
258+ %_ state
259+ passed (~(del by passed) hash)
260+ shared (~(put in shared) hash)
261+ ==
262+ :: if we don't want to proxy, always send as fact
263+ ::
264+ ?. pass.manner
265+ fact
266+ :: if we want to proxy, do so 50% of the time
267+ ::
268+ ?. =(0 (~(rad og eny.bowl) 2))
269+ fact
270+ :: if we're proxying, but there's no reasonable targets, send as fact
271+ ::
272+ =/ aides=(set ship)
273+ :: reasonable targets do not include ourselves, whoever
274+ :: caused us to want to (re)send this rumor, or ships that failed
275+ :: to proxy for us before.
276+ :: that last one is important because we need to avoid the mistake of
277+ :: occasionally sending them pokes that just linger in our outbound
278+ :: ames flows and keep retrying. (ideally we'd do those all pokes for
279+ :: a target on the same flow, but we really like storing information
280+ :: in the wire and don't want to do per-relay queues in state. we also
281+ :: don't particularly care about ordering guarantees for gossip.)
282+ :: we don't just get the set of ships with active incoming or outgoing
283+ :: subscriptions, because that constrains the set unnecessarily. for
284+ :: some configs, ships in .tell (that aren't in .hear) might be
285+ :: running the agent without actively listening to us.
286+ ::
287+ =- (~(dif in (~(del in (~(del in -) our.bowl)) src.bowl)) misses)
288+ ?- tell.manner
289+ %anybody (~(uni in (targets:pals ~.)) leeches:pals)
290+ %targets (targets:pals ~.)
291+ %mutuals (mutuals:pals ~.)
292+ ==
293+ =/ count=@ud ~(wyt in aides)
294+ ?: =(0 count)
295+ fact
296+ :: poke a randomly chosen proxy with the rumor
297+ ::
298+ =/ proxy=ship (snag (~(rad og +(eny.bowl)) count) ~(tap in aides))
299+ =/ =time (add now.bowl pass-timeout)
300+ =. passed (~(put by passed) hash [rumor time])
301+ :_ state
302+ =/ =wire /~/gossip/passed/(scot %p proxy)/(scot %uv hash)
303+ =/ =cage gossip-rumor+!>(rumor)
304+ :~ [%pass wire %agent [proxy dap.bowl] %poke cage]
305+ [%pass wire %arvo %b %wait time]
306+ ==
219307 ::
220308 ++ play-cards
221309 |= cards=(list card)
@@ -242,15 +330,18 @@
242330 [%gossip-rumor !>((en-rumor cage.p.i.cards))]
243331 $(out (snoc out i.cards), cards t.cards)
244332 ::
245− ++ resend-rumor
333+ ++ jump-rumor :: relay a rumor if we haven't yet
246334 |= =rumor
247− ^- (unit card)
335+ ^- (quip card _state)
336+ =/ =hash (en-hash rumor)
337+ ?: (~(has in shared) hash) [~ state]
248338 ?> =(%0 kind.rumor) ::NOTE should have been checked for already
249− ?~ meta=((soft ,hops=@ud) meta.rumor) ~
339+ ?~ meta=((soft ,hops=@ud) meta.rumor) [~ state]
250340 =* hops hops.u.meta
251− ?: =(0 hops) ~
341+ ?: =(0 hops) [~ state]
252342 =. meta.rumor (dec hops)
253− `[%give %fact [/~/gossip/gossip]~ %gossip-rumor !>(rumor)]
343+ :- [%give %fact [/~/gossip/gossip]~ %gossip-rumor !>(rumor)]~
344+ state(shared (~(put in shared) (en-hash rumor)))
254345 ::
255346 ++ may-watch
256347 |= who=ship
@@ -364,7 +455,7 @@
364455 ::
365456 ++ agent
366457 |= inner=agent:gall
367− =| state-0
458+ =| state-3
368459 =* state -
369460 %+ verb |
370461 %- agent:dbug
@@ -382,22 +473,70 @@
382473 =^ cards state (play-cards:up cards)
383474 [(weld watch-pals:up cards) this]
384475 ::
385− ++ on-save !>([[%gossip state] on-save:og])
476+ ++ on-save (slop !>([%gossip state]) on-save:og)
386477 ++ on-load
387478 |= ole=vase
388479 ^- (quip card _this)
389480 ?. ?=([[%gossip *] *] q.ole)
390481 =. manner init
391− =^ cards inner (on-load:og ole)
482+ =^ cards inner (on-load:og ole)
392483 =^ cards state (play-cards:up cards)
393484 [(weld watch-pals:up cards) this]
394485 ::
395− =+ !<([[%gossip old=state-0] ile=vase] ole)
396− =. state old
397− =^ cards inner (on-load:og ile)
398− =^ cards state (play-cards:up cards)
399− ::TODO for later versions, add :future retry logic as needed
400− [cards this]
486+ |^ =+ !<([%gossip old=state-any] (slot 2 ole))
487+ =/ ile=vase (slot 3 ole)
488+ =? old ?=(%0 -.old) (state-0-to-1 old)
489+ =? old ?=(%1 -.old) (state-1-to-2 old)
490+ :: before version 3, we stored the inner +on-save vase inside
491+ :: our own +on-save vase. undoing this double-vasing coincided
492+ :: with the h135 type-of-type upgrade.
493+ ::
494+ =? ile ?=(%2 -.old) (next-vase:h136 !<(vase:h136 ile))
495+ =? old ?=(%2 -.old) (state-2-to-3 old)
496+ ?> ?=(%3 -.old)
497+ =. state old
498+ =^ cards inner (on-load:og ile)
499+ =^ cards state (play-cards:up cards)
500+ ::TODO for later versions, add :future retry logic as needed
501+ [cards this]
502+ ::
503+ +$ state-any $%(state-0 state-1 state-2 state-3)
504+ ::
505+ +$ state-2
506+ $: %2
507+ manner=config
508+ memory=(set hash)
509+ shared=(set hash)
510+ passed=(map hash [rumor @da])
511+ misses=(set ship)
512+ future=(list rumor)
513+ ==
514+ ++ state-2-to-3
515+ |=(s=state-2 s(- %3))
516+ ::
517+ +$ state-1
518+ $: %1
519+ manner=config
520+ memory=(set hash)
521+ shared=(set hash)
522+ passed=(map hash [rumor @da])
523+ future=(list rumor)
524+ ==
525+ ++ state-1-to-2
526+ |= state-1
527+ [%2 manner memory shared passed ~ future]
528+ ::
529+ +$ state-0 [%0 manner=config-0 memory=(set hash) future=(list rumor)]
530+ +$ config-0 [hops=_1 hear=whos tell=whos]
531+ ++ state-0-to-1
532+ |= state-0
533+ ^- state-1
534+ [%1 (config-0-to-1 manner) memory memory ~ future]
535+ ++ config-0-to-1
536+ |= config-0
537+ ^- config
538+ [hops hear tell pass.init]
539+ --
401540 ::
402541 ++ on-watch
403542 |= =path
@@ -414,13 +553,43 @@
414553 =^ cards state (play-first-cards:up cards)
415554 [cards this]
416555 ::
556+ ++ on-poke
557+ |= [=mark =vase]
558+ ^- (quip card _this)
559+ ::TODO gossip config pokes
560+ ?. =(%gossip-rumor mark)
561+ =^ cards inner (on-poke:og +<)
562+ =^ cards state (play-cards:up cards)
563+ [cards this]
564+ ?. (want-target:up src.bowl)
565+ ~|(%gossip-rejected !!)
566+ :: we're getting pokes from them, so we should try relaying through
567+ :: them again if we stopped doing so
568+ ::NOTE this only ever cleans out .misses for ships producing content
569+ ::
570+ =. misses (~(del in misses) src.bowl)
571+ ::TODO dedupe with +on-agent %fact
572+ =+ !<(=rumor vase)
573+ =/ =hash (en-hash:up rumor)
574+ ?: (~(has in memory) hash)
575+ [~ this]
576+ ?. =(%0 kind.rumor)
577+ ~& [gossip+dap.bowl %delaying-unknown-rumor-kind kind.rumor]
578+ [~ this(future [rumor future])]
579+ =. memory (~(put in memory) hash)
580+ =/ mage=cage (en-cage:up data.rumor)
581+ =^ cards inner (on-agent:og /~/gossip/gossip %fact mage)
582+ =^ caz1 state (play-cards:up cards)
583+ =^ caz2 state (emit-rumor:up rumor)
584+ [(weld caz1 caz2) this]
585+ ::
417586 ++ on-agent
418587 |= [=wire =sign:agent:gall]
419588 ^- (quip card _this)
420589 ?. ?=([%~.~ %gossip *] wire)
421590 =^ cards inner (on-agent:og wire sign)
422591 =^ cards state (play-cards:up cards)
423− [cards this]
592+ [cards this]
424593 ::
425594 ?+ t.t.wire ~|([%gossip %unexpected-wire wire] !!)
426595 [%gossip @ ~]
@@ -433,18 +602,26 @@
433602 ?. =(%gossip-rumor mark)
434603 ~& [gossip+dap.bowl %ignoring-unexpected-fact mark=mark]
435604 [~ this]
605+ :: we're getting facts from them, so we should try relaying through
606+ :: them again if we stopped doing so
607+ ::NOTE this only ever cleans out .misses for ships in .hear
608+ ::
609+ =. misses (~(del in misses) src.bowl)
610+ ::TODO de-dupe with +on-poke
436611 =+ !<(=rumor vase)
437− =/ hash (sham data.rumor)
612+ =/ =hash (en-hash:up rumor)
438613 ?: (~(has in memory) hash)
439− [~ this]
614+ =^ cards state (jump-rumor:up rumor)
615+ [cards this]
440616 ?. =(%0 kind.rumor)
441617 ~& [gossip+dap.bowl %delaying-unknown-rumor-kind kind.rumor]
442618 [~ this(future [rumor future])]
619+ =. memory (~(put in memory) hash)
443620 =/ mage=cage (en-cage:up data.rumor)
444621 =^ cards inner (on-agent:og /~/gossip/gossip sign(cage mage))
445− =^ cards state (play-cards:up cards)
446− :_ this(memory (~(put in memory) hash))
447− (weld (drop (resend-rumor:up rumor)) cards)
622+ =^ caz1 state (play-cards:up cards)
623+ =^ caz2 state (jump-rumor:up rumor)
624+ [(weld caz1 caz2) this]
448625 ::
449626 %watch-ack
450627 :_ this
@@ -471,6 +648,32 @@
471648 ~& [gossip+dap.bowl %unexpected-poke-ack wire]
472649 [~ this]
473650 ==
651+ ::
652+ [%passed ?([@ ~] [@ @ ~])]
653+ ?. ?=(%poke-ack -.sign)
654+ ~& [gossip+dap.bowl %unexpected-sign wire -.sign]
655+ [~ this]
656+ ~| t.t.wire
657+ :: regardless of anything else: if they acked the poke, they are
658+ :: accepting of relay requests, if they nacked they're unaccepting
659+ ::
660+ =. misses
661+ ?: ?=(~ p.sign)
662+ (~(del in misses) src.bowl)
663+ (~(put in misses) src.bowl)
664+ =/ =hash
665+ ?: ?=([@ ~] t.t.t.wire)
666+ (slav %uv i.t.t.t.wire)
667+ (slav %uv i.t.t.t.t.wire)
668+ ?~ rum=(~(get by passed) hash)
669+ [~ this]
670+ ::NOTE emitting rest is cute, but doesn't actually work reliably,
671+ :: due to userspace duct shenanigans. %wake logic will have to
672+ :: be defensive...
673+ =/ rest=card [%pass wire %arvo %b %rest +.u.rum]
674+ ?~ p.sign [[rest]~ this(passed (~(del by passed) hash))]
675+ =^ caz state (emit-rumor:up -.u.rum)
676+ [[rest caz] this]
474677 ::
475678 [%pals @ ~]
476679 ?- -.sign
@@ -542,6 +745,14 @@
542745 ++ on-peek
543746 |= =path
544747 ^- (unit (unit cage))
748+ ?: =(/x/whey path)
749+ :+ ~ ~
750+ :- %mass
751+ !> ^- (list mass)
752+ :- %gossip^&+state
753+ =/ dat (on-peek:og path)
754+ ?: ?=(?(~ [~ ~]) dat) ~
755+ (fall ((soft (list mass)) q.q.u.u.dat) ~)
545756 ?: =(/x/dbug/state path)
546757 ``noun+(slop on-save:og !>(gossip=state))
547758 ?. ?=([@ %~.~ %gossip *] path)
@@ -560,14 +771,6 @@
560771 =^ cards state (play-cards:up cards)
561772 [cards this]
562773 ::
563− ++ on-poke
564− |= cage
565− ^- (quip card _this)
566− ::TODO gossip config pokes?
567− =^ cards inner (on-poke:og +<)
568− =^ cards state (play-cards:up cards)
569− [cards this]
570− ::
571774 ++ on-arvo
572775 |= [=wire sign=sign-arvo:agent:gall]
573776 ^- (quip card _this)
@@ -575,19 +778,41 @@
575778 =^ cards inner (on-arvo:og wire sign)
576779 =^ cards state (play-cards:up cards)
577780 [cards this]
578− ?> ?=([%retry *] t.t.wire)
579− ?> ?=(%wake +<.sign)
580− ?+ t.t.t.wire ~|(wire !!)
581− [%pals *]
582− ::NOTE this might result in subscription wire re-use,
583− :: but if we hit this path we should be loud anyway.
584− [watch-pals:up this]
781+ ?+ t.t.wire ~|(wire !!)
782+ [%passed ?([@ ~] [@ @ ~])]
783+ =/ =hash
784+ ?: ?=([@ ~] t.t.t.wire)
785+ (slav %uv i.t.t.t.wire)
786+ (slav %uv i.t.t.t.t.wire)
787+ ?~ rum=(~(get by passed) hash) [~ this]
788+ :: since timer cancellation isn't fully reliable, this timer fire
789+ :: might be from a relay that nacked our poke. if the timer fired
790+ :: earlier than expected, assume that to be the case and no-op.
791+ ::
792+ ?: (gth +.u.rum now.bowl) [~ this]
793+ :: they failed to respond to our relay request in time, mark them as
794+ :: unaccepting (if we know who they are from a the new-style wire)
795+ :: and try sending the rumor again
796+ ::
797+ =? misses ?=([@ @ ~] t.t.t.wire)
798+ (~(put in misses) (slav %p i.t.t.t.wire))
799+ =^ cards state (emit-rumor:up -.u.rum)
800+ [cards this]
585801 ::
586− [%watch @ ~]
587− :_ this
588− =/ target=ship (slav %p i.t.t.t.t.wire)
589− ?. (want-target:up target) ~
590− (watch-target:up target)
802+ [%retry *]
803+ ?> ?=(%wake +<.sign)
804+ ?+ t.t.t.wire ~|(wire !!)
805+ [%pals *]
806+ ::NOTE this might result in subscription wire re-use,
807+ :: but if we hit this path we should be loud anyway.
808+ [watch-pals:up this]
809+ ::
810+ [%watch @ ~]
811+ :_ this
812+ =/ target=ship (slav %p i.t.t.t.t.wire)
813+ ?. (want-target:up target) ~
814+ (watch-target:up target)
815+ ==
591816 ==
592817 ::
593818 ++ on-fail
@@ -598,4 +823,4 @@
598823 [cards this]
599824 --
600825 --
601−--
826+--
desk/lib/pals.hoon+8−2
@@ -1,6 +1,9 @@
11:: pals: manual peer discovery
22::
3+/- *pals
4+::
35|_ bowl:gall
6+++ records (s ^records /)
47++ leeches (s (set ship) /leeches)
58++ targets |= list=@ta (s (set ship) %targets ?~(list / /[list]))
69++ mutuals |= list=@ta (s (set ship) %mutuals ?~(list / /[list]))
@@ -8,8 +11,11 @@
811++ target |= [list=@ta =ship] (s _| /mutuals/[list]/(scot %p ship))
912++ mutual |= [list=@ta =ship] (s _| /mutuals/[list]/(scot %p ship))
1013::
11−++ base ~+ /(scot %p our)/pals/(scot %da now)
12−++ running ~+ .^(? %gu base)
14+++ labels ?. running `(set @ta)`~
15+ ~(key by dir:.^(arch %gy (snoc base %targets)))
16+::
17+++ base ~+ `path`/(scot %p our)/pals/(scot %da now)
18+++ running ~+ .^(? %gu (snoc base %$))
1319::
1420++ s
1521 |* [=mold =path] ~+
desk/lib/sphinx-json.hoon+6−4
@@ -26,10 +26,12 @@
2626 |= =search:s
2727 %- pairs
2828 :~ listings/a/(turn listings.search listing)
29− start/n/(scot %ud start.search)
30− limit/n/(scot %ud limit.search)
31− size/n/(scot %ud size.search)
32− total/n/(scot %ud total.search)
29+ :: numb, not (scot %ud): 1.234 would be read as a fraction
30+ ::
31+ start/(numb start.search)
32+ limit/(numb limit.search)
33+ size/(numb size.search)
34+ total/(numb total.search)
3335 ==
3436 ++ directory
3537 |= d=directory:s
desk/lib/sphinx.hoon+30−18
@@ -47,13 +47,25 @@
4747 %+ turn
4848 entries
4949 |=(=entry hash.entry)
50+:: +lengths-valid: bounds on what a peer can send, checked before
51+:: anything is indexed
52+::
53+:: source and time aren't in the digest, so a peer picks them too:
54+:: +time:enjs crashes before 1970, and rendering a huge atom is
55+:: quadratic
56+::
5057++ lengths-valid
5158 |= l=listing
52− ?& (lte (lent (trip title.post.l)) 77)
53− (lte (lent (trip description.post.l)) 256)
59+ ?& (lte (met 3 title.post.l) 77)
60+ (lte (met 3 description.post.l) 256)
5461 (lte (lent tags.post.l) 8)
55− (lte (lent (trip link.post.l)) 1.024)
56− (lte (lent (trip image.post.l)) 1.024)
62+ (levy tags.post.l |=(tag=@t (lte (met 3 tag) 1.024)))
63+ (lte (met 3 link.post.l) 1.024)
64+ (lte (met 3 image.post.l) 1.024)
65+ (lte (met 3 color.post.l) 1.024)
66+ (lte (met 3 source.l) 16)
67+ (gte time.l ~1970.1.1)
68+ (lth time.l ~275000.1.1)
5769 ==
5870++ urls-valid
5971 |= l=listing
@@ -64,13 +76,9 @@
6476 &(!=(image ~) !=(link ~))
6577++ listing-valid
6678 |= l=listing
67− =/ lengths (lengths-valid l)
68− =/ urls (urls-valid l)
69− :: ~& title.post.l
70− :: ~& hash.l
71− :: ~& ['urls' ?:(urls 'valid' 'invalid')]
72− :: ~& ['lengths' ?:(lengths 'valid' 'invalid')]
73− &(lengths urls)
79+ :: lengths first: don't parse urls of oversized listings
80+ ::
81+ &((lengths-valid l) (urls-valid l))
7482++ delver
7583 |_ [=lookup =trigrams =phonetics =trail]
7684 ++ get
@@ -88,8 +96,11 @@
8896 (sift:sift description.post.l)
8997 |= word=@t
9098 [word ~[[hash rank=8]]]
99+ :: +iching is quadratic in the length of a key, so long
100+ :: tags aren't indexed
101+ ::
91102 %+ turn
92− tags.post.l
103+ (skim tags.post.l |=(tag=@t (lte (met 3 tag) 64)))
93104 |= tag=@t
94105 [(norm:sift tag) ~[[hash rank=12]]]
95106 %+ turn
@@ -100,12 +111,13 @@
100111 ==
101112 =/ keys ~(tap in ~(key by entries))
102113 =. trail (~(put by trail) hash keys)
103− =. lookup
104− %- ~(uni by entries)
105− %- ~(rut by lookup)
106− |= [=key value=(list entry)]
107− ?. (~(has by entries) key) value
108− (weld value (~(got by entries) key))
114+ :: only touch the new keys: rebuilding the whole map for every
115+ :: listing made cataloging a directory quadratic
116+ ::
117+ =. lookup
118+ %+ roll ~(tap by entries)
119+ |= [[=key value=(list entry)] lok=_lookup]
120+ (~(put by lok) key (weld value (~(gut by lok) key ~)))
109121 =. trigrams
110122 %+ roll
111123 %+ snoc
desk/sys.kelvin+1−2
@@ -1,2 +1 @@
1−[%zuse 417]
2−[%zuse 416]
1+[%zuse 408]
flake.locknew file+63
@@ -0,0 +1,63 @@
1+{
2+ "nodes": {
3+ "landscape": {
4+ "flake": false,
5+ "locked": {
6+ "lastModified": 1781809440,
7+ "narHash": "sha256-woNZKzK8xlBxnMMrQOzInA7+gGxs7BgjGbmTi7OGi6g=",
8+ "owner": "tloncorp",
9+ "repo": "landscape",
10+ "rev": "3ea2679f2003ed3ca1a6b322cd7857a774eac305",
11+ "type": "github"
12+ },
13+ "original": {
14+ "owner": "tloncorp",
15+ "repo": "landscape",
16+ "rev": "3ea2679f2003ed3ca1a6b322cd7857a774eac305",
17+ "type": "github"
18+ }
19+ },
20+ "nixpkgs": {
21+ "locked": {
22+ "lastModified": 1792210252,
23+ "narHash": "sha256-Mh0PUaIv4nFY+8wAGonaEh8iBzwsmdNlwjVHmFg1Biw=",
24+ "owner": "NixOS",
25+ "repo": "nixpkgs",
26+ "rev": "09063bd1f2b36fd9fd793c36dfade7bd40a93dfc",
27+ "type": "github"
28+ },
29+ "original": {
30+ "owner": "NixOS",
31+ "ref": "nixos-unstable",
32+ "repo": "nixpkgs",
33+ "type": "github"
34+ }
35+ },
36+ "root": {
37+ "inputs": {
38+ "landscape": "landscape",
39+ "nixpkgs": "nixpkgs",
40+ "urbit": "urbit"
41+ }
42+ },
43+ "urbit": {
44+ "flake": false,
45+ "locked": {
46+ "lastModified": 1785851358,
47+ "narHash": "sha256-eHoGwqirk7zMpOn/y8dKqozz5IiQ7nqek/ypxIOaRiU=",
48+ "owner": "urbit",
49+ "repo": "urbit",
50+ "rev": "5a187fededc4582a34fcd6055c67bb63e0917b94",
51+ "type": "github"
52+ },
53+ "original": {
54+ "owner": "urbit",
55+ "ref": "408k-2",
56+ "repo": "urbit",
57+ "type": "github"
58+ }
59+ }
60+ },
61+ "root": "root",
62+ "version": 7
63+}
flake.nixnew file+98
@@ -0,0 +1,98 @@
1+{
2+ description = "sphinx desk";
3+
4+ inputs = {
5+ nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6+
7+ # Dependency desks. Keep them on releases for the kelvin in
8+ # desk/sys.kelvin ([%zuse 408]) and bump them together with it.
9+ urbit = {
10+ # release tag for [%zuse 408]
11+ url = "github:urbit/urbit/408k-2";
12+ flake = false;
13+ };
14+ landscape = {
15+ # No 408 tag: the staging head Tlon promoted on 2026-06-18, whose
16+ # desk/sys.kelvin lists [%zuse 410] [%zuse 409] [%zuse 408].
17+ url = "github:tloncorp/landscape/3ea2679f2003ed3ca1a6b322cd7857a774eac305";
18+ flake = false;
19+ };
20+ };
21+
22+ outputs =
23+ {
24+ self,
25+ nixpkgs,
26+ urbit,
27+ landscape,
28+ }:
29+ let
30+ forAllSystems = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
31+
32+ # Only what ./desk needs from the dependency desks: its imports,
33+ # transitively, and the marks for its file types plus json and mime.
34+ # 408 clay builds every mark on the desk at each commit and OTA, and one
35+ # that fails to build rejects the whole event, so extra marks are a
36+ # liability.
37+ basePicks = [
38+ "lib/dbug.hoon"
39+ "lib/default-agent.hoon"
40+ "lib/skeleton.hoon"
41+ "lib/strand.hoon"
42+ "lib/strandio.hoon"
43+ "lib/verb.hoon"
44+ "sur/spider.hoon"
45+ "sur/verb.hoon"
46+ "mar/bill.hoon"
47+ "mar/hoon.hoon"
48+ "mar/json.hoon"
49+ "mar/kelvin.hoon"
50+ "mar/mime.hoon"
51+ "mar/noun.hoon"
52+ "mar/txt.hoon"
53+ ];
54+ # desk.docket-0 needs mar/docket-0, which imports lib/docket and
55+ # sur/docket.
56+ docketPicks = [
57+ "lib/docket.hoon"
58+ "mar/docket-0.hoon"
59+ "sur/docket.hoon"
60+ ];
61+ in
62+ {
63+ packages = forAllSystems (
64+ system:
65+ let
66+ pkgs = nixpkgs.legacyPackages.${system};
67+ in
68+ {
69+ # The picks, then ./desk on top. -L copies symlink targets, so the
70+ # desk is plain files.
71+ desk = pkgs.runCommandLocal "sphinx-desk" { } ''
72+ mkdir $out
73+ for f in ${toString basePicks}; do
74+ install -Dm644 ${urbit}/pkg/base-dev/$f $out/$f
75+ done
76+ for f in ${toString docketPicks}; do
77+ install -Dm644 ${landscape}/desk-dev/$f $out/$f
78+ done
79+ cp -RL ${./desk}/. $out
80+ '';
81+ # The UI files, served under /apps/sphinx/. Upload this directory at
82+ # /docket/upload on the ship in desk/desk.docket-0's glob-ames.
83+ glob = pkgs.buildNpmPackage {
84+ name = "sphinx-glob";
85+ src = ./ui;
86+ nodejs = pkgs.nodejs_22;
87+ npmDepsHash = "sha256-uOUDkt0spCWdlOfto5Y3o5R9PP6oeYmGMcwUjEdzC/M=";
88+ installPhase = ''
89+ runHook preInstall
90+ cp -R dist $out
91+ runHook postInstall
92+ '';
93+ };
94+ default = self.packages.${system}.desk;
95+ }
96+ );
97+ };
98+}
ui/.nvmrcnew file+1
@@ -0,0 +1 @@
1+22
ui/index.html+1−1
@@ -8,7 +8,7 @@
88 <link rel="icon" href="/src/assets/favicon.svg" sizes="any" type="image/svg+xml" />
99 <link rel="mask-icon" href="/src/assets/safari-pinned-tab.svg" color="#000000" />
1010 <link rel="apple-touch-icon" sizes="180x180" href="/src/assets/apple-touch-icon.png" />
11− <link rel="manifest" href="/src/assets/manifest.json" />
11+ <link rel="manifest" href="/manifest.json" />
1212 </head>
1313 <body class="font-sans text-mauve bg-linen antialiased">
1414 <div id="app"></div>
ui/package-lock.json+83−361
@@ -12,19 +12,21 @@
1212 "@radix-ui/react-dropdown-menu": "^0.1.6",
1313 "@radix-ui/react-select": "^0.1.1",
1414 "@radix-ui/react-toggle-group": "^0.1.5",
15− "@urbit/api": "^2.0.0",
15+ "@tanstack/react-query": "^4.29.5",
16+ "@urbit/api": "2.2",
1617 "@urbit/http-api": "^2.0.0",
1718 "big-integer": "^1.6.51",
1819 "classnames": "^2.3.1",
20+ "color2k": "^2.0.2",
1921 "lodash.debounce": "^4.0.8",
2022 "query-string": "^7.1.1",
2123 "react": "^17.0.2",
2224 "react-dom": "^17.0.2",
2325 "react-hook-form": "^7.33.1",
24− "react-query": "^3.39.1",
2526 "react-router-dom": "^6.3.0",
2627 "react-select": "^5.4.0",
2728 "remove-markdown-and-html": "^0.1.0",
29+ "urbit-ob": "^5.0.1",
2830 "zustand": "^4.0.0-rc.1"
2931 },
3032 "devDependencies": {
@@ -1068,6 +1070,49 @@
10681070 "node": ">= 8.0.0"
10691071 }
10701072 },
1073+ "node_modules/@tanstack/query-core": {
1074+ "version": "4.29.5",
1075+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.29.5.tgz",
1076+ "integrity": "sha512-xXIiyQ/4r9KfaJ3k6kejqcaqFXXBTzN2aOJ5H1J6aTJE9hl/nbgAdfF6oiIu0CD5xowejJEJ6bBg8TO7BN4NuQ==",
1077+ "funding": {
1078+ "type": "github",
1079+ "url": "https://github.com/sponsors/tannerlinsley"
1080+ }
1081+ },
1082+ "node_modules/@tanstack/react-query": {
1083+ "version": "4.29.5",
1084+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.29.5.tgz",
1085+ "integrity": "sha512-F87cibC3s3eG0Q90g2O+hqntpCrudKFnR8P24qkH9uccEhXErnJxBC/AAI4cJRV2bfMO8IeGZQYf3WyYgmSg0w==",
1086+ "dependencies": {
1087+ "@tanstack/query-core": "4.29.5",
1088+ "use-sync-external-store": "^1.2.0"
1089+ },
1090+ "funding": {
1091+ "type": "github",
1092+ "url": "https://github.com/sponsors/tannerlinsley"
1093+ },
1094+ "peerDependencies": {
1095+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
1096+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
1097+ "react-native": "*"
1098+ },
1099+ "peerDependenciesMeta": {
1100+ "react-dom": {
1101+ "optional": true
1102+ },
1103+ "react-native": {
1104+ "optional": true
1105+ }
1106+ }
1107+ },
1108+ "node_modules/@tanstack/react-query/node_modules/use-sync-external-store": {
1109+ "version": "1.2.0",
1110+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
1111+ "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
1112+ "peerDependencies": {
1113+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
1114+ }
1115+ },
10711116 "node_modules/@types/lodash": {
10721117 "version": "4.14.182",
10731118 "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
@@ -1126,9 +1171,9 @@
11261171 "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
11271172 },
11281173 "node_modules/@urbit/api": {
1129− "version": "2.1.1",
1130− "resolved": "https://registry.npmjs.org/@urbit/api/-/api-2.1.1.tgz",
1131− "integrity": "sha512-QRlqhtJ73q+pgMdSwuOO62HlxA7/2c5ylCcOUT01LXkJ2LTVCl5u+QnejdDvUmqjOuN2PyZk7df30xJVg6rC2A==",
1174+ "version": "2.2.0",
1175+ "resolved": "https://registry.npmjs.org/@urbit/api/-/api-2.2.0.tgz",
1176+ "integrity": "sha512-W8kP9OT6yOK62n+4yCPO3i9QqU5xriLvZQ9WYW4SAV7ktbSrGuf2kmYbnoqfA/NybIs9Q/MbFkPewrz4XJ96Ag==",
11321177 "dependencies": {
11331178 "@babel/runtime": "^7.16.0",
11341179 "big-integer": "^1.6.48",
@@ -1291,11 +1336,6 @@
12911336 "resolve": "^1.12.0"
12921337 }
12931338 },
1294− "node_modules/balanced-match": {
1295− "version": "1.0.2",
1296− "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
1297− "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
1298− },
12991339 "node_modules/big-integer": {
13001340 "version": "1.6.51",
13011341 "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
@@ -1318,15 +1358,6 @@
13181358 "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
13191359 "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA=="
13201360 },
1321− "node_modules/brace-expansion": {
1322− "version": "1.1.11",
1323− "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
1324− "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
1325− "dependencies": {
1326− "balanced-match": "^1.0.0",
1327− "concat-map": "0.0.1"
1328− }
1329− },
13301361 "node_modules/braces": {
13311362 "version": "3.0.2",
13321363 "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
@@ -1339,21 +1370,6 @@
13391370 "node": ">=8"
13401371 }
13411372 },
1342− "node_modules/broadcast-channel": {
1343− "version": "3.7.0",
1344− "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
1345− "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
1346− "dependencies": {
1347− "@babel/runtime": "^7.7.2",
1348− "detect-node": "^2.1.0",
1349− "js-sha3": "0.8.0",
1350− "microseconds": "0.2.0",
1351− "nano-time": "1.0.0",
1352− "oblivious-set": "1.0.0",
1353− "rimraf": "3.0.2",
1354− "unload": "2.2.0"
1355− }
1356− },
13571373 "node_modules/browser-or-node": {
13581374 "version": "1.3.0",
13591375 "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-1.3.0.tgz",
@@ -1488,10 +1504,10 @@
14881504 "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
14891505 "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
14901506 },
1491− "node_modules/concat-map": {
1492− "version": "0.0.1",
1493− "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
1494− "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
1507+ "node_modules/color2k": {
1508+ "version": "2.0.2",
1509+ "resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.2.tgz",
1510+ "integrity": "sha512-kJhwH5nAwb34tmyuqq/lgjEKzlFXn1U99NlnB6Ws4qVaERcRUYeYP1cBw6BJ4vxaWStAUEef4WMr7WjOCnBt8w=="
14951511 },
14961512 "node_modules/connect-history-api-fallback": {
14971513 "version": "1.6.0",
@@ -1582,11 +1598,6 @@
15821598 "integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
15831599 "dev": true
15841600 },
1585− "node_modules/detect-node": {
1586− "version": "2.1.0",
1587− "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
1588− "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="
1589− },
15901601 "node_modules/detect-node-es": {
15911602 "version": "1.1.0",
15921603 "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@@ -2095,11 +2106,6 @@
20952106 "url": "https://www.patreon.com/infusion"
20962107 }
20972108 },
2098− "node_modules/fs.realpath": {
2099− "version": "1.0.0",
2100− "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
2101− "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
2102− },
21032109 "node_modules/fsevents": {
21042110 "version": "2.3.2",
21052111 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -2135,25 +2141,6 @@
21352141 "node": ">=6"
21362142 }
21372143 },
2138− "node_modules/glob": {
2139− "version": "7.2.3",
2140− "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
2141− "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
2142− "dependencies": {
2143− "fs.realpath": "^1.0.0",
2144− "inflight": "^1.0.4",
2145− "inherits": "2",
2146− "minimatch": "^3.1.1",
2147− "once": "^1.3.0",
2148− "path-is-absolute": "^1.0.0"
2149− },
2150− "engines": {
2151− "node": "*"
2152− },
2153− "funding": {
2154− "url": "https://github.com/sponsors/isaacs"
2155− }
2156− },
21572144 "node_modules/glob-parent": {
21582145 "version": "6.0.2",
21592146 "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2233,20 +2220,6 @@
22332220 "url": "https://github.com/sponsors/sindresorhus"
22342221 }
22352222 },
2236− "node_modules/inflight": {
2237− "version": "1.0.6",
2238− "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
2239− "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
2240− "dependencies": {
2241− "once": "^1.3.0",
2242− "wrappy": "1"
2243− }
2244− },
2245− "node_modules/inherits": {
2246− "version": "2.0.4",
2247− "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
2248− "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
2249− },
22502223 "node_modules/invariant": {
22512224 "version": "2.2.4",
22522225 "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -2313,11 +2286,6 @@
23132286 "node": ">=0.12.0"
23142287 }
23152288 },
2316− "node_modules/js-sha3": {
2317− "version": "0.8.0",
2318− "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
2319− "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="
2320− },
23212289 "node_modules/js-tokens": {
23222290 "version": "4.0.0",
23232291 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2390,15 +2358,6 @@
23902358 "loose-envify": "cli.js"
23912359 }
23922360 },
2393− "node_modules/match-sorter": {
2394− "version": "6.3.1",
2395− "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz",
2396− "integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==",
2397− "dependencies": {
2398− "@babel/runtime": "^7.12.5",
2399− "remove-accents": "0.4.2"
2400− }
2401− },
24022361 "node_modules/memoize-one": {
24032362 "version": "5.2.1",
24042363 "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -2426,22 +2385,6 @@
24262385 "node": ">=8.6"
24272386 }
24282387 },
2429− "node_modules/microseconds": {
2430− "version": "0.2.0",
2431− "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
2432− "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA=="
2433− },
2434− "node_modules/minimatch": {
2435− "version": "3.1.2",
2436− "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
2437− "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
2438− "dependencies": {
2439− "brace-expansion": "^1.1.7"
2440− },
2441− "engines": {
2442− "node": "*"
2443− }
2444− },
24452388 "node_modules/minimist": {
24462389 "version": "1.2.6",
24472390 "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
@@ -2453,14 +2396,6 @@
24532396 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
24542397 "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
24552398 },
2456− "node_modules/nano-time": {
2457− "version": "1.0.0",
2458− "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
2459− "integrity": "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==",
2460− "dependencies": {
2461− "big-integer": "^1.6.16"
2462− }
2463− },
24642399 "node_modules/nanoid": {
24652400 "version": "3.3.4",
24662401 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
@@ -2513,19 +2448,6 @@
25132448 "node": ">= 6"
25142449 }
25152450 },
2516− "node_modules/oblivious-set": {
2517− "version": "1.0.0",
2518− "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
2519− "integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw=="
2520− },
2521− "node_modules/once": {
2522− "version": "1.4.0",
2523− "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
2524− "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
2525− "dependencies": {
2526− "wrappy": "1"
2527− }
2528− },
25292451 "node_modules/parent-module": {
25302452 "version": "1.0.1",
25312453 "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -2554,14 +2476,6 @@
25542476 "url": "https://github.com/sponsors/sindresorhus"
25552477 }
25562478 },
2557− "node_modules/path-is-absolute": {
2558− "version": "1.0.1",
2559− "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
2560− "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
2561− "engines": {
2562− "node": ">=0.10.0"
2563− }
2564− },
25652479 "node_modules/path-parse": {
25662480 "version": "1.0.7",
25672481 "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -2832,31 +2746,6 @@
28322746 "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
28332747 "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
28342748 },
2835− "node_modules/react-query": {
2836− "version": "3.39.1",
2837− "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.1.tgz",
2838− "integrity": "sha512-qYKT1bavdDiQZbngWZyPotlBVzcBjDYEJg5RQLBa++5Ix5jjfbEYJmHSZRZD+USVHUSvl/ey9Hu+QfF1QAK80A==",
2839− "dependencies": {
2840− "@babel/runtime": "^7.5.5",
2841− "broadcast-channel": "^3.4.1",
2842− "match-sorter": "^6.0.2"
2843− },
2844− "funding": {
2845− "type": "github",
2846− "url": "https://github.com/sponsors/tannerlinsley"
2847− },
2848− "peerDependencies": {
2849− "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
2850− },
2851− "peerDependenciesMeta": {
2852− "react-dom": {
2853− "optional": true
2854− },
2855− "react-native": {
2856− "optional": true
2857− }
2858− }
2859− },
28602749 "node_modules/react-refresh": {
28612750 "version": "0.10.0",
28622751 "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.10.0.tgz",
@@ -3031,11 +2920,6 @@
30312920 "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
30322921 "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
30332922 },
3034− "node_modules/remove-accents": {
3035− "version": "0.4.2",
3036− "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
3037− "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA=="
3038− },
30392923 "node_modules/remove-markdown-and-html": {
30402924 "version": "0.1.0",
30412925 "resolved": "https://registry.npmjs.org/remove-markdown-and-html/-/remove-markdown-and-html-0.1.0.tgz",
@@ -3075,20 +2959,6 @@
30752959 "node": ">=0.10.0"
30762960 }
30772961 },
3078− "node_modules/rimraf": {
3079− "version": "3.0.2",
3080− "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
3081− "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
3082− "dependencies": {
3083− "glob": "^7.1.3"
3084− },
3085− "bin": {
3086− "rimraf": "bin.js"
3087− },
3088− "funding": {
3089− "url": "https://github.com/sponsors/isaacs"
3090− }
3091− },
30922962 "node_modules/rollup": {
30932963 "version": "2.75.7",
30942964 "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.75.7.tgz",
@@ -3302,15 +3172,6 @@
33023172 "node": ">=4.2.0"
33033173 }
33043174 },
3305− "node_modules/unload": {
3306− "version": "2.2.0",
3307− "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
3308− "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
3309− "dependencies": {
3310− "@babel/runtime": "^7.6.2",
3311− "detect-node": "^2.0.4"
3312− }
3313− },
33143175 "node_modules/update-browserslist-db": {
33153176 "version": "1.0.4",
33163177 "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.4.tgz",
@@ -3475,11 +3336,6 @@
34753336 "vite": "^2.0.2"
34763337 }
34773338 },
3478− "node_modules/wrappy": {
3479− "version": "1.0.2",
3480− "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
3481− "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
3482− },
34833339 "node_modules/xtend": {
34843340 "version": "4.0.2",
34853341 "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -4314,6 +4170,28 @@
43144170 "picomatch": "^2.2.2"
43154171 }
43164172 },
4173+ "@tanstack/query-core": {
4174+ "version": "4.29.5",
4175+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-4.29.5.tgz",
4176+ "integrity": "sha512-xXIiyQ/4r9KfaJ3k6kejqcaqFXXBTzN2aOJ5H1J6aTJE9hl/nbgAdfF6oiIu0CD5xowejJEJ6bBg8TO7BN4NuQ=="
4177+ },
4178+ "@tanstack/react-query": {
4179+ "version": "4.29.5",
4180+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-4.29.5.tgz",
4181+ "integrity": "sha512-F87cibC3s3eG0Q90g2O+hqntpCrudKFnR8P24qkH9uccEhXErnJxBC/AAI4cJRV2bfMO8IeGZQYf3WyYgmSg0w==",
4182+ "requires": {
4183+ "@tanstack/query-core": "4.29.5",
4184+ "use-sync-external-store": "^1.2.0"
4185+ },
4186+ "dependencies": {
4187+ "use-sync-external-store": {
4188+ "version": "1.2.0",
4189+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
4190+ "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
4191+ "requires": {}
4192+ }
4193+ }
4194+ },
43174195 "@types/lodash": {
43184196 "version": "4.14.182",
43194197 "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.182.tgz",
@@ -4372,9 +4250,9 @@
43724250 "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
43734251 },
43744252 "@urbit/api": {
4375− "version": "2.1.1",
4376− "resolved": "https://registry.npmjs.org/@urbit/api/-/api-2.1.1.tgz",
4377− "integrity": "sha512-QRlqhtJ73q+pgMdSwuOO62HlxA7/2c5ylCcOUT01LXkJ2LTVCl5u+QnejdDvUmqjOuN2PyZk7df30xJVg6rC2A==",
4253+ "version": "2.2.0",
4254+ "resolved": "https://registry.npmjs.org/@urbit/api/-/api-2.2.0.tgz",
4255+ "integrity": "sha512-W8kP9OT6yOK62n+4yCPO3i9QqU5xriLvZQ9WYW4SAV7ktbSrGuf2kmYbnoqfA/NybIs9Q/MbFkPewrz4XJ96Ag==",
43784256 "requires": {
43794257 "@babel/runtime": "^7.16.0",
43804258 "big-integer": "^1.6.48",
@@ -4496,11 +4374,6 @@
44964374 "resolve": "^1.12.0"
44974375 }
44984376 },
4499− "balanced-match": {
4500− "version": "1.0.2",
4501− "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
4502− "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
4503− },
45044377 "big-integer": {
45054378 "version": "1.6.51",
45064379 "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
@@ -4517,15 +4390,6 @@
45174390 "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
45184391 "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA=="
45194392 },
4520− "brace-expansion": {
4521− "version": "1.1.11",
4522− "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
4523− "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
4524− "requires": {
4525− "balanced-match": "^1.0.0",
4526− "concat-map": "0.0.1"
4527− }
4528− },
45294393 "braces": {
45304394 "version": "3.0.2",
45314395 "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
@@ -4535,21 +4399,6 @@
45354399 "fill-range": "^7.0.1"
45364400 }
45374401 },
4538− "broadcast-channel": {
4539− "version": "3.7.0",
4540− "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
4541− "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
4542− "requires": {
4543− "@babel/runtime": "^7.7.2",
4544− "detect-node": "^2.1.0",
4545− "js-sha3": "0.8.0",
4546− "microseconds": "0.2.0",
4547− "nano-time": "1.0.0",
4548− "oblivious-set": "1.0.0",
4549− "rimraf": "3.0.2",
4550− "unload": "2.2.0"
4551− }
4552− },
45534402 "browser-or-node": {
45544403 "version": "1.3.0",
45554404 "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-1.3.0.tgz",
@@ -4637,10 +4486,10 @@
46374486 "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
46384487 "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="
46394488 },
4640− "concat-map": {
4641− "version": "0.0.1",
4642− "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
4643− "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
4489+ "color2k": {
4490+ "version": "2.0.2",
4491+ "resolved": "https://registry.npmjs.org/color2k/-/color2k-2.0.2.tgz",
4492+ "integrity": "sha512-kJhwH5nAwb34tmyuqq/lgjEKzlFXn1U99NlnB6Ws4qVaERcRUYeYP1cBw6BJ4vxaWStAUEef4WMr7WjOCnBt8w=="
46444493 },
46454494 "connect-history-api-fallback": {
46464495 "version": "1.6.0",
@@ -4703,11 +4552,6 @@
47034552 "integrity": "sha512-Y2caI5+ZwS5c3RiNDJ6u53VhQHv+hHKwhkI1iHvceKUHw9Df6EK2zRLfjejRgMuCuxK7PfSWIMwWecceVvThjQ==",
47044553 "dev": true
47054554 },
4706− "detect-node": {
4707− "version": "2.1.0",
4708− "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
4709− "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="
4710− },
47114555 "detect-node-es": {
47124556 "version": "1.1.0",
47134557 "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
@@ -5000,11 +4844,6 @@
50004844 "integrity": "sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==",
50014845 "dev": true
50024846 },
5003− "fs.realpath": {
5004− "version": "1.0.0",
5005− "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
5006− "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
5007− },
50084847 "fsevents": {
50094848 "version": "2.3.2",
50104849 "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
@@ -5027,19 +4866,6 @@
50274866 "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz",
50284867 "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="
50294868 },
5030− "glob": {
5031− "version": "7.2.3",
5032− "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
5033− "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
5034− "requires": {
5035− "fs.realpath": "^1.0.0",
5036− "inflight": "^1.0.4",
5037− "inherits": "2",
5038− "minimatch": "^3.1.1",
5039− "once": "^1.3.0",
5040− "path-is-absolute": "^1.0.0"
5041− }
5042− },
50434869 "glob-parent": {
50444870 "version": "6.0.2",
50454871 "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -5097,20 +4923,6 @@
50974923 "resolve-from": "^4.0.0"
50984924 }
50994925 },
5100− "inflight": {
5101− "version": "1.0.6",
5102− "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
5103− "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
5104− "requires": {
5105− "once": "^1.3.0",
5106− "wrappy": "1"
5107− }
5108− },
5109− "inherits": {
5110− "version": "2.0.4",
5111− "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
5112− "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
5113− },
51144926 "invariant": {
51154927 "version": "2.2.4",
51164928 "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -5162,11 +4974,6 @@
51624974 "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
51634975 "dev": true
51644976 },
5165− "js-sha3": {
5166− "version": "0.8.0",
5167− "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
5168− "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="
5169− },
51704977 "js-tokens": {
51714978 "version": "4.0.0",
51724979 "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5221,15 +5028,6 @@
52215028 "js-tokens": "^3.0.0 || ^4.0.0"
52225029 }
52235030 },
5224− "match-sorter": {
5225− "version": "6.3.1",
5226− "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz",
5227− "integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==",
5228− "requires": {
5229− "@babel/runtime": "^7.12.5",
5230− "remove-accents": "0.4.2"
5231− }
5232− },
52335031 "memoize-one": {
52345032 "version": "5.2.1",
52355033 "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
@@ -5251,19 +5049,6 @@
52515049 "picomatch": "^2.3.1"
52525050 }
52535051 },
5254− "microseconds": {
5255− "version": "0.2.0",
5256− "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
5257− "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA=="
5258− },
5259− "minimatch": {
5260− "version": "3.1.2",
5261− "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
5262− "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
5263− "requires": {
5264− "brace-expansion": "^1.1.7"
5265− }
5266− },
52675052 "minimist": {
52685053 "version": "1.2.6",
52695054 "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
@@ -5275,14 +5060,6 @@
52755060 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
52765061 "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
52775062 },
5278− "nano-time": {
5279− "version": "1.0.0",
5280− "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
5281− "integrity": "sha512-flnngywOoQ0lLQOTRNexn2gGSNuM9bKj9RZAWSzhQ+UJYaAFG9bac4DW9VHjUAzrOaIcajHybCTHe/bkvozQqA==",
5282− "requires": {
5283− "big-integer": "^1.6.16"
5284− }
5285− },
52865063 "nanoid": {
52875064 "version": "3.3.4",
52885065 "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz",
@@ -5317,19 +5094,6 @@
53175094 "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
53185095 "dev": true
53195096 },
5320− "oblivious-set": {
5321− "version": "1.0.0",
5322− "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
5323− "integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw=="
5324− },
5325− "once": {
5326− "version": "1.4.0",
5327− "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
5328− "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
5329− "requires": {
5330− "wrappy": "1"
5331− }
5332− },
53335097 "parent-module": {
53345098 "version": "1.0.1",
53355099 "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5349,11 +5113,6 @@
53495113 "lines-and-columns": "^1.1.6"
53505114 }
53515115 },
5352− "path-is-absolute": {
5353− "version": "1.0.1",
5354− "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
5355− "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="
5356− },
53575116 "path-parse": {
53585117 "version": "1.0.7",
53595118 "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -5510,16 +5269,6 @@
55105269 "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
55115270 "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
55125271 },
5513− "react-query": {
5514− "version": "3.39.1",
5515− "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.1.tgz",
5516− "integrity": "sha512-qYKT1bavdDiQZbngWZyPotlBVzcBjDYEJg5RQLBa++5Ix5jjfbEYJmHSZRZD+USVHUSvl/ey9Hu+QfF1QAK80A==",
5517− "requires": {
5518− "@babel/runtime": "^7.5.5",
5519− "broadcast-channel": "^3.4.1",
5520− "match-sorter": "^6.0.2"
5521− }
5522− },
55235272 "react-refresh": {
55245273 "version": "0.10.0",
55255274 "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.10.0.tgz",
@@ -5643,11 +5392,6 @@
56435392 "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
56445393 "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
56455394 },
5646− "remove-accents": {
5647− "version": "0.4.2",
5648− "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
5649− "integrity": "sha512-7pXIJqJOq5tFgG1A2Zxti3Ht8jJF337m4sowbuHsW30ZnkQFnDzy9qBNhgzX8ZLW4+UBcXiiR7SwR6pokHsxiA=="
5650− },
56515395 "remove-markdown-and-html": {
56525396 "version": "0.1.0",
56535397 "resolved": "https://registry.npmjs.org/remove-markdown-and-html/-/remove-markdown-and-html-0.1.0.tgz",
@@ -5674,14 +5418,6 @@
56745418 "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
56755419 "dev": true
56765420 },
5677− "rimraf": {
5678− "version": "3.0.2",
5679− "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
5680− "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
5681− "requires": {
5682− "glob": "^7.1.3"
5683− }
5684− },
56855421 "rollup": {
56865422 "version": "2.75.7",
56875423 "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.75.7.tgz",
@@ -5828,15 +5564,6 @@
58285564 "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==",
58295565 "dev": true
58305566 },
5831− "unload": {
5832− "version": "2.2.0",
5833− "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
5834− "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
5835− "requires": {
5836− "@babel/runtime": "^7.6.2",
5837− "detect-node": "^2.0.4"
5838− }
5839− },
58405567 "update-browserslist-db": {
58415568 "version": "1.0.4",
58425569 "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.4.tgz",
@@ -5928,11 +5655,6 @@
59285655 "connect-history-api-fallback": "^1.6.0"
59295656 }
59305657 },
5931− "wrappy": {
5932− "version": "1.0.2",
5933− "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
5934− "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
5935− },
59365658 "xtend": {
59375659 "version": "4.0.2",
59385660 "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
ui/package.json+4−2
@@ -14,19 +14,21 @@
1414 "@radix-ui/react-dropdown-menu": "^0.1.6",
1515 "@radix-ui/react-select": "^0.1.1",
1616 "@radix-ui/react-toggle-group": "^0.1.5",
17− "@urbit/api": "^2.0.0",
17+ "@tanstack/react-query": "^4.29.5",
18+ "@urbit/api": "2.2",
1819 "@urbit/http-api": "^2.0.0",
1920 "big-integer": "^1.6.51",
2021 "classnames": "^2.3.1",
22+ "color2k": "^2.0.2",
2123 "lodash.debounce": "^4.0.8",
2224 "query-string": "^7.1.1",
2325 "react": "^17.0.2",
2426 "react-dom": "^17.0.2",
2527 "react-hook-form": "^7.33.1",
26− "react-query": "^3.39.1",
2728 "react-router-dom": "^6.3.0",
2829 "react-select": "^5.4.0",
2930 "remove-markdown-and-html": "^0.1.0",
31+ "urbit-ob": "^5.0.1",
3032 "zustand": "^4.0.0-rc.1"
3133 },
3234 "devDependencies": {
ui/src/assets/android-chrome-512x512.png → ui/public/android-chrome-512x512.pngrenamed

Binary file not shown.

ui/src/assets/manifest.json → ui/public/manifest.jsonrenamed+1−1
@@ -3,7 +3,7 @@
33 "short_name": "sphinx",
44 "icons": [
55 {
6− "src": "/apps/grid/android-chrome-512x512.png",
6+ "src": "/apps/sphinx/android-chrome-512x512.png",
77 "sizes": "512x512",
88 "type": "image/png"
99 }
ui/src/api.ts+6
@@ -3,4 +3,10 @@ import Urbit from "@urbit/http-api";
33const api = new Urbit('', '', window.desk);
44api.ship = window.ship;
55
6+// @urbit/http-api 2.x adds `window.addEventListener('beforeunload', this.delete)`
7+// unbound, so its beacon is sent to `${location dir}/undefined` (404) and the
8+// channel is left open until eyre's 12h timeout. Replace it with a bound one.
9+window.removeEventListener('beforeunload', api.delete);
10+window.addEventListener('beforeunload', () => api.delete());
11+
612export default api;
ui/src/app.tsx+1−2
@@ -1,5 +1,5 @@
11import React from 'react';
2−import { QueryClient, QueryClientProvider } from 'react-query';
2+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
33import { BrowserRouter, Route, Routes } from 'react-router-dom';
44import { CenterColumn } from './components/CenterColumn';
55import { Layout } from './components/Layout';
@@ -9,7 +9,6 @@ import { Groups } from './manage-listings/Groups';
99import { MyListings } from './manage-listings/MyListings';
1010import { Notebooks } from './manage-listings/Notebooks';
1111import { Post } from './manage-listings/Post';
12−import { Posts } from './manage-listings/Posts';
1312import { Search } from './pages/Search';
1413import { Tag } from './pages/Tag';
1514
ui/src/components/Listing.tsxnew file+74
@@ -0,0 +1,74 @@
1+import { UsersIcon, UserRemoveIcon } from '@heroicons/react/solid';
2+import { deSig } from '@urbit/api';
3+import React from 'react';
4+import { ListingImage } from './ListingImage';
5+import { Listing as ListingType } from '../types/sphinx';
6+import { channelURL, normalizeLink, refToURL } from '../utils';
7+import { Pals } from '../state/pals';
8+import { ChannelPreview } from '../types';
9+import { useChannelPreview } from '../state/groups';
10+
11+interface ListingProps extends ListingType {
12+ remove: (hash: string) => void;
13+ removePal: (ship: string) => void;
14+ outgoing: Pals;
15+}
16+
17+function getURL(link: string | { nest: string, where: string }, preview?: ChannelPreview) {
18+ if (typeof link === 'string') {
19+ return link;
20+ }
21+
22+ // Tlon routes channels under their group, which only the preview knows.
23+ // A post deep link (post/:authorId/:postId) would also need the author,
24+ // which listing refs don't carry, so link to the post's channel.
25+ if (!preview) {
26+ return '/apps/groups/';
27+ }
28+
29+ return channelURL(preview.group.flag, link.nest);
30+}
31+
32+export const Listing = ({ hash, time, source, post, remove, removePal, outgoing }: ListingProps) => {
33+ const cite = normalizeLink(post.link);
34+ const link = refToURL(cite);
35+ const preview = useChannelPreview(typeof link === 'object' ? link.nest : '');
36+ const hardURL = getURL(link, preview);
37+
38+ return (
39+ <article className='group-1 flex w-full'>
40+ <ListingImage {...post} className="hidden sm:inline-block w-24 h-24 rounded-xl" />
41+ <div className='flex-1 min-w-0 sm:ml-4'>
42+ <div className='flex items-center'>
43+ <ListingImage {...post} className="flex-none sm:hidden w-14 h-14 mr-2 mb-2 rounded-xl" />
44+ <a className='flex-1 min-w-0 mb-2 hover:text-rosy transition-colors' href={hardURL}>
45+ <h2 className='block font-semibold text-lg leading-none mb-1'>{post.title}</h2>
46+ <div className='font-mono text-xs space-x-3 truncate'>
47+ <strong>%{post.type}</strong>
48+ <span>{cite}</span>
49+ </div>
50+ </a>
51+ </div>
52+ <p className='mb-2 text-sm'>{post.description}</p>
53+ <div className='flex flex-col items-start sm:flex-row sm:items-center w-full font-mono text-xs text-zinc-500 sm:opacity-0 group-1-hover:opacity-100 transition-opacity'>
54+ <div className='flex items-center'>
55+ <span className='inline-flex items-center'>
56+ <span className='font-semibold font-sans mr-1'>by:</span>
57+ <span>{source}</span>
58+ {outgoing[deSig(source) || ''] && (
59+ <button className='group-2 relative inline-flex items-center justify-center px-2 hover:text-rosy' aria-label='Remove pal' title="Remove Pal" onClick={() => removePal(deSig(source) || '')}>
60+ <UsersIcon className='h-4 w-4 opacity-0 sm:opacity-100 group-2-hover:opacity-0' />
61+ <UserRemoveIcon className='absolute h-4 w-4 sm:opacity-0 group-2-hover:opacity-100' />
62+ </button>
63+ )}
64+ </span>
65+ <time dateTime={(new Date(time)).toISOString()} className="ml-3">
66+ {(new Date(time)).toLocaleString()}
67+ </time>
68+ </div>
69+ <button className='sm:ml-auto font-semibold font-sans hover:text-rosy bg-transparent border-0' onClick={() => remove(hash)}>remove</button>
70+ </div>
71+ </div>
72+ </article>
73+ )
74+}
ui/src/components/ListingImage.tsx+3−3
@@ -1,6 +1,6 @@
1−import { uxToHex } from '@urbit/api';
21import cn from 'classnames';
32import React from 'react';
3+import { normalizeColor } from '../utils';
44
55interface ListingImageProps {
66 image: string;
@@ -15,12 +15,12 @@ export const ListingImage = ({ image, color, className, imageClasses, colorClass
1515 <img
1616 src={image}
1717 className={cn('object-cover', className, imageClasses)}
18− style={{ backgroundColor: color ? `#${uxToHex(color)}` : undefined }}
18+ style={{ backgroundColor: color ? normalizeColor(color) : undefined }}
1919 />
2020 ) : (
2121 <div
2222 className={cn('bg-rosy/20', className, colorClasses)}
23− style={{ backgroundColor: color ? `#${uxToHex(color)}` : undefined }}
23+ style={{ backgroundColor: color ? normalizeColor(color) : undefined }}
2424 />
2525 )
2626}
ui/src/components/Listings.tsx+4−39
@@ -1,13 +1,11 @@
1−import { UserRemoveIcon, UsersIcon } from '@heroicons/react/solid';
2−import { deSig, uxToHex } from '@urbit/api';
31import cn from 'classnames';
42import React from 'react';
53import { usePals } from '../state/pals';
6−import { Listing } from '../types/sphinx';
7−import { ListingImage } from './ListingImage';
4+import { Listing as ListingType } from '../types/sphinx';
5+import { Listing } from './Listing';
86
97interface ListingsProps {
10− listings: Listing[];
8+ listings: ListingType[];
119 remove: (hash: string) => void;
1210 className?: string;
1311}
@@ -35,40 +33,7 @@ export const Listings = ({ listings, remove, className }: ListingsProps) => {
3533 <ul className={cn('space-y-6', className)}>
3634 {listings && listings.map(l => (
3735 <li key={l.hash}>
38− <article className='group-1 flex w-full'>
39− <ListingImage {...l.post} className="hidden sm:inline-block w-24 h-24 rounded-xl" />
40− <div className='flex-1 min-w-0 sm:ml-4'>
41− <div className='flex items-center'>
42− <ListingImage {...l.post} className="flex-none sm:hidden w-14 h-14 mr-2 mb-2 rounded-xl" />
43− <a className='flex-1 min-w-0 mb-2 hover:text-rosy transition-colors' href={l.post.link}>
44− <h2 className='block font-semibold text-lg leading-none mb-1'>{l.post.title}</h2>
45− <div className='font-mono text-xs space-x-3 truncate'>
46− <strong>%{l.post.type}</strong>
47− <span>{l.post.link.replace('web+urbitgraph://', '')}</span>
48− </div>
49− </a>
50− </div>
51− <p className='mb-2 text-sm'>{l.post.description}</p>
52− <div className='flex flex-col items-start sm:flex-row sm:items-center w-full font-mono text-xs text-zinc-500 sm:opacity-0 group-1-hover:opacity-100 transition-opacity'>
53− <div className='flex items-center'>
54− <span className='inline-flex items-center'>
55− <span className='font-semibold font-sans mr-1'>by:</span>
56− <span>{l.source}</span>
57− {outgoing[deSig(l.source) || ''] && (
58− <button className='group-2 relative inline-flex items-center justify-center px-2 hover:text-rosy' aria-label='Remove pal' title="Remove Pal" onClick={() => removePal(deSig(l.source) || '')}>
59− <UsersIcon className='h-4 w-4 opacity-0 sm:opacity-100 group-2-hover:opacity-0' />
60− <UserRemoveIcon className='absolute h-4 w-4 sm:opacity-0 group-2-hover:opacity-100' />
61− </button>
62− )}
63− </span>
64− <time dateTime={(new Date(l.time)).toISOString()} className="ml-3">
65− {(new Date(l.time)).toLocaleString()}
66− </time>
67− </div>
68− <button className='sm:ml-auto font-semibold font-sans hover:text-rosy bg-transparent border-0' onClick={() => remove(l.hash)}>remove</button>
69− </div>
70− </div>
71− </article>
36+ <Listing {...l} outgoing={outgoing} remove={remove} removePal={removePal} />
7237 </li>
7338 ))}
7439 </ul>
ui/src/components/Meta.tsx+4−2
@@ -3,6 +3,8 @@ import React, { useEffect, useState } from 'react';
33import api from '../api';
44import { Logo } from './Logo';
55
6+const SOURCE = 'github.com/iko-soy/sphinx';
7+
68interface MetaProps {
79 className?: string;
810}
@@ -30,9 +32,9 @@ export const Meta = ({ className }: MetaProps) => {
3032 </div>
3133 <a
3234 className="inline-block font-mono default-ring underline rounded-md hover:text-rosy"
33− href="web+urbitgraph://group/~nocsyx-lassul/sphinx"
35+ href={`https://${SOURCE}`}
3436 >
35− ~nocsyx-lassul/sphinx
37+ {SOURCE}
3638 </a>
3739 </footer>
3840 )
ui/src/components/PostOptions.tsx+3−3
@@ -1,9 +1,9 @@
11import { CheckIcon } from '@heroicons/react/solid';
2−import { uxToHex } from '@urbit/api';
32import cn from 'classnames';
43import React from 'react';
54import { useFormContext } from 'react-hook-form';
65import { PostOption, PostOptionsForm } from '../types/sphinx';
6+import { normalizeColor } from '../utils';
77
88interface PostOptionsProps {
99 options: PostOption[];
@@ -28,12 +28,12 @@ export const PostOptions = ({ options, showTags = false, className }: PostOption
2828 src={o.post.image}
2929 className="show-when-checked w-16 h-16 col-span-1 object-cover rounded-lg opacity-60"
3030 loading={index > 15 ? 'lazy' : 'eager'}
31− style={{ backgroundColor: o.post.color ? `#${uxToHex(o.post.color)}` : undefined }}
31+ style={{ backgroundColor: o.post.color ? normalizeColor(o.post.color) : undefined }}
3232 />
3333 ) : (
3434 <div
3535 className='w-16 h-16 col-span-1 bg-rosy/20 rounded-lg'
36− style={{ backgroundColor: o.post.color ? `#${uxToHex(o.post.color)}` : undefined }}
36+ style={{ backgroundColor: o.post.color ? normalizeColor(o.post.color) : undefined }}
3737 />
3838 )}
3939 <div className='ml-4 min-w-0 sm:space-y-2'>
ui/src/components/TagField.tsx+67−54
@@ -3,6 +3,7 @@ import React, { Component, KeyboardEventHandler, useCallback, useState } from 'r
33import CreatableSelect from 'react-select/creatable';
44import { ActionMeta, components, ControlProps, MultiValue, MultiValueGenericProps, MultiValueRemoveProps, OnChangeValue } from 'react-select';
55import { XIcon } from '@heroicons/react/solid';
6+import { MAX_TAG_BYTES, MAX_TAGS, byteLength } from '../utils';
67
78export interface Option {
89 readonly label: string;
@@ -81,64 +82,76 @@ export const TagField = ({ tags, onTags, className }: TagFieldProps) => {
8182 switch (event.key) {
8283 case 'Enter':
8384 case 'Tab':
84− setInput('');
85− onTags([...tags, createOption(input)])
85+ // %sphinx drops listings with more than 8 tags and doesn't index
86+ // longer tags, so keep the input; Tab still moves on
87+ if (tags.length >= MAX_TAGS || byteLength(input) > MAX_TAG_BYTES) {
88+ if (event.key === 'Enter') event.preventDefault();
89+ return;
90+ }
91+
8692 event.preventDefault();
93+ setInput('');
94+ if (!tags.some(t => t.value === input)) {
95+ onTags([...tags, createOption(input)])
96+ }
8797 }
8898 }, [input, tags]);
8999
90100 return (
91− <CreatableSelect<Option, true>
92− className={className}
93− components={{
94− ...components,
95− Control,
96− MultiValueContainer: TagContainer,
97− MultiValueLabel: TagLabel,
98− MultiValueRemove: TagRemove,
99− DropdownIndicator: null
100− }}
101− styles={{
102− control: (base) => ({}),
103− input: (base) => ({
104− ...base,
105− padding: 0,
106− margin: 0,
107− }),
108− clearIndicator: (base) => ({
109− ...base,
110− cursor: 'pointer',
111− padding: 0
112− }),
113− multiValue: (base) => ({
114− ...base,
115− backgroundColor: '',
116− margin: '0 2px',
117− }),
118− multiValueRemove: (base) => ({
119− ...base,
120− paddingRight: '',
121− paddingLeft: '',
122− '&:hover': {
123− color: 'inherit',
124− backgroundColor: 'inherit',
125− },
126− }),
127− valueContainer: (base) => ({
128− ...base,
129− padding: 0
130− })
131− }}
132− isOptionDisabled={() => tags.length >= 8}
133− inputValue={input}
134− isClearable
135− isMulti
136− menuIsOpen={false}
137− onChange={handleChange}
138− onInputChange={handleInputChange}
139− onKeyDown={handleKeyDown}
140− placeholder="Type something and press enter..."
141− value={tags}
142− />
101+ <div className={className}>
102+ <CreatableSelect<Option, true>
103+ components={{
104+ ...components,
105+ Control,
106+ MultiValueContainer: TagContainer,
107+ MultiValueLabel: TagLabel,
108+ MultiValueRemove: TagRemove,
109+ DropdownIndicator: null
110+ }}
111+ styles={{
112+ control: (base) => ({}),
113+ input: (base) => ({
114+ ...base,
115+ padding: 0,
116+ margin: 0,
117+ }),
118+ clearIndicator: (base) => ({
119+ ...base,
120+ cursor: 'pointer',
121+ padding: 0
122+ }),
123+ multiValue: (base) => ({
124+ ...base,
125+ backgroundColor: '',
126+ margin: '0 2px',
127+ }),
128+ multiValueRemove: (base) => ({
129+ ...base,
130+ paddingRight: '',
131+ paddingLeft: '',
132+ '&:hover': {
133+ color: 'inherit',
134+ backgroundColor: 'inherit',
135+ },
136+ }),
137+ valueContainer: (base) => ({
138+ ...base,
139+ padding: 0
140+ })
141+ }}
142+ isOptionDisabled={() => tags.length >= MAX_TAGS}
143+ inputValue={input}
144+ isClearable
145+ isMulti
146+ menuIsOpen={false}
147+ onChange={handleChange}
148+ onInputChange={handleInputChange}
149+ onKeyDown={handleKeyDown}
150+ placeholder="Type something and press enter..."
151+ value={tags}
152+ />
153+ {byteLength(input) > MAX_TAG_BYTES && <div className='text-mauve/50 text-xs mt-1'>{MAX_TAG_BYTES} bytes per tag maximum</div>}
154+ {tags.length >= MAX_TAGS && <div className='text-mauve/50 text-xs mt-1'>{MAX_TAGS} tags maximum</div>}
155+ </div>
143156 );
144157}
ui/src/env.d.ts+4
@@ -8,4 +8,8 @@ interface ImportMeta {
88
99declare module 'remove-markdown-and-html' {
1010 export default function removeMd(input: string): string;
11+}
12+
13+declare module 'urbit-ob' {
14+ export function isValidPatp(patp: string): boolean;
1115}
ui/src/keys.tsnew file+14
@@ -0,0 +1,14 @@
1+import { QueryKey } from "@tanstack/react-query";
2+
3+export const GROUPS_KEY: QueryKey = ['groups'];
4+export const VATS_KEY: QueryKey = ['vats'];
5+export const PALS_KEY: QueryKey = ['pals'];
6+export const TAGS_KEY: QueryKey = ['tags'];
7+export const CHARGES_KEY: QueryKey = ['charges'];
8+export const APP_LISTINGS_KEY: QueryKey = ['app-listings'];
9+export const GROUP_LISTINGS_KEY: QueryKey = ['group-listings'];
10+export const POST_LISTINGS_KEY: QueryKey = ['post-listings'];
11+export const PUBLISHED_KEY: QueryKey = ['published'];
12+export const notesKey = (diary?: string): QueryKey => diary ? ['notes', diary] : ['notes'];
13+export const tagKey = (tag?: string) => (start: number, size: number): QueryKey => ['tag', tag || '', size, start];
14+export const lookupKey = (selected: string, lookup: string | undefined) => (start: number, size: number): QueryKey => ['lookup', selected, size.toString(), start.toString(), lookup || ''];
ui/src/logic/utils.tsnew file+11
@@ -0,0 +1,11 @@
1+export function preSig(ship: string): string {
2+ if (!ship) {
3+ return '';
4+ }
5+
6+ if (ship.trim().startsWith('~')) {
7+ return ship.trim();
8+ }
9+
10+ return '~'.concat(ship.trim());
11+}
ui/src/manage-listings/AllListings.tsx+6−4
@@ -1,11 +1,13 @@
11import React from 'react';
2−import { useMutation, useQueryClient } from 'react-query';
2+import { useMutation, useQueryClient } from '@tanstack/react-query';
33import { useParams } from 'react-router-dom';
44import api from '../api';
55import { Listings } from '../components/Listings';
66import { Paginator } from '../components/Paginator';
77import { useSearch } from '../state/search';
88import { Remove, Search } from '../types/sphinx';
9+import { PUBLISHED_KEY } from '../keys';
10+import { formatUd } from '../utils';
911
1012interface RouteParams extends Record<string, string | undefined> {
1113 limit?: string;
@@ -24,10 +26,10 @@ export const AllListings = () => {
2426 pageInt,
2527 linkBuild
2628 } = useSearch({
27− key: (start, size) => `all-${start}-${size}`,
29+ key: (start, size) => ['all', start, size],
2830 fetcher: (start, size) => api.scry<Search>({
2931 app: 'sphinx',
30− path: `/lookup/all/${start}/${size}`
32+ path: `/lookup/all/${formatUd(start)}/${formatUd(size)}`
3133 }),
3234 enabled: true,
3335 limit,
@@ -42,7 +44,7 @@ export const AllListings = () => {
4244 })
4345 }, {
4446 onSuccess: () => {
45− queryClient.invalidateQueries('published');
47+ queryClient.invalidateQueries(PUBLISHED_KEY);
4648 }
4749 });
4850
ui/src/manage-listings/Apps.tsx+3−2
@@ -1,12 +1,13 @@
11import React, { useCallback, useEffect, useState } from 'react';
22import { FormProvider, useForm } from 'react-hook-form';
3−import { useMutation, useQueryClient } from 'react-query';
3+import { useMutation, useQueryClient } from '@tanstack/react-query';
44import { Link } from 'react-router-dom';
55import api from '../api';
66import { PostOptions } from '../components/PostOptions';
77import { Spinner } from '../components/Spinner';
88import { useApps } from '../state/apps';
99import { Declare, Listing, PostOption, PostOptionsForm } from '../types/sphinx';
10+import { APP_LISTINGS_KEY } from '../keys';
1011
1112function getAppKeys(apps: PostOption[]): string[] {
1213 return apps.map(({ key }) => key);
@@ -42,7 +43,7 @@ export const Apps = () => {
4243 });
4344 }, {
4445 onSuccess: () => {
45− queryClient.invalidateQueries('app-listings');
46+ queryClient.invalidateQueries(APP_LISTINGS_KEY);
4647 }
4748 })
4849
ui/src/manage-listings/Groups.tsx+4−3
@@ -1,12 +1,13 @@
11import React, { useCallback, useEffect, useState } from 'react';
22import { FormProvider, useForm } from 'react-hook-form';
3−import { useMutation, useQueryClient } from 'react-query';
3+import { useMutation, useQueryClient } from '@tanstack/react-query';
44import { Link } from 'react-router-dom';
55import api from '../api';
66import { PostOptions } from '../components/PostOptions';
77import { Spinner } from '../components/Spinner';
88import { useGroups } from '../state/groups';
9−import { Declare, PostOption, PostOptionsForm } from '../types/sphinx';
9+import { Declare, PostOptionsForm } from '../types/sphinx';
10+import { GROUP_LISTINGS_KEY } from '../keys';
1011
1112export const Groups = () => {
1213 const { groups, loading } = useGroups();
@@ -49,7 +50,7 @@ export const Groups = () => {
4950 });
5051 }, {
5152 onSuccess: () => {
52− queryClient.invalidateQueries('group-listings')
53+ queryClient.invalidateQueries(GROUP_LISTINGS_KEY)
5354 }
5455 })
5556
ui/src/manage-listings/MyListings.tsx+4−4
@@ -1,13 +1,13 @@
1−import { size } from 'lodash';
21import React from 'react';
3−import { useMutation, useQuery, useQueryClient } from 'react-query';
2+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
43import api from '../api';
54import { Listings } from '../components/Listings';
65import { Directory, Remove } from '../types/sphinx';
6+import { PUBLISHED_KEY } from '../keys';
77
88export const MyListings = () => {
99 const queryClient = useQueryClient();
10− const { data } = useQuery('published', () => api.scry<Directory>({
10+ const { data } = useQuery(PUBLISHED_KEY, () => api.scry<Directory>({
1111 app: 'sphinx',
1212 path: '/published'
1313 }));
@@ -19,7 +19,7 @@ export const MyListings = () => {
1919 })
2020 }, {
2121 onSuccess: () => {
22− queryClient.invalidateQueries('published');
22+ queryClient.invalidateQueries(PUBLISHED_KEY);
2323 }
2424 });
2525
ui/src/manage-listings/Notebooks.tsx+13−11
@@ -1,18 +1,18 @@
1−import { uxToHex } from '@urbit/api';
21import cn from 'classnames';
32import React from 'react';
4−import { Link, NavLink, useParams } from 'react-router-dom';
3+import { NavLink, useParams } from 'react-router-dom';
54import { Spinner } from '../components/Spinner';
6−import useMedia, { useIsMobile } from '../logic/useMedia';
5+import useMedia from '../logic/useMedia';
76import { useNotebooks } from '../state/posts';
87import { Posts } from './Posts';
8+import { getColor, getImage } from '../utils';
99
1010export const Notebooks = () => {
1111 const { ship, name } = useParams<{ ship: string, name: string }>();
1212 const limited = useMedia('(max-width: 899px)');
1313 const { groups, notebooks, loading } = useNotebooks();
1414 const sortedNotebooks = Object.entries(notebooks)
15− .sort(([,a], [,b]) => a.metadata.title.localeCompare(b.metadata.title));
15+ .sort(([,a], [,b]) => a.meta.title.localeCompare(b.meta.title));
1616
1717 return (
1818 <div className="flex-none flex flex-col max-w-5xl w-full p-4 sm:py-12 sm:px-8 space-y-6 sm:pl-32 xl:pl-8">
@@ -28,27 +28,29 @@ export const Notebooks = () => {
2828 <span>Loading notebooks...</span>
2929 </div>}
3030 {sortedNotebooks.map(([id, n], index) => {
31− const [,group] = groups.find(([,v]) => v.group === n.group) || [];
31+ const [,group] = groups.find(([k]) => k === n.perms.group) || [];
32+ const image = group && (getImage(group.meta.image) || getImage(group.meta.cover) || '');
33+ const color = group && (getColor(group.meta.image) || getColor(group.meta.cover) || '');
3234
3335 return (
3436 <li key={id}>
3537 <NavLink to={`/manage-listings/posts/${id}`} className={({ isActive }) => cn('flex items-center p-2 space-x-2 leading-tight rounded-md hover:bg-fawn', isActive && 'bg-fawn')}>
36− {group && group.metadata.picture ? (
38+ {group && image ? (
3739 <img
38− src={group.metadata.picture}
40+ src={image}
3941 className="flex-none w-10 h-10 object-cover rounded-md"
4042 loading={index > 15 ? 'lazy' : 'eager'}
41− style={{ backgroundColor: group.metadata.color ? `#${uxToHex(group.metadata.color)}` : undefined }}
43+ style={{ backgroundColor: color }}
4244 />
4345 ) : group ? (
4446 <div
4547 className='flex-none w-10 h-10 bg-rosy/20 rounded-md'
46− style={{ backgroundColor: group.metadata.color ? `#${uxToHex(group.metadata.color)}` : undefined }}
48+ style={{ backgroundColor: color }}
4749 />
4850 ) : null}
4951 <div className='flex flex-col min-w-0'>
50− <strong className='w-full truncate'>{n.metadata.title}</strong>
51− {group && <span className='text-sm'>{group.metadata.title}</span>}
52+ <strong className='w-full truncate'>{n.meta.title}</strong>
53+ {group && <span className='text-sm'>{group.meta.title}</span>}
5254 </div>
5355 </NavLink>
5456 </li>
ui/src/manage-listings/Post.tsx+28−13
@@ -8,17 +8,32 @@ import { ErrorMessage } from '../components/ErrorMessage';
88import { Filter } from '../components/Filter';
99import { Option, TagField } from '../components/TagField';
1010import { Declare, Post as PostForm, PostType } from '../types/sphinx';
11+import {
12+ MAX_DESCRIPTION_BYTES, MAX_TAG_BYTES, MAX_TAGS, MAX_TITLE_BYTES, MAX_URL_BYTES,
13+ byteLength, isListingImage, isListingLink
14+} from '../utils';
1115
12−function errorMessages(length: number) {
16+function errorMessages(length: number, url?: string) {
1317 return {
1418 required: 'This field is required.',
15− maxLength: `The maximum length is ${length}.`
19+ bytes: `The maximum length is ${length} bytes (accented letters and emoji count as 2-4).`,
20+ url
1621 }
1722}
1823
24+// lib/sphinx +lengths-valid counts UTF-8 bytes and drops longer listings silently
25+const maxBytes = (max: number) => ({ bytes: (value: string) => byteLength(value || '') <= max });
26+
27+// what lib/validate-url rejects, which %sphinx also drops silently
28+const URL_RULES = 'no spaces, #fragments, non-ASCII letters or characters like | [ ] { }';
29+
1930export const Post = () => {
2031 const [params] = useSearchParams();
21− const defaultTags = params.getAll('tags').map(t => ({ label: t, value: t})) || [];
32+ // ?tags= can bring in tags that TagField wouldn't take
33+ const defaultTags = params.getAll('tags')
34+ .filter((t, i, all) => t && byteLength(t) <= MAX_TAG_BYTES && all.indexOf(t) === i)
35+ .slice(0, MAX_TAGS)
36+ .map(t => ({ label: t, value: t}));
2237 const [tags, setTags] = useState<MultiValue<Option>>(defaultTags);
2338 const [image, setImage] = useState<string>('');
2439 const form = useForm<PostForm>({
@@ -70,43 +85,43 @@ export const Post = () => {
7085 <h1 className='text-2xl font-semibold'>Add a Listing</h1>
7186 </header>
7287 <FormProvider {...form}>
73− <form onSubmit={handleSubmit(onSubmit)}>
88+ {/* only our checks: the browser's type="url" check refuses relative links like /1/group/~zod/x */}
89+ <form noValidate onSubmit={handleSubmit(onSubmit)}>
7490 <div className='flex w-full space-x-6'>
7591 <div className='flex-1 space-y-3'>
7692 <div>
7793 <label htmlFor='title' className='text-sm font-semibold'>Title</label>
7894 <div className='flex items-center space-x-2'>
79− <input {...register('title', { required: true, maxLength: 77 })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='sphinx'/>
95+ <input {...register('title', { required: true, validate: maxBytes(MAX_TITLE_BYTES) })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='sphinx'/>
8096 <Filter selected={type} onSelect={(value) => setValue('type', value)} />
8197 </div>
82− <ErrorMessage className='mt-1' field="title" messages={errorMessages(77)}/>
98+ <ErrorMessage className='mt-1' field="title" messages={errorMessages(MAX_TITLE_BYTES)}/>
8399 </div>
84100 <div className='flex items-center space-x-6'>
85101 <div className='flex-1 space-y-3'>
86102 <div>
87103 <label htmlFor='image' className='text-sm font-semibold'>Image</label>
88104 <input type="url" {...register('image', {
89− maxLength: 1024
105+ validate: { ...maxBytes(MAX_URL_BYTES), url: isListingImage }
90106 })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='https://nyc3.digitaloceanspaces.com/...' />
91− <ErrorMessage className='mt-1' field="image" messages={errorMessages(1024)}/>
107+ <ErrorMessage className='mt-1' field="image" messages={errorMessages(MAX_URL_BYTES, `Use a web address (https://host/path) with ${URL_RULES}.`)}/>
92108 </div>
93109 <div>
94110 <label htmlFor='link' className='text-sm font-semibold'>Link</label>
95− <input type="url" {...register('link', { required: true, maxLength: 1024 })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='web+urbitgraph://~nocsyx-lassul/sphinx' />
96− <ErrorMessage className='mt-1' field="link" messages={errorMessages(1024)} />
111+ <input type="url" {...register('link', { required: true, validate: { ...maxBytes(MAX_URL_BYTES), url: isListingLink } })} className='flex-1 w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='web+urbitgraph://~donmec-sallud/sphinx' />
112+ <ErrorMessage className='mt-1' field="link" messages={errorMessages(MAX_URL_BYTES, `Use a link with ${URL_RULES}.`)} />
97113 </div>
98114 </div>
99115 <img className='flex-none object-cover w-28 h-28 mt-4 border-2 border-dashed border-mauve/60 rounded-lg' src={image || undefined} />
100116 </div>
101117 <div>
102118 <label htmlFor='description' className='text-sm font-semibold'>Description</label>
103− <textarea {...register('description', { required: true, maxLength: 256 })} rows={2} className='align-middle w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='An app for answering your riddles' />
104− <ErrorMessage className='mt-1' field="description" messages={errorMessages(256)} />
119+ <textarea {...register('description', { required: true, validate: maxBytes(MAX_DESCRIPTION_BYTES) })} rows={2} className='align-middle w-full py-1 px-2 bg-fawn/30 focus:outline-none focus:ring-2 ring-lavender rounded-lg border border-fawn/30' placeholder='An app for answering your riddles' />
120+ <ErrorMessage className='mt-1' field="description" messages={errorMessages(MAX_DESCRIPTION_BYTES)} />
105121 </div>
106122 <div>
107123 <label className='text-sm font-semibold'>Tags</label>
108124 <TagField tags={tags} onTags={setTags} />
109− {tags.length === 8 && <div className='text-mauve/50 text-xs mt-1'>8 tags maximum</div>}
110125 </div>
111126 <div className='pt-3'>
112127 <div className='flex justify-between border-t border-zinc-300 py-3'>
ui/src/manage-listings/Posts.tsx+6−5
@@ -1,8 +1,8 @@
11import { ChevronLeftIcon } from '@heroicons/react/solid';
22import React, { useCallback, useEffect, useState } from 'react';
33import { FormProvider, useForm } from 'react-hook-form';
4−import { useMutation, useQueryClient } from 'react-query';
5−import { Link, useParams } from 'react-router-dom';
4+import { useMutation, useQueryClient } from '@tanstack/react-query';
5+import { Link } from 'react-router-dom';
66import { MultiValue } from 'react-select';
77import api from '../api';
88import { Filter } from '../components/Filter';
@@ -12,6 +12,7 @@ import { TagField, Option } from '../components/TagField';
1212import useMedia from '../logic/useMedia';
1313import { usePosts } from '../state/posts';
1414import { Declare, PostOptionsForm, PostType } from '../types/sphinx';
15+import { POST_LISTINGS_KEY } from '../keys';
1516
1617interface PostsProps {
1718 ship: string;
@@ -62,7 +63,7 @@ export const Posts = ({ ship, name }: PostsProps) => {
6263 });
6364 }, {
6465 onSuccess: () => {
65− queryClient.invalidateQueries('post-listings')
66+ queryClient.invalidateQueries(POST_LISTINGS_KEY)
6667 }
6768 })
6869
@@ -77,10 +78,10 @@ export const Posts = ({ ship, name }: PostsProps) => {
7778 {limited && (
7879 <Link to="/manage-listings/posts" className='flex items-center'>
7980 <ChevronLeftIcon className='h-6 w-6 text-mauve/60' />
80− <h1 className='text-2xl font-semibold'>{notebook?.metadata?.title}</h1>
81+ <h1 className='text-2xl font-semibold'>{notebook?.meta.title}</h1>
8182 </Link>
8283 )}
83− {!limited && <h1 className='text-2xl font-semibold'>{notebook?.metadata?.title}</h1>}
84+ {!limited && <h1 className='text-2xl font-semibold'>{notebook?.meta.title}</h1>}
8485 {posts.length > 0 && (
8586 <button className='p-1.5 ml-auto text-sm underline font-semibold' onClick={toggleAll}>
8687 {options.length !== posts.length && 'select all'}
ui/src/pages/Search.tsx+8−7
@@ -1,7 +1,7 @@
11import cn from 'classnames';
22import React, { useCallback, useRef, useState } from 'react';
33import debounce from 'lodash.debounce';
4−import { useMutation, useQueryClient } from 'react-query';
4+import { useMutation, useQueryClient } from '@tanstack/react-query';
55import { useNavigate, useParams } from 'react-router-dom';
66import { SearchInput } from '../components/SearchInput';
77import { Listings } from '../components/Listings';
@@ -14,7 +14,8 @@ import { usePals } from '../state/pals';
1414import { TagCloud } from '../components/TagCloud';
1515import { useTags } from '../state/tags';
1616import { useSearch } from '../state/search';
17−import { encodeLookup } from '../utils';
17+import { encodeLookup, formatUd } from '../utils';
18+import { lookupKey } from '../keys';
1819
1920interface RouteParams extends Record<string, string | undefined> {
2021 lookup?: string;
@@ -42,10 +43,10 @@ export const Search = () => {
4243 isLoading,
4344 linkBuild
4445 } = useSearch({
45− key: (start, size) => `lookup-${selected}-${size}-${start}-${lookup}`,
46+ key: lookupKey(selected, lookup),
4647 fetcher: (start, size) => api.scry<SearchType>({
4748 app: 'sphinx',
48− path: `/lookup/${selected}/${start}/${size}/${encodeLookup(lookup)}`
49+ path: `/lookup/${selected}/${formatUd(start)}/${formatUd(size)}/${encodeLookup(lookup)}`
4950 }),
5051 enabled: !!lookup,
5152 limit,
@@ -62,7 +63,7 @@ export const Search = () => {
6263 })
6364 }, {
6465 onSuccess: () => {
65− queryClient.invalidateQueries(`lookup-${selected}-${size}-${start}-${lookup}`)
66+ queryClient.invalidateQueries(lookupKey(selected, lookup)(start, size))
6667 }
6768 });
6869
@@ -82,12 +83,12 @@ export const Search = () => {
8283 return (
8384 <div className={cn('w-full space-y-6', !lookup && 'm-auto')}>
8485 <header className='flex items-center space-x-2'>
85− <SearchInput className='flex-1' lookup={rawSearch} loading={isLoading} onChange={onChange} />
86+ <SearchInput className='flex-1' lookup={rawSearch} loading={!!lookup && isLoading} onChange={onChange} />
8687 <Filter selected={selected} onSelect={setSelected} className="min-w-0 sm:w-20" />
8788 </header>
8889 {!palsInstalled && (
8990 <div>
90− <a href="/apps/grid/leap/search/~paldev/apps/~paldev/pals" className='inline-flex items-center py-1 px-2 mr-2 font-semibold bg-green-800 text-linen rounded-md'>
91+ <a href="/apps/landscape/search/~paldev/apps/~paldev/pals" className='inline-flex items-center py-1 px-2 mr-2 font-semibold bg-green-800 text-linen rounded-md'>
9192 <PlusSmIcon className='h-4 w-4' />
9293 install pals
9394 </a>
ui/src/pages/Tag.tsx+4−5
@@ -2,7 +2,7 @@ import { PlusIcon, PlusSmIcon } from '@heroicons/react/solid';
22import cn from 'classnames';
33import qs from 'query-string';
44import React from 'react';
5−import { useQueryClient, useMutation } from 'react-query';
5+import { useQueryClient, useMutation } from '@tanstack/react-query';
66import { Link, useParams } from 'react-router-dom';
77import api from '../api';
88import { Listings } from '../components/Listings';
@@ -11,7 +11,8 @@ import { TagCloud } from '../components/TagCloud';
1111import { useSearch } from '../state/search';
1212import { useTags } from '../state/tags';
1313import { Remove, Search } from '../types/sphinx';
14−import { encodeLookup } from '../utils';
14+import { encodeLookup, formatUd } from '../utils';
15+import { tagKey } from '../keys';
1516
1617interface RouteParams extends Record<string, string | undefined> {
1718 tag?: string;
@@ -19,8 +20,6 @@ interface RouteParams extends Record<string, string | undefined> {
1920 page?: string;
2021}
2122
22−const tagKey = (tag?: string) => (start: number, size: number) => `tag-${tag || ''}-${size}-${start}`
23−
2423export const Tag = () => {
2524 const tags = useTags();
2625 const { tag, limit, page } = useParams<RouteParams>();
@@ -35,7 +34,7 @@ export const Tag = () => {
3534 key: tagKey(tag),
3635 fetcher: (start, size) => api.scry<Search>({
3736 app: 'sphinx',
38− path: `/lookup/tag/${start}/${size}/${encodeLookup(tag)}`
37+ path: `/lookup/tag/${formatUd(start)}/${formatUd(size)}/${encodeLookup(tag)}`
3938 }),
4039 enabled: !!tag,
4140 limit,
ui/src/state/apps.ts+19−15
@@ -1,35 +1,39 @@
1−import { Charge, ChargeUpdateInitial, getVats, scryCharges, Vat, Vats } from "@urbit/api";
2−import { useQuery } from "react-query"
1+import { Charge, ChargeUpdateInitial, getPikes, Pike, Pikes, scryCharges } from "@urbit/api";
2+import { useQuery } from "@tanstack/react-query"
33import api from "../api";
44import { Post, Search } from "../types/sphinx";
5+import { APP_LISTINGS_KEY, CHARGES_KEY, VATS_KEY } from "../keys";
6+import { MAX_DESCRIPTION_BYTES, MAX_TITLE_BYTES, listingImage, truncateBytes } from "../utils";
57
6−function getAppPost(app: string, vat: Vat, charge: Charge): Post {
7− const ship = vat.arak.rail?.publisher || vat.arak.rail?.ship || `~${window.ship}`;
8+function getAppPost(app: string, pike: Pike, charge: Charge): Post {
9+ const ship = pike.sync?.ship || `~${window.ship}`;
810
911 return {
1012 type: 'app',
11− title: charge.title,
12− link: `web+urbitgraph://${ship}/${app}`,
13− description: charge.info || charge.title,
14− image: charge.image || '',
13+ title: truncateBytes(charge.title, MAX_TITLE_BYTES),
14+ // the desk on the publisher, which may differ from the local name
15+ // (|install ~ship %desk, =local %other)
16+ link: `/1/desk/${ship}/${pike.sync?.desk || app}`,
17+ description: truncateBytes(charge.info || charge.title, MAX_DESCRIPTION_BYTES),
18+ image: listingImage(charge.image),
1519 color: charge.color,
1620 tags: ['app']
1721 }
1822}
1923
2024export const useApps = () => {
21− const { data: vats, isLoading: vatsLoading } = useQuery('vats', () => api.scry<Vats>(getVats));
22− const { data: charges, isLoading: chargesLoading } = useQuery('charges', () => api.scry<ChargeUpdateInitial>(scryCharges));
23− const { data, isLoading } = useQuery('app-listings', () => api.scry<Search>({
25+ const { data: pikes, isLoading: pikesLoading } = useQuery(VATS_KEY, () => api.scry<Pikes>(getPikes));
26+ const { data: charges, isLoading: chargesLoading } = useQuery(CHARGES_KEY, () => api.scry<ChargeUpdateInitial>(scryCharges));
27+ const { data, isLoading } = useQuery(APP_LISTINGS_KEY, () => api.scry<Search>({
2428 app: 'sphinx',
2529 path: '/lookup/app/0/999'
2630 }));
2731
2832 return {
29− loading: vatsLoading || chargesLoading || isLoading,
30− apps: !vats || !charges || !data ? [] : Object.entries(charges.initial)
31− .filter(([k]) => k in vats)
32− .map(([k,v]) => ({ key: k, post: getAppPost(k, vats[k], v) }))
33+ loading: pikesLoading || chargesLoading || isLoading,
34+ apps: !pikes || !charges || !data ? [] : Object.entries(charges.initial)
35+ .filter(([k]) => k in pikes)
36+ .map(([k,v]) => ({ key: k, post: getAppPost(k, pikes[k], v) }))
3337 .filter(({ post }) => !data.listings.some(l => l.post.link === post.link || l.post.title === post.title))
3438 }
3539}
ui/src/state/groups.ts+55−20
@@ -1,26 +1,34 @@
1−import { Associations, GroupUpdateInitial, MetadataUpdateInitial } from "@urbit/api";
2−import { useQuery } from "react-query"
1+import { useQuery } from "@tanstack/react-query"
32import api from "../api"
4−import { PostOption, PostType, Search } from "../types/sphinx";
3+import { PostType, Search } from "../types/sphinx";
4+import { ChannelPreview, Groups } from "../types";
5+import { useMemo } from "react";
6+import { GROUPS_KEY, GROUP_LISTINGS_KEY } from "../keys";
7+import { MAX_DESCRIPTION_BYTES, MAX_TITLE_BYTES, getColor, getImage, isColor, listingImage, truncateBytes } from "../utils";
58
6−export type MetaDataUpdate = { 'metadata-update': MetadataUpdateInitial };
9+export const usePublicGroups = () => {
10+ const { data, ...query } = useQuery(GROUPS_KEY,
11+ () => api.scry<Groups>({
12+ app: 'groups',
13+ path: '/groups/light'
14+ }),
15+ { initialData: {} }
16+ );
717
8−export const usePublicGroups = (metadata?: { 'metadata-update': MetadataUpdateInitial }) => {
9− const { data, isLoading } = useQuery('metadata', () => api.subscribeOnce<MetaDataUpdate>('metadata-store', '/all'), {
10− enabled: !metadata
11− });
12− const { data: groupData, isLoading: groupDataLoading } = useQuery('groups', () => api.subscribeOnce<{ groupUpdate: GroupUpdateInitial }>('group-store', '/groups'));
18+ const groups = useMemo(() => {
19+ if (!data) return [];
20+ return Object.entries(data).filter(([,v]) => !v.secret);
21+ }, [data]);
1322
1423 return {
15− loading: isLoading || groupDataLoading,
16− groups: !data || !groupData ? [] : Object.entries(data['metadata-update'].associations)
17− .filter(([k,v]) => v['app-name'] === 'groups' && 'open' in (groupData.groupUpdate.initial[v.resource]?.policy || {}))
24+ ...query,
25+ groups
1826 }
1927}
2028
2129export const useGroups = () => {
22− const { groups, loading } = usePublicGroups();
23− const { data: d, isLoading } = useQuery('group-listings', () => api.scry<Search>({
30+ const { groups, isLoading: loading } = usePublicGroups();
31+ const { data: d, isLoading } = useQuery(GROUP_LISTINGS_KEY, () => api.scry<Search>({
2432 app: 'sphinx',
2533 path: '/lookup/group/0/999'
2634 }));
@@ -29,13 +37,40 @@ export const useGroups = () => {
2937 loading: loading || isLoading,
3038 groups: groups.map(([k,v]) => ({ key: k, post: {
3139 type: 'group' as PostType,
32− title: v.metadata.title,
33− link: v.group.replace('/ship/', 'web+urbitgraph://group/'),
34− image: v.metadata.picture,
35− color: v.metadata.color,
36− description: v.metadata.description,
40+ title: truncateBytes(v.meta.title, MAX_TITLE_BYTES),
41+ link: `/1/group/${k}`, //`/apps/groups/groups/${k}`,
42+ image: listingImage(getImage(v.meta.image) || getImage(v.meta.cover)),
43+ color: getColor(v.meta.image) || getColor(v.meta.cover) || '',
44+ description: truncateBytes(v.meta.description, MAX_DESCRIPTION_BYTES),
3745 tags: ['group']
3846 }}))
3947 .filter(({ post }) => !d || d.listings.length === 0 || !d.listings.some(l => l.post.link === post.link || l.post.title === post.title))
4048 }
41−}
49+}
50+
51+export function useChannelPreview(nest: string): ChannelPreview | undefined {
52+ const { groups, isFetched } = usePublicGroups();
53+ const group = groups.find(([,g]) => {
54+ return !!g.channels[nest];
55+ });
56+
57+ // %groups asks the channel's host and answers once (mark channel-preview-1)
58+ const { data } = useQuery(['channel-preview', nest],
59+ () => api.subscribeOnce<ChannelPreview>('groups', `/v1/channels/${nest}/preview`, 5000),
60+ {
61+ // only for channels outside our groups, so wait until those are known
62+ enabled: isFetched && !!nest && !group,
63+ refetchOnMount: false,
64+ refetchOnWindowFocus: false,
65+ },
66+ );
67+
68+ return group ? {
69+ nest,
70+ meta: group[1].channels[nest].meta,
71+ group: {
72+ flag: group[0],
73+ meta: group[1].meta,
74+ }
75+ } : data;
76+}
ui/src/state/pals.ts+6−5
@@ -1,13 +1,14 @@
11import { ChargeUpdateInitial, scryCharges } from '@urbit/api';
2−import { useMutation, useQuery, useQueryClient } from 'react-query';
2+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
33import api from '../api';
4+import { CHARGES_KEY, PALS_KEY } from '../keys';
45
56interface Pal {
67 lists: string[];
78 ack: true | null;
89}
910
10−interface Pals {
11+export interface Pals {
1112 [k: string]: Pal;
1213}
1314
@@ -26,10 +27,10 @@ interface RemovePal {
2627
2728export const usePals = () => {
2829 const queryClient = useQueryClient();
29− const { data: charges } = useQuery('charges', () => {
30+ const { data: charges } = useQuery(CHARGES_KEY, () => {
3031 return api.scry<ChargeUpdateInitial>(scryCharges);
3132 })
32− const { data } = useQuery('pals', () => {
33+ const { data } = useQuery(PALS_KEY, () => {
3334 return api.scry<PalsData>({
3435 app: 'pals',
3536 path: '/json'
@@ -48,7 +49,7 @@ export const usePals = () => {
4849 })
4950 }, {
5051 onSuccess: () => {
51− queryClient.invalidateQueries('pals');
52+ queryClient.invalidateQueries(PALS_KEY);
5253 }
5354 })
5455
ui/src/state/posts.ts+73−157
@@ -1,192 +1,108 @@
1−import { Association, BigIntOrderedMap, buntPost, Content, getGraph, getKeys, Graph, GraphNode, MetadataConfig, Post, resourceFromPath, TextContent, UrlContent } from "@urbit/api";
2−import bigInt, { BigInteger } from "big-integer";
3−import removeMd from 'remove-markdown-and-html';
4−import _ from "lodash";
5−import { useQuery } from "react-query"
1+import { useQuery } from "@tanstack/react-query"
62import api from "../api"
73import { PostType, Search } from "../types/sphinx";
8−import { MetaDataUpdate, usePublicGroups } from "./groups";
4+import { usePublicGroups } from "./groups";
5+import { POST_LISTINGS_KEY, notesKey } from "../keys";
6+import { ChannelsResponse, Diary, GroupChannel, NoteContent, PagedChannelPosts, VerseInline, inlineToString } from "../types";
7+import { useMemo } from "react";
8+import { udToDec } from '@urbit/api';
9+import { MAX_DESCRIPTION_BYTES, MAX_TITLE_BYTES, listingImage, normalizeLink, truncateBytes } from "../utils";
910
10−interface Resource {
11− ship: string;
12− name: string;
13−}
14−
15−interface GraphKeyUpdate {
16− 'graph-update': {
17− keys: Resource[]
18− }
19−}
11+// notebooks are %channels channels of kind %diary, keyed by nest
12+// "diary/~ship/name" (the old %diary agent is gone from the %groups desk)
13+const DIARY_PREFIX = 'diary/';
2014
2115export const useNotebooks = () => {
22− const { data: keys, isLoading: keysLoading } = useQuery('graph-keys', async () => {
23− const json = await api.scry<GraphKeyUpdate>(getKeys())
24− const update = _.get(json, 'graph-update', false);
25− const data: Resource[] = _.get(update, 'keys', false) || [];
16+ const { groups, isLoading: groupsLoading } = usePublicGroups();
17+ const { data, isLoading } = useQuery(notesKey(), () => api.scry<ChannelsResponse>({
18+ app: 'channels',
19+ path: '/v1/channels'
20+ }));
21+
22+ const notebooks = useMemo(() => {
23+ const nbs: Record<string, GroupChannel & Diary> = {};
24+ if (!data) return nbs;
25+
26+ Object.entries(data).forEach(([nest, v]) => {
27+ if (!nest.startsWith(DIARY_PREFIX)) return;
28+ const flag = nest.slice(DIARY_PREFIX.length);
29+ const group = groups.find(([g]) => g === v.perms.group);
30+ if (!group) return;
31+
32+ const channel = group[1].channels[nest];
33+ if (!channel) return;
2634
27− return new Set(data.map((res) => {
28− const resource = res.ship + '/' + res.name;
29− return resource;
30− }));
31− });
32− const { data, isLoading: metadataLoading } = useQuery('metadata', () => api.subscribeOnce<MetaDataUpdate>('metadata-store', '/all'));
33− const { groups, loading } = usePublicGroups(data || {} as MetaDataUpdate);
35+ if (channel.readers.length === 0) {
36+ nbs[flag] = { ...v, ...channel };
37+ }
38+ });
3439
35− const notebooks: Record<string, Association<MetadataConfig>> = _.fromPairs(!data || loading ? [] : Object.entries(data['metadata-update'].associations)
36− .filter(([,v]) => v['app-name'] === 'graph'
37− && 'graph' in v.metadata.config
38− && v.metadata.config.graph === 'publish'
39− && keys?.has(v.resource.replace('/ship/~', ''))
40− && groups.find(([, gv]) => gv.group === v.group)
41− ).map(([,v]) => [v.resource.replace('/ship/', ''), v]));
40+ return nbs;
41+ }, [data, groups]);
4242
4343 return {
44− loading: metadataLoading || loading,
45− notebooks,
46− groups
44+ notebooks,
45+ groups,
46+ loading: isLoading || groupsLoading
4747 }
4848}
4949
5050export const usePosts = (ship: string, name: string, type: PostType, tags: string[]) => {
51+ const flag = `${ship}/${name}`;
5152 const { notebooks, loading } = useNotebooks();
52− const association = notebooks[`${ship}/${name}`] || {};
53− const { data, isLoading } = useQuery(`graph-${association?.resource}`, () => api.scry<Graph>(getGraph(ship, name)), {
54− enabled: !!association?.resource
55− });
56− const { data: d, isLoading: postsLoading } = useQuery('post-listings', () => api.scry<Search>({
53+ const { data, isLoading } = useQuery(notesKey(flag), () => api.scry<PagedChannelPosts>({
54+ app: 'channels',
55+ path: `/v1/${DIARY_PREFIX}${flag}/posts/newest/9.999.999/outline`
56+ }));
57+ // every listing we have, ours or a mutual's, whatever its tags or type
58+ // (%sphinx refuses to publish a listing it already has)
59+ const { data: d, isLoading: postsLoading } = useQuery(POST_LISTINGS_KEY, () => api.scry<Search>({
5760 app: 'sphinx',
58− path: '/lookup/tag/0/999/~~post'
61+ path: '/lookup/all/0/9.999.999'
5962 }));
60− const graph = addGraph(data);
61− const items = data && graph ? Array.from(graph)
62− .filter(([, node]) => node && typeof node?.post !== 'string')
63− .map(([date, node]) => {
64− const index = node.post.index.split('/');
65− const noteId = bigInt(index[1]);
66− const { title, body } = getLatestRevision(node);
67− const snippet = getSnippet(body);
68− const permalink = getPermalinkForGraph(
69− association.group,
70− association.resource,
71− `/${noteId.toString()}`
72− );
7363
74− return { key: date.toString(), post: {
64+ const items = data ? Object.entries(data.posts)
65+ .flatMap(([id, post]) => {
66+ if (!post) return []; // deleted post
67+ const diary = post.essay['kind-data']?.diary;
68+ return [{ key: id, post: {
7569 type,
76− title,
77− link: permalink,
78− image: snippet.image,
70+ title: truncateBytes(diary?.title || '', MAX_TITLE_BYTES),
71+ link: getPermalink(flag, id),
72+ image: listingImage(diary?.image),
7973 color: '',
80− description: snippet.content,
74+ description: getSnippet(post.essay.content),
8175 tags
82− }}
76+ }}];
8377 }) : [];
8478
85− const posts = items.filter(p => !d || d.listings.length === 0 || !d.listings.find(l => l.post.link === p.post.link));
79+ // listings store refs; older ones may still be web+urbitgraph links
80+ const listed = useMemo(
81+ () => new Set((d?.listings || []).map(l => normalizeLink(l.post.link))),
82+ [d]
83+ );
84+ const posts = items.filter(p => !listed.has(p.post.link));
8685
8786 return {
88− notebook: association,
87+ notebook: notebooks[flag],
8988 loading: loading || isLoading || postsLoading,
9089 posts
9190 }
9291}
9392
94−function getImage(snippet: string) {
95− const pattern = /!\[.*\]\((.*)\)/
96− const match = snippet.match(pattern);
97−
98− if (!match) {
99− return '';
100− }
101−
102− return match[1];
93+function getPermalink(flag: string, id: string): string {
94+ return `/1/chan/${DIARY_PREFIX}${flag}/note/${udToDec(id)}`;
10395}
10496
105−function getLatestRevision(node: GraphNode): { id: number, title: string, body: Content[], post: Post } {
106− const empty = { id: 1, title: '', body: [], post: buntPost() };
107− const revs = node.children?.get(bigInt(1));
108− if (!revs || revs.children === null) {
109− return empty;
110− }
111−
112− const [revNum, rev] = [...revs.children][0];
113− const title = rev.post.contents[0] as TextContent;
114− const body = rev.post.contents.slice(1);
115− return {
116− id: revNum.toJSNumber(),
117− title: title.text,
118− body,
119− post: rev.post
120− };
121−}
122−
123−export function getSnippet(body: Content[]): { image: string, content: string } {
124− const IMAGE_REGEX = new RegExp(
125− /(\.jpg|\.img|\.png|\.gif|\.tiff|\.jpeg|\.webp|\.webm|\.svg)$/i
126− );
127−
128− let sum = 0;
129− const firstContent = body.reduce((text, c) => {
130− if ('text' in c && sum < 255) {
131− sum += removeMd(c.text).length;
132− return text + c.text + ' ';
97+// the first paragraph with any text, as plain text
98+function getSnippet(content: NoteContent): string {
99+ const inlines = content.filter(c => 'inline' in c) as VerseInline[];
100+ for (const verse of inlines) {
101+ const text = verse.inline.map(inlineToString).join('').replace(/\s+/g, ' ').trim();
102+ if (text) {
103+ return truncateBytes(text, MAX_DESCRIPTION_BYTES);
133104 }
134−
135− return text;
136− }, '');
137− const image = body.filter((c: Content): c is UrlContent => 'url' in c && IMAGE_REGEX.test(c.url)).map(c => c.url)[0] || '';
138− const content = removeMd(firstContent.replace(/!\[.*\]\((.*)\)\s*/g, ''));
139− const end = content.length > 256 ? 253 : content.length;
140− let start = content.substring(0, end);
141− const length = new Blob([start]).size;
142− if (length > end) {
143− start = start.substring(0, end - (length - end))
144− }
145−
146− return {
147− content: start === content ? start : `${start}...`,
148− image: getImage(firstContent) || image
149105 }
150−}
151106
152−export function getPermalinkForGraph(
153− group: string,
154− graph: string,
155− index = ''
156−) {
157− const groupLink = getPermalinkForAssociatedGroup(group);
158− const { ship, name } = resourceFromPath(graph);
159− return `${groupLink}/graph/${ship}/${name}${index}`;
107+ return '';
160108}
161−
162−function getPermalinkForAssociatedGroup(group: string) {
163− const { ship, name } = resourceFromPath(group);
164− return `web+urbitgraph://group/${ship}/${name}`;
165−}
166−
167−const addGraph = (json: any): Graph => {
168− const update = _.get(json, 'graph-update', false);
169− const data = _.get(update, 'add-graph', false);
170− let graph = new BigIntOrderedMap<GraphNode>();
171− const items: [BigInteger, GraphNode][] = data.graph ? Object.entries(data.graph as any).map(([idx, value]) => [bigInt(idx), processNode(value as GraphNode)]) : [];
172−
173− if (data) {
174− graph = graph.gas(items);
175− }
176−
177− return graph;
178−};
179−
180−const processNode = (node: GraphNode): GraphNode => {
181− const empty = new BigIntOrderedMap<GraphNode>();
182− const items = _.map<GraphNode, [BigInteger, GraphNode]>((node.children || {}) as any, (item: GraphNode, idx: number) => {
183− return [bigInt(idx), processNode(item)]
184− })
185−
186− const newNode = {
187− ...node,
188− children: !node.children ? empty : empty.gas(items)
189− };
190−
191− return newNode;
192−};
ui/src/state/search.ts+3−3
@@ -1,9 +1,9 @@
11import { useCallback } from "react";
2−import { useQuery } from "react-query";
2+import { QueryKey, useQuery } from "@tanstack/react-query";
33import { Search } from "../types/sphinx";
44
55interface UseSearchParams {
6− key: (start: number, size: number) => string;
6+ key: (start: number, size: number) => QueryKey;
77 fetcher: (start: number, size: number) => Promise<Search>;
88 enabled: boolean;
99 linkPrefix: string;
@@ -16,7 +16,7 @@ export const useSearch = ({ key, fetcher, enabled, limit, page, linkPrefix }: Us
1616 const pageInt = parseInt(page || '1', 10) - 1;
1717 const start = pageInt * size;
1818
19− const { data, ...query } = useQuery<unknown, unknown, Search>(key(start, size), () => fetcher(start, size), {
19+ const { data, ...query } = useQuery<Search>(key(start, size), () => fetcher(start, size), {
2020 enabled,
2121 keepPreviousData: true
2222 });
ui/src/state/tags.ts+3−2
@@ -1,10 +1,11 @@
11import { useMemo } from "react";
2−import { useQuery } from "react-query";
2+import { useQuery } from "@tanstack/react-query";
33import api from "../api";
44import { Tags } from "../types/sphinx";
5+import { TAGS_KEY } from "../keys";
56
67export const useTags = () => {
7− const { data } = useQuery('tags', () => api.scry<Tags>({
8+ const { data } = useQuery(TAGS_KEY, () => api.scry<Tags>({
89 app: 'sphinx',
910 path: '/tags'
1011 }));
ui/src/types/index.tsnew file+304
@@ -0,0 +1,304 @@
1+export interface GroupMeta {
2+ title: string;
3+ description: string;
4+ image: string;
5+ cover: string;
6+}
7+
8+export interface Vessel {
9+ sects: string[];
10+ joined: number;
11+}
12+
13+export interface Fleet {
14+ [ship: string]: Vessel;
15+}
16+
17+export interface GroupChannel {
18+ meta: GroupMeta;
19+ readers: string[];
20+}
21+
22+export interface Channels {
23+ [nest: string]: GroupChannel;
24+}
25+
26+export interface Group {
27+ fleet: Fleet;
28+ channels: Channels;
29+ meta: GroupMeta;
30+ secret: boolean;
31+}
32+
33+export interface Groups {
34+ [flag: string]: Group;
35+}
36+
37+// %groups /v1/channels/<kind>/<ship>/<name>/preview (mark channel-preview-1).
38+// Previews built from our own groups only fill in flag and meta.
39+export interface GroupPreview {
40+ flag: string;
41+ meta: GroupMeta;
42+ time?: number;
43+ 'member-count'?: number;
44+ privacy?: 'public' | 'private' | 'secret';
45+}
46+
47+export interface ChannelPreview {
48+ nest: string;
49+ meta: GroupMeta;
50+ group: GroupPreview;
51+}
52+
53+export interface Ship {
54+ ship: string;
55+}
56+
57+export interface Italics {
58+ italics: Inline[];
59+}
60+
61+export interface Bold {
62+ bold: Inline[];
63+}
64+
65+export interface Strikethrough {
66+ strike: Inline[];
67+}
68+
69+export interface Break {
70+ break: null;
71+}
72+
73+export interface InlineCode {
74+ 'inline-code': string;
75+}
76+
77+export interface BlockCode {
78+ code: string;
79+}
80+
81+export interface Blockquote {
82+ blockquote: Inline[];
83+}
84+
85+/**
86+ A reference to the accompanying blocks, indexed at 0
87+*/
88+export interface BlockReference {
89+ block: {
90+ index: number;
91+ text: string;
92+ };
93+}
94+
95+export interface Tag {
96+ tag: string;
97+}
98+
99+export interface Link {
100+ link: {
101+ href: string;
102+ content: string;
103+ };
104+}
105+
106+export interface Task {
107+ task: {
108+ checked: boolean;
109+ content: Inline[];
110+ };
111+}
112+
113+// a role mention
114+export interface Sect {
115+ sect: string | null;
116+}
117+
118+export type Inline =
119+ | string
120+ | Bold
121+ | Italics
122+ | Strikethrough
123+ | Ship
124+ | Break
125+ | InlineCode
126+ | BlockCode
127+ | Blockquote
128+ | BlockReference
129+ | Tag
130+ | Link
131+ | Task
132+ | Sect;
133+
134+export type InlineKey =
135+ | 'italics'
136+ | 'bold'
137+ | 'strike'
138+ | 'blockquote'
139+ | 'inline-code'
140+ | 'block'
141+ | 'code'
142+ | 'tag'
143+ | 'link'
144+ | 'break';
145+
146+export function isBold(item: unknown): item is Bold {
147+ return typeof item === 'object' && item !== null && 'bold' in item;
148+}
149+
150+export function isItalics(item: unknown): item is Italics {
151+ return typeof item === 'object' && item !== null && 'italics' in item;
152+}
153+
154+export function isLink(item: unknown): item is Link {
155+ return typeof item === 'object' && item !== null && 'link' in item;
156+}
157+
158+export function isStrikethrough(item: unknown): item is Strikethrough {
159+ return typeof item === 'object' && item !== null && 'strike' in item;
160+}
161+
162+export function isBlockquote(item: unknown): item is Blockquote {
163+ return typeof item === 'object' && item !== null && 'blockquote' in item;
164+}
165+
166+export function isInlineCode(item: unknown): item is InlineCode {
167+ return typeof item === 'object' && item !== null && 'inline-code' in item;
168+}
169+
170+export function isBlockCode(item: unknown): item is BlockCode {
171+ return typeof item === 'object' && item !== null && 'code' in item;
172+}
173+
174+export function isBreak(item: unknown): item is Break {
175+ return typeof item === 'object' && item !== null && 'break' in item;
176+}
177+
178+export function isShip(item: unknown): item is Ship {
179+ return typeof item === 'object' && item !== null && 'ship' in item;
180+}
181+
182+export function isTag(item: unknown): item is Tag {
183+ return typeof item === 'object' && item !== null && 'tag' in item;
184+}
185+
186+export function isTask(item: unknown): item is Task {
187+ return typeof item === 'object' && item !== null && 'task' in item;
188+}
189+
190+// plain text of an inline; text runs carry their own whitespace
191+export function inlineToString(inline: Inline): string {
192+ if (typeof inline === 'string') {
193+ return inline;
194+ }
195+
196+ if (isBold(inline)) {
197+ return inline.bold.map(inlineToString).join('');
198+ }
199+
200+ if (isItalics(inline)) {
201+ return inline.italics.map(inlineToString).join('');
202+ }
203+
204+ if (isStrikethrough(inline)) {
205+ return inline.strike.map(inlineToString).join('');
206+ }
207+
208+ if (isLink(inline)) {
209+ return inline.link.content || inline.link.href;
210+ }
211+
212+ if (isBlockquote(inline)) {
213+ return inline.blockquote.map(inlineToString).join('');
214+ }
215+
216+ if (isInlineCode(inline)) {
217+ return inline['inline-code'];
218+ }
219+
220+ if (isBlockCode(inline)) {
221+ return inline.code;
222+ }
223+
224+ if (isTag(inline)) {
225+ return inline.tag;
226+ }
227+
228+ if (isTask(inline)) {
229+ return inline.task.content.map(inlineToString).join('');
230+ }
231+
232+ if (isShip(inline)) {
233+ return inline.ship;
234+ }
235+
236+ if (isBreak(inline)) {
237+ return ' ';
238+ }
239+
240+ return '';
241+}
242+
243+// story verses (lib/story-json): {inline: [...]} or {block: {...}}
244+export interface VerseInline {
245+ inline: Inline[];
246+}
247+
248+export interface VerseBlock {
249+ block: any;
250+}
251+
252+export type Verse = VerseInline | VerseBlock;
253+
254+export type NoteContent = Verse[];
255+
256+export interface DiaryPerm {
257+ writers: string[];
258+ group: string;
259+}
260+
261+export interface Diary {
262+ perms: DiaryPerm;
263+}
264+
265+// %channels /v1/channels (mark %channels): keyed by nest "<kind>/~ship/name";
266+// posts are always empty here
267+export interface ChannelSummary extends Diary {
268+ posts: Record<string, unknown>;
269+ order: string[];
270+ view: string;
271+ sort: string;
272+}
273+
274+export interface ChannelsResponse {
275+ [nest: string]: ChannelSummary;
276+}
277+
278+// %channels /v1/<kind>/~ship/name/posts/newest/<count>/outline
279+// (mark %channel-posts)
280+export interface ChannelEssay {
281+ content: NoteContent;
282+ author: string;
283+ sent: number;
284+ 'kind-data': {
285+ diary?: { title: string; image: string };
286+ heap?: string | null;
287+ chat?: { notice: null } | null;
288+ };
289+}
290+
291+export interface ChannelPost {
292+ seal: { id: string };
293+ revision: string;
294+ essay: ChannelEssay;
295+ type: 'post';
296+}
297+
298+export interface PagedChannelPosts {
299+ // keyed by @ud post id ("170.141..."); null for a deleted post
300+ posts: { [id: string]: ChannelPost | null };
301+ newer: string | null;
302+ older: string | null;
303+ total: number;
304+}
ui/src/utils.ts+189−2
@@ -1,4 +1,6 @@
1−import { stringToTa } from "@urbit/api";
1+import { stringToTa, uxToHex } from "@urbit/api";
2+import { parseToRgba } from "color2k";
3+import { isValidPatp } from "urbit-ob";
24
35export function encodeLookup(value: string | undefined) {
46 if (!value) {
@@ -6,4 +8,189 @@ export function encodeLookup(value: string | undefined) {
68 }
79
810 return stringToTa(value).replace('~.', '~~');
9−}
11+}
12+
13+export function isColor(color: string): boolean {
14+ try {
15+ parseToRgba(color);
16+ return true;
17+ } catch (error) {
18+ return false;
19+ }
20+}
21+
22+export function getColor(image: string): string | undefined {
23+ return isColor(image) ? image : undefined;
24+}
25+
26+export function getImage(image: string): string | undefined {
27+ return isColor(image) ? undefined : image;
28+}
29+
30+export function normalizeColor(color: string): string {
31+ if (color.startsWith('#')) {
32+ return color;
33+ }
34+
35+ return `#${uxToHex(color)}`
36+}
37+
38+export function normalizeLink(link: string): string {
39+ // ref or URL format
40+ if (!link.startsWith('web+urbitgraph://') || link.startsWith('http')) {
41+ return link;
42+ }
43+
44+ // old format
45+ const normLink = link.replace('web+urbitgraph://', '');
46+ const parts = normLink.split('/');
47+ const first = parts[0];
48+
49+ if (first !== 'group') {
50+ return `/1/desk/${normLink}`;
51+ }
52+
53+ if (parts.length === 3) {
54+ return `/1/group/${parts[1]}/${parts[2]}`;
55+ }
56+
57+ return `/1/chan/diary/${parts[4]}/${parts[5]}/note/${parts[6]}`
58+}
59+
60+// Tlon web routes (tlon-apps packages/app/navigation/linking.ts):
61+// /apps/groups/group/:groupId[/channel/:channelId], ids URI-encoded.
62+export function groupURL(flag: string): string {
63+ return `/apps/groups/group/${encodeURIComponent(flag)}`;
64+}
65+
66+export function channelURL(flag: string, nest: string): string {
67+ return `${groupURL(flag)}/channel/${encodeURIComponent(nest)}`;
68+}
69+
70+export function refToURL(cite: string): string | { nest: string, where: string } {
71+ // plain web links (e.g. manual listings) are used as they are
72+ if (/^https?:\/\//i.test(cite)) {
73+ return cite;
74+ }
75+
76+ const parts = cite.slice(1).split('/');
77+ if (parts[0] !== '1') {
78+ return '';
79+ }
80+
81+ const type = parts[1];
82+ if (type === 'desk') {
83+ const ship = parts[2];
84+ const desk = parts[3];
85+ return `/apps/landscape/search/${ship}/apps/${ship}/${desk}`;
86+ }
87+
88+ if (type === 'group') {
89+ const ship = parts[2];
90+ const name = parts[3];
91+ return groupURL(`${ship}/${name}`);
92+ }
93+
94+ const chanType = parts[2];
95+ const ship = parts[3];
96+ const name = parts[4];
97+ return {
98+ nest: `${chanType}/${ship}/${name}`,
99+ where: parts.slice(5).join('/'),
100+ }
101+}
102+
103+// a number as Hoon prints @ud: `slav %ud` reads 1.000 but not 1000
104+// (@urbit/api's decToUd turns 0 into '')
105+export function formatUd(n: number): string {
106+ return String(n).replace(/\B(?=(\d{3})+$)/g, '.');
107+}
108+
109+const encoder = new TextEncoder();
110+
111+// lib/sphinx +lengths-valid limits, in UTF-8 bytes
112+export const MAX_TITLE_BYTES = 77;
113+export const MAX_DESCRIPTION_BYTES = 256;
114+export const MAX_URL_BYTES = 1024;
115+export const MAX_TAGS = 8;
116+// the longest tag +catalog indexes
117+export const MAX_TAG_BYTES = 64;
118+
119+export function byteLength(text: string): number {
120+ return encoder.encode(text).length;
121+}
122+
123+// lib/validate-url (de-purl:html in zuse) as regular expressions: an optional
124+// scheme://host[:port] (host a DNS name or IPv4, or a ~ship without a port),
125+// then an optional /path and ?key=value&key=value query. No #fragment, no
126+// spaces or non-ASCII, none of | [ ] { } ^ ` \ and no '=' inside a value.
127+// %sphinx silently drops a listing whose image or link fails this.
128+const QUERY_CHAR = "(?:[A-Za-z0-9_.!~*'()+/?:,-]|%[0-9A-Fa-f]{2})";
129+const PATH_CHAR = "(?:[A-Za-z0-9_.!~*'()$&+,;=:@/-]|%[0-9A-Fa-f]{2})";
130+const PAIR = `${QUERY_CHAR}+(?:=${QUERY_CHAR}*)?`;
131+const PATH_QUERY = `(?:/${PATH_CHAR}*)?(?:[?](?:${PAIR}(?:[&;]${PAIR})*)?)?`;
132+const SCHEME = '[A-Za-z][A-Za-z0-9+.-]*://';
133+const LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?';
134+const LAST_LABEL = '[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?';
135+const OCTET = '(?:0|[1-9][0-9]{0,2})';
136+const HOST = `(?:(?:${LABEL}[.])*${LAST_LABEL}|${OCTET}(?:[.]${OCTET}){3})(?::(?:0|[1-9][0-9]*))?`;
137+// the shape of a @p; isListingLink checks the syllables with isValidPatp
138+const SHIP = '~[a-z]{3}(?:[a-z]{3})?(?:--?[a-z]{6})*';
139+const IMAGE_RE = new RegExp(`^${SCHEME}${HOST}${PATH_QUERY}$`);
140+const LINK_RE = new RegExp(`^(?:${SCHEME}(?:${HOST}|(${SHIP})))?${PATH_QUERY}$`);
141+
142+export function isListingImage(image: string): boolean {
143+ return !image || (byteLength(image) <= MAX_URL_BYTES && IMAGE_RE.test(image));
144+}
145+
146+export function isListingLink(link: string): boolean {
147+ if (byteLength(link) > MAX_URL_BYTES) {
148+ return false;
149+ }
150+
151+ const match = LINK_RE.exec(link);
152+ return !!match && (!match[1] || isValidPatp(match[1]));
153+}
154+
155+// A listing image %sphinx accepts, or '' (so the listing goes out without
156+// one): http(s) URLs are percent-encoded by URL and lose their #fragment;
157+// anything else, data: URIs included (docket images may be one), is dropped.
158+export function listingImage(image: string | null | undefined): string {
159+ if (!image) {
160+ return '';
161+ }
162+
163+ try {
164+ const url = new URL(image);
165+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
166+ return '';
167+ }
168+
169+ url.hash = '';
170+ return isListingImage(url.href) ? url.href : '';
171+ } catch (e) {
172+ return '';
173+ }
174+}
175+
176+// %sphinx measures listing fields in UTF-8 bytes (lib/sphinx +lengths-valid)
177+// and silently drops listings that are too long, so cut text to at most `max`
178+// bytes on a code point boundary, marking the cut with an ellipsis.
179+export function truncateBytes(text: string, max: number): string {
180+ if (encoder.encode(text).length <= max) {
181+ return text;
182+ }
183+
184+ const ellipsis = '...';
185+ let out = '';
186+ let size = 0;
187+ for (const char of text) {
188+ size += encoder.encode(char).length;
189+ if (size > max - ellipsis.length) {
190+ break;
191+ }
192+ out += char;
193+ }
194+
195+ return `${out.trimEnd()}${ellipsis}`;
196+}
ui/vite.config.ts+7−1
@@ -9,6 +9,12 @@ export default ({ mode }) => {
99 console.log(SHIP_URL);
1010
1111 return defineConfig({
12− plugins: [urbitPlugin({ base: 'sphinx', target: SHIP_URL, changeOrigin: true, secure: false }), reactRefresh()]
12+ plugins: [urbitPlugin({ base: 'sphinx', target: SHIP_URL, changeOrigin: true, secure: false }), reactRefresh()],
13+ // The entry chunk has no import or export, so esbuild 0.14 minifies it as
14+ // a sloppy-mode script and hoists the block-level functions in
15+ // @radix-ui/react-focus-scope and react-use-body-pointer-events in random
16+ // order: rebuilds would give a different bundle and glob hash. Browsers
17+ // run the bundle as a module, which is strict anyway.
18+ build: { rollupOptions: { output: { banner: '"use strict";' } } }
1319 });
1420};