Merge branch 'release/next-userspace' into la/push-hook-list-resource

This commit is contained in:
Logan Allen 2021-02-03 11:52:38 -06:00
commit 3a7c201e80
88 changed files with 2152 additions and 1288 deletions

View File

@ -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]
--

View File

@ -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)]

View File

@ -38,7 +38,9 @@
%_ this
invites.state
%- ~(gas by *invites:store)
[%graph *invitatory:store]~
:~ [%graph *invitatory:store]
[%groups *invitatory:store]
==
==
::
++ on-save !>(state)

View File

@ -39,6 +39,8 @@
++ on-save !>(state)
++ on-load
|= =vase
?: =(1 1)
`this
=+ !<(old=versioned-state vase)
|^
?: ?=(%2 -.old)

View File

@ -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

View File

@ -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])
--

View File

@ -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
==

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1,12 @@
/- *graph-store
|_ per=permissions
++ grad %noun
++ grow
|%
++ noun per
--
++ grab
|%
++ noun permissions
--
--

View File

@ -0,0 +1,12 @@
/- *graph-store
|_ per=permissions
++ grad %noun
++ grow
|%
++ noun per
--
++ grab
|%
++ noun permissions
--
--

View File

@ -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]
==

View File

@ -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]

View File

@ -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=*

View File

@ -12,5 +12,4 @@
++ noun update:store
++ json action:dejs:store
--
::
--

View File

@ -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]

View File

@ -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

View File

@ -9,6 +9,8 @@
:: client side
[%join =resource =ship]
[%leave =resource]
::
[%invite =resource ships=(set ship) description=@t]
==
::

View File

@ -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

View File

@ -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

View 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 !>(~))

View File

@ -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);
}

View File

@ -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;

View File

@ -23,9 +23,9 @@ export default class MetadataApi extends BaseApi<StoreState> {
'date-created': dateCreated,
creator,
'module': moduleName,
preview: false,
picture: '',
permissions: ''
preview: false,
vip: ''
}
}
});

View File

@ -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;
}

View File

@ -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}`,

View File

@ -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]
);

View File

@ -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]);
}

View File

@ -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
};

View File

@ -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 || "";

View File

@ -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) {

View File

@ -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;
}

View File

@ -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,

View File

@ -8,6 +8,7 @@ interface RoleTag {
interface AppTag {
app: string;
resource: string;
tag: string;
}

View File

@ -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>;
}

View File

@ -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';

View File

@ -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'];

View File

@ -9,4 +9,8 @@ interface HomeWorkspace {
type: 'home'
}
export type Workspace = HomeWorkspace | GroupWorkspace;
interface Messages {
type: 'messages'
}
export type Workspace = HomeWorkspace | GroupWorkspace | Messages;

View File

@ -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>
);
}

View File

@ -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"

View File

@ -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>
);
};

View File

@ -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;
}
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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}>

View File

@ -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) {

View File

@ -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;

View File

@ -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 () => {

View File

@ -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`}>

View File

@ -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}

View File

@ -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>
);
}

View File

@ -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);

View File

@ -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]);

View File

@ -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}

View File

@ -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]);

View 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>
);
}

View File

@ -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>

View 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>
);
}

View File

@ -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"

View 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>
);
}
);

View File

@ -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>
);
}}
/>
);
}

View File

@ -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"

View File

@ -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 {

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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

View File

@ -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}

View File

@ -177,7 +177,6 @@ export function GroupsPane(props: GroupsPaneProps) {
{...routeProps}
api={api}
baseUrl={baseUrl}
chatSynced={props.chatSynced}
associations={associations}
groups={groups}
group={groupPath}

View File

@ -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"

View File

@ -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];

View File

@ -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>
);

View File

@ -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>
);
}}
/>

View File

@ -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>
</>
);

View File

@ -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>

View File

@ -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(

View File

@ -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>
);

View File

@ -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'}}

View File

@ -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}
/>
);
})}

View File

@ -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"

View 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>
);
};

View File

@ -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>
);

View File

@ -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 (