diff --git a/pkg/arvo/app/glob.hoon b/pkg/arvo/app/glob.hoon index f8685a267..90b0ab225 100644 --- a/pkg/arvo/app/glob.hoon +++ b/pkg/arvo/app/glob.hoon @@ -5,7 +5,7 @@ /- glob /+ default-agent, verb, dbug |% -++ hash 0v3.29n7b.04srk.3pcv0.1ld5v.vl1io +++ hash 0v6.9vk2h.hr87m.nn63p.8kmo5.k4ljt +$ state-0 [%0 hash=@uv glob=(unit (each glob:glob tid=@ta))] +$ all-states $% state-0 @@ -89,7 +89,7 @@ =+ .^(=map=tube:clay %cc (weld home /map/mime)) =+ .^(arch %cy (weld home /app/landscape/js/bundle)) =/ bundle-hash=@t - %- need + %- need ^- (unit @t) %- ~(rep by dir) |= [[file=@t ~] out=(unit @t)] diff --git a/pkg/arvo/app/graph-store.hoon b/pkg/arvo/app/graph-store.hoon index 6fce9e517..1c6abbefc 100644 --- a/pkg/arvo/app/graph-store.hoon +++ b/pkg/arvo/app/graph-store.hoon @@ -560,6 +560,14 @@ |^ ?> (team:title our.bowl src.bowl) ?+ path (on-peek:def path) + [%x %graph-mark @ @ ~] + =/ =ship (slav %p i.t.t.path) + =/ =term i.t.t.t.path + =/ result=(unit marked-graph:store) + (~(get by graphs) [ship term]) + ?~ result [~ ~] + ``noun+!>(q.u.result) + :: [%x %keys ~] :- ~ :- ~ :- %graph-update !>(`update:store`[%0 now.bowl [%keys ~(key by graphs)]]) diff --git a/pkg/arvo/app/hark-chat-hook.hoon b/pkg/arvo/app/hark-chat-hook.hoon new file mode 100644 index 000000000..472bcb7dc --- /dev/null +++ b/pkg/arvo/app/hark-chat-hook.hoon @@ -0,0 +1,180 @@ +:: hark-chat-hook: notifications for chat-store [landscape] +:: +/- store=hark-store, post, group-store, metadata-store, hook=hark-chat-hook +/+ resource, metadata, default-agent, dbug, chat-store +:: +~% %hark-chat-hook-top ..is ~ +|% ++$ card card:agent:gall ++$ versioned-state + $% state-0 + == +:: ++$ state-0 + $: %0 + watching=(set path) + mentions=_& + == +:: +-- +:: +=| state-0 +=* state - +:: +=> + |_ =bowl:gall + :: + ++ give + |= [paths=(list path) =update:hook] + ^- (list card) + [%give %fact paths hark-chat-hook-update+!>(update)]~ + :: + ++ watch-chat + ^- card + [%pass /chat %agent [our.bowl %chat-store] %watch /updates] + -- +%- agent:dbug +^- agent:gall +~% %hark-chat-hook-agent ..card ~ +|_ =bowl:gall ++* this . + ha ~(. +> bowl) + def ~(. (default-agent this %|) bowl) + met ~(. metadata bowl) +:: +++ on-init + :_ this + ~[watch-chat:ha] +:: +++ on-save !>(state) +++ on-load + |= old=vase + ^- (quip card _this) + `this(state !<(state-0 old)) +:: +++ on-watch + |= =path + ^- (quip card _this) + =^ cards state + ?+ path (on-watch:def path) + :: + [%updates ~] + :_ state + %+ give:ha ~ + :* %initial + watching + == + == + [cards this] +:: +++ on-poke + ~/ %hark-chat-hook-poke + |= [=mark =vase] + ^- (quip card _this) + |^ + ?> (team:title our.bowl src.bowl) + =^ cards state + ?+ mark (on-poke:def mark vase) + %hark-chat-hook-action + (hark-chat-hook-action !<(action:hook vase)) + == + [cards this] + :: + ++ hark-chat-hook-action + |= =action:hook + ^- (quip card _state) + |^ + :- (give:ha ~[/updates] action) + ?- -.action + %listen (listen +.action) + %ignore (ignore +.action) + %set-mentions (set-mentions +.action) + == + ++ listen + |= chat=path + ^+ state + state(watching (~(put in watching) chat)) + :: + ++ ignore + |= chat=path + ^+ state + state(watching (~(del in watching) chat)) + :: + ++ set-mentions + |= ment=? + ^+ state + state(mentions ment) + -- + -- +:: +++ on-agent + ~/ %hark-chat-hook-agent + |= [=wire =sign:agent:gall] + ^- (quip card _this) + |^ + ?+ -.sign (on-agent:def wire sign) + %kick + :_ this + ?. ?=([%chat ~] wire) + ~ + ~[watch-chat:ha] + :: + %fact + ?. ?=(%chat-update p.cage.sign) + (on-agent:def wire sign) + =^ cards state + (chat-update !<(update:chat-store q.cage.sign)) + [cards this] + == + :: + ++ chat-update + |= =update:chat-store + ^- (quip card _state) + :_ state + ?+ -.update ~ + %message (process-envelope path.update envelope.update) + :: + %messages + %- zing + (turn envelopes.update (cury process-envelope path.update)) + == + ++ is-mention + |= =envelope:chat-store + ?. ?=(%text -.letter.envelope) %.n + ?& mentions + ?= ^ + (find (scow %p our.bowl) (trip text.letter.envelope)) + == + :: + ++ is-notification + |= [=path =envelope:chat-store] + ?& (~(has in watching) path) + !=(author.envelope our.bowl) + == + :: + ++ process-envelope + |= [=path =envelope:chat-store] + ^- (list card) + =/ mention=? + (is-mention envelope) + ?. ?|(mention (is-notification path envelope)) + ~ + =/ =index:store + [%chat path mention] + =/ =contents:store + [%chat ~[envelope]] + ~[(poke-store %add index when.envelope %.n contents)] + :: + ++ poke-store + |= =action:store + ^- card + =- [%pass /store %agent [our.bowl %hark-store] %poke -] + hark-action+!>(action) + -- +:: +++ on-peek on-peek:def +:: +++ on-leave on-leave:def +++ on-arvo on-arvo:def +++ on-fail on-fail:def +-- diff --git a/pkg/arvo/app/hark-graph-hook.hoon b/pkg/arvo/app/hark-graph-hook.hoon new file mode 100644 index 000000000..d9d1b7f71 --- /dev/null +++ b/pkg/arvo/app/hark-graph-hook.hoon @@ -0,0 +1,256 @@ +:: hark-graph-hook: notifications for graph-store [landscape] +:: +/- store=hark-store, post, group-store, metadata-store, hook=hark-graph-hook +/+ resource, metadata, default-agent, dbug, graph-store +:: +~% %hark-graph-hook-top ..is ~ +|% ++$ card card:agent:gall ++$ versioned-state + $% state-0 + == +:: ++$ state-0 + $: %0 + watching=(set resource) + mentions=_& + watch-on-self=_& + == +:: +-- +:: +=| state-0 +=* state - +:: +=> + |_ =bowl:gall + :: + ++ 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:hook] + ^- (list card) + [%give %fact paths hark-graph-hook-update+!>(update)]~ + :: + ++ watch-graph + ^- card + [%pass /graph %agent [our.bowl %graph-store] %watch /updates] + -- +%- agent:dbug +^- agent:gall +~% %hark-graph-hook-agent ..card ~ +|_ =bowl:gall ++* this . + ha ~(. +> bowl) + def ~(. (default-agent this %|) bowl) + met ~(. metadata bowl) +:: +++ on-init + :_ this + ~[watch-graph:ha] +:: +++ on-save !>(state) +++ on-load + |= old=vase + ^- (quip card _this) + `this(state !<(state-0 old)) +:: +++ on-watch + |= =path + ^- (quip card _this) + =^ cards state + ?+ path (on-watch:def path) + :: + [%updates ~] + :_ state + %+ give:ha ~ + :* %initial + watching + mentions + watch-on-self + == + == + [cards this] +:: +++ on-poke + ~/ %hark-graph-hook-poke + |= [=mark =vase] + ^- (quip card _this) + |^ + ?> (team:title our.bowl src.bowl) + =^ cards state + ?+ mark (on-poke:def mark vase) + %hark-graph-hook-action + (hark-graph-hook-action !<(action:hook vase)) + == + [cards this] + :: + ++ hark-graph-hook-action + |= =action:hook + ^- (quip card _state) + |^ + :- (give:ha ~[/updates] action) + ?- -.action + %listen (listen +.action) + %ignore (ignore +.action) + %set-mentions (set-mentions +.action) + %set-watch-on-self (set-watch-on-self +.action) + == + ++ listen + |= graph=resource + ^+ state + state(watching (~(put in watching) graph)) + :: + ++ ignore + |= graph=resource + ^+ state + state(watching (~(del in watching) graph)) + :: + ++ set-mentions + |= ment=? + ^+ state + state(mentions ment) + :: + ++ set-watch-on-self + |= self=? + ^+ state + state(watch-on-self self) + -- + -- +:: +++ on-agent + ~/ %hark-graph-hook-agent + |= [=wire =sign:agent:gall] + ^- (quip card _this) + |^ + ?+ -.sign (on-agent:def wire sign) + %kick + :_ this + ?. ?=([%graph ~] wire) + ~ + ~[watch-graph:ha] + :: + %fact + ?. ?=(%graph-update p.cage.sign) + (on-agent:def wire sign) + =^ cards state + (graph-update !<(update:graph-store q.cage.sign)) + [cards this] + == + :: + ++ graph-update + |= =update:graph-store + ^- (quip card _state) + ?. ?=(%add-nodes -.q.update) + [~ state] + =/ group=resource + (need (group-from-app-resource:met %graph resource.q.update)) + =/ =metadata:metadata-store + (need (peek-metadata:met %graph group resource.q.update)) + =* rid resource.q.update + =+ %+ scry:ha + ,mark=(unit mark) + /gx/graph-store/graph-mark/(scot %p entity.rid)/[name.rid]/noun + =+ %+ scry:ha + ,=tube:clay + /cc/[q.byk.bowl]/[(fall mark %graph-validator-link)]/notification-kind + =/ nodes=(list [p=index:graph-store q=node:graph-store]) + ~(tap by nodes.q.update) + =| cards=(list card) + |^ + ?~ nodes + [cards state] + =* index p.i.nodes + =* node q.i.nodes + =^ node-cards state + (check-node node tube) + %_ $ + nodes t.nodes + cards (weld node-cards cards) + == + :: + ++ check-node-children + |= [=node:graph-store =tube:clay] + ^- (quip card _state) + ?: ?=(%empty -.children.node) + [~ state] + =/ children=(list [=atom =node:graph-store]) + (tap:orm:graph-store p.children.node) + =| cards=(list card) + |- ^- (quip card _state) + ?~ children + [cards state] + =^ new-cards state + (check-node node.i.children tube) + %_ $ + cards (weld cards new-cards) + children t.children + == + :: + ++ check-node + |= [=node:graph-store =tube:clay] + ^- (quip card _state) + =^ child-cards state + (check-node-children node tube) + ?: =(our.bowl author.post.node) + =^ self-cards state + (self-post node) + :_ state + (weld child-cards self-cards) + =+ !<(notif-kind=(unit @t) (tube !>([0 post.node]))) + ?~ notif-kind + [child-cards state] + =/ desc=@t + ?: (is-mention contents.post.node) + %mention + u.notif-kind + ?. ?| =(desc %mention) + (~(has in watching) rid) + == + [child-cards state] + =/ notif-index=index:store + [%graph group rid module.metadata desc] + =/ =contents:store + [%graph (limo post.node ~)] + :_ state + %+ snoc child-cards + (add-unread notif-index [time-sent.post.node %.n contents]) + :: + ++ is-mention + |= contents=(list content:post) + ^- ? + ?. mentions %.n + ?~ contents %.n + ?. ?=(%mention -.i.contents) + $(contents t.contents) + ?: =(our.bowl ship.i.contents) + %.y + $(contents t.contents) + :: + ++ self-post + |= =node:graph-store + ^- (quip card _state) + ?. ?=(%.y watch-on-self) + [~ state] + `state(watching (~(put in watching) rid)) + :: + ++ add-unread + |= [=index:store =notification:store] + ^- card + =- [%pass / %agent [our.bowl %hark-store] %poke -] + hark-action+!>([%add index notification]) + :: + -- + -- +:: +++ on-peek on-peek:def +:: +++ on-leave on-leave:def +++ on-arvo on-arvo:def +++ on-fail on-fail:def +-- + diff --git a/pkg/arvo/app/hark-group-hook.hoon b/pkg/arvo/app/hark-group-hook.hoon new file mode 100644 index 000000000..62a7fb5f3 --- /dev/null +++ b/pkg/arvo/app/hark-group-hook.hoon @@ -0,0 +1,169 @@ +:: hark-group-hook: notifications for groups [landscape] +:: +/- store=hark-store, post, group-store, metadata-store, hook=hark-group-hook +/+ resource, metadata, default-agent, dbug, graph-store +:: +~% %hark-group-hook-top ..is ~ +|% ++$ card card:agent:gall ++$ versioned-state + $% state-0 + == +:: ++$ state-0 + $: %0 + watching=(set resource) + == +:: +-- +:: +=| state-0 +=* state - +:: +=< +%- agent:dbug +^- agent:gall +~% %hark-group-hook-agent ..card ~ +|_ =bowl:gall ++* this . + ha ~(. +> bowl) + def ~(. (default-agent this %|) bowl) + met ~(. metadata bowl) +:: +++ on-init + :_ this + :~ watch-metadata:ha + watch-groups:ha + == +:: +++ on-save !>(state) +++ on-load + |= old=vase + ^- (quip card _this) + `this(state !<(state-0 old)) +:: +++ on-watch + |= =path + ?. ?=([%updates ~] path) + (on-watch:def path) + :_ this + =; =cage + [%give %fact ~[/updates] cage]~ + :- %hark-group-hook-update + !> ^- update:hook + [%initial watching] +:: +++ on-poke + ~/ %hark-group-hook-poke + |= [=mark =vase] + ^- (quip card _this) + |^ + ?> (team:title our.bowl src.bowl) + =^ cards state + ?+ mark (on-poke:def mark vase) + %hark-group-hook-action + (hark-group-hook-action !<(action:hook vase)) + == + [cards this] + :: + ++ hark-group-hook-action + |= =action:hook + ^- (quip card _state) + |^ + ?- -.action + %listen (listen +.action) + %ignore (ignore +.action) + == + ++ listen + |= group=resource + ^- (quip card _state) + :- (give %listen group) + state(watching (~(put in watching) group)) + :: + ++ ignore + |= group=resource + ^- (quip card _state) + :- (give %ignore group) + state(watching (~(del in watching) group)) + :: + ++ give + |= =update:hook + ^- (list card) + [%give %fact ~[/updates] %hark-group-hook-update !>(update)]~ + -- + -- +:: +++ on-agent + ~/ %hark-group-hook-agent + |= [=wire =sign:agent:gall] + ^- (quip card _this) + |^ + ?+ -.sign (on-agent:def wire sign) + %kick + :_ this + ?+ wire ~ + [%group ~] ~[watch-groups:ha] + [%metadata ~] ~[watch-metadata:ha] + == + :: + %fact + ?+ p.cage.sign (on-agent:def wire sign) + %group-update + =^ cards state + (group-update !<(update:group-store q.cage.sign)) + [cards this] + :: + %metadata-update + =^ cards state + (metadata-update !<(metadata-update:metadata-store q.cage.sign)) + [cards this] + == + == + :: + ++ group-update + |= =update:group-store + ^- (quip card _state) + ?. ?=(?(%add-members %remove-members) -.update) + [~ state] + ?. (~(has in watching) resource.update) + [~ state] + =/ =contents:store + [%group ~[update]] + =/ =notification:store [now.bowl %.n contents] + =/ =index:store + [%group resource.update -.update] + :_ state + ~[(add-unread index notification)] + :: +metadata-update is stubbed for now, for the following reasons + :: - There's no semantic difference in metadata-store between + :: adding and editing a channel + :: - We have no way of retrieving old metadata to e.g. get a + :: channel's old name when it is renamed + ++ metadata-update + |= update=metadata-update:metadata-store + ^- (quip card _state) + [~ state] + :: + ++ add-unread + |= [=index:store =notification:store] + ^- card + =- [%pass / %agent [our.bowl %hark-store] %poke -] + hark-action+!>([%add index notification]) + -- +:: +++ on-peek on-peek:def +++ on-leave on-leave:def +++ on-arvo on-arvo:def +++ on-fail on-fail:def +-- +|_ =bowl:gall ++* met ~(. metadata bowl) +:: +++ watch-groups + ^- card + [%pass /group %agent [our.bowl %group-store] %watch /groups] +:: +++ watch-metadata + ^- card + [%pass /metadata %agent [our.bowl %metadata-store] %watch /updates] +-- diff --git a/pkg/arvo/app/hark-store.hoon b/pkg/arvo/app/hark-store.hoon new file mode 100644 index 000000000..f1d3905ac --- /dev/null +++ b/pkg/arvo/app/hark-store.hoon @@ -0,0 +1,313 @@ +:: hark-store: notifications [landscape] +:: +/- store=hark-store, post, group-store, metadata-store +/+ resource, metadata, default-agent, dbug, graph-store +:: +~% %hark-store-top ..is ~ +|% ++$ card card:agent:gall ++$ versioned-state + $% state-0 + == +:: ++$ state-0 + $: %0 + =notifications:store + archive=notifications:store + last-seen=@da + dnd=? + == ++$ inflated-state + $: state-0 + cache + == +:: $cache: useful to have precalculated, but can be derived from state +:: albeit expensively ++$ cache + $: unread-count=@ud + ~ + == +:: +++ orm ((ordered-map @da timebox:store) lth) +-- +:: +=| inflated-state +=* state - +:: +=< +%- agent:dbug +^- agent:gall +~% %hark-store-agent ..card ~ +|_ =bowl:gall ++* this . + ha ~(. +> bowl) + def ~(. (default-agent this %|) bowl) + met ~(. metadata bowl) +:: +++ on-init + :_ this + ~[autoseen-timer] +:: +++ on-save !>(-.state) +++ on-load + |= =old=vase + ^- (quip card _this) + =/ old + !<(state-0 old-vase) + `this(-.state old, +.state (inflate-cache old)) +:: +++ on-watch + |= =path + ^- (quip card _this) + |^ + ?+ path (on-watch:def path) + :: + [%updates ~] + :_ this + [%give %fact ~ hark-update+!>(initial-updates)]~ + == + :: + ++ initial-updates + ^- update:store + :- %more + ^- (list update:store) + :+ [%set-dnd dnd] + [%count unread-count] + %+ weld + %+ turn + (tap-nonempty archive) + (timebox-update &) + %+ turn + (tap-nonempty notifications) + (timebox-update |) + :: + ++ timebox-update + |= archived=? + |= [time=@da =timebox:store] + ^- update:store + [%timebox time archived ~(tap by timebox)] + :: + ++ tap-nonempty + |= =notifications:store + ^- (list [@da timebox:store]) + %+ skip (tap:orm notifications) + |=([@da =timebox:store] =(0 ~(wyt by timebox))) + -- +:: +++ on-peek + |= =path + ^- (unit (unit cage)) + ?+ path (on-peek:def path) + :: + [%x %recent @ @ ~] + =/ offset=@ud + (slav %ud i.t.t.path) + =/ length=@ud + (slav %ud i.t.t.t.path) + :^ ~ ~ %noun + !> ^- update:store + :- %more + %+ turn + (scag length (slag offset (tap:orm notifications))) + |= [time=@da =timebox:store] + ^- update:store + :^ %timebox time %.n + ~(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)) + == + [cards this] + :: + ++ hark-action + |= =action:store + ^- (quip card _state) + |^ + ?- -.action + %add (add +.action) + %archive (do-archive +.action) + %seen seen + %read (read +.action) + %unread (unread +.action) + %set-dnd (set-dnd +.action) + == + ++ add + |= [=index:store =notification:store] + ^- (quip card _state) + =/ =timebox:store + (gut-orm:ha notifications last-seen) + =/ existing-notif + (~(get by timebox) index) + =/ new=notification:store + ?~ existing-notif + notification + (merge-notification:ha u.existing-notif notification) + =/ new-timebox=timebox:store + (~(put by timebox) index new) + :- (give:ha [/updates]~ %added last-seen index new) + %_ state + notifications (put:orm notifications last-seen new-timebox) + unread-count ?~(existing-notif +(unread-count) unread-count) + == + :: + ++ do-archive + |= [time=@da =index:store] + ^- (quip card _state) + =/ =timebox:store + (gut-orm:ha notifications time) + =/ =notification:store + (~(got by timebox) index) + =/ new-timebox=timebox:store + (~(del by timebox) index) + :- (give:ha [/updates]~ %archive time index) + %_ state + unread-count ?.(read.notification (dec unread-count) unread-count) + :: + notifications + (put:orm notifications time new-timebox) + :: + archive + %^ jub-orm:ha archive time + |= archive-box=timebox:store + ^- timebox:store + (~(put by archive-box) index notification(read %.y)) + == + :: + ++ read + |= [time=@da =index:store] + ^- (quip card _state) + :- (give:ha [/updates]~ %read time index) + %_ state + unread-count (dec unread-count) + notifications (change-read-status:ha time index %.y) + == + :: + ++ unread + |= [time=@da =index:store] + ^- (quip card _state) + :- (give:ha [/updates]~ %unread time index) + %_ state + unread-count +(unread-count) + notifications (change-read-status:ha time index %.n) + == + :: + ++ seen + ^- (quip card _state) + :_ state(last-seen now.bowl) + :~ cancel-autoseen:ha + autoseen-timer:ha + == + :: + ++ set-dnd + |= d=? + ^- (quip card _state) + :_ state(dnd d) + (give:ha [/updates]~ %set-dnd d) + -- + -- +:: +++ 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) + ?> ?=([%b %wake *] sign-arvo) + :_ this(last-seen now.bowl) + ~[autoseen-timer:ha] +:: +++ on-fail on-fail:def +-- +|_ =bowl:gall ++* met ~(. metadata bowl) +:: +++ merge-notification + |= [existing=notification:store new=notification:store] + ^- notification:store + ?- -.contents.existing + :: + %chat + ?> ?=(%chat -.contents.new) + existing(list.contents (weld list.contents.existing list.contents.new)) + :: + %graph + ?> ?=(%graph -.contents.new) + existing(list.contents (weld list.contents.existing list.contents.new)) + :: + %group + ?> ?=(%group -.contents.new) + existing(list.contents (weld list.contents.existing list.contents.new)) + == +:: +++ change-read-status + |= [time=@da =index:store read=?] + ^+ notifications + %^ jub-orm notifications time + |= =timebox:store + %+ ~(jab by timebox) index + |= =notification:store + ?> !=(read read.notification) + notification(read read) +:: +key-orm: +key:by for ordered maps +++ key-orm + |= =notifications:store + ^- (list @da) + (turn (tap:orm notifications) |=([key=@da =timebox:store] key)) +:: +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) +:: +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 last-seen autoseen-interval)] +:: +++ autoseen-timer + ^- card + [%pass /autoseen %arvo %b %wait (add now.bowl autoseen-interval)] +:: +++ give + |= [paths=(list path) update=update:store] + ^- (list card) + [%give %fact paths [%hark-update !>(update)]]~ +:: +++ inflate-cache + |= state-0 + ^- cache + :_ ~ + %+ roll + (tap:orm notifications) + |= [[time=@da =timebox:store] out=@ud] + =/ unreads ~(tap by timebox) + |- + ?~ unreads out + =* notification q.i.unreads + ?: read.notification + out + %_ $ + unreads t.unreads + :: + out +(out) + == +-- diff --git a/pkg/arvo/app/landscape/index.html b/pkg/arvo/app/landscape/index.html index 0f258bd90..fb74f53b6 100644 --- a/pkg/arvo/app/landscape/index.html +++ b/pkg/arvo/app/landscape/index.html @@ -24,6 +24,6 @@
- + diff --git a/pkg/arvo/lib/graph-store.hoon b/pkg/arvo/lib/graph-store.hoon index daf63fda3..98eaa8fc6 100644 --- a/pkg/arvo/lib/graph-store.hoon +++ b/pkg/arvo/lib/graph-store.hoon @@ -34,6 +34,78 @@ ++ enjs =, enjs:format |% + :: + ++ signatures + |= s=^signatures + ^- json + [%a (turn ~(tap in s) signature)] + :: + ++ signature + |= s=^signature + ^- json + %- pairs + :~ [%signature s+(scot %ux p.s)] + [%ship (ship q.s)] + [%life (numb r.s)] + == + :: + ++ index + |= i=^index + ^- json + =/ j=^tape "" + |- + ?~ i [%s (crip j)] + =/ k=json (numb i.i) + ?> ?=(%n -.k) + %_ $ + i t.i + j (weld j (weld "/" (trip +.k))) + == + :: + ++ uid + |= u=^uid + ^- json + %- pairs + :~ [%resource (enjs:res resource.u)] + [%index (index index.u)] + == + :: + ++ content + |= c=^content + ^- json + ?- -.c + %mention (frond %mention (ship ship.c)) + %text (frond %text s+text.c) + %url (frond %url s+url.c) + %reference (frond %reference (uid uid.c)) + %code + %+ frond %code + %- pairs + :- [%expression s+expression.c] + :_ ~ + :- %output + :: virtualize output rendering, +tank:enjs:format might crash + :: + =/ result=(each (list json) tang) + (mule |.((turn output.c tank))) + ?- -.result + %& a+p.result + %| a+[a+[%s '[[output rendering error]]']~]~ + == + == + :: + ++ post + |= p=^post + ^- json + %- pairs + :~ [%author (ship author.p)] + [%index (index index.p)] + [%time-sent (time time-sent.p)] + [%contents [%a (turn contents.p content)]] + [%hash ?~(hash.p ~ s+(scot %ux u.hash.p))] + [%signatures (signatures signatures.p)] + == + :: ++ update |= upd=^update ^- json @@ -132,20 +204,6 @@ :~ (index [a]~) (node n) == - :: - ++ index - |= i=^index - ^- json - =/ j=^tape "" - |- - ?~ i [%s (crip j)] - =/ k=json (numb i.i) - ?> ?=(%n -.k) - %_ $ - i t.i - j (weld j (weld "/" (trip +.k))) - == - :: ++ node |= n=^node ^- json @@ -158,41 +216,7 @@ == == :: - ++ post - |= p=^post - ^- json - %- pairs - :~ [%author (ship author.p)] - [%index (index index.p)] - [%time-sent (time time-sent.p)] - [%contents [%a (turn contents.p content)]] - [%hash ?~(hash.p ~ s+(scot %ux u.hash.p))] - [%signatures (signatures signatures.p)] - == - :: - ++ content - |= c=^content - ^- json - ?- -.c - %text (frond %text s+text.c) - %url (frond %url s+url.c) - %reference (frond %reference (uid uid.c)) - %code - %+ frond %code - %- pairs - :- [%expression s+expression.c] - :_ ~ - :- %output - :: virtualize output rendering, +tank:enjs:format might crash - :: - =/ result=(each (list json) tang) - (mule |.((turn output.c tank))) - ?- -.result - %& a+p.result - %| a+[a+[%s '[[output rendering error]]']~]~ - == - == - :: + :: ++ nodes |= m=(map ^index ^node) ^- json @@ -210,27 +234,6 @@ ^- json [%a (turn ~(tap in i) index)] :: - ++ uid - |= u=^uid - ^- json - %- pairs - :~ [%resource (enjs:res resource.u)] - [%index (index index.u)] - == - :: - ++ signatures - |= s=^signatures - ^- json - [%a (turn ~(tap in s) signature)] - :: - ++ signature - |= s=^signature - ^- json - %- pairs - :~ [%signature s+(scot %ux p.s)] - [%ship (ship q.s)] - [%life (numb r.s)] - == -- -- :: @@ -322,7 +325,8 @@ :: ++ content %- of - :~ [%text so] + :~ [%mention (su ;~(pfix sig fed:ag))] + [%text so] [%url so] [%reference uid] [%code eval] diff --git a/pkg/arvo/lib/hark/chat-hook.hoon b/pkg/arvo/lib/hark/chat-hook.hoon new file mode 100644 index 000000000..1bdd167f5 --- /dev/null +++ b/pkg/arvo/lib/hark/chat-hook.hoon @@ -0,0 +1,30 @@ +/- sur=hark-chat-hook +^? +=< [. sur] +=, sur +|% +++ dejs + =, dejs:format + |% + ++ action + %- of + :~ listen+pa + ignore+pa + set-mentions+bo + == + -- +:: +++ enjs + =, enjs:format + |% + ++ update + |= upd=^update + %+ frond -.upd + ?- -.upd + ?(%listen %ignore) (path chat.upd) + %set-mentions b+mentions.upd + %initial a+(turn ~(tap in watching.upd) path) + == + -- +-- + diff --git a/pkg/arvo/lib/hark/graph-hook.hoon b/pkg/arvo/lib/hark/graph-hook.hoon new file mode 100644 index 000000000..83995e2df --- /dev/null +++ b/pkg/arvo/lib/hark/graph-hook.hoon @@ -0,0 +1,61 @@ +/- sur=hark-graph-hook +/+ graph-store, resource +^? +=< [. sur] +=, sur +|% +++ dejs + =, dejs:format + |% + ++ graph-indices + %- ot + :~ graph+dejs-path:resource + indices+(as graph-index) + == + :: + ++ graph-index + ^- $-(json index:graph-store) + (su ;~(pfix net (more net dem))) + :: + ++ action + %- of + :~ listen+dejs-path:resource + ignore+dejs-path:resource + set-mentions+bo + set-watch-on-self+bo + == + -- +:: +++ enjs + =, enjs:format + |% + ++ graph-indices + |= [graph=resource indices=(set index:graph-store)] + %- pairs + :~ graph+s+(enjs-path:resource graph) + indices+a+(turn ~(tap in indices) index:enjs:graph-store) + == + :: + ++ action + |= act=^action + ^- json + %+ frond -.act + ?- -.act + %set-watch-on-self b+watch-on-self.act + %set-mentions b+mentions.act + ?(%listen %ignore) s+(enjs-path:resource graph.act) + == + :: + ++ update + |= upd=^update + ^- json + ?. ?=(%initial -.upd) + (action upd) + %+ frond -.upd + %- pairs + :~ 'watchOnSelf'^b+watch-on-self.upd + 'mentions'^b+mentions.upd + watching+a+(turn ~(tap in watching.upd) |=(r=resource s+(enjs-path:resource r))) + == + -- +-- diff --git a/pkg/arvo/lib/hark/group-hook.hoon b/pkg/arvo/lib/hark/group-hook.hoon new file mode 100644 index 000000000..e32ee8bd7 --- /dev/null +++ b/pkg/arvo/lib/hark/group-hook.hoon @@ -0,0 +1,34 @@ +/- sur=hark-group-hook +/+ resource +^? +=< [. sur] +=, sur +|% +++ dejs + =, dejs:format + |% + ++ action + %- of + :~ listen+dejs-path:resource + ignore+dejs-path:resource + == + -- +:: +++ enjs + =, enjs:format + |% + ++ res + (cork enjs-path:resource (lead %s)) + :: + ++ update + |= upd=^update + %+ frond -.upd + ?- -.upd + ?(%listen %ignore) (res group.upd) + :: + %initial + :- %a + (turn ~(tap in watching.upd) res) + == + -- +-- diff --git a/pkg/arvo/lib/hark/store.hoon b/pkg/arvo/lib/hark/store.hoon new file mode 100644 index 000000000..214d42de7 --- /dev/null +++ b/pkg/arvo/lib/hark/store.hoon @@ -0,0 +1,212 @@ +/- sur=hark-store, post +/+ resource, graph-store, group-store, chat-store +^? +=< [. sur] +=, sur +|% +++ dejs + =, dejs:format + |% + ++ index + %- of + :~ graph+graph-index + group+group-index + chat+chat-index + == + :: + ++ chat-index + %- ot + :~ chat+pa + mention+bo + == + :: + ++ group-index + %- ot + :~ group+dejs-path:resource + description+so + == + :: + ++ graph-index + %- ot + :~ group+dejs-path:resource + graph+dejs-path:resource + module+so + description+so + == + :: parse date as @ud + :: TODO: move to zuse + ++ sd + |= jon=json + ^- @da + ?> ?=(%s -.jon) + `@da`(rash p.jon dem:ag) + + :: + ++ notif-ref + ^- $-(json [@da ^index]) + %- ot + :~ time+sd + index+index + == + :: + ++ add + |= jon=json + [*^index *notification] + :: + ++ action + ^- $-(json ^action) + %- of + :~ seen+ul + archive+notif-ref + unread+notif-ref + read+notif-ref + add+add + set-dnd+bo + == + -- +:: +++ enjs + =, enjs:format + |% + ++ update + |= upd=^update + ^- json + |^ + %+ frond -.upd + ?+ -.upd a+~ + %added (added +.upd) + %timebox (timebox +.upd) + %set-dnd b+dnd.upd + %count (numb count.upd) + %more (more +.upd) + :: + ?(%archive %read %unread) + (notif-ref +.upd) + == + :: + ++ added + |= [tim=@da idx=^index not=^notification] + ^- json + %- pairs + :~ time+s+(scot %ud tim) + index+(index idx) + notification+(notification not) + == + :: + ++ notif-ref + |= [tim=@da idx=^index] + ^- json + %- pairs + :~ time+s+(scot %ud tim) + index+(index idx) + == + :: + ++ more + |= upds=(list ^update) + ^- json + a+(turn upds update) + :: + ++ index + |= =^index + %+ frond -.index + |^ + ?- -.index + %graph (graph-index +.index) + %group (group-index +.index) + %chat (chat-index +.index) + == + :: + ++ chat-index + |= [chat=^path mention=?] + ^- json + %- pairs + :~ chat+(path chat) + mention+b+mention + == + :: + ++ graph-index + |= [group=resource graph=resource module=@t description=@t] + ^- json + %- pairs + :~ group+s+(enjs-path:resource group) + graph+s+(enjs-path:resource graph) + module+s+module + description+s+description + == + :: + ++ group-index + |= [group=resource description=@t] + ^- json + %- pairs + :~ group+s+(enjs-path:resource group) + description+s+description + == + -- + :: + ++ notification + |= ^notification + ^- json + %- pairs + :~ time+(time date) + read+b+read + contents+(^contents contents) + == + :: + ++ contents + |= =^contents + ^- json + %+ frond -.contents + |^ + ?- -.contents + %graph (graph-contents +.contents) + %group (group-contents +.contents) + %chat (chat-contents +.contents) + == + :: + ++ chat-contents + |= =(list envelope:chat-store) + ^- json + :- %a + (turn list envelope:enjs:chat-store) + :: + ++ graph-contents + |= =(list post:post) + ^- json + :- %a + (turn list post:enjs:graph-store) + :: + ++ group-contents + |= =(list ^group-contents) + ^- json + :- %a + %+ murn list + |= =^group-contents + ?. ?=(?(%add-members %remove-members) -.group-contents) + ~ + `(update:enjs:group-store group-contents) + -- + :: + ++ indexed-notification + |= [=^index =^notification] + %- pairs + :~ index+(^index index) + notification+(^notification notification) + == + :: + ++ timebox + |= [tim=@da arch=? l=(list [^index ^notification])] + ^- json + %- pairs + :~ time+s+(scot %ud tim) + archive+b+arch + :- %notifications + ^- json + :- %a + %+ turn l + |= [=^index =^notification] + ^- json + (indexed-notification index notification) + == + -- + -- +-- diff --git a/pkg/arvo/lib/hood/drum.hoon b/pkg/arvo/lib/hood/drum.hoon index 826d618fd..db753d57b 100644 --- a/pkg/arvo/lib/hood/drum.hoon +++ b/pkg/arvo/lib/hood/drum.hoon @@ -107,6 +107,10 @@ %graph-store %graph-pull-hook %graph-push-hook + %hark-store + %hark-graph-hook + %hark-group-hook + %hark-chat-hook %observe-hook == :: @@ -210,7 +214,7 @@ == :: ++ on-load - |= [hood-version=?(%1 %2 %3 %4 %5 %6 %7 %8 %9 %10 %11) old=any-state] + |= [hood-version=@ud old=any-state] =< se-abet =< se-view =. sat old =. dev (~(gut by bin) ost *source) @@ -243,7 +247,11 @@ => (se-born | %home %graph-push-hook) (se-born | %home %graph-pull-hook) =? ..on-load (lte hood-version %11) - (se-born | %home %observe-hook) + => (se-born | %home %hark-graph-hook) + => (se-born | %home %hark-group-hook) + => (se-born | %home %hark-chat-hook) + => (se-born | %home %hark-store) + (se-born | %home %observe-hook) ..on-load :: ++ reap-phat :: ack connect diff --git a/pkg/arvo/lib/metadata.hoon b/pkg/arvo/lib/metadata.hoon index 32d5bf4a6..71c894b20 100644 --- a/pkg/arvo/lib/metadata.hoon +++ b/pkg/arvo/lib/metadata.hoon @@ -1,6 +1,7 @@ :: metadata: helpers for getting data from the metadata-store :: /- *metadata-store +/+ res=resource :: |_ =bowl:gall ++ app-paths-from-group @@ -21,6 +22,27 @@ ?. =(app-name.md-resource app-name) ~ `app-path.md-resource :: +++ peek-metadata + |= [app-name=term =group=resource:res =app=resource:res] + ^- (unit metadata) + =/ group-cord=cord (scot %t (spat (en-path:res group-resource))) + =/ app-cord=cord (scot %t (spat (en-path:res app-resource))) + =/ our=cord (scot %p our.bowl) + =/ now=cord (scot %da now.bowl) + .^ (unit metadata) + %gx (scot %p our.bowl) %metadata-store (scot %da now.bowl) + %metadata group-cord app-name app-cord /noun + == +:: +++ group-from-app-resource + |= [app=term =app=resource:res] + ^- (unit resource:res) + =/ app-path (en-path:res app-resource) + =/ group-paths (groups-from-resource app app-path) + ?~ group-paths + ~ + `(de-path:res i.group-paths) +:: ++ groups-from-resource |= =md-resource ^- (list group-path) diff --git a/pkg/arvo/lib/resource.hoon b/pkg/arvo/lib/resource.hoon index 07931f5a4..f84acb0b8 100644 --- a/pkg/arvo/lib/resource.hoon +++ b/pkg/arvo/lib/resource.hoon @@ -37,6 +37,13 @@ %- spat (en-path resource) :: +++ dejs-path + %- su:dejs:format + ;~ pfix + (jest '/ship/') + ;~((glue fas) ;~(pfix sig fed:ag) urs:ab) + == +:: ++ dejs =, dejs:format ^- $-(json resource) diff --git a/pkg/arvo/mar/graph/validator/link.hoon b/pkg/arvo/mar/graph/validator/link.hoon index 02de528df..69087ca1c 100644 --- a/pkg/arvo/mar/graph/validator/link.hoon +++ b/pkg/arvo/mar/graph/validator/link.hoon @@ -3,6 +3,11 @@ ++ grow |% ++ noun i + ++ notification-kind + ?+ index.p.i ~ + [@ ~] `%link + [@ @ ~] `%comment + == -- ++ grab |% @@ -19,7 +24,7 @@ :: comment on link post; comment text :: [@ @ ~] - ?> ?=([[%text @] ~] contents.p.ip) + ?> ?=(^ contents.p.ip) ip == -- diff --git a/pkg/arvo/mar/graph/validator/publish.hoon b/pkg/arvo/mar/graph/validator/publish.hoon index 7688b4a28..7c06e56f7 100644 --- a/pkg/arvo/mar/graph/validator/publish.hoon +++ b/pkg/arvo/mar/graph/validator/publish.hoon @@ -3,6 +3,14 @@ ++ grow |% ++ noun i + :: +notification-kind + :: Ignore all containers, only notify on content + :: + ++ notification-kind + ?+ index.p.i ~ + [@ %1 @ ~] `%note + [@ %2 @ ~] `%comment + == -- ++ grab |% diff --git a/pkg/arvo/mar/hark/action.hoon b/pkg/arvo/mar/hark/action.hoon new file mode 100644 index 000000000..608f7f318 --- /dev/null +++ b/pkg/arvo/mar/hark/action.hoon @@ -0,0 +1,13 @@ +/+ *hark-store +|_ act=action +++ grad %noun +++ grow + |% + ++ noun act + -- +++ grab + |% + ++ noun action + ++ json action:dejs + -- +-- diff --git a/pkg/arvo/mar/hark/chat-hook-action.hoon b/pkg/arvo/mar/hark/chat-hook-action.hoon new file mode 100644 index 000000000..37987c956 --- /dev/null +++ b/pkg/arvo/mar/hark/chat-hook-action.hoon @@ -0,0 +1,13 @@ +/+ *hark-chat-hook +|_ act=action +++ grad %noun +++ grow + |% + ++ noun act + -- +++ grab + |% + ++ noun action + ++ json action:dejs + -- +-- diff --git a/pkg/arvo/mar/hark/chat-hook-update.hoon b/pkg/arvo/mar/hark/chat-hook-update.hoon new file mode 100644 index 000000000..b76de0bb5 --- /dev/null +++ b/pkg/arvo/mar/hark/chat-hook-update.hoon @@ -0,0 +1,16 @@ +/+ *hark-chat-hook +|_ upd=update +++ grad %noun +++ grow + |% + ++ noun upd + ++ json + %+ frond:enjs:format + %hark-chat-hook-update + (update:enjs upd) + -- +++ grab + |% + ++ noun update + -- +-- diff --git a/pkg/arvo/mar/hark/graph-hook-action.hoon b/pkg/arvo/mar/hark/graph-hook-action.hoon new file mode 100644 index 000000000..1dbc05a09 --- /dev/null +++ b/pkg/arvo/mar/hark/graph-hook-action.hoon @@ -0,0 +1,13 @@ +/+ *hark-graph-hook +|_ act=action +++ grad %noun +++ grow + |% + ++ noun act + -- +++ grab + |% + ++ noun action + ++ json action:dejs + -- +-- diff --git a/pkg/arvo/mar/hark/graph-hook-update.hoon b/pkg/arvo/mar/hark/graph-hook-update.hoon new file mode 100644 index 000000000..61c77dcd6 --- /dev/null +++ b/pkg/arvo/mar/hark/graph-hook-update.hoon @@ -0,0 +1,17 @@ +/+ *hark-graph-hook +|_ upd=update +++ grad %noun +++ grow + |% + ++ noun upd + ++ json + %+ frond:enjs:format + %hark-graph-hook-update + (update:enjs upd) + -- +++ grab + |% + ++ noun update + ++ json update:dejs + -- +-- diff --git a/pkg/arvo/mar/hark/group-hook-action.hoon b/pkg/arvo/mar/hark/group-hook-action.hoon new file mode 100644 index 000000000..05b9d15eb --- /dev/null +++ b/pkg/arvo/mar/hark/group-hook-action.hoon @@ -0,0 +1,13 @@ +/+ *hark-group-hook +|_ act=action +++ grad %noun +++ grow + |% + ++ noun act + -- +++ grab + |% + ++ noun action + ++ json action:dejs + -- +-- diff --git a/pkg/arvo/mar/hark/group-hook-update.hoon b/pkg/arvo/mar/hark/group-hook-update.hoon new file mode 100644 index 000000000..95063a5a0 --- /dev/null +++ b/pkg/arvo/mar/hark/group-hook-update.hoon @@ -0,0 +1,16 @@ +/+ *hark-group-hook +|_ upd=update +++ grad %noun +++ grow + |% + ++ noun upd + ++ json + %+ frond:enjs:format + %hark-group-hook-update + (update:enjs upd) + -- +++ grab + |% + ++ noun update + -- +-- diff --git a/pkg/arvo/mar/hark/update.hoon b/pkg/arvo/mar/hark/update.hoon new file mode 100644 index 000000000..8aeff8f5a --- /dev/null +++ b/pkg/arvo/mar/hark/update.hoon @@ -0,0 +1,15 @@ +/+ *hark-store +|_ upd=update +++ grad %noun +++ grow + |% + ++ noun upd + ++ json + %+ frond:enjs:format 'harkUpdate' + (update:enjs upd) + -- +++ grab + |% + ++ noun update + -- +-- diff --git a/pkg/arvo/sur/hark-chat-hook.hoon b/pkg/arvo/sur/hark-chat-hook.hoon new file mode 100644 index 000000000..a5e9bf875 --- /dev/null +++ b/pkg/arvo/sur/hark-chat-hook.hoon @@ -0,0 +1,15 @@ +^? +|% ++$ action + $% [?(%listen %ignore) chat=path] + [%set-mentions mentions=?] + == +:: ++$ update + $% + action + $: %initial + watching=(set path) + == + == +-- diff --git a/pkg/arvo/sur/hark-graph-hook.hoon b/pkg/arvo/sur/hark-graph-hook.hoon new file mode 100644 index 000000000..95deb322a --- /dev/null +++ b/pkg/arvo/sur/hark-graph-hook.hoon @@ -0,0 +1,20 @@ +/- *resource, graph-store +^? +|% ++$ action + $% + [?(%listen %ignore) graph=resource] + [%set-mentions mentions=?] + [%set-watch-on-self watch-on-self=?] + == +:: ++$ update + $% + action + $: %initial + watching=(set resource) + mentions=_& + watch-on-self=_& + == + == +-- diff --git a/pkg/arvo/sur/hark-group-hook.hoon b/pkg/arvo/sur/hark-group-hook.hoon new file mode 100644 index 000000000..f44ed63bc --- /dev/null +++ b/pkg/arvo/sur/hark-group-hook.hoon @@ -0,0 +1,11 @@ +/- *resource +^? +|% ++$ action + [?(%listen %ignore) group=resource] +:: ++$ update + $% action + [%initial watching=(set resource)] + == +-- diff --git a/pkg/arvo/sur/hark-store.hoon b/pkg/arvo/sur/hark-store.hoon new file mode 100644 index 000000000..4379612eb --- /dev/null +++ b/pkg/arvo/sur/hark-store.hoon @@ -0,0 +1,50 @@ +/- *resource, graph-store, post, group-store, metadata-store, chat-store +^? +|% ++$ index + $% [%graph group=resource graph=resource module=@t description=@t] + [%group group=resource description=@t] + [%chat chat=path mention=?] + == +:: ++$ group-contents + $~ [%add-members *resource ~] + $% $>(?(%add-members %remove-members) update:group-store) + metadata-action:metadata-store + == +:: ++$ notification + [date=@da read=? =contents] +:: ++$ contents + $% [%graph =(list post:post)] + [%group =(list group-contents)] + [%chat =(list envelope:chat-store)] + == +:: ++$ timebox + (map index notification) +:: ++$ notifications + ((mop @da timebox) lth) +:: ++$ action + $% [%add =index =notification] + [%archive time=@da index] + [%read time=@da index] + [%unread time=@da index] + [%set-dnd dnd=?] + [%seen ~] + == +:: +++ indexed-notification + [index notification] +:: ++$ update + $% action + [%more =(list update)] + [%added time=@da =index =notification] + [%timebox time=@da archived=? =(list [index notification])] + [%count count=@ud] + == +-- diff --git a/pkg/arvo/sur/post.hoon b/pkg/arvo/sur/post.hoon index c5c354615..4855a3a68 100644 --- a/pkg/arvo/sur/post.hoon +++ b/pkg/arvo/sur/post.hoon @@ -28,6 +28,7 @@ :: +$ content $% [%text text=cord] + [%mention =ship] [%url url=cord] [%code expression=cord output=(list tank)] [%reference =uid] diff --git a/pkg/interface/src/logic/api/global.ts b/pkg/interface/src/logic/api/global.ts index 5bb2a187e..5388259a2 100644 --- a/pkg/interface/src/logic/api/global.ts +++ b/pkg/interface/src/logic/api/global.ts @@ -11,6 +11,7 @@ import GroupsApi from './groups'; import LaunchApi from './launch'; import GraphApi from './graph'; import S3Api from './s3'; +import {HarkApi} from './hark'; export default class GlobalApi extends BaseApi { chat = new ChatApi(this.ship, this.channel, this.store); @@ -22,6 +23,7 @@ export default class GlobalApi extends BaseApi { launch = new LaunchApi(this.ship, this.channel, this.store); s3 = new S3Api(this.ship, this.channel, this.store); graph = new GraphApi(this.ship, this.channel, this.store); + hark = new HarkApi(this.ship, this.channel, this.store); constructor( public ship: Patp, diff --git a/pkg/interface/src/logic/api/graph.ts b/pkg/interface/src/logic/api/graph.ts index fe73cac0b..456776f42 100644 --- a/pkg/interface/src/logic/api/graph.ts +++ b/pkg/interface/src/logic/api/graph.ts @@ -3,10 +3,10 @@ import { StoreState } from '../store/type'; import { Patp, Path, PatpNoSig } from '~/types/noun'; import _ from 'lodash'; import {makeResource, resourceFromPath} from '../lib/group'; -import {GroupPolicy, Enc, Post, NodeMap} from '~/types'; +import {GroupPolicy, Enc, Post, NodeMap, Content} from '~/types'; import { numToUd, unixToDa } from '~/logic/lib/util'; -export const createPost = (contents: Object[], parentIndex: string = '') => { +export const createPost = (contents: Content[], parentIndex: string = '') => { return { author: `~${window.ship}`, index: parentIndex + '/' + unixToDa(Date.now()).toString(), diff --git a/pkg/interface/src/logic/api/hark.ts b/pkg/interface/src/logic/api/hark.ts new file mode 100644 index 000000000..b4706e80d --- /dev/null +++ b/pkg/interface/src/logic/api/hark.ts @@ -0,0 +1,144 @@ +import BaseApi from "./base"; +import { StoreState } from "../store/type"; +import { dateToDa, decToUd } from "../lib/util"; +import {NotifIndex} from "~/types"; +import { BigInteger } from 'big-integer'; + +export class HarkApi extends BaseApi { + private harkAction(action: any): Promise { + return this.action("hark-store", "hark-action", action); + } + + private graphHookAction(action: any) { + return this.action("hark-graph-hook", "hark-graph-hook-action", action); + } + + private groupHookAction(action: any) { + return this.action("hark-group-hook", "hark-group-hook-action", action); + } + + private chatHookAction(action: any) { + return this.action("hark-chat-hook", "hark-chat-hook-action", action); + } + + private actOnNotification(frond: string, intTime: BigInteger, index: NotifIndex) { + const time = decToUd(intTime.toString()); + return this.harkAction({ + [frond]: { + time, + index + } + }); + } + + async setMentions(mentions: boolean) { + await this.graphHookAction({ + 'set-mentions': mentions + }); + return this.chatHookAction({ + 'set-mentions': mentions + }); + } + + setWatchOnSelf(watchSelf: boolean) { + return this.graphHookAction({ + 'set-watch-on-self': watchSelf + }); + } + + setDoNotDisturb(dnd: boolean) { + return this.harkAction({ + 'set-dnd': dnd + }); + } + + archive(time: BigInteger, index: NotifIndex) { + return this.actOnNotification('archive', time, index); + } + + read(time: BigInteger, index: NotifIndex) { + return this.actOnNotification('read', time, index); + } + + unread(time: BigInteger, index: NotifIndex) { + return this.actOnNotification('unread', time, index); + } + + seen() { + return this.harkAction({ seen: null }); + } + + mute(index: NotifIndex) { + if('graph' in index) { + const { graph } = index.graph; + return this.ignoreGraph(graph); + } + if('group' in index) { + const { group } = index.group; + return this.ignoreGroup(group); + } + if('chat' in index) { + return this.ignoreChat(index.chat); + } + return Promise.resolve(); + } + + unmute(index: NotifIndex) { + if('graph' in index) { + return this.listenGraph(index.graph.graph); + } + if('group' in index) { + return this.listenGroup(index.group.group); + } + if('chat' in index) { + return this.listenChat(index.chat); + } + return Promise.resolve(); + } + + ignoreGroup(group: string) { + return this.groupHookAction({ + ignore: group + }) + } + + ignoreGraph(graph: string) { + return this.graphHookAction({ + ignore: graph + }) + } + + ignoreChat(chat: string) { + return this.chatHookAction({ + ignore: chat + }); + } + + + listenGroup(group: string) { + return this.groupHookAction({ + listen: group + }) + } + + listenGraph(graph: string) { + return this.graphHookAction({ + listen: graph + }) + } + + listenChat(chat: string) { + return this.chatHookAction({ + listen: chat + }); + } + + async getTimeSubset(start?: Date, end?: Date) { + const s = start ? dateToDa(start) : "-"; + const e = end ? dateToDa(end) : "-"; + const result = await this.scry("hark-hook", `/time-subset/${s}/${e}`); + this.store.handleEvent({ + data: result, + }); + } +} diff --git a/pkg/interface/src/logic/lib/graph.ts b/pkg/interface/src/logic/lib/graph.ts new file mode 100644 index 000000000..b17848ded --- /dev/null +++ b/pkg/interface/src/logic/lib/graph.ts @@ -0,0 +1,24 @@ +import { Content } from "~/types"; +import urbitOb from "urbit-ob"; + +export function scanForMentions(text: string) { + const regex = /~([a-z]|-)+/g; + let result: Content[] = []; + let match: RegExpExecArray | null; + let lastPos = 0; + while ((match = regex.exec(text)) !== null) { + const newPos = match.index + match[0].length; + if (urbitOb.isValidPatp(match[0])) { + if (match.index !== lastPos) { + result.push({ text: text.slice(lastPos, match.index) }); + } + result.push({ mention: match[0] }); + } + lastPos = newPos; + } + const remainder = text.slice(lastPos, text.length); + if (remainder) { + result.push({ text: remainder }); + } + return result; +} diff --git a/pkg/interface/src/logic/lib/omnibox.js b/pkg/interface/src/logic/lib/omnibox.js index 583bb01de..1bc38a4e2 100644 --- a/pkg/interface/src/logic/lib/omnibox.js +++ b/pkg/interface/src/logic/lib/omnibox.js @@ -55,6 +55,7 @@ const appIndex = function (apps) { const otherIndex = function() { const other = []; other.push(result('DMs + Drafts', '/~landscape/home', 'home', null)); + other.push(result('Notifications', '/~notifications', 'inbox', null)); other.push(result('Profile and Settings', '/~profile/identity', 'profile', null)); other.push(result('Log Out', '/~/logout', 'logout', null)); diff --git a/pkg/interface/src/logic/lib/sigil.js b/pkg/interface/src/logic/lib/sigil.js index 6e606be90..067711236 100644 --- a/pkg/interface/src/logic/lib/sigil.js +++ b/pkg/interface/src/logic/lib/sigil.js @@ -25,6 +25,7 @@ export const Sigil = memo(({ classes = '', color, foreground = '', ship, size, s display='inline-block' height={size} width={size} + className={classes} />) : ( parseInt(c.join(''), 16)), // as hex - f.map(c => clamp(c + amount, 0, 255).toString(16)), // adjust - f.join('') - )(color.slice(1)) - return `#${res}`; -} - - -export function resourceAsPath(resource) { - const { name, ship } = resource; - return `/ship/~${ship}/${name}`; -} - -export function uuid() { - let str = '0v'; - str += Math.ceil(Math.random()*8)+'.'; - for (let i = 0; i < 5; i++) { - let _str = Math.ceil(Math.random()*10000000).toString(32); - _str = ('00000'+_str).substr(-5,5); - str += _str+'.'; - } - - return str.slice(0,-1); -} - -/* - Goes from: - ~2018.7.17..23.15.09..5be5 // urbit @da - To: - (javascript Date object) -*/ -export function daToDate(st) { - const dub = function(n) { - return parseInt(n) < 10 ? '0' + parseInt(n) : n.toString(); - }; - const da = st.split('..'); - const bigEnd = da[0].split('.'); - const lilEnd = da[1].split('.'); - const ds = `${bigEnd[0].slice(1)}-${dub(bigEnd[1])}-${dub(bigEnd[2])}T${dub(lilEnd[0])}:${dub(lilEnd[1])}:${dub(lilEnd[2])}Z`; - return new Date(ds); -} - -/* - Goes from: - (javascript Date object) - To: - ~2018.7.17..23.15.09..5be5 // urbit @da -*/ - -export function dateToDa(d, mil) { -  const fil = function(n) { -    return n >= 10 ? n : '0' + n; -  }; -  return ( -    `~${d.getUTCFullYear()}.` + -    `${(d.getUTCMonth() + 1)}.` + -    `${fil(d.getUTCDate())}..` + -    `${fil(d.getUTCHours())}.` + -    `${fil(d.getUTCMinutes())}.` + -    `${fil(d.getUTCSeconds())}` + - `${mil ? '..0000' : ''}` -  ); -} - -export function deSig(ship) { - if(!ship) { - return null; - } - return ship.replace('~', ''); -} - -export function uxToHex(ux) { - if (ux.length > 2 && ux.substr(0,2) === '0x') { - const value = ux.substr(2).replace('.', '').padStart(6, '0'); - return value; - } - - const value = ux.replace('.', '').padStart(6, '0'); - return value; -} - -export function hexToUx(hex) { - const ux = f.flow( - f.chunk(4), - f.map(x => _.dropWhile(x, y => y === 0).join('')), - f.join('.') - )(hex.split('')) - return `0x${ux}`; -} - -export function writeText(str) { - return new Promise(((resolve, reject) => { - const range = document.createRange(); - range.selectNodeContents(document.body); - document.getSelection().addRange(range); - - let success = false; - function listener(e) { - e.clipboardData.setData('text/plain', str); - e.preventDefault(); - success = true; - } - document.addEventListener('copy', listener); - document.execCommand('copy'); - document.removeEventListener('copy', listener); - - document.getSelection().removeAllRanges(); - - success ? resolve() : reject(); - })).catch((error) => { - console.error(error); - });; -}; - -// trim patps to match dojo, chat-cli -export function cite(ship) { - let patp = ship, shortened = ''; - if (patp === null || patp === '') { - return null; - } - if (patp.startsWith('~')) { - patp = patp.substr(1); - } - // comet - if (patp.length === 56) { - shortened = '~' + patp.slice(0, 6) + '_' + patp.slice(50, 56); - return shortened; - } - // moon - if (patp.length === 27) { - shortened = '~' + patp.slice(14, 20) + '^' + patp.slice(21, 27); - return shortened; - } - return `~${patp}`; -} - -export function alphabeticalOrder(a,b) { - return a.toLowerCase().localeCompare(b.toLowerCase()); -} - -// TODO: deprecated -export function alphabetiseAssociations(associations) { - const result = {}; - Object.keys(associations).sort((a, b) => { - let aName = a.substr(1); - let bName = b.substr(1); - if (associations[a].metadata && associations[a].metadata.title) { - aName = associations[a].metadata.title !== '' - ? associations[a].metadata.title - : a.substr(1); - } - if (associations[b].metadata && associations[b].metadata.title) { - bName = associations[b].metadata.title !== '' - ? associations[b].metadata.title - : b.substr(1); - } - return alphabeticalOrder(aName,bName); - }).map((each) => { - result[each] = associations[each]; - }); - return result; -} - -// encode the string into @ta-safe format, using logic from +wood. -// for example, 'some Chars!' becomes '~.some.~43.hars~21.' -// -export function stringToTa(string) { - let out = ''; - for (let i = 0; i < string.length; i++) { - const char = string[i]; - let add = ''; - switch (char) { - case ' ': - add = '.'; - break; - case '.': - add = '~.'; - break; - case '~': - add = '~~'; - break; - default: - const charCode = string.charCodeAt(i); - if ( - (charCode >= 97 && charCode <= 122) || // a-z - (charCode >= 48 && charCode <= 57) || // 0-9 - char === '-' - ) { - add = char; - } else { - // TODO behavior for unicode doesn't match +wood's, - // but we can probably get away with that for now. - add = '~' + charCode.toString(16) + '.'; - } - } - out = out + add; - } - return '~.' + out; -} - -export function amOwnerOfGroup(groupPath) { - if (!groupPath) -return false; - const groupOwner = /(\/~)?\/~([a-z-]{3,})\/.*/.exec(groupPath)[2]; - return window.ship === groupOwner; -} - -export function getContactDetails(contact) { - const member = !contact; - contact = contact || { - nickname: '', - avatar: null, - color: '0x0' - }; - const nickname = contact.nickname || ''; - const color = uxToHex(contact.color || '0x0'); - const avatar = contact.avatar || null; - return { nickname, color, member, avatar }; -} - -export function stringToSymbol(str) { - let result = ''; - for (let i = 0; i < str.length; i++) { - const n = str.charCodeAt(i); - if (((n >= 97) && (n <= 122)) || - ((n >= 48) && (n <= 57))) { - result += str[i]; - } else if ((n >= 65) && (n <= 90)) { - result += String.fromCharCode(n + 32); - } else { - result += '-'; - } - } - result = result.replace(/^[\-\d]+|\-+/g, '-'); - result = result.replace(/^\-+|\-+$/g, ''); - if (result === '') { - return dateToDa(new Date()); - } - return result; -} - -export function scrollIsAtTop(container) { - if ( - (navigator.userAgent.includes("Safari") && - navigator.userAgent.includes("Chrome")) || - navigator.userAgent.includes("Firefox") - ) { - return container.scrollTop === 0; - } else if (navigator.userAgent.includes("Safari")) { - return ( - container.scrollHeight + Math.round(container.scrollTop) <= - container.clientHeight + 10 - ); - } else { - return false; - } -} - -export function scrollIsAtBottom(container) { - if ( - (navigator.userAgent.includes("Safari") && - navigator.userAgent.includes("Chrome")) || - navigator.userAgent.includes("Firefox") - ) { - return ( - container.scrollHeight - Math.round(container.scrollTop) <= - container.clientHeight + 10 - ); - } else if (navigator.userAgent.includes("Safari")) { - return container.scrollTop === 0; - } else { - return false; - } -} - -/** - * Formats a numbers as a `@ud` inserting dot where needed - */ -export function numToUd(num) { - return f.flow( - f.split(''), - f.reverse, - f.chunk(3), - f.reverse, - f.map(s => s.join('')), - f.join('.') - )(num.toString()) -} - -export function usePreventWindowUnload(shouldPreventDefault, message = "You have unsaved changes. Are you sure you want to exit?") { - React.useEffect(() => { - if (!shouldPreventDefault) return; - const handleBeforeUnload = event => { - event.preventDefault(); - return message; - } - window.addEventListener("beforeunload", handleBeforeUnload); - window.onbeforeunload = handleBeforeUnload; - return () => { - window.removeEventListener("beforeunload", handleBeforeUnload); - window.onbeforeunload = undefined; - } - }, [shouldPreventDefault]); -} \ No newline at end of file diff --git a/pkg/interface/src/logic/lib/util.ts b/pkg/interface/src/logic/lib/util.ts new file mode 100644 index 000000000..53cd38c30 --- /dev/null +++ b/pkg/interface/src/logic/lib/util.ts @@ -0,0 +1,360 @@ +import { useEffect } from 'react'; +import _ from "lodash"; +import f from "lodash/fp"; +import bigInt, { BigInteger } from "big-integer"; + +export const MOBILE_BROWSER_REGEX = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i; + +export const MOMENT_CALENDAR_DATE = { + sameDay: "[Today]", + nextDay: "[Tomorrow]", + nextWeek: "dddd", + lastDay: "[Yesterday]", + lastWeek: "[Last] dddd", + sameElse: "DD/MM/YYYY", +}; + +export function appIsGraph(app: string) { + return app === 'publish' || app == 'link'; +} + +export function parentPath(path: string) { + return _.dropRight(path.split('/'), 1).join('/'); +} + +const DA_UNIX_EPOCH = bigInt("170141184475152167957503069145530368000"); // `@ud` ~1970.1.1 +const DA_SECOND = bigInt("18446744073709551616"); // `@ud` ~s1 +export function daToUnix(da: BigInteger) { + // ported from +time:enjs:format in hoon.hoon + const offset = DA_SECOND.divide(bigInt(2000)); + const epochAdjusted = offset.add(da.subtract(DA_UNIX_EPOCH)); + + return Math.round( + epochAdjusted.multiply(bigInt(1000)).divide(DA_SECOND).toJSNumber() + ); +} + +export function unixToDa(unix: number) { + const timeSinceEpoch = bigInt(unix).multiply(DA_SECOND).divide(bigInt(1000)); + return DA_UNIX_EPOCH.add(timeSinceEpoch); +} + +export function makePatDa(patda: string) { + return bigInt(udToDec(patda)); +} + +export function udToDec(ud: string): string { + return ud.replace(/\./g, ""); +} + +export function decToUd(str: string): string { + return _.trimStart( + f.flow( + f.split(""), + f.reverse, + f.chunk(3), + f.map(f.flow(f.reverse, f.join(""))), + f.reverse, + f.join(".") + )(str), + "0." + ); +} + +/** + * Clamp a number between a min and max + */ +export function clamp(x: number, min: number, max: number) { + return Math.max(min, Math.min(max, x)); +} + +// color is a #000000 color +export function adjustHex(color: string, amount: number): string { + return f.flow( + f.split(""), + f.chunk(2), // get RGB channels + f.map((c) => parseInt(c.join(""), 16)), // as hex + f.map((c) => clamp(c + amount, 0, 255).toString(16)), // adjust + f.join(""), + (res) => `#${res}` //format + )(color.slice(1)); +} + +export function resourceAsPath(resource: any) { + const { name, ship } = resource; + return `/ship/~${ship}/${name}`; +} + +export function uuid() { + let str = "0v"; + str += Math.ceil(Math.random() * 8) + "."; + for (let i = 0; i < 5; i++) { + let _str = Math.ceil(Math.random() * 10000000).toString(32); + _str = ("00000" + _str).substr(-5, 5); + str += _str + "."; + } + + return str.slice(0, -1); +} + +/* + Goes from: + ~2018.7.17..23.15.09..5be5 // urbit @da + To: + (javascript Date object) +*/ +export function daToDate(st: string) { + const dub = function (n: string) { + return parseInt(n) < 10 ? "0" + parseInt(n) : n.toString(); + }; + const da = st.split(".."); + const bigEnd = da[0].split("."); + const lilEnd = da[1].split("."); + const ds = `${bigEnd[0].slice(1)}-${dub(bigEnd[1])}-${dub(bigEnd[2])}T${dub( + lilEnd[0] + )}:${dub(lilEnd[1])}:${dub(lilEnd[2])}Z`; + return new Date(ds); +} + +/* + Goes from: + (javascript Date object) + To: + ~2018.7.17..23.15.09..5be5 // urbit @da +*/ + +export function dateToDa(d: Date, mil: boolean = false) { + const fil = function (n: number) { + return n >= 10 ? n : "0" + n; + }; + return ( + `~${d.getUTCFullYear()}.` + + `${d.getUTCMonth() + 1}.` + + `${fil(d.getUTCDate())}..` + + `${fil(d.getUTCHours())}.` + + `${fil(d.getUTCMinutes())}.` + + `${fil(d.getUTCSeconds())}` + + `${mil ? "..0000" : ""}` + ); +} + +export function deSig(ship: string) { + if (!ship) { + return null; + } + return ship.replace("~", ""); +} + +export function uxToHex(ux: string) { + if (ux.length > 2 && ux.substr(0, 2) === "0x") { + const value = ux.substr(2).replace(".", "").padStart(6, "0"); + return value; + } + + const value = ux.replace(".", "").padStart(6, "0"); + return value; +} + +export const hexToUx: (hex: string) => string = f.flow( + f.split(""), + f.chunk(4), + f.map( + f.flow( + f.dropWhile((y) => y === 0), + f.join + ) + ), + f.join("."), + (x) => `0x${x}` +); + +export function writeText(str: string) { + return new Promise((resolve, reject) => { + const range = document.createRange(); + range.selectNodeContents(document.body); + document?.getSelection()?.addRange(range); + + let success = false; + function listener(e) { + e.clipboardData.setData("text/plain", str); + e.preventDefault(); + success = true; + } + document.addEventListener("copy", listener); + document.execCommand("copy"); + document.removeEventListener("copy", listener); + + document?.getSelection()?.removeAllRanges(); + + success ? resolve() : reject(); + }).catch((error) => { + console.error(error); + }); +} + +// trim patps to match dojo, chat-cli +export function cite(ship: string) { + let patp = ship, + shortened = ""; + if (patp === null || patp === "") { + return null; + } + if (patp.startsWith("~")) { + patp = patp.substr(1); + } + // comet + if (patp.length === 56) { + shortened = "~" + patp.slice(0, 6) + "_" + patp.slice(50, 56); + return shortened; + } + // moon + if (patp.length === 27) { + shortened = "~" + patp.slice(14, 20) + "^" + patp.slice(21, 27); + return shortened; + } + return `~${patp}`; +} + +export function alphabeticalOrder(a: string, b: string) { + return a.toLowerCase().localeCompare(b.toLowerCase()); +} + +// TODO: deprecated +export function alphabetiseAssociations(associations: any) { + const result = {}; + Object.keys(associations) + .sort((a, b) => { + let aName = a.substr(1); + let bName = b.substr(1); + if (associations[a].metadata && associations[a].metadata.title) { + aName = + associations[a].metadata.title !== "" + ? associations[a].metadata.title + : a.substr(1); + } + if (associations[b].metadata && associations[b].metadata.title) { + bName = + associations[b].metadata.title !== "" + ? associations[b].metadata.title + : b.substr(1); + } + return alphabeticalOrder(aName, bName); + }) + .map((each) => { + result[each] = associations[each]; + }); + return result; +} + +// encode the string into @ta-safe format, using logic from +wood. +// for example, 'some Chars!' becomes '~.some.~43.hars~21.' +// +export function stringToTa(str: string) { + let out = ""; + for (let i = 0; i < str.length; i++) { + const char = str[i]; + let add = ""; + switch (char) { + case " ": + add = "."; + break; + case ".": + add = "~."; + break; + case "~": + add = "~~"; + break; + default: + const charCode = str.charCodeAt(i); + if ( + (charCode >= 97 && charCode <= 122) || // a-z + (charCode >= 48 && charCode <= 57) || // 0-9 + char === "-" + ) { + add = char; + } else { + // TODO behavior for unicode doesn't match +wood's, + // but we can probably get away with that for now. + add = "~" + charCode.toString(16) + "."; + } + } + out = out + add; + } + return "~." + out; +} + +export function amOwnerOfGroup(groupPath: string) { + if (!groupPath) return false; + const groupOwner = /(\/~)?\/~([a-z-]{3,})\/.*/.exec(groupPath)?.[2]; + return window.ship === groupOwner; +} + +export function getContactDetails(contact: any) { + const member = !contact; + contact = contact || { + nickname: "", + avatar: null, + color: "0x0", + }; + const nickname = contact.nickname || ""; + const color = uxToHex(contact.color || "0x0"); + const avatar = contact.avatar || null; + return { nickname, color, member, avatar }; +} + +export function stringToSymbol(str: string) { + let result = ""; + for (let i = 0; i < str.length; i++) { + const n = str.charCodeAt(i); + if ((n >= 97 && n <= 122) || (n >= 48 && n <= 57)) { + result += str[i]; + } else if (n >= 65 && n <= 90) { + result += String.fromCharCode(n + 32); + } else { + result += "-"; + } + } + result = result.replace(/^[\-\d]+|\-+/g, "-"); + result = result.replace(/^\-+|\-+$/g, ""); + if (result === "") { + return dateToDa(new Date()); + } + return result; +} + + + +/** + * Formats a numbers as a `@ud` inserting dot where needed + */ +export function numToUd(num: number) { + return f.flow( + f.split(''), + f.reverse, + f.chunk(3), + f.reverse, + f.map(s => s.join('')), + f.join('.') + )(num.toString()) +} + +export function usePreventWindowUnload(shouldPreventDefault: boolean, message = "You have unsaved changes. Are you sure you want to exit?") { + useEffect(() => { + if (!shouldPreventDefault) return; + const handleBeforeUnload = event => { + event.preventDefault(); + return message; + } + window.addEventListener("beforeunload", handleBeforeUnload); + window.onbeforeunload = handleBeforeUnload; + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + // @ts-ignore + window.onbeforeunload = undefined; + } + }, [shouldPreventDefault]); +} + +export function pluralize(text: string, isPlural = false, vowel = false) { + return isPlural ? `${text}s`: `${vowel ? 'an' : 'a'} ${text}`; +} diff --git a/pkg/interface/src/logic/reducers/hark-update.ts b/pkg/interface/src/logic/reducers/hark-update.ts new file mode 100644 index 000000000..401555a06 --- /dev/null +++ b/pkg/interface/src/logic/reducers/hark-update.ts @@ -0,0 +1,285 @@ +import { + Notifications, + NotifIndex, + NotificationGraphConfig, + GroupNotificationsConfig, +} from "~/types"; +import { makePatDa } from "~/logic/lib/util"; +import _ from "lodash"; + +type HarkState = { + notifications: Notifications; + archivedNotifications: Notifications; + notificationsCount: number; + notificationsGraphConfig: NotificationGraphConfig; + notificationsGroupConfig: GroupNotificationsConfig; + notificationsChatConfig: string[]; +}; + +export const HarkReducer = (json: any, state: HarkState) => { + const data = _.get(json, "harkUpdate", false); + if (data) { + reduce(data, state); + } + const graphHookData = _.get(json, "hark-graph-hook-update", false); + if (graphHookData) { + console.log(graphHookData); + graphInitial(graphHookData, state); + graphIgnore(graphHookData, state); + graphListen(graphHookData, state); + graphWatchSelf(graphHookData, state); + graphMentions(graphHookData, state); + } + const groupHookData = _.get(json, "hark-group-hook-update", false); + if (groupHookData) { + groupInitial(groupHookData, state); + groupListen(groupHookData, state); + groupIgnore(groupHookData, state); + } + + const chatHookData = _.get(json, "hark-chat-hook-update", false); + if(chatHookData) { + + chatInitial(chatHookData, state); + chatListen(chatHookData, state); + chatIgnore(chatHookData, state); + + } +}; + +function chatInitial(json: any, state: HarkState) { + const data = _.get(json, "initial", false); + if (data) { + state.notificationsChatConfig = data; + } +} + + +function chatListen(json: any, state: HarkState) { + const data = _.get(json, "listen", false); + if (data) { + state.notificationsChatConfig = [...state.notificationsChatConfig, data]; + } +} + +function chatIgnore(json: any, state: HarkState) { + const data = _.get(json, "ignore", false); + if (data) { + state.notificationsChatConfig = state.notificationsChatConfig.filter(x => x !== data); + } +} + +function groupInitial(json: any, state: HarkState) { + const data = _.get(json, "initial", false); + if (data) { + state.notificationsGroupConfig = data; + } +} + +function graphInitial(json: any, state: HarkState) { + const data = _.get(json, "initial", false); + if (data) { + state.notificationsGraphConfig = data; + } +} + +function graphListen(json: any, state: HarkState) { + const data = _.get(json, "listen", false); + if (data) { + state.notificationsGraphConfig.watching = [ + ...state.notificationsGraphConfig.watching, + data, + ]; + } +} + +function graphIgnore(json: any, state: HarkState) { + const data = _.get(json, "ignore", false); + if (data) { + state.notificationsGraphConfig.watching = state.notificationsGraphConfig.watching.filter( + (n) => n !== data + ); + } +} + +function groupListen(json: any, state: HarkState) { + const data = _.get(json, "listen", false); + if (data) { + state.notificationsGroupConfig = [...state.notificationsGroupConfig, data]; + } +} + +function groupIgnore(json: any, state: HarkState) { + const data = _.get(json, "ignore", false); + if (data) { + state.notificationsGroupConfig = state.notificationsGroupConfig.filter( + (n) => n !== data + ); + } +} + +function graphMentions(json: any, state: HarkState) { + const data = _.get(json, "set-mentions", undefined); + if (!_.isUndefined(data)) { + state.notificationsGraphConfig.mentions = data; + } +} + +function graphWatchSelf(json: any, state: HarkState) { + const data = _.get(json, "set-watch-on-self", undefined); + if (!_.isUndefined(data)) { + state.notificationsGraphConfig.watchOnSelf = data; + } +} + +function reduce(data: any, state: HarkState) { + unread(data, state); + read(data, state); + archive(data, state); + timebox(data, state); + more(data, state); + dnd(data, state); + count(data, state); + added(data, state); +} + +function added(json: any, state: HarkState) { + const data = _.get(json, "added", false); + if (data) { + const { index, notification } = data; + const time = makePatDa(data.time); + const timebox = state.notifications.get(time) || []; + const arrIdx = timebox.findIndex((idxNotif) => + notifIdxEqual(index, idxNotif.index) + ); + if (arrIdx !== -1) { + timebox[arrIdx] = { index, notification }; + state.notifications.set(time, timebox); + } else { + state.notifications.set(time, [...timebox, { index, notification }]); + state.notificationsCount++; + } + } +} + +function count(json: any, state: HarkState) { + const data = _.get(json, "count", false); + if (data !== false) { + state.notificationsCount = data; + } +} + +const dnd = (json: any, state: HarkState) => { + const data = _.get(json, "set-dnd", undefined); + if (!_.isUndefined(data)) { + state.doNotDisturb = data; + } +}; + +const timebox = (json: any, state: HarkState) => { + const data = _.get(json, "timebox", false); + if (data) { + const time = makePatDa(data.time); + if (data.archive) { + state.archivedNotifications.set(time, data.notifications); + } else { + state.notifications.set(time, data.notifications); + } + } +}; + +function more(json: any, state: HarkState) { + const data = _.get(json, "more", false); + if (data) { + _.forEach(data, (d) => reduce(d, 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.module === b.graph.module && + 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 + ); + } else if ("chat" in a && "chat" in b) { + return a.chat.chat === b.chat.chat && + a.chat.mention === b.chat.mention; + } + return false; +} + +function setRead( + time: string, + index: NotifIndex, + read: boolean, + state: HarkState +) { + const patDa = makePatDa(time); + const timebox = state.notifications.get(patDa); + if (_.isNull(timebox)) { + console.warn("Modifying nonexistent timebox"); + return; + } + const arrIdx = timebox.findIndex((idxNotif) => + notifIdxEqual(index, idxNotif.index) + ); + if (arrIdx === -1) { + console.warn("Modifying nonexistent index"); + return; + } + timebox[arrIdx].notification.read = read; + state.notifications.set(patDa, timebox); +} + +function read(json: any, state: HarkState) { + const data = _.get(json, "read", false); + if (data) { + const { time, index } = data; + state.notificationsCount--; + setRead(time, index, true, state); + } +} + +function unread(json: any, state: HarkState) { + const data = _.get(json, "unread", false); + if (data) { + const { time, index } = data; + state.notificationsCount++; + setRead(time, index, false, state); + } +} + +function archive(json: any, state: HarkState) { + const data = _.get(json, "archive", false); + if (data) { + const { index } = data; + const time = makePatDa(data.time); + const timebox = state.notifications.get(time); + if (!timebox) { + console.warn("Modifying nonexistent timebox"); + return; + } + const [archived, unarchived] = _.partition(timebox, (idxNotif) => + notifIdxEqual(index, idxNotif.index) + ); + state.notifications.set(time, unarchived); + const archiveBox = state.archivedNotifications.get(time) || []; + state.notificationsCount -= archived.filter( + ({ notification }) => !notification.read + ).length; + state.archivedNotifications.set(time, [ + ...archiveBox, + ...archived.map(({ notification, index }) => ({ + notification: { ...notification, read: true }, + index, + })), + ]); + } +} diff --git a/pkg/interface/src/logic/store/store.ts b/pkg/interface/src/logic/store/store.ts index c33ecdbb8..03382aafb 100644 --- a/pkg/interface/src/logic/store/store.ts +++ b/pkg/interface/src/logic/store/store.ts @@ -5,13 +5,17 @@ import LocalReducer from '../reducers/local'; import ChatReducer from '../reducers/chat-update'; import { StoreState } from './type'; +import { Timebox } from '~/types'; import { Cage } from '~/types/cage'; import ContactReducer from '../reducers/contact-update'; import S3Reducer from '../reducers/s3-update'; import { GraphReducer } from '../reducers/graph-update'; +import { HarkReducer } from '../reducers/hark-update'; import GroupReducer from '../reducers/group-update'; import LaunchReducer from '../reducers/launch-update'; import ConnectionReducer from '../reducers/connection'; +import {OrderedMap} from '../lib/OrderedMap'; +import { BigIntOrderedMap } from '../lib/BigIntOrderedMap'; export const homeAssociation = { "app-path": "/home", @@ -93,6 +97,16 @@ export default class GlobalStore extends BaseStore { dark: false, inbox: {}, chatSynced: null, + notifications: new BigIntOrderedMap(), + archivedNotifications: new BigIntOrderedMap(), + notificationsGroupConfig: [], + notificationsChatConfig: [], + notificationsGraphConfig: { + watchOnSelf: false, + mentions: false, + watching: [], + }, + notificationsCount: 0 }; } @@ -107,5 +121,6 @@ export default class GlobalStore extends BaseStore { this.launchReducer.reduce(data, this.state); this.connReducer.reduce(data, this.state); GraphReducer(data, this.state); + HarkReducer(data, this.state); } } diff --git a/pkg/interface/src/logic/store/type.ts b/pkg/interface/src/logic/store/type.ts index de3f51e72..75a5c5a7d 100644 --- a/pkg/interface/src/logic/store/type.ts +++ b/pkg/interface/src/logic/store/type.ts @@ -9,7 +9,13 @@ import { S3State } from '~/types/s3-update'; import { LaunchState, WeatherState } from '~/types/launch-update'; import { ConnectionStatus } from '~/types/connection'; import {Graphs} from '~/types/graph-update'; -import { BackgroundConfig, LocalUpdateRemoteContentPolicy } from '~/types/local-update'; +import { + Notifications, + NotificationGraphConfig, + GroupNotificationsConfig, + LocalUpdateRemoteContentPolicy, + BackgroundConfig +} from "~/types"; export interface StoreState { // local state @@ -45,11 +51,18 @@ export interface StoreState { userLocation: string | null; // publish state - notebooks: Notebooks; + notebooks: any; // Chat state chatInitialized: boolean; chatSynced: ChatHookUpdate | null; inbox: Inbox; pendingMessages: Map; + + notifications: Notifications; + notificationsGraphConfig: NotificationGraphConfig; + notificationsGroupConfig: GroupNotificationsConfig; + notificationsChatConfig: string[]; + notificationsCount: number, + doNotDisturb: boolean; } diff --git a/pkg/interface/src/logic/subscription/global.ts b/pkg/interface/src/logic/subscription/global.ts index d3ad08e9e..4e6d6e1e9 100644 --- a/pkg/interface/src/logic/subscription/global.ts +++ b/pkg/interface/src/logic/subscription/global.ts @@ -51,6 +51,10 @@ export default class GlobalSubscription extends BaseSubscription { this.subscribe('/all', 'launch'); this.subscribe('/all', 'weather'); this.subscribe('/keys', 'graph-store'); + this.subscribe('/updates', 'hark-store'); + this.subscribe('/updates', 'hark-graph-hook'); + this.subscribe('/updates', 'hark-group-hook'); + this.subscribe('/updates', 'hark-chat-hook'); } restart() { diff --git a/pkg/interface/src/types/graph-update.ts b/pkg/interface/src/types/graph-update.ts index cdcb1585e..2f452f908 100644 --- a/pkg/interface/src/types/graph-update.ts +++ b/pkg/interface/src/types/graph-update.ts @@ -1,12 +1,28 @@ import { Patp } from "./noun"; import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap"; - -export interface TextContent { text: string; }; -export interface UrlContent { url: string; } -export interface CodeContent { expresssion: string; output: string; }; -export interface ReferenceContent { uid: string; } -export type Content = TextContent | UrlContent | CodeContent | ReferenceContent; +export interface TextContent { + text: string; +} +export interface UrlContent { + url: string; +} +export interface CodeContent { + expresssion: string; + output: string; +} +export interface ReferenceContent { + uid: string; +} +export interface MentionContent { + mention: string; +} +export type Content = + | TextContent + | UrlContent + | CodeContent + | ReferenceContent + | MentionContent; export interface Post { author: Patp; @@ -15,10 +31,9 @@ export interface Post { index: string; pending?: boolean; signatures: string[]; - 'time-sent': number; + "time-sent": number; } - export interface GraphNode { children: Graph; post: Post; @@ -27,5 +42,3 @@ export interface GraphNode { export type Graph = BigIntOrderedMap; export type Graphs = { [rid: string]: Graph }; - - diff --git a/pkg/interface/src/types/hark-update.ts b/pkg/interface/src/types/hark-update.ts new file mode 100644 index 000000000..d549ed623 --- /dev/null +++ b/pkg/interface/src/types/hark-update.ts @@ -0,0 +1,63 @@ +import _ from "lodash"; +import { Post } from "./graph-update"; +import { GroupUpdate } from "./group-update"; +import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap"; +import { Envelope } from './chat-update'; + +type GraphNotifDescription = "link" | "comment"; + +export interface GraphNotifIndex { + graph: string; + group: string; + description: GraphNotifDescription; + module: string; +} + +export interface GroupNotifIndex { + group: string; + description: string; +} + +export interface ChatNotifIndex { + chat: string; + mention: boolean; +} + +export type NotifIndex = + | { graph: GraphNotifIndex } + | { group: GroupNotifIndex } + | { chat: ChatNotifIndex }; + +export type GraphNotificationContents = Post[]; + +export type GroupNotificationContents = GroupUpdate[]; + +export type ChatNotificationContents = Envelope[]; + +export type NotificationContents = + | { graph: GraphNotificationContents } + | { group: GroupNotificationContents } + | { chat: ChatNotificationContents }; + +interface Notification { + read: boolean; + time: number; + contents: NotificationContents; +} + +export interface IndexedNotification { + index: NotifIndex; + notification: Notification; +} + +export type Timebox = IndexedNotification[]; + +export type Notifications = BigIntOrderedMap; + +export interface NotificationGraphConfig { + watchOnSelf: boolean; + mentions: boolean; + watching: string[]; +} + +export type GroupNotificationsConfig = string[]; diff --git a/pkg/interface/src/types/index.ts b/pkg/interface/src/types/index.ts index 32d4b2166..684763c57 100644 --- a/pkg/interface/src/types/index.ts +++ b/pkg/interface/src/types/index.ts @@ -6,6 +6,7 @@ export * from './contact-update'; export * from './global'; export * from './group-update'; export * from './graph-update'; +export * from './hark-update'; export * from './invite-update'; export * from './launch-update'; export * from './local-update'; diff --git a/pkg/interface/src/views/App.js b/pkg/interface/src/views/App.js index 82e6640b1..c857e1b18 100644 --- a/pkg/interface/src/views/App.js +++ b/pkg/interface/src/views/App.js @@ -125,6 +125,9 @@ class App extends React.Component { const theme = state.dark ? dark : light; const { background } = state; + const notificationsCount = state.notificationsCount || 0; + const doNotDisturb = state.doNotDisturb || false; + return ( @@ -143,6 +146,8 @@ class App extends React.Component { connection={this.state.connection} subscription={this.subscription} ship={this.ship} + doNotDisturb={doNotDisturb} + notificationsCount={notificationsCount} /> diff --git a/pkg/interface/src/views/apps/chat/ChatResource.tsx b/pkg/interface/src/views/apps/chat/ChatResource.tsx index 3174b300c..0d8c905f7 100644 --- a/pkg/interface/src/views/apps/chat/ChatResource.tsx +++ b/pkg/interface/src/views/apps/chat/ChatResource.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useCallback } from 'react'; +import React, { useRef, useCallback, useEffect } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { Col } from '@tlon/indigo-react'; import _ from 'lodash'; @@ -93,6 +93,15 @@ export function ChatResource(props: ChatResourceProps) { station ]); + const scrollTo = new URLSearchParams(location.search).get('msg'); + useEffect(() => { + const clear = () => { + props.history.replace(location.pathname); + }; + setTimeout(clear, 10000); + return clear; + }, [station]); + return ( {dragging && } @@ -117,6 +126,7 @@ export function ChatResource(props: ChatResourceProps) { hideNicknames={props.hideNicknames} hideAvatars={props.hideAvatars} location={props.location} + scrollTo={scrollTo ? parseInt(scrollTo, 10) : undefined} /> ( interface ChatMessageProps { measure(element): void; msg: Envelope | IMessage; - previousMsg: Envelope | IMessage | undefined; - nextMsg: Envelope | IMessage | undefined; + previousMsg?: Envelope | IMessage; + nextMsg?: Envelope | IMessage; isLastRead: boolean; group: Group; association: Association; @@ -48,6 +48,7 @@ interface ChatMessageProps { unreadMarkerRef: React.RefObject; history: any; api: any; + highlighted?: boolean; } export default class ChatMessage extends Component { @@ -84,7 +85,8 @@ export default class ChatMessage extends Component { isLastMessage, unreadMarkerRef, history, - api + api, + highlighted } = this.props; const renderSigil = Boolean((nextMsg && msg.author !== nextMsg.author) || !nextMsg || msg.number === 1); @@ -115,7 +117,8 @@ export default class ChatMessage extends Component { isPending, history, api, - scrollWindow + scrollWindow, + highlighted }; const unreadContainerStyle = { @@ -124,6 +127,7 @@ export default class ChatMessage extends Component { return ( { + isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + render() { const { msg, @@ -176,8 +182,8 @@ export class MessageWithSigil extends PureComponent { hideAvatars, remoteContentPolicy, measure, - history, api, + history, scrollWindow } = this.props; @@ -185,8 +191,8 @@ export class MessageWithSigil extends PureComponent { const contact = msg.author in contacts ? contacts[msg.author] : false; const showNickname = !hideNicknames && contact && contact.nickname; const name = showNickname ? contact.nickname : cite(msg.author); - const color = contact ? `#${uxToHex(contact.color)}` : '#000000'; - const sigilClass = contact ? '' : 'mix-blend-diff'; + const color = contact ? `#${uxToHex(contact.color)}` : this.isDark ? '#000000' :'#FFFFFF' + const sigilClass = contact ? '' : this.isDark ? 'mix-blend-diff' : 'mix-blend-darken'; let nameSpan = null; @@ -213,7 +219,7 @@ export class MessageWithSigil extends PureComponent { scrollWindow={scrollWindow} history={history} api={api} - className="fl pr3 v-top bg-white bg-gray0-d pt1" + className="fl pr3 v-top pt1" /> { {timestamp} {datestamp} - + ); diff --git a/pkg/interface/src/views/apps/chat/components/ChatWindow.tsx b/pkg/interface/src/views/apps/chat/components/ChatWindow.tsx index 31b0ab6d0..f7a6e4ff4 100644 --- a/pkg/interface/src/views/apps/chat/components/ChatWindow.tsx +++ b/pkg/interface/src/views/apps/chat/components/ChatWindow.tsx @@ -43,6 +43,7 @@ type ChatWindowProps = RouteComponentProps<{ hideNicknames: boolean; hideAvatars: boolean; remoteContentPolicy: LocalUpdateRemoteContentPolicy; + scrollTo?: number; } interface ChatWindowState { @@ -84,6 +85,10 @@ export default class ChatWindow extends Component { + if(this.props.scrollTo) { + this.scrollToUnread(); + } + this.setState({ initialized: true }); }, this.INITIALIZATION_MAX_TIME); } @@ -167,8 +172,9 @@ export default class ChatWindow extends Component { + if (props.archived) { + return; + } + + const func = read ? "unread" : "read"; + return api.hark[func](timebox, { chat: index }); + }, [api, timebox, index, read]); + + return ( + +
+ + {_.map(_.take(contents, 5), (content, idx) => { + const to = `/~landscape${groupPath}/resource/chat${appPath}?msg=${content.number}`; + return ( + + {}} + msg={content} + isLastRead={false} + group={group} + contacts={groupContacts} + /> + + ); + })} + {contents.length > 5 && ( + + and {contents.length - 5} other message + {contents.length > 6 ? "s" : ""} + + )} + + + ); +} diff --git a/pkg/interface/src/views/apps/notifications/graph.tsx b/pkg/interface/src/views/apps/notifications/graph.tsx new file mode 100644 index 000000000..e7f68ef9c --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/graph.tsx @@ -0,0 +1,231 @@ +import React, { ReactNode, useCallback } from "react"; +import moment from "moment"; +import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react"; +import { Link } from "react-router-dom"; +import _ from "lodash"; +import { + Post, + GraphNotifIndex, + GraphNotificationContents, + Associations, + Content, + Rolodex, +} from "~/types"; +import { Header } from "./header"; +import { cite, deSig, pluralize } from "~/logic/lib/util"; +import { Sigil } from "~/logic/lib/sigil"; +import RichText from "~/views/components/RichText"; +import GlobalApi from "~/logic/api/global"; +import ReactMarkdown from "react-markdown"; +import { getSnippet } from "~/logic/lib/publish"; +import styled from "styled-components"; + +function getGraphModuleIcon(module: string) { + if (module === "link") { + return "Links"; + } + return _.capitalize(module); +} + +const FilterBox = styled(Box)` + background: linear-gradient( + ${(p) => p.theme.colors.scales.white10} 0%, + ${(p) => p.theme.colors.scales.white60} 40%, + ${(p) => p.theme.colors.scales.white100} 100% + ); +`; + +function describeNotification(description: string, plural: boolean) { + switch (description) { + case "link": + return `added ${pluralize("new link", plural)} to`; + case "comment": + return `left ${pluralize("comment", plural)} on`; + case "note": + return `posted ${pluralize("note", plural)} to`; + case "mention": + return "mentioned you on"; + default: + return description; + } +} + +const GraphUrl = ({ url, title }) => ( + + + + {title} + + +); + +const GraphNodeContent = ({ contents, mod, description, index }) => { + const idx = index.slice(1).split("/"); + if (mod === "link") { + if (idx.length === 1) { + const [{ text }, { url }] = contents; + return ; + } else if (idx.length === 2) { + const [{ text }] = contents; + return {text}; + } + return null; + } + if (mod === "publish") { + if (idx.length !== 3) { + return null; + } else if (idx[1] === "2") { + const [{ text }] = contents; + return {text}; + } else if (idx[1] === "1") { + const [{ text: header }, { text: body }] = contents; + const snippet = getSnippet(body); + return ( + + + {header} + + + {snippet} + + + + ); + } + } + return null; +}; + +function getNodeUrl(mod: string, group: string, graph: string, index: string) { + const graphUrl = `/~landscape${group}/resource/${mod}${graph}`; + const idx = index.slice(1).split("/"); + if (mod === "publish") { + const [noteId] = idx; + return `${graphUrl}/note/${noteId}`; + } else if (mod === "link") { + const [linkId] = idx; + return `${graphUrl}/-${linkId}`; + } + return ""; +} +const GraphNode = ({ + contents, + author, + mod, + description, + time, + index, + graph, + group, +}) => { + author = deSig(author); + + const img = ( + + ); + + const nodeUrl = getNodeUrl(mod, group, graph, index); + + return ( + + + {img} + + + + {cite(author)} + + + {moment(time).format("HH:mm")} + + + + + + + + + ); +}; + +export function GraphNotification(props: { + index: GraphNotifIndex; + contents: GraphNotificationContents; + archived: boolean; + read: boolean; + time: number; + timebox: BigInteger; + associations: Associations; + contacts: Rolodex; + api: GlobalApi; +}) { + const { contents, index, read, time, api, timebox } = props; + + const authors = _.map(contents, "author"); + const { graph, group } = index; + const icon = getGraphModuleIcon(index.module); + const desc = describeNotification(index.description, contents.length !== 1); + + const onClick = useCallback(() => { + if (props.archived) { + return; + } + + const func = read ? "unread" : "read"; + return api.hark[func](timebox, { graph: index }); + }, [api, timebox, index, read]); + + return ( + +
+ + {_.map(contents, (content, idx) => ( + + ))} + + + ); +} diff --git a/pkg/interface/src/views/apps/notifications/group.tsx b/pkg/interface/src/views/apps/notifications/group.tsx new file mode 100644 index 000000000..2b5a66f8d --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/group.tsx @@ -0,0 +1,90 @@ +import React, { ReactNode, useCallback } from "react"; +import moment from "moment"; +import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react"; +import _ from "lodash"; +import { NotificationProps } from "./types"; +import { + Post, + GraphNotifIndex, + GraphNotificationContents, + Associations, + Content, + IndexedNotification, + GroupNotificationContents, + GroupNotifIndex, + GroupUpdate, + Rolodex, +} from "~/types"; +import { Header } from "./header"; +import { cite, deSig } from "~/logic/lib/util"; +import { Sigil } from "~/logic/lib/sigil"; +import RichText from "~/views/components/RichText"; +import GlobalApi from "~/logic/api/global"; +import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction"; + + +function describeNotification(description: string, plural: boolean) { + switch (description) { + case "add-members": + return "joined"; + case "remove-members": + return "left"; + default: + return description; + } +} + +function getGroupUpdateParticipants(update: GroupUpdate) { + if ("addMembers" in update) { + return update.addMembers.ships; + } + if ("removeMembers" in update) { + return update.removeMembers.ships; + } + return []; +} + +interface GroupNotificationProps { + index: GroupNotifIndex; + contents: GroupNotificationContents; + archived: boolean; + read: boolean; + time: number; + timebox: BigInteger; + associations: Associations; + contacts: Rolodex; + api: GlobalApi; +} + +export function GroupNotification(props: GroupNotificationProps) { + const { contents, index, read, time, api, timebox, associations } = props; + + const authors = _.flatten(_.map(contents, getGroupUpdateParticipants)); + + const { group } = index; + const desc = describeNotification(index.description, contents.length !== 1); + + const onClick = useCallback(() => { + if (props.archived) { + return; + } + const func = read ? "unread" : "read"; + return api.hark[func](timebox, { group: index }); + }, [api, timebox, index, read]); + + return ( + +
+ + ); +} + diff --git a/pkg/interface/src/views/apps/notifications/header.tsx b/pkg/interface/src/views/apps/notifications/header.tsx new file mode 100644 index 000000000..36964b208 --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/header.tsx @@ -0,0 +1,107 @@ +import React from "react"; +import { Text as NormalText, Row, Icon } from "@tlon/indigo-react"; +import f from "lodash/fp"; +import _ from "lodash"; +import moment from "moment"; +import { PropFunc } from "~/types/util"; +import { getContactDetails } from "~/logic/lib/util"; +import { Associations, Contact, Contacts, Rolodex } from "~/types"; + +const Text = (props: PropFunc) => ( + +); + +const Divider = (props: PropFunc) => ( + + | + +); + +function Author(props: { patp: string; contacts: Contacts; last?: boolean }) { + const contact: Contact | undefined = props.contacts?.[props.patp]; + + const showNickname = !!contact?.nickname; + const name = contact?.nickname || `~${props.patp}`; + + return ( + + {name} + {!props.last && ", "} + + ); +} + +export function Header(props: { + authors: string[]; + archived?: boolean; + channel?: string; + group: string; + contacts: Rolodex; + description: string; + moduleIcon?: string; + time: number; + read: boolean; + associations: Associations; + chat?: boolean; +}) { + const { description, channel, group, moduleIcon, read } = props; + const contacts = props.contacts[group] || {}; + + const authors = _.uniq(props.authors); + + const authorDesc = f.flow( + f.take(3), + f.entries, + f.map(([idx, p]: [string, string]) => { + const lent = Math.min(3, authors.length); + const last = lent - 1 === parseInt(idx, 10); + return ; + }), + (auths) => ( + + {auths} + + {authors.length > 3 && + ` and ${authors.length - 3} other${authors.length === 4 ? "" : "s"}`} + + ) + )(authors); + + const time = moment(props.time).format("HH:mm"); + const groupTitle = + props.associations.contacts?.[props.group]?.metadata?.title || props.group; + + const app = props.chat ? 'chat' : 'graph'; + const channelTitle = + (channel && props.associations?.[app]?.[channel]?.metadata?.title) || + channel; + + return ( + + {!props.archived && ( + + )} + + {authorDesc} + + {description} + {!!moduleIcon && } + {!!channel && {channelTitle}} + + | + + {groupTitle} + + | + + + {time} + + + ); +} diff --git a/pkg/interface/src/views/apps/notifications/inbox.tsx b/pkg/interface/src/views/apps/notifications/inbox.tsx new file mode 100644 index 000000000..817330a47 --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/inbox.tsx @@ -0,0 +1,165 @@ +import React, { useEffect } from "react"; +import f from "lodash/fp"; +import _ from "lodash"; +import { Icon, Col, Row, Box, Text, Anchor } from "@tlon/indigo-react"; +import moment from "moment"; +import { Notifications, Rolodex, Timebox, IndexedNotification } from "~/types"; +import { MOMENT_CALENDAR_DATE, daToUnix } from "~/logic/lib/util"; +import { BigInteger } from "big-integer"; +import GlobalApi from "~/logic/api/global"; +import { Notification } from "./notification"; +import { Associations } from "~/types"; + +type DatedTimebox = [BigInteger, Timebox]; + +function filterNotification(associations: Associations, groups: string[]) { + if (groups.length === 0) { + return () => true; + } + return (n: IndexedNotification) => { + if ("graph" in n.index) { + const { group } = n.index.graph; + return groups.findIndex((g) => group === g) !== -1; + } else if ("group" in n.index) { + const { group } = n.index.group; + return groups.findIndex((g) => group === g) !== -1; + } else if ("chat" in n.index) { + const group = associations.chat[n.index.chat]?.["group-path"]; + return groups.findIndex((g) => group === g) !== -1; + } + return true; + }; +} + +export default function Inbox(props: { + notifications: Notifications; + archive: Notifications; + showArchive?: boolean; + api: GlobalApi; + associations: Associations; + contacts: Rolodex; + filter: string[]; +}) { + const { api, associations } = props; + useEffect(() => { + let seen = false; + setTimeout(() => { + seen = true; + }, 3000); + return () => { + if (seen) { + api.hark.seen(); + } + }; + }, []); + + const [newNotifications, ...notifications] = + Array.from(props.showArchive ? props.archive : props.notifications) || []; + + const notificationsByDay = f.flow( + f.map(([date, nots]) => [ + date, + nots.filter(filterNotification(associations, props.filter)), + ]), + f.groupBy(([date]) => + moment(daToUnix(date)).format("DDMMYYYY") + ), + f.values + )(notifications); + + return ( + + {newNotifications && ( + + )} + + {_.map( + notificationsByDay, + (timeboxes, idx) => + timeboxes.length > 0 && ( + + ) + )} + + ); +} + +function sortTimeboxes([a]: DatedTimebox, [b]: DatedTimebox) { + return b.subtract(a); +} + +function sortIndexedNotification( + { notification: a }: IndexedNotification, + { notification: b }: IndexedNotification +) { + return b.time - a.time; +} + +function DaySection({ + contacts, + archive, + timeboxes, + latest = false, + associations, + api, + groupConfig, + graphConfig, + chatConfig, +}) { + const calendar = latest + ? MOMENT_CALENDAR_DATE + : { ...MOMENT_CALENDAR_DATE, sameDay: "[Earlier Today]" }; + if (timeboxes.length === 0) { + return null; + } + + return ( + <> + + + {moment(daToUnix(timeboxes[0][0])).calendar(null, calendar)} + + + {_.map(timeboxes.sort(sortTimeboxes), ([date, nots], i) => + _.map(nots.sort(sortIndexedNotification), (not, j: number) => ( + + {(i !== 0 || j !== 0) && ( + + )} + + + )) + )} + + ); +} diff --git a/pkg/interface/src/views/apps/notifications/metadata.tsx b/pkg/interface/src/views/apps/notifications/metadata.tsx new file mode 100644 index 000000000..b5643f359 --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/metadata.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Box } from "@tlon/indigo-react"; + +import { MetadataBody, NotificationProps } from "./types"; +import { Header } from "./header"; + +function getInvolvedUsers(body: MetadataBody) { + return []; +} + +function getDescription(body: MetadataBody) { + const b = body.metadata; + if ("new" in b) { + return "created"; + } else if ("changedTitle" in b) { + return "changed the title to"; + } else if ("changedDescription" in b) { + return "changed the description to"; + } else if ("changedColor" in b) { + return "changed the color to"; + } else if ("deleted" in b) { + return "deleted"; + } else { + throw new Error("bad metadata frond"); + } +} + +export function MetadataNotification(props: NotificationProps<"metadata">) { + const { unread } = props; + const description = getDescription(unread.unreads[0].body); + + return ( + +
+ + ); +} diff --git a/pkg/interface/src/views/apps/notifications/notification.tsx b/pkg/interface/src/views/apps/notifications/notification.tsx new file mode 100644 index 000000000..e15de4c3e --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/notification.tsx @@ -0,0 +1,175 @@ +import React, { ReactNode, useCallback, useMemo } from "react"; +import { Row, Box, Col, Text, Anchor, Icon, Action } from "@tlon/indigo-react"; +import _ from "lodash"; +import { + GraphNotificationContents, + IndexedNotification, + GroupNotificationContents, + NotificationGraphConfig, + GroupNotificationsConfig, + NotifIndex, + Associations, +} from "~/types"; +import GlobalApi from "~/logic/api/global"; +import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction"; +import { GroupNotification } from "./group"; +import { GraphNotification } from "./graph"; +import { ChatNotification } from "./chat"; +import { BigInteger } from "big-integer"; + +interface NotificationProps { + notification: IndexedNotification; + time: BigInteger; + associations: Associations; + api: GlobalApi; + archived: boolean; + graphConfig: NotificationGraphConfig; + groupConfig: GroupNotificationsConfig; + chatConfig: string[]; +} + +function getMuted( + idx: NotifIndex, + groups: GroupNotificationsConfig, + graphs: NotificationGraphConfig, + chat: string[] +) { + if ("graph" in idx) { + const { graph } = idx.graph; + return _.findIndex(graphs?.watching || [], (g) => g === graph) === -1; + } + if ("group" in idx) { + return _.findIndex(groups || [], (g) => g === idx.group.group) === -1; + } + if ("chat" in idx) { + return _.findIndex(chat || [], (c) => c === idx.chat) === -1; + } + return false; +} + +function NotificationWrapper(props: { + api: GlobalApi; + time: BigInteger; + notif: IndexedNotification; + children: ReactNode; + archived: boolean; + graphConfig: NotificationGraphConfig; + groupConfig: GroupNotificationsConfig; + chatConfig: string[]; +}) { + const { api, time, notif, children } = props; + + const onArchive = useCallback(async () => { + return api.hark.archive(time, notif.index); + }, [time, notif]); + + const isMuted = getMuted( + notif.index, + props.groupConfig, + props.graphConfig, + props.chatConfig + ); + + const onChangeMute = useCallback(async () => { + const func = isMuted ? "unmute" : "mute"; + return api.hark[func](notif.index); + }, [notif, api, isMuted]); + + const changeMuteDesc = isMuted ? "Unmute" : "Mute"; + return ( + + {children} + + + {changeMuteDesc} + + {!props.archived && ( + + Archive + + )} + + + ); +} + +export function Notification(props: NotificationProps) { + const { notification, associations, archived } = props; + const { read, contents, time } = notification.notification; + + const Wrapper = ({ children }) => ( + + {children} + + ); + + if ("graph" in notification.index) { + const index = notification.index.graph; + const c: GraphNotificationContents = (contents as any).graph; + + return ( + + + + ); + } + if ("group" in notification.index) { + const index = notification.index.group; + const c: GroupNotificationContents = (contents as any).group; + return ( + + + + ); + } + if ("chat" in notification.index) { + const index = notification.index.chat; + const c: ChatNotificationContents = (contents as any).chat; + return ( + + + + ); + } + + return null; +} diff --git a/pkg/interface/src/views/apps/notifications/notifications.tsx b/pkg/interface/src/views/apps/notifications/notifications.tsx new file mode 100644 index 000000000..688193968 --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/notifications.tsx @@ -0,0 +1,141 @@ +import React, { useCallback, useState } from "react"; +import _ from 'lodash'; +import { Box, Col, Text, Row } from "@tlon/indigo-react"; +import { Link, Switch, Route } from "react-router-dom"; + +import { Body } from "~/views/components/Body"; +import { PropFunc } from "~/types/util"; +import Inbox from "./inbox"; +import NotificationPreferences from "./preferences"; +import { Dropdown } from "~/views/components/Dropdown"; +import { Formik } from "formik"; +import { FormikOnBlur } from "~/views/components/FormikOnBlur"; +import GroupSearch from "~/views/components/GroupSearch"; + +const baseUrl = "/~notifications"; + +const HeaderLink = ( + props: PropFunc & { view?: string; current: string } +) => { + const { current, view, ...textProps } = props; + const to = view ? `${baseUrl}/${view}` : baseUrl; + const active = view ? current === view : !current; + + return ( + + + + ); +}; + +interface NotificationFilter { + groups: string[]; +} + +export default function NotificationsScreen(props: any) { + const relativePath = (p: string) => baseUrl + p; + + const [filter, setFilter] = useState({ groups: [] }); + const onSubmit = async (values: { groups: string }) => { + setFilter({ groups: values.groups ? [values.groups] : [] }); + }; + const groupFilterDesc = + filter.groups.length === 0 + ? "All" + : filter.groups + .map((g) => props.associations?.contacts?.[g]?.metadata?.title) + .join(", "); + return ( + + { + const { view } = routeProps.match.params; + return ( + + + + Updates + + + + Inbox + + + + + Archive + + + + + Preferences + + + + + + + + + } + > + + + Filter: + + {groupFilterDesc} + + + + {view === "archive" && ( + + )} + {view === "preferences" && ( + + )} + {!view && } + + + ); + }} + /> + + ); +} diff --git a/pkg/interface/src/views/apps/notifications/preferences.tsx b/pkg/interface/src/views/apps/notifications/preferences.tsx new file mode 100644 index 000000000..f18956372 --- /dev/null +++ b/pkg/interface/src/views/apps/notifications/preferences.tsx @@ -0,0 +1,90 @@ +import React, { useCallback } from "react"; + +import { Box, Col, ManagedCheckboxField as Checkbox } from "@tlon/indigo-react"; +import { Formik, Form, FormikHelpers } from "formik"; +import * as Yup from "yup"; +import _ from "lodash"; +import { AsyncButton } from "~/views/components/AsyncButton"; +import { FormikOnBlur } from "~/views/components/FormikOnBlur"; +import { NotificationGraphConfig } from "~/types"; +import GlobalApi from "~/logic/api/global"; + +interface FormSchema { + mentions: boolean; + dnd: boolean; + watchOnSelf: boolean; + watching: string[]; +} + +interface NotificationPreferencesProps { + graphConfig: NotificationGraphConfig; + dnd: boolean; + api: GlobalApi; +} + +export default function NotificationPreferences( + props: NotificationPreferencesProps +) { + const { graphConfig, api, dnd } = props; + + const initialValues: FormSchema = { + mentions: graphConfig.mentions, + watchOnSelf: graphConfig.watchOnSelf, + dnd, + watching: graphConfig.watching, + }; + + const onSubmit = useCallback( + async (values: FormSchema, actions: FormikHelpers) => { + console.log(values); + try { + let promises: Promise[] = []; + if (values.mentions !== graphConfig.mentions) { + promises.push(api.hark.setMentions(values.mentions)); + } + if (values.watchOnSelf !== graphConfig.watchOnSelf) { + promises.push(api.hark.setWatchOnSelf(values.watchOnSelf)); + } + if (values.dnd !== dnd && !_.isUndefined(values.dnd)) { + promises.push(api.hark.setDoNotDisturb(values.dnd)) + } + + await Promise.all(promises); + actions.setStatus({ success: null }); + actions.resetForm({ values: initialValues }); + } catch (e) { + console.error(e); + actions.setStatus({ error: e.message }); + } + }, + [api, graphConfig] + ); + + return ( + +
+ + + + + + + +
+ ); +} diff --git a/pkg/interface/src/views/components/CommentItem.tsx b/pkg/interface/src/views/components/CommentItem.tsx index bed0901f2..4186848e1 100644 --- a/pkg/interface/src/views/components/CommentItem.tsx +++ b/pkg/interface/src/views/components/CommentItem.tsx @@ -1,5 +1,4 @@ -import React, { useState } from 'react'; -import { Comment, NoteId } from '~/types/publish-update'; +import React from 'react'; import { Contacts } from '~/types/contact-update'; import GlobalApi from '~/logic/api/global'; import { Box, Row } from '@tlon/indigo-react'; @@ -7,8 +6,8 @@ import styled from 'styled-components'; import { Author } from '~/views/apps/publish/components/Author'; import { GraphNode, TextContent } from '~/types/graph-update'; import tokenizeMessage from '~/logic/lib/tokenizeMessage'; -import RichText from '~/views/components/RichText'; import { LocalUpdateRemoteContentPolicy } from '~/types'; +import { MentionText } from '~/views/components/MentionText'; const ClickBox = styled(Box)` cursor: pointer; @@ -30,9 +29,7 @@ interface CommentItemProps { export function CommentItem(props: CommentItemProps) { const { ship, contacts, name, api, remoteContentPolicy } = props; const commentData = props.comment?.post; - const comment = commentData.contents[0] as TextContent; - - const content = tokenizeMessage(comment.text).flat().join(' '); + const comment = commentData.contents; const disabled = props.pending || window.ship !== commentData.author; @@ -61,7 +58,11 @@ export function CommentItem(props: CommentItemProps) { - {content} + ); diff --git a/pkg/interface/src/views/components/Comments.tsx b/pkg/interface/src/views/components/Comments.tsx index ecba34ff1..2ed61075c 100644 --- a/pkg/interface/src/views/components/Comments.tsx +++ b/pkg/interface/src/views/components/Comments.tsx @@ -1,13 +1,14 @@ import React from 'react'; import { Col } from '@tlon/indigo-react'; -import { CommentItem } from '~/views/components/CommentItem'; -import CommentInput from '~/views/components/CommentInput'; +import { CommentItem } from './CommentItem'; +import CommentInput from './CommentInput'; import { Contacts } from '~/types/contact-update'; import GlobalApi from '~/logic/api/global'; import { FormikHelpers } from 'formik'; import { GraphNode } from '~/types/graph-update'; import { createPost } from '~/logic/api/graph'; import { LocalUpdateRemoteContentPolicy } from '~/types'; +import { scanForMentions } from '~/logic/lib/graph'; interface CommentsProps { comments: GraphNode; @@ -28,7 +29,8 @@ export function Comments(props: CommentsProps) { actions: FormikHelpers<{ comment: string }> ) => { try { - const post = createPost([{ text: comment }], comments?.post?.index); + const content = scanForMentions(comment); + const post = createPost(content, comments?.post?.index); await api.graph.addPost(ship, name, post); actions.resetForm(); actions.setStatus({ success: null }); @@ -44,7 +46,7 @@ export function Comments(props: CommentsProps) { {Array.from(comments.children).reverse().map(([idx, comment]) => ( { - formikBag.resetForm({ values }); + formikBag.resetForm({ values, touched: {} }); }); } }, [ diff --git a/pkg/interface/src/views/components/GroupSearch.tsx b/pkg/interface/src/views/components/GroupSearch.tsx index edc787f60..1167b4d9d 100644 --- a/pkg/interface/src/views/components/GroupSearch.tsx +++ b/pkg/interface/src/views/components/GroupSearch.tsx @@ -79,7 +79,7 @@ export function GroupSearch(props: InviteSearchProps) { : Object.values(props.associations?.contacts || {}); }, [props.associations?.contacts]); - const [{ value }, meta, { setValue }] = useField(props.id); + const [{ value }, meta, { setValue, setTouched }] = useField(props.id); const { title: groupTitle } = props.associations.contacts?.[value]?.metadata || {}; @@ -87,12 +87,14 @@ export function GroupSearch(props: InviteSearchProps) { const onSelect = useCallback( (a: Association) => { setValue(a["group-path"]); + setTouched(true); }, [setValue] ); const onUnselect = useCallback(() => { setValue(undefined); + setTouched(true); }, [setValue]); return ( diff --git a/pkg/interface/src/views/components/MentionText.tsx b/pkg/interface/src/views/components/MentionText.tsx new file mode 100644 index 000000000..76ffbc29b --- /dev/null +++ b/pkg/interface/src/views/components/MentionText.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import _ from "lodash"; +import { Text } from "@tlon/indigo-react"; +import { Contacts, Content, LocalUpdateRemoteContentPolicy } from "~/types"; +import RichText from "~/views/components/RichText"; +import { cite } from "~/logic/lib/util"; + +interface MentionTextProps { + contacts: Contacts; + content: Content[]; + remoteContentPolicy: LocalUpdateRemoteContentPolicy; +} +export function MentionText(props: MentionTextProps) { + const { content, contacts } = props; + + return ( + <> + {_.map(content, (c, idx) => { + if ("text" in c) { + return ( + + {c.text} + + ); + } else if ("mention" in c) { + return ( + + ); + } + return null; + })} + + ); +} + +function Mention(props: { ship: string; contacts: Contacts }) { + const { contacts, ship } = props; + const contact = contacts[ship]; + const showNickname = !!contact?.nickname; + const name = showNickname ? contact?.nickname : cite(ship); + + return ( + + {name} + + ); +} diff --git a/pkg/interface/src/views/components/RichText.js b/pkg/interface/src/views/components/RichText.js index 27b5a1d4f..b77347979 100644 --- a/pkg/interface/src/views/components/RichText.js +++ b/pkg/interface/src/views/components/RichText.js @@ -30,7 +30,7 @@ const RichText = React.memo(({ remoteContentPolicy, ...props }) => ( return {props.children}; }, paragraph: (paraProps) => { - return {paraProps.children}; + return {paraProps.children}; } }} plugins={[[ diff --git a/pkg/interface/src/views/components/StatusBar.js b/pkg/interface/src/views/components/StatusBar.js index 931e7a341..6f10df868 100644 --- a/pkg/interface/src/views/components/StatusBar.js +++ b/pkg/interface/src/views/components/StatusBar.js @@ -28,6 +28,11 @@ const StatusBar = (props) => { props.api.local.setOmnibox()}> + { !props.doNotDisturb && props.notificationsCount > 0 && + ( + + + )} Leap @@ -42,7 +47,15 @@ const StatusBar = (props) => { /> - + {!props.doNotDisturb && ( props.history.push('/~notifications')}> + 0 ? "500" : "400"} + fontSize='0' + color="blue" + > + {(props.notificationsCount > 99) ? "99+" : props.notificationsCount} + + )} props.history.push('/~profile')}> ~{props.ship} diff --git a/pkg/interface/src/views/components/leap/Omnibox.js b/pkg/interface/src/views/components/leap/Omnibox.js index 4a42c4ca3..23199ff64 100644 --- a/pkg/interface/src/views/components/leap/Omnibox.js +++ b/pkg/interface/src/views/components/leap/Omnibox.js @@ -117,7 +117,7 @@ export class Omnibox extends Component { const { props } = this; this.setState({ results: this.initialResults(), query: '' }, () => { props.api.local.setOmnibox(); - if (defaultApps.includes(app.toLowerCase()) || app === 'profile' || app === 'Links' || app === 'home') { + if (defaultApps.includes(app.toLowerCase()) || app === 'profile' || app === 'Links' || app === 'home' || app === 'inbox') { props.history.push(link); } else { window.location.href = link; diff --git a/pkg/interface/src/views/components/leap/OmniboxResult.js b/pkg/interface/src/views/components/leap/OmniboxResult.js index 6bc2079b7..5c8e419f6 100644 --- a/pkg/interface/src/views/components/leap/OmniboxResult.js +++ b/pkg/interface/src/views/components/leap/OmniboxResult.js @@ -30,17 +30,20 @@ export class OmniboxResult extends Component { const sigilFill = (this.state.hovered || (selected === link)) ? '#3a8ff7' : '#ffffff'; let graphic =
; - if (defaultApps.includes(icon.toLowerCase()) || icon.toLowerCase() === 'links') { + if (defaultApps.includes(icon.toLowerCase()) || icon.toLowerCase() === 'links' || icon === 'inbox') { + icon = (icon === 'inbox') ? 'Inbox' : icon; icon = (icon === 'Link') ? 'Links' : icon; graphic = ; } else if (icon === 'logout') { graphic = ; } else if (icon === 'profile') { - graphic = ; + graphic = ; } else if (icon === 'home') { graphic = ; + } else if (icon === 'notifications') { + graphic = ; } else { - graphic = ; + graphic = ; } return graphic; diff --git a/pkg/interface/src/views/landscape/components/ChannelMenu.tsx b/pkg/interface/src/views/landscape/components/ChannelMenu.tsx index 6687f3284..786619d69 100644 --- a/pkg/interface/src/views/landscape/components/ChannelMenu.tsx +++ b/pkg/interface/src/views/landscape/components/ChannelMenu.tsx @@ -3,8 +3,10 @@ import { Link, useHistory } from "react-router-dom"; import { Icon, Row, Col, Button, Text, Box, Action } from "@tlon/indigo-react"; import { Dropdown } from "~/views/components/Dropdown"; -import { Association } from "~/types"; +import { Association, NotificationGraphConfig } from "~/types"; import GlobalApi from "~/logic/api/global"; +import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction"; +import { appIsGraph } from "~/logic/lib/util"; const ChannelMenuItem = ({ icon, @@ -27,6 +29,8 @@ const ChannelMenuItem = ({ interface ChannelMenuProps { association: Association; api: GlobalApi; + graphNotificationConfig: NotificationGraphConfig; + chatNotificationConfig: string[]; } export function ChannelMenu(props: ChannelMenuProps) { @@ -34,8 +38,9 @@ export function ChannelMenu(props: ChannelMenuProps) { const history = useHistory(); const { metadata } = association; const app = metadata.module || association["app-name"]; - const workspace = history.location.pathname.startsWith('/~landscape/home') - ? '/home' : association?.['group-path']; + const workspace = history.location.pathname.startsWith("/~landscape/home") + ? "/home" + : association?.["group-path"]; const baseUrl = `/~landscape${workspace}/resource/${app}${association["app-path"]}`; const appPath = association["app-path"]; @@ -44,6 +49,22 @@ export function ChannelMenu(props: ChannelMenuProps) { : appPath.split("/"); const isOurs = ship.slice(1) === window.ship; + + const isMuted = appIsGraph(app) + ? props.graphNotificationConfig.watching.findIndex((a) => a === appPath) === + -1 + : props.chatNotificationConfig.findIndex((a) => a === appPath) === -1; + const onChangeMute = async () => { + const func = + association["app-name"] === "chat" + ? isMuted + ? "listenChat" + : "ignoreChat" + : isMuted + ? "listenGraph" + : "ignoreGraph"; + await api.hark[func](appPath); + }; const onUnsubscribe = useCallback(async () => { const app = metadata.module || association["app-name"]; switch (app) { @@ -83,15 +104,35 @@ export function ChannelMenu(props: ChannelMenuProps) { return ( + + + + {isMuted ? "Unmute" : "Mute"} this channel + + {isOurs ? ( <> - + Delete Channel - + Channel Settings diff --git a/pkg/interface/src/views/landscape/components/Content.js b/pkg/interface/src/views/landscape/components/Content.js index ba3f6e041..94e6f0dad 100644 --- a/pkg/interface/src/views/landscape/components/Content.js +++ b/pkg/interface/src/views/landscape/components/Content.js @@ -8,6 +8,7 @@ import DojoApp from '~/views/apps/dojo/app'; import Landscape from '~/views/landscape/index'; import Profile from '~/views/apps/profile/profile'; import ErrorComponent from '~/views/components/Error'; +import Notifications from '~/views/apps/notifications/notifications'; export const Container = styled(Box)` @@ -61,6 +62,12 @@ export const Content = (props) => { /> )} /> + ( + + )} + /> ( - ) => { - try { - const { title, description, color, isPrivate } = values; - const uxColor = uxToHex(color); - await props.api.metadata.update(props.association, { - title, - description, - color: uxColor - }); - if (isPrivate !== currentPrivate) { - const resource = resourceFromPath(props.association["group-path"]); - const newPolicy: Enc = isPrivate - ? { invite: { pending: [] } } - : { open: { banRanks: [], banned: [] } }; - const diff = { replace: newPolicy }; - await props.api.groups.changePolicy(resource, diff); - } - - actions.setStatus({ success: null }); - } catch (e) { - console.log(e); - actions.setStatus({ error: e.message }); - } - }; - - const onDelete = async () => { - const name = association['group-path'].split('/').pop(); - if (prompt(`To confirm deleting this group, type ${name}`) === name) { - await props.api.contacts.delete(association["group-path"]); - history.push("/"); - } - }; - - const disabled = - resourceFromPath(association["group-path"]).ship.slice(1) !== window.ship && - roleForShip(group, window.ship) !== "admin"; - - return ( - - -
- - {!disabled ? ( - - - - - Delete this group - - - ) : ( - - - - - Leave this group - - - )} - - - - - - - Save - - - - -
-
- ); -} diff --git a/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx b/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx new file mode 100644 index 000000000..901017f45 --- /dev/null +++ b/pkg/interface/src/views/landscape/components/GroupSettings/Admin.tsx @@ -0,0 +1,134 @@ +import React, { useEffect } from "react"; +import { AsyncButton } from "~/views/components/AsyncButton"; +import * as Yup from "yup"; +import { + Box, + ManagedTextInputField as Input, + ManagedToggleSwitchField as Checkbox, + Col, + Label, + Button, +} from "@tlon/indigo-react"; +import { Formik, Form, useFormikContext, FormikHelpers } from "formik"; +import { FormError } from "~/views/components/FormError"; +import { Group, GroupPolicy } from "~/types/group-update"; +import { Enc } from "~/types/noun"; +import { Association } from "~/types/metadata-update"; +import GlobalApi from "~/logic/api/global"; +import { resourceFromPath, roleForShip } from "~/logic/lib/group"; +import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton"; +import { ColorInput } from "~/views/components/ColorInput"; +import { useHistory } from "react-router-dom"; + +import { uxToHex } from "~/logic/lib/util"; + +interface FormSchema { + title: string; + description: string; + color: string; + isPrivate: boolean; +} + +const formSchema = Yup.object({ + title: Yup.string().required("Group must have a name"), + description: Yup.string(), + color: Yup.string(), + isPrivate: Yup.boolean(), +}); + +interface GroupAdminSettingsProps { + group: Group; + association: Association; + api: GlobalApi; +} + +export function GroupAdminSettings(props: GroupAdminSettingsProps) { + const { group, association } = props; + const { metadata } = association; + const history = useHistory(); + const currentPrivate = "invite" in props.group.policy; + const initialValues: FormSchema = { + title: metadata?.title, + description: metadata?.description, + color: metadata?.color, + isPrivate: currentPrivate, + }; + + const onSubmit = async ( + values: FormSchema, + actions: FormikHelpers + ) => { + try { + const { title, description, color, isPrivate } = values; + const uxColor = uxToHex(color); + await props.api.metadata.update(props.association, { + title, + description, + color: uxColor, + }); + if (isPrivate !== currentPrivate) { + const resource = resourceFromPath(props.association["group-path"]); + const newPolicy: Enc = isPrivate + ? { invite: { pending: [] } } + : { open: { banRanks: [], banned: [] } }; + const diff = { replace: newPolicy }; + await props.api.groups.changePolicy(resource, diff); + } + + actions.setStatus({ success: null }); + } catch (e) { + console.log(e); + actions.setStatus({ error: e.message }); + } + }; + + const disabled = + resourceFromPath(association["group-path"]).ship.slice(1) !== window.ship && + roleForShip(group, window.ship) !== "admin"; + + return ( + +
+ + + + + + + Save + + + + +
+ ); +} diff --git a/pkg/interface/src/views/landscape/components/GroupSettings/GroupSettings.tsx b/pkg/interface/src/views/landscape/components/GroupSettings/GroupSettings.tsx new file mode 100644 index 000000000..9004af0aa --- /dev/null +++ b/pkg/interface/src/views/landscape/components/GroupSettings/GroupSettings.tsx @@ -0,0 +1,44 @@ +import React, { useEffect } from "react"; +import { AsyncButton } from "~/views/components/AsyncButton"; +import * as Yup from "yup"; +import { + Box, + ManagedTextInputField as Input, + ManagedToggleSwitchField as Checkbox, + Col, + Label, + Button, +} from "@tlon/indigo-react"; +import { Formik, Form, useFormikContext, FormikHelpers } from "formik"; +import { FormError } from "~/views/components/FormError"; +import { Group, GroupPolicy } from "~/types/group-update"; +import { Enc } from "~/types/noun"; +import { Association } from "~/types/metadata-update"; +import GlobalApi from "~/logic/api/global"; +import { resourceFromPath, roleForShip } from "~/logic/lib/group"; +import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton"; +import { ColorInput } from "~/views/components/ColorInput"; +import { useHistory } from "react-router-dom"; + +import { uxToHex } from "~/logic/lib/util"; +import { GroupAdminSettings } from "./Admin"; +import { GroupPersonalSettings } from "./Personal"; +import {GroupNotificationsConfig} from "~/types"; + +interface GroupSettingsProps { + group: Group; + association: Association; + api: GlobalApi; + notificationsGroupConfig: GroupNotificationsConfig; +} +export function GroupSettings(props: GroupSettingsProps) { + return ( + + + + + + + + ); +} diff --git a/pkg/interface/src/views/landscape/components/GroupSettings/Personal.tsx b/pkg/interface/src/views/landscape/components/GroupSettings/Personal.tsx new file mode 100644 index 000000000..33be942d7 --- /dev/null +++ b/pkg/interface/src/views/landscape/components/GroupSettings/Personal.tsx @@ -0,0 +1,97 @@ +import React, { useCallback } from "react"; + +import { AsyncButton } from "~/views/components/AsyncButton"; +import * as Yup from "yup"; +import { + Box, + ManagedTextInputField as Input, + ManagedToggleSwitchField as Toggle, + Col, + Label, + Button, +} from "@tlon/indigo-react"; +import { Formik, Form, useFormikContext, FormikHelpers } from "formik"; +import { FormError } from "~/views/components/FormError"; +import { Group, GroupPolicy } from "~/types/group-update"; +import { Enc } from "~/types/noun"; +import { Association } from "~/types/metadata-update"; +import GlobalApi from "~/logic/api/global"; +import { resourceFromPath, roleForShip } from "~/logic/lib/group"; +import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton"; +import { ColorInput } from "~/views/components/ColorInput"; +import { useHistory } from "react-router-dom"; + +import { uxToHex } from "~/logic/lib/util"; +import { FormikOnBlur } from "~/views/components/FormikOnBlur"; +import {GroupNotificationsConfig} from "~/types"; + +function DeleteGroup(props: { + owner: boolean; + api: GlobalApi; + association: Association; +}) { + const history = useHistory(); + const onDelete = async () => { + const name = props.association['group-path'].split('/').pop(); + if (prompt(`To confirm deleting this group, type ${name}`) === name) { + await props.api.contacts.delete(props.association["group-path"]); + history.push("/"); + } + }; + + const action = props.owner ? "Delete" : "Leave"; + const description = props.owner + ? "Permanently delete this group. (All current members will no longer see this group.)" + : "Leave this group. You can rejoin if it is an open group, or if you are reinvited"; + + return ( + + + + + {action} this group + + + ); +} + +interface FormSchema { + watching: boolean; +} + +export function GroupPersonalSettings(props: { + api: GlobalApi; + association: Association; + notificationsGroupConfig: GroupNotificationsConfig; +}) { + + const groupPath = props.association['group-path']; + + const watching = props.notificationsGroupConfig.findIndex(g => g === groupPath) !== -1; + + const initialValues: FormSchema = { + watching + }; + const onSubmit = async (values: FormSchema) => { + if(values.watching === watching) { + return; + } + const func = values.watching ? 'listenGroup' : 'ignoreGroup'; + await props.api.hark[func](groupPath); + }; + + return ( + + + + + + + ); +} diff --git a/pkg/interface/src/views/landscape/components/GroupsPane.tsx b/pkg/interface/src/views/landscape/components/GroupsPane.tsx index 09f4bb7b6..9c65d10a2 100644 --- a/pkg/interface/src/views/landscape/components/GroupsPane.tsx +++ b/pkg/interface/src/views/landscape/components/GroupsPane.tsx @@ -68,6 +68,8 @@ export function GroupsPane(props: GroupsPaneProps) { s3={props.s3} hideAvatars={props.hideAvatars} hideNicknames={props.hideNicknames} + notificationsGroupConfig={props.notificationsGroupConfig} + {...routeProps} baseUrl={baseUrl} />)} diff --git a/pkg/interface/src/views/landscape/components/PopoverRoutes.tsx b/pkg/interface/src/views/landscape/components/PopoverRoutes.tsx index a3e362f83..f04da00d6 100644 --- a/pkg/interface/src/views/landscape/components/PopoverRoutes.tsx +++ b/pkg/interface/src/views/landscape/components/PopoverRoutes.tsx @@ -7,10 +7,10 @@ import { Contacts } from "~/types/contact-update"; import { Group } from "~/types/group-update"; import { Association } from "~/types/metadata-update"; import GlobalApi from "~/logic/api/global"; -import {S3State} from "~/types"; +import {GroupNotificationsConfig, S3State} from "~/types"; import { ContactCard } from "./ContactCard"; -import { GroupSettings } from "./GroupSettings"; +import { GroupSettings } from "./GroupSettings/GroupSettings"; import { Participants } from "./Participants"; @@ -41,6 +41,7 @@ export function PopoverRoutes( api: GlobalApi; hideAvatars: boolean; hideNicknames: boolean; + notificationsGroupConfig: GroupNotificationsConfig; } & RouteComponentProps ) { const relativeUrl = (url: string) => `${props.baseUrl}/popover${url}`; @@ -125,6 +126,7 @@ export function PopoverRoutes( group={props.group} association={props.association} api={props.api} + notificationsGroupConfig={props.notificationsGroupConfig} /> )} {view === "participants" && ( diff --git a/pkg/interface/src/views/landscape/components/Resource.tsx b/pkg/interface/src/views/landscape/components/Resource.tsx index b5fe52756..754f7cc3c 100644 --- a/pkg/interface/src/views/landscape/components/Resource.tsx +++ b/pkg/interface/src/views/landscape/components/Resource.tsx @@ -57,7 +57,13 @@ export function Resource(props: ResourceProps) { ( - + {app === "chat" ? ( ) : app === "publish" ? ( diff --git a/pkg/interface/src/views/landscape/components/ResourceSkeleton.tsx b/pkg/interface/src/views/landscape/components/ResourceSkeleton.tsx index e48438be7..ea594b07f 100644 --- a/pkg/interface/src/views/landscape/components/ResourceSkeleton.tsx +++ b/pkg/interface/src/views/landscape/components/ResourceSkeleton.tsx @@ -6,13 +6,14 @@ import { Link } from "react-router-dom"; import { ChatResource } from "~/views/apps/chat/ChatResource"; import { PublishResource } from "~/views/apps/publish/PublishResource"; -import RichText from '~/views/components/RichText'; +import RichText from "~/views/components/RichText"; import { Association } from "~/types/metadata-update"; import GlobalApi from "~/logic/api/global"; import { RouteComponentProps, Route, Switch } from "react-router-dom"; import { ChannelSettings } from "./ChannelSettings"; import { ChannelMenu } from "./ChannelMenu"; +import { NotificationGraphConfig } from "~/types"; const TruncatedBox = styled(Box)` white-space: nowrap; @@ -22,6 +23,7 @@ const TruncatedBox = styled(Box)` type ResourceSkeletonProps = { association: Association; + notificationsGraphConfig: NotificationGraphConfig; api: GlobalApi; baseUrl: string; children: ReactNode; @@ -33,13 +35,14 @@ export function ResourceSkeleton(props: ResourceSkeletonProps) { const { association, api, baseUrl, children, atRoot } = props; const app = association?.metadata?.module || association["app-name"]; const appPath = association["app-path"]; - const workspace = (baseUrl === '/~landscape/home') ? '/home' : association["group-path"]; + const workspace = + baseUrl === "/~landscape/home" ? "/home" : association["group-path"]; const title = props.title || association?.metadata?.title; const disableRemoteContent = { audioShown: false, imageShown: false, oembedShown: false, - videoShown: false + videoShown: false, }; return ( @@ -64,11 +67,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps) { {"<- Back"} ) : ( - + Go back to channel @@ -78,22 +77,34 @@ export function ResourceSkeleton(props: ResourceSkeletonProps) { {atRoot && ( <> - {title} + + {title} + - - {association?.metadata?.description} + + {association?.metadata?.description} - + )} diff --git a/pkg/interface/src/views/themes/light.ts b/pkg/interface/src/views/themes/light.ts index 7a0a31f6c..bd3ec6837 100644 --- a/pkg/interface/src/views/themes/light.ts +++ b/pkg/interface/src/views/themes/light.ts @@ -20,6 +20,7 @@ const scales = { white80: "rgba(255,255,255,0.8)", white90: "rgba(255,255,255,0.9)", white100: "rgba(255,255,255,1)", + black05: "rgba(0,0,0,0.05)", black10: "rgba(0,0,0,0.1)", black20: "rgba(0,0,0,0.2)", black30: "rgba(0,0,0,0.3)",