mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-12-14 17:41:33 +03:00
Merge pull request #3766 from urbit/lf/hark-redux
hark: notification store
This commit is contained in:
commit
fa71638abd
@ -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
|
||||
|
@ -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)]])
|
||||
|
180
pkg/arvo/app/hark-chat-hook.hoon
Normal file
180
pkg/arvo/app/hark-chat-hook.hoon
Normal file
@ -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
|
||||
--
|
256
pkg/arvo/app/hark-graph-hook.hoon
Normal file
256
pkg/arvo/app/hark-graph-hook.hoon
Normal file
@ -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
|
||||
--
|
||||
|
169
pkg/arvo/app/hark-group-hook.hoon
Normal file
169
pkg/arvo/app/hark-group-hook.hoon
Normal file
@ -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]
|
||||
--
|
313
pkg/arvo/app/hark-store.hoon
Normal file
313
pkg/arvo/app/hark-store.hoon
Normal file
@ -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)
|
||||
==
|
||||
--
|
@ -24,6 +24,6 @@
|
||||
<div id="portal-root"></div>
|
||||
<script src="/~landscape/js/channel.js"></script>
|
||||
<script src="/~landscape/js/session.js"></script>
|
||||
<script src="/~landscape/js/bundle/index.20fe6ee95061ade450fc.js"></script>
|
||||
<script src="/~landscape/js/bundle/index.5f9890df6be59a4ff9c5.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -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]
|
||||
|
30
pkg/arvo/lib/hark/chat-hook.hoon
Normal file
30
pkg/arvo/lib/hark/chat-hook.hoon
Normal file
@ -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)
|
||||
==
|
||||
--
|
||||
--
|
||||
|
61
pkg/arvo/lib/hark/graph-hook.hoon
Normal file
61
pkg/arvo/lib/hark/graph-hook.hoon
Normal file
@ -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)))
|
||||
==
|
||||
--
|
||||
--
|
34
pkg/arvo/lib/hark/group-hook.hoon
Normal file
34
pkg/arvo/lib/hark/group-hook.hoon
Normal file
@ -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)
|
||||
==
|
||||
--
|
||||
--
|
212
pkg/arvo/lib/hark/store.hoon
Normal file
212
pkg/arvo/lib/hark/store.hoon
Normal file
@ -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)
|
||||
==
|
||||
--
|
||||
--
|
||||
--
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
==
|
||||
--
|
||||
|
@ -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
|
||||
|%
|
||||
|
13
pkg/arvo/mar/hark/action.hoon
Normal file
13
pkg/arvo/mar/hark/action.hoon
Normal file
@ -0,0 +1,13 @@
|
||||
/+ *hark-store
|
||||
|_ act=action
|
||||
++ grad %noun
|
||||
++ grow
|
||||
|%
|
||||
++ noun act
|
||||
--
|
||||
++ grab
|
||||
|%
|
||||
++ noun action
|
||||
++ json action:dejs
|
||||
--
|
||||
--
|
13
pkg/arvo/mar/hark/chat-hook-action.hoon
Normal file
13
pkg/arvo/mar/hark/chat-hook-action.hoon
Normal file
@ -0,0 +1,13 @@
|
||||
/+ *hark-chat-hook
|
||||
|_ act=action
|
||||
++ grad %noun
|
||||
++ grow
|
||||
|%
|
||||
++ noun act
|
||||
--
|
||||
++ grab
|
||||
|%
|
||||
++ noun action
|
||||
++ json action:dejs
|
||||
--
|
||||
--
|
16
pkg/arvo/mar/hark/chat-hook-update.hoon
Normal file
16
pkg/arvo/mar/hark/chat-hook-update.hoon
Normal file
@ -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
|
||||
--
|
||||
--
|
13
pkg/arvo/mar/hark/graph-hook-action.hoon
Normal file
13
pkg/arvo/mar/hark/graph-hook-action.hoon
Normal file
@ -0,0 +1,13 @@
|
||||
/+ *hark-graph-hook
|
||||
|_ act=action
|
||||
++ grad %noun
|
||||
++ grow
|
||||
|%
|
||||
++ noun act
|
||||
--
|
||||
++ grab
|
||||
|%
|
||||
++ noun action
|
||||
++ json action:dejs
|
||||
--
|
||||
--
|
17
pkg/arvo/mar/hark/graph-hook-update.hoon
Normal file
17
pkg/arvo/mar/hark/graph-hook-update.hoon
Normal file
@ -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
|
||||
--
|
||||
--
|
13
pkg/arvo/mar/hark/group-hook-action.hoon
Normal file
13
pkg/arvo/mar/hark/group-hook-action.hoon
Normal file
@ -0,0 +1,13 @@
|
||||
/+ *hark-group-hook
|
||||
|_ act=action
|
||||
++ grad %noun
|
||||
++ grow
|
||||
|%
|
||||
++ noun act
|
||||
--
|
||||
++ grab
|
||||
|%
|
||||
++ noun action
|
||||
++ json action:dejs
|
||||
--
|
||||
--
|
16
pkg/arvo/mar/hark/group-hook-update.hoon
Normal file
16
pkg/arvo/mar/hark/group-hook-update.hoon
Normal file
@ -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
|
||||
--
|
||||
--
|
15
pkg/arvo/mar/hark/update.hoon
Normal file
15
pkg/arvo/mar/hark/update.hoon
Normal file
@ -0,0 +1,15 @@
|
||||
/+ *hark-store
|
||||
|_ upd=update
|
||||
++ grad %noun
|
||||
++ grow
|
||||
|%
|
||||
++ noun upd
|
||||
++ json
|
||||
%+ frond:enjs:format 'harkUpdate'
|
||||
(update:enjs upd)
|
||||
--
|
||||
++ grab
|
||||
|%
|
||||
++ noun update
|
||||
--
|
||||
--
|
15
pkg/arvo/sur/hark-chat-hook.hoon
Normal file
15
pkg/arvo/sur/hark-chat-hook.hoon
Normal file
@ -0,0 +1,15 @@
|
||||
^?
|
||||
|%
|
||||
+$ action
|
||||
$% [?(%listen %ignore) chat=path]
|
||||
[%set-mentions mentions=?]
|
||||
==
|
||||
::
|
||||
+$ update
|
||||
$%
|
||||
action
|
||||
$: %initial
|
||||
watching=(set path)
|
||||
==
|
||||
==
|
||||
--
|
20
pkg/arvo/sur/hark-graph-hook.hoon
Normal file
20
pkg/arvo/sur/hark-graph-hook.hoon
Normal file
@ -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=_&
|
||||
==
|
||||
==
|
||||
--
|
11
pkg/arvo/sur/hark-group-hook.hoon
Normal file
11
pkg/arvo/sur/hark-group-hook.hoon
Normal file
@ -0,0 +1,11 @@
|
||||
/- *resource
|
||||
^?
|
||||
|%
|
||||
+$ action
|
||||
[?(%listen %ignore) group=resource]
|
||||
::
|
||||
+$ update
|
||||
$% action
|
||||
[%initial watching=(set resource)]
|
||||
==
|
||||
--
|
50
pkg/arvo/sur/hark-store.hoon
Normal file
50
pkg/arvo/sur/hark-store.hoon
Normal file
@ -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]
|
||||
==
|
||||
--
|
@ -28,6 +28,7 @@
|
||||
::
|
||||
+$ content
|
||||
$% [%text text=cord]
|
||||
[%mention =ship]
|
||||
[%url url=cord]
|
||||
[%code expression=cord output=(list tank)]
|
||||
[%reference =uid]
|
||||
|
@ -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<StoreState> {
|
||||
chat = new ChatApi(this.ship, this.channel, this.store);
|
||||
@ -22,6 +23,7 @@ export default class GlobalApi extends BaseApi<StoreState> {
|
||||
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,
|
||||
|
@ -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(),
|
||||
|
144
pkg/interface/src/logic/api/hark.ts
Normal file
144
pkg/interface/src/logic/api/hark.ts
Normal file
@ -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<StoreState> {
|
||||
private harkAction(action: any): Promise<any> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
24
pkg/interface/src/logic/lib/graph.ts
Normal file
24
pkg/interface/src/logic/lib/graph.ts
Normal file
@ -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;
|
||||
}
|
@ -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));
|
||||
|
||||
|
@ -25,6 +25,7 @@ export const Sigil = memo(({ classes = '', color, foreground = '', ship, size, s
|
||||
display='inline-block'
|
||||
height={size}
|
||||
width={size}
|
||||
className={classes}
|
||||
/>) : (
|
||||
<Box
|
||||
display='inline-block'
|
||||
|
@ -1,344 +0,0 @@
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import f from 'lodash/fp';
|
||||
import bigInt from 'big-integer';
|
||||
|
||||
export const MOBILE_BROWSER_REGEX = /Android|webOS|iPhone|iPad|iPod|BlackBerry/i;
|
||||
|
||||
const DA_UNIX_EPOCH = bigInt("170141184475152167957503069145530368000"); // `@ud` ~1970.1.1
|
||||
const DA_SECOND = bigInt("18446744073709551616"); // `@ud` ~s1
|
||||
export function daToUnix(da) {
|
||||
// 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) {
|
||||
const timeSinceEpoch = bigInt(unix).multiply(DA_SECOND).divide(bigInt(1000));
|
||||
return DA_UNIX_EPOCH.add(timeSinceEpoch);
|
||||
}
|
||||
|
||||
export function appIsGraph(app) {
|
||||
return app === 'link' || app === 'publish';
|
||||
}
|
||||
|
||||
export function parentPath(path) {
|
||||
return _.dropRight(path.split('/'), 1).join('/');
|
||||
}
|
||||
|
||||
export function clamp(x,min,max) {
|
||||
return Math.max(min, Math.min(max, x));
|
||||
}
|
||||
|
||||
// color is a #000000 color
|
||||
export function adjustHex(color, amount) {
|
||||
const res = f.flow(
|
||||
f.split(''), f.chunk(2), // get individual color channels
|
||||
f.map(c => 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]);
|
||||
}
|
360
pkg/interface/src/logic/lib/util.ts
Normal file
360
pkg/interface/src/logic/lib/util.ts
Normal file
@ -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<void>((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}`;
|
||||
}
|
285
pkg/interface/src/logic/reducers/hark-update.ts
Normal file
285
pkg/interface/src/logic/reducers/hark-update.ts
Normal file
@ -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,
|
||||
})),
|
||||
]);
|
||||
}
|
||||
}
|
@ -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<StoreState> {
|
||||
dark: false,
|
||||
inbox: {},
|
||||
chatSynced: null,
|
||||
notifications: new BigIntOrderedMap<Timebox>(),
|
||||
archivedNotifications: new BigIntOrderedMap<Timebox>(),
|
||||
notificationsGroupConfig: [],
|
||||
notificationsChatConfig: [],
|
||||
notificationsGraphConfig: {
|
||||
watchOnSelf: false,
|
||||
mentions: false,
|
||||
watching: [],
|
||||
},
|
||||
notificationsCount: 0
|
||||
};
|
||||
}
|
||||
|
||||
@ -107,5 +121,6 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
this.launchReducer.reduce(data, this.state);
|
||||
this.connReducer.reduce(data, this.state);
|
||||
GraphReducer(data, this.state);
|
||||
HarkReducer(data, this.state);
|
||||
}
|
||||
}
|
||||
|
@ -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<Path, Envelope[]>;
|
||||
|
||||
notifications: Notifications;
|
||||
notificationsGraphConfig: NotificationGraphConfig;
|
||||
notificationsGroupConfig: GroupNotificationsConfig;
|
||||
notificationsChatConfig: string[];
|
||||
notificationsCount: number,
|
||||
doNotDisturb: boolean;
|
||||
}
|
||||
|
@ -51,6 +51,10 @@ export default class GlobalSubscription extends BaseSubscription<StoreState> {
|
||||
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() {
|
||||
|
@ -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<GraphNode>;
|
||||
|
||||
export type Graphs = { [rid: string]: Graph };
|
||||
|
||||
|
||||
|
63
pkg/interface/src/types/hark-update.ts
Normal file
63
pkg/interface/src/types/hark-update.ts
Normal file
@ -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<Timebox>;
|
||||
|
||||
export interface NotificationGraphConfig {
|
||||
watchOnSelf: boolean;
|
||||
mentions: boolean;
|
||||
watching: string[];
|
||||
}
|
||||
|
||||
export type GroupNotificationsConfig = string[];
|
@ -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';
|
||||
|
@ -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 (
|
||||
<ThemeProvider theme={theme}>
|
||||
<Helmet>
|
||||
@ -143,6 +146,8 @@ class App extends React.Component {
|
||||
connection={this.state.connection}
|
||||
subscription={this.subscription}
|
||||
ship={this.ship}
|
||||
doNotDisturb={doNotDisturb}
|
||||
notificationsCount={notificationsCount}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary>
|
||||
|
@ -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 (
|
||||
<Col {...bind} height="100%" overflow="hidden" position="relative">
|
||||
{dragging && <SubmitDragger />}
|
||||
@ -117,6 +126,7 @@ export function ChatResource(props: ChatResourceProps) {
|
||||
hideNicknames={props.hideNicknames}
|
||||
hideAvatars={props.hideAvatars}
|
||||
location={props.location}
|
||||
scrollTo={scrollTo ? parseInt(scrollTo, 10) : undefined}
|
||||
/>
|
||||
<ChatInput
|
||||
ref={chatInput}
|
||||
|
@ -31,8 +31,8 @@ export const DayBreak = ({ when }) => (
|
||||
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<HTMLDivElement>;
|
||||
history: any;
|
||||
api: any;
|
||||
highlighted?: boolean;
|
||||
}
|
||||
|
||||
export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
@ -84,7 +85,8 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
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<ChatMessageProps> {
|
||||
isPending,
|
||||
history,
|
||||
api,
|
||||
scrollWindow
|
||||
scrollWindow,
|
||||
highlighted
|
||||
};
|
||||
|
||||
const unreadContainerStyle = {
|
||||
@ -124,6 +127,7 @@ export default class ChatMessage extends Component<ChatMessageProps> {
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg={highlighted ? 'washedBlue' : 'white'}
|
||||
width='100%'
|
||||
display='flex'
|
||||
flexWrap='wrap'
|
||||
@ -165,6 +169,8 @@ interface MessageProps {
|
||||
};
|
||||
|
||||
export class MessageWithSigil extends PureComponent<MessageProps> {
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
render() {
|
||||
const {
|
||||
msg,
|
||||
@ -176,8 +182,8 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
|
||||
hideAvatars,
|
||||
remoteContentPolicy,
|
||||
measure,
|
||||
history,
|
||||
api,
|
||||
history,
|
||||
scrollWindow
|
||||
} = this.props;
|
||||
|
||||
@ -185,8 +191,8 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
|
||||
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<MessageProps> {
|
||||
scrollWindow={scrollWindow}
|
||||
history={history}
|
||||
api={api}
|
||||
className="fl pr3 v-top bg-white bg-gray0-d pt1"
|
||||
className="fl pr3 v-top pt1"
|
||||
/>
|
||||
<Box flexGrow='1' display='block' className="clamp-message">
|
||||
<Box
|
||||
@ -239,7 +245,7 @@ export class MessageWithSigil extends PureComponent<MessageProps> {
|
||||
<Text flexShrink='0' gray mono className="v-mid">{timestamp}</Text>
|
||||
<Text gray mono ml={2} className="v-mid child dn-s">{datestamp}</Text>
|
||||
</Box>
|
||||
<Box fontSize='14px'><MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} /></Box>
|
||||
<Box fontSize='14px'><MessageContent content={msg.letter} remoteContentPolicy={remoteContentPolicy} measure={measure} /></Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
@ -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<ChatWindowProps, ChatWindowSta
|
||||
window.addEventListener('focus', this.handleWindowFocus);
|
||||
this.initialFetch();
|
||||
setTimeout(() => {
|
||||
if(this.props.scrollTo) {
|
||||
this.scrollToUnread();
|
||||
}
|
||||
|
||||
this.setState({ initialized: true });
|
||||
}, this.INITIALIZATION_MAX_TIME);
|
||||
}
|
||||
@ -167,8 +172,9 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
|
||||
}
|
||||
|
||||
scrollToUnread() {
|
||||
const { mailboxSize, unreadCount } = this.props;
|
||||
this.virtualList?.scrollToData(mailboxSize - unreadCount);
|
||||
const { mailboxSize, unreadCount, scrollTo } = this.props;
|
||||
const target = scrollTo || (mailboxSize - unreadCount);
|
||||
this.virtualList?.scrollToData(target);
|
||||
}
|
||||
|
||||
dismissUnread() {
|
||||
@ -297,7 +303,8 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
|
||||
const isPending: boolean = 'pending' in msg && Boolean(msg.pending);
|
||||
const isLastMessage: boolean = Boolean(index === lastMessage)
|
||||
const isLastRead: boolean = Boolean(!isLastMessage && index === this.state.lastRead);
|
||||
const props = { measure, scrollWindow, isPending, isLastRead, isLastMessage, msg, ...messageProps };
|
||||
const highlighted = index === this.props.scrollTo;
|
||||
const props = { measure, highlighted, scrollWindow, isPending, isLastRead, isLastMessage, msg, ...messageProps };
|
||||
return (
|
||||
<ChatMessage
|
||||
key={index}
|
||||
|
@ -87,6 +87,10 @@ h2 {
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
|
||||
.mix-blend-darken {
|
||||
mix-blend-mode: darken;
|
||||
}
|
||||
|
||||
.placeholder-inter::placeholder {
|
||||
font-family: "Inter", sans-serif;
|
||||
}
|
||||
|
99
pkg/interface/src/views/apps/notifications/chat.tsx
Normal file
99
pkg/interface/src/views/apps/notifications/chat.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React, { useCallback } from "react";
|
||||
import _ from "lodash";
|
||||
import { Link } from "react-router-dom";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import {
|
||||
Rolodex,
|
||||
Associations,
|
||||
ChatNotifIndex,
|
||||
ChatNotificationContents,
|
||||
Groups,
|
||||
} from "~/types";
|
||||
import { BigInteger } from "big-integer";
|
||||
import { Box, Col } from "@tlon/indigo-react";
|
||||
import { Header } from "./header";
|
||||
import { pluralize } from "~/logic/lib/util";
|
||||
import ChatMessage from "../chat/components/ChatMessage";
|
||||
|
||||
function describeNotification(mention: boolean, lent: number) {
|
||||
const msg = pluralize("message", lent !== 1);
|
||||
if (mention) {
|
||||
return `mentioned you in ${msg} in`;
|
||||
}
|
||||
return `sent ${msg} in`;
|
||||
}
|
||||
|
||||
export function ChatNotification(props: {
|
||||
index: ChatNotifIndex;
|
||||
contents: ChatNotificationContents;
|
||||
archived: boolean;
|
||||
read: boolean;
|
||||
time: number;
|
||||
timebox: BigInteger;
|
||||
associations: Associations;
|
||||
contacts: Rolodex;
|
||||
groups: Groups;
|
||||
api: GlobalApi;
|
||||
}) {
|
||||
const { index, contents, read, time, api, timebox } = props;
|
||||
const authors = _.map(contents, "author");
|
||||
|
||||
const { chat, mention } = index;
|
||||
const association = props.associations.chat[chat];
|
||||
const groupPath = association["group-path"];
|
||||
const appPath = association["app-path"];
|
||||
|
||||
const group = props.groups[groupPath];
|
||||
|
||||
const desc = describeNotification(mention, contents.length);
|
||||
const groupContacts = props.contacts[groupPath];
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (props.archived) {
|
||||
return;
|
||||
}
|
||||
|
||||
const func = read ? "unread" : "read";
|
||||
return api.hark[func](timebox, { chat: index });
|
||||
}, [api, timebox, index, read]);
|
||||
|
||||
return (
|
||||
<Col onClick={onClick} flexGrow="1" p="2">
|
||||
<Header
|
||||
chat
|
||||
associations={props.associations}
|
||||
read={read}
|
||||
archived={props.archived}
|
||||
time={time}
|
||||
authors={authors}
|
||||
moduleIcon="Chat"
|
||||
channel={chat}
|
||||
contacts={props.contacts}
|
||||
group={groupPath}
|
||||
description={desc}
|
||||
/>
|
||||
<Col pb="3" pl="5">
|
||||
{_.map(_.take(contents, 5), (content, idx) => {
|
||||
const to = `/~landscape${groupPath}/resource/chat${appPath}?msg=${content.number}`;
|
||||
return (
|
||||
<Link key={idx} to={to}>
|
||||
<ChatMessage
|
||||
measure={() => {}}
|
||||
msg={content}
|
||||
isLastRead={false}
|
||||
group={group}
|
||||
contacts={groupContacts}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{contents.length > 5 && (
|
||||
<Box ml="4" mt="3" mb="2" color="gray" fontSize="14px">
|
||||
and {contents.length - 5} other message
|
||||
{contents.length > 6 ? "s" : ""}
|
||||
</Box>
|
||||
)}
|
||||
</Col>
|
||||
</Col>
|
||||
);
|
||||
}
|
231
pkg/interface/src/views/apps/notifications/graph.tsx
Normal file
231
pkg/interface/src/views/apps/notifications/graph.tsx
Normal file
@ -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 }) => (
|
||||
<Box borderRadius="1" p="2" bg="washedGray">
|
||||
<Anchor target="_blank" color="gray" href={url}>
|
||||
<Icon verticalAlign="bottom" mr="2" icon="ArrowExternal" />
|
||||
{title}
|
||||
</Anchor>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const GraphNodeContent = ({ contents, mod, description, index }) => {
|
||||
const idx = index.slice(1).split("/");
|
||||
if (mod === "link") {
|
||||
if (idx.length === 1) {
|
||||
const [{ text }, { url }] = contents;
|
||||
return <GraphUrl title={text} url={url} />;
|
||||
} else if (idx.length === 2) {
|
||||
const [{ text }] = contents;
|
||||
return <RichText>{text}</RichText>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (mod === "publish") {
|
||||
if (idx.length !== 3) {
|
||||
return null;
|
||||
} else if (idx[1] === "2") {
|
||||
const [{ text }] = contents;
|
||||
return <RichText>{text}</RichText>;
|
||||
} else if (idx[1] === "1") {
|
||||
const [{ text: header }, { text: body }] = contents;
|
||||
const snippet = getSnippet(body);
|
||||
return (
|
||||
<Col>
|
||||
<Box mb="2" fontWeight="500">
|
||||
{header}
|
||||
</Box>
|
||||
<Box overflow="hidden" maxHeight="400px">
|
||||
{snippet}
|
||||
<FilterBox
|
||||
width="100%"
|
||||
zIndex="1"
|
||||
height="calc(100% - 2em)"
|
||||
bottom="0px"
|
||||
position="absolute"
|
||||
/>
|
||||
</Box>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
}
|
||||
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 = (
|
||||
<Sigil
|
||||
ship={`~${author}`}
|
||||
size={16}
|
||||
icon
|
||||
color={`#000000`}
|
||||
classes="mix-blend-diff"
|
||||
/>
|
||||
);
|
||||
|
||||
const nodeUrl = getNodeUrl(mod, group, graph, index);
|
||||
|
||||
return (
|
||||
<Link to={nodeUrl}>
|
||||
<Row gapX="2" py="2">
|
||||
<Col>{img}</Col>
|
||||
<Col alignItems="flex-start">
|
||||
<Row
|
||||
mb="2"
|
||||
height="16px"
|
||||
alignItems="center"
|
||||
p="1"
|
||||
backgroundColor="white"
|
||||
>
|
||||
<Text mono title={author}>
|
||||
{cite(author)}
|
||||
</Text>
|
||||
<Text ml="2" gray>
|
||||
{moment(time).format("HH:mm")}
|
||||
</Text>
|
||||
</Row>
|
||||
<Row p="1">
|
||||
<GraphNodeContent
|
||||
contents={contents}
|
||||
mod={mod}
|
||||
description={description}
|
||||
index={index}
|
||||
/>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Col onClick={onClick} p="2">
|
||||
<Header
|
||||
archived={props.archived}
|
||||
time={time}
|
||||
read={read}
|
||||
authors={authors}
|
||||
moduleIcon={icon}
|
||||
channel={graph}
|
||||
contacts={props.contacts}
|
||||
group={group}
|
||||
description={desc}
|
||||
associations={props.associations}
|
||||
/>
|
||||
<Col pl="5">
|
||||
{_.map(contents, (content, idx) => (
|
||||
<GraphNode
|
||||
author={content.author}
|
||||
contents={content.contents}
|
||||
mod={index.module}
|
||||
time={content["time-sent"]}
|
||||
description={index.description}
|
||||
index={content.index}
|
||||
graph={graph}
|
||||
group={group}
|
||||
/>
|
||||
))}
|
||||
</Col>
|
||||
</Col>
|
||||
);
|
||||
}
|
90
pkg/interface/src/views/apps/notifications/group.tsx
Normal file
90
pkg/interface/src/views/apps/notifications/group.tsx
Normal file
@ -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 (
|
||||
<Col onClick={onClick} p="2">
|
||||
<Header
|
||||
archived={props.archived}
|
||||
time={time}
|
||||
read={read}
|
||||
group={group}
|
||||
contacts={props.contacts}
|
||||
authors={authors}
|
||||
description={desc}
|
||||
associations={associations}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
107
pkg/interface/src/views/apps/notifications/header.tsx
Normal file
107
pkg/interface/src/views/apps/notifications/header.tsx
Normal file
@ -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<typeof Text>) => (
|
||||
<NormalText fontWeight="500" {...props} />
|
||||
);
|
||||
|
||||
const Divider = (props: PropFunc<typeof Text>) => (
|
||||
<Text lineHeight="tall" mx="1" fontWeight="bold" color="lightGray">
|
||||
|
|
||||
</Text>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Text mono={!showNickname}>
|
||||
{name}
|
||||
{!props.last && ", "}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
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 <Author key={idx} contacts={contacts} patp={p} last={last} />;
|
||||
}),
|
||||
(auths) => (
|
||||
<React.Fragment>
|
||||
{auths}
|
||||
|
||||
{authors.length > 3 &&
|
||||
` and ${authors.length - 3} other${authors.length === 4 ? "" : "s"}`}
|
||||
</React.Fragment>
|
||||
)
|
||||
)(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 (
|
||||
<Row p="2" flexWrap="wrap" gapX="1" alignItems="center">
|
||||
{!props.archived && (
|
||||
<Icon
|
||||
display="block"
|
||||
mr="1"
|
||||
icon={read ? "Circle" : "Bullet"}
|
||||
color="blue"
|
||||
/>
|
||||
)}
|
||||
<Text mr="1" mono>
|
||||
{authorDesc}
|
||||
</Text>
|
||||
<Text mr="1">{description}</Text>
|
||||
{!!moduleIcon && <Icon icon={moduleIcon as any} />}
|
||||
{!!channel && <Text fontWeight="500">{channelTitle}</Text>}
|
||||
<Text mx="1" fontWeight="bold" color="lightGray">
|
||||
|
|
||||
</Text>
|
||||
<Text fontWeight="500">{groupTitle}</Text>
|
||||
<Text lineHeight="tall" mx="1" fontWeight="bold" color="lightGray">
|
||||
|
|
||||
</Text>
|
||||
<Text fontWeight="regular" color="lightGray">
|
||||
{time}
|
||||
</Text>
|
||||
</Row>
|
||||
);
|
||||
}
|
165
pkg/interface/src/views/apps/notifications/inbox.tsx
Normal file
165
pkg/interface/src/views/apps/notifications/inbox.tsx
Normal file
@ -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<DatedTimebox>(([date, nots]) => [
|
||||
date,
|
||||
nots.filter(filterNotification(associations, props.filter)),
|
||||
]),
|
||||
f.groupBy<DatedTimebox>(([date]) =>
|
||||
moment(daToUnix(date)).format("DDMMYYYY")
|
||||
),
|
||||
f.values
|
||||
)(notifications);
|
||||
|
||||
return (
|
||||
<Col overflowY="auto" flexGrow="1">
|
||||
{newNotifications && (
|
||||
<DaySection
|
||||
latest
|
||||
timeboxes={[newNotifications]}
|
||||
contacts={props.contacts}
|
||||
archive={!!props.showArchive}
|
||||
associations={props.associations}
|
||||
graphConfig={props.notificationsGraphConfig}
|
||||
groupConfig={props.notificationsGroupConfig}
|
||||
chatConfig={props.notificationsChatConfig}
|
||||
api={api}
|
||||
/>
|
||||
)}
|
||||
|
||||
{_.map(
|
||||
notificationsByDay,
|
||||
(timeboxes, idx) =>
|
||||
timeboxes.length > 0 && (
|
||||
<DaySection
|
||||
key={idx}
|
||||
timeboxes={timeboxes}
|
||||
contacts={props.contacts}
|
||||
archive={!!props.showArchive}
|
||||
associations={props.associations}
|
||||
api={api}
|
||||
graphConfig={props.notificationsGraphConfig}
|
||||
groupConfig={props.notificationsGroupConfig}
|
||||
chatConfig={props.notificationsChatConfig}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<Box position="sticky" zIndex="3" top="-1px" bg="white">
|
||||
<Box p="2" bg="scales.black05">
|
||||
{moment(daToUnix(timeboxes[0][0])).calendar(null, calendar)}
|
||||
</Box>
|
||||
</Box>
|
||||
{_.map(timeboxes.sort(sortTimeboxes), ([date, nots], i) =>
|
||||
_.map(nots.sort(sortIndexedNotification), (not, j: number) => (
|
||||
<React.Fragment key={j}>
|
||||
{(i !== 0 || j !== 0) && (
|
||||
<Box flexShrink="0" height="4px" bg="scales.black05" />
|
||||
)}
|
||||
<Notification
|
||||
graphConfig={graphConfig}
|
||||
groupConfig={groupConfig}
|
||||
chatConfig={chatConfig}
|
||||
api={api}
|
||||
associations={associations}
|
||||
notification={not}
|
||||
archived={archive}
|
||||
contacts={contacts}
|
||||
time={date}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
43
pkg/interface/src/views/apps/notifications/metadata.tsx
Normal file
43
pkg/interface/src/views/apps/notifications/metadata.tsx
Normal file
@ -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 (
|
||||
<Box p="2">
|
||||
<Header
|
||||
authors={[]}
|
||||
description={description}
|
||||
group={unread.group}
|
||||
channel={unread.channel}
|
||||
date={unread.date}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
175
pkg/interface/src/views/apps/notifications/notification.tsx
Normal file
175
pkg/interface/src/views/apps/notifications/notification.tsx
Normal file
@ -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 (
|
||||
<Row alignItems="center" justifyContent="space-between">
|
||||
{children}
|
||||
<Row gapX="2" p="2" alignItems="center">
|
||||
<StatelessAsyncAction name={changeMuteDesc} onClick={onChangeMute}>
|
||||
{changeMuteDesc}
|
||||
</StatelessAsyncAction>
|
||||
{!props.archived && (
|
||||
<StatelessAsyncAction name={time.toString()} onClick={onArchive}>
|
||||
Archive
|
||||
</StatelessAsyncAction>
|
||||
)}
|
||||
</Row>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
export function Notification(props: NotificationProps) {
|
||||
const { notification, associations, archived } = props;
|
||||
const { read, contents, time } = notification.notification;
|
||||
|
||||
const Wrapper = ({ children }) => (
|
||||
<NotificationWrapper
|
||||
archived={archived}
|
||||
notif={notification}
|
||||
time={props.time}
|
||||
api={props.api}
|
||||
graphConfig={props.graphConfig}
|
||||
groupConfig={props.groupConfig}
|
||||
chatConfig={props.chatConfig}
|
||||
>
|
||||
{children}
|
||||
</NotificationWrapper>
|
||||
);
|
||||
|
||||
if ("graph" in notification.index) {
|
||||
const index = notification.index.graph;
|
||||
const c: GraphNotificationContents = (contents as any).graph;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<GraphNotification
|
||||
api={props.api}
|
||||
index={index}
|
||||
contents={c}
|
||||
contacts={props.contacts}
|
||||
read={read}
|
||||
archived={archived}
|
||||
timebox={props.time}
|
||||
time={time}
|
||||
associations={associations}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
if ("group" in notification.index) {
|
||||
const index = notification.index.group;
|
||||
const c: GroupNotificationContents = (contents as any).group;
|
||||
return (
|
||||
<Wrapper>
|
||||
<GroupNotification
|
||||
api={props.api}
|
||||
index={index}
|
||||
contents={c}
|
||||
contacts={props.contacts}
|
||||
read={read}
|
||||
timebox={props.time}
|
||||
archived={archived}
|
||||
time={time}
|
||||
associations={associations}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
if ("chat" in notification.index) {
|
||||
const index = notification.index.chat;
|
||||
const c: ChatNotificationContents = (contents as any).chat;
|
||||
return (
|
||||
<Wrapper>
|
||||
<ChatNotification
|
||||
api={props.api}
|
||||
index={index}
|
||||
contents={c}
|
||||
contacts={props.contacts}
|
||||
read={read}
|
||||
archived={archived}
|
||||
groups={{}}
|
||||
timebox={props.time}
|
||||
time={time}
|
||||
associations={associations}
|
||||
/>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
141
pkg/interface/src/views/apps/notifications/notifications.tsx
Normal file
141
pkg/interface/src/views/apps/notifications/notifications.tsx
Normal file
@ -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<typeof Text> & { view?: string; current: string }
|
||||
) => {
|
||||
const { current, view, ...textProps } = props;
|
||||
const to = view ? `${baseUrl}/${view}` : baseUrl;
|
||||
const active = view ? current === view : !current;
|
||||
|
||||
return (
|
||||
<Link to={to}>
|
||||
<Text px="2" {...textProps} gray={!active} />
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface NotificationFilter {
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export default function NotificationsScreen(props: any) {
|
||||
const relativePath = (p: string) => baseUrl + p;
|
||||
|
||||
const [filter, setFilter] = useState<NotificationFilter>({ 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 (
|
||||
<Switch>
|
||||
<Route
|
||||
path={[relativePath("/:view"), relativePath("")]}
|
||||
render={(routeProps) => {
|
||||
const { view } = routeProps.match.params;
|
||||
return (
|
||||
<Body>
|
||||
<Col height="100%">
|
||||
<Row
|
||||
p="3"
|
||||
alignItems="center"
|
||||
height="48px"
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
borderBottom="1"
|
||||
borderBottomColor="washedGray"
|
||||
>
|
||||
<Box>Updates </Box>
|
||||
<Row>
|
||||
<Box>
|
||||
<HeaderLink current={view} view="">
|
||||
Inbox
|
||||
</HeaderLink>
|
||||
</Box>
|
||||
<Box>
|
||||
<HeaderLink current={view} view="archive">
|
||||
Archive
|
||||
</HeaderLink>
|
||||
</Box>
|
||||
<Box>
|
||||
<HeaderLink current={view} view="preferences">
|
||||
Preferences
|
||||
</HeaderLink>
|
||||
</Box>
|
||||
</Row>
|
||||
<Dropdown
|
||||
alignX="right"
|
||||
alignY="top"
|
||||
options={
|
||||
<Col
|
||||
p="2"
|
||||
backgroundColor="white"
|
||||
border={1}
|
||||
borderRadius={1}
|
||||
borderColor="lightGray"
|
||||
gapY="2"
|
||||
>
|
||||
<FormikOnBlur
|
||||
initialValues={filter}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<GroupSearch
|
||||
id="groups"
|
||||
label="Filter Groups"
|
||||
caption="Only show notifications from this group"
|
||||
associations={props.associations}
|
||||
/>
|
||||
</FormikOnBlur>
|
||||
</Col>
|
||||
}
|
||||
>
|
||||
<Box>
|
||||
<Text mr="1" gray>
|
||||
Filter:
|
||||
</Text>
|
||||
{groupFilterDesc}
|
||||
</Box>
|
||||
</Dropdown>
|
||||
</Row>
|
||||
{view === "archive" && (
|
||||
<Inbox
|
||||
{...props}
|
||||
archive={props.archivedNotifications}
|
||||
showArchive
|
||||
filter={filter.groups}
|
||||
/>
|
||||
)}
|
||||
{view === "preferences" && (
|
||||
<NotificationPreferences
|
||||
graphConfig={props.notificationsGraphConfig}
|
||||
api={props.api}
|
||||
dnd={props.doNotDisturb}
|
||||
/>
|
||||
)}
|
||||
{!view && <Inbox {...props} filter={filter.groups} />}
|
||||
</Col>
|
||||
</Body>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
90
pkg/interface/src/views/apps/notifications/preferences.tsx
Normal file
90
pkg/interface/src/views/apps/notifications/preferences.tsx
Normal file
@ -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<FormSchema>) => {
|
||||
console.log(values);
|
||||
try {
|
||||
let promises: Promise<any>[] = [];
|
||||
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 (
|
||||
<FormikOnBlur
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form>
|
||||
<Col maxWidth="384px" p="3" gapY="4">
|
||||
<Checkbox
|
||||
label="Do not disturb"
|
||||
id="dnd"
|
||||
caption="You won't see the notification badge, but notifications will still appear in your inbox."
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
label="Watch for replies"
|
||||
id="watchOnSelf"
|
||||
caption="Automatically follow a post for notifications when it's yours"
|
||||
/>
|
||||
<Checkbox
|
||||
label="Watch for mentions"
|
||||
id="mentions"
|
||||
caption="Notify me if someone mentions my @p in a channel I've joined"
|
||||
/>
|
||||
</Col>
|
||||
</Form>
|
||||
</FormikOnBlur>
|
||||
);
|
||||
}
|
@ -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) {
|
||||
</Author>
|
||||
</Row>
|
||||
<Box mb={2}>
|
||||
<RichText className="f9 white-d" remoteContentPolicy={remoteContentPolicy}>{content}</RichText>
|
||||
<MentionText
|
||||
contacts={contacts}
|
||||
content={comment}
|
||||
remoteContentPolicy={remoteContentPolicy}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
@ -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]) => (
|
||||
<CommentItem
|
||||
comment={comment}
|
||||
key={idx}
|
||||
key={idx.toString()}
|
||||
contacts={props.contacts}
|
||||
api={api}
|
||||
name={name}
|
||||
|
@ -15,7 +15,7 @@ export function FormikOnBlur<
|
||||
) {
|
||||
const { values } = formikBag;
|
||||
formikBag.submitForm().then(() => {
|
||||
formikBag.resetForm({ values });
|
||||
formikBag.resetForm({ values, touched: {} });
|
||||
});
|
||||
}
|
||||
}, [
|
||||
|
@ -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 (
|
||||
|
51
pkg/interface/src/views/components/MentionText.tsx
Normal file
51
pkg/interface/src/views/components/MentionText.tsx
Normal file
@ -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 (
|
||||
<RichText
|
||||
inline
|
||||
key={idx}
|
||||
remoteContentPolicy={props.remoteContentPolicy}
|
||||
>
|
||||
{c.text}
|
||||
</RichText>
|
||||
);
|
||||
} else if ("mention" in c) {
|
||||
return (
|
||||
<Mention key={idx} contacts={contacts || {}} ship={c.mention} />
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<Text mx="2px" px="2px" bg="washedBlue" color="blue" mono={!showNickname}>
|
||||
{name}
|
||||
</Text>
|
||||
);
|
||||
}
|
@ -30,7 +30,7 @@ const RichText = React.memo(({ remoteContentPolicy, ...props }) => (
|
||||
return <BaseAnchor target='_blank' rel='noreferrer noopener' borderBottom='1px solid' {...props}>{props.children}</BaseAnchor>;
|
||||
},
|
||||
paragraph: (paraProps) => {
|
||||
return <Text display='block' mb='2' {...props}>{paraProps.children}</Text>;
|
||||
return <Text display={props.inline ? 'inline' : 'block'} mb='2' {...props}>{paraProps.children}</Text>;
|
||||
}
|
||||
}}
|
||||
plugins={[[
|
||||
|
@ -28,6 +28,11 @@ const StatusBar = (props) => {
|
||||
</Button>
|
||||
|
||||
<StatusBarItem mr={2} onClick={() => props.api.local.setOmnibox()}>
|
||||
{ !props.doNotDisturb && props.notificationsCount > 0 &&
|
||||
(<Box display="block" right="-8px" top="-8px" position="absolute" >
|
||||
<Icon color="blue" icon="Bullet" />
|
||||
</Box>
|
||||
)}
|
||||
<Icon icon='LeapArrow'/>
|
||||
<Text ml={2} color='black'>
|
||||
Leap
|
||||
@ -42,7 +47,15 @@ const StatusBar = (props) => {
|
||||
/>
|
||||
</Row>
|
||||
<Row justifyContent="flex-end" collapse>
|
||||
|
||||
{!props.doNotDisturb && (<StatusBarItem color="blue" px='12px' mr='2' onClick={() => props.history.push('/~notifications')}>
|
||||
<Text
|
||||
fontWeight={props.notificationsCount > 0 ? "500" : "400"}
|
||||
fontSize='0'
|
||||
color="blue"
|
||||
>
|
||||
{(props.notificationsCount > 99) ? "99+" : props.notificationsCount}
|
||||
</Text>
|
||||
</StatusBarItem>)}
|
||||
<StatusBarItem px={'2'} flexShrink='0' onClick={() => props.history.push('/~profile')}>
|
||||
<Sigil ship={props.ship} size={16} color='black' classes='mix-blend-diff' icon />
|
||||
<Text ml={2} display={["none", "inline"]} fontFamily="mono">~{props.ship}</Text>
|
||||
|
@ -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;
|
||||
|
@ -30,17 +30,20 @@ export class OmniboxResult extends Component {
|
||||
const sigilFill = (this.state.hovered || (selected === link)) ? '#3a8ff7' : '#ffffff';
|
||||
|
||||
let graphic = <div />;
|
||||
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 = <Icon display="inline-block" verticalAlign="middle" icon={icon} mr='2' size='16px' color={iconFill} />;
|
||||
} else if (icon === 'logout') {
|
||||
graphic = <Icon display="inline-block" verticalAlign="middle" icon='ArrowWest' mr='2' size='16px' color={iconFill} />;
|
||||
} else if (icon === 'profile') {
|
||||
graphic = <Sigil color={sigilFill} classes='dib v-mid mr2' ship={window.ship} size={16} icon padded />;
|
||||
graphic = <Sigil color={sigilFill} classes='dib flex-shrink-0 v-mid mr2' ship={window.ship} size={16} icon padded />;
|
||||
} else if (icon === 'home') {
|
||||
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Circle' mr='2' size='16px' color={iconFill} />;
|
||||
} else if (icon === 'notifications') {
|
||||
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Inbox' mr='2' size='16px' color={iconFill} />;
|
||||
} else {
|
||||
graphic = <Icon icon='NullIcon' verticalAlign="middle" mr='2' size="16px" color={iconFill} />;
|
||||
graphic = <Icon display='inline-block' icon='NullIcon' verticalAlign="middle" mr='2' size="16px" color={iconFill} />;
|
||||
}
|
||||
|
||||
return graphic;
|
||||
|
@ -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 (
|
||||
<Dropdown
|
||||
options={
|
||||
<Col backgroundColor="white" border={1} borderRadius={1} borderColor="lightGray">
|
||||
<Col
|
||||
backgroundColor="white"
|
||||
border={1}
|
||||
borderRadius={1}
|
||||
borderColor="lightGray"
|
||||
>
|
||||
<ChannelMenuItem color="blue" icon="Inbox">
|
||||
<StatelessAsyncAction
|
||||
m="2"
|
||||
bg="white"
|
||||
name="notif"
|
||||
onClick={onChangeMute}
|
||||
>
|
||||
{isMuted ? "Unmute" : "Mute"} this channel
|
||||
</StatelessAsyncAction>
|
||||
</ChannelMenuItem>
|
||||
{isOurs ? (
|
||||
<>
|
||||
<ChannelMenuItem color="red" icon="TrashCan">
|
||||
<Action m="2" backgroundColor='white' destructive onClick={onDelete}>
|
||||
<Action
|
||||
m="2"
|
||||
backgroundColor="white"
|
||||
destructive
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete Channel
|
||||
</Action>
|
||||
</ChannelMenuItem>
|
||||
<ChannelMenuItem bottom icon="Gear" color='black'>
|
||||
<ChannelMenuItem bottom icon="Gear" color="black">
|
||||
<Link to={`${baseUrl}/settings`}>
|
||||
<Box fontSize={0} p="2">
|
||||
Channel Settings
|
||||
|
@ -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) => {
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path="/~notifications"
|
||||
render={ p => (
|
||||
<Notifications {...props} />
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
render={p => (
|
||||
<ErrorComponent
|
||||
|
@ -1,176 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { AsyncButton } from "~/views/components/AsyncButton";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
Box,
|
||||
ManagedTextInputField as Input,
|
||||
ManagedCheckboxField 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 GroupSettingsProps {
|
||||
group: Group;
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
}
|
||||
export function GroupSettings(props: GroupSettingsProps) {
|
||||
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<FormSchema>
|
||||
) => {
|
||||
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<GroupPolicy> = 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 (
|
||||
<Box height="100%" overflowY="auto">
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form style={{ display: "contents" }}>
|
||||
<Box
|
||||
maxWidth="300px"
|
||||
gridTemplateColumns="1fr"
|
||||
gridAutoRows="auto"
|
||||
display="grid"
|
||||
gridRowGap={4}
|
||||
my={3}
|
||||
mx={4}
|
||||
>
|
||||
{!disabled ? (
|
||||
<Col>
|
||||
<Label>Delete Group</Label>
|
||||
<Label gray mt="2">
|
||||
Permanently delete this group. (All current members will no
|
||||
longer see this group.)
|
||||
</Label>
|
||||
<StatelessAsyncButton onClick={onDelete} mt={2} destructive>
|
||||
Delete this group
|
||||
</StatelessAsyncButton>
|
||||
</Col>
|
||||
) : (
|
||||
<Col>
|
||||
<Label>Leave Group</Label>
|
||||
<Label gray mt="2">
|
||||
Leave this group. You can rejoin if it is an open group, or if
|
||||
you are reinvited
|
||||
</Label>
|
||||
<StatelessAsyncButton onClick={onDelete} mt={2} destructive>
|
||||
Leave this group
|
||||
</StatelessAsyncButton>
|
||||
</Col>
|
||||
)}
|
||||
<Box borderBottom={1} borderBottomColor="washedGray" />
|
||||
<Input
|
||||
id="title"
|
||||
label="Group Name"
|
||||
caption="The name for your group to be called by"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
id="description"
|
||||
label="Group Description"
|
||||
caption="The description of your group"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ColorInput
|
||||
id="color"
|
||||
label="Group color"
|
||||
caption="A color to represent your group"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Checkbox
|
||||
id="isPrivate"
|
||||
label="Private group"
|
||||
caption="If enabled, users must be invited to join the group"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<AsyncButton
|
||||
disabled={disabled}
|
||||
primary
|
||||
loadingText="Updating.."
|
||||
border
|
||||
>
|
||||
Save
|
||||
</AsyncButton>
|
||||
<FormError message="Failed to update settings" />
|
||||
</Box>
|
||||
</Form>
|
||||
</Formik>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -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<FormSchema>
|
||||
) => {
|
||||
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<GroupPolicy> = 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 (
|
||||
<Formik
|
||||
validationSchema={formSchema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form>
|
||||
<Col gapY={4}>
|
||||
<Input
|
||||
id="title"
|
||||
label="Group Name"
|
||||
caption="The name for your group to be called by"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
id="description"
|
||||
label="Group Description"
|
||||
caption="The description of your group"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<ColorInput
|
||||
id="color"
|
||||
label="Group color"
|
||||
caption="A color to represent your group"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Checkbox
|
||||
id="isPrivate"
|
||||
label="Private group"
|
||||
caption="If enabled, users must be invited to join the group"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<AsyncButton
|
||||
disabled={disabled}
|
||||
primary
|
||||
loadingText="Updating.."
|
||||
border
|
||||
>
|
||||
Save
|
||||
</AsyncButton>
|
||||
<FormError message="Failed to update settings" />
|
||||
</Col>
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
}
|
@ -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 (
|
||||
<Box height="100%" overflowY="auto">
|
||||
<Col maxWidth="384px" p="4" gapY="4">
|
||||
<GroupPersonalSettings {...props} />
|
||||
<Box borderBottom="1" borderBottomColor="washedGray" />
|
||||
<GroupAdminSettings {...props} />
|
||||
</Col>
|
||||
</Box>
|
||||
);
|
||||
}
|
@ -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 (
|
||||
<Col>
|
||||
<Label>{action} Group</Label>
|
||||
<Label gray mt="2">
|
||||
{description}
|
||||
</Label>
|
||||
<StatelessAsyncButton onClick={onDelete} mt={2} destructive>
|
||||
{action} this group
|
||||
</StatelessAsyncButton>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Col gapY="4">
|
||||
<FormikOnBlur initialValues={initialValues} onSubmit={onSubmit}>
|
||||
<Toggle
|
||||
id="watching"
|
||||
label="Notify me on group activity"
|
||||
caption="Send me notifications when this group changes"
|
||||
/>
|
||||
</FormikOnBlur>
|
||||
<DeleteGroup association={props.association} owner api={props.api} />
|
||||
</Col>
|
||||
);
|
||||
}
|
@ -68,6 +68,8 @@ export function GroupsPane(props: GroupsPaneProps) {
|
||||
s3={props.s3}
|
||||
hideAvatars={props.hideAvatars}
|
||||
hideNicknames={props.hideNicknames}
|
||||
notificationsGroupConfig={props.notificationsGroupConfig}
|
||||
|
||||
{...routeProps}
|
||||
baseUrl={baseUrl}
|
||||
/>)}
|
||||
|
@ -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" && (
|
||||
|
@ -57,7 +57,13 @@ export function Resource(props: ResourceProps) {
|
||||
<Route
|
||||
path={relativePath("")}
|
||||
render={(routeProps) => (
|
||||
<ResourceSkeleton baseUrl={props.baseUrl} {...skelProps} atRoot>
|
||||
<ResourceSkeleton
|
||||
notificationsGraphConfig={props.notificationsGraphConfig}
|
||||
notificationsChatConfig={props.notificationsChatConfig}
|
||||
baseUrl={props.baseUrl}
|
||||
{...skelProps}
|
||||
atRoot
|
||||
>
|
||||
{app === "chat" ? (
|
||||
<ChatResource {...props} />
|
||||
) : app === "publish" ? (
|
||||
|
@ -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 (
|
||||
<Col width="100%" height="100%" overflowY="hidden">
|
||||
@ -64,11 +67,7 @@ export function ResourceSkeleton(props: ResourceSkeletonProps) {
|
||||
<Link to={`/~landscape${workspace}`}> {"<- Back"}</Link>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
color="blue"
|
||||
pr={2}
|
||||
mr={2}
|
||||
>
|
||||
<Box color="blue" pr={2} mr={2}>
|
||||
<Link to={`/~landscape${workspace}/resource/${app}${appPath}`}>
|
||||
<Text color="blue">Go back to channel</Text>
|
||||
</Link>
|
||||
@ -78,22 +77,34 @@ export function ResourceSkeleton(props: ResourceSkeletonProps) {
|
||||
{atRoot && (
|
||||
<>
|
||||
<Box pr={1} mr={2}>
|
||||
<Text display='inline-block' verticalAlign='middle'>{title}</Text>
|
||||
<Text display="inline-block" verticalAlign="middle">
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<TruncatedBox
|
||||
display={["none", "block"]}
|
||||
maxWidth="60%"
|
||||
verticalAlign='middle'
|
||||
verticalAlign="middle"
|
||||
flexShrink={1}
|
||||
title={association?.metadata?.description}
|
||||
color="gray"
|
||||
>
|
||||
<RichText color='gray' remoteContentPolicy={disableRemoteContent} mb='0' display='inline-block'>
|
||||
{association?.metadata?.description}
|
||||
<RichText
|
||||
color="gray"
|
||||
remoteContentPolicy={disableRemoteContent}
|
||||
mb="0"
|
||||
display="inline-block"
|
||||
>
|
||||
{association?.metadata?.description}
|
||||
</RichText>
|
||||
</TruncatedBox>
|
||||
<Box flexGrow={1} />
|
||||
<ChannelMenu association={association} api={api} />
|
||||
<ChannelMenu
|
||||
graphNotificationConfig={props.notificationsGraphConfig}
|
||||
chatNotificationConfig={props.notificationsChatConfig}
|
||||
association={association}
|
||||
api={api}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
@ -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)",
|
||||
|
Loading…
Reference in New Issue
Block a user