mirror of
https://github.com/ilyakooo0/urbit.git
synced 2024-12-02 07:06:41 +03:00
Merge branch 'release/next-userspace' into la/push-hook-list-resource
This commit is contained in:
commit
3a7c201e80
@ -1,5 +1,7 @@
|
||||
/- *group
|
||||
/- metadata=metadata-store
|
||||
/+ store=graph-store
|
||||
/+ metadata
|
||||
/+ mdl=metadata
|
||||
/+ res=resource
|
||||
/+ graph
|
||||
/+ group
|
||||
@ -20,84 +22,83 @@
|
||||
::
|
||||
+$ agent (push-hook:push-hook config)
|
||||
::
|
||||
++ is-allowed
|
||||
|= [=resource:res =bowl:gall requires-admin=?]
|
||||
^- ?
|
||||
=/ grp ~(. group bowl)
|
||||
=/ met ~(. metadata bowl)
|
||||
=/ group=(unit resource:res)
|
||||
(peek-group:met %graph resource)
|
||||
?~ group %.n
|
||||
?: requires-admin
|
||||
(is-admin:grp src.bowl u.group)
|
||||
?| (is-member:grp src.bowl u.group)
|
||||
(is-admin:grp src.bowl u.group)
|
||||
==
|
||||
::
|
||||
++ is-allowed-remove
|
||||
|= [=resource:res indices=(set index:store) =bowl:gall]
|
||||
^- ?
|
||||
=/ gra ~(. graph bowl)
|
||||
?. (is-allowed resource bowl %.n)
|
||||
%.n
|
||||
%+ levy
|
||||
~(tap in indices)
|
||||
|= =index:store
|
||||
^- ?
|
||||
=/ =node:store
|
||||
(got-node:gra resource index)
|
||||
?| =(author.post.node src.bowl)
|
||||
(is-allowed resource bowl %.y)
|
||||
==
|
||||
+$ state-null ~
|
||||
+$ state-zero [%0 marks=(set mark)]
|
||||
+$ versioned-state
|
||||
$@ state-null
|
||||
state-zero
|
||||
--
|
||||
::
|
||||
=| state-zero
|
||||
=* state -
|
||||
%- agent:dbug
|
||||
^- agent:gall
|
||||
%- (agent:push-hook config)
|
||||
^- agent
|
||||
=<
|
||||
|_ =bowl:gall
|
||||
+* this .
|
||||
def ~(. (default-agent this %|) bowl)
|
||||
grp ~(. group bowl)
|
||||
gra ~(. graph bowl)
|
||||
hc ~(. +> bowl)
|
||||
::
|
||||
++ on-init on-init:def
|
||||
++ on-save !>(~)
|
||||
++ on-load on-load:def
|
||||
++ on-save !>(state)
|
||||
++ on-load
|
||||
|= =vase
|
||||
=+ !<(old=versioned-state vase)
|
||||
=? old ?=(~ old)
|
||||
[%0 ~]
|
||||
?> ?=(%0 -.old)
|
||||
`this(state old)
|
||||
::
|
||||
++ on-poke on-poke:def
|
||||
++ on-agent on-agent:def
|
||||
++ on-watch on-watch:def
|
||||
++ on-leave on-leave:def
|
||||
++ on-peek on-peek:def
|
||||
++ on-arvo on-arvo:def
|
||||
++ on-arvo
|
||||
|= [=wire =sign-arvo]
|
||||
^- (quip card _this)
|
||||
?+ wire (on-arvo:def wire sign-arvo)
|
||||
::
|
||||
[%perms @ @ ~]
|
||||
?> ?=(?(%add %remove) i.t.t.wire)
|
||||
=* mark i.t.wire
|
||||
:_ this
|
||||
(build-permissions mark i.t.t.wire %next)^~
|
||||
==
|
||||
::
|
||||
++ on-fail on-fail:def
|
||||
::
|
||||
++ should-proxy-update
|
||||
|= =vase
|
||||
^- ?
|
||||
=/ =update:store !<(update:store vase)
|
||||
=* rid resource.q.update
|
||||
?- -.q.update
|
||||
%add-graph (is-allowed resource.q.update bowl %.y)
|
||||
%remove-graph (is-allowed resource.q.update bowl %.y)
|
||||
%add-nodes (is-allowed resource.q.update bowl %.n)
|
||||
%remove-nodes (is-allowed-remove resource.q.update indices.q.update bowl)
|
||||
%add-signatures (is-allowed resource.uid.q.update bowl %.n)
|
||||
%remove-signatures (is-allowed resource.uid.q.update bowl %.y)
|
||||
%archive-graph (is-allowed resource.q.update bowl %.y)
|
||||
%add-graph %.n
|
||||
%remove-graph %.n
|
||||
%add-nodes (is-allowed-add:hc resource.q.update nodes.q.update)
|
||||
%remove-nodes (is-allowed-remove:hc resource.q.update indices.q.update)
|
||||
%add-signatures %.n
|
||||
%remove-signatures %.n
|
||||
%archive-graph %.n
|
||||
%unarchive-graph %.n
|
||||
%add-tag %.n
|
||||
%remove-tag %.n
|
||||
%keys %.n
|
||||
%tags %.n
|
||||
%tag-queries %.n
|
||||
%run-updates (is-allowed resource.q.update bowl %.y)
|
||||
%run-updates %.n
|
||||
==
|
||||
++ resource-for-update resource-for-update:gra
|
||||
::
|
||||
++ initial-watch
|
||||
|= [=path =resource:res]
|
||||
^- vase
|
||||
?> (is-allowed resource bowl %.n)
|
||||
?> (is-allowed resource)
|
||||
!> ^- update:store
|
||||
?~ path
|
||||
:: new subscribe
|
||||
@ -116,6 +117,15 @@
|
||||
^- [(list card) agent]
|
||||
=/ =update:store !<(update:store vase)
|
||||
?+ -.q.update [~ this]
|
||||
%add-graph
|
||||
?~ mark.q.update `this
|
||||
=* mark u.mark.q.update
|
||||
?: (~(has in marks) mark) `this
|
||||
:_ this(marks (~(put in marks) mark))
|
||||
:~ (build-permissions:hc mark %add %sing)
|
||||
(build-permissions:hc mark %remove %sing)
|
||||
==
|
||||
::
|
||||
%remove-graph
|
||||
:_ this
|
||||
[%give %kick ~[resource+(en-path:res resource.q.update)] ~]~
|
||||
@ -125,3 +135,138 @@
|
||||
[%give %kick ~[resource+(en-path:res resource.q.update)] ~]~
|
||||
==
|
||||
--
|
||||
|_ =bowl:gall
|
||||
+* grp ~(. group bowl)
|
||||
met ~(. mdl bowl)
|
||||
gra ~(. graph bowl)
|
||||
++ scry
|
||||
|= [care=@t desk=@t =path]
|
||||
%+ weld
|
||||
/[care]/(scot %p our.bowl)/[desk]/(scot %da now.bowl)
|
||||
path
|
||||
::
|
||||
++ scry-mark
|
||||
|= =resource:res
|
||||
.^ (unit mark)
|
||||
(scry %gx %graph-store /graph-mark/(scot %p entity.resource)/[name.resource]/noun)
|
||||
==
|
||||
::
|
||||
++ perm-mark-name
|
||||
|= perm=@t
|
||||
^- @t
|
||||
(cat 3 'graph-permissions-' perm)
|
||||
::
|
||||
++ perm-mark
|
||||
|= [=resource:res perm=@t vip=vip-metadata:metadata =indexed-post:store]
|
||||
^- permissions:store
|
||||
=- (check vip)
|
||||
!< check=$-(vip-metadata:metadata permissions:store)
|
||||
%. !>(indexed-post)
|
||||
=/ mark (get-mark:gra resource)
|
||||
?~ mark |=(=vase !>([%no %no %no]))
|
||||
.^(tube:clay (scry %cc %home /[u.mark]/(perm-mark-name perm)))
|
||||
::
|
||||
++ add-mark
|
||||
|= [=resource:res vip=vip-metadata:metadata =indexed-post:store]
|
||||
(perm-mark resource %add vip indexed-post)
|
||||
::
|
||||
++ remove-mark
|
||||
|= [=resource:res vip=vip-metadata:metadata =indexed-post:store]
|
||||
(perm-mark resource %remove vip indexed-post)
|
||||
::
|
||||
++ get-permission
|
||||
|= [=permissions:store is-admin=? writers=(set ship)]
|
||||
^- permission-level:store
|
||||
?: is-admin
|
||||
admin.permissions
|
||||
?: =(~ writers)
|
||||
writer.permissions
|
||||
?: (~(has in writers) src.bowl)
|
||||
writer.permissions
|
||||
reader.permissions
|
||||
::
|
||||
++ is-allowed
|
||||
|= =resource:res
|
||||
=/ group-res=resource:res
|
||||
(need (peek-group:met %graph resource))
|
||||
(is-member:grp src.bowl group-res)
|
||||
::
|
||||
++ get-roles-writers-variation
|
||||
|= =resource:res
|
||||
^- (unit [is-admin=? writers=(set ship) vip=vip-metadata:metadata])
|
||||
=/ assoc=(unit association:metadata)
|
||||
(peek-association:met %graph resource)
|
||||
?~ assoc ~
|
||||
=/ role=(unit (unit role-tag))
|
||||
(role-for-ship:grp group.u.assoc src.bowl)
|
||||
=/ writers=(set ship)
|
||||
(get-tagged-ships:grp group.u.assoc [%graph resource %writers])
|
||||
?~ role ~
|
||||
=/ is-admin=?
|
||||
?=(?([~ %admin] [~ %moderator]) u.role)
|
||||
`[is-admin writers vip.metadatum.u.assoc]
|
||||
::
|
||||
++ node-to-indexed-post
|
||||
|= =node:store
|
||||
^- indexed-post:store
|
||||
=* index index.post.node
|
||||
[(snag (dec (lent index)) index) post.node]
|
||||
::
|
||||
++ is-allowed-add
|
||||
|= [=resource:res nodes=(map index:store node:store)]
|
||||
^- ?
|
||||
%- (bond |.(%.n))
|
||||
%+ biff (get-roles-writers-variation resource)
|
||||
|= [is-admin=? writers=(set ship) vip=vip-metadata:metadata]
|
||||
%- some
|
||||
%+ levy ~(tap by nodes)
|
||||
|= [=index:store =node:store]
|
||||
=/ =permissions:store
|
||||
%^ add-mark resource vip
|
||||
(node-to-indexed-post node)
|
||||
=/ =permission-level:store
|
||||
(get-permission permissions is-admin writers)
|
||||
~& permission-level
|
||||
?- permission-level
|
||||
%yes %.y
|
||||
%no %.n
|
||||
::
|
||||
%self
|
||||
=/ parent-index=index:store
|
||||
(scag (dec (lent index)) index)
|
||||
=/ parent-node=node:store
|
||||
(got-node:gra resource parent-index)
|
||||
=(author.post.parent-node src.bowl)
|
||||
==
|
||||
::
|
||||
++ is-allowed-remove
|
||||
|= [=resource:res indices=(set index:store)]
|
||||
^- ?
|
||||
%- (bond |.(%.n))
|
||||
%+ biff (get-roles-writers-variation)
|
||||
|= [is-admin=? writers=(set ship) vip=vip-metadata:metadata]
|
||||
%- some
|
||||
%+ levy ~(tap by indices)
|
||||
|= =index:store
|
||||
=/ =node:store
|
||||
(got-node:gra resource index)
|
||||
=/ =permissions:store
|
||||
%^ remove-mark resource vip
|
||||
(node-to-indexed-post node)
|
||||
=/ =permission-level:store
|
||||
(get-permission permissions is-admin writers)
|
||||
?- permission-level
|
||||
%yes %.y
|
||||
%no %.n
|
||||
%self =(author.post.node src.bowl)
|
||||
==
|
||||
::
|
||||
++ build-permissions
|
||||
|= [=mark kind=?(%add %remove) mode=?(%sing %next)]
|
||||
^- card
|
||||
=/ =wire /perms/[mark]/[kind]
|
||||
=/ =mood:clay [%c da+now.bowl /[mark]/(perm-mark-name kind)]
|
||||
=/ =rave:clay ?:(?=(%sing mode) [mode mood] [mode mood])
|
||||
[%pass wire %arvo %c %warp our.bowl %home `rave]
|
||||
--
|
||||
|
||||
|
@ -37,26 +37,24 @@
|
||||
+$ versioned-state
|
||||
$% state-zero
|
||||
state-one
|
||||
state-two
|
||||
==
|
||||
::
|
||||
+$ state-zero
|
||||
$: %0
|
||||
=groups:state-zero:store
|
||||
==
|
||||
::
|
||||
[%0 *]
|
||||
::
|
||||
+$ state-one
|
||||
$: %1
|
||||
=groups
|
||||
=groups:groups-state-one
|
||||
==
|
||||
::
|
||||
+$ diff
|
||||
$% [%group-update update:store]
|
||||
[%group-initial groups]
|
||||
+$ state-two
|
||||
$: %2
|
||||
=groups
|
||||
==
|
||||
--
|
||||
::
|
||||
=| state-one
|
||||
=| state-two
|
||||
=* state -
|
||||
::
|
||||
%- agent:dbug
|
||||
@ -74,90 +72,37 @@
|
||||
++ on-load
|
||||
|= =old=vase
|
||||
=/ old !<(versioned-state old-vase)
|
||||
?: ?=(%1 -.old)
|
||||
`this(state old)
|
||||
|^
|
||||
:- :~ [%pass / %agent [our.bowl dap.bowl] %poke %noun !>(%perm-upgrade)]
|
||||
kick-all
|
||||
==
|
||||
=* paths ~(key by groups.old)
|
||||
=/ [unmanaged=(list path) managed=(list path)]
|
||||
(skid ~(tap in paths) |=(=path =('~' (snag 0 path))))
|
||||
=. groups (all-unmanaged unmanaged)
|
||||
=. groups (all-managed managed)
|
||||
this
|
||||
::
|
||||
++ all-managed
|
||||
|= paths=(list path)
|
||||
^+ groups
|
||||
?~ paths
|
||||
groups
|
||||
=/ [rid=resource =group]
|
||||
(migrate-group i.paths)
|
||||
%= $
|
||||
paths t.paths
|
||||
::
|
||||
groups
|
||||
(~(put by groups) rid group)
|
||||
?- -.old
|
||||
%2 `this(state old)
|
||||
::
|
||||
%1
|
||||
%_ $
|
||||
-.old %2
|
||||
groups.old (groups-1-to-2 groups.old)
|
||||
==
|
||||
::
|
||||
%0 $(old *state-two)
|
||||
==
|
||||
::
|
||||
++ all-unmanaged
|
||||
|= paths=(list path)
|
||||
^+ groups
|
||||
?~ paths
|
||||
groups
|
||||
?: |(=(/~/default i.paths) =(4 (lent i.paths)))
|
||||
$(paths t.paths)
|
||||
=/ [=resource =group]
|
||||
(migrate-unmanaged i.paths)
|
||||
%= $
|
||||
paths t.paths
|
||||
::
|
||||
groups
|
||||
(~(put by groups) resource group)
|
||||
==
|
||||
++ kick-all
|
||||
^- card
|
||||
:+ %give %kick
|
||||
:_ ~
|
||||
%~ tap by
|
||||
%+ roll ~(val by sup.bowl)
|
||||
|= [[=ship pax=path] paths=(set path)]
|
||||
(~(put in paths) pax)
|
||||
::
|
||||
++ migrate-unmanaged
|
||||
|= pax=path
|
||||
^- [resource group]
|
||||
=/ members=(set ship)
|
||||
(~(got by groups.old) pax)
|
||||
=| =invite:policy
|
||||
?> ?=(^ pax)
|
||||
=/ rid=resource
|
||||
(resource-from-old-path t.pax)
|
||||
++ groups-1-to-2
|
||||
|= =groups:groups-state-one
|
||||
^+ ^groups
|
||||
%- ~(run by groups)
|
||||
|= =group:groups-state-one
|
||||
=/ =tags
|
||||
(~(put ju *tags) %admin entity.rid)
|
||||
:- rid
|
||||
[members tags invite %.y]
|
||||
::
|
||||
++ resource-from-old-path
|
||||
|= pax=path
|
||||
^- resource
|
||||
?> ?=([@ @ *] pax)
|
||||
=/ ship
|
||||
(slav %p i.pax)
|
||||
[ship i.t.pax]
|
||||
::
|
||||
++ migrate-group
|
||||
|= pax=path
|
||||
=/ members
|
||||
(~(got by groups.old) pax)
|
||||
=| =invite:policy
|
||||
=/ rid=resource
|
||||
(resource-from-old-path pax)
|
||||
=/ =tags
|
||||
(~(put ju *tags) %admin entity.rid)
|
||||
[rid members tags invite %.n]
|
||||
(tags-1-to-2 tags.group)
|
||||
[members.group tags [policy hidden]:group]
|
||||
::
|
||||
++ tags-1-to-2
|
||||
|= =tags:groups-state-one
|
||||
^- ^tags
|
||||
%- ~(gas by *^tags)
|
||||
%+ murn
|
||||
~(tap by tags)
|
||||
|= [=tag:groups-state-one ships=(set ship)]
|
||||
?^ tag ~
|
||||
`[tag ships]
|
||||
--
|
||||
::
|
||||
++ on-poke
|
||||
@ -273,8 +218,8 @@
|
||||
|= arc=*
|
||||
^- (quip card _state)
|
||||
|^
|
||||
=/ sty=state-one
|
||||
[%1 (remake-groups ;;((tree [resource tree-group]) +.arc))]
|
||||
=/ sty=state-two
|
||||
[%2 (remake-groups ;;((tree [resource tree-group]) +.arc))]
|
||||
:_ sty
|
||||
%+ roll ~(tap by groups.sty)
|
||||
|= [[rid=resource grp=group] out=(list card)]
|
||||
|
@ -38,7 +38,9 @@
|
||||
%_ this
|
||||
invites.state
|
||||
%- ~(gas by *invites:store)
|
||||
[%graph *invitatory:store]~
|
||||
:~ [%graph *invitatory:store]
|
||||
[%groups *invitatory:store]
|
||||
==
|
||||
==
|
||||
::
|
||||
++ on-save !>(state)
|
||||
|
@ -39,6 +39,8 @@
|
||||
++ on-save !>(state)
|
||||
++ on-load
|
||||
|= =vase
|
||||
?: =(1 1)
|
||||
`this
|
||||
=+ !<(old=versioned-state vase)
|
||||
|^
|
||||
?: ?=(%2 -.old)
|
||||
|
@ -78,13 +78,19 @@
|
||||
++ should-proxy-update
|
||||
|= =vase
|
||||
=+ !<(=update:store vase)
|
||||
?. ?=(?(%add %remove %update) -.update)
|
||||
?. ?=(?(%add %remove) -.update)
|
||||
%.n
|
||||
=/ role=(unit (unit role-tag))
|
||||
(role-for-ship:grp group.update src.bowl)
|
||||
=/ =metadatum:store
|
||||
(need (peek-metadatum:met %contacts group.update))
|
||||
?~ role %.n
|
||||
?~ u.role %.n
|
||||
?=(?(%admin %moderator) u.u.role)
|
||||
?^ u.role
|
||||
?=(?(%admin %moderator) u.u.role)
|
||||
?. ?=(%add -.update) %.n
|
||||
?& =(src.bowl entity.resource.resource.update)
|
||||
?=(%member-metadata vip.metadatum)
|
||||
==
|
||||
::
|
||||
++ resource-for-update resource-for-update:met
|
||||
++ take-update
|
||||
|
@ -125,4 +125,8 @@
|
||||
index (snoc index atom)
|
||||
nodes (tap:orm:store p.children.node)
|
||||
==
|
||||
::
|
||||
++ get-mark
|
||||
|= res=resource
|
||||
(scry-for ,(unit mark) /graph-mark/(scot %p entity.res)/[name.res])
|
||||
--
|
||||
|
@ -127,38 +127,17 @@
|
||||
++ tags
|
||||
|= =^tags
|
||||
^- json
|
||||
|^
|
||||
:- %o
|
||||
(~(uni by app) group)
|
||||
++ group
|
||||
^- (map @t json)
|
||||
%- malt
|
||||
%+ murn
|
||||
~(tap by tags)
|
||||
|= [=^tag ships=(^set ^ship)]
|
||||
^- (unit [@t json])
|
||||
?^ tag
|
||||
~
|
||||
`[tag (set ship ships)]
|
||||
++ app
|
||||
^- (map @t json)
|
||||
=| app-tags=(map @t json)
|
||||
=/ tags ~(tap by tags)
|
||||
|-
|
||||
?~ tags
|
||||
app-tags
|
||||
=* tag i.tags
|
||||
?@ p.tag
|
||||
$(tags t.tags)
|
||||
=/ app=json
|
||||
(~(gut by app-tags) app.p.tag [%o ~])
|
||||
?> ?=(%o -.app)
|
||||
=. p.app
|
||||
(~(put by p.app) tag.p.tag (set ship q.tag))
|
||||
=. app-tags
|
||||
(~(put by app-tags) app.p.tag app)
|
||||
$(tags t.tags)
|
||||
--
|
||||
%- pairs
|
||||
%+ turn ~(tap by tags)
|
||||
|= [=^tag ships=(^set ^ship)]
|
||||
^- [@t json]
|
||||
:_ (set ship ships)
|
||||
?@ tag tag
|
||||
;: (cury cat 3)
|
||||
app.tag '\\'
|
||||
tag.tag '\\'
|
||||
(enjs-path:resource resource.tag)
|
||||
==
|
||||
::
|
||||
++ set
|
||||
|* [item=$-(* json) sit=(^set)]
|
||||
@ -167,6 +146,7 @@
|
||||
%+ turn
|
||||
~(tap in sit)
|
||||
item
|
||||
::
|
||||
++ tag
|
||||
|= =^tag
|
||||
^- json
|
||||
@ -175,6 +155,7 @@
|
||||
%- pairs
|
||||
:~ app+s+app.tag
|
||||
tag+s+tag.tag
|
||||
resource+s+(enjs-path:resource resource.tag)
|
||||
==
|
||||
::
|
||||
++ policy
|
||||
@ -366,6 +347,7 @@
|
||||
%. json
|
||||
%- ot
|
||||
:~ app+so
|
||||
resource+dejs-path:resource
|
||||
tag+so
|
||||
==
|
||||
|
||||
|
@ -14,6 +14,7 @@
|
||||
remove+remove
|
||||
join+join
|
||||
leave+leave
|
||||
invite+invite
|
||||
==
|
||||
::
|
||||
++ create
|
||||
@ -33,6 +34,13 @@
|
||||
:~ resource+dejs:resource
|
||||
ship+(su ;~(pfix sig fed:ag))
|
||||
==
|
||||
::
|
||||
++ invite
|
||||
%- ot
|
||||
:~ resource+dejs:resource
|
||||
ships+(as (su ;~(pfix sig fed:ag)))
|
||||
description+so
|
||||
==
|
||||
--
|
||||
::
|
||||
++ enjs
|
||||
|
@ -101,6 +101,14 @@
|
||||
:- %groups
|
||||
(weld (en-path:resource rid) /join/(scot %p ship))
|
||||
::
|
||||
++ get-tagged-ships
|
||||
|= [rid=resource =tag]
|
||||
^- (set ship)
|
||||
=/ grp=(unit group)
|
||||
(scry-group rid)
|
||||
?~ grp ~
|
||||
(~(get ju tags.u.grp) tag)
|
||||
::
|
||||
++ is-managed
|
||||
|= rid=resource
|
||||
=/ group=(unit group)
|
||||
|
12
pkg/arvo/mar/graph/permissions/add.hoon
Normal file
12
pkg/arvo/mar/graph/permissions/add.hoon
Normal file
@ -0,0 +1,12 @@
|
||||
/- *graph-store
|
||||
|_ per=permissions
|
||||
++ grad %noun
|
||||
++ grow
|
||||
|%
|
||||
++ noun per
|
||||
--
|
||||
++ grab
|
||||
|%
|
||||
++ noun permissions
|
||||
--
|
||||
--
|
12
pkg/arvo/mar/graph/permissions/remove.hoon
Normal file
12
pkg/arvo/mar/graph/permissions/remove.hoon
Normal file
@ -0,0 +1,12 @@
|
||||
/- *graph-store
|
||||
|_ per=permissions
|
||||
++ grad %noun
|
||||
++ grow
|
||||
|%
|
||||
++ noun per
|
||||
--
|
||||
++ grab
|
||||
|%
|
||||
++ noun permissions
|
||||
--
|
||||
--
|
@ -1,10 +1,22 @@
|
||||
/- *post
|
||||
/- *post, met=metadata-store
|
||||
|_ i=indexed-post
|
||||
++ grow
|
||||
|%
|
||||
++ noun i
|
||||
::
|
||||
++ graph-permissions-add
|
||||
|= vip=vip-metadata:met
|
||||
?+ index.p.i !!
|
||||
[@ ~] [%yes %yes %no]
|
||||
==
|
||||
::
|
||||
++ graph-permissions-remove
|
||||
|= vip=vip-metadata:met
|
||||
?+ index.p.i !!
|
||||
[@ ~] [%self %self %no]
|
||||
==
|
||||
::
|
||||
++ notification-kind
|
||||
::
|
||||
?+ index.p.i ~
|
||||
[@ ~] `[%message 0 %count %.n]
|
||||
==
|
||||
|
@ -1,8 +1,29 @@
|
||||
/- *post
|
||||
/- *post, met=metadata-store
|
||||
|_ i=indexed-post
|
||||
++ grow
|
||||
|%
|
||||
++ noun i
|
||||
::
|
||||
++ graph-permissions-add
|
||||
|= vip=vip-metadata:met
|
||||
=/ reader
|
||||
?=(%reader-comments vip)
|
||||
?+ index.p.i !!
|
||||
[@ ~] [%yes %yes %no]
|
||||
[@ @ ~] [%yes %yes ?:(reader %yes %no)]
|
||||
[@ @ @ ~] [%self %self %self]
|
||||
==
|
||||
::
|
||||
++ graph-permissions-remove
|
||||
|= vip=vip-metadata:met
|
||||
=/ reader
|
||||
?=(%reader-comments vip)
|
||||
?+ index.p.i !!
|
||||
[@ ~] [%yes %self %self]
|
||||
[@ @ ~] [%yes %self %self]
|
||||
[@ @ @ ~] [%yes %self %self]
|
||||
==
|
||||
::
|
||||
++ notification-kind
|
||||
?+ index.p.i ~
|
||||
[@ ~] `[%link 0 %each %.y]
|
||||
|
@ -1,10 +1,27 @@
|
||||
/- *post
|
||||
/- *post, met=metadata-store
|
||||
|_ i=indexed-post
|
||||
++ grow
|
||||
|%
|
||||
++ noun i
|
||||
++ graph-permissions-add
|
||||
|= vip=vip-metadata:met
|
||||
?+ index.p.i !!
|
||||
[@ ~] [%yes %yes %no] :: new note
|
||||
[@ %1 @ ~] [%self %self %no]
|
||||
[@ %2 @ ~] [%yes %yes ?:(?=(%reader-comments vip) %yes %no)]
|
||||
[@ %2 @ @ ~] [%self %self %self]
|
||||
==
|
||||
::
|
||||
++ graph-permissions-remove
|
||||
|= vip=vip-metadata:met
|
||||
?+ index.p.i !!
|
||||
[@ ~] [%yes %self %self]
|
||||
[@ %1 @ @ ~] [%yes %self %self]
|
||||
[@ %2 @ ~] [%yes %self %self]
|
||||
[@ %2 @ @ ~] [%yes %self %self]
|
||||
==
|
||||
:: +notification-kind
|
||||
:: Ignore all containers, only notify on content
|
||||
:: ignore all containers, only notify on content
|
||||
::
|
||||
++ notification-kind
|
||||
?+ index.p.i ~
|
||||
@ -16,7 +33,7 @@
|
||||
--
|
||||
++ grab
|
||||
|%
|
||||
:: +noun: Validate publish post
|
||||
:: +noun: validate publish post
|
||||
::
|
||||
++ noun
|
||||
|= p=*
|
||||
|
@ -12,5 +12,4 @@
|
||||
++ noun update:store
|
||||
++ json action:dejs:store
|
||||
--
|
||||
::
|
||||
--
|
||||
|
@ -1,5 +1,17 @@
|
||||
/- *post
|
||||
|%
|
||||
::
|
||||
+$ permissions
|
||||
[admin=permission-level writer=permission-level reader=permission-level]
|
||||
::
|
||||
:: $permission-level: levels of permissions in increasing order
|
||||
::
|
||||
:: %no: May not add/remove node
|
||||
:: %self: May only nodes beneath nodes that were added by
|
||||
:: the same pilot, may remove nodes that the pilot 'owns'
|
||||
:: %yes: May add a node or remove node
|
||||
+$ permission-level
|
||||
?(%no %self %yes)
|
||||
+$ graph ((mop atom node) gth)
|
||||
+$ marked-graph [p=graph q=(unit mark)]
|
||||
::
|
||||
@ -11,6 +23,7 @@
|
||||
+$ update-log ((mop time logged-update) gth)
|
||||
+$ update-logs (map resource update-log)
|
||||
::
|
||||
::
|
||||
+$ internal-graph
|
||||
$~ [%empty ~]
|
||||
$% [%graph p=graph]
|
||||
|
@ -2,25 +2,6 @@
|
||||
^?
|
||||
|%
|
||||
::
|
||||
++ state-zero
|
||||
|%
|
||||
+$ group (set ship)
|
||||
::
|
||||
+$ group-action
|
||||
$% [%add members=group pax=path] :: add member to group
|
||||
[%remove members=group pax=path] :: remove member from group
|
||||
[%bundle pax=path] :: create group at path
|
||||
[%unbundle pax=path] :: delete group at path
|
||||
==
|
||||
::
|
||||
+$ group-update
|
||||
$% [%keys keys=(set path)] :: keys have changed
|
||||
[%path members=group pax=path]
|
||||
group-action
|
||||
==
|
||||
::
|
||||
+$ groups (map path group)
|
||||
--
|
||||
:: $action: request to change group-store state
|
||||
::
|
||||
:: %add-group: add a group
|
||||
|
@ -9,6 +9,8 @@
|
||||
:: client side
|
||||
[%join =resource =ship]
|
||||
[%leave =resource]
|
||||
::
|
||||
[%invite =resource ships=(set ship) description=@t]
|
||||
==
|
||||
|
||||
::
|
||||
|
@ -2,6 +2,22 @@
|
||||
::
|
||||
^?
|
||||
|%
|
||||
::
|
||||
++ groups-state-one
|
||||
|%
|
||||
+$ groups (map resource group)
|
||||
::
|
||||
+$ tag $@(group-tag [app=term tag=term])
|
||||
::
|
||||
+$ tags (jug tag ship)
|
||||
::
|
||||
+$ group
|
||||
$: members=(set ship)
|
||||
=tags
|
||||
=policy
|
||||
hidden=?
|
||||
==
|
||||
--
|
||||
:: $groups: a mapping from group-ids to groups
|
||||
::
|
||||
+$ groups (map resource group)
|
||||
@ -16,7 +32,7 @@
|
||||
:: Tags may be used and recognised differently across apps.
|
||||
:: for example, you could use tags like `%author`, `%bot`, `%flagged`...
|
||||
::
|
||||
+$ tag $@(group-tag [app=term tag=term])
|
||||
+$ tag $@(group-tag [app=term =resource tag=term])
|
||||
:: $role-tag: a kind of $group-tag that identifies a privileged user
|
||||
::
|
||||
:: These roles are
|
||||
|
@ -1,8 +1,4 @@
|
||||
<<<<<<< HEAD
|
||||
/- spider, graph-view, graph=graph-store, metadata=metadata-store, *group
|
||||
=======
|
||||
/- spider, graph-view, graph=graph-store, met=metadata-store, *group
|
||||
>>>>>>> origin/la/contact-store
|
||||
/- spider, graph-view, graph=graph-store, metadata=metadata-store, *group, group-store
|
||||
/+ strandio, resource
|
||||
=>
|
||||
|%
|
||||
@ -12,11 +8,7 @@
|
||||
::
|
||||
++ scry-metadata
|
||||
|= rid=resource
|
||||
<<<<<<< HEAD
|
||||
=/ m (strand ,resource)
|
||||
=======
|
||||
=/ m (strand ,(unit resource))
|
||||
>>>>>>> origin/la/contact-store
|
||||
;< group=(unit resource) bind:m
|
||||
%+ scry:strandio ,(unit resource)
|
||||
;: weld
|
||||
@ -24,11 +16,7 @@
|
||||
(en-path:resource rid)
|
||||
/noun
|
||||
==
|
||||
<<<<<<< HEAD
|
||||
(pure:m (need group))
|
||||
=======
|
||||
(pure:m group)
|
||||
>>>>>>> origin/la/contact-store
|
||||
::
|
||||
++ scry-group
|
||||
|= rid=resource
|
||||
@ -57,6 +45,27 @@
|
||||
!> ^- action:metadata
|
||||
[%remove group-rid [%graph rid]]
|
||||
(pure:m ~)
|
||||
::
|
||||
++ delete-tags
|
||||
|= [graph=resource grp-rid=resource =group]
|
||||
=/ m (strand ,~)
|
||||
^- form:m
|
||||
=/ tags=(list [=tag tagged=(set ship)])
|
||||
%+ skim ~(tap by tags.group) |= [=tag tagged=(set ship)]
|
||||
?@ tag %.n
|
||||
?& =(app.tag %graph)
|
||||
=(resource.tag graph)
|
||||
==
|
||||
|- =* loop $
|
||||
^- form:m
|
||||
?~ tags
|
||||
(pure:m ~)
|
||||
;< ~ bind:m
|
||||
%+ poke [entity.grp-rid %group-push-hook]
|
||||
:- %group-update
|
||||
!> ^- update:group-store
|
||||
[%remove-tag grp-rid tag.i.tags tagged.i.tags]
|
||||
loop(tags t.tags)
|
||||
--
|
||||
::
|
||||
^- thread:spider
|
||||
@ -72,6 +81,8 @@
|
||||
(scry-metadata rid.action)
|
||||
;< =group bind:m
|
||||
(scry-group group-rid)
|
||||
;< ~ bind:m
|
||||
(delete-tags rid.action group-rid group)
|
||||
;< ~ bind:m
|
||||
(delete-graph group-rid rid.action)
|
||||
?. hidden.group
|
||||
|
58
pkg/arvo/ted/group/invite.hoon
Normal file
58
pkg/arvo/ted/group/invite.hoon
Normal file
@ -0,0 +1,58 @@
|
||||
/- spider,
|
||||
metadata=metadata-store,
|
||||
*group,
|
||||
inv=invite-store,
|
||||
store=group-store,
|
||||
push-hook
|
||||
/+ strandio, resource, view=group-view, grpl=group
|
||||
=>
|
||||
|%
|
||||
++ strand strand:spider
|
||||
++ poke poke:strandio
|
||||
++ poke-our poke-our:strandio
|
||||
++ gallify-bowl
|
||||
|= =bowl:spider
|
||||
^- bowl:gall
|
||||
:* [our src %$]:bowl
|
||||
[~ ~]
|
||||
[0 eny now byk]:bowl
|
||||
==
|
||||
::
|
||||
++ invite-ships
|
||||
|= [ships=(set ship) rid=resource description=cord]
|
||||
=/ m (strand ,~)
|
||||
^- form:m
|
||||
;< =bowl:spider bind:m get-bowl:strandio
|
||||
=/ =action:inv
|
||||
:^ %invites %groups (shaf %group-uid eny.bowl)
|
||||
^- multi-invite:inv
|
||||
[our.bowl %group-push-hook rid ships description]
|
||||
;< ~ bind:m (poke-our %invite-hook invite-action+!>(action))
|
||||
(pure:m ~)
|
||||
::
|
||||
++ add-pending
|
||||
|= [ships=(set ship) rid=resource]
|
||||
=/ m (strand ,~)
|
||||
^- form:m
|
||||
=/ =action:store
|
||||
[%change-policy rid %invite %add-invites ships]
|
||||
;< ~ bind:m (poke-our %group-push-hook %group-update !>(action))
|
||||
(pure:m ~)
|
||||
--
|
||||
^- thread:spider
|
||||
|= arg=vase
|
||||
=/ m (strand ,vase)
|
||||
^- form:m
|
||||
=+ !<([~ =action:view] arg)
|
||||
?> ?=(%invite -.action)
|
||||
;< =bowl:spider bind:m get-bowl:strandio
|
||||
=/ =bowl:gall (gallify-bowl bowl)
|
||||
?> (~(is-admin grpl bowl) our.bowl resource.action)
|
||||
;< ~ bind:m
|
||||
(invite-ships [ships resource description]:action)
|
||||
=/ =group
|
||||
(need (~(scry-group grpl bowl) resource.action))
|
||||
?: ?=(%open -.policy.group)
|
||||
(pure:m !>(~))
|
||||
;< ~ bind:m (add-pending [ships resource]:action)
|
||||
(pure:m !>(~))
|
@ -67,6 +67,18 @@ export default class GroupsApi extends BaseApi<StoreState> {
|
||||
});
|
||||
}
|
||||
|
||||
invite(ship: string, name: string, ships: Patp[], description: string) {
|
||||
const resource = makeResource(ship, name);
|
||||
return this.viewThread('group-invite', {
|
||||
invite: {
|
||||
resource,
|
||||
ships,
|
||||
description
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private proxyAction(action: GroupAction) {
|
||||
return this.action('group-push-hook', 'group-update', action);
|
||||
}
|
||||
|
@ -18,9 +18,6 @@ export class HarkApi extends BaseApi<StoreState> {
|
||||
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());
|
||||
@ -36,9 +33,6 @@ export class HarkApi extends BaseApi<StoreState> {
|
||||
await this.graphHookAction({
|
||||
'set-mentions': mentions
|
||||
});
|
||||
return this.chatHookAction({
|
||||
'set-mentions': mentions
|
||||
});
|
||||
}
|
||||
|
||||
setWatchOnSelf(watchSelf: boolean) {
|
||||
@ -129,9 +123,6 @@ export class HarkApi extends BaseApi<StoreState> {
|
||||
const { group } = notif.index.group;
|
||||
return this.ignoreGroup(group);
|
||||
}
|
||||
if('chat' in notif.index) {
|
||||
return this.ignoreChat(notif.index.chat.chat);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@ -147,9 +138,6 @@ export class HarkApi extends BaseApi<StoreState> {
|
||||
if('group' in notif.index) {
|
||||
return this.listenGroup(notif.index.group.group);
|
||||
}
|
||||
if('chat' in notif.index) {
|
||||
return this.listenChat(notif.index.chat.chat);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@ -168,13 +156,6 @@ export class HarkApi extends BaseApi<StoreState> {
|
||||
})
|
||||
}
|
||||
|
||||
ignoreChat(chat: string) {
|
||||
return this.chatHookAction({
|
||||
ignore: chat
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
listenGroup(group: string) {
|
||||
return this.groupHookAction({
|
||||
listen: group
|
||||
@ -190,12 +171,6 @@ export class HarkApi extends BaseApi<StoreState> {
|
||||
})
|
||||
}
|
||||
|
||||
listenChat(chat: string) {
|
||||
return this.chatHookAction({
|
||||
listen: chat
|
||||
});
|
||||
}
|
||||
|
||||
async getMore(): Promise<boolean> {
|
||||
const offset = this.store.state['notifications']?.size || 0;
|
||||
const count = 3;
|
||||
|
@ -23,9 +23,9 @@ export default class MetadataApi extends BaseApi<StoreState> {
|
||||
'date-created': dateCreated,
|
||||
creator,
|
||||
'module': moduleName,
|
||||
preview: false,
|
||||
picture: '',
|
||||
permissions: ''
|
||||
preview: false,
|
||||
vip: ''
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -1,8 +1,12 @@
|
||||
import { roleTags, RoleTags, Group, Resource } from '~/types/group-update';
|
||||
import { PatpNoSig, Path } from '~/types/noun';
|
||||
import _ from "lodash";
|
||||
import { roleTags, RoleTags, Group, Resource } from "~/types/group-update";
|
||||
import { PatpNoSig, Path } from "~/types/noun";
|
||||
import {deSig} from "./util";
|
||||
|
||||
|
||||
export function roleForShip(group: Group, ship: PatpNoSig): RoleTags | undefined {
|
||||
export function roleForShip(
|
||||
group: Group,
|
||||
ship: PatpNoSig
|
||||
): RoleTags | undefined {
|
||||
return roleTags.reduce((currRole, role) => {
|
||||
const roleShips = group?.tags?.role?.[role];
|
||||
return roleShips && roleShips.has(ship) ? role : currRole;
|
||||
@ -10,11 +14,40 @@ export function roleForShip(group: Group, ship: PatpNoSig): RoleTags | undefined
|
||||
}
|
||||
|
||||
export function resourceFromPath(path: Path): Resource {
|
||||
const [, , ship, name] = path.split('/');
|
||||
return { ship, name }
|
||||
}
|
||||
|
||||
export function makeResource(ship: string, name:string) {
|
||||
const [, , ship, name] = path.split("/");
|
||||
return { ship, name };
|
||||
}
|
||||
|
||||
export function makeResource(ship: string, name: string) {
|
||||
return { ship, name };
|
||||
}
|
||||
|
||||
export function isWriter(group: Group, resource: string) {
|
||||
const writers: Set<string> | undefined = _.get(
|
||||
group.tags,
|
||||
["graph", resource, "writers"],
|
||||
undefined
|
||||
);
|
||||
const admins = group.tags?.role?.admin ?? new Set();
|
||||
if (_.isUndefined(writers)) {
|
||||
return true;
|
||||
} else {
|
||||
return writers.has(window.ship) || admins.has(window.ship);
|
||||
}
|
||||
}
|
||||
|
||||
export function isChannelAdmin(group: Group, resource: string, ship: string = `~${window.ship}`) {
|
||||
const role = roleForShip(group, ship.slice(1));
|
||||
|
||||
return (
|
||||
isHost(resource, ship) ||
|
||||
role === "admin" ||
|
||||
role === "moderator"
|
||||
);
|
||||
}
|
||||
|
||||
export function isHost(resource: string, ship: string = `~${window.ship}`) {
|
||||
const [, , host] = resource.split("/");
|
||||
|
||||
return ship === host;
|
||||
}
|
||||
|
@ -63,7 +63,7 @@ const appIndex = function (apps) {
|
||||
|
||||
const otherIndex = function() {
|
||||
const other = [];
|
||||
other.push(result('DMs + Drafts', '/~landscape/home', 'home', null));
|
||||
other.push(result('My Channels', '/~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));
|
||||
@ -110,8 +110,12 @@ export default function index(contacts, associations, apps, currentGroup, groups
|
||||
landscape.push(obj);
|
||||
} else {
|
||||
const app = each.metadata.module || each['app-name'];
|
||||
const group = (groups[each.group]?.hidden)
|
||||
? '/home' : each.group;
|
||||
let group = each.group;
|
||||
if (groups[each.group]?.hidden && app === 'chat') {
|
||||
group = '/messages';
|
||||
} else if (groups[each.group]?.hidden) {
|
||||
group = '/home';
|
||||
}
|
||||
const obj = result(
|
||||
title,
|
||||
`/~landscape${group}/join/${app}${each.resource}`,
|
||||
|
@ -5,9 +5,12 @@ import React, {
|
||||
SyntheticEvent,
|
||||
useMemo,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
import { Box } from "@tlon/indigo-react";
|
||||
import { useOutsideClick } from "./useOutsideClick";
|
||||
import { ModalOverlay } from "~/views/components/ModalOverlay";
|
||||
|
||||
type ModalFunc = (dismiss: () => void) => JSX.Element;
|
||||
interface UseModalProps {
|
||||
@ -19,11 +22,8 @@ interface UseModalResult {
|
||||
showModal: () => void;
|
||||
}
|
||||
|
||||
const stopPropagation = (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
export function useModal(props: UseModalProps): UseModalResult {
|
||||
const innerRef = useRef<HTMLElement>();
|
||||
const [modalShown, setModalShown] = useState(false);
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
@ -44,54 +44,25 @@ export function useModal(props: UseModalProps): UseModalResult {
|
||||
[modalShown, props.modal, dismiss]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event) => {
|
||||
if (event.key === "Escape") {
|
||||
dismiss();
|
||||
}
|
||||
},
|
||||
[dismiss]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [modalShown]);
|
||||
useOutsideClick(innerRef, dismiss);
|
||||
|
||||
const modal = useMemo(
|
||||
() =>
|
||||
!inner ? null : (
|
||||
<Box
|
||||
backgroundColor="scales.black30"
|
||||
left="0px"
|
||||
top="0px"
|
||||
<ModalOverlay
|
||||
ref={innerRef}
|
||||
maxWidth="500px"
|
||||
width="100%"
|
||||
height="100%"
|
||||
zIndex={10}
|
||||
position="fixed"
|
||||
bg="white"
|
||||
borderRadius={2}
|
||||
border={[0, 1]}
|
||||
borderColor={["washedGray", "washedGray"]}
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
onClick={dismiss}
|
||||
alignItems="stretch"
|
||||
flexDirection="column"
|
||||
>
|
||||
<Box
|
||||
maxWidth="500px"
|
||||
width="100%"
|
||||
bg="white"
|
||||
borderRadius={2}
|
||||
border={[0, 1]}
|
||||
borderColor={["washedGray", "washedGray"]}
|
||||
onClick={stopPropagation}
|
||||
display="flex"
|
||||
alignItems="stretch"
|
||||
flexDirection="column"
|
||||
>
|
||||
{inner}
|
||||
</Box>
|
||||
</Box>
|
||||
{inner}
|
||||
</ModalOverlay>
|
||||
),
|
||||
[inner, dismiss]
|
||||
);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { useEffect, RefObject } from "react";
|
||||
|
||||
export function useOutsideClick(
|
||||
ref: RefObject<HTMLElement>,
|
||||
ref: RefObject<HTMLElement | null | undefined>,
|
||||
onClick: () => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
@ -14,10 +14,19 @@ export function useOutsideClick(
|
||||
onClick();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(ev) {
|
||||
if(ev.key === "Escape") {
|
||||
onClick();
|
||||
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClick);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [ref.current, onClick]);
|
||||
}
|
||||
|
@ -23,6 +23,12 @@ export const getModuleIcon = (mod: string) => {
|
||||
return _.capitalize(mod);
|
||||
}
|
||||
|
||||
export function wait(ms: number) {
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export function appIsGraph(app: string) {
|
||||
return app === 'publish' || app == 'link';
|
||||
}
|
||||
@ -386,3 +392,16 @@ export const useHovering = (): useHoveringInterface => {
|
||||
};
|
||||
return { hovering, bind };
|
||||
};
|
||||
|
||||
const DM_REGEX = /ship\/~([a-z]|-)*\/dm--/;
|
||||
export function getItemTitle(association: Association) {
|
||||
if(DM_REGEX.test(association.resource)) {
|
||||
const [,,ship,name] = association.resource.split('/');
|
||||
if(ship.slice(1) === window.ship) {
|
||||
return cite(`~${name.slice(4)}`);
|
||||
}
|
||||
return cite(ship);
|
||||
|
||||
}
|
||||
return association.metadata.title || association.resource
|
||||
};
|
||||
|
@ -6,7 +6,9 @@ export function getTitleFromWorkspace(
|
||||
) {
|
||||
switch (workspace.type) {
|
||||
case "home":
|
||||
return "DMs + Drafts";
|
||||
return "My Channels";
|
||||
case "messages":
|
||||
return "Messages";
|
||||
case "group":
|
||||
const association = associations.groups[workspace.group];
|
||||
return association?.metadata?.title || "";
|
||||
|
@ -41,22 +41,16 @@ function decodePolicy(policy: Enc<GroupPolicy>): GroupPolicy {
|
||||
}
|
||||
|
||||
function decodeTags(tags: Enc<Tags>): Tags {
|
||||
console.log(tags);
|
||||
return _.reduce(
|
||||
tags,
|
||||
(acc, tag, key): Tags => {
|
||||
if (Array.isArray(tag)) {
|
||||
acc.role[key] = new Set(tag);
|
||||
(acc, ships, key): Tags => {
|
||||
if (key.search(/\\/) === -1) {
|
||||
acc.role[key] = new Set(ships);
|
||||
return acc;
|
||||
} else {
|
||||
const app = _.reduce(
|
||||
tag,
|
||||
(inner, t, k) => {
|
||||
inner[k] = new Set(t);
|
||||
return inner;
|
||||
},
|
||||
{}
|
||||
);
|
||||
acc[key] = app;
|
||||
const [app, tag, resource] = key.split('\\');
|
||||
_.set(acc, [app, resource, tag], new Set(ships));
|
||||
return acc;
|
||||
}
|
||||
},
|
||||
@ -143,7 +137,7 @@ export default class GroupReducer<S extends GroupState> {
|
||||
const resourcePath = resourceAsPath(resource);
|
||||
const tags = state.groups[resourcePath].tags;
|
||||
const tagAccessors =
|
||||
'app' in tag ? [tag.app,tag.tag] : ['role', tag.tag];
|
||||
'app' in tag ? [tag.app,tag.resource, tag.tag] : ['role', tag.tag];
|
||||
const tagged = _.get(tags, tagAccessors, new Set());
|
||||
for (const ship of ships) {
|
||||
tagged.add(ship);
|
||||
@ -158,7 +152,7 @@ export default class GroupReducer<S extends GroupState> {
|
||||
const resourcePath = resourceAsPath(resource);
|
||||
const tags = state.groups[resourcePath].tags;
|
||||
const tagAccessors =
|
||||
'app' in tag ? [tag.app,tag.tag] : ['role', tag.tag];
|
||||
'app' in tag ? [tag.app, tag.resource, tag.tag] : ['role', tag.tag];
|
||||
const tagged = _.get(tags, tagAccessors, new Set());
|
||||
|
||||
if (!tagged) {
|
||||
|
@ -10,7 +10,7 @@ import _ from "lodash";
|
||||
import {StoreState} from "../store/type";
|
||||
import { BigIntOrderedMap } from '../lib/BigIntOrderedMap';
|
||||
|
||||
type HarkState = Pick<StoreState, "notifications" | "notificationsGraphConfig" | "notificationsGroupConfig" | "unreads" | "notificationsChatConfig">;
|
||||
type HarkState = Pick<StoreState, "notifications" | "notificationsGraphConfig" | "notificationsGroupConfig" | "unreads" >;
|
||||
|
||||
|
||||
export const HarkReducer = (json: any, state: HarkState) => {
|
||||
@ -32,39 +32,8 @@ export const HarkReducer = (json: any, state: HarkState) => {
|
||||
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) {
|
||||
@ -211,7 +180,6 @@ function clearState(state){
|
||||
notifications: new BigIntOrderedMap<Timebox>(),
|
||||
archivedNotifications: new BigIntOrderedMap<Timebox>(),
|
||||
notificationsGroupConfig: [],
|
||||
notificationsChatConfig: [],
|
||||
notificationsGraphConfig: {
|
||||
watchOnSelf: false,
|
||||
mentions: false,
|
||||
@ -324,9 +292,6 @@ function notifIdxEqual(a: NotifIndex, b: NotifIndex) {
|
||||
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;
|
||||
}
|
||||
|
@ -83,7 +83,6 @@ export default class GlobalStore extends BaseStore<StoreState> {
|
||||
notifications: new BigIntOrderedMap<Timebox>(),
|
||||
archivedNotifications: new BigIntOrderedMap<Timebox>(),
|
||||
notificationsGroupConfig: [],
|
||||
notificationsChatConfig: [],
|
||||
notificationsGraphConfig: {
|
||||
watchOnSelf: false,
|
||||
mentions: false,
|
||||
|
@ -8,6 +8,7 @@ interface RoleTag {
|
||||
|
||||
interface AppTag {
|
||||
app: string;
|
||||
resource: string;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,6 @@ import _ from "lodash";
|
||||
import { Post } from "./graph-update";
|
||||
import { GroupUpdate } from "./group-update";
|
||||
import { BigIntOrderedMap } from "~/logic/lib/BigIntOrderedMap";
|
||||
import { Envelope } from './chat-update';
|
||||
|
||||
export type GraphNotifDescription = "link" | "comment" | "note" | "mention";
|
||||
|
||||
@ -25,27 +24,17 @@ export interface GroupNotifIndex {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface ChatNotifIndex {
|
||||
chat: string;
|
||||
mention: boolean;
|
||||
}
|
||||
|
||||
export type NotifIndex =
|
||||
| { graph: GraphNotifIndex }
|
||||
| { group: GroupNotifIndex }
|
||||
| { chat: ChatNotifIndex };
|
||||
| { group: GroupNotifIndex };
|
||||
|
||||
export type GraphNotificationContents = Post[];
|
||||
|
||||
export type GroupNotificationContents = GroupUpdate[];
|
||||
|
||||
export type ChatNotificationContents = Envelope[];
|
||||
|
||||
export type NotificationContents =
|
||||
| { graph: GraphNotificationContents }
|
||||
| { group: GroupNotificationContents }
|
||||
| { chat: ChatNotificationContents };
|
||||
|
||||
| { group: GroupNotificationContents };
|
||||
export interface Notification {
|
||||
read: boolean;
|
||||
time: number;
|
||||
@ -68,7 +57,6 @@ export interface NotificationGraphConfig {
|
||||
}
|
||||
|
||||
export interface Unreads {
|
||||
chat: Record<string, UnreadStats>;
|
||||
graph: Record<string, Record<string, UnreadStats>>;
|
||||
group: Record<string, UnreadStats>;
|
||||
}
|
||||
|
@ -62,7 +62,7 @@ export interface Metadata {
|
||||
module: string;
|
||||
picture: string;
|
||||
preview: boolean;
|
||||
permissions: Permissions;
|
||||
vip: PermVariation;
|
||||
}
|
||||
|
||||
export type Permissions = '' | 'reader-comments';
|
||||
export type PermVariation = '' | 'reader-comments' | 'member-metadata';
|
||||
|
@ -1,2 +1,4 @@
|
||||
|
||||
import { Icon } from "@tlon/indigo-react";
|
||||
export type PropFunc<T extends (...args: any[]) => any> = Parameters<T>[0];
|
||||
|
||||
export type IconRef = PropFunc<typeof Icon>['icon'];
|
||||
|
@ -9,4 +9,8 @@ interface HomeWorkspace {
|
||||
type: 'home'
|
||||
}
|
||||
|
||||
export type Workspace = HomeWorkspace | GroupWorkspace;
|
||||
interface Messages {
|
||||
type: 'messages'
|
||||
}
|
||||
|
||||
export type Workspace = HomeWorkspace | GroupWorkspace | Messages;
|
||||
|
@ -14,6 +14,7 @@ import SubmitDragger from '~/views/components/SubmitDragger';
|
||||
import { useLocalStorageState } from '~/logic/lib/useLocalStorageState';
|
||||
import { Loading } from '~/views/components/Loading';
|
||||
import useS3 from '~/logic/lib/useS3';
|
||||
import {isWriter} from '~/logic/lib/group';
|
||||
|
||||
type ChatResourceProps = StoreState & {
|
||||
association: Association;
|
||||
@ -39,6 +40,8 @@ export function ChatResource(props: ChatResourceProps) {
|
||||
|
||||
const chatInput = useRef<ChatInput>();
|
||||
|
||||
const canWrite = isWriter(group, station);
|
||||
|
||||
useEffect(() => {
|
||||
const count = Math.min(50, unreadCount + 15);
|
||||
props.api.graph.getNewest(owner, name, count);
|
||||
@ -118,6 +121,7 @@ export function ChatResource(props: ChatResourceProps) {
|
||||
location={props.location}
|
||||
scrollTo={scrollTo ? parseInt(scrollTo, 10) : undefined}
|
||||
/>
|
||||
{ canWrite && (
|
||||
<ChatInput
|
||||
ref={chatInput}
|
||||
api={props.api}
|
||||
@ -130,7 +134,7 @@ export function ChatResource(props: ChatResourceProps) {
|
||||
placeholder="Message..."
|
||||
message={unsent[station] || ''}
|
||||
deleteMessage={clearUnsent}
|
||||
/>
|
||||
/> )}
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
@ -15,8 +15,6 @@ import VirtualScroller from "~/views/components/VirtualScroller";
|
||||
|
||||
import ChatMessage, { MessagePlaceholder } from './ChatMessage';
|
||||
import { UnreadNotice } from "./unread-notice";
|
||||
import { ResubscribeElement } from "./resubscribe-element";
|
||||
import { BacklogElement } from "./backlog-element";
|
||||
|
||||
const INITIAL_LOAD = 20;
|
||||
const DEFAULT_BACKLOG_SIZE = 100;
|
||||
@ -269,8 +267,6 @@ export default class ChatWindow extends Component<ChatWindowProps, ChatWindowSta
|
||||
dismissUnread={this.dismissUnread}
|
||||
onClick={this.scrollToUnread}
|
||||
/>
|
||||
<BacklogElement isChatLoading={isChatLoading} />
|
||||
<ResubscribeElement {...{ api, host: ship, station, isChatUnsynced}} />
|
||||
<VirtualScroller
|
||||
ref={list => {this.virtualList = list}}
|
||||
origin="bottom"
|
||||
|
@ -1,34 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, LoadingSpinner, Text } from '@tlon/indigo-react';
|
||||
|
||||
export const BacklogElement = (props) => {
|
||||
if (!props.isChatLoading) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
marginLeft='auto'
|
||||
marginRight='auto'
|
||||
maxWidth='32rem'
|
||||
position='absolute'
|
||||
zIndex='9999'
|
||||
style={{ left: 0, right: 0, top: 0 }}
|
||||
>
|
||||
<Box
|
||||
display='flex'
|
||||
justifyContent='center'
|
||||
p='3'
|
||||
m='3'
|
||||
border='1px solid'
|
||||
borderColor='washedGray'
|
||||
backgroundColor='white'
|
||||
>
|
||||
<LoadingSpinner
|
||||
foreground='black'
|
||||
background='gray'
|
||||
/>
|
||||
<Text display='block' ml='3' lineHeight='tall'>Past messages are being restored</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
@ -1,35 +0,0 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Box, Text, Button } from '@tlon/indigo-react';
|
||||
|
||||
export class ResubscribeElement extends Component {
|
||||
onClickResubscribe() {
|
||||
this.props.api.chat.addSynced(
|
||||
this.props.host,
|
||||
this.props.station,
|
||||
true);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
if (props.isChatUnsynced) {
|
||||
return (
|
||||
<Box p='3' m='3' border='1px solid' borderColor='yellow' backgroundColor='lightYellow'>
|
||||
<Text lineHeight='tall' display='block'>
|
||||
Your ship has been disconnected from the chat's host.
|
||||
This may be due to a bad connection, going offline, lack of permission,
|
||||
or an over-the-air update.
|
||||
</Text>
|
||||
<Button
|
||||
primary
|
||||
mt='3'
|
||||
onClick={this.onClickResubscribe.bind(this)}
|
||||
>
|
||||
Reconnect to this chat
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
@ -76,9 +76,9 @@ export default function LaunchApp(props) {
|
||||
<Row alignItems='center'>
|
||||
<Icon
|
||||
color="black"
|
||||
icon="Mail"
|
||||
icon="Home"
|
||||
/>
|
||||
<Text ml="2" mt='1px' color="black">DMs + Drafts</Text>
|
||||
<Text ml="2" mt='1px' color="black">My Channels</Text>
|
||||
</Row>
|
||||
</Box>
|
||||
</Tile>
|
||||
|
@ -61,7 +61,6 @@ export function LinkResource(props: LinkResourceProps) {
|
||||
return <Center width='100%' height='100%'><LoadingSpinner/></Center>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Col alignItems="center" height="100%" width="100%" overflowY="hidden">
|
||||
<Switch>
|
||||
|
@ -15,6 +15,7 @@ import GlobalApi from "~/logic/api/global";
|
||||
import VirtualScroller from "~/views/components/VirtualScroller";
|
||||
import { LinkItem } from "./components/LinkItem";
|
||||
import LinkSubmit from "./components/LinkSubmit";
|
||||
import {isWriter} from "~/logic/lib/group";
|
||||
|
||||
interface LinkWindowProps {
|
||||
association: Association;
|
||||
@ -49,6 +50,7 @@ export function LinkWindow(props: LinkWindowProps) {
|
||||
|
||||
const first = graph.peekLargest()?.[0];
|
||||
const [,,ship, name] = association.resource.split('/');
|
||||
const canWrite = isWriter(props.group, association.resource)
|
||||
|
||||
const style = useMemo(() =>
|
||||
({
|
||||
@ -86,7 +88,7 @@ export function LinkWindow(props: LinkWindowProps) {
|
||||
measure,
|
||||
key: index.toString()
|
||||
};
|
||||
if(index.eq(first ?? bigInt.zero)) {
|
||||
if(canWrite && index.eq(first ?? bigInt.zero)) {
|
||||
return (
|
||||
<>
|
||||
<Col key={index.toString()} mx="auto" mt="4" maxWidth="768px" width="100%" flexShrink={0} px={3}>
|
||||
|
@ -139,8 +139,13 @@ const GraphNodeContent = ({ group, post, contacts, mod, description, index, remo
|
||||
return null;
|
||||
};
|
||||
|
||||
function getNodeUrl(mod: string, group: string, graph: string, index: string) {
|
||||
const graphUrl = `/~landscape${group}/resource/${mod}${graph}`;
|
||||
function getNodeUrl(mod: string, group: boolean, groupPath: string, graph: string, index: string) {
|
||||
if (!group && mod === 'chat') {
|
||||
groupPath = '/messages';
|
||||
} else if (!group) {
|
||||
groupPath = '/home';
|
||||
}
|
||||
const graphUrl = `/~landscape${groupPath}/resource/${mod}${graph}`;
|
||||
const idx = index.slice(1).split("/");
|
||||
if (mod === "publish") {
|
||||
const [noteId] = idx;
|
||||
@ -186,7 +191,7 @@ const GraphNode = ({
|
||||
|
||||
const groupContacts = contacts[groupPath] ?? {};
|
||||
|
||||
const nodeUrl = getNodeUrl(mod, group?.hidden ? '/home' : groupPath, graph, index);
|
||||
const nodeUrl = getNodeUrl(mod, group?.hidden, groupPath, graph, index);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if(!read) {
|
||||
|
@ -36,7 +36,6 @@ export function Header(props: {
|
||||
time: number;
|
||||
read: boolean;
|
||||
associations: Associations;
|
||||
chat?: boolean;
|
||||
} & PropFunc<typeof Row> ) {
|
||||
const { description, channel, group, moduleIcon, read } = props;
|
||||
const contacts = props.contacts[group] || {};
|
||||
@ -65,7 +64,7 @@ export function Header(props: {
|
||||
const groupTitle =
|
||||
props.associations.groups?.[props.group]?.metadata?.title;
|
||||
|
||||
const app = props.chat ? 'chat' : 'graph';
|
||||
const app = 'graph';
|
||||
const channelTitle =
|
||||
(channel && props.associations?.[app]?.[channel]?.metadata?.title) ||
|
||||
channel;
|
||||
|
@ -34,8 +34,7 @@ interface NotificationProps {
|
||||
function getMuted(
|
||||
idxNotif: IndexedNotification,
|
||||
groups: GroupNotificationsConfig,
|
||||
graphs: NotificationGraphConfig,
|
||||
chat: string[]
|
||||
graphs: NotificationGraphConfig
|
||||
) {
|
||||
const { index, notification } = idxNotif;
|
||||
if ("graph" in idxNotif.index) {
|
||||
@ -64,7 +63,6 @@ function NotificationWrapper(props: {
|
||||
archived: boolean;
|
||||
graphConfig: NotificationGraphConfig;
|
||||
groupConfig: GroupNotificationsConfig;
|
||||
chatConfig: string[];
|
||||
}) {
|
||||
const { api, time, notif, children } = props;
|
||||
|
||||
@ -75,8 +73,7 @@ function NotificationWrapper(props: {
|
||||
const isMuted = getMuted(
|
||||
notif,
|
||||
props.groupConfig,
|
||||
props.graphConfig,
|
||||
props.chatConfig
|
||||
props.graphConfig
|
||||
);
|
||||
|
||||
const onChangeMute = useCallback(async () => {
|
||||
|
@ -52,7 +52,7 @@ export function Note(props: NoteProps & RouteComponentProps) {
|
||||
|
||||
|
||||
let adminLinks: JSX.Element | null = null;
|
||||
if (window.ship === note?.post?.author) {
|
||||
if (true || window.ship === note?.post?.author) {
|
||||
adminLinks = (
|
||||
<Box display="inline-block" verticalAlign="middle">
|
||||
<Link to={`${baseUrl}/edit`}>
|
||||
|
@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import { RouteComponentProps, Link } from "react-router-dom";
|
||||
import { NotebookPosts } from "./NotebookPosts";
|
||||
import { Col } from "@tlon/indigo-react";
|
||||
import { Col, Box, Text, Button, Row } from "@tlon/indigo-react";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Contacts, Rolodex, Groups, Associations, Graph, Association, Unreads } from "~/types";
|
||||
import { useShowNickname } from "~/logic/lib/util";
|
||||
|
||||
interface NotebookProps {
|
||||
api: GlobalApi;
|
||||
@ -35,8 +36,27 @@ export function Notebook(props: NotebookProps & RouteComponentProps) {
|
||||
return null; // Waiting on groups to populate
|
||||
}
|
||||
|
||||
|
||||
const relativePath = (p: string) => props.baseUrl + p;
|
||||
|
||||
const contact = notebookContacts?.[ship];
|
||||
const isOwn = `~${window.ship}` === ship;
|
||||
console.log(association.resource);
|
||||
|
||||
const showNickname = useShowNickname(contact);
|
||||
|
||||
return (
|
||||
<Col gapY="4" pt={4} mx="auto" px={3} maxWidth="768px">
|
||||
<Row justifyContent="space-between">
|
||||
<Box>
|
||||
<Text display='block'>{association.metadata?.title}</Text>
|
||||
<Text color="lightGray">by </Text>
|
||||
<Text fontFamily={showNickname ? 'sans' : 'mono'}>
|
||||
{showNickname ? contact?.nickname : ship}
|
||||
</Text>
|
||||
</Box>
|
||||
</Row>
|
||||
<Box borderBottom="1" borderBottomColor="washedGray" />
|
||||
<NotebookPosts
|
||||
graph={graph}
|
||||
host={ship}
|
||||
|
@ -18,7 +18,7 @@ export class Writers extends Component {
|
||||
const ships = values.ships.map(e => `~${e}`);
|
||||
await api.groups.addTag(
|
||||
resource,
|
||||
{ app: 'publish', tag: `writers-${name}` },
|
||||
{ app: 'graph', resource: association.resource, tag: `writers` },
|
||||
ships
|
||||
);
|
||||
actions.resetForm();
|
||||
@ -28,7 +28,8 @@ export class Writers extends Component {
|
||||
actions.setStatus({ error: e.message });
|
||||
}
|
||||
};
|
||||
const writers = Array.from(groups?.[association?.group]?.tags.publish?.[`writers-${name}`] || new Set()).map(e => cite(`~${e}`)).join(', ');
|
||||
const writers = Array.from(groups?.[association?.group]?.tags.graph[association.resource]?.writers || []).map(s => `~${s}`).join(', ');
|
||||
|
||||
|
||||
return (
|
||||
<Box maxWidth='512px'>
|
||||
@ -51,10 +52,14 @@ export class Writers extends Component {
|
||||
</AsyncButton>
|
||||
</Form>
|
||||
</Formik>
|
||||
{writers.length > 0 && <>
|
||||
{writers.length > 0 ? <>
|
||||
<Text display='block' mt='2'>Current writers:</Text>
|
||||
<Text mt='2' display='block' mono>{writers}</Text>
|
||||
</>}
|
||||
</> :
|
||||
<Text display='block' mt='2'>
|
||||
All group members can write to this channel
|
||||
</Text>
|
||||
}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
@ -32,7 +32,9 @@ export default function NewPost(props: NewPostProps & RouteComponentProps) {
|
||||
try {
|
||||
const [noteId, nodes] = newPost(title, body)
|
||||
await api.graph.addNodes(ship, book, nodes)
|
||||
await waiter(p => p.graph.has(noteId));
|
||||
await waiter(p =>
|
||||
p.graph.has(noteId) && !p.graph.get(noteId)?.post?.pending
|
||||
);
|
||||
history.push(`${props.baseUrl}/note/${noteId}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
@ -4,11 +4,12 @@ import { Button, LoadingSpinner } from "@tlon/indigo-react";
|
||||
|
||||
import { useFormikContext } from "formik";
|
||||
|
||||
export function AsyncButton({
|
||||
export function AsyncButton<T = any>({
|
||||
children,
|
||||
onSuccess = () => {},
|
||||
...rest
|
||||
}: Parameters<typeof Button>[0]) {
|
||||
const { isSubmitting, status, isValid } = useFormikContext();
|
||||
const { isSubmitting, status, isValid, setStatus } = useFormikContext<T>();
|
||||
const [success, setSuccess] = useState<boolean | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
@ -16,6 +17,7 @@ export function AsyncButton({
|
||||
let done = false;
|
||||
if ("success" in s) {
|
||||
setSuccess(true);
|
||||
onSuccess();
|
||||
done = true;
|
||||
} else if ("error" in s) {
|
||||
setSuccess(false);
|
||||
@ -25,6 +27,7 @@ export function AsyncButton({
|
||||
setTimeout(() => {
|
||||
setSuccess(undefined);
|
||||
}, 1500);
|
||||
done = false;
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
|
@ -11,6 +11,7 @@ import { createPost, createBlankNodeWithChildPost } from '~/logic/api/graph';
|
||||
import { getLatestCommentRevision } from '~/logic/lib/publish';
|
||||
import { scanForMentions } from '~/logic/lib/graph';
|
||||
import { getUnreadCount } from '~/logic/lib/hark';
|
||||
import {isWriter} from '~/logic/lib/group';
|
||||
|
||||
interface CommentsProps {
|
||||
comments: GraphNode;
|
||||
@ -92,18 +93,19 @@ export function Comments(props: CommentsProps) {
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
console.log(`dismissing ${association?.resource}`);
|
||||
return () => {
|
||||
api.hark.markCountAsRead(association, parentIndex, 'comment')
|
||||
};
|
||||
}, [comments.post.index])
|
||||
|
||||
|
||||
const readCount = children.length - getUnreadCount(props?.unreads, association.resource, parentIndex)
|
||||
const readCount = children.length - getUnreadCount(props?.unreads, association.resource, parentIndex);
|
||||
|
||||
const canComment = isWriter(group, association.resource) || association.metadata.vip === 'reader-comments';
|
||||
|
||||
return (
|
||||
<Col>
|
||||
{( !props.editCommentId ? <CommentInput onSubmit={onSubmit} /> : null )}
|
||||
{( !props.editCommentId && canComment ? <CommentInput onSubmit={onSubmit} /> : null )}
|
||||
{( !!props.editCommentId ? (
|
||||
<CommentInput
|
||||
onSubmit={onEdit}
|
||||
|
@ -37,6 +37,7 @@ interface DropdownSearchExtraProps<C> {
|
||||
placeholder?: string;
|
||||
onChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onBlur?: (e: any) => void;
|
||||
onFocus?: (e: FocusEvent) => void;
|
||||
}
|
||||
|
||||
type DropdownSearchProps<C> = PropFunc<typeof Box> &
|
||||
@ -53,6 +54,7 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
|
||||
renderCandidate,
|
||||
disabled,
|
||||
placeholder,
|
||||
onFocus = () => {},
|
||||
onChange = () => {},
|
||||
onBlur = () => {},
|
||||
...rest
|
||||
@ -101,7 +103,7 @@ export function DropdownSearch<C>(props: DropdownSearchProps<C>) {
|
||||
return () => {
|
||||
mousetrap.unbind(["down", "tab"]);
|
||||
mousetrap.unbind(["up", "shift+tab"]);
|
||||
mousetrap.unbind("enter", onEnter);
|
||||
mousetrap.unbind("enter");
|
||||
};
|
||||
}, [textarea.current, next, back, onEnter]);
|
||||
|
||||
|
41
pkg/interface/src/views/components/FormSubmit.tsx
Normal file
41
pkg/interface/src/views/components/FormSubmit.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React, { useCallback, ReactNode } from "react";
|
||||
import { useFormikContext } from "formik";
|
||||
import { Row, Button } from "@tlon/indigo-react";
|
||||
import { AsyncButton } from "./AsyncButton";
|
||||
|
||||
interface FormSubmitProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function FormSubmit<T = any>(props: FormSubmitProps) {
|
||||
const { children } = props;
|
||||
const { initialValues, values, dirty, resetForm, isSubmitting } = useFormikContext<T>();
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
resetForm({ errors: {}, touched: {}, values, status: {} });
|
||||
}, [resetForm, values]);
|
||||
|
||||
const handleRevert = useCallback(() => {
|
||||
resetForm({ errors: {}, touched: {}, values: initialValues, status: {} });
|
||||
}, [resetForm, initialValues]);
|
||||
|
||||
|
||||
return (
|
||||
<Row
|
||||
p="2"
|
||||
bottom="0px"
|
||||
justifyContent="flex-end"
|
||||
gapX="2"
|
||||
alignItems="center"
|
||||
>
|
||||
{dirty && !isSubmitting && (
|
||||
<Button onClick={handleRevert} backgroundColor="washedGray">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<AsyncButton onSuccess={handleSuccess} primary>
|
||||
{children}
|
||||
</AsyncButton>
|
||||
</Row>
|
||||
);
|
||||
}
|
@ -2,6 +2,7 @@ import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { Box } from "@tlon/indigo-react";
|
||||
import { PropFunc } from "~/types/util";
|
||||
interface HoverBoxProps {
|
||||
selected: boolean;
|
||||
bg: string;
|
||||
@ -16,7 +17,15 @@ export const HoverBox = styled(Box)<HoverBoxProps>`
|
||||
}
|
||||
`;
|
||||
|
||||
export const HoverBoxLink = ({ to, children, ...rest }) => (
|
||||
interface HoverBoxLinkProps {
|
||||
to: string;
|
||||
}
|
||||
|
||||
export const HoverBoxLink = ({
|
||||
to,
|
||||
children,
|
||||
...rest
|
||||
}: HoverBoxLinkProps & PropFunc<typeof HoverBox>) => (
|
||||
<Link to={to}>
|
||||
<HoverBox {...rest}>{children}</HoverBox>
|
||||
</Link>
|
||||
|
149
pkg/interface/src/views/components/IconRadio.tsx
Normal file
149
pkg/interface/src/views/components/IconRadio.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import styled from "styled-components";
|
||||
import {
|
||||
Icon,
|
||||
Box,
|
||||
Row,
|
||||
BaseLabel,
|
||||
Indicator,
|
||||
Col,
|
||||
Label,
|
||||
} from "@tlon/indigo-react";
|
||||
import { useField } from "formik";
|
||||
|
||||
type IconRadioProps = Parameters<typeof Row>[0] & {
|
||||
id: string;
|
||||
icon: string;
|
||||
name: string;
|
||||
disabled?: boolean;
|
||||
caption?: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
// Hide this input completely
|
||||
const HiddenInput = styled.input`
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
width: 0;
|
||||
margin: 0px;
|
||||
`;
|
||||
|
||||
type IconIndicatorProps = Parameters<typeof Box> & {
|
||||
disabled?: boolean;
|
||||
selected?: boolean;
|
||||
hasError?: boolean;
|
||||
};
|
||||
|
||||
// stolen from indigo
|
||||
// TODO: indigo should probably export this
|
||||
const indicator = {
|
||||
state: {
|
||||
on: {
|
||||
//"*": { fill: "white" },
|
||||
backgroundColor: "blue",
|
||||
borderColor: "blue",
|
||||
},
|
||||
off: {
|
||||
//"*": { fill: "transparent" },
|
||||
backgroundColor: "white",
|
||||
borderColor: "lightGray",
|
||||
},
|
||||
onError: {
|
||||
//"*": { fill: "white" },
|
||||
backgroundColor: "red",
|
||||
borderColor: "red",
|
||||
},
|
||||
offError: {
|
||||
// "*": { fill: "transparent" },
|
||||
backgroundColor: "washedRed",
|
||||
borderColor: "red",
|
||||
},
|
||||
offDisabled: {
|
||||
//"*": { fill: "transparent" },
|
||||
backgroundColor: "washedGray",
|
||||
borderColor: "lightGray",
|
||||
},
|
||||
onDisabled: {
|
||||
//"*": { fill: "lightGray" },
|
||||
backgroundColor: "washedGray",
|
||||
borderColor: "lightGray",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const IconIndicator = ({ disabled, selected, hasError, children, ...rest }) => {
|
||||
const style = useMemo(() => {
|
||||
if (selected && disabled) return indicator.state.onDisabled;
|
||||
if (selected && hasError) return indicator.state.onError;
|
||||
if (selected) return indicator.state.on;
|
||||
if (disabled) return indicator.state.offDisabled;
|
||||
if (hasError) return indicator.state.offError;
|
||||
return indicator.state.off;
|
||||
}, [selected, disabled, hasError]);
|
||||
|
||||
return (
|
||||
<Box borderRadius="1" border="1" {...rest} {...style}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export function IconRadio(props: IconRadioProps) {
|
||||
const { id, icon, disabled, caption, label, name, ...rest } = props;
|
||||
const [field, meta, { setTouched }] = useField({
|
||||
name,
|
||||
id,
|
||||
value: id,
|
||||
type: "radio",
|
||||
});
|
||||
|
||||
const onChange = useCallback(
|
||||
(e: React.ChangeEvent) => {
|
||||
setTouched(true);
|
||||
field.onChange(e);
|
||||
},
|
||||
[field.onChange, setTouched]
|
||||
);
|
||||
|
||||
return (
|
||||
<Row {...rest}>
|
||||
<BaseLabel
|
||||
htmlFor={id}
|
||||
display="flex"
|
||||
flexDirection="row"
|
||||
cursor="pointer"
|
||||
>
|
||||
<IconIndicator
|
||||
hasError={meta.touched && meta.error !== undefined}
|
||||
selected={field.checked}
|
||||
disabled={disabled}
|
||||
mr="2"
|
||||
>
|
||||
<Icon
|
||||
m="2"
|
||||
color={field.checked ? "white" : "black"}
|
||||
icon={icon as any}
|
||||
/>
|
||||
</IconIndicator>
|
||||
<Col justifyContent="space-around">
|
||||
<Label color={field.checked ? "blue" : "black"}>{label}</Label>
|
||||
{caption ? (
|
||||
<Label gray mt="2">
|
||||
{caption}
|
||||
</Label>
|
||||
) : null}
|
||||
<HiddenInput
|
||||
{...field}
|
||||
onChange={onChange}
|
||||
value={id}
|
||||
name={name}
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
type="radio"
|
||||
/>
|
||||
</Col>
|
||||
</BaseLabel>
|
||||
</Row>
|
||||
);
|
||||
}
|
@ -12,8 +12,16 @@ export class InviteItem extends Component<{invite: Invite, onAccept: (i: any) =>
|
||||
<>
|
||||
<Box width='100%' p='4'>
|
||||
<Box width='100%' verticalAlign='middle'>
|
||||
<Text display='block' pb='2' gray><Text mono>{cite(props.invite.resource.ship)}</Text> invited you to <Text fontWeight='500'>{props.invite.resource.name}</Text></Text>
|
||||
<Text display='block' pb='2' gray>
|
||||
<Text mono>{cite(props.invite.resource.ship)}</Text>
|
||||
{" "}invited you to{" "}
|
||||
<Text fontWeight='500'>{props.invite.resource.name}</Text></Text>
|
||||
</Box>
|
||||
{props.invite.text && (
|
||||
<Box pb="2">
|
||||
<Text gray>{props.invite.text}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Row>
|
||||
<StatelessAsyncAction
|
||||
name="accept"
|
||||
|
29
pkg/interface/src/views/components/ModalOverlay.tsx
Normal file
29
pkg/interface/src/views/components/ModalOverlay.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { Box } from "@tlon/indigo-react";
|
||||
import { PropFunc } from "~/types/util";
|
||||
|
||||
interface ModalOverlayProps {
|
||||
spacing: PropFunc<typeof Box>["m"];
|
||||
}
|
||||
export const ModalOverlay = React.forwardRef(
|
||||
(props: ModalOverlayProps & PropFunc<typeof Box>, ref) => {
|
||||
const { spacing, ...rest } = props;
|
||||
return (
|
||||
<Box
|
||||
backgroundColor="scales.black20"
|
||||
left="0px"
|
||||
top="0px"
|
||||
width="100%"
|
||||
height="100%"
|
||||
zIndex={10}
|
||||
position="fixed"
|
||||
display="flex"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
p={spacing}
|
||||
>
|
||||
<Box ref={ref} {...rest} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
);
|
@ -1,8 +1,25 @@
|
||||
import React, { useMemo, useCallback, ChangeEvent, useState, SyntheticEvent, useEffect } from "react";
|
||||
import { Box, Label, Icon, Text, Row, Col, ErrorLabel } from "@tlon/indigo-react";
|
||||
import React, {
|
||||
useMemo,
|
||||
useCallback,
|
||||
ChangeEvent,
|
||||
useState,
|
||||
SyntheticEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
Box,
|
||||
Label,
|
||||
Icon,
|
||||
Text,
|
||||
Row,
|
||||
Col,
|
||||
ErrorLabel,
|
||||
} from "@tlon/indigo-react";
|
||||
import _ from "lodash";
|
||||
import ob from "urbit-ob";
|
||||
import { useField } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { useField, FieldArray, useFormikContext } from "formik";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { DropdownSearch } from "./DropdownSearch";
|
||||
@ -13,18 +30,47 @@ import { HoverBox } from "./HoverBox";
|
||||
|
||||
const INVALID_SHIP_ERR = "Invalid ship";
|
||||
|
||||
interface InviteSearchProps {
|
||||
interface InviteSearchProps<I extends string> {
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
caption?: string;
|
||||
id: string;
|
||||
id: I;
|
||||
contacts: Rolodex;
|
||||
groups: Groups;
|
||||
hideSelection?: boolean;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
const getNicknameForShips = (groups: Groups, contacts: Rolodex) => {
|
||||
const peerSet = new Set<string>();
|
||||
const nicknames = new Map<string, string[]>();
|
||||
_.forEach(groups, (group, path) => {
|
||||
if (group.members.size > 0) {
|
||||
const groupEntries = group.members.values();
|
||||
for (const member of groupEntries) {
|
||||
peerSet.add(member);
|
||||
}
|
||||
}
|
||||
|
||||
const groupContacts = contacts[path];
|
||||
|
||||
if (groupContacts) {
|
||||
const groupEntries = group.members.values();
|
||||
for (const member of groupEntries) {
|
||||
if (groupContacts[member]) {
|
||||
if (nicknames.has(member)) {
|
||||
nicknames.get(member)?.push(groupContacts[member].nickname);
|
||||
} else {
|
||||
nicknames.set(member, [groupContacts[member].nickname]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return [Array.from(peerSet), nicknames] as const;
|
||||
};
|
||||
|
||||
const Candidate = ({ title, detail, selected, onClick }) => (
|
||||
<HoverBox
|
||||
display="flex"
|
||||
@ -45,100 +91,46 @@ const Candidate = ({ title, detail, selected, onClick }) => (
|
||||
</HoverBox>
|
||||
);
|
||||
|
||||
export function ShipSearch(props: InviteSearchProps) {
|
||||
type Value<I extends string> = {
|
||||
[k in I]: string[];
|
||||
};
|
||||
|
||||
const shipItemSchema = Yup.string().test(
|
||||
"is-patp",
|
||||
"${value} is not a valid @p",
|
||||
ob.isValidPatp
|
||||
);
|
||||
|
||||
export const shipSearchSchema = Yup.array(shipItemSchema).compact();
|
||||
|
||||
export const shipSearchSchemaInGroup = (members: string[]) =>
|
||||
Yup.array(shipItemSchema.oneOf(members, "${value} not a member of this group")).compact();
|
||||
|
||||
export function ShipSearch<I extends string, V extends Value<I>>(
|
||||
props: InviteSearchProps<I>
|
||||
) {
|
||||
const { id, label, caption } = props;
|
||||
const [{}, meta, { setValue, setTouched, setError: _setError }] = useField<string[]>({
|
||||
name: id,
|
||||
multiple: true
|
||||
});
|
||||
const {
|
||||
values,
|
||||
touched,
|
||||
errors,
|
||||
initialValues,
|
||||
setFieldValue,
|
||||
} = useFormikContext<V>();
|
||||
|
||||
const setError = _setError as unknown as (s: string | undefined) => void;
|
||||
const inputIdx = useRef(initialValues[id].length);
|
||||
|
||||
const { error, touched } = meta;
|
||||
const selected: string[] = values[id] ?? [];
|
||||
|
||||
const [selected, setSelected] = useState([] as string[]);
|
||||
const [inputShip, setInputShip] = useState(undefined as string | undefined);
|
||||
const [inputTouched, setInputTouched] = useState(false);
|
||||
const name = () => `${props.id}[${inputIdx.current}]`;
|
||||
|
||||
const checkInput = useCallback((valid: boolean, ship: string | undefined) => {
|
||||
if(valid) {
|
||||
setInputShip(ship);
|
||||
setError(error === INVALID_SHIP_ERR ? undefined : error);
|
||||
} else if (ship === undefined) {
|
||||
return;
|
||||
} else {
|
||||
setError(INVALID_SHIP_ERR);
|
||||
setInputTouched(false);
|
||||
}
|
||||
}, [setError, error, setInputTouched, setInputShip]);
|
||||
const pills = selected.slice(0, inputIdx.current);
|
||||
|
||||
const onChange = useCallback(
|
||||
(e: any) => {
|
||||
let ship = `~${deSig(e.target.value) || ""}`;
|
||||
if(ob.isValidPatp(ship)) {
|
||||
checkInput(true, ship);
|
||||
} else {
|
||||
checkInput(ship.length !== 1, undefined)
|
||||
}
|
||||
},
|
||||
[checkInput]
|
||||
const [peers, nicknames] = useMemo(
|
||||
() => getNicknameForShips(props.groups, props.contacts),
|
||||
[props.contacts, props.groups]
|
||||
);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
setInputTouched(true);
|
||||
}, [setInputTouched]);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(s: string) => {
|
||||
setTouched(true);
|
||||
checkInput(true, undefined);
|
||||
s = `${deSig(s)}`;
|
||||
setSelected(v => _.uniq([...v, s]))
|
||||
},
|
||||
[setTouched, checkInput, setSelected]
|
||||
);
|
||||
|
||||
const onRemove = useCallback(
|
||||
(s: string) => {
|
||||
setSelected(ships => ships.filter(ship => ship !== s))
|
||||
},
|
||||
[setSelected]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const newValue = inputShip ? [...selected, inputShip] : selected;
|
||||
setValue(newValue);
|
||||
}, [inputShip, selected])
|
||||
|
||||
const [peers, nicknames] = useMemo(() => {
|
||||
const peerSet = new Set<string>();
|
||||
const contacts = new Map<string, string[]>();
|
||||
_.forEach(props.groups, (group, path) => {
|
||||
if (group.members.size > 0) {
|
||||
const groupEntries = group.members.values();
|
||||
for (const member of groupEntries) {
|
||||
peerSet.add(member);
|
||||
}
|
||||
}
|
||||
|
||||
const groupContacts = props.contacts[path];
|
||||
|
||||
if (groupContacts) {
|
||||
const groupEntries = group.members.values();
|
||||
for (const member of groupEntries) {
|
||||
if (groupContacts[member]) {
|
||||
if (contacts.has(member)) {
|
||||
contacts.get(member)?.push(groupContacts[member].nickname);
|
||||
} else {
|
||||
contacts.set(member, [groupContacts[member].nickname]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return [Array.from(peerSet), contacts] as const;
|
||||
}, [props.contacts, props.groups]);
|
||||
|
||||
const renderCandidate = useCallback(
|
||||
(s: string, selected: boolean, onSelect: (s: string) => void) => {
|
||||
const detail = _.uniq(nicknames.get(s)).join(", ");
|
||||
@ -158,62 +150,87 @@ export function ShipSearch(props: InviteSearchProps) {
|
||||
[nicknames]
|
||||
);
|
||||
|
||||
const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const newValue =
|
||||
e.target.value?.length > 0 ? `~${deSig(e.target.value)}` : "";
|
||||
setFieldValue(name(), newValue);
|
||||
};
|
||||
|
||||
const error = _.compact(errors[id] as string[]);
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{caption && (
|
||||
<Label gray mt="2">
|
||||
{caption}
|
||||
</Label>
|
||||
)}
|
||||
<DropdownSearch<string>
|
||||
mt="2"
|
||||
isExact={(s) => {
|
||||
const ship = `~${deSig(s)}`;
|
||||
const result = ob.isValidPatp(ship);
|
||||
return result ? deSig(s) ?? undefined : undefined;
|
||||
}}
|
||||
placeholder="Search for ships..."
|
||||
candidates={peers}
|
||||
renderCandidate={renderCandidate}
|
||||
disabled={props.maxLength ? selected.length >= props.maxLength : false}
|
||||
search={(s: string, t: string) =>
|
||||
t.toLowerCase().startsWith(s.toLowerCase())
|
||||
}
|
||||
getKey={(s: string) => s}
|
||||
onSelect={onSelect}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<Row minHeight="34px" flexWrap="wrap">
|
||||
{selected.map((s) => (
|
||||
<Row
|
||||
fontFamily="mono"
|
||||
alignItems="center"
|
||||
py={1}
|
||||
px={2}
|
||||
color="black"
|
||||
borderRadius='2'
|
||||
bg='washedGray'
|
||||
fontSize={0}
|
||||
mt={2}
|
||||
mr={2}
|
||||
>
|
||||
<Text fontFamily="mono">{cite(s)}</Text>
|
||||
<Icon
|
||||
icon="X"
|
||||
ml={2}
|
||||
onClick={() => onRemove(s)}
|
||||
cursor="pointer"
|
||||
<FieldArray
|
||||
name={id}
|
||||
render={(arrayHelpers) => {
|
||||
const onAdd = () => {
|
||||
inputIdx.current += 1;
|
||||
arrayHelpers.push("");
|
||||
};
|
||||
|
||||
const onRemove = (idx: number) => {
|
||||
inputIdx.current -= 1;
|
||||
arrayHelpers.remove(idx);
|
||||
};
|
||||
|
||||
return (
|
||||
<Col>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{caption && (
|
||||
<Label gray mt="2">
|
||||
{caption}
|
||||
</Label>
|
||||
)}
|
||||
|
||||
<DropdownSearch<string>
|
||||
mt="2"
|
||||
isExact={(s) => {
|
||||
const ship = `~${deSig(s)}`;
|
||||
const result = ob.isValidPatp(ship);
|
||||
return result ? deSig(s) ?? undefined : undefined;
|
||||
}}
|
||||
placeholder="Search for ships"
|
||||
candidates={peers}
|
||||
renderCandidate={renderCandidate}
|
||||
disabled={
|
||||
props.maxLength ? selected.length >= props.maxLength : false
|
||||
}
|
||||
search={(s: string, t: string) =>
|
||||
(t || "").toLowerCase().startsWith(s.toLowerCase())
|
||||
}
|
||||
getKey={(s: string) => s}
|
||||
onChange={onChange}
|
||||
onSelect={onAdd}
|
||||
/>
|
||||
</Row>
|
||||
))}
|
||||
</Row>
|
||||
<ErrorLabel
|
||||
mt="3"
|
||||
hasError={error === INVALID_SHIP_ERR ? inputTouched : !!(touched && error)}>
|
||||
{error}
|
||||
</ErrorLabel>
|
||||
</Col>
|
||||
<Row minHeight="34px" flexWrap="wrap">
|
||||
{pills.map((s, i) => (
|
||||
<Row
|
||||
fontFamily="mono"
|
||||
alignItems="center"
|
||||
py={1}
|
||||
px={2}
|
||||
color="black"
|
||||
borderRadius="2"
|
||||
bg="washedGray"
|
||||
fontSize={0}
|
||||
mt={2}
|
||||
mr={2}
|
||||
>
|
||||
<Text fontFamily="mono">{cite(s)}</Text>
|
||||
<Icon
|
||||
icon="X"
|
||||
ml={2}
|
||||
onClick={() => onRemove(i)}
|
||||
cursor="pointer"
|
||||
/>
|
||||
</Row>
|
||||
))}
|
||||
</Row>
|
||||
<ErrorLabel mt="3" hasError={error.length > 0}>
|
||||
{error.join(", ")}
|
||||
</ErrorLabel>
|
||||
</Col>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -91,6 +91,9 @@ const StatusBar = (props) => {
|
||||
>
|
||||
<Text color='#000000'>Submit <Text color='#000000' display={['none', 'inline']}>an</Text> issue</Text>
|
||||
</StatusBarItem>
|
||||
<StatusBarItem mr={2} onClick={() => props.history.push('/~landscape/messages')}>
|
||||
<Icon icon="Users"/>
|
||||
</StatusBarItem>
|
||||
<Dropdown
|
||||
dropWidth="150px"
|
||||
width="auto"
|
||||
|
@ -53,7 +53,7 @@ export class OmniboxResult extends Component {
|
||||
text = text.startsWith('Profile') ? window.ship : text;
|
||||
graphic = <Sigil color={color} classes='dib flex-shrink-0 v-mid mr2' ship={text} size={18} icon padded />;
|
||||
} else if (icon === 'home') {
|
||||
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Mail' mr='2' size='18px' color={iconFill} />;
|
||||
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Home' mr='2' size='18px' color={iconFill} />;
|
||||
} else if (icon === 'notifications') {
|
||||
graphic = <Icon display='inline-block' verticalAlign='middle' icon='Inbox' mr='2' size='18px' color={iconFill} />;
|
||||
} else {
|
||||
|
@ -1,127 +0,0 @@
|
||||
import React, { useCallback } from "react";
|
||||
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, NotificationGraphConfig } from "~/types";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { StatelessAsyncAction } from "~/views/components/StatelessAsyncAction";
|
||||
import { appIsGraph } from "~/logic/lib/util";
|
||||
|
||||
const ChannelMenuItem = ({
|
||||
icon,
|
||||
color = undefined as string | undefined,
|
||||
children,
|
||||
bottom = false,
|
||||
}) => (
|
||||
<Row
|
||||
alignItems="center"
|
||||
borderBottom={bottom ? 0 : 1}
|
||||
borderBottomColor="lightGray"
|
||||
px={2}
|
||||
py={1}
|
||||
>
|
||||
<Icon color={color} icon={icon} />
|
||||
{children}
|
||||
</Row>
|
||||
);
|
||||
|
||||
interface ChannelMenuProps {
|
||||
association: Association;
|
||||
api: GlobalApi;
|
||||
graphNotificationConfig: NotificationGraphConfig;
|
||||
chatNotificationConfig: string[];
|
||||
}
|
||||
|
||||
export function ChannelMenu(props: ChannelMenuProps) {
|
||||
const { association, api } = props;
|
||||
const history = useHistory();
|
||||
const { metadata } = association;
|
||||
const app = metadata.module || association["app-name"];
|
||||
const workspace = history.location.pathname.startsWith("/~landscape/home")
|
||||
? "/home"
|
||||
: association?.group;
|
||||
const baseUrl = `/~landscape${workspace}/resource/${app}${association.resource}`;
|
||||
const rid = association.resource;
|
||||
|
||||
const [,, ship, name] = rid.split("/");
|
||||
|
||||
const isOurs = ship.slice(1) === window.ship;
|
||||
|
||||
const isMuted =
|
||||
props.graphNotificationConfig.watching.findIndex(
|
||||
(a) => a.graph === rid && a.index === "/"
|
||||
) === -1;
|
||||
|
||||
const onChangeMute = async () => {
|
||||
const func = isMuted ? "listenGraph" : "ignoreGraph";
|
||||
await api.hark[func](rid, "/");
|
||||
};
|
||||
const onUnsubscribe = useCallback(async () => {
|
||||
await api.graph.leaveGraph(ship, name);
|
||||
history.push(`/~landscape${workspace}`);
|
||||
}, [api, association]);
|
||||
|
||||
const onDelete = useCallback(async () => {
|
||||
if (confirm('Are you sure you want to delete this channel?')) {
|
||||
await api.graph.deleteGraph(name);
|
||||
history.push(`/~landscape${workspace}`);
|
||||
}
|
||||
}, [api, association]);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
options={
|
||||
<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}
|
||||
>
|
||||
Delete Channel
|
||||
</Action>
|
||||
</ChannelMenuItem>
|
||||
<ChannelMenuItem bottom icon="Gear" color="black">
|
||||
<Link to={`${baseUrl}/settings`}>
|
||||
<Box fontSize={1} p="2">
|
||||
Channel Settings
|
||||
</Box>
|
||||
</Link>
|
||||
</ChannelMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<ChannelMenuItem color="red" bottom icon="ArrowEast">
|
||||
<Action bg="white" m="2" destructive onClick={onUnsubscribe}>
|
||||
Unsubscribe from Channel
|
||||
</Action>
|
||||
</ChannelMenuItem>
|
||||
)}
|
||||
</Col>
|
||||
}
|
||||
alignX="right"
|
||||
alignY="top"
|
||||
dropWidth="250px"
|
||||
>
|
||||
<Icon display="block" icon="Menu" color="gray" pr='2' />
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
@ -0,0 +1,194 @@
|
||||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
Label,
|
||||
ManagedToggleSwitchField as Checkbox,
|
||||
Box,
|
||||
Col,
|
||||
Text,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Formik, Form } from "formik";
|
||||
import { PermVariation, Association, Group, Groups, Rolodex } from "~/types";
|
||||
import { shipSearchSchemaInGroup, } from "~/views/components/ShipSearch";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { resourceFromPath } from "~/logic/lib/group";
|
||||
import { FormSubmit } from "~/views/components/FormSubmit";
|
||||
import { ChannelWritePerms } from "../ChannelWritePerms";
|
||||
|
||||
function PermissionsSummary(props: {
|
||||
writersSize: number;
|
||||
vip: PermVariation;
|
||||
}) {
|
||||
const { writersSize, vip } = props;
|
||||
|
||||
const description =
|
||||
writersSize === 0
|
||||
? "Currently, all members of the group can write to this channel"
|
||||
: `Currently, only ${writersSize} ship${
|
||||
writersSize > 1 ? "s" : ""
|
||||
} can write to this channel`;
|
||||
|
||||
const vipDescription =
|
||||
vip === "reader-comments" && writersSize !== 0
|
||||
? ". All ships may comment"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Box
|
||||
p="2"
|
||||
border="1"
|
||||
borderColor="lightBlue"
|
||||
borderRadius="1"
|
||||
backgroundColor="washedBlue"
|
||||
>
|
||||
<Text>
|
||||
{description}
|
||||
{vipDescription}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
interface GraphPermissionsProps {
|
||||
association: Association;
|
||||
group: Group;
|
||||
groups: Groups;
|
||||
contacts: Rolodex;
|
||||
api: GlobalApi;
|
||||
}
|
||||
|
||||
interface FormSchema {
|
||||
writePerms: "self" | "everyone" | "subset";
|
||||
writers: string[];
|
||||
readerComments: boolean;
|
||||
}
|
||||
|
||||
const formSchema = (members: string[]) => {
|
||||
return Yup.object({
|
||||
writePerms: Yup.string(),
|
||||
writers: shipSearchSchemaInGroup(members),
|
||||
readerComments: Yup.boolean(),
|
||||
});
|
||||
};
|
||||
|
||||
export function GraphPermissions(props: GraphPermissionsProps) {
|
||||
const { api, group, association } = props;
|
||||
|
||||
const writers = _.get(
|
||||
group.tags,
|
||||
["graph", association.resource, "writers"],
|
||||
new Set()
|
||||
);
|
||||
|
||||
let [, , hostShip] = association.resource.split("/");
|
||||
hostShip = hostShip.slice(1);
|
||||
|
||||
const writePerms =
|
||||
writers.size === 0
|
||||
? ("everyone" as const)
|
||||
: writers.size === 1 && writers.has(hostShip)
|
||||
? ("self" as const)
|
||||
: ("subset" as const);
|
||||
|
||||
const readerComments = association.metadata.vip === "reader-comments";
|
||||
|
||||
const initialValues = {
|
||||
writePerms,
|
||||
writers: Array.from(writers)
|
||||
.filter((x) => x !== hostShip)
|
||||
.map((s) => `~${s}`),
|
||||
readerComments: association.metadata.vip === "reader-comments",
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormSchema, actions) => {
|
||||
values.writers = _.compact(values.writers);
|
||||
const resource = resourceFromPath(association.group);
|
||||
const tag = {
|
||||
app: "graph",
|
||||
resource: association.resource,
|
||||
tag: "writers",
|
||||
};
|
||||
const allWriters = Array.from(writers).map((w) => `~${w}`);
|
||||
if (values.readerComments !== readerComments) {
|
||||
await api.metadata.update(association, {
|
||||
vip: values.readerComments ? "reader-comments" : "",
|
||||
});
|
||||
}
|
||||
|
||||
if (values.writePerms === "everyone") {
|
||||
if (writePerms === "everyone") {
|
||||
actions.setStatus({ success: null });
|
||||
return;
|
||||
}
|
||||
await api.groups.removeTag(resource, tag, allWriters);
|
||||
} else if (values.writePerms === "self") {
|
||||
if (writePerms === "self") {
|
||||
actions.setStatus({ success: null });
|
||||
return;
|
||||
}
|
||||
let promises: Promise<any>[] = [];
|
||||
allWriters.length > 0 &&
|
||||
promises.push(api.groups.removeTag(resource, tag, allWriters));
|
||||
promises.push(api.groups.addTag(resource, tag, [`~${hostShip}`]));
|
||||
await Promise.all(promises);
|
||||
actions.setStatus({ success: null });
|
||||
} else if (values.writePerms === "subset") {
|
||||
const toRemove = _.difference(allWriters, values.writers);
|
||||
|
||||
const toAdd = [
|
||||
..._.difference(values.writers, allWriters),
|
||||
`~${hostShip}`,
|
||||
];
|
||||
|
||||
let promises: Promise<any>[] = [];
|
||||
toRemove.length > 0 &&
|
||||
promises.push(api.groups.removeTag(resource, tag, toRemove));
|
||||
toAdd.length > 0 &&
|
||||
promises.push(api.groups.addTag(resource, tag, toAdd));
|
||||
await Promise.all(promises);
|
||||
|
||||
actions.setStatus({ success: null });
|
||||
}
|
||||
};
|
||||
|
||||
const schema = formSchema(Array.from(group.members).map((m) => `~${m}`));
|
||||
|
||||
return (
|
||||
<Formik
|
||||
validationSchema={schema}
|
||||
initialValues={initialValues}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
<Form style={{ display: "contents" }}>
|
||||
<Col mt="4" flexShrink={0} gapY="5">
|
||||
<Col gapY="1">
|
||||
<Text id="permissions" fontWeight="bold" fontSize="2">
|
||||
Permissions
|
||||
</Text>
|
||||
<Text gray>
|
||||
Add or remove read/write privileges to this channel. Group admins
|
||||
can always write to a channel
|
||||
</Text>
|
||||
</Col>
|
||||
<Col>
|
||||
<Label mb="2">Permissions Summary</Label>
|
||||
<PermissionsSummary
|
||||
writersSize={writers.size}
|
||||
vip={association.metadata.vip}
|
||||
/>
|
||||
</Col>
|
||||
<ChannelWritePerms contacts={props.contacts} groups={props.groups} />
|
||||
{association.metadata.module !== "chat" && (
|
||||
<Checkbox
|
||||
id="readerComments"
|
||||
label="Allow readers to comment"
|
||||
caption="If enabled, all members of the group can comment on this channel"
|
||||
/>
|
||||
)}
|
||||
<FormSubmit>Update Permissions</FormSubmit>
|
||||
</Col>
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
ManagedTextInputField as Input,
|
||||
ManagedCheckboxField as Checkbox,
|
||||
Col,
|
||||
Label,
|
||||
Text,
|
||||
Row,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Formik, Form } from "formik";
|
||||
|
||||
import { FormError } from "~/views/components/FormError";
|
||||
import { ColorInput } from "~/views/components/ColorInput";
|
||||
import { AsyncButton } from "~/views/components/AsyncButton";
|
||||
import { uxToHex, wait } from "~/logic/lib/util";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Association } from "~/types";
|
||||
import { FormSubmit } from "~/views/components/FormSubmit";
|
||||
|
||||
interface FormSchema {
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ChannelDetailsProps {
|
||||
api: GlobalApi;
|
||||
association: Association;
|
||||
}
|
||||
|
||||
export function ChannelDetails(props: ChannelDetailsProps) {
|
||||
const { association, api } = props;
|
||||
const { metadata } = association;
|
||||
const initialValues: FormSchema = {
|
||||
title: metadata?.title || "",
|
||||
description: metadata?.description || "",
|
||||
color: metadata?.color || "0x0",
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormSchema, actions) => {
|
||||
const { title, description } = values;
|
||||
const color = uxToHex(values.color);
|
||||
await api.metadata.update(association, { title, color, description });
|
||||
actions.setStatus({ success: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<Formik initialValues={initialValues} onSubmit={onSubmit}>
|
||||
<Form style={{ display: "contents" }}>
|
||||
<Col mb="4" flexShrink={0} gapY="4">
|
||||
<Col mb={3}>
|
||||
<Text id="details" fontSize="2" fontWeight="bold">
|
||||
Channel Details
|
||||
</Text>
|
||||
<Label gray mt="2">
|
||||
Set the title, description and colour of the channel
|
||||
</Label>
|
||||
</Col>
|
||||
<Input
|
||||
id="title"
|
||||
label="Title"
|
||||
caption="Change the title of this channel"
|
||||
/>
|
||||
<Input
|
||||
id="description"
|
||||
label="Change description"
|
||||
caption="Change the description of this channel"
|
||||
/>
|
||||
<ColorInput
|
||||
id="color"
|
||||
label="Color"
|
||||
caption="Change the color of this channel"
|
||||
/>
|
||||
<FormSubmit>
|
||||
Update Details
|
||||
</FormSubmit>
|
||||
<FormError message="Failed to update settings" />
|
||||
</Col>
|
||||
</Form>
|
||||
</Formik>
|
||||
);
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { Col, Text, BaseLabel, Label } from "@tlon/indigo-react";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Association, NotificationGraphConfig } from "~/types";
|
||||
import { StatelessAsyncToggle } from "~/views/components/StatelessAsyncToggle";
|
||||
|
||||
interface ChannelNotificationsProps {
|
||||
api: GlobalApi;
|
||||
association: Association;
|
||||
notificationsGraphConfig: NotificationGraphConfig;
|
||||
}
|
||||
|
||||
export function ChannelNotifications(props: ChannelNotificationsProps) {
|
||||
const { api, association } = props;
|
||||
const rid = association.resource;
|
||||
|
||||
const isMuted =
|
||||
props.notificationsGraphConfig.watching.findIndex(
|
||||
(a) => a.graph === rid && a.index === "/"
|
||||
) === -1;
|
||||
|
||||
const onChangeMute = async () => {
|
||||
const func = isMuted ? "listenGraph" : "ignoreGraph";
|
||||
await api.hark[func](rid, "/");
|
||||
};
|
||||
|
||||
return (
|
||||
<Col mb="6" gapY="4">
|
||||
<Text id="notifications" fontSize="2" fontWeight="bold">
|
||||
Channel Notifications
|
||||
</Text>
|
||||
<BaseLabel display="flex" cursor="pointer">
|
||||
<StatelessAsyncToggle selected={isMuted} onClick={onChangeMute} />
|
||||
<Col>
|
||||
<Label>Mute this channel</Label>
|
||||
<Label gray mt="1">
|
||||
Muting this channel will prevent it from sending updates to your
|
||||
inbox
|
||||
</Label>
|
||||
</Col>
|
||||
</BaseLabel>
|
||||
</Col>
|
||||
);
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
import { Text, Col } from "@tlon/indigo-react";
|
||||
import { SidebarItem } from "../SidebarItem";
|
||||
import { isChannelAdmin } from "~/logic/lib/group";
|
||||
|
||||
export function ChannelPopoverRoutesSidebar(props: {
|
||||
baseUrl: string;
|
||||
isOwner: boolean;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const { baseUrl, isAdmin, isOwner } = props;
|
||||
|
||||
const relativePath = (p: string) => `${baseUrl}${p}`;
|
||||
|
||||
return (
|
||||
<Col
|
||||
minWidth="200px"
|
||||
borderRight="1"
|
||||
borderRightColor="washedGray"
|
||||
py="5"
|
||||
gapY="2"
|
||||
>
|
||||
<Text mx="3" my="3" fontSize="1" fontWeight="medium">
|
||||
Channel Settings
|
||||
</Text>
|
||||
<Text mx="3" my="2" gray>
|
||||
Preferences
|
||||
</Text>
|
||||
<SidebarItem
|
||||
icon="Inbox"
|
||||
text="Notifications"
|
||||
to={relativePath("/settings#notifications")}
|
||||
/>
|
||||
{!isOwner && (
|
||||
<SidebarItem
|
||||
icon="SignOut"
|
||||
text="Unsubscribe"
|
||||
color="red"
|
||||
to={relativePath("/settings#unsubscribe")}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Text mx="3" py="2" gray>
|
||||
Administration
|
||||
</Text>
|
||||
<SidebarItem
|
||||
icon="Boot"
|
||||
text="Channel Details"
|
||||
to={relativePath("/settings#details")}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon="Keyfile"
|
||||
text="Permissions"
|
||||
to={relativePath("/settings#permissions")}
|
||||
/>
|
||||
{ isOwner ? (
|
||||
<SidebarItem
|
||||
icon="TrashCan"
|
||||
text="Archive Channel"
|
||||
to={relativePath("/settings#archive")}
|
||||
color="red"
|
||||
/>
|
||||
) : (
|
||||
<SidebarItem
|
||||
icon="TrashCan"
|
||||
text="Archive Channel"
|
||||
to={relativePath("/settings#remove")}
|
||||
color="red"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
);
|
||||
}
|
@ -0,0 +1,142 @@
|
||||
import React, { useRef } from "react";
|
||||
import { ModalOverlay } from "~/views/components/ModalOverlay";
|
||||
import { Col, Box, Text, Row } from "@tlon/indigo-react";
|
||||
import { ChannelPopoverRoutesSidebar } from "./Sidebar";
|
||||
import { ChannelDetails } from "./Details";
|
||||
import { GraphPermissions } from "./ChannelPermissions";
|
||||
import {
|
||||
Association,
|
||||
Groups,
|
||||
Group,
|
||||
Rolodex,
|
||||
NotificationGraphConfig,
|
||||
} from "~/types";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { useHashLink } from "~/logic/lib/useHashLink";
|
||||
import { useOutsideClick } from "~/logic/lib/useOutsideClick";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { ChannelNotifications } from "./Notifications";
|
||||
import { StatelessAsyncButton } from "~/views/components/StatelessAsyncButton";
|
||||
import { wait } from "~/logic/lib/util";
|
||||
import { isChannelAdmin, isHost } from "~/logic/lib/group";
|
||||
|
||||
interface ChannelPopoverRoutesProps {
|
||||
baseUrl: string;
|
||||
association: Association;
|
||||
group: Group;
|
||||
groups: Groups;
|
||||
contacts: Rolodex;
|
||||
api: GlobalApi;
|
||||
notificationsGraphConfig: NotificationGraphConfig;
|
||||
}
|
||||
|
||||
export function ChannelPopoverRoutes(props: ChannelPopoverRoutesProps) {
|
||||
const { association, group, api } = props;
|
||||
useHashLink();
|
||||
const overlayRef = useRef<HTMLElement>();
|
||||
const history = useHistory();
|
||||
|
||||
useOutsideClick(overlayRef, () => {
|
||||
history.push(props.baseUrl);
|
||||
});
|
||||
|
||||
const handleUnsubscribe = async () => {
|
||||
const [,,ship,name] = association.resource.split('/');
|
||||
await api.graph.leaveGraph(ship, name);
|
||||
};
|
||||
const handleRemove = async () => {
|
||||
await api.metadata.remove('graph', association.resource, association.group);
|
||||
};
|
||||
const handleArchive = async () => {
|
||||
const [,,,name] = association.resource.split('/');
|
||||
await api.graph.deleteGraph(name);
|
||||
};
|
||||
|
||||
const canAdmin = isChannelAdmin(group, association.resource);
|
||||
const isOwner = isHost(association.resource);
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
bg="transparent"
|
||||
height="100%"
|
||||
width="100%"
|
||||
spacing={[3, 5, 7]}
|
||||
ref={overlayRef}
|
||||
>
|
||||
<Row
|
||||
border="1"
|
||||
borderColor="lightGray"
|
||||
borderRadius="2"
|
||||
bg="white"
|
||||
height="100%"
|
||||
>
|
||||
<ChannelPopoverRoutesSidebar
|
||||
isAdmin={canAdmin}
|
||||
isOwner={isOwner}
|
||||
baseUrl={props.baseUrl}
|
||||
/>
|
||||
<Col height="100%" overflowY="auto" p="5" flexGrow={1}>
|
||||
<ChannelNotifications {...props} />
|
||||
{!isOwner && (
|
||||
<Col mb="6">
|
||||
<Text id="unsubscribe" fontSize="2" fontWeight="bold">
|
||||
Unsubscribe from Channel
|
||||
</Text>
|
||||
<Text mt="1" maxWidth="450px" gray>
|
||||
Unsubscribing from a channel will revoke your ability to read
|
||||
its contents. Any permissions set by the channel host will still
|
||||
apply once you have left.
|
||||
</Text>
|
||||
<Row mt="3">
|
||||
<StatelessAsyncButton destructive onClick={handleUnsubscribe}>
|
||||
Unsubscribe from {props.association.metadata.title}
|
||||
</StatelessAsyncButton>
|
||||
</Row>
|
||||
</Col>
|
||||
)}
|
||||
{canAdmin && (
|
||||
<>
|
||||
<ChannelDetails {...props} />
|
||||
<GraphPermissions {...props} />
|
||||
{ isOwner ? (
|
||||
<Col mt="5" mb="6">
|
||||
<Text id="archive" fontSize="2" fontWeight="bold">
|
||||
Archive channel
|
||||
</Text>
|
||||
<Text mt="1" maxWidth="450px" gray>
|
||||
Archiving a channel will prevent further updates to the channel.
|
||||
Users who are currently joined to the channel will retain a copy
|
||||
of the channel.
|
||||
</Text>
|
||||
<Row mt="3">
|
||||
<StatelessAsyncButton destructive onClick={handleArchive}>
|
||||
Archive {props.association.metadata.title}
|
||||
</StatelessAsyncButton>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
) : (
|
||||
<Col mt="5" mb="6">
|
||||
<Text id="remove" fontSize="2" fontWeight="bold">
|
||||
Remove channel from group
|
||||
</Text>
|
||||
<Text mt="1" maxWidth="450px" gray>
|
||||
Removing a channel will prevent further updates to the channel.
|
||||
Users who are currently joined to the channel will retain a copy
|
||||
of the channel.
|
||||
</Text>
|
||||
<Row mt="3">
|
||||
<StatelessAsyncButton destructive onClick={handleRemove}>
|
||||
Remove {props.association.metadata.title}
|
||||
</StatelessAsyncButton>
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}
|
@ -1,110 +0,0 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { AsyncButton } from "~/views/components/AsyncButton";
|
||||
import * as Yup from "yup";
|
||||
import {
|
||||
Box,
|
||||
ManagedTextInputField as Input,
|
||||
Col,
|
||||
Label,
|
||||
Text,
|
||||
} from "@tlon/indigo-react";
|
||||
import { Formik, Form, useFormikContext, FormikHelpers } from "formik";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { uxToHex } from "~/logic/lib/util";
|
||||
import { FormError } from "~/views/components/FormError";
|
||||
import { ColorInput } from "~/views/components/ColorInput";
|
||||
import { Association, Groups, Associations } from "~/types";
|
||||
import Writers from '~/views/apps/publish/components/Writers';
|
||||
import GroupifyForm from "./GroupifyForm";
|
||||
|
||||
interface FormSchema {
|
||||
title: string;
|
||||
description: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ChannelSettingsProps {
|
||||
association: Association;
|
||||
groups: Groups;
|
||||
associations: Associations;
|
||||
api: GlobalApi;
|
||||
}
|
||||
|
||||
export function ChannelSettings(props: ChannelSettingsProps) {
|
||||
const { api, association } = props;
|
||||
const { metadata } = association;
|
||||
const initialValues: FormSchema = {
|
||||
title: metadata?.title || "",
|
||||
description: metadata?.description || "",
|
||||
color: metadata?.color || "0x0",
|
||||
};
|
||||
|
||||
const onSubmit = async (
|
||||
values: FormSchema,
|
||||
actions: FormikHelpers<FormSchema>
|
||||
) => {
|
||||
try {
|
||||
const app = association["app-name"];
|
||||
const resource = association.resource;
|
||||
const group = association.group;
|
||||
const date = metadata["date-created"];
|
||||
const { title, description, color } = values;
|
||||
await api.metadata.metadataAdd(
|
||||
app,
|
||||
resource,
|
||||
group,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
uxToHex(color),
|
||||
metadata.module
|
||||
);
|
||||
actions.setStatus({ success: null });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
actions.setStatus({ error: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Col gapY="6" overflowY="auto" p={4}>
|
||||
<Formik initialValues={initialValues} onSubmit={onSubmit}>
|
||||
<Form style={{ display: "contents" }}>
|
||||
<Col flexShrink="0" maxWidth="512px" gapY="4">
|
||||
<Col mb={3}>
|
||||
<Text fontWeight="bold">Channel Settings</Text>
|
||||
<Label gray mt="2">
|
||||
Set the title, description and colour of the channel
|
||||
</Label>
|
||||
</Col>
|
||||
<Input
|
||||
id="title"
|
||||
label="Title"
|
||||
caption="Change the title of this channel"
|
||||
/>
|
||||
<Input
|
||||
id="description"
|
||||
label="Change description"
|
||||
caption="Change the description of this channel"
|
||||
/>
|
||||
<ColorInput
|
||||
id="color"
|
||||
label="Color"
|
||||
caption="Change the color of this channel"
|
||||
/>
|
||||
<AsyncButton primary loadingText="Updating.." border>
|
||||
Save
|
||||
</AsyncButton>
|
||||
<FormError message="Failed to update settings" />
|
||||
</Col>
|
||||
</Form>
|
||||
</Formik>
|
||||
<Box borderBottom="1" borderBottomColor="lightGray" width="100%" maxWidth="512px" />
|
||||
{(metadata?.module === 'publish') && (<>
|
||||
<Writers {...props} />
|
||||
<Box borderBottom="1" borderBottomColor="lightGray" width="100%" maxWidth="512px" />
|
||||
</>)}
|
||||
<GroupifyForm {...props} />
|
||||
</Col>
|
||||
);
|
||||
}
|
@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Label,
|
||||
Box,
|
||||
ManagedRadioButtonField as Radio,
|
||||
Col,
|
||||
} from "@tlon/indigo-react";
|
||||
import { useFormikContext } from "formik";
|
||||
import { Groups, Rolodex } from "~/types";
|
||||
import { ShipSearch } from "~/views/components/ShipSearch";
|
||||
|
||||
export type WritePerms = "everyone" | "subset" | "self";
|
||||
export interface ChannelWriteFieldSchema {
|
||||
writePerms: WritePerms;
|
||||
writers: string[];
|
||||
}
|
||||
|
||||
interface ChannelWritePermsProps {
|
||||
groups: Groups;
|
||||
contacts: Rolodex;
|
||||
}
|
||||
|
||||
export function ChannelWritePerms<
|
||||
T extends ChannelWriteFieldSchema = ChannelWriteFieldSchema
|
||||
>(props: ChannelWritePermsProps) {
|
||||
const { values, errors } = useFormikContext<T>();
|
||||
|
||||
return (
|
||||
<Col gapY="3">
|
||||
<Label> Write Access</Label>
|
||||
<Radio name="writePerms" id="everyone" label="All group members" />
|
||||
<Radio name="writePerms" id="self" label="Only host" />
|
||||
<Radio name="writePerms" id="subset" label="Host and selected ships" />
|
||||
{values.writePerms === "subset" && (
|
||||
<ShipSearch
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
id="writers"
|
||||
label=""
|
||||
maxLength={undefined}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
);
|
||||
}
|
@ -31,6 +31,7 @@ interface FormSchema {
|
||||
color: string;
|
||||
isPrivate: boolean;
|
||||
picture: string;
|
||||
adminMetadata: boolean;
|
||||
}
|
||||
|
||||
const formSchema = Yup.object({
|
||||
@ -38,6 +39,7 @@ const formSchema = Yup.object({
|
||||
description: Yup.string(),
|
||||
color: Yup.string(),
|
||||
isPrivate: Yup.boolean(),
|
||||
adminMetadata: Yup.boolean()
|
||||
});
|
||||
|
||||
interface GroupAdminSettingsProps {
|
||||
@ -58,6 +60,7 @@ export function GroupAdminSettings(props: GroupAdminSettingsProps) {
|
||||
color: metadata?.color,
|
||||
picture: metadata?.picture,
|
||||
isPrivate: currentPrivate,
|
||||
adminMetadata: metadata.vip !== 'member-metadata'
|
||||
};
|
||||
|
||||
const onSubmit = async (
|
||||
@ -65,13 +68,15 @@ export function GroupAdminSettings(props: GroupAdminSettingsProps) {
|
||||
actions: FormikHelpers<FormSchema>
|
||||
) => {
|
||||
try {
|
||||
const { title, description, picture, color, isPrivate } = values;
|
||||
const { title, description, picture, color, isPrivate, adminMetadata } = values;
|
||||
const uxColor = uxToHex(color);
|
||||
const vip = adminMetadata ? '' : 'member-metadata';
|
||||
await props.api.metadata.update(props.association, {
|
||||
title,
|
||||
description,
|
||||
picture,
|
||||
color: uxColor,
|
||||
vip
|
||||
});
|
||||
if (isPrivate !== currentPrivate) {
|
||||
const resource = resourceFromPath(props.association.group);
|
||||
@ -135,6 +140,13 @@ export function GroupAdminSettings(props: GroupAdminSettingsProps) {
|
||||
caption="If enabled, users must be invited to join the group"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Checkbox
|
||||
id="adminMetadata"
|
||||
label="Restrict channel adding to admins"
|
||||
caption="If enabled, users must be an admin to add a channel to the group"
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<AsyncButton
|
||||
disabled={disabled}
|
||||
primary
|
||||
|
@ -78,7 +78,9 @@ export function GroupSwitcher(props: {
|
||||
}) {
|
||||
const { associations, workspace, isAdmin } = props;
|
||||
const title = getTitleFromWorkspace(associations, workspace);
|
||||
const metadata = workspace.type === 'home' ? undefined : associations.groups[workspace.group].metadata;
|
||||
const metadata = (workspace.type === 'home' || workspace.type === 'messages')
|
||||
? undefined
|
||||
: associations.groups[workspace.group].metadata;
|
||||
const navTo = (to: string) => `${props.baseUrl}${to}`;
|
||||
return (
|
||||
<Row width="100%" alignItems="center" height='48px' backgroundColor="white" zIndex="2" position="sticky" top="0px" pl='3' borderBottom='1px solid' borderColor='washedGray'>
|
||||
@ -117,9 +119,9 @@ export function GroupSwitcher(props: {
|
||||
mr={2}
|
||||
color="gray"
|
||||
display="block"
|
||||
icon="Mail"
|
||||
icon="Home"
|
||||
/>
|
||||
<Text>DMs + Drafts</Text>
|
||||
<Text>My Channels</Text>
|
||||
</GroupSwitcherItem>}
|
||||
<RecentGroups
|
||||
recent={props.recentGroups}
|
||||
|
@ -177,7 +177,6 @@ export function GroupsPane(props: GroupsPaneProps) {
|
||||
{...routeProps}
|
||||
api={api}
|
||||
baseUrl={baseUrl}
|
||||
chatSynced={props.chatSynced}
|
||||
associations={associations}
|
||||
groups={groups}
|
||||
group={groupPath}
|
||||
|
@ -1,8 +1,16 @@
|
||||
import React, { useCallback, useRef, useMemo } from "react";
|
||||
import _ from 'lodash';
|
||||
import { Switch, Route, useHistory } from "react-router-dom";
|
||||
import { Formik, Form } from "formik";
|
||||
import * as Yup from 'yup';
|
||||
import { Box, Text, Col, Button, Row } from "@tlon/indigo-react";
|
||||
import {
|
||||
ManagedTextInputField as Input,
|
||||
Box,
|
||||
Text,
|
||||
Col,
|
||||
Button,
|
||||
Row
|
||||
} from "@tlon/indigo-react";
|
||||
|
||||
import { ShipSearch } from "~/views/components/ShipSearch";
|
||||
import { Association } from "~/types/metadata-update";
|
||||
@ -25,6 +33,7 @@ interface InvitePopoverProps {
|
||||
|
||||
interface FormSchema {
|
||||
emails: string[];
|
||||
description: string;
|
||||
ships: string[];
|
||||
}
|
||||
|
||||
@ -46,18 +55,16 @@ export function InvitePopover(props: InvitePopoverProps) {
|
||||
}, [history.push, props.baseUrl]);
|
||||
useOutsideClick(innerRef, onOutsideClick);
|
||||
|
||||
const onSubmit = async ({ ships, emails }: { ships: string[] }, actions) => {
|
||||
if(props.workspace.type === 'home') {
|
||||
history.push(`/~landscape/dm/${deSig(ships[0])}`);
|
||||
return;
|
||||
}
|
||||
const onSubmit = async ({ ships, description }: FormSchema, actions) => {
|
||||
// TODO: how to invite via email?
|
||||
try {
|
||||
const resource = resourceFromPath(association.group);
|
||||
await ships.reduce(
|
||||
(acc, s) => acc.then(() => api.contacts.invite(resource, `~${deSig(s)}`)),
|
||||
Promise.resolve()
|
||||
const { ship, name } = resourceFromPath(association.group);
|
||||
await api.groups.invite(
|
||||
ship, name,
|
||||
_.compact(ships).map(s => `~${deSig(s)}`),
|
||||
description
|
||||
);
|
||||
|
||||
actions.setStatus({ success: null });
|
||||
onOutsideClick();
|
||||
} catch (e) {
|
||||
@ -66,7 +73,7 @@ export function InvitePopover(props: InvitePopoverProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const initialValues: FormSchema = { ships: [], emails: [] };
|
||||
const initialValues: FormSchema = { ships: [], emails: [], description: '' };
|
||||
|
||||
|
||||
return (
|
||||
@ -105,16 +112,19 @@ export function InvitePopover(props: InvitePopoverProps) {
|
||||
<Col gapY="3" pt={3} px={3}>
|
||||
<Box>
|
||||
<Text>Invite to </Text>
|
||||
<Text fontWeight="800">{title || "DM"}</Text>
|
||||
<Text fontWeight="800">{title}</Text>
|
||||
</Box>
|
||||
<ShipSearch
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
id="ships"
|
||||
label=""
|
||||
maxLength={props.workspace.type === 'home' ? 1 : undefined}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
id="description"
|
||||
label="Enter a message for the invite"
|
||||
/>
|
||||
<FormError message="Failed to invite" />
|
||||
{/* <ChipInput
|
||||
id="emails"
|
||||
|
@ -85,7 +85,7 @@ export function JoinGroup(props: JoinGroupProps) {
|
||||
await waiter((p: JoinGroupProps) => {
|
||||
return group in p.groups &&
|
||||
(group in (p.associations?.graph ?? {})
|
||||
|| group in (p.associations?.contacts ?? {}))
|
||||
|| group in (p.associations?.groups ?? {}))
|
||||
});
|
||||
if(props.groups?.[group]?.hidden) {
|
||||
const { metadata } = associations.graph[group];
|
||||
|
@ -3,10 +3,7 @@ import {
|
||||
Box,
|
||||
ManagedTextInputField as Input,
|
||||
Col,
|
||||
ManagedRadioButtonField as Radio,
|
||||
Text,
|
||||
Icon,
|
||||
Row
|
||||
Text
|
||||
} from '@tlon/indigo-react';
|
||||
import { Formik, Form } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
@ -19,31 +16,31 @@ import { resourceFromPath } from '~/logic/lib/group';
|
||||
import { Associations } from '~/types/metadata-update';
|
||||
import { useWaitForProps } from '~/logic/lib/useWaitForProps';
|
||||
import { Groups } from '~/types/group-update';
|
||||
import { ShipSearch } from '~/views/components/ShipSearch';
|
||||
import { ShipSearch, shipSearchSchemaInGroup, shipSearchSchema } from '~/views/components/ShipSearch';
|
||||
import { Rolodex, Workspace } from '~/types';
|
||||
import { IconRadio } from '~/views/components/IconRadio';
|
||||
import { ChannelWriteFieldSchema, ChannelWritePerms } from './ChannelWritePerms';
|
||||
|
||||
interface FormSchema {
|
||||
type FormSchema = {
|
||||
name: string;
|
||||
description: string;
|
||||
ships: string[];
|
||||
moduleType: 'chat' | 'publish' | 'link';
|
||||
writers: string[];
|
||||
}
|
||||
} & ChannelWriteFieldSchema;
|
||||
|
||||
const formSchema = (group, groups) => Yup.object({
|
||||
name: Yup.string().required('Channel must have a name'),
|
||||
const formSchema = (members?: string[]) => Yup.object({
|
||||
name: Yup.string(),
|
||||
description: Yup.string(),
|
||||
ships: Yup.array(Yup.string()),
|
||||
moduleType: Yup.string().required('Must choose channel type'),
|
||||
writers: Yup.array(Yup.string().test('ingroup', 'Writers must be in group',
|
||||
value => groups?.[group]?.members?.has(value)))
|
||||
writers: members ? shipSearchSchemaInGroup(members) : shipSearchSchema,
|
||||
writePerms: Yup.string()
|
||||
});
|
||||
|
||||
interface NewChannelProps {
|
||||
api: GlobalApi;
|
||||
associations: Associations;
|
||||
contacts: Rolodex;
|
||||
chatSynced: any;
|
||||
groups: Groups;
|
||||
group?: string;
|
||||
workspace: Workspace;
|
||||
@ -55,18 +52,15 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
|
||||
const waiter = useWaitForProps(props, 5000);
|
||||
|
||||
const onSubmit = async (values: FormSchema, actions) => {
|
||||
const name = (values.name) ? values.name : values.moduleType;
|
||||
const resId: string = stringToSymbol(values.name)
|
||||
+ ((workspace?.type !== 'home') ? `-${Math.floor(Math.random() * 10000)}`
|
||||
+ ((workspace?.type !== 'messages') ? `-${Math.floor(Math.random() * 10000)}`
|
||||
: '');
|
||||
try {
|
||||
const { name, description, moduleType, ships, writers } = values;
|
||||
if(moduleType === 'publish' && writers.length > 0) {
|
||||
const resource = resourceFromPath(group);
|
||||
await api.groups.addTag(
|
||||
resource,
|
||||
{ app: 'publish', tag: `writers-${resId}` },
|
||||
writers.map(s => `~${s}`)
|
||||
);
|
||||
let { description, moduleType, ships, writers } = values;
|
||||
ships = ships.filter(e => e !== "");
|
||||
if (workspace?.type === 'messages' && ships.length === 1) {
|
||||
return history.push(`/~landscape/dm/${deSig(ships[0])}`);
|
||||
}
|
||||
if (group) {
|
||||
await api.graph.createManagedGraph(
|
||||
@ -76,6 +70,21 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
|
||||
group,
|
||||
moduleType
|
||||
);
|
||||
const tag = {
|
||||
app: 'graph',
|
||||
resource: `/ship/~${window.ship}/${resId}`,
|
||||
tag: 'writers'
|
||||
};
|
||||
|
||||
const resource = resourceFromPath(group);
|
||||
writers = _.compact(writers);
|
||||
const us = `~${window.ship}`;
|
||||
if(values.writePerms === 'self') {
|
||||
await api.groups.addTag(resource, tag, [us]);
|
||||
} else if(values.writePerms === 'subset') {
|
||||
writers.push(us);
|
||||
await api.groups.addTag(resource, tag, writers);
|
||||
}
|
||||
} else {
|
||||
await api.graph.createUnmanagedGraph(
|
||||
resId,
|
||||
@ -100,83 +109,82 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const members = group ? Array.from(groups[group]?.members).map(s => `~${s}`) : undefined;
|
||||
|
||||
return (
|
||||
<Col overflowY="auto" p={3}>
|
||||
<Box pb='3' display={['block', 'none']} onClick={() => history.push(props.baseUrl)}>
|
||||
<Text fontSize='0' bold>{'<- Back'}</Text>
|
||||
</Box>
|
||||
<Box fontWeight="bold" mb={4} color="black">
|
||||
New Channel
|
||||
<Box color="black">
|
||||
<Text fontSize={2} bold>{workspace?.type === 'messages' ? 'Direct Message' : 'New Channel'}</Text>
|
||||
</Box>
|
||||
<Formik
|
||||
validationSchema={formSchema(group, groups)}
|
||||
validationSchema={formSchema(members)}
|
||||
initialValues={{
|
||||
moduleType: 'chat',
|
||||
moduleType: (workspace?.type === 'home') ? 'publish' : 'chat',
|
||||
name: '',
|
||||
description: '',
|
||||
group: '',
|
||||
ships: [],
|
||||
writePerms: 'everyone',
|
||||
writers: []
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{ ({ errors, values }) => <Form>
|
||||
<Form>
|
||||
<Col
|
||||
maxWidth="348px"
|
||||
gapY="4"
|
||||
>
|
||||
<Col gapY="2">
|
||||
<Box color="black" mb={2}>Channel Type</Box>
|
||||
<Radio label="Chat" id="chat" name="moduleType" />
|
||||
<Radio label="Notebook" id="publish" name="moduleType" />
|
||||
<Radio label="Collection" id="link" name="moduleType" />
|
||||
<Col pt={4} gapY="2" display={(workspace?.type === "messages") ? 'none' : 'flex'}>
|
||||
<Box fontSize="1" color="black" mb={2}>Channel Type</Box>
|
||||
<IconRadio
|
||||
display={!(workspace?.type === 'home') ? 'flex' : 'none'}
|
||||
icon="Chat"
|
||||
label="Chat"
|
||||
id="chat"
|
||||
name="moduleType"
|
||||
/>
|
||||
<IconRadio
|
||||
icon="Publish"
|
||||
label="Notebook"
|
||||
id="publish"
|
||||
name="moduleType"
|
||||
/>
|
||||
<IconRadio
|
||||
icon="Links"
|
||||
label="Collection"
|
||||
id="link"
|
||||
name="moduleType"
|
||||
/>
|
||||
</Col>
|
||||
<Input
|
||||
display={workspace?.type === 'messages' ? 'none' : 'flex'}
|
||||
id="name"
|
||||
label="Name"
|
||||
caption="Provide a name for your channel"
|
||||
placeholder="eg. My Channel"
|
||||
/>
|
||||
<Input
|
||||
display={workspace?.type === 'messages' ? 'none' : 'flex'}
|
||||
id="description"
|
||||
label="Description"
|
||||
caption="What's your channel about?"
|
||||
placeholder="Channel description"
|
||||
/>
|
||||
{(workspace?.type === 'home') &&
|
||||
{(workspace?.type === 'home' || workspace?.type === 'messages') ? (
|
||||
<ShipSearch
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
id="ships"
|
||||
label="Invitees"
|
||||
/>}
|
||||
{(workspace?.type !== 'home' && values.moduleType === 'publish') &&
|
||||
<>
|
||||
<ShipSearch
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
caption="Add writers to restrict who can write to this
|
||||
notebook, or leave blank to allow all group members to write"
|
||||
id="writers"
|
||||
label="Writers"
|
||||
/>) : (
|
||||
<ChannelWritePerms
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
/>
|
||||
{errors.writers &&
|
||||
<>
|
||||
<Row>
|
||||
<Icon
|
||||
color='white'
|
||||
mr='2'
|
||||
backgroundColor='red'
|
||||
borderRadius='999px'
|
||||
icon="ExclaimationMarkBold"
|
||||
/>
|
||||
<Text color='red'>
|
||||
{Array.from(new Set([...errors.writers]))}
|
||||
</Text>
|
||||
</Row>
|
||||
</>
|
||||
}
|
||||
</>}
|
||||
)}
|
||||
<Box justifySelf="start">
|
||||
<AsyncButton
|
||||
primary
|
||||
@ -184,12 +192,12 @@ export function NewChannel(props: NewChannelProps & RouteComponentProps) {
|
||||
type="submit"
|
||||
border
|
||||
>
|
||||
Create Channel
|
||||
Create
|
||||
</AsyncButton>
|
||||
</Box>
|
||||
<FormError message="Channel creation failed" />
|
||||
</Col>
|
||||
</Form>}
|
||||
</Form>
|
||||
</Formik>
|
||||
</Col>
|
||||
);
|
||||
|
@ -14,27 +14,8 @@ import { Participants } from "./Participants";
|
||||
import {useHashLink} from "~/logic/lib/useHashLink";
|
||||
import {DeleteGroup} from "./DeleteGroup";
|
||||
import {resourceFromPath} from "~/logic/lib/group";
|
||||
|
||||
const SidebarItem = ({ selected, icon, text, to, children = null }) => {
|
||||
return (
|
||||
<HoverBoxLink
|
||||
to={to}
|
||||
selected={selected}
|
||||
bg="white"
|
||||
bgActive="washedGray"
|
||||
display="flex"
|
||||
px="3"
|
||||
py="1"
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Row>
|
||||
<Icon icon={icon} mr='2'/>
|
||||
<Text color={selected ? "black" : "gray"}>{text}</Text>
|
||||
</Row>
|
||||
{children}
|
||||
</HoverBoxLink>
|
||||
);
|
||||
};
|
||||
import {ModalOverlay} from "~/views/components/ModalOverlay";
|
||||
import { SidebarItem } from "~/views/landscape/components/SidebarItem";
|
||||
|
||||
export function PopoverRoutes(
|
||||
props: {
|
||||
@ -72,106 +53,95 @@ export function PopoverRoutes(
|
||||
render={(routeProps) => {
|
||||
const { view } = routeProps.match.params;
|
||||
return (
|
||||
<Box
|
||||
px={[3, 5, 8]}
|
||||
py={[3, 5]}
|
||||
backgroundColor='scales.black30'
|
||||
left="0px"
|
||||
top="0px"
|
||||
<ModalOverlay
|
||||
spacing={[3,5,7]}
|
||||
ref={innerRef}
|
||||
border={1}
|
||||
borderColor="washedGray"
|
||||
borderRadius={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
zIndex={4}
|
||||
position="fixed"
|
||||
bg="white"
|
||||
>
|
||||
<Box
|
||||
ref={innerRef}
|
||||
border={1}
|
||||
borderColor="washedGray"
|
||||
borderRadius={1}
|
||||
width="100%"
|
||||
display="grid"
|
||||
gridTemplateRows={["32px 1fr", "100%"]}
|
||||
gridTemplateColumns={["100%", "250px 1fr"]}
|
||||
height="100%"
|
||||
bg="white"
|
||||
width="100%"
|
||||
>
|
||||
<Box
|
||||
display="grid"
|
||||
gridTemplateRows={["32px 1fr", "100%"]}
|
||||
gridTemplateColumns={["100%", "250px 1fr"]}
|
||||
height="100%"
|
||||
width="100%"
|
||||
<Col
|
||||
display={!!view ? ["none", "flex"] : "flex"}
|
||||
borderRight={1}
|
||||
borderRightColor="washedGray"
|
||||
>
|
||||
<Col
|
||||
display={!!view ? ["none", "flex"] : "flex"}
|
||||
borderRight={1}
|
||||
borderRightColor="washedGray"
|
||||
>
|
||||
<Text my="4" mx="3" fontWeight="600" fontSize="2">Group Settings</Text>
|
||||
<Col gapY="2">
|
||||
<Text my="1" mx="3" gray>Group</Text>
|
||||
<SidebarItem
|
||||
icon="Inbox"
|
||||
to={relativeUrl("/settings#notifications")}
|
||||
text="Notifications"
|
||||
/>
|
||||
<SidebarItem
|
||||
icon="Users"
|
||||
to={relativeUrl("/participants")}
|
||||
text="Participants"
|
||||
selected={view === "participants"}
|
||||
><Text gray>{groupSize}</Text>
|
||||
</SidebarItem>
|
||||
{ admin && (
|
||||
<>
|
||||
<Box pt="3" mb="1" mx="3">
|
||||
<Text gray>Administration</Text>
|
||||
</Box>
|
||||
<SidebarItem
|
||||
icon="Groups"
|
||||
to={relativeUrl("/settings#group-details")}
|
||||
text="Group Details"
|
||||
/>
|
||||
<SidebarItem
|
||||
icon="Spaces"
|
||||
to={relativeUrl("/settings#channels")}
|
||||
text="Channel Management"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<DeleteGroup owner={owner} api={props.api} association={props.association} />
|
||||
</Col>
|
||||
<Text my="4" mx="3" fontWeight="600" fontSize="2">Group Settings</Text>
|
||||
<Col gapY="2">
|
||||
<Text my="1" mx="3" gray>Group</Text>
|
||||
<SidebarItem
|
||||
icon="Inbox"
|
||||
to={relativeUrl("/settings#notifications")}
|
||||
text="Notifications"
|
||||
/>
|
||||
<SidebarItem
|
||||
icon="Users"
|
||||
to={relativeUrl("/participants")}
|
||||
text="Participants"
|
||||
selected={view === "participants"}
|
||||
><Text gray>{groupSize}</Text>
|
||||
</SidebarItem>
|
||||
{ admin && (
|
||||
<>
|
||||
<Box pt="3" mb="1" mx="3">
|
||||
<Text gray>Administration</Text>
|
||||
</Box>
|
||||
<SidebarItem
|
||||
icon="Groups"
|
||||
to={relativeUrl("/settings#group-details")}
|
||||
text="Group Details"
|
||||
/>
|
||||
<SidebarItem
|
||||
icon="Spaces"
|
||||
to={relativeUrl("/settings#channels")}
|
||||
text="Channel Management"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<DeleteGroup owner={owner} api={props.api} association={props.association} />
|
||||
</Col>
|
||||
<Box
|
||||
gridArea={"1 / 1 / 2 / 2"}
|
||||
p={2}
|
||||
display={["auto", "none"]}
|
||||
>
|
||||
<Link to={!!view ? relativeUrl("") : props.baseUrl}>
|
||||
<Text>{"<- Back"}</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
<Box overflow="hidden">
|
||||
{view === "settings" && (
|
||||
<GroupSettings
|
||||
baseUrl={`${props.baseUrl}/popover`}
|
||||
group={props.group}
|
||||
association={props.association}
|
||||
api={props.api}
|
||||
notificationsGroupConfig={props.notificationsGroupConfig}
|
||||
associations={props.associations}
|
||||
s3={props.s3}
|
||||
/>
|
||||
)}
|
||||
{view === "participants" && (
|
||||
<Participants
|
||||
group={props.group}
|
||||
contacts={props.contacts}
|
||||
association={props.association}
|
||||
api={props.api}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Col>
|
||||
<Box
|
||||
gridArea={"1 / 1 / 2 / 2"}
|
||||
p={2}
|
||||
display={["auto", "none"]}
|
||||
>
|
||||
<Link to={!!view ? relativeUrl("") : props.baseUrl}>
|
||||
<Text>{"<- Back"}</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
<Box overflow="hidden">
|
||||
{view === "settings" && (
|
||||
<GroupSettings
|
||||
baseUrl={`${props.baseUrl}/popover`}
|
||||
group={props.group}
|
||||
association={props.association}
|
||||
api={props.api}
|
||||
notificationsGroupConfig={props.notificationsGroupConfig}
|
||||
associations={props.associations}
|
||||
s3={props.s3}
|
||||
/>
|
||||
)}
|
||||
{view === "participants" && (
|
||||
<Participants
|
||||
group={props.group}
|
||||
contacts={props.contacts}
|
||||
association={props.association}
|
||||
api={props.api}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</ModalOverlay>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
@ -13,6 +13,7 @@ import GlobalApi from "~/logic/api/global";
|
||||
import { RouteComponentProps, Route, Switch } from "react-router-dom";
|
||||
import { ChannelSettings } from "./ChannelSettings";
|
||||
import { ResourceSkeleton } from "./ResourceSkeleton";
|
||||
import {ChannelPopoverRoutes} from "./ChannelPopoverRoutes";
|
||||
|
||||
const TruncatedBox = styled(Box)`
|
||||
white-space: nowrap;
|
||||
@ -27,14 +28,14 @@ type ResourceProps = StoreState & {
|
||||
} & RouteComponentProps;
|
||||
|
||||
export function Resource(props: ResourceProps) {
|
||||
const { association, api, notificationsGraphConfig } = props;
|
||||
const { association, api, notificationsGraphConfig, groups, contacts } = props;
|
||||
const app = association.metadata.module || association["app-name"];
|
||||
const rid = association.resource;
|
||||
const selectedGroup = association.group;
|
||||
const relativePath = (p: string) =>
|
||||
|
||||
`${props.baseUrl}/resource/${app}${rid}${p}`;
|
||||
const skelProps = { api, association };
|
||||
const skelProps = { api, association, groups, contacts };
|
||||
let title = props.association.metadata.title;
|
||||
if ('workspace' in props) {
|
||||
if ('group' in props.workspace && props.workspace.group in props.associations.groups) {
|
||||
@ -46,48 +47,35 @@ export function Resource(props: ResourceProps) {
|
||||
<Helmet defer={false}>
|
||||
<title>{props.notificationsCount ? `(${String(props.notificationsCount)}) ` : ''}{ title }</title>
|
||||
</Helmet>
|
||||
<ResourceSkeleton
|
||||
{...skelProps}
|
||||
baseUrl={relativePath("")}
|
||||
>
|
||||
{app === "chat" ? (
|
||||
<ChatResource {...props} />
|
||||
) : app === "publish" ? (
|
||||
<PublishResource {...props} />
|
||||
) : (
|
||||
<LinkResource {...props} />
|
||||
)}
|
||||
</ResourceSkeleton>
|
||||
<Switch>
|
||||
<Route
|
||||
path={relativePath("/settings")}
|
||||
render={(routeProps) => {
|
||||
return (
|
||||
<ResourceSkeleton
|
||||
baseUrl={props.baseUrl}
|
||||
groupTags={props.groups?.[selectedGroup]?.tags}
|
||||
{...skelProps}
|
||||
>
|
||||
<ChannelSettings
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
associations={props.associations}
|
||||
api={api}
|
||||
association={association}
|
||||
/>
|
||||
</ResourceSkeleton>
|
||||
<ChannelPopoverRoutes
|
||||
association={association}
|
||||
group={props.groups?.[selectedGroup]}
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
api={props.api}
|
||||
baseUrl={relativePath("")}
|
||||
notificationsGraphConfig={notificationsGraphConfig}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
path={relativePath("")}
|
||||
render={(routeProps) => (
|
||||
<ResourceSkeleton
|
||||
notificationsGraphConfig={props.notificationsGraphConfig}
|
||||
notificationsChatConfig={props.notificationsChatConfig}
|
||||
baseUrl={props.baseUrl}
|
||||
groupTags={props.groups?.[selectedGroup]?.tags}
|
||||
{...skelProps}
|
||||
atRoot
|
||||
>
|
||||
{app === "chat" ? (
|
||||
<ChatResource {...props} />
|
||||
) : app === "publish" ? (
|
||||
<PublishResource {...props} />
|
||||
) : (
|
||||
<LinkResource {...props} />
|
||||
)}
|
||||
</ResourceSkeleton>
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { Row, Box, Col, Text } from "@tlon/indigo-react";
|
||||
import { Row, Icon, Box, Col, Text } from "@tlon/indigo-react";
|
||||
import styled from "styled-components";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@ -13,7 +13,10 @@ 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";
|
||||
import { NotificationGraphConfig, Groups } from "~/types";
|
||||
import {isWriter} from "~/logic/lib/group";
|
||||
import urbitOb from 'urbit-ob';
|
||||
import { getItemTitle } from '~/logic/lib/util';
|
||||
|
||||
const TruncatedBox = styled(Box)`
|
||||
white-space: pre;
|
||||
@ -22,33 +25,49 @@ const TruncatedBox = styled(Box)`
|
||||
`;
|
||||
|
||||
type ResourceSkeletonProps = {
|
||||
groups: Groups;
|
||||
contacts: any;
|
||||
association: Association;
|
||||
notificationsGraphConfig: NotificationGraphConfig;
|
||||
api: GlobalApi;
|
||||
baseUrl: string;
|
||||
children: ReactNode;
|
||||
atRoot?: boolean;
|
||||
title?: string;
|
||||
groupTags?: any;
|
||||
};
|
||||
|
||||
export function ResourceSkeleton(props: ResourceSkeletonProps) {
|
||||
const { association, api, baseUrl, children, atRoot, groupTags } = props;
|
||||
const { association, api, baseUrl, children, atRoot, groups } = props;
|
||||
const app = association?.metadata?.module || association["app-name"];
|
||||
const rid = association.resource;
|
||||
const workspace =
|
||||
baseUrl === "/~landscape/home" ? "/home" : association.group;
|
||||
const title = props.title || association?.metadata?.title;
|
||||
const group = groups[association.group];
|
||||
let workspace = association.group;
|
||||
|
||||
if (group?.hidden && app === "chat") {
|
||||
workspace = "/messages";
|
||||
} else if (group?.hidden) {
|
||||
workspace = "/home";
|
||||
}
|
||||
|
||||
let title = (workspace === "/messages")
|
||||
? getItemTitle(association)
|
||||
: association?.metadata?.title;
|
||||
|
||||
let recipient = false;
|
||||
|
||||
if (urbitOb.isValidPatp(title)) {
|
||||
recipient = title;
|
||||
title = (props.contacts?.[title]?.nickname) ? props.contacts[title].nickname : title;
|
||||
}
|
||||
|
||||
const [, , ship, resource] = rid.split("/");
|
||||
|
||||
const resourcePath = (p: string) => baseUrl + `/resource/${app}/ship/${ship}/${resource}` + p;
|
||||
const resourcePath = (p: string) => baseUrl + p;
|
||||
|
||||
const isOwn = `~${window.ship}` === ship;
|
||||
let isWriter = (app === 'publish') ? true : false;
|
||||
let canWrite = (app === 'publish') ? true : false;
|
||||
|
||||
if (groupTags?.publish?.[`writers-${resource}`]) {
|
||||
isWriter = isOwn || groupTags?.publish?.[`writers-${resource}`]?.has(window.ship);
|
||||
if (!isWriter(group, association.resource)) {
|
||||
canWrite = isOwn;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -63,64 +82,59 @@ export function ResourceSkeleton(props: ResourceSkeletonProps) {
|
||||
borderBottom={1}
|
||||
borderBottomColor="washedGray"
|
||||
>
|
||||
{atRoot ? (
|
||||
<Box
|
||||
borderRight={1}
|
||||
borderRightColor="gray"
|
||||
pr={3}
|
||||
fontSize='1'
|
||||
mr={3}
|
||||
my="1"
|
||||
display={["block", "none"]}
|
||||
flexShrink={0}
|
||||
<Box
|
||||
borderRight={1}
|
||||
borderRightColor="gray"
|
||||
pr={3}
|
||||
fontSize='1'
|
||||
mr={3}
|
||||
my="1"
|
||||
display={["block", "none"]}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Link to={`/~landscape${workspace}`}> {"<- Back"}</Link>
|
||||
</Box>
|
||||
<Box px={1} mr={2} minWidth={0} display="flex">
|
||||
<Text
|
||||
mono={urbitOb.isValidPatp(title)}
|
||||
fontSize='2'
|
||||
fontWeight='700'
|
||||
display="inline-block"
|
||||
verticalAlign="middle"
|
||||
textOverflow="ellipsis"
|
||||
overflow="hidden"
|
||||
whiteSpace="pre"
|
||||
minWidth={0}>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<TruncatedBox
|
||||
display={["none", "block"]}
|
||||
verticalAlign="middle"
|
||||
maxWidth='60%'
|
||||
flexShrink={1}
|
||||
title={association?.metadata?.description}
|
||||
color="gray"
|
||||
>
|
||||
<RichText
|
||||
display={(workspace === '/messages' && (urbitOb.isValidPatp(title))) ? "none" : "inline-block"}
|
||||
mono={(workspace === '/messages' && !(urbitOb.isValidPatp(title)))}
|
||||
color="gray"
|
||||
mb="0"
|
||||
disableRemoteContent
|
||||
>
|
||||
<Link to={`/~landscape${workspace}`}> {"<- Back"}</Link>
|
||||
</Box>
|
||||
) : (
|
||||
<Box color="blue" pr={2} mr={2}>
|
||||
<Link to={`/~landscape${workspace}/resource/${app}${rid}`}>
|
||||
<Text color="blue">Go back to channel</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{atRoot && (
|
||||
<>
|
||||
<Box px={1} mr={2} minWidth={0} display="flex">
|
||||
<Text fontSize='2' fontWeight='700' display="inline-block" verticalAlign="middle" textOverflow="ellipsis" overflow="hidden" whiteSpace="pre" minWidth={0}>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<TruncatedBox
|
||||
display={["none", "block"]}
|
||||
verticalAlign="middle"
|
||||
maxWidth='60%'
|
||||
flexShrink={1}
|
||||
title={association?.metadata?.description}
|
||||
color="gray"
|
||||
>
|
||||
<RichText
|
||||
color="gray"
|
||||
mb="0"
|
||||
display="inline-block"
|
||||
disableRemoteContent
|
||||
>
|
||||
{association?.metadata?.description}
|
||||
</RichText>
|
||||
</TruncatedBox>
|
||||
<Box flexGrow={1} />
|
||||
{isWriter && (
|
||||
<Link to={resourcePath('/new')} style={{ flexShrink: '0' }}>
|
||||
<Text bold pr='3' color='blue'>+ New Post</Text>
|
||||
</Link>
|
||||
)}
|
||||
<ChannelMenu
|
||||
graphNotificationConfig={props.notificationsGraphConfig}
|
||||
association={association}
|
||||
api={api}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{(workspace === "/messages") ? recipient : association?.metadata?.description}
|
||||
</RichText>
|
||||
</TruncatedBox>
|
||||
<Box flexGrow={1} />
|
||||
{canWrite && (
|
||||
<Link to={resourcePath('/new')} style={{ flexShrink: '0' }}>
|
||||
<Text bold pr='3' color='blue'>+ New Post</Text>
|
||||
</Link>
|
||||
)}
|
||||
<Link to={`${baseUrl}/settings`}>
|
||||
<Icon icon="Menu" color="gray" pr="2" />
|
||||
</Link>
|
||||
</Box>
|
||||
{children}
|
||||
</Col>
|
||||
|
@ -1,42 +1,7 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { Inbox, ChatHookUpdate, Notebooks, Graphs, UnreadStats } from "~/types";
|
||||
import { Graphs, UnreadStats } from "~/types";
|
||||
import { SidebarItemStatus, SidebarAppConfig } from "./types";
|
||||
|
||||
export function useChat(
|
||||
inbox: Inbox,
|
||||
chatSynced: ChatHookUpdate | null
|
||||
): SidebarAppConfig {
|
||||
const getStatus = useCallback(
|
||||
(s: string): SidebarItemStatus | undefined => {
|
||||
if (!(s in (chatSynced || {}))) {
|
||||
return "unsubscribed";
|
||||
}
|
||||
const mailbox = inbox?.[s];
|
||||
if (!mailbox) {
|
||||
return undefined;
|
||||
}
|
||||
const { config } = mailbox;
|
||||
if (config?.read !== config?.length) {
|
||||
return "unread";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[inbox, chatSynced]
|
||||
);
|
||||
|
||||
const lastUpdated = useCallback(
|
||||
(s: string) => {
|
||||
const mailbox = inbox?.[s];
|
||||
if (!mailbox) {
|
||||
return 0;
|
||||
}
|
||||
return mailbox?.envelopes?.[0]?.when || 0;
|
||||
},
|
||||
[inbox]
|
||||
);
|
||||
|
||||
return { lastUpdated, getStatus };
|
||||
}
|
||||
|
||||
|
||||
export function useGraphModule(
|
||||
|
@ -85,6 +85,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
workspace={props.workspace}
|
||||
/>
|
||||
<SidebarListHeader
|
||||
associations={associations}
|
||||
contacts={props.contacts}
|
||||
baseUrl={props.baseUrl}
|
||||
groups={props.groups}
|
||||
@ -92,6 +93,8 @@ export function Sidebar(props: SidebarProps) {
|
||||
handleSubmit={setConfig}
|
||||
selected={selected || ''}
|
||||
workspace={workspace}
|
||||
api={props.api}
|
||||
history={props.history}
|
||||
/>
|
||||
<SidebarList
|
||||
config={config}
|
||||
@ -101,6 +104,8 @@ export function Sidebar(props: SidebarProps) {
|
||||
groups={props.groups}
|
||||
apps={props.apps}
|
||||
baseUrl={props.baseUrl}
|
||||
workspace={workspace}
|
||||
contacts={props.contacts}
|
||||
/>
|
||||
</ScrollbarLessCol>
|
||||
);
|
||||
|
@ -1,13 +1,14 @@
|
||||
import React from "react";
|
||||
import _ from 'lodash';
|
||||
|
||||
import { Icon, Row, Box, Text } from "@tlon/indigo-react";
|
||||
import { Icon, Row, Box, Text, BaseImage } from "@tlon/indigo-react";
|
||||
|
||||
import { SidebarAppConfigs, SidebarItemStatus } from "./Sidebar";
|
||||
import { HoverBoxLink } from "~/views/components/HoverBox";
|
||||
import { Groups, Association } from "~/types";
|
||||
|
||||
import { cite, getModuleIcon } from "~/logic/lib/util";
|
||||
import { Sigil } from '~/logic/lib/sigil';
|
||||
import urbitOb from 'urbit-ob';
|
||||
import { getModuleIcon, getItemTitle, uxToHex } from "~/logic/lib/util";
|
||||
|
||||
function SidebarItemIndicator(props: { status?: SidebarItemStatus }) {
|
||||
switch (props.status) {
|
||||
@ -24,31 +25,18 @@ function SidebarItemIndicator(props: { status?: SidebarItemStatus }) {
|
||||
}
|
||||
}
|
||||
|
||||
;
|
||||
|
||||
const DM_REGEX = /ship\/~([a-z]|-)*\/dm--/;
|
||||
function getItemTitle(association: Association) {
|
||||
if(DM_REGEX.test(association.resource)) {
|
||||
const [,,ship,name] = association.resource.split('/');
|
||||
if(ship.slice(1) === window.ship) {
|
||||
return cite(`~${name.slice(4)}`);
|
||||
}
|
||||
return cite(ship);
|
||||
|
||||
}
|
||||
return association.metadata.title || association.resource
|
||||
}
|
||||
|
||||
export function SidebarItem(props: {
|
||||
hideUnjoined: boolean;
|
||||
association: Association;
|
||||
contacts: any;
|
||||
groups: Groups;
|
||||
path: string;
|
||||
selected: boolean;
|
||||
apps: SidebarAppConfigs;
|
||||
workspace: Workspace;
|
||||
}) {
|
||||
const { association, path, selected, apps, groups } = props;
|
||||
const title = getItemTitle(association);
|
||||
let title = getItemTitle(association);
|
||||
const appName = association?.["app-name"];
|
||||
const mod = association?.metadata?.module || appName;
|
||||
const rid = association?.resource
|
||||
@ -58,12 +46,19 @@ export function SidebarItem(props: {
|
||||
if (!app) {
|
||||
return null;
|
||||
}
|
||||
const DM = (isUnmanaged && props.workspace?.type === "messages");
|
||||
const itemStatus = app.getStatus(path);
|
||||
const hasUnread = itemStatus === "unread" || itemStatus === "mention";
|
||||
|
||||
const isSynced = itemStatus !== "unsubscribed";
|
||||
|
||||
const baseUrl = isUnmanaged ? `/~landscape/home` : `/~landscape${groupPath}`;
|
||||
let baseUrl = `/~landscape${groupPath}`;
|
||||
|
||||
if (DM) {
|
||||
baseUrl = '/~landscape/messages';
|
||||
} else if (isUnmanaged) {
|
||||
baseUrl = '/~landscape/home';
|
||||
}
|
||||
|
||||
const to = isSynced
|
||||
? `${baseUrl}/resource/${mod}${rid}`
|
||||
@ -75,6 +70,21 @@ export function SidebarItem(props: {
|
||||
return null;
|
||||
}
|
||||
|
||||
let img = null;
|
||||
|
||||
if (urbitOb.isValidPatp(title)) {
|
||||
if (props.contacts?.[title] && props.contacts[title].avatar) {
|
||||
img = <BaseImage src={props.contacts[title].avatar} width='16px' height='16px' borderRadius={2}/>;
|
||||
} else {
|
||||
img = <Sigil ship={title} color={`#${uxToHex(props.contacts?.[title]?.color || '0x0')}`} icon padded size={16}/>
|
||||
}
|
||||
if (props.contacts?.[title] && props.contacts[title].nickname) {
|
||||
title = props.contacts[title].nickname;
|
||||
}
|
||||
} else {
|
||||
img = <Box flexShrink={0} height={16} width={16} borderRadius={2} backgroundColor={`#${uxToHex(props?.association?.metadata?.color)}` || "#000000"}/>
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverBoxLink
|
||||
to={to}
|
||||
@ -90,11 +100,14 @@ export function SidebarItem(props: {
|
||||
selected={selected}
|
||||
>
|
||||
<Row width='100%' alignItems="center" flex='1 auto' minWidth='0'>
|
||||
<Icon
|
||||
display="block"
|
||||
color={color}
|
||||
icon={getModuleIcon(mod) as any}
|
||||
/>
|
||||
{DM ? img : (
|
||||
<Icon
|
||||
display="block"
|
||||
color={color}
|
||||
icon={getModuleIcon(mod) as any}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<Box width='100%' flexShrink={2} ml={2} display='flex' overflow='hidden'>
|
||||
<Text
|
||||
lineHeight="tall"
|
||||
@ -102,6 +115,7 @@ export function SidebarItem(props: {
|
||||
flex='1'
|
||||
overflow='hidden'
|
||||
width='100%'
|
||||
mono={urbitOb.isValidPatp(title)}
|
||||
fontWeight={hasUnread ? "bold" : "regular"}
|
||||
color={selected || isSynced ? "black" : "lightGray"}
|
||||
style={{ textOverflow: 'ellipsis', whiteSpace: 'pre'}}
|
||||
|
@ -37,27 +37,28 @@ function sidebarSort(
|
||||
|
||||
export function SidebarList(props: {
|
||||
apps: SidebarAppConfigs;
|
||||
contacts: any;
|
||||
config: SidebarListConfig;
|
||||
associations: Associations;
|
||||
groups: Groups;
|
||||
baseUrl: string;
|
||||
group?: string;
|
||||
selected?: string;
|
||||
workspace: Workspace;
|
||||
}) {
|
||||
const { selected, group, config } = props;
|
||||
const associations = {
|
||||
...props.associations.chat,
|
||||
...props.associations.publish,
|
||||
...props.associations.link,
|
||||
...props.associations.graph,
|
||||
};
|
||||
const { selected, group, config, workspace } = props;
|
||||
const associations = { ...props.associations.graph };
|
||||
|
||||
const ordered = Object.keys(associations)
|
||||
.filter((a) => {
|
||||
const assoc = associations[a];
|
||||
return group
|
||||
? assoc.group === group
|
||||
: !(assoc.group in props.associations.groups);
|
||||
if (workspace?.type === 'messages') {
|
||||
return (!(assoc.group in props.associations.groups) && assoc.metadata.module === "chat");
|
||||
} else {
|
||||
return group
|
||||
? assoc.group === group
|
||||
: (!(assoc.group in props.associations.groups) && assoc.metadata.module !== "chat");
|
||||
}
|
||||
})
|
||||
.sort(sidebarSort(associations, props.apps)[config.sortBy]);
|
||||
|
||||
@ -74,6 +75,8 @@ export function SidebarList(props: {
|
||||
apps={props.apps}
|
||||
hideUnjoined={config.hideUnjoined}
|
||||
groups={props.groups}
|
||||
contacts={props.contacts}
|
||||
workspace={workspace}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
@ -16,10 +16,14 @@ import { SidebarListConfig, Workspace } from "./types";
|
||||
import { Link, useHistory } from 'react-router-dom';
|
||||
import { getGroupFromWorkspace } from "~/logic/lib/workspace";
|
||||
import { roleForShip } from "~/logic/lib/group";
|
||||
import {Groups, Rolodex} from "~/types";
|
||||
import {Groups, Rolodex, Associations} from "~/types";
|
||||
import { NewChannel } from "~/views/landscape/components/NewChannel";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
|
||||
export function SidebarListHeader(props: {
|
||||
api: GlobalApi;
|
||||
initialValues: SidebarListConfig;
|
||||
associations: Associations;
|
||||
groups: Groups;
|
||||
contacts: Rolodex;
|
||||
baseUrl: string;
|
||||
@ -39,7 +43,12 @@ export function SidebarListHeader(props: {
|
||||
|
||||
const groupPath = getGroupFromWorkspace(props.workspace);
|
||||
const role = props.groups?.[groupPath] ? roleForShip(props.groups[groupPath], window.ship) : undefined;
|
||||
const isAdmin = (role === "admin") || (props.workspace?.type === 'home');
|
||||
const memberMetadata =
|
||||
groupPath ? props.associations.contacts?.[groupPath].metadata.vip === 'member-metadata' : false;
|
||||
|
||||
const isAdmin = memberMetadata || (role === "admin") || (props.workspace?.type === 'home') || (props.workspace?.type === "messages");
|
||||
|
||||
const noun = (props.workspace?.type === "messages") ? "Messages" : "Channels";
|
||||
|
||||
return (
|
||||
<Row
|
||||
@ -52,7 +61,7 @@ export function SidebarListHeader(props: {
|
||||
>
|
||||
<Box flexShrink='0'>
|
||||
<Text>
|
||||
{props.initialValues.hideUnjoined ? "Joined Channels" : "All Channels"}
|
||||
{props.initialValues.hideUnjoined ? `Joined ${noun}` : `All ${noun}`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
@ -60,26 +69,44 @@ export function SidebarListHeader(props: {
|
||||
display='flex'
|
||||
alignItems='center'
|
||||
>
|
||||
<Link
|
||||
style={{
|
||||
{props.workspace?.type === "messages"
|
||||
? (
|
||||
<Dropdown
|
||||
flexShrink={0}
|
||||
dropWidth="300px"
|
||||
width="auto"
|
||||
alignY="top"
|
||||
alignX={["right", "left"]}
|
||||
options={
|
||||
<Col
|
||||
background="white"
|
||||
border={1}
|
||||
borderColor="washedGray"
|
||||
>
|
||||
<NewChannel
|
||||
api={props.api}
|
||||
history={props.history}
|
||||
associations={props.associations}
|
||||
contacts={props.contacts}
|
||||
groups={props.groups}
|
||||
workspace={props.workspace}
|
||||
/>
|
||||
</Col>
|
||||
}
|
||||
>
|
||||
<Icon icon="Plus" color="gray" pr='12px'/>
|
||||
</Dropdown>
|
||||
)
|
||||
: (
|
||||
<Link style={{
|
||||
display: isAdmin ? "inline-block" : "none" }}
|
||||
to={
|
||||
!!groupPath ? `/~landscape${groupPath}/new` : `/~landscape/home/new`}>
|
||||
to={!!groupPath
|
||||
? `/~landscape${groupPath}/new`
|
||||
: `/~landscape/${props.workspace?.type}/new`}>
|
||||
<Icon icon="Plus" color="gray" pr='12px'/>
|
||||
</Link>
|
||||
<Link to={`${props.baseUrl}/invites`}
|
||||
style={{ display: (props.workspace?.type === 'home') ? 'inline-block' : 'none'}}>
|
||||
<Text
|
||||
display='inline-block'
|
||||
py='1px'
|
||||
px='3px'
|
||||
mr='12px'
|
||||
backgroundColor='washedBlue'
|
||||
color='blue'
|
||||
borderRadius='1'>
|
||||
+ DM
|
||||
</Text>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
<Dropdown
|
||||
flexShrink='0'
|
||||
width="auto"
|
||||
|
44
pkg/interface/src/views/landscape/components/SidebarItem.tsx
Normal file
44
pkg/interface/src/views/landscape/components/SidebarItem.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { Row, Icon, Text } from "@tlon/indigo-react";
|
||||
|
||||
import { IconRef, PropFunc } from "~/types/util";
|
||||
import { HoverBoxLink } from "~/views/components/HoverBox";
|
||||
|
||||
interface SidebarItemProps {
|
||||
selected?: boolean;
|
||||
icon: IconRef;
|
||||
text: string;
|
||||
to: string;
|
||||
color?: string;
|
||||
children?: JSX.Element;
|
||||
}
|
||||
|
||||
export const SidebarItem = ({
|
||||
icon,
|
||||
text,
|
||||
to,
|
||||
selected = false,
|
||||
color = "black",
|
||||
children,
|
||||
...rest
|
||||
}: SidebarItemProps & PropFunc<typeof HoverBoxLink>) => {
|
||||
return (
|
||||
<HoverBoxLink
|
||||
to={to}
|
||||
selected={selected}
|
||||
bg="white"
|
||||
bgActive="washedGray"
|
||||
display="flex"
|
||||
px="3"
|
||||
py="1"
|
||||
justifyContent="space-between"
|
||||
{...rest}
|
||||
>
|
||||
<Row>
|
||||
<Icon color={color} icon={icon as any} mr="2" />
|
||||
<Text color={color}>{text}</Text>
|
||||
</Row>
|
||||
{children}
|
||||
</HoverBoxLink>
|
||||
);
|
||||
};
|
@ -1,20 +1,17 @@
|
||||
import React, { ReactNode, useEffect, useMemo } from "react";
|
||||
import { Box, Text } from "@tlon/indigo-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import React, { ReactNode, useEffect, useMemo } from 'react';
|
||||
import { Box, Text } from '@tlon/indigo-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Sidebar } from "./Sidebar/Sidebar";
|
||||
import { ChatHookUpdate } from "~/types/chat-hook-update";
|
||||
import { Inbox } from "~/types/chat-update";
|
||||
import { Associations } from "~/types/metadata-update";
|
||||
import { Notebooks } from "~/types/publish-update";
|
||||
import GlobalApi from "~/logic/api/global";
|
||||
import { Path, AppName } from "~/types/noun";
|
||||
import { LinkCollections } from "~/types/link-update";
|
||||
import styled from "styled-components";
|
||||
import GlobalSubscription from "~/logic/subscription/global";
|
||||
import { Workspace, Groups, Graphs, Invites, Rolodex } from "~/types";
|
||||
import { useChat, useGraphModule } from "./Sidebar/Apps";
|
||||
import { Body } from "~/views/components/Body";
|
||||
import { Sidebar } from './Sidebar/Sidebar';
|
||||
import { Associations } from '~/types/metadata-update';
|
||||
import { Notebooks } from '~/types/publish-update';
|
||||
import GlobalApi from '~/logic/api/global';
|
||||
import { Path, AppName } from '~/types/noun';
|
||||
import { LinkCollections } from '~/types/link-update';
|
||||
import GlobalSubscription from '~/logic/subscription/global';
|
||||
import { Workspace, Groups, Graphs, Invites, Rolodex } from '~/types';
|
||||
import { useGraphModule } from './Sidebar/Apps';
|
||||
import { Body } from '~/views/components/Body';
|
||||
|
||||
interface SkeletonProps {
|
||||
contacts: Rolodex;
|
||||
@ -22,14 +19,12 @@ interface SkeletonProps {
|
||||
recentGroups: string[];
|
||||
groups: Groups;
|
||||
associations: Associations;
|
||||
chatSynced: ChatHookUpdate | null;
|
||||
graphKeys: Set<string>;
|
||||
graphs: Graphs;
|
||||
linkListening: Set<Path>;
|
||||
links: LinkCollections;
|
||||
notebooks: Notebooks;
|
||||
invites: Invites;
|
||||
inbox: Inbox;
|
||||
selected?: string;
|
||||
selectedApp?: AppName;
|
||||
baseUrl: string;
|
||||
@ -38,41 +33,38 @@ interface SkeletonProps {
|
||||
subscription: GlobalSubscription;
|
||||
includeUnmanaged: boolean;
|
||||
workspace: Workspace;
|
||||
hideSidebar?: boolean;
|
||||
unreads: any;
|
||||
}
|
||||
|
||||
export function Skeleton(props: SkeletonProps) {
|
||||
const chatConfig = useChat(props.inbox, props.chatSynced);
|
||||
const graphConfig = useGraphModule(props.graphKeys, props.graphs, props.unreads.graph);
|
||||
const config = useMemo(
|
||||
() => ({
|
||||
graph: graphConfig,
|
||||
chat: chatConfig,
|
||||
graph: graphConfig
|
||||
}),
|
||||
[graphConfig, chatConfig]
|
||||
[graphConfig]
|
||||
);
|
||||
|
||||
return (
|
||||
<Body
|
||||
display="grid"
|
||||
gridTemplateColumns={["100%", "250px 1fr"]}
|
||||
gridTemplateColumns={['100%', '250px 1fr']}
|
||||
gridTemplateRows="100%"
|
||||
>
|
||||
{!props.hideSidebar && (
|
||||
<Sidebar
|
||||
contacts={props.contacts}
|
||||
api={props.api}
|
||||
recentGroups={props.recentGroups}
|
||||
selected={props.selected}
|
||||
associations={props.associations}
|
||||
invites={props.invites}
|
||||
apps={config}
|
||||
baseUrl={props.baseUrl}
|
||||
groups={props.groups}
|
||||
mobileHide={props.mobileHide}
|
||||
workspace={props.workspace}
|
||||
></Sidebar>
|
||||
)}
|
||||
<Sidebar
|
||||
contacts={props.contacts}
|
||||
api={props.api}
|
||||
recentGroups={props.recentGroups}
|
||||
selected={props.selected}
|
||||
associations={props.associations}
|
||||
invites={props.invites}
|
||||
apps={config}
|
||||
baseUrl={props.baseUrl}
|
||||
groups={props.groups}
|
||||
mobileHide={props.mobileHide}
|
||||
workspace={props.workspace}
|
||||
history={props.history}
|
||||
/>
|
||||
{props.children}
|
||||
</Body>
|
||||
);
|
||||
|
@ -27,7 +27,7 @@ type LandscapeProps = StoreState & {
|
||||
export function DMRedirect(props: LandscapeProps & RouteComponentProps & { ship: string; }) {
|
||||
const { ship, api, history, graphKeys } = props;
|
||||
const goToGraph = useCallback((graph: string) => {
|
||||
history.push(`/~landscape/home/resource/chat/ship/~${graph}`);
|
||||
history.push(`/~landscape/messages/resource/chat/ship/~${graph}`);
|
||||
}, [history]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -74,7 +74,6 @@ export default class Landscape extends Component<LandscapeProps, {}> {
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
const { api } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -104,6 +103,14 @@ export default class Landscape extends Component<LandscapeProps, {}> {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route path="/~landscape/messages"
|
||||
render={routeProps => {
|
||||
const ws: Workspace = { type: 'messages' };
|
||||
return (
|
||||
<GroupsPane workspace={ws} baseUrl="/~landscape/messages" {...props} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route path="/~landscape/new"
|
||||
render={routeProps=> {
|
||||
return (
|
||||
|
Loading…
Reference in New Issue
Block a user