Merge pull request #5206 from urbit/lf/nu-hark-store

hark-store: revise for third party distro
This commit is contained in:
Liam Fitzgerald 2021-09-17 12:58:38 +10:00 committed by GitHub
commit 5e5e0cb681
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
81 changed files with 18203 additions and 63097 deletions

8839
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,6 @@
"build:prod": "lerna run build:prod"
},
"lint-staged": {
"*.{js,ts,tsx}": "eslint --cache --fix"
"*.{js,ts,tsx}": "eslint --cache --fix --quiet"
}
}

View File

@ -119,6 +119,16 @@
^- card
[%give %fact paths cage]
::
++ fact-all
|= =cage
^- (unit card)
=/ paths=(set path)
%- ~(gas in *(set path))
%+ turn ~(tap by sup.bowl)
|=([duct ship =path] path)
?: =(~ paths) ~
`(fact cage ~(tap in paths))
::
++ kick
|= paths=(list path)
[%give %kick paths ~]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,159 @@
^?
::
:: %hark-store: Notification, unreads store
::
:: Timeboxing & binning:
::
:: Unread notifications accumulate in $unreads. They are grouped by
:: their $bin. A notification may become read by either:
:: a) being read by a %read-count or %read-each or %read-note
:: b) being read by a %seen
::
:: If a) then we insert the corresponding bin into $reads at the
:: current timestamp
:: If b) then we empty $unreads and move all bins to $reads at the
:: current timestamp
::
:: Unread tracking:
:: Unread tracking has two 'modes' which may be used concurrently,
:: if necessary.
::
:: count:
:: This stores the unreads as a simple atom, describing the number
:: of unread items. May be increased with %unread-count and
:: set to zero with %read-count. Ideal for high-frequency linear
:: datastructures, e.g. chat
:: each:
:: This stores the unreads as a set of paths, describing the set of
:: unread items. Unreads may be added to the set with %unread-each
:: and removed with %read-each. Ideal for non-linear, low-frequency
:: datastructures, e.g. blogs
::
|%
:: $place: A location, under which landscape stores stats
::
:: .desk must match q.byk.bowl
:: Examples:
:: A chat:
:: [%landscape /~dopzod/urbit-help]
:: A note in a notebook:
:: [%landscape /~darrux-landes/feature-requests/12374893234232]
:: A group:
:: [%hark-group-hook /~bitbet-bolbel/urbit-community]
:: Comments on a link
:: [%landscape /~dabben-larbet/urbit-in-the-news/17014118450499614194868/2]
::
+$ place [=desk =path]
::
:: $bin: Identifier for grouping notifications
::
:: Examples
:: A mention in a chat:
:: [/mention %landscape /~dopzod/urbit-help]
:: New messages in a chat
:: [/message %landscape /~dopzod/urbit-help]
:: A new comment in a notebook:
:: [/comment %landscape /~darrux-landes/feature-requests/12374893234232/2]
::
+$ bin [=path =place]
::
:: $lid: Reference to a timebox
::
+$ lid
$% [%archive =time]
[%seen ~]
[%unseen ~]
==
:: $content: Notification content
+$ content
$% [%ship =ship]
[%text =cord]
==
::
:: $body: A notification body
::
+$ body
$: title=(list content)
content=(list content)
=time
binned=path
link=path
==
::
+$ notification
[date=@da =bin body=(list body)]
:: $timebox: Group of notificatons
+$ timebox
(map bin notification)
:: $archive: Archived notifications, ordered by time
+$ archive
((mop @da timebox) gth)
::
+$ action
$% :: hook actions
::
:: %add-note: add a notification
[%add-note =bin =body]
::
:: %del-place: Underlying resource disappeared, remove all
:: associated notifications
[%del-place =place]
:: %unread-count: Change unread count by .count
[%unread-count =place inc=? count=@ud]
:: %unread-each: Add .path to list of unreads for .place
[%unread-each =place =path]
:: %saw-place: Update last-updated for .place to now.bowl
[%saw-place =place time=(unit time)]
:: store actions
::
:: %archive: archive single notification
:: if .time is ~, then archiving unread notification
:: else, archiving read notification
[%archive =lid =bin]
:: %read-count: set unread count to zero
[%read-count =place]
:: %read-each: remove path from unreads for .place
[%read-each =place =path]
:: %read-note: Read note at .bin
[%read-note =bin]
:: %archive-all: Archive all notifications
[%archive-all ~]
:: %opened: User opened notifications, reset timeboxing logic.
::
[%opened ~]
::
:: XX: previously in hark-store, now deprecated
:: the hooks responsible for creating notifications may offer pokes
:: similar to this
:: [%read-graph =resource]
:: [%read-group =resource]
:: [%remove-graph =resource]
::
==
:: .stats: Statistics for a .place
::
+$ stats
$: count=@ud
each=(set path)
last=@da
timebox=(unit @da)
==
::
+$ update
$% action
:: %more: more updates
[%archived =time =lid =notification]
[%more more=(list update)]
:: %note-read: note has been read with timestamp
[%note-read =time =bin]
[%added =notification]
:: %timebox: description of timebox.
::
[%timebox =lid =(list notification)]
:: %place-stats: description of .stats for a .place
[%place-stats =place =stats]
:: %place-stats: stats for all .places
[%all-stats places=(map place stats)]
==
--

View File

@ -25,7 +25,7 @@
--
^- agent:gall
%- agent:dbug
%+ verb &
%+ verb |
=| inflated-state
=* state -
=<

View File

@ -0,0 +1,518 @@
:: hark-store: notifications and unread counts [landscape]
::
:: hark-store can store unread counts differently, depending on the
:: resource.
:: - last seen. This way, hark-store simply stores an index into
:: graph-store, which represents the last "seen" item, useful for
:: high-volume applications which are intrinsically time-ordered. i.e.
:: chats, comments
:: - each. Hark-store will store an index for each item that is unread.
:: Usefull for non-linear, low-volume applications, i.e. blogs,
:: collections
::
/- store=hark-store
/+ verb, dbug, default-agent, re=hark-unreads, agentio
::
::
~% %hark-store-top ..part ~
|%
+$ card card:agent:gall
+$ versioned-state
$% state-2
state-3
state-4
state-5
state-6
state-7
state-8
==
::
+$ base-state
$: places=(map place:store stats:store)
seen=timebox:store
unseen=timebox:store
=archive:store
half-open=(map bin:store @da)
==
::
+$ state-2
[%2 *]
::
+$ state-3
[%3 *]
::
+$ state-4
[%4 *]
::
+$ state-5
[%5 *]
::
+$ state-6
[%6 *]
::
+$ state-7
[%7 *]
::
+$ state-8
[%8 base-state]
::
::
+$ cached-state
$: by-place=(jug place:store [=lid:store =path])
~
==
+$ inflated-state
[state-8 cached-state]
::
++ orm ((ordered-map @da timebox:store) gth)
--
::
=| inflated-state
=* state -
::
=<
%+ verb &
%- agent:dbug
^- agent:gall
~% %hark-store-agent ..card ~
|_ =bowl:gall
+* this .
ha ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
io ~(. agentio bowl)
pass pass:io
::
++ on-init
=. current-timebox now.bowl
`this
::
++ on-save !>(-.state)
++ on-load
|= =old=vase
=/ old
!<(versioned-state old-vase)
=| cards=(list card)
|^ ^- (quip card _this)
?: ?=(%8 -.old)
=. -.state old
=. +.state inflate
:_(this (flop cards))
::
:_ this
(poke-our:pass %hark-graph-hook hark-graph-migrate+old-vase)^~
::
++ index-timebox
|= [=lid:store =timebox:store out=_by-place]
^+ by-place
%+ roll ~(tap by timebox)
|= [[=bin:store =notification:store] out=_out]
(~(put ju out) place.bin [lid path.bin])
::
++ inflate
=. by-place (index-timebox seen+~ seen by-place)
=. by-place (index-timebox unseen+~ unseen by-place)
=. by-place
%+ roll (tap:orm archive)
|= [[=time =timebox:store] out=_by-place]
(index-timebox archive/time timebox out)
+.state
--
::
++ on-watch
|= =path
^- (quip card _this)
?> (team:title [src our]:bowl)
|^
?+ path (on-watch:def path)
[%notes ~] `this
::
[%updates ~]
:_ this
[%give %fact ~ hark-update+!>(initial-updates)]~
::
==
::
++ initial-updates
^- update:store
:- %more
^- (list update:store)
:~ [%timebox unseen+~ ~(val by unseen)]
[%timebox seen+~ ~(val by seen)]
[%all-stats places]
==
--
::
++ on-peek
|= =path
^- (unit (unit cage))
?+ path (on-peek:def path)
::
[%x %recent %inbox @ @ ~]
=/ date=@da
(slav %ud i.t.t.t.path)
=/ length=@ud
(slav %ud i.t.t.t.t.path)
:^ ~ ~ %hark-update
!> ^- update:store
:- %more
%+ turn (tab:orm archive `date length)
|= [time=@da =timebox:store]
^- update:store
[%timebox archive+time ~(val by timebox)]
==
::
++ on-poke
~/ %hark-store-poke
|= [=mark =vase]
^- (quip card _this)
|^
?> (team:title our.bowl src.bowl)
=^ cards state
?+ mark (on-poke:def mark vase)
%hark-action (hark-action !<(action:store vase))
%noun (poke-noun !<(* vase))
==
[cards this]
::
++ poke-noun
|= val=*
?+ val ~|(%bad-noun-poke !!)
%print ~&(+.state [~ state])
%clear [~ state(. *inflated-state)]
==
::
++ poke-us
|= =action:store
^- card
[%pass / %agent [our dap]:bowl %poke hark-action+!>(action)]
::
++ hark-action
|= =action:store
^- (quip card _state)
abet:(abed:poke-engine:ha action)
--
::
++ on-agent on-agent:def
::
++ on-leave on-leave:def
++ on-arvo
|= [=wire =sign-arvo]
^- (quip card _this)
?. ?=([%autoseen ~] wire)
(on-arvo:def wire sign-arvo)
`this
::
++ on-fail on-fail:def
--
|_ =bowl:gall
+* met ~(. metadata bowl)
io ~(. agentio bowl)
pass pass:io
++ poke-engine
|_ [out=(list update:store) cards=(list card)]
++ poke-core .
::
++ abed
|= in=action:store
^+ poke-core
?- -.in
::
%add-note (add-note +.in)
%del-place (del-place +.in)
%archive (do-archive +.in)
::
%unread-count (unread-count +.in)
%read-count (read-count +.in)
::
%read-each (read-each +.in)
%unread-each (unread-each +.in)
::
%read-note (read-note +.in)
::
%saw-place (saw-place +.in)
::
%opened opened
%archive-all archive-all
::
==
::
++ abet
^- (quip card _state)
:_ state
%+ snoc (flop cards)
[%give %fact ~[/updates] %hark-update !>([%more (flop out)])]
::
++ give |=(=update:store poke-core(out [update out]))
++ emit |=(=card poke-core(cards [card cards]))
::
::
:: +| %note
::
:: notification tracking
++ put-notifs
|= [time=@da =timebox:store]
poke-core(archive (put:orm archive time timebox))
::
++ put-lid
|= [=lid:store =bin:store =notification:store]
^+ poke-core
=. by-place (~(put ju by-place) place.bin [lid path.bin])
?- -.lid
%seen
poke-core(seen (~(put by seen) bin notification))
::
%unseen
poke-core(unseen (~(put by unseen) bin notification))
::
%archive
poke-core(archive (~(put re archive) time.lid bin notification))
==
::
++ del-lid
|= [=lid:store =bin:store]
=. by-place (~(del ju by-place) place.bin [lid path.bin])
?- -.lid
%seen poke-core(seen (~(del by seen) bin))
%unseen poke-core(unseen (~(del by unseen) bin))
%archive poke-core(archive (~(del re archive) time.lid bin))
==
::
++ add-note
|= [=bin:store =body:store]
^+ poke-core
=. poke-core
(emit (fact:io hark-update+!>([%add-note bin body]) /notes ~))
=. by-place
(~(put ju by-place) place.bin unseen+~ path.bin)
=/ existing-notif
(~(gut by unseen) bin *notification:store)
=/ new=notification:store
[now.bowl bin [body body.existing-notif]]
=. unseen
(~(put by unseen) bin new)
(give %added new)
::
++ del-place
|= =place:store
=. poke-core (give %del-place place)
=/ notes=(list [=lid:store =path])
~(tap in (~(get ju by-place) place))
|- ^+ poke-core
?~ notes poke-core
=, i.notes
=. poke-core
(del-lid lid path place)
$(notes t.notes)
::
++ do-archive
|= [=lid:store =bin:store]
^+ poke-core
~| %already-archived
?< ?=(%time -.lid)
~| %non-existent
=/ =notification:store (need (get-lid lid bin))
=. poke-core (del-lid lid bin)
=. poke-core (put-lid archive+now.bowl bin notification)
=? poke-core ?=(%unseen -.lid)
?~ n=(get-lid seen+~ bin) poke-core
=. archive
%^ ~(job re archive) now.bowl bin
|= og=(unit notification:store)
(merge-notification og u.n)
poke-core
(give %archived now.bowl lid notification)
::
++ read-note
|= =bin:store
=/ =notification:store
(~(got by unseen) bin)
=. unseen
(~(del by unseen) bin)
=/ =time
(fall timebox:(gut-place place.bin) now.bowl)
=. date.notification time
=. archive (~(put re archive) time bin notification)
(give %note-read time bin)
::
::
:: +| %each
::
:: each unread tracking
::
++ unread-each
|= [=place:store =path]
=. poke-core (saw-place place ~)
=. poke-core (give %unread-each place path)
%+ jub-place place
|=(=stats:store stats(each (~(put in each.stats) path)))
::
++ read-index-each
|= [=place:store =path]
%- read-bins
%+ skim
~(tap in ~(key by unseen))
|= =bin:store
?. =(place place.bin) %.n
=/ not=notification:store
(~(got by unseen) bin)
(lien body.not |=(=body:store =(binned.body path)))
::
++ read-each
|= [=place:store =path]
=. poke-core (read-index-each place path)
=. poke-core (give %read-each place path)
%+ jub-place place
|= =stats:store
%_ stats
timebox `now.bowl
each (~(del in each.stats) path)
==
::
++ gut-place
|= =place:store
?: (~(has by places) place) (~(got by places) place)
=| def=stats:store
def(timebox ~, last now.bowl)
::
++ jub-place
|= $: =place:store
f=$-(stats:store stats:store)
==
^+ poke-core
=/ =stats:store
(gut-place place)
poke-core(places (~(put by places) place (f stats)))
::
++ unread-count
|= [=place:store inc=? count=@ud]
=. poke-core
(give %unread-count place inc count)
=. poke-core (saw-place place ~)
=/ f
?: inc (cury add count)
(curr sub count)
%+ jub-place place
|= =stats:store
stats(count (f count.stats))
::
++ half-archive
|= =place:store
=/ bins=(list [=lid:store =path])
~(tap in (~(get ju by-place) place))
|-
?~ bins poke-core
=/ =bin:store
[path.i.bins place]
=* lid lid.i.bins
?: ?=(%archive -.lid)
$(bins t.bins)
=/ seen-place (~(get by seen) bin)
=/ n=(unit notification:store) (get-lid lid bin)
?~ n $(bins t.bins)
=* note u.n
=/ =time (~(gut by half-open) bin now.bowl)
=? half-open !(~(has by half-open) bin)
(~(put by half-open) bin now.bowl)
=. archive
%^ ~(job re archive) time bin
|=(n=(unit notification:store) (merge-notification n note))
=. by-place (~(put ju by-place) place [archive/now.bowl path.bin])
=. poke-core (give %archived now.bowl unseen+~ (~(got re archive) time bin))
=. poke-core (give %archived now.bowl seen+~ (~(got re archive) time bin))
$(bins t.bins)
::
++ read-count
|= =place:store
=. poke-core (give %read-count place)
=. poke-core (half-archive place)
%+ jub-place place
|= =stats:store
stats(count 0, timebox `now.bowl)
::
++ read-bins
|= bins=(list bin:store)
|-
?~ bins poke-core
=/ core
(read-note i.bins)
$(poke-core core, bins t.bins)
::
++ saw-place
|= [=place:store time=(unit time)]
=. poke-core (give %saw-place place time)
%+ jub-place place
|=(=stats:store stats(last (fall time now.bowl)))
::
++ archive-seen
=/ seen=(list [=bin:store =notification:store]) ~(tap by seen)
poke-core
::
++ opened
=. seen
%- ~(gas by *timebox:store)
%+ murn ~(tap in (~(uni in ~(key by seen)) ~(key by unseen)))
|= =bin:store
=/ se (~(get by seen) bin)
=/ un (~(get by unseen) bin)
?~ un
?~(se ~ `[bin u.se])
`[bin (merge-notification se u.un)]
=. unseen ~
=. poke-core (turn-places |=(=stats:store stats(timebox ~)))
(give %opened ~)
::
++ archive-all
(give:opened %archive-all ~)
::
++ turn-places
|= f=$-(stats:store stats:store)
=/ places ~(tap in ~(key by places))
|- ^+ poke-core
?~ places poke-core
=/ core=_poke-core (jub-place i.places f)
$(poke-core core, places t.places)
--
::
++ get-lid
|= [=lid:store =bin:store]
=/ =timebox:store ?:(?=(%unseen -.lid) unseen seen)
(~(get by timebox) bin)
::
++ merge-notification
|= [existing=(unit notification:store) new=notification:store]
^- notification:store
?~ existing new
[(max date.u.existing date.new) bin.new (welp body.new body.u.existing)]
::
:: +key-orm: +key:by for ordered maps
++ key-orm
|= =archive:store
^- (list @da)
(turn (tap:orm archive) |=([@da *] +<-))
::
:: +gut-orm: +gut:by for ordered maps
:: TODO: move to zuse.hoon
++ gut-orm
|= [=archive:store time=@da]
^- timebox:store
(fall (get:orm archive time) ~)
::
::
++ scry
|* [=mold p=path]
?> ?=(^ p)
?> ?=(^ t.p)
.^(mold i.p (scot %p our.bowl) i.t.p (scot %da now.bowl) t.t.p)
::
++ give
|= [paths=(list path) update=update:store]
^- (list card)
[%give %fact paths [%hark-update !>(update)]]~
::
++ tap-nonempty
|= =archive:store
^- (list [@da timebox:store])
%+ skim (tap:orm archive)
|=([@da =timebox:store] !=(~(wyt by timebox) 0))
--

View File

@ -0,0 +1,171 @@
/- hark=hark-store, hood, docket
/+ verb, dbug, default-agent, agentio
|%
+$ card card:agent:gall
+$ state-0 [%0 lagging=_|]
::
++ lag-interval ~m10
--
%+ verb &
%- agent:dbug
^- agent:gall
=| state-0
=* state -
=<
|_ =bowl:gall
+* this .
def ~(. (default-agent this %|) bowl)
io ~(. agentio bowl)
pass pass:io
cc ~(. +> bowl)
++ on-init
^- (quip card _this)
:_ this
[onboard watch:kiln check:lag ~]:cc
::
++ on-load
|= =vase
=+ !<(old=state-0 vase)
`this(state old)
::
++ on-save !>(state)
++ on-poke on-poke:def
++ on-peek on-peek:def
++ on-watch on-watch:def
++ on-agent
|= [=wire =sign:agent:gall]
|^
?+ wire (on-agent:def wire sign)
[%kiln %vats ~] take-kiln-vats
==
++ take-kiln-vats
?- -.sign
?(%poke-ack %watch-ack) (on-agent:def wire sign)
%kick :_(this (drop safe-watch:kiln:cc))
::
%fact
?. ?=(%kiln-vats-diff p.cage.sign) `this
=+ !<(=diff:hood q.cage.sign)
?+ -.diff `this
::
%merge
=/ =action:hark ~(updated de:cc desk.diff)
:_ this
~[(poke:ha:cc action)]
::
%block
=/ =action:hark (~(blocked de:cc desk.diff) blockers.diff)
:_ this
~[(poke:ha:cc action)]
==
==
--
::
++ on-arvo
|= [=wire sign=sign-arvo]
^- (quip card _this)
?. ?=([%check-lag ~] wire) (on-arvo:def wire sign)
?> ?=([%behn %wake *] sign)
=+ .^(lag=? %$ (scry:io %$ /zen/lag))
?: =(lagging lag) :_(this ~[check:lag:cc])
:_ this(lagging lag)
:_ ~[check:lag:cc]
?:(lagging start:lag:cc stop:lag:cc)
::
++ on-fail on-fail:def
++ on-leave on-leave:def
--
|_ =bowl:gall
+* io ~(. agentio bowl)
pass pass:io
::
++ onboard
^- card
%- poke:ha
:+ %add-note [/ [q.byk.bowl /onboard]]
:: We special case this in the grid UI, but should include something
:: for third parties
[~[text+'Welcome to urbit'] ~ now.bowl / /]
::
++ lag
|%
++ check (~(wait pass /check-lag) (add now.bowl lag-interval))
++ place [q.byk.bowl /lag]
++ body `body:hark`[~[text/'Runtime lagging'] ~ now.bowl / /]
++ start (poke:ha %add-note [/ place] body)
++ stop (poke:ha %del-place place)
--
++ ha
|%
++ pass ~(. ^pass /hark)
++ poke
|=(=action:hark (poke-our:pass %hark-store hark-action+!>(action)))
--
++ kiln
|%
++ path /kiln/vats
++ pass ~(. ^pass path)
++ watch (watch-our:pass %hood path)
++ watching (~(has by wex.bowl) [path our.bowl %hood])
++ safe-watch `(unit card)`?:(watching ~ `watch)
--
::
++ de
|_ =desk
++ scry-path (scry:io desk /desk/docket)
++ has-docket .^(? %cu scry-path)
++ docket .^(docket:^docket %cx scry-path)
++ hash .^(@uv %cz (scry:io desk ~))
++ place `place:hark`[q.byk.bowl /desk/[desk]]
++ body
|= [=path title=cord content=cord]
^- body:hark
[~[text+title] ~[text+content] now.bowl ~ path]
::
::
++ title-prefix
|= =cord
?: =(desk %base)
'System software'
?: has-docket
(rap 3 'App: ' title:docket cord ~)
(rap 3 'Desk: ' desk cord ~)
::
++ updated
^- action:hark
:+ %add-note [/update place]
%^ body /desk/[desk] (title-prefix ' has been updated')
?: has-docket
(rap 3 'Version: ' (ver version:docket) ~)
(rap 3 'Hash: ' (scot %uv hash) ~)
::
++ blocked
|= blockers=(set ^desk)
^- action:hark
:+ %add-note [/blocked place]
%^ body /blocked (title-prefix ' is blocked from upgrading')
(rap 3 'Blocking desks: ' (join ', ' ~(tap in blockers)))
::
++ ver
|= =version:^docket
=, version
`@t`(rap 3 (num major) '.' (num minor) '.' (num patch) ~)
::
++ num
|= a=@ud
`@t`(rsh 4 (scot %ui a))
--
++ note
|%
++ merge
|= [=desk hash=@uv]
^- (list body:hark)
:_ ~
:* ~[text+'Desk Updated']
~[text+(crip "Desk {(trip desk)} has been updated to hash {(scow %uv hash)}")]
now.bowl
/update/[desk]
/
==
--
--

View File

@ -1,6 +1,8 @@
:~ :- %apes
:~ %docket
%treaty
%hark-store
%hark-system-hook
%settings-store
==
:- %fish ~

View File

@ -0,0 +1,234 @@
/- sur=hark-store
^?
=, sur
=< [. sur]
|%
++ enjs
=, enjs:format
|%
++ update
|= upd=^update
^- json
|^
%+ frond -.upd
?+ -.upd a+~
%added (notification +.upd)
%add-note (add-note +.upd)
%timebox (timebox +.upd)
%more (more +.upd)
%read-each (read-each +.upd)
%read-count (place +.upd)
%unread-each (read-each +.upd)
%unread-count (unread-count +.upd)
%saw-place (saw-place +.upd)
%all-stats (all-stats +.upd)
::%read-note (index +.upd)
::%note-read (note-read +.upd)
%archived (archived +.upd)
==
::
++ add-note
|= [bi=^bin bo=^body]
%- pairs
:~ bin+(bin bi)
body+(body bo)
==
::
++ saw-place
|= [p=^place t=(unit ^time)]
%- pairs
:~ place+(place p)
time+?~(t ~ (time u.t))
==
::
++ archived
|= [t=^time l=^lid n=^notification]
%- pairs
:~ lid+(lid l)
time+s+(scot %ud t)
notification+(notification n)
==
::
++ note-read
|= *
(pairs ~)
::
++ all-stats
|= places=(map ^place ^stats)
^- json
:- %a
^- (list json)
%+ turn ~(tap by places)
|= [p=^place s=^stats]
%- pairs
:~ stats+(stats s)
place+(place p)
==
::
++ stats
|= s=^stats
^- json
%- pairs
:~ each+a+(turn ~(tap in each.s) (cork spat (lead %s)))
last+(time last.s)
count+(numb count.s)
==
++ more
|= upds=(list ^update)
^- json
a+(turn upds update)
::
++ place
|= =^place
%- pairs
:~ desk+s+desk.place
path+s+(spat path.place)
==
::
++ bin
|= =^bin
%- pairs
:~ place+(place place.bin)
path+s+(spat path.bin)
==
++ notification
|= ^notification
^- json
%- pairs
:~ time+(time date)
bin+(^bin bin)
body+(bodies body)
==
++ bodies
|= bs=(list ^body)
^- json
a+(turn bs body)
::
++ contents
|= cs=(list ^content)
^- json
a+(turn cs content)
::
++ content
|= c=^content
^- json
%+ frond -.c
?- -.c
%ship s+(scot %p ship.c)
%text s+cord.c
==
::
++ body
|= ^body
^- json
%- pairs
:~ title+(contents title)
content+(contents content)
time+(^time time)
link+s+(spat link)
==
::
++ binned-notification
|= [=^bin =^notification]
%- pairs
:~ bin+(^bin bin)
notification+(^notification notification)
==
++ lid
|= l=^lid
^- json
%+ frond -.l
?- -.l
?(%seen %unseen) ~
%archive s+(scot %ud time.l)
==
::
++ timebox
|= [li=^lid l=(list ^notification)]
^- json
%- pairs
:~ lid+(lid li)
notifications+a+(turn l notification)
==
::
++ read-each
|= [p=^place pax=^path]
%- pairs
:~ place+(place p)
path+(path pax)
==
::
++ unread-count
|= [p=^place inc=? count=@ud]
%- pairs
:~ place+(place p)
inc+b+inc
count+(numb count)
==
--
--
++ dejs
=, dejs:format
|%
:: TODO: fix +stab
::
++ pa
|= j=json
^- path
?> ?=(%s -.j)
?: =('/' p.j) /
(stab p.j)
::
++ place
%- ot
:~ desk+so
path+pa
==
::
++ bin
%- ot
:~ path+pa
place+place
==
::
++ read-each
%- ot
:~ place+place
path+pa
==
::
:: parse date as @ud
:: TODO: move to zuse
++ sd
|= jon=json
^- @da
?> ?=(%s -.jon)
`@da`(rash p.jon dem:ag)
::
++ lid
%- of
:~ archive+sd
unseen+ul
seen+ul
==
::
++ archive
%- ot
:~ lid+lid
bin+bin
==
::
++ action
^- $-(json ^action)
%- of
:~ archive-all+ul
archive+archive
opened+ul
read-count+place
read-each+read-each
read-note+bin
==
--
--

View File

@ -0,0 +1,35 @@
/+ store=hark-store
|_ =archive:store
++ orm ((on @da timebox:store) gth)
++ del
|= [=time =bin:store]
?~ box=(get:orm archive time) archive
(put:orm archive time (~(del by u.box) bin))
++ put
|= [=time =bin:store =notification:store]
=/ box=timebox:store (fall (get:orm archive time) ~)
=. box (~(put by box) bin notification)
(put:orm archive time box)
::
++ get
|= [=time =bin:store]
^- (unit notification:store)
?~ box=(get:orm archive time) ~
(~(get by u.box) bin)
::
++ got
|= [=time =bin:store]
(need (get time bin))
::
++ has
|= [=time =bin:store]
?~((get time bin) %.n %.y)
::
++ jab
|= [=time =bin:store f=$-(notification:store notification:store)]
(put time bin (f (got time bin)))
::
++ job
|= [=time =bin:store f=$-((unit notification:store) notification:store)]
(put time bin (f (get time bin)))
--

View File

@ -0,0 +1,13 @@
/+ *hark-store
|_ act=action
++ grad %noun
++ grow
|%
++ noun act
--
++ grab
|%
++ noun action
++ json action:dejs
--
--

View File

@ -0,0 +1,13 @@
/+ *hark-store
|_ upd=update
++ grad %noun
++ grow
|%
++ noun upd
++ json (update:enjs upd)
--
++ grab
|%
++ noun update
--
--

View File

@ -0,0 +1 @@
../../garden-dev/sur/hark-store.hoon

View File

@ -47,6 +47,7 @@ module.exports = {
'@typescript-eslint/no-shadow': ['error'],
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'off',
'react/no-array-index-key': 'off',
'import/prefer-default-export': 'off',
'react/prop-types': 'off',
'react/jsx-props-no-spreading': 'off',

File diff suppressed because it is too large Load Diff

View File

@ -20,8 +20,10 @@
"@radix-ui/react-portal": "^0.0.15",
"@radix-ui/react-toggle": "^0.0.10",
"@tlon/sigil-js": "^1.4.4",
"@types/lodash": "^4.14.172",
"@urbit/api": "^1.4.0",
"@urbit/http-api": "^1.3.1",
"big-integer": "^1.6.48",
"classnames": "^2.3.1",
"clipboard-copy": "^4.0.1",
"color2k": "^1.2.4",
@ -32,8 +34,8 @@
"mousetrap": "^1.6.5",
"postcss-import": "^14.0.2",
"query-string": "^7.0.1",
"react": "^16.0.0",
"react-dom": "^16.0.0",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-router-dom": "^5.2.0",
"slugify": "^1.6.0",
"zustand": "^3.5.7"

View File

@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import Mousetrap from 'mousetrap';
import { BrowserRouter, Switch, Route, useHistory } from 'react-router-dom';
import { BrowserRouter, Switch, Route, useHistory, useLocation } from 'react-router-dom';
import { Grid } from './pages/Grid';
import useDocketState from './state/docket';
import { PermalinkRoutes } from './pages/PermalinkRoutes';
@ -12,8 +12,25 @@ import { useHarkStore } from './state/hark';
import { useTheme } from './state/settings';
import { useLocalState } from './state/local';
const getNoteRedirect = (path: string) => {
if (path.startsWith('/desk/')) {
const [, , desk] = path.split('/');
return `/app/${desk}`;
}
return '';
};
const AppRoutes = () => {
const { push } = useHistory();
const { search } = useLocation();
useEffect(() => {
const query = new URLSearchParams(search);
if (query.has('grid-note')) {
const redir = getNoteRedirect(query.get('grid-note')!);
push(redir);
}
}, [search]);
const theme = useTheme();
const isDarkMode = useMedia('(prefers-color-scheme: dark)');
@ -27,6 +44,8 @@ const AppRoutes = () => {
}
}, [isDarkMode, theme]);
useEffect(() => {}, []);
useEffect(() => {
window.name = 'grid';

View File

@ -1,4 +1,4 @@
import { chadIsRunning, Treaty } from '@urbit/api/docket';
import { chadIsRunning, Treaty } from '@urbit/api';
import clipboardCopy from 'clipboard-copy';
import React, { FC } from 'react';
import cn from 'classnames';

View File

@ -0,0 +1,32 @@
import React, { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { useCharge } from '../state/docket';
import { getAppHref } from '../state/util';
interface DeskLinkProps extends React.AnchorHTMLAttributes<any> {
desk: string;
to?: string;
children?: ReactNode;
className?: string;
}
export function DeskLink({ children, className, desk, to = '', ...rest }: DeskLinkProps) {
const charge = useCharge(desk);
if (!charge) {
return null;
}
if (desk === window.desk) {
return (
<Link to={to} className={className} {...rest}>
{children}
</Link>
);
}
const href = `${getAppHref(charge.href)}${to}`;
return (
<a href={href} target={desk} className={className} {...rest}>
{children}
</a>
);
}

View File

@ -1,22 +1,32 @@
import React from 'react';
import { Link } from 'react-router-dom';
import React, { useEffect } from 'react';
import { Link, NavLink, Route, Switch } from 'react-router-dom';
import { Notification, HarkLid } from '@urbit/api';
import { useLeapStore } from './Nav';
import { Button } from '../components/Button';
import { Notification } from '../state/hark-types';
import { BasicNotification } from './notifications/BasicNotification';
import {
BaseBlockedNotification,
RuntimeLagNotification
} from './notifications/SystemNotification';
import { useNotifications } from '../state/notifications';
import { useHarkStore } from '../state/hark';
import { OnboardingNotification } from './notifications/OnboardingNotification';
import { Inbox } from './notifications/Inbox';
function renderNotification(notification: Notification, key: string) {
if (notification.type === 'system-updates-blocked') {
return <BaseBlockedNotification key={key} notification={notification} />;
function renderNotification(notification: Notification, key: string, lid: HarkLid) {
// Special casing
if (notification.bin.place.desk === window.desk) {
if (notification.bin.place.path === '/lag') {
return <RuntimeLagNotification key={key} />;
}
if (notification.bin.place.path === '/blocked') {
return <BaseBlockedNotification key={key} />;
}
if (notification.bin.place.path === '/onboard') {
return <OnboardingNotification key={key} unread />
}
}
if (notification.type === 'runtime-lag') {
return <RuntimeLagNotification key={key} />;
}
return <BasicNotification key={key} notification={notification} />;
return <BasicNotification key={key} notification={notification} lid={lid} />;
}
const Empty = () => (
@ -26,17 +36,49 @@ const Empty = () => (
);
export const Notifications = () => {
// const select = useLeapStore((s) => s.select);
const { notifications, systemNotifications, hasAnyNotifications } = useNotifications();
const select = useLeapStore((s) => s.select);
const { unseen, seen, hasAnyNotifications } = useNotifications();
const markAllAsRead = () => {
const { archiveAll } = useHarkStore.getState();
archiveAll();
};
// useEffect(() => {
// select('Notifications');
// }, []);
useEffect(() => {
select('Notifications');
const { getMore } = useHarkStore.getState();
getMore();
function visibilitychange() {
useHarkStore.getState().opened();
}
document.addEventListener('visibilitychange', visibilitychange);
return () => {
document.removeEventListener('visibilitychange', visibilitychange);
useHarkStore.getState().opened();
};
}, []);
// const select = useLeapStore((s) => s.select);
return (
<div className="grid grid-rows-[auto,1fr] h-full p-4 md:p-8 overflow-hidden">
<header className="space-x-2 mb-8">
<Button variant="secondary" className="py-1.5 px-6 rounded-full">
<NavLink
exact
activeClassName="text-black"
className="text-base font-semibold px-4"
to="/leap/notifications"
>
New
</NavLink>
<NavLink
activeClassName="text-black"
className="text-base font-semibold px-4"
to="/leap/notifications/archive"
>
Archive
</NavLink>
<Button onClick={markAllAsRead} variant="secondary" className="py-1.5 px-6 rounded-full">
Mark All as Read
</Button>
<Button
@ -48,16 +90,14 @@ export const Notifications = () => {
Notification Settings
</Button>
</header>
{!hasAnyNotifications && <Empty />}
{hasAnyNotifications && (
<section className="text-gray-400 space-y-2 overflow-y-auto">
{notifications.map((n, index) => renderNotification(n, index.toString()))}
{systemNotifications.map((n, index) =>
renderNotification(n, (notifications.length + index).toString())
)}
</section>
)}
<Switch>
<Route path="/leap/notifications" exact>
<Inbox />
</Route>
<Route path="/leap/notifications/archive" exact>
<Inbox archived />
</Route>
</Switch>
</div>
);
};

View File

@ -1,25 +1,25 @@
import classNames from 'classnames';
import React from 'react';
import { Timebox } from '@urbit/api';
import { Link, LinkProps } from 'react-router-dom';
import { Bullet } from '../components/icons/Bullet';
import { Cross } from '../components/icons/Cross';
import { Notification } from '../state/hark-types';
import { useNotifications } from '../state/notifications';
import { useHarkStore } from '../state/hark';
type NotificationsState = 'empty' | 'unread' | 'attention-needed' | 'open';
function getNotificationsState(
notificationsOpen: boolean,
notifications: Notification[],
systemNotifications: Notification[]
): NotificationsState {
if (notificationsOpen) {
return 'open';
}
if (systemNotifications.length > 0) {
function getNotificationsState(isOpen: boolean, box: Timebox): NotificationsState {
const notifications = Object.values(box);
if (
notifications.filter(
({ bin }) => bin.place.desk === window.desk && ['/lag', 'blocked'].includes(bin.place.path)
).length > 0
) {
return 'attention-needed';
}
if (isOpen) {
return 'open';
}
// TODO: when real structure, this should be actually be unread not just existence
if (notifications.length > 0) {
@ -40,8 +40,8 @@ export const NotificationsLink = ({
notificationsOpen,
shouldDim
}: NotificationsLinkProps) => {
const { notifications, systemNotifications } = useNotifications();
const state = getNotificationsState(notificationsOpen, notifications, systemNotifications);
const unseen = useHarkStore((s) => s.unseen);
const state = getNotificationsState(notificationsOpen, unseen);
return (
<Link
@ -58,7 +58,7 @@ export const NotificationsLink = ({
)}
>
{state === 'empty' && <Bullet className="w-6 h-6" />}
{state === 'unread' && notifications.length}
{state === 'unread' && Object.keys(unseen).length}
{state === 'attention-needed' && (
<span className="h2">
! <span className="sr-only">Attention needed</span>

View File

@ -1,10 +1,87 @@
import React from 'react';
import { BasicNotification as BasicNotificationType } from '../../state/hark-types';
import cn from 'classnames';
import { Notification, harkBinToId, HarkContent, HarkLid } from '@urbit/api';
import { map, take } from 'lodash';
import { useCharge } from '../../state/docket';
import { Elbow } from '../../components/icons/Elbow';
import { ShipName } from '../../components/ShipName';
import { getAppHref } from '../../state/util';
import { Link } from 'react-router-dom';
import { DeskLink } from '../../components/DeskLink';
import {useHarkStore} from '../../state/hark';
interface BasicNotificationProps {
notification: BasicNotificationType;
notification: Notification;
lid: HarkLid;
}
export const BasicNotification = ({ notification }: BasicNotificationProps) => (
<div>{notification.message}</div>
);
const MAX_CONTENTS = 5;
const NotificationText = ({ contents }: { contents: HarkContent[] }) => {
return (
<>
{contents.map((content, idx) => {
if ('ship' in content) {
return <ShipName className="color-blue" key={idx} name={content.ship} />;
}
return content.text;
})}
</>
);
};
export const BasicNotification = ({ notification, lid }: BasicNotificationProps) => {
const { desk } = notification.bin.place;
const binId = harkBinToId(notification.bin);
const id = `notif-${notification.time}-${binId}`;
const charge = useCharge(desk);
const first = notification.body?.[0];
if (!first || !charge) {
return null;
}
const contents = map(notification.body, 'content').filter((c) => c.length > 0);
const large = contents.length === 0;
const archive = () => {
const { bin } = notification;
useHarkStore.getState().archiveNote(notification.bin, lid);
};
return (
<DeskLink
onClick={archive}
to={`?grid-note=${encodeURIComponent(first.link)}`}
desk={desk}
className={cn(
'text-black rounded',
'unseen' in lid ? 'bg-blue-100' : 'bg-gray-100',
large ? 'note-grid-no-content' : 'note-grid-content'
)}
aria-labelledby={id}
>
<header id={id} className="contents">
<div
className="note-grid-icon rounded w-full h-full"
style={{ backgroundColor: charge?.color ?? '#ee5432' }}
/>
<div className="note-grid-title font-semibold">{charge?.title || desk}</div>
{!large ? <Elbow className="note-grid-arrow w-6 h-6 text-gray-300" /> : null}
<h2 id={`${id}-title`} className="note-grid-head font-semibold text-gray-600">
<NotificationText contents={first.title} />
</h2>
</header>
{contents.length > 0 ? (
<div className="note-grid-body space-y-2">
{take(contents, MAX_CONTENTS).map((content) => (
<p className="">
<NotificationText contents={content} />
</p>
))}
{contents.length > MAX_CONTENTS ? (
<p className="text-gray-300">and {contents.length - MAX_CONTENTS} more</p>
) : null}
</div>
) : null}
</DeskLink>
);
};

View File

@ -0,0 +1,92 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { HarkLid, Notification } from '@urbit/api';
import { useLeapStore } from '../Nav';
import { Button } from '../../components/Button';
import { BasicNotification } from './BasicNotification';
import { BaseBlockedNotification, RuntimeLagNotification } from './SystemNotification';
import { useNotifications } from '../../state/notifications';
import { useHarkStore } from '../../state/hark';
import { OnboardingNotification } from './OnboardingNotification';
function renderNotification(notification: Notification, key: string, lid: HarkLid) {
// Special casing
if (notification.bin.place.desk === window.desk) {
if (notification.bin.place.path === '/lag') {
return <RuntimeLagNotification key={key} />;
}
if (notification.bin.place.path === '/blocked') {
return <BaseBlockedNotification key={key} />;
}
if (notification.bin.place.path === '/onboard') {
return <OnboardingNotification key={key} unread={false} />;
}
}
return <BasicNotification key={key} notification={notification} lid={lid} />;
}
const Empty = () => (
<section className="flex justify-center items-center min-h-[480px] text-gray-400 space-y-2">
<span className="h4">All clear!</span>
</section>
);
export const Inbox = ({ archived = false }) => {
const select = useLeapStore((s) => s.select);
const { unseen, seen, hasAnyNotifications } = useNotifications();
const archive = useHarkStore((s) => s.archive);
const markAllAsRead = () => {};
useEffect(() => {
useHarkStore.getState().getMore();
}, [archived]);
useEffect(() => {
select('Notifications');
const { getMore } = useHarkStore.getState();
getMore();
function visibilitychange() {
setTimeout(() => useHarkStore.getState().opened(), 100);
}
document.addEventListener('visibilitychange', visibilitychange);
return () => {
document.removeEventListener('visibilitychange', visibilitychange);
visibilitychange();
};
}, []);
// const select = useLeapStore((s) => s.select);
if (false) {
return <Empty />;
}
return (
<div className="text-gray-400 space-y-2 overflow-y-auto">
{archived ? (
Array.from(archive).map(([key, box]) => {
return Object.entries(box)
.sort(([, a], [, b]) => b.time - a.time)
.map(([binId, n], index) => renderNotification(n, `${key.toString}-${binId}`, { time: key.toString() }));
})
) : (
<>
<header>Unseen</header>
<section className="space-y-2">
{Object.entries(unseen)
.sort(([, a], [, b]) => b.time - a.time)
.map(([binId, n], index) => renderNotification(n, `unseen-${binId}`, { unseen: null }))}
</section>
<header>Seen</header>
<section className="space-y-2">
{Object.entries(seen)
.sort(([, a], [, b]) => b.time - a.time)
.map(([binId, n], index) => renderNotification(n, `seen-${binId}`, { seen: null }))}
</section>
</>
)}
</div>
);
};

View File

@ -0,0 +1,86 @@
import React from 'react';
import cn from 'classnames';
import { Button } from '../../components/Button';
const cards: OnboardingCardProps[] = [
{
title: 'Terminal',
body: "Install a web terminal to access your Urbit's command line",
button: 'Install',
color: '#9CA4B1'
},
{
title: 'Groups',
body: 'Install Groups, a suite of social software to communicate with other urbit users',
button: 'Install',
color: '#D1DDD3'
},
{
title: 'Bitcoin',
body: 'Install a bitcoin wallet. You can send bitcoin to any btc address, or even another ship',
button: 'Install',
color: '#F6EBDB'
},
{
title: 'Debug',
body: "Install a debugger. You can inspect your ship's internals using this interface",
button: 'Install'
},
{
title: 'Build an app',
body: 'You can instantly get started building new things on Urbit. Just right click your Landscape and select “New App”',
button: 'Learn more',
color: '#82A6CA'
}
];
interface OnboardingCardProps {
title: string;
button: string;
body: string;
color?: string;
}
const OnboardingCard = ({ title, button, body, color }: OnboardingCardProps) => (
<div
className="p-4 flex flex-col space-y-2 text-black bg-gray-100 justify-between rounded-xl"
style={color ? { backgroundColor: color } : {}}
>
<div className="space-y-1">
<h4 className="font-semibold text-black">{title}</h4>
<p>{body}</p>
</div>
<Button variant="primary" className="bg-gray-500">
{button}
</Button>
</div>
);
interface OnboardingNotificationProps {
unread: boolean;
}
export const OnboardingNotification = ({ unread }: OnboardingNotificationProps) => (
<section
className={cn('notification space-y-2 text-black', unread ? 'bg-blue-100' : 'bg-gray-100')}
aria-labelledby=""
>
<header id="system-updates-blocked" className="relative space-y-2">
<div className="flex space-x-2">
<span className="inline-block w-6 h-6 bg-orange-500 rounded-full" />
<span className="font-medium">Grid</span>
</div>
<div className="flex space-x-2">
<h2 id="runtime-lag">Hello there, welcome to Grid!</h2>
</div>
</header>
<div className="grid grid-cols-3 grid-rows-2 gap-4">
{
/* eslint-disable-next-line react/no-array-index-key */
cards.map((card, i) => (
<OnboardingCard key={i} {...card} />
))
}
</div>
</section>
);

View File

@ -7,15 +7,10 @@ import { Dialog, DialogClose, DialogContent, DialogTrigger } from '../../compone
import { Elbow } from '../../components/icons/Elbow';
import api from '../../state/api';
import { useCharges } from '../../state/docket';
import { BaseBlockedNotification as BaseBlockedNotificationType } from '../../state/hark-types';
import { NotificationButton } from './NotificationButton';
import { disableDefault } from '../../state/util';
interface BaseBlockedNotificationProps {
notification: BaseBlockedNotificationType;
}
export const RuntimeLagNotification = () => (
<section
className="notification pl-12 space-y-2 text-black bg-orange-50"
@ -40,8 +35,8 @@ export const RuntimeLagNotification = () => (
</section>
);
export const BaseBlockedNotification = ({ notification }: BaseBlockedNotificationProps) => {
const { desks } = notification;
export const BaseBlockedNotification = () => {
const desks: string[] = [];
const charges = useCharges();
const blockedCharges = Object.values(pick(charges, desks));
const count = blockedCharges.length;

View File

@ -8,7 +8,11 @@ import { useSettingsState, SettingsState } from '../../state/settings';
const selDnd = (s: SettingsState) => s.display.doNotDisturb;
async function toggleDnd() {
const state = useSettingsState.getState();
await state.putEntry('display', 'doNotDisturb', !selDnd(state));
const curr = selDnd(state);
if(curr) {
Notification.requestPermission();
}
await state.putEntry('display', 'doNotDisturb', !curr);
}
const selMentions = (s: HarkState) => s.notificationsGraphConfig.mentions;

View File

@ -1,30 +1,25 @@
import React, { useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { AppInfo } from '../../components/AppInfo';
import { ShipName } from '../../components/ShipName';
import useDocketState, { useCharge, useTreaty } from '../../state/docket';
import { useVat } from '../../state/kiln';
import { useLeapStore } from '../Nav';
export const TreatyInfo = () => {
const select = useLeapStore((state) => state.select);
const { ship, host, desk } = useParams<{ ship: string; host: string; desk: string }>();
const { host, desk } = useParams<{ host: string; desk: string }>();
const treaty = useTreaty(host, desk);
const vat = useVat(desk);
const charge = useCharge(desk);
useEffect(() => {
if(!charge) {
if (!charge) {
useDocketState.getState().requestTreaty(host, desk);
}
}, [host, desk]);
useEffect(() => {
select(
<>
{treaty?.title}
</>
);
select(<>{treaty?.title}</>);
}, [treaty?.title]);
if (!treaty) {

View File

@ -3,41 +3,39 @@ import { Switch, Route, Redirect, RouteComponentProps } from 'react-router-dom';
import { Spinner } from '../components/Spinner';
import { useQuery } from '../logic/useQuery';
import { useCharge } from '../state/docket';
import useKilnState, { useKilnLoaded, useVat } from '../state/kiln';
import useKilnState, { useKilnLoaded } from '../state/kiln';
import { getAppHref } from '../state/util';
function getDeskByForeignRef(ship: string, desk: string): string | undefined {
const { vats } = useKilnState.getState();
console.log(ship, desk);
const found = Object.entries(vats).find(
([d, vat]) => vat.arak.ship === ship && vat.arak.desk === desk
([, vat]) => vat.arak.ship === ship && vat.arak.desk === desk
);
return !!found ? found[0] : undefined;
return found ? found[0] : undefined;
}
interface AppLinkProps
extends RouteComponentProps<{
ship: string;
desk: string;
link: string;
}> {}
type AppLinkProps = RouteComponentProps<{
ship: string;
desk: string;
link: string;
}>;
function AppLink(props: AppLinkProps) {
const { ship, desk, link = '' } = props.match.params;
function AppLink({ match, history, location }: AppLinkProps) {
const { ship, desk, link = '' } = match.params;
const ourDesk = getDeskByForeignRef(ship, desk);
if (ourDesk) {
return <AppLinkRedirect desk={ourDesk} link={link} />;
}
return <AppLinkNotFound {...props} />;
return <AppLinkNotFound match={match} history={history} location={location} />;
}
function AppLinkNotFound(props: AppLinkProps) {
const { ship, desk } = props.match.params;
return (<Redirect to={`/leap/search/direct/apps/${ship}/${desk}`} />);
function AppLinkNotFound({ match }: AppLinkProps) {
const { ship, desk } = match.params;
return <Redirect to={`/leap/search/direct/apps/${ship}/${desk}`} />;
}
function AppLinkInvalid(props: AppLinkProps) {
function AppLinkInvalid() {
return (
<div>
<h4>Link was malformed</h4>
@ -46,7 +44,6 @@ function AppLinkInvalid(props: AppLinkProps) {
);
}
function AppLinkRedirect({ desk, link }: { desk: string; link: string }) {
const vat = useVat(desk);
const charge = useCharge(desk);
useEffect(() => {
const query = new URLSearchParams({
@ -61,8 +58,8 @@ function AppLinkRedirect({ desk, link }: { desk: string; link: string }) {
const LANDSCAPE_SHIP = '~zod';
const LANDSCAPE_DESK = 'groups';
function LandscapeLink(props: RouteComponentProps<{ link: string }>) {
const { link } = props.match.params;
function LandscapeLink({ match }: RouteComponentProps<{ link: string }>) {
const { link } = match.params;
return <Redirect to={`/perma/${LANDSCAPE_SHIP}/${LANDSCAPE_DESK}/${link}`} />;
}

View File

@ -16,9 +16,10 @@ import {
AllyUpdateIni,
TreatyUpdateIni,
docketInstall,
ChargeUpdate
} from '@urbit/api/docket';
import { kilnRevive, kilnSuspend } from '@urbit/api/hood';
ChargeUpdate,
kilnRevive,
kilnSuspend
} from '@urbit/api';
import api from './api';
import { mockAllies, mockCharges, mockTreaties } from './mock-data';
import { fakeRequest, useMockData } from './util';

View File

@ -1,17 +1,50 @@
import _ from 'lodash';
import { NotificationGraphConfig } from '@urbit/api';
import { Notification } from './hark-types';
import { mockNotification } from './mock-data';
/* eslint-disable no-param-reassign */
import create from 'zustand';
import {
Notification as HarkNotification,
harkBinEq,
makePatDa,
readAll,
decToUd,
unixToDa,
Timebox,
harkBinToId,
opened,
HarkBin,
HarkLid,
archive,
HarkContent,
NotificationGraphConfig
} from '@urbit/api';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
/* eslint-disable-next-line camelcase */
import { unstable_batchedUpdates } from 'react-dom';
import produce from 'immer';
import _, { map } from 'lodash';
import api from './api';
import { useMockData } from './util';
import { BaseState, createState, createSubscription, reduceStateN } from './base';
import { mockNotifications } from './mock-data';
import useDocketState from './docket';
import { useSettingsState } from './settings';
import { BaseState, createState, createSubscription, reduceStateN} from './base';
export interface HarkState {
notifications: Notification[];
seen: Timebox;
unseen: Timebox;
archive: BigIntOrderedMap<Timebox>;
set: (f: (s: HarkState) => void) => void;
opened: () => Promise<void>;
notificationsGraphConfig: NotificationGraphConfig;
archiveAll: () => Promise<void>;
archiveNote: (bin: HarkBin, lid: HarkLid) => Promise<void>;
getMore: () => Promise<void>;
webNotes: {
[binId: string]: Notification[];
};
[ref: string]: unknown;
}
type BaseHarkState = HarkState & BaseState<HarkState>;
type BaseHarkState = BaseState<HarkState> & HarkState;
function updateState(
key: string,
@ -34,16 +67,41 @@ export const reduceGraph = [
})
];
export const useHarkStore = createState<HarkState>(
'Hark',
() => ({
notifications: useMockData ? [mockNotification] : [],
notificationsGraphConfig: {
watchOnSelf: false,
mentions: false,
watching: []
}
}),
(set, get) => ({
seen: {},
unseen: {},
archive: new BigIntOrderedMap<Timebox>(),
webNotes: {},
notificationsGraphConfig: {
watchOnSelf: false,
mentions: false,
watching: []
},
set: (f) => {
const newState = produce(get(), f);
set(newState);
},
archiveAll: async () => {},
archiveNote: async (bin, lid) => {
await api.poke(archive(bin, lid));
},
opened: async () => {
await api.poke(opened);
},
getMore: async () => {
const { archive } = get();
const idx = decToUd((archive.peekSmallest()?.[0] || unixToDa(Date.now() * 1000)).toString());
const update = await api.scry({
app: 'hark-store',
path: `/recent/inbox/${idx}/5`
});
reduceHark(update);
}
}),
[],
[
(set, get) =>
@ -52,6 +110,128 @@ export const useHarkStore = createState<HarkState>(
if (graphHookData) {
reduceStateN(get(), graphHookData, reduceGraph);
}
}),
(set, get) =>
createSubscription('hark-store', '/updates', u => {
/* eslint-ignore-next-line camelcase */
unstable_batchedUpdates(() => {
reduceHark(u);
});
})
]
);
function reduceHark(u: any) {
const { set } = useHarkStore.getState();
if (!u) {
return;
}
if ('more' in u) {
u.more.forEach((upd: any) => {
reduceHark(upd);
});
} else if ('all-stats' in u) {
// TODO: probably ignore?
} else if ('added' in u) {
set((draft) => {
const { bin } = u.added;
const binId = harkBinToId(bin);
draft.unseen[binId] = u.added;
});
} else if ('timebox' in u) {
const { timebox } = u;
console.log(timebox);
const { lid, notifications } = timebox;
if ('archive' in lid) {
set((draft) => {
const time = makePatDa(lid.archive);
const timebox = draft.archive.get(time) || {};
console.log(timebox);
notifications.forEach((note: any) => {
console.log(note);
const binId = harkBinToId(note.bin);
timebox[binId] = note;
});
console.log(notifications);
draft.archive = draft.archive.set(time, timebox);
});
} else {
set((draft) => {
const seen = 'seen' in lid ? 'seen' : 'unseen';
notifications.forEach((note: any) => {
const binId = harkBinToId(note.bin);
draft[seen][binId] = note;
});
});
}
} else if ('archived' in u) {
const { lid, notification } = u.archived;
console.log(u.archived);
set((draft) => {
const seen = 'seen' in lid ? 'seen' : 'unseen';
const binId = harkBinToId(notification.bin);
delete draft[seen][binId];
const time = makePatDa(u.archived.time);
const timebox = draft.archive.get(time) || {};
timebox[binId] = notification;
draft.archive = draft.archive.set(time, timebox);
});
} else if ('opened' in u) {
set((draft) => {
const bins = Object.keys(draft.unseen);
bins.forEach((bin) => {
const old = draft.seen[bin];
const curr = draft.unseen[bin];
curr.body = [...curr.body, ...(old?.body || [])];
draft.seen[bin] = curr;
delete draft.unseen[bin];
});
});
}
}
api.subscribe({
app: 'hark-store',
path: '/updates',
event: (u: any) => {
/* eslint-ignore-next-line camelcase */
unstable_batchedUpdates(() => {
reduceHark(u);
});
}
});
function harkContentsToPlainText(contents: HarkContent[]) {
return contents
.map((c) => {
if ('ship' in c) {
return c.ship;
}
return c.text;
})
.join('');
}
api.subscribe({
app: 'hark-store',
path: '/notes',
event: (u: any) => {
if ('add-note' in u) {
if (useSettingsState.getState().display.doNotDisturb) {
//return;
}
const { bin, body } = u['add-note'];
const binId = harkBinToId(bin);
const { title, content } = body;
const image = useDocketState.getState().charges[bin.desk]?.image;
const notification = new Notification(harkContentsToPlainText(title), {
body: harkContentsToPlainText(content),
tag: binId,
renotify: true
});
}
}
});

View File

@ -1,9 +1,20 @@
import {
Vat,
Vats,
Allies,
Charges,
DocketHrefGlob,
Treaties,
Treaty,
Notification,
HarkContent,
HarkBody,
unixToDa,
Contact,
Contacts
} from '@urbit/api';
import _ from 'lodash';
import { Contact, Contacts } from '@urbit/api';
import { Allies, Charges, DocketHrefGlob, Treaties, Treaty } from '@urbit/api/docket';
import { Vat, Vats } from '@urbit/api/hood';
import systemUrl from '../assets/system.png';
import { BasicNotification } from './hark-types';
export const appMetaData: Pick<Treaty, 'cass' | 'hash' | 'website' | 'license' | 'version'> = {
cass: {
@ -165,6 +176,90 @@ export const mockAllies: Allies = [
'~nalrys'
].reduce((acc, val) => ({ ...acc, [val]: charter }), {});
function ship(s: string) {
return { ship: s };
}
function text(t: string) {
return { text: t };
}
function createDmNotification(...content: HarkContent[]): HarkBody {
return {
title: [ship('~hastuc-dibtux'), text(' messaged you')],
time: unixToDa(Date.now() - 3_600).toString(),
content,
binned: '/',
link: '/'
};
}
function createBitcoinNotif(amount: string) {
return {
title: [ship('~silnem'), text(` sent you ${amount}`)],
time: unixToDa(Date.now() - 3_600).toString(),
content: [],
binned: '/',
link: '/'
};
}
function createGroupNotif(to: string): HarkBody {
return {
title: [ship('~ridlur-figbud'), text(` invited you to ${to}`)],
content: [],
time: unixToDa(Date.now() - 3_600).toString(),
binned: '/',
link: '/'
};
}
window.desk = window.desk || 'garden';
function createMockSysNotification(path: string) {
return {
bin: {
place: {
desk: window.desk,
path
},
path: '/'
},
time: Date.now() - 3_600,
body: []
};
}
const lag = createMockSysNotification('/lag');
const blocked = createMockSysNotification('/blocked');
const onboard = createMockSysNotification('/onboard');
export function createMockNotification(desk: string, body: HarkBody[]): Notification {
return {
bin: {
place: {
desk,
path: '/'
},
path: '/'
},
time: Date.now() - 3_600,
body
};
}
export const mockNotifications: Notification[] = [
lag,
blocked,
onboard,
createMockNotification('groups', [
createDmNotification(text('ie the hook agent responsible for marking the notifications')),
createDmNotification(ship('~hastuc-dibtux'), text(' sent a link'))
]),
createMockNotification('bitcoin-wallet', [createBitcoinNotif('0.025 BTC')]),
createMockNotification('groups', [createGroupNotif('a Group: Tlon Corporation')])
];
const contact: Contact = {
nickname: '',
bio: '',
@ -212,18 +307,13 @@ export const mockContacts: Contacts = {
}
};
export const mockNotification: BasicNotification = {
type: 'basic',
time: '',
message: 'test'
};
export const mockVat = (desk: string, blockers?: boolean): Vat => ({
cass: {
da: '~2021.9.13..05.41.04..ae65',
ud: 1
},
desk,
paused: false,
arak: {
rein: {
sub: [],

View File

@ -1,28 +1,13 @@
import shallow from 'zustand/shallow';
import { useHarkStore } from './hark';
import { Notification } from './hark-types';
import { useBlockers, useLag } from './kiln';
function getSystemNotifications(lag: boolean, blockers: string[]) {
const nots = [] as Notification[];
if (lag) {
nots.push({ type: 'runtime-lag' });
}
if (blockers.length > 0) {
nots.push({ type: 'system-updates-blocked', desks: blockers });
}
return nots;
}
export const useNotifications = () => {
const notifications = useHarkStore((s) => s.notifications);
const blockers = useBlockers();
const lag = useLag();
const systemNotifications = getSystemNotifications(lag, blockers);
const hasAnyNotifications = notifications.length > 0 || systemNotifications.length > 0;
const [unseen, seen] = useHarkStore((s) => [s.unseen, s.seen], shallow);
const hasAnyNotifications = Object.keys(seen).length > 0 || Object.keys(unseen).length > 0;
return {
notifications,
systemNotifications,
unseen,
seen,
hasAnyNotifications
};
};

View File

@ -0,0 +1,45 @@
.note-grid-content {
display: grid;
grid-template-columns: 1.5rem 1fr;
grid-template-rows: 1.5rem 1.5rem;
grid-gap: 0.5rem;
padding: 1rem;
grid-template-areas:
'icon title'
'arrow head'
'. body';
}
.note-grid-no-content {
display: grid;
width: 100%;
padding: 1rem;
grid-template-rows: 1.75rem 1.75rem;
grid-template-columns: 3.5rem 1fr;
grid-column-gap: 0.75rem;
align-items: center;
grid-template-areas:
'icon title'
'icon head';
}
.note-grid-title {
grid-area: title;
}
.note-grid-icon {
grid-area: icon;
}
.note-grid-body {
grid-area: body;
}
.note-grid-arrow {
grid-area: arrow;
}
.note-grid-head {
grid-area: head;
}

View File

@ -5,4 +5,6 @@
@import "./components.css";
@import "tailwindcss/utilities";
@import "./utilities.css";
@import "./utilities.css";
@import "./grids.css";

View File

@ -1,6 +1,6 @@
import classNames from 'classnames';
import React, { FunctionComponent } from 'react';
import { chadIsRunning } from '@urbit/api/docket';
import { chadIsRunning } from '@urbit/api';
import { TileMenu } from './TileMenu';
import { Spinner } from '../components/Spinner';
import { getAppHref } from '../state/util';

View File

@ -20,6 +20,7 @@ export default ({ mode }) => {
mode === 'mock'
? undefined
: {
https: true,
proxy: {
'^/apps/grid/desk.js': {
target: SHIP_URL

View File

@ -9,12 +9,14 @@ const { execSync } = require('child_process');
const GIT_DESC = execSync('git describe --always', { encoding: 'utf8' }).trim();
let devServer = {
contentBase: path.join(__dirname, '../dist'),
contentBase: path.join(__dirname, '../public'),
hot: true,
port: 9000,
host: '0.0.0.0',
disableHostCheck: true,
historyApiFallback: true,
historyApiFallback: {
disableDotRule: true
},
publicPath: '/apps/landscape/'
};
@ -24,17 +26,20 @@ if(urbitrc.URL) {
devServer = {
...devServer,
index: 'index.html',
proxy: [{
changeOrigin: true,
target: urbitrc.URL,
router,
context: (path) => {
if(path === '/apps/landscape/desk.js') {
return true;
}
return !path.startsWith('/apps/landscape');
}
}]
proxy: [
{
context: (path) => {
console.log(path);
if(path === '/apps/landscape/desk.js') {
return true;
}
return !path.startsWith('/apps/landscape');
},
changeOrigin: true,
target: urbitrc.URL,
router
}
]
};
}

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@
"private": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@radix-ui/react-dialog": "^0.1.0",
"@reach/disclosure": "^0.10.5",
"@reach/menu-button": "^0.10.5",
"@reach/tabs": "^0.10.5",
@ -33,9 +34,9 @@
"oembed-parser": "^1.4.5",
"prop-types": "^15.7.2",
"querystring": "^0.2.0",
"react": "^16.14.0",
"react": "^17.0.2",
"react-codemirror2": "^6.0.1",
"react-dom": "^16.14.0",
"react-dom": "^17.0.2",
"react-helmet": "^6.1.0",
"react-markdown": "^4.3.1",
"react-oembed-container": "^1.0.0",

View File

@ -554,3 +554,14 @@ export async function jsonFetch<T>(info: RequestInfo, init?: RequestInit): Promi
export function clone<T>(a: T) {
return JSON.parse(JSON.stringify(a)) as T;
}
export function toHarkPath(path: string, index = '') {
return `/graph/${path.slice(6)}${index}`;
}
export function toHarkPlace(graph: string, index = '') {
return {
desk: (window as any).desk,
path: toHarkPath(graph, index)
};
}

View File

@ -1,13 +1,11 @@
import {
NotificationContents,
NotifIndex,
Timebox
HarkPlace,
Timebox,
HarkStats
} from '@urbit/api';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import _ from 'lodash';
import { compose } from 'lodash/fp';
import { makePatDa } from '~/logic/lib/util';
import { describeNotification, getReferent } from '../lib/hark';
import { BaseState } from '../state/base';
import { HarkState as State } from '../state/hark';
@ -97,18 +95,21 @@ function readAll(json: any, state: HarkState): HarkState {
return state;
}
function removeGraph(json: any, state: HarkState): HarkState {
const data = _.get(json, 'remove-graph');
if(data) {
delete state.unreads.graph[data];
}
return state;
const emptyStats = () => ({
each: [],
count: 0,
last: 0
});
function updateNotificationStats(state: HarkState, place: HarkPlace, f: (s: HarkStats) => Partial<HarkStats>) {
const old = state.unreads?.[place.path] || emptyStats();
state.unreads[place.path] = { ...old, ...f(old) };
}
function seenIndex(json: any, state: HarkState): HarkState {
const data = _.get(json, 'seen-index');
if(data) {
updateNotificationStats(state, data.index, 'last', () => data.time);
updateNotificationStats(state, data, s => ({ last: Date.now() }));
}
return state;
}
@ -116,7 +117,8 @@ function seenIndex(json: any, state: HarkState): HarkState {
function readEach(json: any, state: HarkState): HarkState {
const data = _.get(json, 'read-each');
if (data) {
updateUnreads(state, data.index, u => u.delete(data.target));
const { place, path } = data;
updateNotificationStats(state, place, s => ({ each: s.each.filter(e => e !== path) }));
}
return state;
}
@ -124,7 +126,8 @@ function readEach(json: any, state: HarkState): HarkState {
function readSince(json: any, state: HarkState): HarkState {
const data = _.get(json, 'read-count');
if(data) {
updateUnreadCount(state, data, () => 0);
console.log(data);
updateNotificationStats(state, data, s => ({ count: 0 }));
}
return state;
}
@ -132,7 +135,9 @@ function readSince(json: any, state: HarkState): HarkState {
function unreadSince(json: any, state: HarkState): HarkState {
const data = _.get(json, 'unread-count');
if (data) {
updateUnreadCount(state, data.index, u => u + 1);
console.log(data);
const { inc, count, place } = data;
updateNotificationStats(state, place, s => ({ count: inc ? s.count + count : s.count - count }));
}
return state;
}
@ -140,34 +145,17 @@ function unreadSince(json: any, state: HarkState): HarkState {
function unreadEach(json: any, state: HarkState): HarkState {
const data = _.get(json, 'unread-each');
if(data) {
updateUnreads(state, data.index, us => us.add(data.target));
const { place, path } = data;
updateNotificationStats(state, place, s => ({ each: [...s.each, path] }));
}
return state;
}
function unreads(json: any, state: HarkState): HarkState {
const data = _.get(json, 'unreads');
if(data) {
clearState(state);
data.forEach(({ index, stats }) => {
const { unreads, notifications, last } = stats;
updateNotificationStats(state, index, 'last', () => last);
if(index.graph.graph === '/ship/~hastuc-dibtux/test-book-7531') {
console.log(index, stats);
}
_.each(notifications, ({ time, index }) => {
if(!time) {
addNotificationToUnread(state, index);
}
});
if('count' in unreads) {
updateUnreadCount(state, index, (u = 0) => u + unreads.count);
} else {
updateUnreads(state, index, s => new Set());
unreads.each.forEach((u: string) => {
updateUnreads(state, index, s => s.add(u));
});
}
function allStats(json: any, state: HarkState): HarkState {
if('all-stats' in json) {
const data = json['all-stats'];
data.forEach(({ place, stats }) => {
state.unreads[place.path] = stats;
});
}
return state;
@ -183,10 +171,7 @@ function clearState(state: HarkState): HarkState {
mentions: false,
watching: []
},
unreads: {
graph: {},
group: {}
},
unreads: {},
notificationsCount: 0,
unreadNotes: {}
};
@ -195,98 +180,6 @@ function clearState(state: HarkState): HarkState {
return state;
}
function updateUnreadCount(state: HarkState, index: NotifIndex, count: (c: number) => number): HarkState {
if(!('graph' in index)) {
return state;
}
const property = [index.graph.graph, index.graph.index, 'unreads'];
const curr = _.get(state.unreads.graph, property, 0);
if(typeof curr !== 'number') {
return state;
}
const newCount = count(curr);
_.set(state.unreads.graph, property, newCount);
return state;
}
function updateUnreads(state: HarkState, index: NotifIndex, f: (us: Set<string>) => void): HarkState {
if(!('graph' in index)) {
return state;
}
const unreads: any = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], new Set<string>());
f(unreads);
_.set(state.unreads.graph, [index.graph.graph, index.graph.index, 'unreads'], unreads);
return state;
}
function addNotificationToUnread(state: HarkState, index: NotifIndex) {
if('graph' in index) {
const path = [index.graph.graph, index.graph.index, 'notifications'];
const curr = _.get(state.unreads.graph, path, []);
_.set(state.unreads.graph, path,
[
...curr.filter((c) => {
return !(notifIdxEqual(c.index, index));
}),
{ index }
]
);
} else if ('group' in index) {
const path = [index.group.group, 'notifications'];
const curr = _.get(state.unreads.group, path, []);
_.set(state.unreads.group, path,
[
...curr.filter(c => !notifIdxEqual(c.index, index)),
{ index }
]
);
}
}
function removeNotificationFromUnread(state: HarkState, index: NotifIndex) {
if('graph' in index) {
const path = [index.graph.graph, index.graph.index, 'notifications'];
const curr = _.get(state.unreads.graph, path, []);
_.set(state.unreads.graph, path, curr.filter(c => !notifIdxEqual(c.index, index)));
} else if ('group' in index) {
const path = [index.group.group, 'notifications'];
const curr = _.get(state.unreads.group, path, []);
_.set(state.unreads.group, path, curr.filter(c => !notifIdxEqual(c.index, index)));
}
}
function updateNotificationStats(state: HarkState, index: NotifIndex, statField: 'unreads' | 'last', f: (x: number) => number, notify = false) {
if('graph' in index) {
const curr: any = _.get(state.unreads.graph, [index.graph.graph, index.graph.index, statField], 0);
_.set(state.unreads.graph, [index.graph.graph, index.graph.index, statField], f(curr));
} else if('group' in index) {
const curr: any = _.get(state.unreads.group, [index.group.group, statField], 0);
_.set(state.unreads.group, [index.group.group, statField], f(curr));
}
}
function added(json: any, state: HarkState): HarkState {
const data = _.get(json, 'added', false);
if (data) {
const { index, notification } = data;
const [fresh] = _.partition(state.unreadNotes, ({ index: idx }) => !notifIdxEqual(index, idx));
state.unreadNotes = [...fresh, { index, notification }];
if ('Notification' in window && !state.doNotDisturb) {
const description = describeNotification(data);
const referent = getReferent(data);
new Notification(`${description} ${referent}`, {
tag: 'landscape',
image: '/img/favicon.png',
icon: '/img/favicon.png',
badge: '/img/favicon.png',
renotify: true
});
}
}
return state;
}
const dnd = (json: any, state: HarkState): HarkState => {
const data = _.get(json, 'set-dnd', undefined);
if (!_.isUndefined(data)) {
@ -295,22 +188,6 @@ const dnd = (json: any, state: HarkState): HarkState => {
return state;
};
const timebox = (json: any, state: HarkState): HarkState => {
const data = _.get(json, 'timebox', false);
if (data) {
if (data.time) {
const time = makePatDa(data.time);
state.notifications = state.notifications.set(time, data.notifications);
} else {
state.unreadNotes = data.notifications;
_.each(data.notifications, ({ index }) => {
addNotificationToUnread(state, index);
});
}
}
return state;
};
function more(json: any, state: HarkState): HarkState {
const data = _.get(json, 'more', false);
if (data) {
@ -321,98 +198,17 @@ function more(json: any, state: HarkState): HarkState {
return state;
}
function notifIdxEqual(a: NotifIndex, b: NotifIndex) {
if ('graph' in a && 'graph' in b) {
return (
a.graph.graph === b.graph.graph &&
a.graph.group === b.graph.group &&
a.graph.mark === b.graph.mark &&
a.graph.description === b.graph.description
);
} else if ('group' in a && 'group' in b) {
return (
a.group.group === b.group.group &&
a.group.description === b.group.description
);
}
return false;
}
function mergeNotifs(a: NotificationContents, b: NotificationContents) {
if ('graph' in a && 'graph' in b) {
return {
graph: [...a.graph, ...b.graph]
};
} else if ('group' in a && 'group' in b) {
return {
group: [...a.group, ...b.group]
};
}
return a;
}
function read(json: any, state: HarkState): HarkState {
const data = _.get(json, 'note-read', false);
if (data) {
const { index } = data;
const time = makePatDa(data.time);
const [read, unread] = _.partition(state.unreadNotes,({ index: idx }) => notifIdxEqual(index, idx));
state.unreadNotes = unread;
const oldTimebox = state.notifications.get(time) ?? [];
const [toMerge, rest] = _.partition(oldTimebox, i => notifIdxEqual(index, i.index));
if(toMerge.length > 0 && read.length > 0) {
read[0].notification.contents = mergeNotifs(read[0].notification.contents, toMerge[0].notification.contents);
}
state.notifications = state.notifications.set(time, [...read, ...rest]);
removeNotificationFromUnread(state, index);
}
return state;
}
function archive(json: any, state: HarkState): HarkState {
const data = _.get(json, 'archive', false);
if (data) {
const { index } = data;
if(data.time) {
const time = makePatDa(data.time);
const timebox = state.notifications.get(time);
if (!timebox) {
console.warn('Modifying nonexistent timebox');
return state;
}
const unarchived = _.filter(timebox, idxNotif =>
!notifIdxEqual(index, idxNotif.index)
);
if(unarchived.length === 0) {
console.log('deleting entire timebox');
state.notifications = state.notifications.delete(time);
} else {
state.notifications = state.notifications.set(time, unarchived);
}
} else {
state.unreadNotes = state.unreadNotes.filter(({ index: idx }) => !notifIdxEqual(idx, index));
removeNotificationFromUnread(state, index);
}
}
return state;
}
export function reduce(data, state) {
const reducers = [
calculateCount,
read,
archive,
timebox,
allStats,
more,
dnd,
added,
unreads,
readEach,
readSince,
unreadSince,
unreadEach,
seenIndex,
removeGraph,
readAll
];
const reducer = compose(reducers.map(r => (s) => {

View File

@ -1,10 +1,10 @@
import {
archive,
HarkBin,
markCountAsRead,
Notification,
NotificationGraphConfig,
NotifIndex,
readNote,
Timebox,
Unreads
} from '@urbit/api';
import { Poke } from '@urbit/http-api';
@ -12,58 +12,68 @@ import { patp2dec } from 'urbit-ob';
import _ from 'lodash';
import BigIntOrderedMap from '@urbit/api/lib/BigIntOrderedMap';
import api from '~/logic/api';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { createState, createSubscription, pokeOptimisticallyN, reduceState, reduceStateN } from './base';
import {
createState,
createSubscription,
pokeOptimisticallyN,
reduceState,
reduceStateN
} from './base';
import { reduce, reduceGraph, reduceGroup } from '../reducers/hark-update';
import { BigInteger } from 'big-integer';
export const HARK_FETCH_MORE_COUNT = 3;
export interface HarkState {
archivedNotifications: BigIntOrderedMap<Timebox>;
archivedNotifications: BigIntOrderedMap<Notification[]>;
doNotDisturb: boolean;
poke: (poke: Poke<any>) => Promise<void>;
getMore: () => Promise<boolean>;
getSubset: (offset: number, count: number, isArchive: boolean) => Promise<void>;
getSubset: (
offset: number,
count: number,
isArchive: boolean
) => Promise<void>;
// getTimeSubset: (start?: Date, end?: Date) => Promise<void>;
notifications: BigIntOrderedMap<Timebox>;
unreadNotes: Timebox;
notifications: BigIntOrderedMap<Notification[]>;
unreadNotes: Notification[];
notificationsCount: number;
notificationsGraphConfig: NotificationGraphConfig; // TODO unthread this everywhere
notificationsGroupConfig: string[];
unreads: Unreads;
archive: (index: NotifIndex, time?: BigInteger) => Promise<void>;
readNote: (index: NotifIndex) => Promise<void>;
readCount: (resource: string, index?: string) => Promise<void>;
archive: (bin: HarkBin, time?: BigInteger) => Promise<void>;
readNote: (bin: HarkBin) => Promise<void>;
readCount: (path: string) => Promise<void>;
}
const useHarkState = createState<HarkState>(
'Hark',
(set, get) => ({
archivedNotifications: new BigIntOrderedMap<Timebox>(),
archivedNotifications: new BigIntOrderedMap<Notification[]>(),
doNotDisturb: false,
unreadNotes: [],
poke: async (poke: Poke<any>) => {
await pokeOptimisticallyN(useHarkState, poke, [reduce]);
},
readCount: async (resource: string, index?: string) => {
const poke = markCountAsRead(resource, index);
readCount: async (path) => {
const poke = markCountAsRead({ desk: (window as any).desk, path });
await pokeOptimisticallyN(useHarkState, poke, [reduce]);
},
archive: async (index: NotifIndex, time?: BigInteger) => {
const poke = archive(index, time);
archive: async (bin: HarkBin, time?: BigInteger) => {
const poke = archive(bin, time);
await pokeOptimisticallyN(useHarkState, poke, [reduce]);
},
readNote: async (index) => {
await pokeOptimisticallyN(useHarkState, readNote(index), [reduce]);
readNote: async (bin) => {
await pokeOptimisticallyN(useHarkState, readNote(bin), [reduce]);
},
getMore: async (): Promise<boolean> => {
const state = get();
const offset = state.notifications.size || 0;
await state.getSubset(offset, HARK_FETCH_MORE_COUNT, false);
const newState = get();
return offset === (newState?.notifications?.size || 0);
const state = get();
const offset = state.notifications.size || 0;
await state.getSubset(offset, HARK_FETCH_MORE_COUNT, false);
const newState = get();
return offset === (newState?.notifications?.size || 0);
},
getSubset: async (offset, count, isArchive): Promise<void> => {
const where = isArchive ? 'archive' : 'inbox';
@ -73,7 +83,7 @@ const useHarkState = createState<HarkState>(
});
reduceState(useHarkState, harkUpdate, [reduce]);
},
notifications: new BigIntOrderedMap<Timebox>(),
notifications: new BigIntOrderedMap<Notification[]>(),
notificationsCount: 0,
notificationsGraphConfig: {
watchOnSelf: false,
@ -81,10 +91,7 @@ const useHarkState = createState<HarkState>(
watching: []
},
notificationsGroupConfig: [],
unreads: {
graph: {},
group: {}
}
unreads: {}
}),
[
'unreadNotes',
@ -94,38 +101,69 @@ const useHarkState = createState<HarkState>(
'notificationsCount'
],
[
(set, get) => createSubscription('hark-store', '/updates', (j) => {
const d = _.get(j, 'harkUpdate', false);
if (d) {
(set, get) =>
createSubscription('hark-store', '/updates', (d) => {
reduceStateN(get(), d, [reduce]);
}
}),
(set, get) => createSubscription('hark-graph-hook', '/updates', (j) => {
const graphHookData = _.get(j, 'hark-graph-hook-update', false);
if (graphHookData) {
reduceStateN(get(), graphHookData, reduceGraph);
}
}),
(set, get) => createSubscription('hark-group-hook', '/updates', (j) => {
const data = _.get(j, 'hark-group-hook-update', false);
if (data) {
reduceStateN(get(), data, reduceGroup);
}
})
}),
(set, get) =>
createSubscription('hark-graph-hook', '/updates', (j) => {
const graphHookData = _.get(j, 'hark-graph-hook-update', false);
if (graphHookData) {
reduceStateN(get(), graphHookData, reduceGraph);
}
}),
(set, get) =>
createSubscription('hark-group-hook', '/updates', (j) => {
const data = _.get(j, 'hark-group-hook-update', false);
if (data) {
reduceStateN(get(), data, reduceGroup);
}
})
]
);
const emptyStats = () => ({
last: 0,
count: 0,
each: []
});
export function useHarkDm(ship: string) {
return useHarkState(
useCallback(
(s) => {
return s.unreads.graph[`/ship/~${window.ship}/dm-inbox`]?.[
return s.unreads[`/graph/~${window.ship}/dm-inbox`]?.[
`/${patp2dec(ship)}`
];
] || emptyStats();
},
[ship]
)
);
}
export function useHarkStat(path: string) {
return useHarkState(
useCallback(s => s.unreads[path] || emptyStats(), [path])
);
}
export function useHarkGraph(graph: string) {
const [, ship, name] = useMemo(() => graph.split('/'), [graph]);
return useHarkState(
useCallback(s => s.unreads[`/graph/${ship}/${name}`], [ship, name])
);
}
export function useHarkGraphIndex(graph: string, index: string) {
const [, ship, name] = useMemo(() => graph.split('/'), [graph]);
return useHarkState(
useCallback(s => s.unreads[`/graph/${ship}/${name}/index`], [
ship,
name,
index
])
);
}
window.hark = useHarkState.getState;
export default useHarkState;

View File

@ -11,12 +11,13 @@ import { isWriter, resourceFromPath } from '~/logic/lib/group';
import { getPermalinkForGraph } from '~/logic/lib/permalinks';
import useGraphState, { useGraphForAssoc } from '~/logic/state/graph';
import { useGroupForAssoc } from '~/logic/state/group';
import useHarkState from '~/logic/state/hark';
import useHarkState, { useHarkStat } from '~/logic/state/hark';
import { Loading } from '~/views/components/Loading';
import { ChatPane } from './components/ChatPane';
import airlock from '~/logic/api';
import { disallowedShipsForOurContact } from '~/logic/lib/contact';
import shallow from 'zustand/shallow';
import { toHarkPath } from '~/logic/lib/util';
const getCurrGraphSize = (ship: string, name: string) => {
const { graphs } = useGraphState.getState();
@ -35,9 +36,8 @@ const ChatResource = (props: ChatResourceProps): ReactElement => {
const [toShare, setToShare] = useState<string[] | string | undefined>();
const group = useGroupForAssoc(association)!;
const graph = useGraphForAssoc(association);
const unreads = useHarkState(state => state.unreads);
const unreadCount =
(unreads.graph?.[resource]?.['/']?.unreads as number) || 0;
const stats = useHarkStat(toHarkPath(association.resource));
const unreadCount = stats.count;
const canWrite = group ? isWriter(group, resource) : false;
const [
getNewest,
@ -94,8 +94,8 @@ const ChatResource = (props: ChatResourceProps): ReactElement => {
[group]
);
const fetchMessages = useCallback(async (newer: boolean) => {
const pageSize = 100;
const fetchMessages = useCallback(async (newer: boolean) => {
const pageSize = 100;
const [, , ship, name] = resource.split('/');
const graphSize = graph?.size ?? 0;
@ -140,7 +140,7 @@ const ChatResource = (props: ChatResourceProps): ReactElement => {
}, [resource]);
const dismissUnread = useCallback(() => {
useHarkState.getState().readCount(association.resource);
useHarkState.getState().readCount(toHarkPath(association.resource));
}, [association.resource]);
const getPermalink = useCallback(

View File

@ -52,7 +52,7 @@ export function DmResource(props: DmResourceProps) {
const { ship } = props;
const dm = useDM(ship);
const hark = useHarkDm(ship);
const unreadCount = (hark?.unreads as number) ?? 0;
const unreadCount = hark.count;
const contact = useContact(ship);
const { hideNicknames } = useSettingsState(selectCalmState);
const showNickname = !hideNicknames && Boolean(contact);
@ -111,8 +111,8 @@ export function DmResource(props: DmResourceProps) {
);
const dismissUnread = useCallback(() => {
const resource = `/ship/~${window.ship}/dm-inbox`;
useHarkState.getState().readCount(resource, `/${patp2dec(ship)}`);
const harkPath = `/graph/~${window.ship}/dm-inbox/${patp2dec(ship)}`;
useHarkState.getState().readCount(harkPath);
}, [ship]);
const onSubmit = useCallback(

View File

@ -3,6 +3,7 @@ import { Box, Button, Col, Icon, Row, Text } from '@tlon/indigo-react';
import f from 'lodash/fp';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import { Helmet } from 'react-helmet';
import { Route, useHistory } from 'react-router-dom';
import styled from 'styled-components';
import {
hasTutorialGroup,
@ -30,6 +31,7 @@ import Groups from './components/Groups';
import ModalButton from './components/ModalButton';
import Tiles from './components/tiles';
import Tile from './components/tiles/tile';
import { Invite } from './components/Invite';
import './css/custom.css';
import { join } from '@urbit/api/groups';
import { joinGraph } from '@urbit/api/graph';
@ -185,6 +187,9 @@ export const LaunchApp = (props: LaunchAppProps): ReactElement | null => {
<Helmet defer={false}>
<title>{ notificationsCount ? `(${String(notificationsCount) }) `: '' }Landscape</title>
</Helmet>
<Route path="/invites/:app/:uid">
<Invite />
</Route>
<ScrollbarLessBox height='100%' overflowY='scroll' display="flex" flexDirection="column">
{modal}
<Box

View File

@ -0,0 +1,242 @@
import React, { useState, useEffect, useCallback } from "react";
import { useHistory, useParams } from "react-router-dom";
import {
Box,
Col,
Row,
Text,
Button,
Action,
LoadingSpinner,
} from "@tlon/indigo-react";
import * as Dialog from "@radix-ui/react-dialog";
import { PropFunc } from "~/types";
import { useRunIO } from "~/logic/lib/useRunIO";
import useMetadataState from "~/logic/state/metadata";
import { GroupSummary } from "~/views/landscape/components/GroupSummary";
import {
accept,
decline,
GraphConfig,
Invite as IInvite,
join,
Metadata,
MetadataUpdatePreview,
resourceFromPath,
} from "@urbit/api";
import useInviteState from "~/logic/state/invite";
import useGroupState from "~/logic/state/group";
import useGraphState from "~/logic/state/graph";
import { useWaitForProps } from "~/logic/lib/useWaitForProps";
import airlock from "~/logic/api";
function InviteDialog({ children, ...rest }: PropFunc<typeof Col>) {
return (
<Dialog.Root open>
<Dialog.Overlay asChild>
<Box
backgroundColor="scales.black20"
left="0px"
top="0px"
width="100%"
height="100%"
position="fixed"
display="flex"
zIndex={10}
justifyContent="center"
alignItems="center"
p="4"
></Box>
</Dialog.Overlay>
<Dialog.DialogContent asChild>
<Col
left="0px"
top="0px"
width="100%"
height="100%"
position="fixed"
zIndex={10}
justifyContent="center"
alignItems="center"
>
<Col
p="4"
gapY="4"
border="1"
borderColor="lightGray"
backgroundColor="white"
{...rest}
>
{children}
</Col>
</Col>
</Dialog.DialogContent>
</Dialog.Root>
);
}
function inviteUrl(
hidden: boolean,
resource: string,
metadata?: Metadata<GraphConfig>
) {
if (!hidden) {
return `/~landscape${resource}`;
}
if (metadata.config?.graph === "chat") {
return `/~landscape/messages/resource/${metadata.config.graph}${resource}`;
} else {
return `/~landscape/home/resource/${metadata.config?.graph}${resource}`;
}
}
function useInviteAccept(resource: string, app?: string, uid?: string) {
const { ship, name } = resourceFromPath(resource);
const history = useHistory();
const associations = useMetadataState((s) => s.associations);
const groups = useGroupState((s) => s.groups);
const graphKeys = useGraphState((s) => s.graphKeys);
const waiter = useWaitForProps({ associations, graphKeys, groups });
return useRunIO<void, boolean>(
async () => {
if (!(app && uid)) {
return false;
}
if (resource in groups) {
await airlock.poke(decline(app, uid));
return false;
}
await airlock.poke(join(ship, name));
await airlock.poke(accept(app, uid));
await waiter((p) => {
return (
(resource in p.groups &&
resource in (p.associations?.graph ?? {}) &&
p.graphKeys.has(resource.slice(7))) ||
resource in (p.associations?.groups ?? {})
);
});
return true;
},
(success: boolean) => {
if (!success) {
return;
}
const redir = inviteUrl(
groups?.[resource]?.hidden,
resource,
associations?.graph?.[resource]?.metadata
);
if (redir) {
// weird race condition
setTimeout(() => {
history.push(redir);
}, 200);
}
},
resource
);
}
export function Invite() {
const { uid, app } = useParams<{
app: string;
uid: string;
}>();
const invite = useInviteState((s) => s.invites?.[app]?.[uid]);
return (
<InviteDialog>
{!!invite ? (
<>
{renderInviteContent(app, uid, invite)}
<InviteActions app={app} uid={uid} invite={invite} />
</>
) : (
<LoadingSpinner />
)}
</InviteDialog>
);
}
function InviteActions({
app,
uid,
invite,
}: {
app: string;
uid: string;
invite: IInvite;
}) {
const { push } = useHistory();
const { ship, name } = invite.resource;
const resource = `/ship/~${ship}/${name}`;
const inviteAccept = useInviteAccept(resource, app, uid);
const acceptInvite = () => inviteAccept();
const declineInvite = useCallback(async () => {
if (!(app && uid)) {
return;
}
await airlock.poke(decline(app, uid));
push('/');
}, [app, uid]);
return (
<Row gapX="4">
<Action onClick={acceptInvite} backgroundColor="white" color="blue">
Accept
</Action>
<Action onClick={declineInvite} backgroundColor="white" destructive>
Decline
</Action>
</Row>
);
}
function GroupInvite({ uid, invite }: { uid: string; invite: IInvite }) {
const {
resource: { ship, name },
} = invite;
const { associations, getPreview } = useMetadataState();
const [preview, setPreview] = useState<MetadataUpdatePreview | null>(null);
useEffect(() => {
(async () => {
setPreview(await getPreview(`/ship/~${ship}/${name}`));
})();
return () => {
setPreview(null);
};
}, [ship, name]);
return preview ? (
<GroupSummary
metadata={preview.metadata}
channelCount={preview["channel-count"]}
memberCount={preview["members"]}
/>
) : (
<LoadingSpinner />
);
}
function GraphInvite({ uid, invite }: { uid: string; invite: IInvite }) {
return <Text>You have been invited to a group chat by {invite.ship}</Text>;
}
function renderInviteContent(app: string, uid: string, invite: IInvite) {
switch (app) {
case "groups":
return <GroupInvite uid={uid} invite={invite} />;
case "graph":
return <GraphInvite uid={uid} invite={invite} />;
default:
return null;
}
}

View File

@ -1,11 +1,13 @@
import { Col, Row, RowProps } from '@tlon/indigo-react';
import { Association, GraphNode, TextContent, UrlContent } from '@urbit/api';
import React from 'react';
import { Association, GraphNode, markEachAsRead, TextContent, UrlContent } from '@urbit/api';
import React, { useEffect } from 'react';
import { useGroup } from '~/logic/state/group';
import Author from '~/views/components/Author';
import Comments from '~/views/components/Comments';
import { TruncatedText } from '~/views/components/TruncatedText';
import { LinkBlockItem } from './LinkBlockItem';
import airlock from '~/logic/api';
import { toHarkPlace } from '~/logic/lib/util';
export interface LinkDetailProps extends RowProps {
node: GraphNode;
@ -17,6 +19,10 @@ export function LinkDetail(props: LinkDetailProps) {
const { node, association, baseUrl, ...rest } = props;
const group = useGroup(association.group);
const { post } = node;
useEffect(() => {
airlock.poke(markEachAsRead(toHarkPlace(association.resource), node.post.index));
}, [association, node]);
const [{ text: title }] = post.contents as [TextContent, UrlContent];
return (
/* @ts-ignore indio props?? */

View File

@ -5,7 +5,7 @@ import { Link, Redirect } from 'react-router-dom';
import { roleForShip } from '~/logic/lib/group';
import { getPermalinkForGraph, referenceToPermalink } from '~/logic/lib/permalinks';
import { useCopy } from '~/logic/lib/useCopy';
import useHarkState from '~/logic/state/hark';
import { useHarkStat } from '~/logic/state/hark';
import Author from '~/views/components/Author';
import { Dropdown } from '~/views/components/Dropdown';
import RemoteContent from '~/views/components/RemoteContent';
@ -40,8 +40,12 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
}, []);
const index = node.post.index.split('/')[1];
const [ship, name] = resource.split('/');
const harkPath = `/graph/~${ship}/${name}`;
const markRead = useCallback(() => {
airlock.poke(markEachAsRead(resource, '/', `/${index}`));
airlock.poke(
markEachAsRead({ desk: (window as any).desk, path: harkPath }, `/${index}`)
);
}, [resource, index]);
useEffect(() => {
@ -74,7 +78,6 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
const baseUrl = props.baseUrl || `/~404/${resource}`;
const ourRole = group ? roleForShip(group, window.ship) : undefined;
const [ship, name] = resource.split('/');
const permalink = getPermalinkForGraph(
association.group,
@ -98,11 +101,10 @@ export const LinkItem = React.forwardRef((props: LinkItemProps, ref: RefObject<H
}
};
const appPath = `/ship/~${resource}`;
const unreads = useHarkState(state => state.unreads?.[appPath]);
const commColor = (unreads?.[`/${index}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray';
// @ts-ignore hark will have to choose between sets and numbers
const isUnread = unreads?.['/']?.unreads?.has?.(node.post.index);
const linkStats = useHarkStat(harkPath);
const commStats = useHarkStat(`${harkPath}/${index}`);
const commColor = commStats.count > 0 ? 'blue' : 'gray';
const isUnread = linkStats.each.includes(`/${index}`);
return (
<Box

View File

@ -95,19 +95,10 @@ export default function Inbox(props: {
})
);
const scrollRef = useRef(null);
const { isDone, isLoading } = useLazyScroll(
scrollRef,
ready,
0.2,
_.flatten(notifications).length,
getMore
);
const date = unixToDa(Date.now());
return (
<Col p={1} ref={scrollRef} position="relative" height="100%" overflowY="auto" overflowX="hidden">
<Col p={1} position="relative" height="100%" overflowY="auto" overflowX="hidden">
{runtimeLag && (
<Box bg="yellow" borderRadius={2} p={2} m={2}>
<Icon verticalAlign="middle" mr={2} icon="Tutorial" />
@ -117,28 +108,6 @@ export default function Inbox(props: {
</Box>
)}
<Invites pendingJoin={props.pendingJoin} />
<DaySection unread key="unread" timeboxes={[[date,unreadNotes]]} />
{[...notificationsByDayMap.keys()].sort().reverse().map((day, index) => {
const timeboxes = notificationsByDayMap.get(day)!;
return timeboxes.length > 0 && (
<DaySection
key={day}
timeboxes={timeboxes}
/>
);
})}
{isDone ? (
<Center mt={2} borderTop={notifications.length !== 0 ? 1 : 0} borderTopColor="lightGray" width="100%" height="96px">
<Text gray fontSize={1}>No more notifications</Text>
</Center>
) : isLoading ? (
<Center mt={2} borderTop={notifications.length !== 0 ? 1 : 0} borderTopColor="lightGray" width="100%" height="96px">
<LoadingSpinner />
</Center>
) : (
<Box mt={2} height="96px" />
)}
</Col>
);
}

View File

@ -13,6 +13,7 @@ import { Spinner } from '~/views/components/Spinner';
import { GraphContent } from '~/views/landscape/components/Graph/GraphContent';
import { NoteNavigation } from './NoteNavigation';
import airlock from '~/logic/api';
import { toHarkPlace } from '~/logic/lib/util';
interface NoteProps {
ship: string;
@ -59,8 +60,8 @@ export function Note(props: NoteProps & RouteComponentProps) {
const noteId = bigInt(index[1]);
useEffect(() => {
airlock.poke(markEachAsRead(props.association.resource, '/',`/${index[1]}/1/1`));
}, [props.association, props.note]);
airlock.poke(markEachAsRead(toHarkPlace(association.resource), `/${index[1]}`));
}, [association, props.note]);
const adminLinks: JSX.Element[] = [];
const ourRole = roleForShip(group, window.ship);

View File

@ -10,7 +10,7 @@ import {
getLatestRevision,
getSnippet
} from '~/logic/lib/publish';
import useHarkState from '~/logic/state/hark';
import { useHarkStat } from '~/logic/state/hark';
import Author from '~/views/components/Author';
interface NotePreviewProps {
@ -67,14 +67,15 @@ export function NotePreview(props: NotePreviewProps) {
const url = `${props.baseUrl}/note/${noteId}`;
const [, title, body] = getLatestRevision(node);
const appPath = `/ship/${props.host}/${props.book}`;
const unreads = useHarkState(state => state.unreads.graph?.[appPath]);
const harkPath = `/graph/${props.host}/${props.book}`;
const bookStats = useHarkStat(harkPath);
const noteStats = useHarkStat(`${harkPath}/${noteId}`);
// @ts-ignore hark will have to choose between sets and numbers
const isUnread = (unreads?.['/'].unreads ?? new Set()).has(`/${noteId}/1/1`);
const isUnread = bookStats.each.includes(`/${noteId}`);
const snippet = getSnippet(body);
const commColor = (unreads?.[`/${noteId}`]?.unreads ?? 0) > 0 ? 'blue' : 'gray';
const commColor = noteStats.count > 0 ? 'blue' : 'gray';
const cursorStyle = post.pending ? 'default' : 'pointer';

View File

@ -25,6 +25,7 @@ import { CommentItem } from './CommentItem';
import airlock from '~/logic/api';
import useGraphState from '~/logic/state/graph';
import { useHistory } from 'react-router';
import { toHarkPlace } from '~/logic/lib/util';
interface CommentsProps {
comments: GraphNode;
@ -126,10 +127,11 @@ export function Comments(props: CommentsProps & PropFunc<typeof Col>) {
const children = Array.from(comments.children);
useEffect(() => {
console.log(parentIndex);
return () => {
airlock.poke(markCountAsRead(association.resource));
airlock.poke(markCountAsRead(toHarkPlace(association.resource, parentIndex)));
};
}, [comments.post?.index]);
}, [comments.post?.index, association.resource]);
const unreads = useHarkState(state => state.unreads);
const readCount = children.length - getUnreadCount(unreads, association.resource, parentIndex);

View File

@ -22,6 +22,7 @@ import { ProfileStatus } from './ProfileStatus';
import ReconnectButton from './ReconnectButton';
import { StatusBarItem } from './StatusBarItem';
import { useTutorialModal } from './useTutorialModal';
import {StatusBarJoins} from './StatusBarJoins';
const localSel = selectLocalState(['toggleOmnibox']);
@ -104,6 +105,7 @@ const StatusBar = (props) => {
{metaKey}/
</Text>
</StatusBarItem>
<StatusBarJoins />
<ReconnectButton />
</Row>
<Row justifyContent='flex-end'>

View File

@ -0,0 +1,116 @@
import { LoadingSpinner } from "@tlon/indigo-react";
import React, { useState } from "react";
import { Box, Row, Col, Text } from "@tlon/indigo-react";
import { PropFunc } from "~/types";
import _ from "lodash";
import * as Dialog from "@radix-ui/react-dialog";
import { StatusBarItem } from "./StatusBarItem";
import useGroupState from "~/logic/state/group";
import { JoinRequest, joinProgress } from "@urbit/api";
import { usePreview } from "~/logic/state/metadata";
import { Dropdown } from "./Dropdown";
import { MetadataIcon } from "../landscape/components/MetadataIcon";
function Elbow(
props: { size?: number; color?: string } & PropFunc<typeof Box>
) {
const { size = 12, color = "lightGray", ...rest } = props;
return (
<Box
{...rest}
overflow="hidden"
width={size}
height={size}
position="relative"
>
<Box
border="2px solid"
borderRadius={3}
borderColor={color}
position="absolute"
left="0px"
bottom="0px"
width={size * 2}
height={size * 2}
/>
</Box>
);
}
export function StatusBarJoins() {
const pendingJoin = useGroupState((s) => s.pendingJoin);
const [isOpen, setIsOpen] = useState(false);
if (
Object.keys(_.omitBy(pendingJoin, (j) => j.progress === "done")).length ===
0
) {
return null;
}
return (
<Dropdown
dropWidth="256px"
options={
<Col
left="0px"
top="120%"
position="absolute"
zIndex={10}
alignItems="center"
p="2"
gapY="4"
border="1"
borderColor="lightGray"
backgroundColor="white"
>
<Col>
{Object.keys(pendingJoin).map((g) => (
<JoinStatus key={g} group={g} join={pendingJoin[g]} />
))}
</Col>
</Col>
}
alignX="left"
alignY="bottom"
>
<StatusBarItem mr="2" width="32px" flexShrink={0} border={0}>
<LoadingSpinner foreground="black" />
</StatusBarItem>
</Dropdown>
);
}
const description: string[] = [
"Contacting host...",
"Retrieving data...",
"Finished join",
"Unable to join, you do not have the correct permissions",
"Internal error, please file an issue",
];
export function JoinStatus({
group,
join,
}: {
group: string;
join: JoinRequest;
}) {
const { preview, error } = usePreview(group);
const current = join && joinProgress.indexOf(join.progress);
const desc = _.isNumber(current) && description[current];
return (
<Col gapY="2">
<Row alignItems="center" gapX="2">
{preview ? (
<MetadataIcon height={4} width={4} metadata={preview.metadata} />
) : null}
<Text>{preview?.metadata.title || group.slice(6)}</Text>
</Row>
<Row ml="2" alignItems="center" gapX="2">
<Elbow />
<Text>{desc}</Text>
</Row>
</Col>
);
}

View File

@ -1,8 +1,10 @@
import { Box } from '@tlon/indigo-react';
import React, { useCallback, useEffect } from 'react';
import { Route, Switch, useHistory } from 'react-router-dom';
import { Route, Switch, useHistory, useLocation } from 'react-router-dom';
import styled from 'styled-components';
import ob from 'urbit-ob';
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
import useMetadataState from '~/logic/state/metadata';
import LaunchApp from '~/views/apps/launch/App';
import Notifications from '~/views/apps/notifications/notifications';
import { PermalinkRoutes } from '~/views/apps/permalinks/app';
@ -22,8 +24,93 @@ export const Container = styled(Box)`
height: calc(100% - 62px);
`;
function getGroupResourceRedirect(key: string) {
const association = useMetadataState.getState().associations.graph[`/ship/${key}`];
const { metadata } = association;
if(!association || !('graph' in metadata.config)) {
return '';
}
return `/~landscape${association.group}/resource/${metadata.config.graph}${association.resource}`;
}
function getPostRedirect(key: string, segs: string[]) {
const association = useMetadataState.getState().associations.graph[`/ship/${key}`];
const { metadata } = association;
if(!association || !('graph' in metadata.config)) {
return '';
}
return `/~landscape${association.group}/feed/thread/${segs.slice(0, -1).join('/')}`;
}
function getChatRedirect(chat: string, segs: string[]) {
const qs = segs.length > 0 ? `?msg=${segs[0]}` : '';
return `${getGroupResourceRedirect(chat)}${qs}`;
}
function getPublishRedirect(graphKey: string, segs: string[]) {
const base = getGroupResourceRedirect(graphKey);
if(segs.length === 3) {
return `${base}/note/${segs[0]}`;
} else if (segs.length === 4) {
return `${base}/note/${segs[0]}?selected=${segs[2]}`;
}
return base;
}
function getLinkRedirect(graphKey: string, segs: string[]) {
const base = getGroupResourceRedirect(graphKey);
if(segs.length === 1) {
return `${base}/index/${segs[0]}`;
} else if (segs.length === 3) {
return `${base}/index/${segs[0]}?selected=${segs[1]}`;
}
return base;
}
function getGraphRedirect(link: string) {
const [,mark, ship, name, ...rest] = link.split('/');
const graphKey = `${ship}/${name}`;
switch(mark) {
case 'graph-validator-dm':
return `/~landscape/messages/dm/${ob.patp(rest[0])}`;
case 'graph-validator-chat':
return getChatRedirect(graphKey, rest);
case 'graph-validator-publish':
return getPublishRedirect(graphKey, rest);
case 'graph-validator-link':
return getLinkRedirect(graphKey, rest);
case 'graph-validator-post':
return getPostRedirect(graphKey, rest);
default:
return'';
}
}
function getInviteRedirect(link: string) {
const [,,app,uid] = link.split('/');
return `/invites/${app}/${uid}`;
}
function getNotificationRedirect(link: string) {
if(link.startsWith('/graph-validator')) {
return getGraphRedirect(link);
} else if (link.startsWith('/invite')) {
return getInviteRedirect(link);
}
}
export const Content = (props) => {
const history = useHistory();
const location = useLocation();
const associations = useMetadataState(s => s.associations.graph);
useEffect(() => {
const query = new URLSearchParams(location.search);
if(Object.keys(associations).length > 0 && query.has('grid-note')) {
history.push(getNotificationRedirect(query.get('grid-note')));
}
}, [location.search]);
useShortcut('navForward', useCallback((e) => {
e.preventDefault();
@ -58,7 +145,7 @@ export const Content = (props) => {
<Switch>
<Route
exact
path='/'
path={['/', '/invites/:app/:uid']}
render={p => (
<LaunchApp
location={p.location}
@ -102,6 +189,7 @@ export const Content = (props) => {
/>
<GraphApp path="/~graph" {...props} />
<PermalinkRoutes {...props} />
<Route
render={p => (
<ErrorComponent

View File

@ -8,7 +8,7 @@ import { addTag, createManagedGraph, createUnmanagedGraph } from '@urbit/api';
import { Form, Formik } from 'formik';
import _ from 'lodash';
import React, { ReactElement } from 'react';
import { useHistory } from 'react-router-dom';
import { useHistory, useRouteMatch } from 'react-router-dom';
import * as Yup from 'yup';
import { resourceFromPath } from '~/logic/lib/group';
import { useWaitForProps } from '~/logic/lib/useWaitForProps';
@ -49,6 +49,7 @@ type NewChannelProps = {
export function NewChannel(props: NewChannelProps): ReactElement {
const history = useHistory();
const match = useRouteMatch();
const { group, workspace, existingMembers, ...rest } = props;
const groups = useGroupState(state => state.groups);
const waiter = useWaitForProps({ groups }, 5000);
@ -121,9 +122,9 @@ export function NewChannel(props: NewChannelProps): ReactElement {
);
}
actions.setStatus({ success: null });
const resourceUrl = location.pathname.includes('/messages')
const resourceUrl = match.url.includes('/messages')
? '/~landscape/messages'
: parentPath(location.pathname);
: parentPath(match.path);
history.push(
`${resourceUrl}/resource/${moduleType}/ship/~${window.ship}/${resId}`
);

View File

@ -12,7 +12,7 @@ import useContactState, { useContact } from '~/logic/state/contact';
import { getItemTitle, getModuleIcon, uxToHex } from '~/logic/lib/util';
import useGroupState from '~/logic/state/group';
import Dot from '~/views/components/Dot';
import useHarkState, { useHarkDm } from '~/logic/state/hark';
import { useHarkDm, useHarkStat } from '~/logic/state/hark';
import useSettingsState from '~/logic/state/settings';
import useGraphState from '~/logic/state/graph';
@ -20,14 +20,10 @@ function useAssociationStatus(resource: string) {
const [, , ship, name] = resource.split('/');
const graphKey = `${ship.slice(1)}/${name}`;
const isSubscribed = useGraphState(s => s.graphKeys.has(graphKey));
const { unreads, notifications } = useHarkState(
s => s.unreads.graph?.[resource]?.['/'] || { unreads: 0, notifications: 0, last: 0 }
);
const hasNotifications =
(typeof notifications === 'number' && notifications > 0) ||
(typeof notifications === 'object' && notifications.length);
const hasUnread =
typeof unreads === 'number' ? unreads > 0 : unreads?.size ?? 0 > 0;
const stats = useHarkStat(`/graph/~${graphKey}`);
const { count, each } = stats;
const hasNotifications = false;
const hasUnread = count > 0 || each.length > 0;
return hasNotifications
? 'notification'
: hasUnread

View File

@ -1,5 +1,5 @@
import React, { ReactElement, useCallback } from 'react';
import { AppAssociations, Associations, Graph, UnreadStats } from '@urbit/api';
import { Associations, Graph } from '@urbit/api';
import { patp, patp2dec } from 'urbit-ob';
import { SidebarAssociationItem, SidebarDmItem } from './SidebarItem';
@ -12,10 +12,9 @@ import useMetadataState from '~/logic/state/metadata';
import { useHistory } from 'react-router';
import { useShortcut } from '~/logic/state/settings';
function sidebarSort(
associations: AppAssociations,
unreads: Record<string, Record<string, UnreadStats>>
): Record<SidebarSort, (a: string, b: string) => number> {
function sidebarSort(): Record<SidebarSort, (a: string, b: string) => number> {
const { associations } = useMetadataState.getState();
const { unreads } = useHarkState.getState();
const alphabetical = (a: string, b: string) => {
const aAssoc = associations[a];
const bAssoc = associations[b];
@ -26,18 +25,13 @@ function sidebarSort(
};
const lastUpdated = (a: string, b: string) => {
const aAssoc = associations[a];
const bAssoc = associations[b];
const aResource = aAssoc?.resource;
const bResource = bAssoc?.resource;
const aUpdated = a.startsWith('~')
? (unreads?.[`/ship/~${window.ship}/dm-inbox`]?.[`/${patp2dec(a)}`]?.last || 0)
: ((unreads?.[aResource]?.['/']?.last) || 0);
? (unreads?.[`/graph/~${window.ship}/dm-inbox/${patp2dec(a)}`]?.last || 0)
: (unreads?.[`/graph/${a.slice(6)}`]?.last || 0);
const bUpdated = b.startsWith('~')
? (unreads?.[`/ship/~${window.ship}/dm-inbox`]?.[`/${patp2dec(b)}`]?.last || 0)
: ((unreads?.[bResource]?.['/']?.last) || 0);
? (unreads?.[`/graph/~${window.ship}/dm-inbox/${patp2dec(b)}`]?.last || 0)
: (unreads?.[`/graph/${b.slice(6)}`]?.last || 0);
return bUpdated - aUpdated || alphabetical(a, b);
};
@ -94,11 +88,10 @@ export function SidebarList(props: {
const { selected, config, workspace } = props;
const associations = useMetadataState(state => state.associations);
const inbox = useInbox();
const unreads = useHarkState(s => s.unreads.graph);
const graphKeys = useGraphState(s => s.graphKeys);
const ordered = getItems(associations, workspace, inbox)
.sort(sidebarSort(associations.graph, unreads)[config.sortBy]);
.sort(sidebarSort()[config.sortBy]);
const history = useHistory();

View File

@ -1,7 +1,9 @@
:: hark-graph-hook: notifications for graph-store [landscape]
::
/- post, group-store, metadata=metadata-store, hook=hark-graph-hook, store=hark-store
/+ resource, mdl=metadata, default-agent, dbug, graph-store, graph, grouplib=group, store=hark-store
/- hist=hark-store-historical
/+ resource, mdl=metadata, default-agent, dbug, graph-store, graph, grouplib=group
/+ agentio
::
::
~% %hark-graph-hook-top ..part ~
@ -18,23 +20,34 @@
+$ state-1
[%1 base-state-0]
::
+$ state-2
[%2 base-state-1]
::
+$ base-state-0
$: watching=(set [resource index:post])
mentions=_&
watch-on-self=_&
==
::
+$ base-state-1
$: watching=(set [resource index:post])
mentions=_&
watch-on-self=_&
places=(map resource place:store)
==
::
++ scry
|* [[our=@p now=@da] =mold p=path]
?> ?=(^ p)
~! p
?> ?=(^ t.p)
.^(mold i.p (scot %p our) i.t.p (scot %da now) t.t.p)
::
++ scry-notif-conversion
|= [[our=@p now=@da] desk=term =mark]
^- $-(indexed-post:graph-store (unit notif-kind:hook))
^- $-(indexed-post:graph-store $-(cord (unit notif-kind:hook)))
%^ scry [our now]
$-(indexed-post:graph-store (unit notif-kind:hook))
$-(indexed-post:graph-store $-(cord (unit notif-kind:hook)))
/cf/[desk]/[mark]/notification-kind
--
::
@ -52,6 +65,8 @@
met ~(. mdl bowl)
grp ~(. grouplib bowl)
gra ~(. graph bowl)
io ~(. agentio bowl)
pass pass:io
::
++ on-init
:_ this
@ -63,6 +78,7 @@
^- (quip card _this)
=+ !<(old=versioned-state vase)
=| cards=(list card)
=. cards [watch-graph:ha cards]
|-
?: ?=(%0 -.old)
%_ $
@ -103,13 +119,57 @@
?> (team:title our.bowl src.bowl)
=^ cards state
?+ mark (on-poke:def mark vase)
%hark-graph-migrate
=+ !<(old=versioned-state:hist vase)
?. ?=(%7 -.old) ~|(%old-hark-dropping !!)
(hark-graph-migrate old)
::
%hark-graph-hook-action
(hark-graph-hook-action !<(action:hook vase))
::
%noun
(poke-noun !<(* vase))
==
[cards this]
::
++ hark-graph-migrate
|= old=state-7:hist
=| cards=(list card)
|^
[(flop get-places) state]
::
++ hark
|= =action:store
[(poke-our:pass %hark-store hark-action+!>(action)) cards]
::
++ get-places
^- (list card)
=/ stats-indices=(set stats-index:hist)
(~(uni in ~(key by last-seen.old)) ~(key by unreads-count.old))
%- zing
(turn ~(tap in stats-indices) get-stats)
::
++ get-stats
|= =stats-index:hist
^- (list card)
=/ place=(unit place:store)
(stats-index-to-place stats-index)
?~ place ~
=/ count (~(get by unreads-count.old) stats-index)
=? cards ?=(^ count)
(hark %unread-count u.place & u.count)
=/ last (~(get by last-seen.old) stats-index)
=? cards ?=(^ last)
(hark %saw-place u.place `u.last)
cards
::
++ stats-index-to-place
|= =stats-index:hist
^- (unit place:store)
?. ?=(%graph -.stats-index) ~
`(get-place [graph index]:stats-index)
--
::
++ poke-noun
|= non=*
[~ state]
@ -202,7 +262,7 @@
%- ~(gas by *(set [resource index:graph-store]))
(turn ~(tap in indices) (lead rid))
:_ state(watching (~(dif in watching) to-remove))
=/ convert (get-conversion:ha rid)
=/ convert (get-conversion:ha rid '')
%+ roll
~(tap in indices)
|= [=index:graph-store out=(list card)]
@ -211,11 +271,11 @@
=/ notif-kind=(unit notif-kind:hook)
(convert indexed-post)
?~ notif-kind out
=/ =stats-index:store
[%graph rid (scag parent.index-len.u.notif-kind index)]
=/ =place:store
(get-place rid index)
?. ?=(%each mode.u.notif-kind) out
:_ out
(poke-hark %read-each stats-index index)
(poke-hark %read-each place index)
::
++ poke-hark
|= =action:store
@ -231,11 +291,8 @@
=(r rid)
:_ state(watching (~(dif in watching) unwatched))
^- (list card)
:- (poke-hark:ha %remove-graph rid)
%- zing
%+ turn ~(tap in unwatched)
|= [r=resource =index:graph-store]
(give:ha ~[/updates] %ignore r index)
~
:: XX: fix
::
++ add-graph
|= rid=resource
@ -280,15 +337,47 @@
grp ~(. grouplib bowl)
gra ~(. graph bowl)
::
++ graph-index-to-path
|= =index:graph-store
^- path
(turn index (cork (cury scot %ui) (cury rsh 4)))
::
++ summarize
|= contents=(list content:post)
%+ rap 3
%+ join ' '
%+ turn contents
|= =content:post
?- -.content
%text text.content
%url url.content
%code '<Code fragment>'
%reference '<A reference>'
%mention (scot %p ship.content)
==
::
++ get-place
|= [rid=resource =index:graph-store]
:- q.byk.bowl
(welp /graph/(scot %p entity.rid)/[name.rid] (graph-index-to-path index))
::
++ get-bin
|= [rid=resource parent=index:graph-store is-mention=?]
^- bin:store
[?:(is-mention /mention /) (get-place rid parent)]
::
++ get-conversion
|= rid=resource
|= [rid=resource title=cord]
^- $-(indexed-post:graph-store (unit notif-kind:hook))
=+ %^ scry [our now]:bowl
,mark=(unit mark)
/gx/graph-store/graph/(scot %p entity.rid)/[name.rid]/mark/noun
?~ mark
|=(=indexed-post:graph-store ~)
(scry-notif-conversion [our now]:bowl q.byk.bowl u.mark)
=/ f=$-(indexed-post:graph-store $-(cord (unit notif-kind:hook)))
(scry-notif-conversion [our now]:bowl q.byk.bowl u.mark)
|= =indexed-post:graph-store
((f indexed-post) title)
::
++ give
|= [paths=(list path) =update:hook]
@ -338,8 +427,11 @@
(get-mark:gra r)
update-core(rid r, updates upds, mark m)
::
++ title
~+ title:(fall (peek-metadatum:met %graph rid) *metadatum:metadata)
::
++ get-conversion
~+ (^get-conversion rid)
~+ (^get-conversion rid title)
::
++ abet
^- (quip card _state)
@ -391,63 +483,65 @@
=. update-core (check-node-children node)
?: ?=(%| -.post.node)
update-core
::?~ mark update-core
=* pos p.post.node
=/ notif-kind=(unit notif-kind:hook)
(get-conversion [0 pos])
(get-conversion [0 pos])
?~ notif-kind
update-core
=/ desc=@t
?: (is-mention contents.pos)
%mention
name.u.notif-kind
=* not-kind u.notif-kind
=/ parent=index:post
(scag parent.index-len.not-kind index.pos)
=/ notif-index=index:store
[%graph rid mark desc parent]
=/ is-mention (is-mention contents.pos)
=/ =bin:store
(get-bin rid parent is-mention)
?: =(our.bowl author.pos)
(self-post node notif-index not-kind)
(self-post node bin u.notif-kind)
=. update-core
(update-unread-count not-kind notif-index [time-sent index]:pos)
%^ update-unread-count u.notif-kind bin
(scag self.index-len.not-kind index.pos)
=? update-core
?| =(desc %mention)
?| is-mention
(~(has in watching) [rid parent])
=(mark `%graph-validator-dm)
==
=/ =contents:store
[%graph (limo pos ~)]
(add-unread notif-index [time-sent.pos %.n contents])
=/ link=path
(welp /(fall mark '')/(scot %p entity.rid)/[name.rid] (graph-index-to-path index.pos))
=/ title=(list content:store)
?. is-mention title.not-kind
~[text/(rap 3 'You were mentioned in ' title ~)]
=/ =body:store
[title body.not-kind now.bowl path.bin link]
(add-unread bin body)
update-core
::
::
++ update-unread-count
|= [=notif-kind:hook =index:store time=@da ref=index:graph-store]
=/ =stats-index:store
(to-stats-index:store index)
|= [=notif-kind:hook =bin:store =index:graph-store]
?- mode.notif-kind
%count (hark %unread-count stats-index time)
%each (hark %unread-each stats-index ref time)
%count (hark %unread-count place.bin %.y 1)
%each (hark %unread-each place.bin /(rsh 4 (scot %ui (rear index))))
%none update-core
==
::
::
++ self-post
|= $: =node:graph-store
=index:store
=bin:store
=notif-kind:hook
==
^+ update-core
?> ?=(%& -.post.node)
=/ =stats-index:store
(to-stats-index:store index)
=. update-core
(hark %seen-index time-sent.p.post.node stats-index)
(hark %saw-place place.bin `now.bowl)
=? update-core ?=(%count mode.notif-kind)
(hark %read-count stats-index)
(hark %read-count place.bin)
=? update-core watch-on-self
(new-watch index.p.post.node [watch-for index-len]:notif-kind)
update-core
::
++ add-unread
|= [=index:store =notification:store]
(hark %add-note index notification)
|= [=bin:store =body:store]
(hark %add-note bin body)
--
--

View File

@ -14,6 +14,8 @@
$: %0
watching=(set resource)
==
+$ update
$>(?(%add-members %remove-members) update:group-store)
::
--
::
@ -127,13 +129,13 @@
[~ state]
?. (~(has in watching) resource.update)
[~ state]
=/ =contents:store
[%group ~[update]]
=/ =notification:store [now.bowl %.n contents]
=/ =index:store
[%group resource.update -.update]
=/ body=(unit body:store)
(get-content:ha update)
?~ body `state
=/ =bin:store
(get-bin:ha resource.update -.update)
:_ state
~[(add-unread index notification)]
~[(add-unread bin u.body)]
:: +metadata-update is stubbed for now, for the following reasons
:: - There's no semantic difference in metadata-store between
:: adding and editing a channel
@ -145,12 +147,12 @@
[~ state]
::
++ add-unread
|= [=index:store =notification:store]
|= [=bin:store =body:store]
^- card
=- [%pass / %agent [our.bowl %hark-store] %poke -]
:- %hark-action
!> ^- action:store
[%add-note index notification]
[%add-note bin body]
--
::
++ on-peek on-peek:def
@ -159,7 +161,48 @@
++ on-fail on-fail:def
--
|_ =bowl:gall
+* met ~(. metadata bowl)
+* met ~(. mdl bowl)
++ get-content
|= =update:group-store
^- (unit body:store)
?. ?=(?(%add-members %remove-members) -.update) ~
?~ meta=(peek-metadatum:met %groups resource.update)
~
=/ ships=(list content:store)
%+ turn ~(tap in ships.update)
|= =ship
^- content:store
ship+ship
=/ sep=content:store text+', '
=. ships
(join sep ships)
?- -.update
%add-members
:- ~
:* (snoc ships text+(rap 3 ' joined ' title.u.meta ~))
~
now.bowl
/
/
==
::
%remove-members
:- ~
:* (snoc ships text+(rap 3 ' joined ' title.u.meta ~))
~
now.bowl
/
/
==
==
++ get-bin
|= [rid=resource reason=@t]
^- bin:store
[/[reason] (get-place rid)]
++ get-place
|= rid=resource
^- place:store
[q.byk.bowl /(scot %p entity.rid)/[name.rid]]
::
++ watch-groups
^- card

View File

@ -0,0 +1,174 @@
/- hark=hark-store, invite=invite-store
/+ verb, dbug, default-agent, agentio, resource, metadata=metadata-store
|%
+$ card card:agent:gall
+$ versioned-state
$% state-0
==
::
+$ state-0
$: blocked=(jug resource [uid=serial:invite =invite:invite])
~
==
--
%+ verb &
%- agent:dbug
^- agent:gall
=| state-0
=* state -
=<
|_ =bowl:gall
+* this .
def ~(. (default-agent this %|) bowl)
io ~(. agentio bowl)
pass pass:io
cc ~(. +> bowl)
++ on-init
:_ this
~[watch:invite-store:cc]
::
++ on-load
|= =vase
:_ this
(drop safe-watch:invite-store:cc)
++ on-save !>(state)
::
++ on-poke on-poke:def
++ on-peek on-peek:def
++ on-watch on-watch:def
++ on-agent
|= [=wire =sign:agent:gall]
|^
?+ wire (on-agent:def wire sign)
[%invites ~] take-invites
::
[%groups @ @ @ ~]
=/ rid=resource (de-path:resource t.wire)
(take-groups rid)
==
++ take-groups
|= rid=resource
=/ blocking (~(get ju blocked) rid)
|^
?+ -.sign (on-agent:def wire sign)
::
%kick
?: =(~ blocking) `this
(flush-blocking (rap 3 (scot %p entity.rid) '/' name.rid ~))
::
%fact
?. ?=(%metadata-hook-update p.cage.sign) `this
=+ !<(=hook-update:metadata q.cage.sign)
?. ?=(%preview -.hook-update) `this
(flush-blocking title.metadatum.hook-update)
==
::
++ flush-blocking
|= to=cord
:_ this(blocked (~(del by blocked) rid))
%+ turn ~(tap in blocking)
|= [uid=serial:invite =invite:invite]
(~(created inv:cc %groups uid) invite to)
--
::
++ take-invites
?- -.sign
?(%poke-ack %watch-ack) (on-agent:def wire sign)
%kick :_(this (drop safe-watch:invite-store:cc))
::
%fact
?. ?=(%invite-update p.cage.sign) `this
=+ !<(=update:invite q.cage.sign)
?+ -.update `this
::
%accepted
:_ this
~(dismissed inv:cc [term uid]:update)^~
::
%decline
~& decline/uid.update
:_ this
~(dismissed inv:cc [term uid]:update)^~
::
%invite
=* rid resource.invite.update
?+ term.update
:: default
=/ fallback (rap 3 (scot %p entity.rid) '/' name.rid ~)
:_ this
(~(created inv:cc [term uid]:update) invite.update fallback)^~
::
%groups
=. blocked (~(put ju blocked) rid [uid invite]:update)
:_ this
(drop ~(safe-watch preview:cc rid))
::
%graph
:_ this
(~(created inv:cc [term uid]:update) invite.update 'a group chat')^~
==
==
==
--
::
++ on-arvo on-arvo:def
++ on-fail on-fail:def
++ on-leave on-leave:def
--
|_ =bowl:gall
+* io ~(. agentio bowl)
pass pass:io
++ ha
|%
++ pass ~(. ^pass /hark)
++ poke
|=(=action:hark (poke-our:pass %hark-store hark-action+!>(action)))
--
++ invite-store
|%
++ path /updates
++ wire /invites
++ pass ~(. ^pass wire)
++ watch (watch-our:pass %invite-store path)
++ watching (~(has by wex.bowl) [wire our.bowl %invite-store])
++ safe-watch `(unit card)`?:(watching ~ `watch)
--
::
++ preview
|_ rid=resource
++ path preview+(en-path:resource rid)
++ wire groups+(en-path:resource rid)
++ pass ~(. ^pass wire)
++ watching (~(has by wex.bowl) [wire our.bowl %metadata-pull-hook])
++ watch (watch-our:pass %metadata-pull-hook path)
++ safe-watch `(unit card)`?:(watching ~ `watch)
--
::
++ inv
|_ [=term uid=serial:invite]
++ bin [/ place]
++ path /[term]/(scot %uv uid)
++ place `place:hark`[q.byk.bowl path]
++ dismissed
(poke:ha %del-place place)
++ created
|= [=invite:invite to=cord]
=; =body:hark
(poke:ha %add-note bin body)
=, invite
=/ title=@t
(rap 3 'You have been invited to ' to ' by ' ~)
:* ~[text+title ship+ship]
~[text+text]
now.bowl
/
invite+path
==
--
--

View File

@ -1,745 +0,0 @@
:: hark-store: notifications and unread counts [landscape]
::
:: hark-store can store unread counts differently, depending on the
:: resource.
:: - last seen. This way, hark-store simply stores an index into
:: graph-store, which represents the last "seen" item, useful for
:: high-volume applications which are intrinsically time-ordered. i.e.
:: chats, comments
:: - each. Hark-store will store an index for each item that is unread.
:: Usefull for non-linear, low-volume applications, i.e. blogs,
:: collections
::
/- post, group-store, metadata-store, store=hark-store
/+ resource, metadata, default-agent, dbug, graph-store, graphl=graph, verb, store=hark-store
::
::
~% %hark-store-top ..part ~
|%
+$ card card:agent:gall
+$ versioned-state
$% state:state-zero:store
state:state-one:store
state-2
state-3
state-4
state-5
state-6
state-7
==
+$ unread-stats
[indices=(set index:graph-store) last=@da]
::
+$ base-state
$: unreads-each=(jug stats-index:store index:graph-store)
unreads-count=(map stats-index:store @ud)
timeboxes=(map stats-index:store @da)
unread-notes=timebox:store
last-seen=(map stats-index:store @da)
=notifications:store
archive=notifications:store
current-timebox=@da
dnd=_|
==
::
+$ state-2
[%2 state-two:store]
::
+$ state-3
[%3 state-two:store]
::
+$ state-4
[%4 state-three:store]
::
+$ state-5
[%5 state-three:store]
::
+$ state-6
[%6 state-four:store]
::
+$ state-7
[%7 base-state]
::
::
++ orm ((ordered-map @da timebox:store) gth)
--
::
=| state-7
=* state -
::
=<
%+ verb |
%- agent:dbug
^- agent:gall
~% %hark-store-agent ..card ~
|_ =bowl:gall
+* this .
ha ~(. +> bowl)
def ~(. (default-agent this %|) bowl)
met ~(. metadata bowl)
gra ~(. graphl bowl)
::
++ on-init
:_ this
~[autoseen-timer]
::
++ on-save !>(state)
++ on-load
|= =old=vase
^- (quip card _this)
=/ old
!<(versioned-state old-vase)
=| cards=(list card)
|^
^- (quip card _this)
?- -.old
%7
:- (flop cards)
this(state old)
::
%6
%_ $
-.old %7
::
+.old
%* . *base-state
notifications (notifications:to-five:upgrade:store notifications.old)
archive ~
unreads-each unreads-each.old
unreads-count unreads-count.old
last-seen last-seen.old
current-timebox current-timebox
dnd dnd.old
==
==
::
%5
%_ $
-.old %6
notifications.old (notifications:to-four:upgrade:store notifications.old)
archive.old *notifications:state-four:store
==
::
%4
%_ $
-.old %5
::
last-seen.old
%- ~(run by last-seen.old)
|=(old=@da (min old now.bowl))
==
::
%3
%_ $
-.old %4
notifications.old (notifications:to-three:upgrade:store notifications.old)
archive.old *notifications:state-three:store
==
::
%2
%_ $
-.old %3
::
cards
:_ cards
[%pass / %agent [our dap]:bowl %poke noun+!>(%fix-dangling)]
==
::
%1
%_ $
::
old
%* . *state-2
unreads-each ((convert-unread ,(set index:graph-store)) uni-by unreads-each.old)
unreads-count ((convert-unread ,@ud) add unreads-count.old)
last-seen ((convert-unread ,@da) max last-seen.old)
notifications notifications.old
archive archive.old
current-timebox current-timebox.old
dnd dnd.old
==
==
::
%0
%_ $
::
old
%* . *state:state-one:store
notifications (convert-notifications-1 notifications.old)
archive (convert-notifications-1 archive.old)
current-timebox current-timebox.old
dnd dnd.old
==
==
==
::
++ uni-by
|= [a=(set index:graph-store) b=(set index:graph-store)]
=/ merged
(~(uni in a) b)
%- ~(gas in *(set index:graph-store))
%+ skip ~(tap in merged)
|=(=index:graph-store &(=((lent index) 3) !=(-:(flop index) 1)))
::
++ convert-unread
|* value=mold
|= [combine=$-([value value] value) unreads=(map index:store value)]
^- (map stats-index:store value)
%+ roll
~(tap in unreads)
|= [[=index:store val=value] out=(map stats-index:store value)]
=/ old=value
(~(gut by unreads) index (combine))
=/ =stats-index:store
(to-stats-index:store index)
(~(put by out) stats-index (combine old val))
::
++ convert-notifications-1
|= old=notifications:state-zero:store
%+ gas:orm:state-two:store *notifications:state-two:store
^- (list [@da timebox:state-two:store])
%+ murn
(tap:orm:state-zero:store old)
|= [time=@da =timebox:state-zero:store]
^- (unit [@da timebox:state-two:store])
=/ new-timebox=timebox:state-two:store
(convert-timebox-1 timebox)
?: =(0 ~(wyt by new-timebox))
~
`[time new-timebox]
::
++ convert-timebox-1
|= =timebox:state-zero:store
^- timebox:state-two:store
%- ~(gas by *timebox:state-two:store)
^- (list [index:state-two:store notification:state-two:store])
%+ murn
~(tap by timebox)
|= [=index:state-zero:store =notification:state-zero:store]
^- (unit [index:state-two:store notification:state-two:store])
=/ new-index=(unit index:state-two:store)
(convert-index-1 index)
=/ new-notification=(unit notification:state-two:store)
(convert-notification-1 notification)
?~ new-index ~
?~ new-notification ~
`[u.new-index u.new-notification]
::
++ convert-index-1
|= =index:state-zero:store
^- (unit index:state-two:store)
?+ -.index `index
%chat ~
::
%graph
=, index
`[%graph graph *resource module description ~]
==
::
++ convert-notification-1
|= =notification:state-zero:store
^- (unit notification:state-two:store)
?: ?=(%chat -.contents.notification)
~
`notification
--
::
++ on-watch
|= =path
^- (quip card _this)
?> (team:title [src our]:bowl)
|^
?+ path (on-watch:def path)
::
[%updates ~]
:_ this
[%give %fact ~ hark-update+!>(initial-updates)]~
==
::
++ initial-updates
^- update:store
:- %more
^- (list update:store)
:~ give-unreads
[%set-dnd dnd]
give-notifications
==
::
++ give-notifications
^- update:store
[%timebox ~ ~(tap by unread-notes)]
::
++ give-since-unreads
^- (list [stats-index:store stats:store])
%+ turn
~(tap by unreads-count)
|= [=stats-index:store count=@ud]
:* stats-index
[%count count]
(~(gut by last-seen) stats-index *time)
==
::
++ give-each-unreads
^- (list [stats-index:store stats:store])
%+ turn
~(tap by unreads-each)
|= [=stats-index:store indices=(set index:graph-store)]
:* stats-index
[%each indices]
(~(gut by last-seen) stats-index *time)
==
::
++ give-unreads
^- update:store
:- %unreads
;: weld
give-each-unreads
give-since-unreads
==
--
::
++ on-peek
|= =path
^- (unit (unit cage))
?+ path (on-peek:def path)
::
[%x %recent ?(%archive %inbox) @ @ ~]
=/ is-archive
=(%archive i.t.t.path)
=/ offset=@ud
(slav %ud i.t.t.t.path)
=/ length=@ud
(slav %ud i.t.t.t.t.path)
:^ ~ ~ %hark-update
!> ^- update:store
:- %more
%+ turn
%+ scag length
%+ slag offset
%- tap-nonempty:ha
?:(is-archive archive notifications)
|= [time=@da =timebox:store]
^- update:store
[%timebox `time ~(tap by timebox)]
==
::
++ on-poke
~/ %hark-store-poke
|= [=mark =vase]
^- (quip card _this)
|^
?> (team:title our.bowl src.bowl)
=^ cards state
?+ mark (on-poke:def mark vase)
%hark-action (hark-action !<(action:store vase))
%noun (poke-noun !<(* vase))
==
[cards this]
::
++ poke-noun
|= val=*
?+ val ~|(%bad-noun-poke !!)
%fix-dangling fix-dangling
%print ~&(+.state [~ state])
==
::
++ fix-dangling
=/ graphs get-keys:gra
:_ state
%+ roll
~(tap by unreads-each)
|= $: [=stats-index:store indices=(set index:graph-store)]
out=(list card)
==
?. ?=(%graph -.stats-index) out
?. (~(has in graphs) graph.stats-index)
:_(out (poke-us %remove-graph graph.stats-index))
%+ welp out
%+ turn
%+ skip
~(tap in indices)
|= =index:graph-store
(check-node-existence:gra graph.stats-index index)
|=(=index:graph-store (poke-us %read-each stats-index index))
::
++ poke-us
|= =action:store
^- card
[%pass / %agent [our dap]:bowl %poke hark-action+!>(action)]
::
++ hark-action
|= =action:store
^- (quip card _state)
abet:translate:(abed:poke-engine:ha action)
--
::
++ on-agent on-agent:def
::
++ on-leave on-leave:def
++ on-arvo
|= [=wire =sign-arvo]
^- (quip card _this)
?. ?=([%autoseen ~] wire)
(on-arvo:def wire sign-arvo)
`this
::
++ on-fail on-fail:def
--
|_ =bowl:gall
+* met ~(. metadata bowl)
++ poke-engine
|_ [in=action:store out=(list update:store) cards=(list card)]
++ poke-core .
::
++ abed
|= =action:store poke-core(in action)
::
++ abet
^- (quip card _state)
:_ state
%+ snoc (flop cards)
[%give %fact ~[/updates] %hark-update !>([%more (flop out)])]
::
++ give
|= =update:store poke-core(out [update out])
::
++ emit
|= =card poke-core(cards [card cards])
::
++ translate
^+ poke-core
?- -.in
::
%add-note (add-note +.in)
%archive (do-archive +.in)
::
%unread-count (unread-count +.in)
%read-count (read-count +.in)
::
%read-each (read-each +.in)
%unread-each (unread-each +.in)
::
%read-note (read-note +.in)
::
%seen-index (seen-index +.in)
::
%remove-graph (remove-graph +.in)
%read-graph (read-graph +.in)
%read-group (read-group +.in)
::
%set-dnd (set-dnd +.in)
%seen seen
%read-all read-all
::
==
::
:: +| %note
::
:: notification tracking
++ put-notifs
|= [time=@da =timebox:store]
poke-core(notifications (put:orm notifications time timebox))
::
++ add-note
|= [=index:store =notification:store]
^+ poke-core
=/ existing-notif
(~(get by unread-notes) index)
=/ new=notification:store
(merge-notification existing-notif notification)
=. unread-notes
(~(put by unread-notes) index new)
=/ timebox=@da
(~(gut by timeboxes) (to-stats-index:store index) current-timebox)
(give %added index new)
::
++ do-archive
|= [time=(unit @da) =index:store]
^+ poke-core
|^
?~(time archive-unread (archive-read u.time))
::
++ archive-unread
=. unread-notes
(~(del by unread-notes) index)
(give %archive ~ index)
::
++ archive-read
|= time=@da
=/ =timebox:store
(gut-orm notifications time)
=/ =notification:store
(~(got by timebox) index)
=/ new-timebox=timebox:store
(~(del by timebox) index)
=. poke-core
(put-notifs time new-timebox)
(give %archive `time index)
--
::
++ read-note
|= =index:store
=/ =notification:store
(~(got by unread-notes) index)
=. unread-notes
(~(del by unread-notes) index)
=/ =time
(~(gut by timeboxes) (to-stats-index:store index) current-timebox)
=/ =timebox:store
(gut-orm notifications time)
=/ existing-notif
(~(get by timebox) index)
=/ new=notification:store
(merge-notification existing-notif notification)
=. timebox
(~(put by timebox) index new)
=. notifications
(put:orm notifications time timebox)
(give %note-read time index)
::
::
:: +| %each
::
:: each unread tracking
::
++ unread-each
|= [=stats-index:store unread=index:graph-store time=@da]
=. poke-core (seen-index time stats-index)
%+ jub-unreads-each:(give %unread-each stats-index unread time)
stats-index
|= indices=(set index:graph-store)
(~(put ^in indices) unread)
::
++ read-index-each
|= [=stats-index:store ref=index:graph-store]
%- read-indices
%+ skim
~(tap ^in ~(key by unread-notes))
|= =index:store
?. (stats-index-is-index:store stats-index index) %.n
=/ not=notification:store
(~(got by unread-notes) index)
?. ?=(%graph -.index) %.n
?. ?=(%graph -.contents.not) %.n
(lien list.contents.not |=(p=post:post =(index.p ref)))
::
++ read-each
|= [=stats-index:store ref=index:graph-store]
=. timeboxes (~(put by timeboxes) stats-index now.bowl)
=. poke-core (read-index-each stats-index ref)
%+ jub-unreads-each:(give %read-each stats-index ref)
stats-index
|= indices=(set index:graph-store)
(~(del ^in indices) ref)
::
++ jub-unreads-each
|= $: =stats-index:store
f=$-((set index:graph-store) (set index:graph-store))
==
poke-core(unreads-each (jub stats-index f))
::
++ unread-count
|= [=stats-index:store time=@da]
=/ new-count
+((~(gut by unreads-count) stats-index 0))
=. unreads-count
(~(put by unreads-count) stats-index new-count)
(seen-index:(give %unread-count stats-index time) time stats-index)
::
++ read-count
|= =stats-index:store
=. unreads-count (~(put by unreads-count) stats-index 0)
=/ times=(list index:store)
(unread-for-stats-index stats-index)
=? timeboxes !(~(has by timeboxes) stats-index) (~(put by timeboxes) stats-index now.bowl)
(give:(read-indices times) %read-count stats-index)
::
++ read-indices
|= times=(list =index:store)
|-
?~ times poke-core
=/ core
(read-note i.times)
$(poke-core core, times t.times)
::
++ seen-index
|= [time=@da =stats-index:store]
=/ new-time=@da
(max time (~(gut by last-seen) stats-index 0))
=. last-seen
(~(put by last-seen) stats-index new-time)
(give %seen-index new-time stats-index)
::
++ get-stats-indices
|= rid=resource
%- ~(gas ^in *(set stats-index:store))
%+ skim
;: weld
~(tap ^in ~(key by unreads-count))
~(tap ^in ~(key by last-seen))
~(tap ^in ~(key by unreads-each))
==
|= =stats-index:store
?. ?=(%graph -.stats-index) %.n
=(graph.stats-index rid)
::
++ read-all-each
|= =stats-index:store
=/ refs=(list index:graph-store)
~(tap ^in (~(get ju unreads-each) stats-index))
|-
?~ refs poke-core
$(refs t.refs, poke-core (read-each stats-index i.refs))
::
++ read-graph
|= rid=resource
=/ indices=(list stats-index:store)
~(tap ^in (get-stats-indices rid))
|-
?~ indices poke-core
=* index i.indices
=? poke-core (~(has by unreads-count) index)
(read-count i.indices)
=? poke-core (~(has by unreads-each) index)
(read-all-each i.indices)
$(indices t.indices)
::
++ read-group
|= rid=resource
=/ graphs=(list resource)
(graphs-of-group:met rid)
|-
?~ graphs poke-core
=/ core=_poke-core (read-graph i.graphs)
$(graphs t.graphs, poke-core core)
::
++ remove-graph
|= rid=resource
|^
=/ indices (get-stats-indices rid)
=. poke-core
(give %remove-graph rid)
=. poke-core
(remove-notifications indices)
=. unreads-count
((dif-map-by-key ,@ud) unreads-count indices)
=. unreads-each
%+ (dif-map-by-key ,(set index:graph-store))
unreads-each indices
=. last-seen
((dif-map-by-key ,@da) last-seen indices)
poke-core
::
++ dif-map-by-key
|* value=mold
|= [=(map stats-index:store value) =(set stats-index:store)]
=/ to-remove ~(tap ^in set)
|-
?~ to-remove map
=. map
(~(del by map) i.to-remove)
$(to-remove t.to-remove)
::
++ remove-notifications
|= =(set stats-index:store)
^+ poke-core
=/ indices
~(tap ^in set)
|-
?~ indices poke-core
=/ times=(list =index:store)
(unread-for-stats-index i.indices)
=. poke-core
(read-indices times)
$(indices t.indices)
--
::
++ seen
=. poke-core
(read-indices ~(tap ^in ~(key by unread-notes)))
poke-core(current-timebox now.bowl, timeboxes ~)
::
++ read-all
=: unreads-count (~(run by unreads-count) _0)
unreads-each (~(run by unreads-each) _~)
notifications (~(run by notifications) _~)
==
(give:seen %read-all ~)
::
++ set-dnd
|= d=?
(give:poke-core(dnd d) %set-dnd d)
--
::
++ unread-for-stats-index
|= =stats-index:store
%+ skim ~(tap in ~(key by unread-notes))
(cury stats-index-is-index:store stats-index)
::
++ merge-notification
|= [existing=(unit notification:store) new=notification:store]
^- notification:store
?~ existing new
?- -.contents.u.existing
::
%graph
?> ?=(%graph -.contents.new)
u.existing(list.contents (weld list.contents.u.existing list.contents.new))
::
%group
?> ?=(%group -.contents.new)
u.existing(list.contents (weld list.contents.u.existing list.contents.new))
==
::
:: +key-orm: +key:by for ordered maps
++ key-orm
|= =notifications:store
^- (list @da)
(turn (tap:orm notifications) |=([@da *] +<-))
:: +jub-orm: combo +jab/+gut for ordered maps
:: TODO: move to zuse.hoon
++ jub-orm
|= [=notifications:store time=@da fun=$-(timebox:store timebox:store)]
^- notifications:store
=/ =timebox:store
(fun (gut-orm notifications time))
(put:orm notifications time timebox)
++ jub
|= [=stats-index:store f=$-((set index:graph-store) (set index:graph-store))]
^- (jug stats-index:store index:graph-store)
=/ val=(set index:graph-store)
(~(gut by unreads-each) stats-index ~)
(~(put by unreads-each) stats-index (f val))
:: +gut-orm: +gut:by for ordered maps
:: TODO: move to zuse.hoon
++ gut-orm
|= [=notifications:store time=@da]
^- timebox:store
(fall (get:orm notifications time) ~)
::
++ autoseen-interval ~h3
++ cancel-autoseen
^- card
[%pass /autoseen %arvo %b %rest (add current-timebox autoseen-interval)]
::
++ autoseen-timer
^- card
[%pass /autoseen %arvo %b %wait (add now.bowl autoseen-interval)]
::
++ scry
|* [=mold p=path]
?> ?=(^ p)
?> ?=(^ t.p)
.^(mold i.p (scot %p our.bowl) i.t.p (scot %da now.bowl) t.t.p)
::
++ give
|= [paths=(list path) update=update:store]
^- (list card)
[%give %fact paths [%hark-update !>(update)]]~
::
++ tap-nonempty
|= =notifications:store
^- (list [@da timebox:store])
%+ skim (tap:orm notifications)
|=([@da =timebox:store] !=(~(wyt by timebox) 0))
--

View File

@ -1,6 +1,6 @@
::
/- *notify, resource, hark-store, post
/+ default-agent, verb, dbug, group, agentio
/+ default-agent, verb, dbug, group, agentio, graphlib=graph
::
|%
+$ card card:agent:gall
@ -18,19 +18,25 @@
$: providers=(jug @p term)
==
::
+$ state-0
$: %0
=provider-state
+$ base-state-0
$: =provider-state
=client-state
==
::
+$ state-0
[%0 base-state-0]
::
+$ state-1
[%1 base-state-0]
::
+$ versioned-state
$% state-0
state-1
==
::
--
::
=| state-0
=| state-1
=* state -
::
%- agent:dbug
@ -47,19 +53,27 @@
::
++ on-init
:_ this
[(~(watch-our pass:io /hark) %hark-store /updates)]~
[(~(watch-our pass:io /hark) %hark-store /notes)]~
::
++ on-save !>(state)
++ on-load
|= =old=vase
^- (quip card _this)
=/ old !<(versioned-state old-vase)
=| cards=(list card)
|-
?- -.old
%1 [(flop cards) this]
::
%0
:_ this(state old)
?. (~(has by wex.bowl) [/hark our.bowl %hark-store])
~
[(~(watch-our pass:io /hark) %hark-store /updates)]~
%_ $
::
cards
%+ welp cards
:~ (~(leave-our pass:io /hark) %hark-store)
(~(watch-our pass:io /hark) %hark-store /notes)
==
==
==
::
++ on-poke
@ -199,12 +213,10 @@
?. ?=(%hark-update p.cage.sign)
~
=+ !<(hark-update=update:hark-store q.cage.sign)
=/ notes=(list notification) (filter-notifications:do hark-update)
?~ notes
~
?~ not=(filter-notifications:do hark-update) ~
:: only send the last one, since hark accumulates notifcations
=/ =update [%notification `notification`(snag 0 (flop notes))]
=/ card (fact-all:io %notify-update !>(update))
=/ =update [%notification u.not]
=/ card=(unit card) ~ ::(fact-all:io %notify-update !>(update))
(drop card)
::
%kick
@ -281,39 +293,23 @@
++ on-fail on-fail:def
--
|_ bowl=bowl:gall
+* gra ~(. graphlib bowl)
::
++ filter-notifications
|= =update:hark-store
^- (list notification)
?+ -.update ~
%more
(zing (turn more.update filter-notifications))
::
%added
?- -.index.update
%graph
?: =(`%graph-validator-dm mark.index.update)
?. ?=(%graph -.contents.notification.update)
~
%+ turn list.contents.notification.update
|= =post:post
^- notification
[graph.index.update index.post]
?: =(`%graph-validator-chat mark.index.update)
=/ hid (group-is-hidden graph.index.update)
?~ hid ~
?. u.hid ~
?. ?=(%graph -.contents.notification.update)
~
%+ turn list.contents.notification.update
|= =post:post
^- notification
[graph.index.update index.post]
~
::
%group ~
==
==
^- (unit notification)
?. ?=(%add-note -.update) ~
=* place place.bin.update
?. ?=(%landscape desk.place) ~
?. ?=([%graph *] path.place) ~
=/ link=path link.body.update
?. ?=([@ @ @ *] link) ~
?~ ship=(slaw %p i.t.link) ~
=* name i.t.t.link
=/ =resource:resource [u.ship name]
=/ =index:graph-store
(turn t.t.t.link (curr rash dim:ag))
`[resource index]
::
++ group-is-hidden
|= =resource:resource

View File

@ -1,39 +1,38 @@
:~ :- %apes
:~ %chat-cli
%chat-hook
:~ %metadata-store :: start stores first
%contact-store
%chat-store
%graph-store
%group-store
%invite-store
%s3-store
%settings-store
%chat-cli
%chat-hook
%chat-view
%clock
%contact-hook
%contact-pull-hook
%contact-push-hook
%contact-store
%contact-view
%demo-pull-hook
%demo-push-hook
%demo-store
%dm-hook
%graph-pull-hook
%graph-push-hook
%graph-store
%group-pull-hook
%group-push-hook
%group-store
%group-view
%hark-chat-hook
%hark-graph-hook
%hark-group-hook
%hark-store
%hark-invite-hook
%invite-hook
%invite-store
%invite-view
%launch
%metadata-hook
%metadata-pull-hook
%metadata-push-hook
%metadata-store
%notify
%observe-hook
%s3-store
%sane
%weather
==

View File

@ -1,9 +1,9 @@
:~ title+'Landscape'
info+'A suite of applications to communicate on Urbit'
color+0xee.5432
glob-http+'https://bootstrap.urbit.org/glob-0v2dl97.2n2r9.4glcs.e533a.uakd1.glob'
glob-http+'https://bootstrap.urbit.org/glob-0v7.bcnlj.eocmq.cmbeu.b8s54.8se40.glob'
base+'landscape'
version+[0 0 1]
version+[1 3 5]
website+'https://tlon.io'
license+'MIT'
==

View File

@ -1,10 +1,24 @@
/- sur=graph-store, pos=post, pull-hook
/- sur=graph-store, pos=post, pull-hook, hark=hark-store
/+ res=resource, migrate
=< [sur .]
=< [pos .]
=, sur
=, pos
|%
++ hark-content
|= =content
^- content:hark
?- -.content
%text content
%mention ship+ship.content
%url text+url.content
%code text+'A code excerpt'
%reference text+'A reference'
==
::
++ hark-contents
|= cs=(list content)
(turn cs hark-content)
:: NOTE: move these functions to zuse
++ nu :: parse number as hex
|= jon=json

View File

@ -1,4 +1,5 @@
/- *post, met=metadata-store, graph=graph-store, hark=hark-graph-hook
/- *post, met=metadata-store, hark=hark-graph-hook
/+ graph=graph-store
|_ i=indexed-post
++ grow
|%
@ -24,9 +25,15 @@
==
::
++ notification-kind
|= title=cord
^- (unit notif-kind:hark)
?+ index.p.i ~
[@ ~] `[%message [0 1] %count %none]
[@ ~]
:- ~
:* ~[text+(rap 3 'New messages in ' title ~)]
[ship+author.p.i text+': ' (hark-contents:graph contents.p.i)]
[0 1] %count %none
==
==
::
++ transform-add-nodes

View File

@ -1,6 +1,8 @@
/- *post, met=metadata-store, graph=graph-store, hark=hark-graph-hook
/- *post, met=metadata-store, hark=hark-graph-hook
/+ graph=graph-store
|_ i=indexed-post
++ grow
::
++ grow
|%
++ noun i
::
@ -11,9 +13,15 @@
i
::
++ notification-kind
|= title=cord
^- (unit notif-kind:hark)
?+ index.p.i ~
[@ @ ~] `[%message [1 2] %count %none]
[@ @ ~]
:- ~
:* ~[text+'New messages from ' ship+author.p.i]
(hark-contents:graph contents.p.i)
[1 2] %count %none
==
==
::
--

View File

@ -1,4 +1,5 @@
/- *post, met=metadata-store, graph=graph-store, hark=hark-graph-hook
/- *post, met=metadata-store, hark=hark-graph-hook
/+ graph=graph-store
|_ i=indexed-post
++ grow
|%
@ -49,10 +50,24 @@
==
::
++ notification-kind
|= title=cord
^- (unit notif-kind:hark)
?+ index.p.i ~
[@ ~] `[%link [0 1] %each %children]
[@ @ %1 ~] `[%comment [1 2] %count %siblings]
[@ ~]
:- ~
:* [text+(rap 3 'New links in ' title ~)]~
[ship+author.p.i text+': ' (hark-contents:graph contents.p.i)]
[0 1] %each %children
==
[@ @ %1 ~]
:- ~
:* [text+(rap 3 'New comments on a post in ' title ~)]~
[ship+author.p.i text+': ' (hark-contents:graph contents.p.i)]
[1 2] %count %siblings
==
==
::
++ transform-add-nodes

View File

@ -1,4 +1,5 @@
/- *post, met=metadata-store, graph=graph-store, hark=hark-graph-hook
/- *post, met=metadata-store, hark=hark-graph-hook
/+ graph=graph-store
|_ i=indexed-post
++ grow
|%
@ -26,11 +27,16 @@
:: +notification-kind: don't track unreads, notify on replies
::
++ notification-kind
|= title=cord
^- (unit notif-kind:hark)
=/ len (lent index.p.i)
=/ =mode:hark
?:(=(1 len) %count %none)
`[%post [(dec len) len] mode %children]
:- ~
:* ~[text+(rap 3 'Your post in ' title ' received replies ' ~)]
[ship+author.p.i text+': ' (hark-contents:graph contents.p.i)]
[(dec len) len] mode %children
==
::
++ transform-add-nodes
|= [=index =post =atom was-parent-modified=?]

View File

@ -1,4 +1,5 @@
/- *post, met=metadata-store, graph=graph-store, hark=hark-graph-hook
/+ graph=graph-store
|_ i=indexed-post
++ grow
|%
@ -64,10 +65,22 @@
:: ignore all containers, only notify on content
::
++ notification-kind
|= title=cord
^- (unit notif-kind:hark)
?+ index.p.i ~
[@ %1 %1 ~] `[%note [0 1] %each %children]
[@ %2 @ %1 ~] `[%comment [1 3] %count %siblings]
[@ %1 %1 ~]
:- ~
:* [%text (rap 3 'New notes in ' title ~)]~
~[(hark-content:graph (snag 0 contents.p.i)) text+' by ' ship+author.p.i]
[0 1] %each %children
==
::
[@ %2 @ %1 ~]
:- ~
:* [%text (rap 3 'New comments in ' title ~)]~
[ship+author.p.i text+': ' (hark-contents:graph contents.p.i)]
[1 3] %count %siblings
==
==
::
++ transform-add-nodes

View File

@ -1,4 +1,4 @@
/- *resource, graph-store, post
/- *resource, graph-store, post, store=hark-store
^?
|%
::
@ -10,7 +10,12 @@
[parent=@ud self=@ud]
::
+$ notif-kind
[name=@t =index-len =mode =watch-for]
$: title=(list content:store)
body=(list content:store)
=index-len
=mode
=watch-for
==
::
+$ action
$%

View File

@ -0,0 +1,347 @@
/- chat-store, graph-store, post, *resource, group-store, metadata-store
^?
|%
::
+$ old-state
[?(%0 %1 %2 %3 %4 %5 %6) *]
::
+$ state-7
[%7 base-state]
::
+$ versioned-state
$% old-state
state-7
==
+$ unread-stats
[indices=(set index:graph-store) last=@da]
::
+$ base-state
$: unreads-each=(jug stats-index index:graph-store)
unreads-count=(map stats-index @ud)
timeboxes=(map stats-index @da)
unread-notes=timebox
last-seen=(map stats-index @da)
=notifications
archive=notifications
current-timebox=@da
dnd=_|
==
::
+$ index
$% $: %graph
graph=resource
mark=(unit mark)
description=@t
=index:graph-store
==
[%group group=resource description=@t]
==
::
+$ group-contents
$~ [%add-members *resource ~]
$>(?(%add-members %remove-members) update:group-store)
::
+$ notification
[date=@da read=? =contents]
::
+$ contents
$% [%graph =(list post:post)]
[%group =(list group-contents)]
==
::
+$ timebox
(map index notification)
::
+$ notifications
((mop @da timebox) gth)
::
+$ action
$% [%add-note =index =notification]
:: if .time is ~, then archiving unread notification
:: else, archiving read notification
[%archive time=(unit @da) =index]
::
[%unread-count =stats-index =time]
[%read-count =stats-index]
::
[%unread-each =stats-index ref=index:graph-store time=@da]
[%read-each =stats-index ref=index:graph-store]
::
[%read-note =index]
::
[%seen-index time=@da =stats-index]
::
[%read-graph =resource]
[%read-group =resource]
[%remove-graph =resource]
::
[%read-all ~]
[%set-dnd dnd=?]
[%seen ~]
==
::
++ stats-index
$% [%graph graph=resource =index:graph-store]
[%group group=resource]
==
::
+$ indexed-notification
[index notification]
::
+$ stats
[=unreads last-seen=@da]
::
+$ unreads
$% [%count num=@ud]
[%each indices=(set index:graph-store)]
==
::
+$ update
$% action
[%more more=(list update)]
[%added =index =notification]
[%note-read =time =index]
[%timebox time=(unit @da) =(list [index notification])]
[%count count=@ud]
[%clear =stats-index]
[%unreads unreads=(list [stats-index stats])]
==
:: historical
++ state-zero
|%
+$ state
$: %0
notifications=notifications
archive=notifications
current-timebox=@da
dnd=_|
==
++ orm
((ordered-map @da timebox) gth)
::
+$ notifications
((mop @da timebox) gth)
::
+$ timebox
(map index notification)
::
+$ index
$% [%graph graph=resource module=@t description=@t]
[%group group=resource description=@t]
[%chat chat=path mention=?]
==
::
+$ group-contents
$~ [%add-members *resource ~]
$% [%add *]
[%remove *] :: old metadata actions
$>(?(%add-members %remove-members) update:group-store)
==
::
+$ contents
$% [%graph =(list post:post-zero:post)]
[%group =(list group-contents)]
[%chat =(list envelope:chat-store)]
==
::
+$ notification
[date=@da read=? =contents]
--
::
++ state-one
|%
+$ state
$: %1
unreads-each=(jug index index:graph-store)
unreads-count=(map index @ud)
last-seen=(map index @da)
=notifications:state-two
archive=notifications:state-two
current-timebox=@da
dnd=_|
==
--
++ state-two
=< state
|%
+$ state
$: unreads-each=(jug stats-index index:graph-store)
unreads-count=(map stats-index @ud)
last-seen=(map stats-index @da)
=notifications
archive=notifications
current-timebox=@da
dnd=_|
==
::
+$ index
$% $: %graph
group=resource
graph=resource
module=@t
description=@t
=index:graph-store
==
[%group group=resource description=@t]
==
::
++ orm
((ordered-map @da timebox) gth)
::
+$ notification
[date=@da read=? =contents]
::
+$ contents
$% [%graph =(list post:post-zero:post)]
[%group =(list group-contents)]
==
::
+$ group-contents
group-contents:state-zero
::
+$ timebox
(map index notification)
::
+$ notifications
((mop @da timebox) gth)
::
--
::
++ state-three
=< state
|%
+$ state
$: unreads-each=(jug stats-index index:graph-store)
unreads-count=(map stats-index @ud)
last-seen=(map stats-index @da)
=notifications
archive=notifications
current-timebox=@da
dnd=_|
==
::
++ orm
((ordered-map @da timebox) gth)
::
+$ index
$% $: %graph
group=resource
graph=resource
module=@t
description=@t
=index:graph-store
==
[%group group=resource description=@t]
==
::
+$ notification
[date=@da read=? =contents]
::
+$ contents
$% [%graph =(list post:post-zero:post)]
[%group =(list group-contents)]
==
::
+$ timebox
(map index notification)
::
+$ notifications
((mop @da timebox) gth)
::
--
::
++ state-four
=< base-state
|%
++ orm
((ordered-map @da timebox) gth)
::
+$ base-state
$: unreads-each=(jug stats-index index:graph-store)
unreads-count=(map stats-index @ud)
last-seen=(map stats-index @da)
=notifications
archive=notifications
current-timebox=@da
dnd=_|
==
::
+$ index
$% $: %graph
group=resource
graph=resource
module=@t
description=@t
=index:graph-store
==
[%group group=resource description=@t]
==
::
+$ group-contents
$~ [%add-members *resource ~]
$>(?(%add-members %remove-members) update:group-store)
::
+$ notification
[date=@da read=? =contents]
::
+$ contents
$% [%graph =(list post:post)]
[%group =(list group-contents)]
==
::
+$ timebox
(map index notification)
::
+$ notifications
((mop @da timebox) gth)
::
+$ action
$% [%add-note =index =notification]
[%archive time=@da index]
::
[%unread-count =stats-index =time]
[%read-count =stats-index]
::
::
[%unread-each =stats-index ref=index:graph-store time=@da]
[%read-each =stats-index ref=index:graph-store]
::
[%read-note time=@da index]
[%unread-note time=@da index]
::
[%seen-index time=@da =stats-index]
::
[%remove-graph =resource]
::
[%read-all ~]
[%set-dnd dnd=?]
[%seen ~]
==
::
++ stats-index
$% [%graph graph=resource =index:graph-store]
[%group group=resource]
==
::
+$ indexed-notification
[index notification]
::
+$ stats
[notifications=(set [time index]) =unreads last-seen=@da]
::
+$ unreads
$% [%count num=@ud]
[%each indices=(set index:graph-store)]
==
::
+$ update
$% action
[%more more=(list update)]
[%added time=@da =index =notification]
[%timebox time=@da archived=? =(list [index notification])]
[%count count=@ud]
[%clear =stats-index]
[%unreads unreads=(list [stats-index stats])]
==
--
--

View File

@ -1,321 +0,0 @@
/- chat-store, graph-store, post, *resource, group-store, metadata-store
^?
|%
+$ index
$% $: %graph
graph=resource
mark=(unit mark)
description=@t
=index:graph-store
==
[%group group=resource description=@t]
==
::
+$ group-contents
$~ [%add-members *resource ~]
$>(?(%add-members %remove-members) update:group-store)
::
+$ notification
[date=@da read=? =contents]
::
+$ contents
$% [%graph =(list post:post)]
[%group =(list group-contents)]
==
::
+$ timebox
(map index notification)
::
+$ notifications
((mop @da timebox) gth)
::
+$ action
$% [%add-note =index =notification]
:: if .time is ~, then archiving unread notification
:: else, archiving read notification
[%archive time=(unit @da) =index]
::
[%unread-count =stats-index =time]
[%read-count =stats-index]
::
[%unread-each =stats-index ref=index:graph-store time=@da]
[%read-each =stats-index ref=index:graph-store]
::
[%read-note =index]
::
[%seen-index time=@da =stats-index]
::
[%read-graph =resource]
[%read-group =resource]
[%remove-graph =resource]
::
[%read-all ~]
[%set-dnd dnd=?]
[%seen ~]
==
::
++ stats-index
$% [%graph graph=resource =index:graph-store]
[%group group=resource]
==
::
+$ indexed-notification
[index notification]
::
+$ stats
[=unreads last-seen=@da]
::
+$ unreads
$% [%count num=@ud]
[%each indices=(set index:graph-store)]
==
::
+$ update
$% action
[%more more=(list update)]
[%added =index =notification]
[%note-read =time =index]
[%timebox time=(unit @da) =(list [index notification])]
[%count count=@ud]
[%clear =stats-index]
[%unreads unreads=(list [stats-index stats])]
==
:: historical
++ state-zero
|%
+$ state
$: %0
notifications=notifications
archive=notifications
current-timebox=@da
dnd=_|
==
++ orm
((ordered-map @da timebox) gth)
::
+$ notifications
((mop @da timebox) gth)
::
+$ timebox
(map index notification)
::
+$ index
$% [%graph graph=resource module=@t description=@t]
[%group group=resource description=@t]
[%chat chat=path mention=?]
==
::
+$ group-contents
$~ [%add-members *resource ~]
$% [%add *]
[%remove *] :: old metadata actions
$>(?(%add-members %remove-members) update:group-store)
==
::
+$ contents
$% [%graph =(list post:post-zero:post)]
[%group =(list group-contents)]
[%chat =(list envelope:chat-store)]
==
::
+$ notification
[date=@da read=? =contents]
--
::
++ state-one
|%
+$ state
$: %1
unreads-each=(jug index index:graph-store)
unreads-count=(map index @ud)
last-seen=(map index @da)
=notifications:state-two
archive=notifications:state-two
current-timebox=@da
dnd=_|
==
--
++ state-two
=< state
|%
+$ state
$: unreads-each=(jug stats-index index:graph-store)
unreads-count=(map stats-index @ud)
last-seen=(map stats-index @da)
=notifications
archive=notifications
current-timebox=@da
dnd=_|
==
::
+$ index
$% $: %graph
group=resource
graph=resource
module=@t
description=@t
=index:graph-store
==
[%group group=resource description=@t]
==
::
++ orm
((ordered-map @da timebox) gth)
::
+$ notification
[date=@da read=? =contents]
::
+$ contents
$% [%graph =(list post:post-zero:post)]
[%group =(list group-contents)]
==
::
+$ group-contents
group-contents:state-zero
::
+$ timebox
(map index notification)
::
+$ notifications
((mop @da timebox) gth)
::
--
::
++ state-three
=< state
|%
+$ state
$: unreads-each=(jug stats-index index:graph-store)
unreads-count=(map stats-index @ud)
last-seen=(map stats-index @da)
=notifications
archive=notifications
current-timebox=@da
dnd=_|
==
::
++ orm
((ordered-map @da timebox) gth)
::
+$ index
$% $: %graph
group=resource
graph=resource
module=@t
description=@t
=index:graph-store
==
[%group group=resource description=@t]
==
::
+$ notification
[date=@da read=? =contents]
::
+$ contents
$% [%graph =(list post:post-zero:post)]
[%group =(list group-contents)]
==
::
+$ timebox
(map index notification)
::
+$ notifications
((mop @da timebox) gth)
::
--
::
++ state-four
=< base-state
|%
++ orm
((ordered-map @da timebox) gth)
::
+$ base-state
$: unreads-each=(jug stats-index index:graph-store)
unreads-count=(map stats-index @ud)
last-seen=(map stats-index @da)
=notifications
archive=notifications
current-timebox=@da
dnd=_|
==
::
+$ index
$% $: %graph
group=resource
graph=resource
module=@t
description=@t
=index:graph-store
==
[%group group=resource description=@t]
==
::
+$ group-contents
$~ [%add-members *resource ~]
$>(?(%add-members %remove-members) update:group-store)
::
+$ notification
[date=@da read=? =contents]
::
+$ contents
$% [%graph =(list post:post)]
[%group =(list group-contents)]
==
::
+$ timebox
(map index notification)
::
+$ notifications
((mop @da timebox) gth)
::
+$ action
$% [%add-note =index =notification]
[%archive time=@da index]
::
[%unread-count =stats-index =time]
[%read-count =stats-index]
::
::
[%unread-each =stats-index ref=index:graph-store time=@da]
[%read-each =stats-index ref=index:graph-store]
::
[%read-note time=@da index]
[%unread-note time=@da index]
::
[%seen-index time=@da =stats-index]
::
[%remove-graph =resource]
::
[%read-all ~]
[%set-dnd dnd=?]
[%seen ~]
==
::
++ stats-index
$% [%graph graph=resource =index:graph-store]
[%group group=resource]
==
::
+$ indexed-notification
[index notification]
::
+$ stats
[notifications=(set [time index]) =unreads last-seen=@da]
::
+$ unreads
$% [%count num=@ud]
[%each indices=(set index:graph-store)]
==
::
+$ update
$% action
[%more more=(list update)]
[%added time=@da =index =notification]
[%timebox time=@da archived=? =(list [index notification])]
[%count count=@ud]
[%clear =stats-index]
[%unreads unreads=(list [stats-index stats])]
==
--
--

View File

@ -0,0 +1 @@
../../garden-dev/sur/hark-store.hoon

View File

@ -1,10 +1,13 @@
import f from 'lodash/fp';
import bigInt, { BigInteger } from 'big-integer';
import { BigInteger } from 'big-integer';
import { Poke } from '../lib/types';
import { GraphNotifDescription, GraphNotificationContents, GraphNotifIndex, IndexedNotification, NotifIndex, Unreads } from './types';
import {
HarkBin,
HarkBinId,
HarkLid,
HarkPlace
} from './types';
import { decToUd } from '../lib';
import { Association } from '../metadata/types';
export const harkAction = <T>(data: T): Poke<T> => ({
app: 'hark-store',
@ -31,232 +34,116 @@ export { groupHookAction as harkGroupHookAction };
export const actOnNotification = (
frond: string,
intTime: BigInteger,
index: NotifIndex
): Poke<unknown> => harkAction({
[frond]: {
time: decToUd(intTime.toString()),
index
}
});
export const getParentIndex = (
idx: GraphNotifIndex,
contents: GraphNotificationContents
): string | undefined => {
const origIndex = contents[0].index.slice(1).split('/');
const ret = (i: string[]) => `/${i.join('/')}`;
switch (idx.description) {
case 'link':
return '/';
case 'comment':
return ret(origIndex.slice(0, 1));
case 'note':
return '/';
case 'mention':
return undefined;
default:
return undefined;
}
}
export const setMentions = (
mentions: boolean
): Poke<unknown> => graphHookAction({
'set-mentions': mentions
});
export const setWatchOnSelf = (
watchSelf: boolean
): Poke<unknown> => graphHookAction({
'set-watch-on-self': watchSelf
});
export const setDoNotDisturb = (
dnd: boolean
): Poke<unknown> => harkAction({
'set-dnd': dnd
});
export const archive = (
index: NotifIndex,
time?: BigInteger,
): Poke<unknown> => harkAction({
'archive': {
time: time ? decToUd(time.toString()) : null,
index
}
});
export const readNote = (
index: NotifIndex
): Poke<unknown> => harkAction({ 'read-note': index });
export const readIndex = (
index: NotifIndex
): Poke<unknown> => harkAction({
'read-index': index
});
export const unread = (
time: BigInteger,
index: NotifIndex
): Poke<unknown> => actOnNotification('unread-note', time, index);
export const markCountAsRead = (
graph: string,
index = '/'
): Poke<unknown> => harkAction({
'read-count': {
graph: {
graph,
index
bin: HarkBin
): Poke<unknown> =>
harkAction({
[frond]: {
time: decToUd(intTime.toString()),
bin
}
}
});
export const setMentions = (mentions: boolean): Poke<unknown> =>
graphHookAction({
'set-mentions': mentions
});
export const setWatchOnSelf = (watchSelf: boolean): Poke<unknown> =>
graphHookAction({
'set-watch-on-self': watchSelf
});
export const setDoNotDisturb = (dnd: boolean): Poke<unknown> =>
harkAction({
'set-dnd': dnd
});
export const archive = (bin: HarkBin, lid: HarkLid): Poke<unknown> =>
harkAction({
archive: {
lid,
bin
}
});
export const opened = harkAction({
opened: null
});
export const markCountAsRead = (place: HarkPlace): Poke<unknown> =>
harkAction({
'read-count': place
});
export const markEachAsRead = (
graph: string,
index: string,
target: string
): Poke<unknown> => harkAction({
'read-each': {
index: {
graph: {
graph,
index
}
},
target
}
});
export const dec = (
index: NotifIndex,
ref: string
): Poke<unknown> => harkAction({
dec: {
index,
ref
}
});
place: HarkPlace,
path: string
): Poke<unknown> =>
harkAction({
'read-each': {
place,
path
}
});
export const seen = () => harkAction({ seen: null });
export const readAll = () => harkAction({ 'read-all': null });
export const readAll = harkAction({ 'read-all': null });
export const archiveAll = harkAction({ 'archive-all': null });
export const ignoreGroup = (
group: string
): Poke<unknown> => groupHookAction({
ignore: group
});
export const ignoreGroup = (group: string): Poke<unknown> =>
groupHookAction({
ignore: group
});
export const ignoreGraph = (
graph: string,
index: string
): Poke<unknown> => graphHookAction({
ignore: {
graph,
index
}
});
export const listenGroup = (
group: string
): Poke<unknown> => groupHookAction({
listen: group
});
export const listenGraph = (
graph: string,
index: string
): Poke<unknown> => graphHookAction({
listen: {
graph,
index
}
});
export const mute = (
notif: IndexedNotification
): Poke<any> | {} => {
if('graph' in notif.index && 'graph' in notif.notification.contents) {
const { index } = notif;
const parentIndex = getParentIndex(index.graph, notif.notification.contents.graph);
if(!parentIndex) {
return {};
export const ignoreGraph = (graph: string, index: string): Poke<unknown> =>
graphHookAction({
ignore: {
graph,
index
}
return ignoreGraph(index.graph.graph, parentIndex);
}
if('group' in notif.index) {
const { group } = notif.index.group;
return ignoreGroup(group);
}
return {};
}
});
export const unmute = (
notif: IndexedNotification
): Poke<any> | {} => {
if('graph' in notif.index && 'graph' in notif.notification.contents) {
const { index } = notif;
const parentIndex = getParentIndex(index.graph, notif.notification.contents.graph);
if(!parentIndex) {
return {};
export const listenGroup = (group: string): Poke<unknown> =>
groupHookAction({
listen: group
});
export const listenGraph = (graph: string, index: string): Poke<unknown> =>
graphHookAction({
listen: {
graph,
index
}
return listenGraph(index.graph.graph, parentIndex);
}
if('group' in notif.index) {
return listenGroup(notif.index.group.group);
}
return {};
}
});
export const getLastSeen = (
unreads: Unreads,
path: string,
index: string
): BigInteger | undefined => {
const lastSeenIdx = unreads.graph?.[path]?.[index]?.unreads;
if (!(typeof lastSeenIdx === 'string')) {
return bigInt.zero;
}
return f.flow(f.split('/'), f.last, x => (x ? bigInt(x) : undefined))(
lastSeenIdx
);
}
export const getUnreadCount = (
unreads: Unreads,
path: string,
index: string
): number => {
const graphUnreads = unreads.graph?.[path]?.[index]?.unreads ?? 0;
return typeof graphUnreads === 'number' ? graphUnreads : graphUnreads.size;
}
export const getNotificationCount = (
unreads: Unreads,
path: string
): number => {
const unread = unreads.graph?.[path] || {};
return Object.keys(unread)
.map(index => unread[index]?.notifications as number || 0)
.reduce(f.add, 0);
}
/**
/**
* Read all graphs belonging to a particular group
*/
export const readGroup = (group: string) =>
export const readGroup = (group: string) =>
harkAction({
'read-group': group
});
/**
/**
* Read all unreads in a graph
*/
export const readGraph = (graph: string) =>
export const readGraph = (graph: string) =>
harkAction({
'read-graph': graph
});
export function harkBinToId(bin: HarkBin): HarkBinId {
const { place, path } = bin;
return `${place.desk}${place.path}${path}`;
}
export function harkBinEq(a: HarkBin, b: HarkBin): boolean {
return (
a.place.path === b.place.path &&
a.place.desk === b.place.desk &&
a.path === b.path
);
}

View File

@ -1,60 +1,46 @@
import { BigInteger } from "big-integer";
import { Post } from "../graph/types";
import { GroupUpdate } from "../groups/types";
import BigIntOrderedMap from "../lib/BigIntOrderedMap";
export type GraphNotifDescription = "link" | "comment" | "note" | "mention" | "message" | "post";
export interface UnreadStats {
unreads: Set<string> | number;
notifications: NotifRef[] | number;
export interface HarkStats {
count: number;
each: string[];
last: number;
}
interface NotifRef {
time: BigInteger | undefined;
index: NotifIndex;
export interface Timebox {
[binId: string]: Notification;
}
export interface GraphNotifIndex {
graph: string;
group: string;
description: GraphNotifDescription;
mark: string;
index: string;
export type HarkContent = { ship: string; } | { text: string; };
export interface HarkBody {
title: HarkContent[];
time: string;
content: HarkContent[];
link: string;
binned: string;
}
export interface GroupNotifIndex {
group: string;
description: string;
export interface HarkPlace {
desk: string;
path: string;
}
export type NotifIndex =
| { graph: GraphNotifIndex }
| { group: GroupNotifIndex };
export interface HarkBin {
path: string;
place: HarkPlace;
}
export type GraphNotificationContents = Post[];
export type HarkLid =
{ unseen: null; }
| { seen: null; }
| { time: string; };
export type GroupNotificationContents = GroupUpdate[];
export type NotificationContents =
| { graph: GraphNotificationContents }
| { group: GroupNotificationContents };
export type HarkBinId = string;
export interface Notification {
read: boolean;
bin: HarkBin;
time: number;
contents: NotificationContents;
body: HarkBody[];
}
export interface IndexedNotification {
index: NotifIndex;
notification: Notification;
}
export type Timebox = IndexedNotification[];
export type Notifications = BigIntOrderedMap<Timebox>;
export interface NotificationGraphConfig {
watchOnSelf: boolean;
mentions: boolean;
@ -62,8 +48,7 @@ export interface NotificationGraphConfig {
}
export interface Unreads {
graph: Record<string, Record<string, UnreadStats>>;
group: Record<string, UnreadStats>;
[path: string]: HarkStats;
}
interface WatchedIndex {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff